From c09b784f5193506204baf0001241a5d8e43ae0e4 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 16 Jul 2024 09:10:48 -0400 Subject: [PATCH 001/191] spec dependencies python --- .github/workflows/testing.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 5a542e87..a2a28f01 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - python-version: ["3.9", "3.10"] + python-version: ["3.10", "3.11", "3.12"] os: [ubuntu-latest, windows-latest, macOS-latest] steps: - uses: actions/checkout@master From d160b69590e4548c63bdde5d10a772fa882fdbe0 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 16 Jul 2024 10:10:56 -0400 Subject: [PATCH 002/191] Add plotting unit tests --- astrophot/fit/oldlm.py | 712 ----------------------------------------- tests/test_plots.py | 70 ++++ 2 files changed, 70 insertions(+), 712 deletions(-) delete mode 100644 astrophot/fit/oldlm.py diff --git a/astrophot/fit/oldlm.py b/astrophot/fit/oldlm.py deleted file mode 100644 index 8df1e884..00000000 --- a/astrophot/fit/oldlm.py +++ /dev/null @@ -1,712 +0,0 @@ -# Levenberg-Marquardt algorithm -import os -from time import time -from typing import List, Callable, Optional, Sequence, Any - -import torch -from torch.autograd.functional import jacobian -import numpy as np - -from .base import BaseOptimizer -from .. import AP_config - -__all__ = ["oldLM", "LM_Constraint"] - - -@torch.no_grad() -@torch.jit.script -def Broyden_step(J, h, Yp, Yph): - delta = torch.matmul(J, h) - # avoid constructing a second giant jacobian matrix, instead go one row at a time - for j in range(J.shape[1]): - J[:, j] += (Yph - Yp - delta) * h[j] / torch.linalg.norm(h) - return J - - -class oldLM(BaseOptimizer): - """based heavily on: - @article{gavin2019levenberg, - title={The Levenberg-Marquardt algorithm for nonlinear least squares curve-fitting problems}, - author={Gavin, Henri P}, - journal={Department of Civil and Environmental Engineering, Duke University}, - volume={19}, - year={2019} - } - - The Levenberg-Marquardt algorithm bridges the gap between a - gradient descent optimizer and a Newton's Method optimizer. The - Hessian for the Newton's Method update is too complex to evaluate - with automatic differentiation (memory scales roughly as - parameters^2 * pixels^2) and so an approximation is made using the - Jacobian of the image pixels wrt to the parameters of the - model. Automatic differentiation provides an exact Jacobian as - opposed to a finite differences approximation. - - Once a Hessian H and gradient G have been determined, the update - step is defined as h which is the solution to the linear equation: - - (H + L*I)h = G - - where L is the Levenberg-Marquardt damping parameter and I is the - identity matrix. For small L this is just the Newton's method, for - large L this is just a small gradient descent step (approximately - h = grad/L). The method implemented is modified from Gavin 2019. - - Args: - model (AstroPhot_Model): object with which to perform optimization - initial_state (Optional[Sequence]): an initial state for optimization - epsilon4 (Optional[float]): approximation accuracy requirement, for any rho < epsilon4 the step will be rejected. Default 0.1 - epsilon5 (Optional[float]): numerical stability factor, added to the diagonal of the Hessian. Default 1e-8 - constraints (Optional[Union[LM_Constraint,tuple[LM_Constraint]]]): Constraint objects which control the fitting process. - L0 (Optional[float]): initial value for L factor in (H +L*I)h = G. Default 1. - Lup (Optional[float]): amount to increase L when rejecting an update step. Default 11. - Ldn (Optional[float]): amount to decrease L when accetping an update step. Default 9. - - """ - - def __init__( - self, - model: "AstroPhot_Model", - initial_state: Sequence = None, - max_iter: int = 100, - fit_parameters_identity: Optional[tuple] = None, - **kwargs, - ): - super().__init__( - model, - initial_state, - max_iter=max_iter, - fit_parameters_identity=fit_parameters_identity, - **kwargs, - ) - - # Set optimizer parameters - self.epsilon4 = kwargs.get("epsilon4", 0.1) - self.epsilon5 = kwargs.get("epsilon5", 1e-8) - self.Lup = kwargs.get("Lup", 11.0) - self.Ldn = kwargs.get("Ldn", 9.0) - self.L = kwargs.get("L0", 1e-3) - self.use_broyden = kwargs.get("use_broyden", False) - - # Initialize optimizer attributes - self.Y = self.model.target[self.fit_window].flatten("data") - # 1 / sigma^2 - self.W = ( - 1.0 / self.model.target[self.fit_window].flatten("variance") - if model.target.has_variance - else 1.0 - ) - # # pixels # parameters - self.ndf = len(self.Y) - len(self.current_state) - self.J = None - self.full_jac = False - self.current_Y = None - self.prev_Y = [None, None] - if self.model.target.has_mask: - self.mask = self.model.target[self.fit_window].flatten("mask") - # subtract masked pixels from degrees of freedom - self.ndf -= torch.sum(self.mask) - self.L_history = [] - self.decision_history = [] - self.rho_history = [] - self._count_converged = 0 - self.ndf = kwargs.get("ndf", self.ndf) - self._covariance_matrix = None - - # update attributes with constraints - self.constraints = kwargs.get("constraints", None) - if self.constraints is not None and isinstance(self.constraints, LM_Constraint): - self.constraints = (self.constraints,) - - if self.constraints is not None: - for con in self.constraints: - self.Y = torch.cat((self.Y, con.reference_value)) - self.W = torch.cat((self.W, 1 / con.weight)) - self.ndf -= con.reduce_ndf - if self.model.target.has_mask: - self.mask = torch.cat( - ( - self.mask, - torch.zeros_like(con.reference_value, dtype=torch.bool), - ) - ) - - def L_up(self, Lup=None): - if Lup is None: - Lup = self.Lup - self.L = min(1e9, self.L * Lup) - - def L_dn(self, Ldn=None): - if Ldn is None: - Ldn = self.Ldn - self.L = max(1e-9, self.L / Ldn) - - def step(self, current_state=None) -> None: - """ - Levenberg-Marquardt update step - """ - if current_state is not None: - self.current_state = current_state - - if self.iteration > 0: - if self.verbose > 0: - AP_config.ap_logger.info("---------iter---------") - else: - if self.verbose > 0: - AP_config.ap_logger.info("---------init---------") - - h = self.update_h() - if self.verbose > 1: - AP_config.ap_logger.info(f"h: {h.detach().cpu().numpy()}") - - self.update_Yp(h) - loss = self.update_chi2() - if self.verbose > 0: - AP_config.ap_logger.info(f"LM loss: {loss.item()}") - - if self.iteration == 0: - self.prev_Y[1] = self.current_Y - self.loss_history.append(loss.detach().cpu().item()) - self.L_history.append(self.L) - self.lambda_history.append(np.copy((self.current_state + h).detach().cpu().numpy())) - - if self.iteration > 0 and not torch.isfinite(loss): - if self.verbose > 0: - AP_config.ap_logger.warning("nan loss") - self.decision_history.append("nan") - self.rho_history.append(None) - self._count_reject += 1 - self.iteration += 1 - self.L_up() - return - elif self.iteration > 0: - lossmin = np.nanmin(self.loss_history[:-1]) - rho = self.rho(lossmin, loss, h) - if self.verbose > 1: - AP_config.ap_logger.debug( - f"LM loss: {loss.item()}, best loss: {np.nanmin(self.loss_history[:-1])}, loss diff: {np.nanmin(self.loss_history[:-1]) - loss.item()}, L: {self.L}" - ) - self.rho_history.append(rho) - if self.verbose > 1: - AP_config.ap_logger.debug(f"rho: {rho.item()}") - - if rho > self.epsilon4: - if self.verbose > 0: - AP_config.ap_logger.info("accept") - self.decision_history.append("accept") - self.prev_Y[0] = self.prev_Y[1] - self.prev_Y[1] = torch.clone(self.current_Y) - self.current_state += h - self.L_dn() - self._count_reject = 0 - if 0 < ((lossmin - loss) / loss) < self.relative_tolerance: - self._count_finish += 1 - else: - self._count_finish = 0 - else: - if self.verbose > 0: - AP_config.ap_logger.info("reject") - self.decision_history.append("reject") - self.L_up() - self._count_reject += 1 - return - else: - self.decision_history.append("init") - self.rho_history.append(None) - - if ( - (not self.use_broyden) - or self.J is None - or self.iteration < 2 - or "reset" in self.decision_history[-2:] - or rho < self.epsilon4 - or self._count_reject > 0 - or self.iteration >= (2 * len(self.current_state)) - or self.decision_history[-1] == "nan" - ): - if self.verbose > 1: - AP_config.ap_logger.debug("full jac") - self.update_J_AD() - else: - if self.verbose > 1: - AP_config.ap_logger.debug("Broyden jac") - self.update_J_Broyden(h, self.prev_Y[0], self.current_Y) - - self.update_hess() - self.update_grad(self.prev_Y[1]) - self.iteration += 1 - - def fit(self): - self.iteration = 0 - self._count_reject = 0 - self._count_finish = 0 - self.grad_only = False - - start_fit = time() - try: - while True: - if self.verbose > 0: - AP_config.ap_logger.info(f"L: {self.L}") - - # take LM step - self.step() - - # Save the state of the model - if self.save_steps is not None and self.decision_history[-1] == "accept": - self.model.save( - os.path.join( - self.save_steps, - f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", - ) - ) - - lam, L, loss = self.progress_history() - - # Check for convergence - if ( - self.decision_history.count("accept") > 2 - and self.decision_history[-1] == "accept" - and L[-1] < 0.1 - and ((loss[-2] - loss[-1]) / loss[-1]) < (self.relative_tolerance / 10) - ): - self._count_converged += 1 - elif self.iteration >= self.max_iter: - self.message = self.message + f"fail max iterations reached: {self.iteration}" - break - elif not torch.all(torch.isfinite(self.current_state)): - self.message = self.message + "fail non-finite step taken" - break - elif ( - self.L >= (1e9 - 1) and self._count_reject >= 8 and not self.take_low_rho_step() - ): - self.message = ( - self.message - + "fail by immobility, unable to find improvement or even small bad step" - ) - break - if self._count_converged >= 3: - self.message = self.message + "success" - break - lam, L, loss = self.accept_history() - if len(loss) >= 10: - loss10 = np.array(loss[-10:]) - if ( - np.all( - np.abs((loss10[0] - loss10[-1]) / loss10[-1]) < self.relative_tolerance - ) - and L[-1] < 0.1 - ): - self.message = self.message + "success" - break - if ( - np.all( - np.abs((loss10[0] - loss10[-1]) / loss10[-1]) < self.relative_tolerance - ) - and L[-1] >= 0.1 - ): - self.message = ( - self.message - + "fail by immobility, possible bad area of parameter space." - ) - break - except KeyboardInterrupt: - self.message = self.message + "fail interrupted" - - if self.message.startswith("fail") and self._count_finish > 0: - self.message = ( - self.message - + ". possibly converged to numerical precision and could not make a better step." - ) - self.model.parameters.set_values( - self.res(), - as_representation=True, - parameters_identity=self.fit_parameters_identity, - ) - if self.verbose > 1: - AP_config.ap_logger.info( - f"LM Fitting complete in {time() - start_fit} sec with message: {self.message}" - ) - - return self - - def update_uncertainty(self): - # set the uncertainty for each parameter - cov = self.covariance_matrix - if torch.all(torch.isfinite(cov)): - try: - self.model.parameters.set_uncertainty( - torch.sqrt(torch.abs(torch.diag(cov))), - as_representation=False, - parameters_identity=self.fit_parameters_identity, - ) - except RuntimeError as e: - AP_config.ap_logger.warning(f"Unable to update uncertainty due to: {e}") - - @torch.no_grad() - def undo_step(self) -> None: - AP_config.ap_logger.info("undoing step, trying to recover") - assert ( - self.decision_history.count("accept") >= 2 - ), "cannot undo with not enough accepted steps, retry with new parameters" - assert len(self.decision_history) == len(self.lambda_history) - assert len(self.decision_history) == len(self.L_history) - found_accept = False - for i in reversed(range(len(self.decision_history))): - if not found_accept and self.decision_history[i] == "accept": - found_accept = True - continue - if self.decision_history[i] != "accept": - continue - self.current_state = torch.tensor( - self.lambda_history[i], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - self.L = self.L_history[i] * self.Lup - - def take_low_rho_step(self) -> bool: - for i in reversed(range(len(self.decision_history))): - if "accept" in self.decision_history[i]: - return False - if self.rho_history[i] is not None and self.rho_history[i] > 0: - if self.verbose > 0: - AP_config.ap_logger.info( - f"taking a low rho step for some progress: {self.rho_history[i]}" - ) - self.current_state = torch.tensor( - self.lambda_history[i], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - self.L = self.L_history[i] - - self.loss_history.append(self.loss_history[i]) - self.L_history.append(self.L) - self.lambda_history.append(np.copy((self.current_state).detach().cpu().numpy())) - self.decision_history.append("low rho accept") - self.rho_history.append(self.rho_history[i]) - - with torch.no_grad(): - self.update_Yp(torch.zeros_like(self.current_state)) - self.prev_Y[0] = self.prev_Y[1] - self.prev_Y[1] = self.current_Y - self.update_J_AD() - self.update_hess() - self.update_grad(self.prev_Y[1]) - self.iteration += 1 - self.count_reject = 0 - return True - - @torch.no_grad() - def update_h(self) -> torch.Tensor: - """Solves the LM update linear equation (H + L*I)h = G to determine - the proposal for how to adjust the parameters to decrease the - chi2. - - """ - h = torch.zeros_like(self.current_state) - if self.iteration == 0: - return h - - h = torch.linalg.solve( - ( - self.hess - + self.L**2 - * torch.eye(len(self.grad), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - ) - * ( - 1 - + self.L**2 - * torch.eye(len(self.grad), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - ) - ** 2 - / (1 + self.L**2), - self.grad, - ) - return h - - @torch.no_grad() - def update_Yp(self, h): - """ - Updates the current model values for each pixel - """ - # Sample model at proposed state - self.current_Y = self.model( - parameters=self.current_state + h, - as_representation=True, - parameters_identity=self.fit_parameters_identity, - window=self.fit_window, - ).flatten("data") - - # Add constraint evaluations - if self.constraints is not None: - for con in self.constraints: - self.current_Y = torch.cat((self.current_Y, con(self.model))) - - @torch.no_grad() - def update_chi2(self): - """ - Updates the chi squared / ndf value - """ - # Apply mask if needed - if self.model.target.has_mask: - loss = ( - torch.sum(((self.Y - self.current_Y) ** 2 * self.W)[torch.logical_not(self.mask)]) - / self.ndf - ) - else: - loss = torch.sum((self.Y - self.current_Y) ** 2 * self.W) / self.ndf - - return loss - - def update_J_AD(self) -> None: - """ - Update the jacobian using automatic differentiation, produces an accurate jacobian at the current state. - """ - # Free up memory - del self.J - if "cpu" not in AP_config.ap_device: - torch.cuda.empty_cache() - - # Compute jacobian on image - self.J = self.model.jacobian( - torch.clone(self.current_state).detach(), - as_representation=True, - parameters_identity=self.fit_parameters_identity, - window=self.fit_window, - ).flatten("data") - - # compute the constraint jacobian if needed - if self.constraints is not None: - for con in self.constraints: - self.J = torch.cat((self.J, con.jacobian(self.model))) - - # Apply mask if needed - if self.model.target.has_mask: - self.J[self.mask] = 0.0 - - # Note that the most recent jacobian was a full autograd jacobian - self.full_jac = True - - def update_J_natural(self) -> None: - """ - Update the jacobian using automatic differentiation, produces an accurate jacobian at the current state. Use this method to get the jacobian in the parameter space instead of representation space. - """ - # Free up memory - del self.J - if "cpu" not in AP_config.ap_device: - torch.cuda.empty_cache() - - # Compute jacobian on image - self.J = self.model.jacobian( - torch.clone( - self.model.parameters.transform( - self.current_state, - to_representation=False, - parameters_identity=self.fit_parameters_identity, - ) - ).detach(), - as_representation=False, - parameters_identity=self.fit_parameters_identity, - window=self.fit_window, - ).flatten("data") - - # compute the constraint jacobian if needed - if self.constraints is not None: - for con in self.constraints: - self.J = torch.cat((self.J, con.jacobian(self.model))) - - # Apply mask if needed - if self.model.target.has_mask: - self.J[self.mask] = 0.0 - - # Note that the most recent jacobian was a full autograd jacobian - self.full_jac = False - - @torch.no_grad() - def update_J_Broyden(self, h, Yp, Yph) -> None: - """ - Use the Broyden update to approximate the new Jacobian tensor at the current state. Less accurate, but far faster. - """ - - # Update the Jacobian - self.J = Broyden_step(self.J, h, Yp, Yph) - - # Apply mask if needed - if self.model.target.has_mask: - self.J[self.mask] = 0.0 - - # compute the constraint jacobian if needed - if self.constraints is not None: - for con in self.constraints: - self.J = torch.cat((self.J, con.jacobian(self.model))) - - # Note that the most recent jacobian update was with Broyden step - self.full_jac = False - - @torch.no_grad() - def update_hess(self) -> None: - """ - Update the Hessian using the jacobian most recently computed on the image. - """ - - if isinstance(self.W, float): - self.hess = torch.matmul(self.J.T, self.J) - else: - self.hess = torch.matmul(self.J.T, self.W.view(len(self.W), -1) * self.J) - self.hess += self.epsilon5 * torch.eye( - len(self.current_state), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - @property - @torch.no_grad() - def covariance_matrix(self) -> torch.Tensor: - if self._covariance_matrix is not None: - return self._covariance_matrix - self.update_J_natural() - self.update_hess() - try: - self._covariance_matrix = 2 * torch.linalg.inv(self.hess) - except: - AP_config.ap_logger.warning( - "WARNING: Hessian is singular, likely at least one model is non-physical. Will massage Hessian to continue but results should be inspected." - ) - self.hess += torch.eye( - len(self.grad), dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) * (torch.diag(self.hess) == 0) - self._covariance_matrix = 2 * torch.linalg.inv(self.hess) - return self._covariance_matrix - - @torch.no_grad() - def update_grad(self, Yph) -> None: - """ - Update the gradient using the model evaluation on all pixels - """ - self.grad = torch.matmul(self.J.T, self.W * (self.Y - Yph)) - - @torch.no_grad() - def rho(self, Xp, Xph, h) -> torch.Tensor: - return ( - self.ndf - * (Xp - Xph) - / abs( - torch.dot( - h, - self.L**2 * (torch.abs(torch.diag(self.hess) - self.epsilon5) * h) + self.grad, - ) - ) - ) - - def accept_history(self) -> (List[np.ndarray], List[np.ndarray], List[float]): - lambdas = [] - Ls = [] - losses = [] - - for l in range(len(self.decision_history)): - if "accept" in self.decision_history[l] and np.isfinite(self.loss_history[l]): - lambdas.append(self.lambda_history[l]) - Ls.append(self.L_history[l]) - losses.append(self.loss_history[l]) - return lambdas, Ls, losses - - def progress_history(self) -> (List[np.ndarray], List[np.ndarray], List[float]): - lambdas = [] - Ls = [] - losses = [] - - for l in range(len(self.decision_history)): - if self.decision_history[l] == "accept": - lambdas.append(self.lambda_history[l]) - Ls.append(self.L_history[l]) - losses.append(self.loss_history[l]) - return lambdas, Ls, losses - - -class LM_Constraint: - """Add an arbitrary constraint to the LM optimization algorithm. - - Expresses a constraint between parameters in the LM optimization - routine. Constraints may be used to bias parameters to have - certain behaviour, for example you may require the radius of one - model to be larger than that of another, or may require two models - to have the same position on the sky. The constraints defined in - this object are fuzzy constraints and so can be broken to some - degree, the amount of constraint breaking is determined my how - informative the data is and how strong the constraint weight is - set. To create a constraint, first construct a function which - takes as argument a 1D tensor of the model parameters and gives as - output a real number (or 1D tensor of real numbers) which is zero - when the constraint is satisfied and non-zero increasing based on - how much the constraint is violated. For example: - - def example_constraint(P): - return (P[1] - P[0]) * (P[1] > P[0]).int() - - which enforces that parameter 1 is less than parameter 0. Note - that we do not use any control flow "if" statements and instead - incorporate the condition through multiplication, this is - important as it allows pytorch to compute derivatives through the - expression and performs far faster on GPU since no communication - is needed back and forth to handle the if-statement. Keep this in - mind while constructing your constraint function. Also, make sure - that any math operations are performed by pytorch so it can - construct a computational graph. Bayond the requirement that the - constraint be differentiable, there is no limitation on what - constraints can be built with this system. - - Args: - constraint_func (Callable[torch.Tensor, torch.Tensor]): python function which takes in a 1D tensor of parameters and generates real values in a tensor. - constraint_args (Optional[tuple]): An optional tuple of arguments for the constraint function that will be unpacked when calling the function. - weight (torch.Tensor): The weight of this constraint in the range (0,inf). Smaller values mean a stronger constraint, larger values mean a weaker constraint. Default 1. - representation_parameters (bool): if the constraint_func expects the parameters in the form of their representation or their standard value. Default False - out_len (int): the length of the output tensor by constraint_func. Default 1 - reference_value (torch.Tensor): The value at which the constraint is satisfied. Default 0. - reduce_ndf (float): Amount by which to reduce the degrees of freedom. Default 0. - - """ - - def __init__( - self, - constraint_func: Callable[[torch.Tensor, Any], torch.Tensor], - constraint_args: tuple = (), - representation_parameters: bool = False, - out_len: int = 1, - reduce_ndf: float = 0.0, - weight: Optional[torch.Tensor] = None, - reference_value: Optional[torch.Tensor] = None, - **kwargs, - ): - self.constraint_func = constraint_func - self.constraint_args = constraint_args - self.representation_parameters = representation_parameters - self.out_len = out_len - self.reduce_ndf = reduce_ndf - self.reference_value = torch.as_tensor( - reference_value if reference_value is not None else torch.zeros(out_len), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - self.weight = torch.as_tensor( - weight if weight is not None else torch.ones(out_len), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - def jacobian(self, model: "AstroPhot_Model"): - jac = jacobian( - lambda P: self.constraint_func(P, *self.constraint_args), - model.parameters.get_vector(as_representation=self.representation_parameters), - strategy="forward-mode", - vectorize=True, - create_graph=False, - ) - - return jac.reshape(-1, np.sum(model.parameters.vector_len())) - - def __call__(self, model: "AstroPhot_Model"): - return self.constraint_func( - model.parameters.get_vector(as_representation=self.representation_parameters), - *self.constraint_args, - ) diff --git a/tests/test_plots.py b/tests/test_plots.py index 1550182b..0c910084 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -130,3 +130,73 @@ def test_model_windows(self): ap.plots.model_window(fig, ax, new_model) plt.close() + + def test_covariance_matrix(self): + covariance_matrix = np.array([[1, 0.5], [0.5, 1]]) + mean = np.array([0, 0]) + + try: + fig, ax = plt.subplots() + except Exception: + print("skipping test because matplotlib is not installed properly") + return + + fig, ax = ap.plots.covariance_matrix(covariance_matrix, mean, labels=["x", "y"]) + + plt.close() + + def test_radial_profile(self): + target = make_basic_sersic() + + new_model = ap.models.AstroPhot_Model( + name="constrained sersic", + model_type="sersic galaxy model", + parameters={ + "center": [20, 20], + "PA": 60 * np.pi / 180, + "q": 0.5, + "n": 2, + "Re": 5, + "Ie": 1, + }, + target=target, + ) + new_model.initialize() + + try: + fig, ax = plt.subplots() + except Exception: + print("skipping test because matplotlib is not installed properly") + return + + ap.plots.radial_light_profile(fig, ax, new_model) + + plt.close() + + def test_radial_median_profile(self): + target = make_basic_sersic() + + new_model = ap.models.AstroPhot_Model( + name="constrained sersic", + model_type="sersic galaxy model", + parameters={ + "center": [20, 20], + "PA": 60 * np.pi / 180, + "q": 0.5, + "n": 2, + "Re": 5, + "Ie": 1, + }, + target=target, + ) + new_model.initialize() + + try: + fig, ax = plt.subplots() + except Exception: + print("skipping test because matplotlib is not installed properly") + return + + ap.plots.radial_median_profile(fig, ax, new_model) + + plt.close() From ba8613f833fc79a4bee3c6ebf277fca6a0b2bb0f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 16 Jul 2024 10:13:20 -0400 Subject: [PATCH 003/191] Remove parse config in favour of config scripts --- astrophot/parse_config/__init__.py | 2 - astrophot/parse_config/basic_config.py | 121 --------------------- astrophot/parse_config/galfit_config.py | 127 ----------------------- astrophot/parse_config/shared_methods.py | 0 4 files changed, 250 deletions(-) delete mode 100644 astrophot/parse_config/__init__.py delete mode 100644 astrophot/parse_config/basic_config.py delete mode 100644 astrophot/parse_config/galfit_config.py delete mode 100644 astrophot/parse_config/shared_methods.py diff --git a/astrophot/parse_config/__init__.py b/astrophot/parse_config/__init__.py deleted file mode 100644 index 1a1aaec3..00000000 --- a/astrophot/parse_config/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .basic_config import * -from .galfit_config import * diff --git a/astrophot/parse_config/basic_config.py b/astrophot/parse_config/basic_config.py deleted file mode 100644 index 72da3256..00000000 --- a/astrophot/parse_config/basic_config.py +++ /dev/null @@ -1,121 +0,0 @@ -import sys -import os -import importlib -import numpy as np -from astropy.io import fits -from ..image import Target_Image -from ..models import AstroPhot_Model -from ..fit import LM -from .. import AP_config - -__all__ = ["basic_config"] - - -def GetOptions(c): - newoptions = {} - for var in dir(c): - if var.startswith("ap_"): - val = getattr(c, var) - if val is not None: - newoptions[var] = val - return newoptions - - -def import_configfile(config_file): - if "/" in config_file: - startat = config_file.rfind("/") + 1 - else: - startat = 0 - if "." in config_file: - use_config = config_file[startat : config_file.rfind(".")] - else: - use_config = config_file[startat:] - if startat > 0: - sys.path.append(os.path.abspath(config_file[: config_file.rfind("/")])) - else: - sys.path.append(os.getcwd()) - c = importlib.import_module(use_config) - return c - - -def basic_config(config_file): - c = import_configfile(config_file) # importlib.import_module(config_file) - config = GetOptions(c) - - # Parse Target - ###################################################################### - AP_config.ap_logger.info("Collecting target information") - target = config.get("ap_target", None) - if target is None: - target_file = config.get("ap_target_file", None) - target_hdu = config.get("ap_target_hdu", 0) - variance_file = config.get("ap_variance_file", None) - variance_hdu = config.get("ap_variance_hdu", 0) - target_pixelscale = config.get("ap_target_pixelscale", None) - target_zeropoint = config.get("ap_target.zeropoint", None) - target_origin = config.get("ap_target_origin", None) - - if variance_file is not None: - var_data = np.array(fits.open(target_file)[target_hdu].data, dtype=np.float64) - else: - var_data = None - if target_file is not None: - data = np.array(fits.open(target_file)[target_hdu].data, dtype=np.float64) - target = Target_Image( - data=data, - pixelscale=target_pixelscale, - zeropoint=target_zeropoint, - variance=var_data, - origin=target_origin, - ) - - # Parse Models - ###################################################################### - AP_config.ap_logger.info("Constructing models") - model_info_list = config.get("ap_models", []) - name_order = config.get( - "ap_model_name_order", - list(n[9:] for n in filter(lambda k: k.startswith("ap_model_"), config.keys())), - ) - for name in name_order: - key_name = "ap_model_" + name - model_info_list.append(config[key_name]) - if "name" not in model_info_list[-1]: - model_info_list[-1]["name"] = name - model_list = [] - for model in model_info_list: - model_list.append(AstroPhot_Model(target=target, **model)) - - MODEL = AstroPhot_Model( - name="AstroPhot", - model_type="group model", - models=model_list, - target=target, - ) - - # Parse Optimize - ###################################################################### - AP_config.ap_logger.info("Running optimization") - MODEL.initialize() - - optim_type = config.get("ap_optimizer", "LM") - optim_kwargs = config.get("ap_optimizer_kwargs", {}) - if optim_type is None: - # perform no optimization, simply write the astrophot model and the requested images - pass - elif optim_type == "LM": - result = LM(MODEL, **optim_kwargs).fit() - - # Parse Save - ###################################################################### - AP_config.ap_logger.info("Saving model") - model_save = config.get("ap_saveto_model", "AstroPhot.yaml") - MODEL.save(model_save) - - model_image_save = config.get("ap_saveto_model_image", None) - if model_image_save is not None: - MODEL().save(model_image_save) - - model_residual_save = config.get("ap_saveto_model_residual", None) - if model_residual_save is not None: - (target - MODEL()).save(model_residual_save) diff --git a/astrophot/parse_config/galfit_config.py b/astrophot/parse_config/galfit_config.py deleted file mode 100644 index 2248043c..00000000 --- a/astrophot/parse_config/galfit_config.py +++ /dev/null @@ -1,127 +0,0 @@ -__all__ = ["galfit_config"] - -galfit_object_type_map = { - "sersic": "sersic galaxy model", - "sky": "flat sky model", -} - -galfit_parameter_map = { - "sersic galaxy model": { - "1": ["centerpix", 2], - "3": ["totalmag", 1], - "4": ["Repix", 1], - "5": ["n", 1], - "9": ["q", 1], - "10": ["PAdeg", 1], - } -} - - -def space_split(l): - items = list(ls.strip() for ls in l.split(" ")) - index = 0 - while index < len(items): - if items[index] == "": - items.pop(index) - else: - index += 1 - return items - - -def galfit_config(config_file): - if True: - raise NotImplementedError("galfit configuration file interface under construction") - with open(config_file, "r") as f: - config_lines = f.readlines() - # Header info - headerinfo = {} - for line in config_lines: - # remove comment from line and strip whitespace - comment = line.find("#") - if comment >= 0: - line = line[:comment].strip() - if line == "": - continue - if line.startswith("A)"): - headerinfo["target_file"] = line[2:].strip() - if line.startswith("B)"): - headerinfo["saveto_model"] = line[2:].strip() - if line.startswith("C)"): - headerinfo["varaince_file"] = line[2:].strip() - if line.startswith("D)"): - headerinfo["psf_file"] = line[2:].strip() - if line.startswith("E)"): - headerinfo["psf_upample"] = line[2:].strip() - if line.startswith("F)"): - headerinfo["mask_file"] = line[2:].strip() - if line.startswith("G)"): - headerinfo["constraints_file"] = line[2:].strip() - if line.startswith("H)"): - headerinfo["fit_window"] = line[2:].strip() - if line.startswith("I)"): - headerinfo["convolution_window"] = line[2:].strip() - if line.startswith("J)"): - headerinfo["target_zeropoint"] = line[2:].strip() - if line.startswith("K)"): - headerinfo["target_pixelscale"] = line[2:].strip() - - # Object info - objects = [] - in_object = False - for line in config_lines: - # remove comment from line and strip whitespace - comment = line.find("#") - if comment >= 0: - linem = line[:comment].strip() - if linem == "": - continue - - # New model added to the fit - if linem.startswith("0)"): - objects.append({"model_type": galfit_object_type_map[linem[2:].strip()]}) - in_object = True - # Model finished adding - if linem.startswith("Z)"): - in_object = False - - # Collect the parameters - if in_object: - param = linem[: linem.find(")")] - objects[-1][galfit_parameter_map[objects[-1]["model_type"]][param][0]] = space_split( - linem[linem.find(")") + 1 :] - ) - if len(objects[-1][galfit_parameter_map[objects[-1]["model_type"]][param][0]]) != ( - 2 * galfit_parameter_map[objects[-1]["model_type"]][param][1] - ): - raise ValueError(f"Incorrectly formatted line in GALFIT config file:\n{line}") - - # Format parameters - for i in range(len(objects)): - astrophot_object = { - "model_type": objects[i]["model_type"], - } - - # common params - if "centerpix" in objects[i]: - astrophot_object["center"] = { - "value": [ - float(objects[i]["centerpix"][0]) * headerinfo["target_pixelscale"], - float(objects[i]["centerpix"][1]) * headerinfo["target_pixelscale"], - ], - "locked": bool(objects[i]["centerpix"][2]), - } - if "Repix" in objects[i]: - astrophot_object["Re"] = { - "value": float(objects[i]["Repix"][0]) * headerinfo["target_pixelscale"], - "locked": bool(objects[i]["Repix"][1]), - } - if "q" in objects[i]: - astrophot_object["q"] = { - "value": float(objects[i]["q"][0]), - "locked": bool(objects[i]["q"][1]), - } - if "PAdeg" in objects[i]: - astrophot_object["PA"] = { - "value": float(objects[i]["PAdeg"][0]) * np.pi / 180, - "locked": bool(objects[i]["PAdeg"][1]), - } diff --git a/astrophot/parse_config/shared_methods.py b/astrophot/parse_config/shared_methods.py deleted file mode 100644 index e69de29b..00000000 From 25994351b2491b102f2c5460e83a0dda1f097db3 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 16 Jul 2024 10:22:39 -0400 Subject: [PATCH 004/191] Add isophote ellipse tests --- tests/test_utils.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index b5c8a2fe..fe3383d2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -497,5 +497,29 @@ def test_angle_com(self): self.assertAlmostEqual(res + np.pi / 2, 115 * np.pi / 180, delta=0.1) +class TestIsophote(unittest.TestCase): + def test_ellipse(self): + rs = ap.utils.isophote.ellipse.Rscale_Fmodes(1.0, [1, 2], [1, 2], [1, 2]) + + self.assertTrue(np.isfinite(rs), "Rscale_Fmodes should return finite values") + + rs = ap.utils.isophote.ellipse.parametric_Fmodes( + np.linspace(0, np.pi / 2, 10), [1, 2], [1, 2], [1, 2] + ) + + self.assertTrue(np.all(np.isfinite(rs)), "parametric_Fmodes should return finite values") + + for C in np.linspace(1, 3, 5): + rs = ap.utils.isophote.ellipse.Rscale_SuperEllipse(1.0, 1.0, C) + self.assertTrue(np.isfinite(rs), "Rscale_SuperEllipse should return finite values") + + rs = ap.utils.isophote.ellipse.parametric_SuperEllipse( + np.linspace(0, np.pi / 2, 10), 1.0, C + ) + self.assertTrue( + np.all(np.isfinite(rs)), "parametric_SuperEllipse should return finite values" + ) + + if __name__ == "__main__": unittest.main() From e34e1739717099024add0985e3eb82ac15cc8d48 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 16 Jul 2024 10:30:06 -0400 Subject: [PATCH 005/191] remove config import --- astrophot/__init__.py | 100 +++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 65 deletions(-) diff --git a/astrophot/__init__.py b/astrophot/__init__.py index 91b884a8..9a0ad067 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -1,7 +1,6 @@ import argparse import requests import torch -from .parse_config import galfit_config, basic_config from . import models, image, plots, utils, fit, param, AP_config try: @@ -21,29 +20,7 @@ def run_from_terminal() -> None: """ - Execute AstroPhot from the command line with various options. - - This function uses the `argparse` module to parse command line arguments and execute the appropriate functionality. - It accepts the following arguments: - - - `filename`: the path to the configuration file. Or just 'tutorial' to download tutorials. - - `--config`: the type of configuration file being provided. One of: astrophot, galfit. - - `-v`, `--version`: print the current AstroPhot version to screen. - - `--log`: set the log file name for AstroPhot. Use 'none' to suppress the log file. - - `-q`: quiet flag to stop command line output, only print to log file. - - `--dtype`: set the float point precision. Must be one of: float64, float32. - - `--device`: set the device for AstroPhot to use for computations. Must be one of: cpu, gpu. - - If the `filename` argument is not provided, it raises a `RuntimeError`. - If the `filename` argument is `tutorial` or `tutorials`, - it downloads tutorials from various URLs and saves them locally. - - This function logs messages using the `AP_config` module, - which sets the logging output based on the `--log` and `-q` arguments. - The `dtype` and `device` of AstroPhot can also be set using the `--dtype` and `--device` arguments, respectively. - - Returns: - None + Running from terminal no longer supported. This is only used for convenience to download the tutorials. """ AP_config.ap_logger.debug("running from the terminal, not sure if it will catch me.") @@ -58,14 +35,14 @@ def run_from_terminal() -> None: metavar="configfile", help="the path to the configuration file. Or just 'tutorial' to download tutorials.", ) - parser.add_argument( - "--config", - type=str, - default="astrophot", - choices=["astrophot", "galfit"], - metavar="format", - help="The type of configuration file being being provided. One of: astrophot, galfit.", - ) + # parser.add_argument( + # "--config", + # type=str, + # default="astrophot", + # choices=["astrophot", "galfit"], + # metavar="format", + # help="The type of configuration file being being provided. One of: astrophot, galfit.", + # ) parser.add_argument( "-v", "--version", @@ -73,31 +50,31 @@ def run_from_terminal() -> None: version=f"%(prog)s {__version__}", help="print the current AstroPhot version to screen", ) - parser.add_argument( - "--log", - type=str, - metavar="logfile.log", - help="set the log file name for AstroPhot. use 'none' to suppress the log file.", - ) - parser.add_argument( - "-q", - action="store_true", - help="quiet flag to stop command line output, only print to log file", - ) - parser.add_argument( - "--dtype", - type=str, - choices=["float64", "float32"], - metavar="datatype", - help="set the float point precision. Must be one of: float64, float32", - ) - parser.add_argument( - "--device", - type=str, - choices=["cpu", "gpu"], - metavar="device", - help="set the device for AstroPhot to use for computations. Must be one of: cpu, gpu", - ) + # parser.add_argument( + # "--log", + # type=str, + # metavar="logfile.log", + # help="set the log file name for AstroPhot. use 'none' to suppress the log file.", + # ) + # parser.add_argument( + # "-q", + # action="store_true", + # help="quiet flag to stop command line output, only print to log file", + # ) + # parser.add_argument( + # "--dtype", + # type=str, + # choices=["float64", "float32"], + # metavar="datatype", + # help="set the float point precision. Must be one of: float64, float32", + # ) + # parser.add_argument( + # "--device", + # type=str, + # choices=["cpu", "gpu"], + # metavar="device", + # help="set the device for AstroPhot to use for computations. Must be one of: cpu, gpu", + # ) args = parser.parse_args() @@ -128,7 +105,6 @@ def run_from_terminal() -> None: "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/BasicPSFModels.ipynb", "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/AdvancedPSFModels.ipynb", "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/ConstrainedModels.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot-tutorials/main/docs/tutorials/simple_config.py", ] for url in tutorials: try: @@ -141,11 +117,5 @@ def run_from_terminal() -> None: ) AP_config.ap_logger.info("collected the tutorials") - elif args.config == "astrophot": - basic_config(args.filename) - elif args.config == "galfit": - galfit_config(args.filename) else: - raise ValueError( - f"Unrecognized configuration file format {args.config}. Should be one of: astrophot, galfit" - ) + raise ValueError(f"Unrecognized request") From 4e5835e02d198bd7a99018e564d229c03b548b21 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 16 Jul 2024 10:33:02 -0400 Subject: [PATCH 006/191] remove oldlm import --- astrophot/fit/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 13976d48..9d4027c9 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,6 +1,5 @@ from .base import * from .lm import * -from .oldlm import * from .gradient import * from .iterative import * from .minifit import * From d463e31b878208e9fc812cde7685ab100ff47232 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 16 Jul 2024 10:38:41 -0400 Subject: [PATCH 007/191] fix ellip test --- tests/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index fe3383d2..d9db8071 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -510,11 +510,11 @@ def test_ellipse(self): self.assertTrue(np.all(np.isfinite(rs)), "parametric_Fmodes should return finite values") for C in np.linspace(1, 3, 5): - rs = ap.utils.isophote.ellipse.Rscale_SuperEllipse(1.0, 1.0, C) + rs = ap.utils.isophote.ellipse.Rscale_SuperEllipse(1.0, 0.8, C) self.assertTrue(np.isfinite(rs), "Rscale_SuperEllipse should return finite values") rs = ap.utils.isophote.ellipse.parametric_SuperEllipse( - np.linspace(0, np.pi / 2, 10), 1.0, C + np.linspace(0, np.pi / 2, 10), 0.8, C ) self.assertTrue( np.all(np.isfinite(rs)), "parametric_SuperEllipse should return finite values" From 004a48bcca0d16d65f07524cdf64f9552f648493 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 26 Dec 2024 13:09:25 -0500 Subject: [PATCH 008/191] update colormaps --- astrophot/plots/visuals.py | 376 +------------------------------------ 1 file changed, 9 insertions(+), 367 deletions(-) diff --git a/astrophot/plots/visuals.py b/astrophot/plots/visuals.py index 8ebc913f..e77a2587 100644 --- a/astrophot/plots/visuals.py +++ b/astrophot/plots/visuals.py @@ -1,373 +1,15 @@ -import numpy as np -from matplotlib.colors import LinearSegmentedColormap +from matplotlib.pyplot import get_cmap __all__ = ["main_pallet", "cmap_grad", "cmap_div"] main_pallet = { - "primary1": "#5FAD41", - "primary2": "#46A057", - "primary3": "#2D936C", - "secondary1": "#595122", - "secondary2": "#BFAE48", - "pop": "#391463", + "primary1": "tab:green", + "primary2": "limegreen", + "primary3": "lime", + "secondary1": "tab:blue", + "secondary2": "blue", + "pop": "tab:orange", } -# grad_list = [ -# "#000000", -# "#1A1F16", -# "#1E3F20", -# "#335E31", # "#294C28", -# "#477641", # "#345830", -# "#5D986D", # "#4A7856", -# "#88BF9E", # "#6FB28A", -# "#94ECBE", -# "#FFFFFF", -# ] - -# grad_list = np.load(os.path.join(os.path.dirname(os.path.abspath(__file__)), "rgb_colours.npy")) -# not proud of this but it works -grad_list = [ - [0.02352941176470601, 0.05490196078431372, 0.03137254901960787], - [0.025423221664412132, 0.057920953380312966, 0.033516620406216086], - [0.027376785284830882, 0.06093685701603565, 0.035720426678078974], - [0.02938891743876659, 0.0639504745699993, 0.03798295006291389], - [0.03145841869575705, 0.06696255038243151, 0.04030317185736432], - [0.033584075048444885, 0.06997377604547542, 0.04260833195845542], - [0.03576465765226276, 0.07298479546414544, 0.044888919486002675], - [0.03799892263356237, 0.0759962092973116, 0.0471477168479796], - [0.04028561096212802, 0.07900857886908796, 0.049385189300118405], - [0.04255435348091496, 0.08202242962578685, 0.05160177112762838], - [0.04479522750797262, 0.0850382542012795, 0.053797868796876404], - [0.047011290960205, 0.08805651514356005, 0.05597386374386699], - [0.04920301153517722, 0.09107764734708199, 0.05813011485045571], - [0.05137081101697757, 0.09410206022865564, 0.06026696065107289], - [0.05351507020340438, 0.09713013967907827, 0.062384721306060466], - [0.05563613318418619, 0.1001622498180042, 0.06448370037222773], - [0.057734311075703586, 0.10319873457564618, 0.06656418639667772], - [0.0598098852979892, 0.10623991912163352, 0.0686264543561699], - [0.06186311046428467, 0.10928611115857814, 0.07067076696111699], - [0.06389421694104419, 0.11233760209556845, 0.07269737584065203], - [0.06590341312637987, 0.11539466811482368, 0.07470652262295904], - [0.06789088748697142, 0.11845757114304345, 0.07669843992315992], - [0.06985681038697011, 0.12152655973754478, 0.07867335224943414], - [0.07180133573715267, 0.12460186989603428, 0.08063147683667268], - [0.07372460248826435, 0.12768372579779083, 0.08257302441578668], - [0.07562673598889558, 0.13077234048311664, 0.08449819992578214], - [0.07750784922530482, 0.1338679164771084, 0.0864072031748393], - [0.0793680439581338, 0.13697064636311304, 0.08830022945588598], - [0.08120741176889373, 0.14008071331062474, 0.0901774701215026], - [0.08302603502740363, 0.1431982915618533, 0.09203911312243368], - [0.08482398778987404, 0.14632354688073884, 0.0938853435134896], - [0.08660133663613406, 0.14945663696777384, 0.09571634393019346], - [0.08835814145343088, 0.15259771184365092, 0.0975322950391592], - [0.09009445617335096, 0.15574691420443426, 0.09933337596485287], - [0.09181032946766504, 0.1589043797506794, 0.10111976469511005], - [0.09350580540821188, 0.16207023749268656, 0.1028916384675223], - [0.0951809240954313, 0.16524461003384922, 0.1046491741385914], - [0.09683572225960263, 0.1684276138338816, 0.10639254853734492], - [0.09847023383851092, 0.17161935945352033, 0.10812193880494256], - [0.10008449053481877, 0.17481995178216395, 0.10983752272163866], - [0.10167852235616967, 0.1780294902497653, 0.11153947902233724], - [0.10325235814074307, 0.18124806902417712, 0.11322798770185102], - [0.10480602607076109, 0.18447577719504085, 0.1149032303108651], - [0.10633955417621349, 0.18771269894521686, 0.11656539024350993], - [0.10785297083092218, 0.19095891371065837, 0.11821465301736292], - [0.10934630524287259, 0.19421449632956456, 0.11985120654661363], - [0.11081958794061691, 0.19747951718156898, 0.12147524140906224], - [0.11227285125743197, 0.20075404231766003, 0.12308695110755152], - [0.113706129814793, 0.2040381335814737, 0.12468653232637883], - [0.11511946100664691, 0.20733184872254534, 0.12627418518317554], - [0.1165128854858628, 0.21063524150205992, 0.12785011347669853], - [0.11788644765418299, 0.21394836179160054, 0.12941452493092973], - [0.11924019615691589, 0.21727125566535316, 0.13096763143583748], - [0.12057418438355699, 0.2206039654861931, 0.13250964928512182], - [0.12188847097547184, 0.2239465299860438, 0.13404079941121932], - [0.12318312034172044, 0.2272989843408748, 0.13556130761782226], - [0.12445820318406359, 0.23066136024067158, 0.13707140481012495], - [0.12571379703214872, 0.2340336859546926, 0.13857132722298834], - [0.12694998678982505, 0.23741598639230338, 0.14006131664718174], - [0.1281668652935146, 0.2408082831596569, 0.14154162065383566], - [0.12936453388351488, 0.24421059461247346, 0.14301249281721415], - [0.13054310298908373, 0.24762293590515277, 0.14447419293589053], - [0.13170269272811447, 0.25104531903643956, 0.1459269872523864], - [0.1328434335221802, 0.25447775289184116, 0.14737114867131162], - [0.1339654667276644, 0.2579202432829991, 0.14880695697601956], - [0.13506894528368824, 0.26137279298418187, 0.15023469904377038], - [0.1361540343774794, 0.26483540176607334, 0.15165466905937425], - [0.13722091212776352, 0.2683080664270149, 0.15306716872726295], - [0.1382697702867517, 0.271790780821844, 0.15447250748191957], - [0.13930081496119553, 0.2752835358884738, 0.1558710026965733], - [0.14031426735293703, 0.2787863196723416, 0.15726297989004664], - [0.1413103645193169, 0.2822991173488483, 0.15864877293161908], - [0.142289360153694, 0.28582191124391093, 0.16002872424375406], - [0.1432515253862969, 0.2893546808527299, 0.16140318500251255], - [0.1441971496054517, 0.29289740285688415, 0.16277251533545473], - [0.14512654129921948, 0.29645005113984313, 0.16413708451681472], - [0.1460400289172567, 0.3000125968009987, 0.1654972711597065], - [0.14693796175266738, 0.30358500816829653, 0.16685346340510454], - [0.14782071084339368, 0.3071672508095593, 0.16820605910731407], - [0.14868866989260401, 0.31075928754257476, 0.16955546601563484], - [0.14954225620729, 0.3143610784440312, 0.1709021019518895], - [0.1503819116541449, 0.31797258085736924, 0.1722463949834796], - [0.15120810363154275, 0.32159374939962293, 0.17358878359160151], - [0.1520213260562527, 0.32522453596731304, 0.17492971683424066], - [0.15282210036320543, 0.32886488974146083, 0.1762696545035385], - [0.15361097651642713, 0.33251475719178275, 0.17760906727710982], - [0.15438853402891373, 0.3361740820801234, 0.17894843686287198], - [0.15515538298892081, 0.3398428054631865, 0.18028825613691796], - [0.15591216508980174, 0.34352086569461787, 0.18162902927396304], - [0.15665955466015927, 0.3472081984264961, 0.18297127186986703], - [0.15739825969072788, 0.3509047366102775, 0.1843155110557227], - [0.1581290228539065, 0.35461041049725495, 0.18566228560298584], - [0.15885262251157475, 0.35832514763856854, 0.18701214601910962], - [0.1595698737062211, 0.36204887288482673, 0.1883656546331343], - [0.16028162913006866, 0.365781508385379, 0.18972338567067223], - [0.1609887800663473, 0.3695229735872851, 0.19108592531772042], - [0.1616922572963582, 0.3732731852340314, 0.19245387177272855], - [0.1623930319654953, 0.3770320573640352, 0.19382783528634243], - [0.1630921164008896, 0.38079950130898077, 0.1952084381882439], - [0.16379056487278745, 0.38457542569203246, 0.19659631490050877], - [0.16448947429132857, 0.38835973642596816, 0.19799211193690508], - [0.16518998482987113, 0.39215233671127353, 0.19939648788756642], - [0.1658932804655635, 0.3959531270342421, 0.20081011338847304], - [0.166600589427405, 0.39976200516512433, 0.20223367107520046], - [0.16731318454171665, 0.4035788661563645, 0.20366785552039823], - [0.1680323834645103, 0.4074036023409737, 0.20511337315448636], - [0.16875954879008526, 0.4112361033310768, 0.20657094216908256], - [0.16949608802490335, 0.4150762560166792, 0.20804129240268987], - [0.17024345341575386, 0.4189239445646968, 0.20952516520821696], - [0.17100314162122876, 0.42277905041829333, 0.21102331330192792], - [0.17177669321562006, 0.4266414522965662, 0.21253650059345497], - [0.1725656920146934, 0.43051102619463094, 0.21406550199656194], - [0.17337176421315095, 0.43438764538414765, 0.2156111032203738], - [0.174196577324217, 0.4382711804143329, 0.21717410054084768], - [0.1750418389125363, 0.4421614991135102, 0.21875530055231346], - [0.1759092951125111, 0.44605846659124265, 0.22035551989895852], - [0.17680072892538157, 0.4499619452410963, 0.22197558498620257], - [0.17771795828963766, 0.4538717947440886, 0.2236163316719602], - [0.178662833920936, 0.45778787207287114, 0.22527860493786028], - [0.1796372369194738, 0.46171003149669404, 0.22696325854055394], - [0.18064307614457878, 0.46563812458721304, 0.22867115464331578], - [0.18168228535855538, 0.4695720002251926, 0.23040316342821293], - [0.1827568201439772, 0.47351150460815455, 0.23216016268918455], - [0.1838686546011176, 0.477456481259039, 0.233943037406457], - [0.18501977783468404, 0.48140677103593116, 0.23575267930278093], - [0.18621219024170063, 0.4853622121429166, 0.23758998638206003], - [0.18744789961499414, 0.4893226401421262, 0.23945586245100942], - [0.1887289170794073, 0.49328788796703654, 0.24135121662454895], - [0.19005725288048025, 0.4972577859370923, 0.24327696281571337], - [0.19143491204781923, 0.5012321617737128, 0.24523401921092142], - [0.19286388995773657, 0.505210840617762, 0.2472233077315104], - [0.1943461678218979, 0.509193645048545, 0.24924575348250605], - [0.1958837081305836, 0.5131803951044065, 0.2513022841896479], - [0.1974784500806806, 0.5171709083050143, 0.25339382962574203], - [0.1991323050198725, 0.5211649996753929, 0.2555213210274589], - [0.20084715193916947, 0.5251624817718009, 0.2576856905037352], - [0.20262483304633028, 0.5291631647095255, 0.2598878704369637], - [0.204467149452764, 0.5331668561926818, 0.26212879287818813], - [0.206375857005755, 0.5371733615461064, 0.2644093889375338], - [0.20835266229704946, 0.5411824837494301, 0.26673058817112216], - [0.2103992188771397, 0.5451940234734288, 0.26909331796570707], - [0.21251712370282538, 0.5492077791187413, 0.2714985029222856], - [0.21470791384316384, 0.5532235468570559, 0.2739470642399031], - [0.21697306346622774, 0.5572411206748591, 0.27643991910086557], - [0.21931398112597572, 0.5612602924198622, 0.2789779800585389], - [0.22173200736531357, 0.5652808518501962, 0.28156215442887567], - [0.22422841264772203, 0.5693025866864954, 0.28419334368676863], - [0.2268043956263523, 0.5733252826669735, 0.2868724428682829], - [0.22946108175552465, 0.5773487236056106, 0.28960033997974594], - [0.23219952224604845, 0.5813726914535655, 0.2923779154146281], - [0.23502069336196157, 0.5853969663639336, 0.29520604137906364], - [0.23792549605282798, 0.5894213267599773, 0.2980855813267848], - [0.24091475591248918, 0.5934455494069475, 0.30101738940417244], - [0.2439892234519782, 0.5974694094876285, 0.3040023099060248], - [0.2471495746716238, 0.6014926806817401, 0.3070411767425731], - [0.2503964119149868, 0.6055151352493269, 0.31013481291816875], - [0.25373026498509416, 0.6095365441182762, 0.3132840300219835], - [0.25715159250188796, 0.6135566769761028, 0.3164896277309624], - [0.26066078347841287, 0.6175753023661407, 0.31975239332517896], - [0.26425815909238537, 0.6215921877882963, 0.3230731012156454], - [0.26794397462927927, 0.6256070998045035, 0.32645251248454027], - [0.27171842157278525, 0.6296198041490337, 0.32989137443772115], - [0.2755816298187335, 0.6336300658438219, 0.3333904201693042], - [0.27953366998896156, 0.6376376493189533, 0.3369503681380034], - [0.2835745558223273, 0.6416423185384807, 0.34057192175484835], - [0.2877042466209889, 0.6456438371317278, 0.3442557689818123], - [0.2919226497312409, 0.6496419685302498, 0.34800258194081274], - [0.2962296230395252, 0.6536364761106029, 0.3518130165324921], - [0.30062497746549316, 0.6576271233431101, 0.3556877120641009], - [0.3051084794357496, 0.6616136739467778, 0.3596272908857725], - [0.3096798533232689, 0.6655958920505426, 0.3636323580344192], - [0.3143387838391119, 0.6695735423610237, 0.3677035008844355], - [0.3190849183647877, 0.6735463903369483, 0.37184128880436657], - [0.3239178692149385, 0.677514202370436, 0.37604627281865705], - [0.3288372158217989, 0.6814767459753105, 0.38031898527358976], - [0.33384250683409117, 0.6854337899826277, 0.3846599395064855], - [0.33893326212463143, 0.68938510474359, 0.38906962951724855], - [0.344108974702066, 0.6933304623400306, 0.39354852964131404], - [0.34936911252340047, 0.697269636802651, 0.398097094223074], - [0.354713120205116, 0.7012024043371861, 0.40271575728885467], - [0.3601404206316701, 0.7051285435586824, 0.407404932218529], - [0.365650416461051, 0.7090478357340677, 0.4121650114148717], - [0.3712424915278934, 0.7129600650331871, 0.4169963659697734], - [0.3769160121453231, 0.7168650187884974, 0.4218993453264664], - [0.3826703283074528, 0.7207624877635708, 0.426874276936929], - [0.38850477479467865, 0.724652266430624, 0.4319214659136745], - [0.39441867218475135, 0.7285341532572063, 0.4370411946751511], - [0.4004113277726398, 0.7324079510022498, 0.4422337225840276], - [0.40648203640264285, 0.7362734670216392, 0.4474992855776477], - [0.41263008121642264, 0.7401305135834749, 0.4528380957899934], - [0.41885473432075093, 0.7439789081931996, 0.4582503411645124], - [0.42515525737890464, 0.74781847392875, 0.4637361850571962], - [0.4315309021296915, 0.7516490397859019, 0.4692957658293329], - [0.4379809108381271, 0.755470441033972, 0.4749291964293609], - [0.44450451668174257, 0.7592825195820302, 0.4806365639632952], - [0.4511009440764859, 0.7630851243557921, 0.48641792925318267], - [0.4577694089460426, 0.766878111685348, 0.49227332638307436], - [0.46450911893842234, 0.7706613457038759, 0.49820276223198207], - [0.47131927359337594, 0.7744346987575222, 0.5042062159932884], - [0.47819906446422616, 0.7781980518265826, 0.5102836386800519], - [0.4851476751974689, 0.7819512949581628, 0.5164349526156181], - [0.4921642815733072, 0.7856943277104902, 0.5226600509088999], - [0.49924805151023793, 0.7894270596090324, 0.528958796913626], - [0.506398145036514, 0.7931494106146145, 0.5353310236707783], - [0.5136137142311699, 0.7968613116037284, 0.5417765333333355], - [0.5208939031371336, 0.8005627048612254, 0.5482950965723069], - [0.528237847648764, 0.8042535445856207, 0.5548864519629064], - [0.5356446753758648, 0.8079337974072546, 0.5615503053495099], - [0.5431135054862372, 0.8116034429195591, 0.5682863291878316], - [0.5506434485283842, 0.8152624742237489, 0.5750941618624987], - [0.558233606235997, 0.8189108984872628, 0.5819734069778754], - [0.5658830713155681, 0.8225487375163245, 0.588923632619649], - [0.5735909272182081, 0.8261760283430954, 0.5959443705842361], - [0.5813562478966682, 0.8297928238278868, 0.6030351155725882], - [0.5891780975482949, 0.8333991932770318, 0.6101953243443609], - [0.5970555303443447, 0.8369952230771135, 0.6174244148277456], - [0.6049875901460008, 0.8405810173463196, 0.6247217651794131], - [0.6129733102071075, 0.8441566986038703, 0.6320867127880987], - [0.6210117128633796, 0.8477224084586106, 0.6395185532141892], - [0.629101809207598, 0.8512783083180583, 0.6470165390563735], - [0.6372425987500262, 0.8548245801194263, 0.6545798787348152], - [0.6454330690629193, 0.8583614270844092, 0.6622077351784434], - [0.6536721954076695, 0.861889074499868, 0.6698992244017301], - [0.6619589403427637, 0.8654077705269353, 0.6776534139536473], - [0.670292253310307, 0.868917787041518, 0.6854693212183214], - [0.6786710701982759, 0.8724194205097873, 0.6933459115430326], - [0.6870943128752733, 0.8759129929029018, 0.7012820961645896], - [0.6955608886937588, 0.8793988526560546, 0.7092767298994272], - [0.7040696899570745, 0.8828773756779759, 0.7173286085559195], - [0.7126195933446154, 0.8863489664182553, 0.7254364660189113], - [0.7212094592884835, 0.8898140590014009, 0.7335989709460631], - [0.7298381312936156, 0.8932731184384776, 0.7418147230026438], - [0.738504435191736, 0.8967266419295534, 0.7500822485452499], - [0.7472071783175026, 0.900175160273203, 0.7583999956446169], - [0.755945148592551, 0.9036192394031619, 0.7667663283120103], - [0.7647171134997374, 0.9070594820771404, 0.7751795197609306], - [0.7735218189254033, 0.9104965297491636, 0.783637744493875], - [0.7823579878412776, 0.9139310646651907, 0.7921390689495337], - [0.79122431878925, 0.9173638122327802, 0.8006814403748957], - [0.8001194841201199, 0.9207955437304958, 0.8092626734934449], - [0.8090421279203749, 0.9242270794429251, 0.817880434416763], - [0.8179908635354565, 0.9276592923352225, 0.8265322210808196], - [0.8269642705600306, 0.9310931124203533, 0.8352153392634705], - [0.8359608911072839, 0.934529532028408, 0.8439268729323351], - [0.844979225077973, 0.9379696122690698, 0.852663647247446], - [0.8540177240040997, 0.9414144910996233, 0.8614221819497985], - [0.863074782804233, 0.9448653935946189, 0.8701986320294973], - [0.8721487283913724, 0.9483236452977989, 0.87898871137326], - [0.8812378033992858, 0.9517906899876957, 0.8877875933734316], - [0.890340142117086, 0.955268113920236, 0.8965897799935405], - [0.8994537336219597, 0.9587576798305327, 0.9053889271758421], - [0.908576363256908, 0.9622613760595616, 0.9141776092709066], - [0.9177055163843063, 0.9657814898283584, 0.9229469978386258], - [0.9268382144426769, 0.9693207202671751, 0.9316864204851563], - [0.9359707258804478, 0.9728823589346071, 0.9403827547526942], - [0.9450980392156855, 0.9764705882352952, 0.9490196078431373], -] - -cmap_grad = LinearSegmentedColormap.from_list("cmap_grad", grad_list) - -# # grad_list = ["#000000", "#1A1F16", "#1E3F20", "#294C28", "#345830", "#4A7856", "#6FB28A", "#94ECBE", "#FFFFFF"] -# grad_cdict = {"red": [], "green": [], "blue": []} -# cpoints = np.linspace(0, 1, len(grad_list)) -# for i in range(len(grad_list)): -# grad_cdict["red"].append( -# [cpoints[i], int(grad_list[i][1:3], 16) / 256, int(grad_list[i][1:3], 16) / 256] -# ) -# grad_cdict["green"].append( -# [cpoints[i], int(grad_list[i][3:5], 16) / 256, int(grad_list[i][3:5], 16) / 256] -# ) -# grad_cdict["blue"].append( -# [cpoints[i], int(grad_list[i][5:7], 16) / 256, int(grad_list[i][5:7], 16) / 256] -# ) -# cmap_grad = LinearSegmentedColormap("cmap_grad", grad_cdict) - -div_list = [ - "#332A1F", - "#514129", - "#7C6527", - "#A2862A", - "#DAB944", - "#FFFFFF", - "#7EC87E", - "#3EA343", - "#267D2F", - "#0D5D09", - "#073805", -] -# div_list = ["#083D77", "#7E886B", "#B9AE65", "#FFFFFF", "#F1B555", "#EE964B", "#F95738"] -div_cdict = {"red": [], "green": [], "blue": []} -cpoints = np.linspace(0, 1, len(div_list)) -for i in range(len(div_list)): - div_cdict["red"].append( - [cpoints[i], int(div_list[i][1:3], 16) / 256, int(div_list[i][1:3], 16) / 256] - ) - div_cdict["green"].append( - [cpoints[i], int(div_list[i][3:5], 16) / 256, int(div_list[i][3:5], 16) / 256] - ) - div_cdict["blue"].append( - [cpoints[i], int(div_list[i][5:7], 16) / 256, int(div_list[i][5:7], 16) / 256] - ) -cmap_div = LinearSegmentedColormap("cmap_div", div_cdict) - -# P = plt.cm.plasma_r -# C = plt.cm.cividis -# N = 3 -# cmap_div = ListedColormap(["#083D77", "#7E886B", "#B9AE65", "#FFFFFF", "#F1B555", "#EE964B", "#F95738"]) - -# main_pallet = { -# "primary1": "g", -# "primary2": "r", -# "primary3": "b", -# "primary4": "ornnge", -# "primary5": "cyan", -# "secondary1": "purple", -# "secondary2": "salmon", -# "secondary3": "k", -# "pop": "yellow", -# } - -# cmap_grad = plt.cm.magma -# cmap_div = plt.cm.seismic - -# from matplotlib.colors import LinearSegmentedColormap -# cmaplist = ["#000000", "#720026", "#A0213F", "#ce4257", "#E76154", "#ff9b54", "#ffd1b1"] -# cdict = {"red": [], "green": [], "blue": []} -# cpoints = np.linspace(0, 1, len(cmaplist)) -# for i in range(len(cmaplist)): -# cdict["red"].append( -# [cpoints[i], int(cmaplist[i][1:3], 16) / 256, int(cmaplist[i][1:3], 16) / 256] -# ) -# cdict["green"].append( -# [cpoints[i], int(cmaplist[i][3:5], 16) / 256, int(cmaplist[i][3:5], 16) / 256] -# ) -# cdict["blue"].append( -# [cpoints[i], int(cmaplist[i][5:7], 16) / 256, int(cmaplist[i][5:7], 16) / 256] -# ) -# autocmap = LinearSegmentedColormap("autocmap", cdict) -# autocolours = { -# "red1": "#c33248", -# "blue1": "#84DCCF", -# "blue2": "#6F8AB7", -# "redrange": ["#720026", "#A0213F", "#ce4257", "#E76154", "#ff9b54", "#ffd1b1"], -# } # '#D95D39' +cmap_grad = get_cmap("inferno") +cmap_div = get_cmap("seismic") From c38705629051c5d431344c5512ff57675edd271f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 13 Jan 2025 10:27:22 -0500 Subject: [PATCH 009/191] change params passing --- astrophot/models/_model_methods.py | 49 +++--------- astrophot/models/_shared_methods.py | 46 ----------- astrophot/models/core_model.py | 119 ++++------------------------ astrophot/models/model_object.py | 35 +++----- astrophot/param/__init__.py | 114 +++++++++++++++++++++++++- astrophot/utils/decorators.py | 46 ++++++++++- requirements.txt | 1 + 7 files changed, 194 insertions(+), 216 deletions(-) diff --git a/astrophot/models/_model_methods.py b/astrophot/models/_model_methods.py index d934c490..af46cdc3 100644 --- a/astrophot/models/_model_methods.py +++ b/astrophot/models/_model_methods.py @@ -31,52 +31,27 @@ @default_internal -def angular_metric(self, X, Y, image=None, parameters=None): +def angular_metric(self, X, Y, image=None): return torch.atan2(Y, X) @default_internal -def radius_metric(self, X, Y, image=None, parameters=None): - return torch.sqrt(X**2 + Y**2 + self.softening**2) - - -@classmethod -def build_parameter_specs(cls, user_specs=None): - parameter_specs = {} - for base in cls.__bases__: - try: - parameter_specs.update(base.build_parameter_specs()) - except AttributeError: - pass - parameter_specs.update(cls.parameter_specs) - parameter_specs = deepcopy(parameter_specs) - if isinstance(user_specs, dict): - for p in user_specs: - # If the user supplied a parameter object subclass, simply use that as is - if isinstance(user_specs[p], Parameter_Node): - parameter_specs[p] = user_specs[p] - elif isinstance( - user_specs[p], dict - ): # if the user supplied parameter specifications, update the defaults - parameter_specs[p].update(user_specs[p]) - else: - parameter_specs[p]["value"] = user_specs[p] +def radius_metric(self, X, Y, image=None): + return torch.sqrt(X**2 + Y**2) - return parameter_specs +def build_parameter_specs(self, kwargs): + parameter_specs = deepcopy(self._parameter_specs) -def build_parameters(self): - for p in self.__class__._parameter_order: - # skip if the parameter already exists - if p in self.parameters: + for p in kwargs: + if p not in self._parameter_specs: continue - # If a parameter object is provided, simply use as-is - if isinstance(self.parameter_specs[p], Parameter_Node): - self.parameters.link(self.parameter_specs[p].to()) - elif isinstance(self.parameter_specs[p], dict): - self.parameters.link(Parameter_Node(p, **self.parameter_specs[p])) + if isinstance(kwargs[p], dict): + parameter_specs[p].update(kwargs[p]) else: - raise ValueError(f"unrecognized parameter specification for {p}") + parameter_specs[p]["value"] = kwargs[p] + + return parameter_specs def _sample_init(self, image, parameters, center): diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index c00b3b26..3c0115e7 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -18,56 +18,10 @@ Rotate_Cartesian, ) from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..image import ( - Image_List, - Model_Image_List, - Target_Image_List, - Window_List, -) from ..param import Param_Unlock, Param_SoftLimits from .. import AP_config -# Target Selector Decorator -###################################################################### -def select_target(func): - @functools.wraps(func) - def targeted(self, target=None, **kwargs): - if target is None: - send_target = self.target - elif isinstance(target, Target_Image_List) and not isinstance(self.target, Image_List): - for sub_target in target: - if sub_target.identity == self.target.identity: - send_target = sub_target - break - else: - raise RuntimeError("{self.name} could not find matching target to initialize with") - else: - send_target = target - return func(self, target=send_target, **kwargs) - - return targeted - - -def select_sample(func): - @functools.wraps(func) - def targeted(self, image=None, **kwargs): - if isinstance(image, Model_Image_List) and not isinstance(self.target, Image_List): - for i, sub_image in enumerate(image): - if sub_image.target_identity == self.target.identity: - send_image = sub_image - if "window" in kwargs and isinstance(kwargs["window"], Window_List): - kwargs["window"] = kwargs["window"].window_list[i] - break - else: - raise RuntimeError(f"{self.name} could not find matching image to sample with") - else: - send_image = image - return func(self, image=send_image, **kwargs) - - return targeted - - def _sample_image(image, transform, metric, parameters, rad_bins=None): dat = image.data.detach().cpu().clone().numpy() # Fill masked pixels diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index 5f872381..137b675b 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -7,7 +7,7 @@ from ..utils.conversions.dict_to_hdf5 import dict_to_hdf5, hdf5_to_dict from ..utils.decorators import ignore_numpy_warnings, default_internal from ..image import Window, Target_Image, Target_Image_List -from ..param import Parameter_Node +from caskade import Module, forward from ._shared_methods import select_target, select_sample from .. import AP_config from ..errors import NameNotAllowed, InvalidTarget, UnrecognizedModel, InvalidWindow @@ -22,7 +22,7 @@ def all_subclasses(cls): ###################################################################### -class AstroPhot_Model(object): +class AstroPhot_Model(Module): """Core class for all AstroPhot models and model like objects. This class defines the signatures to interact with AstroPhot models both for users and internal functions. @@ -89,6 +89,7 @@ class defines the signatures to interact with AstroPhot models """ model_type = "model" + _parameter_specs = {} default_uncertainty = 1e-2 # During initialization, uncertainty will be assumed 1% of initial value if no uncertainty is given usable = False model_names = [] @@ -113,61 +114,20 @@ def __new__(cls, *, filename=None, model_type=None, **kwargs): return super().__new__(cls) def __init__(self, *, name=None, target=None, window=None, locked=False, **kwargs): + super().__init__() if not hasattr(self, "_window"): self._window = None if not hasattr(self, "_target"): self._target = None self.name = name - AP_config.ap_logger.debug("Creating model named: {self.name}") - self.parameters = Parameter_Node(self.name) + AP_config.ap_logger.debug(f"Creating model named: {self.name}") self.target = target self.window = window - self._locked = locked self.mask = kwargs.get("mask", None) - @property - def name(self): - """The name for this model as a string. The name should be unique - though this is not enforced here. The name should not contain - the `|` or `:` characters as these are reserved for internal - use. If one tries to set the name of a model as `None` (for - example by not providing a name for the model) then a new - unique name will be generated. The unique name is just the - model type for this model with an extra unique id appended to - the end in the format of `[#]` where `#` is a number that - increases until a unique name is found. - - """ - return self._name - - @name.setter - def name(self, name): - try: - if name == self.name: - return - except AttributeError: - pass - if name is None: - i = 0 - while True: - proposed_name = f"{self.model_type} [{i}]" - if proposed_name in AstroPhot_Model.model_names: - i += 1 - else: - name = proposed_name - break - if ":" in name or "|" in name: - raise NameNotAllowed( - "characters '|' and ':' are reserved for internal model operations please do not include these in a model name" - ) - self._name = name - AstroPhot_Model.model_names.append(name) - @torch.no_grad() - @ignore_numpy_warnings @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): + def initialize(self, target=None, **kwargs): """When this function finishes, all parameters should have numerical values (non None) that are reasonable estimates of the final values. @@ -188,7 +148,8 @@ def make_model_image(self, window: Optional[Window] = None): window = self.window & window return self.target[window].model_image() - def sample(self, image=None, window=None, parameters=None, *args, **kwargs): + @forward + def sample(self, image=None, window=None, *args, **kwargs): """Calling this function should fill the given image with values sampled from the given model. @@ -202,19 +163,14 @@ def fit_mask(self): """ return torch.zeros_like(self.target[self.window].mask) + @forward def negative_log_likelihood( self, - parameters=None, as_representation=False, ): """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. """ - if parameters is not None: - if as_representation: - self.parameters.vector_set_representation(parameters) - else: - self.parameters.vector_set_values(parameters) model = self.sample() data = self.target[self.window] @@ -240,16 +196,17 @@ def negative_log_likelihood( return chi2 + @forward def jacobian( self, - parameters=None, **kwargs, ): raise NotImplementedError("please use a subclass of AstroPhot_Model") @default_internal - def total_flux(self, parameters=None, window=None, image=None): - F = self(parameters=parameters, window=None, image=None) + @forward + def total_flux(self, window=None, image=None): + F = self(window=None, image=None) return torch.sum(F.data) @property @@ -301,36 +258,6 @@ def target(self, tar): raise InvalidTarget("AstroPhot_Model target must be a Target_Image instance.") self._target = tar - @property - def locked(self): - """Set when the model should remain fixed going forward. This model - will be bypassed when fitting parameters, however it will - still be sampled for generating the model image. - - Warning: - - This feature is not yet fully functional and should be avoided for now. It is included here for the sake of testing. - - """ - return self._locked - - @locked.setter - def locked(self, val): - self._locked = val - - @property - def parameter_order(self): - """Returns the model parameters in the order they are kept for - flattening, such as when evaluating the model with a tensor of - parameter values. - - """ - return tuple(P.name for P in self.parameters) - - def __str__(self): - """String representation for the model.""" - return self.parameters.__str__() - def __repr__(self): """Detailed string representation for the model.""" return yaml.dump(self.get_state(), indent=2) @@ -432,13 +359,8 @@ def List_Model_Names(cls, usable=None): def __eq__(self, other): return self is other - def __getitem__(self, key): - return self.parameters[key] - - def __contains__(self, key): - return self.parameters.__contains__(key) - def __del__(self): + super().__del__() try: i = AstroPhot_Model.model_names.index(self.name) AstroPhot_Model.model_names.pop(i) @@ -446,21 +368,12 @@ def __del__(self): pass @select_sample + @forward def __call__( self, image=None, - parameters=None, window=None, - as_representation=False, **kwargs, ): - if parameters is None: - parameters = self.parameters - elif isinstance(parameters, torch.Tensor): - if as_representation: - self.parameters.vector_set_representation(parameters) - else: - self.parameters.vector_set_values(parameters) - parameters = self.parameters - return self.sample(image=image, window=window, parameters=parameters, **kwargs) + return self.sample(image=image, window=window, **kwargs) diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 71850299..1b4573d9 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -12,10 +12,9 @@ Target_Image_List, Image, ) -from ..param import Parameter_Node, Param_Unlock, Param_SoftLimits +from caskade import Param, forward from ..utils.initialize import center_of_mass -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ._shared_methods import select_target +from ..utils.decorators import ignore_numpy_warnings, default_internal, select_target from .. import AP_config from ..errors import InvalidTarget @@ -54,11 +53,9 @@ class Component_Model(AstroPhot_Model): """ # Specifications for the model parameters including units, value, uncertainty, limits, locked, and cyclic - parameter_specs = { + _parameter_specs = AstroPhot_Model._parameter_specs | { "center": {"units": "arcsec", "uncertainty": [0.1, 0.1]}, } - # Fixed order of parameters for all methods that interact with the list of parameters - _parameter_order = ("center",) # Scope for PSF convolution psf_mode = "none" # none, full @@ -132,11 +129,8 @@ def __init__(self, *, name=None, **kwargs): self.load(kwargs["filename"], new_name=name) return - self.parameter_specs = self.build_parameter_specs(kwargs.get("parameters", None)) - with torch.no_grad(): - self.build_parameters() - if isinstance(kwargs.get("parameters", None), torch.Tensor): - self.parameters.value = kwargs["parameters"] + self.parameter_specs = self.build_parameter_specs(kwargs) + self.center = Param("center", **self.parameter_specs["center"]) def set_aux_psf(self, aux_psf, add_parameters=True): """Set the PSF for this model as an auxiliary psf model. This psf @@ -183,12 +177,11 @@ def psf(self, val): ###################################################################### @torch.no_grad() @ignore_numpy_warnings - @select_target @default_internal def initialize( self, - target: Optional["Target_Image"] = None, - parameters: Optional[Parameter_Node] = None, + target: Optional[Target_Image] = None, + window: Optional[Window] = None, **kwargs, ): """Determine initial values for the center coordinates. This is done @@ -200,21 +193,17 @@ def initialize( target (Optional[Target_Image]): A target image object to use as a reference when setting parameter values """ - super().initialize(target=target, parameters=parameters) + super().initialize(target=target, window=window) # Get the sub-image area corresponding to the model image - target_area = target[self.window] + target_area = target[window] # Use center of window if a center hasn't been set yet - if parameters["center"].value is None: - with ( - Param_Unlock(parameters["center"]), - Param_SoftLimits(parameters["center"]), - ): - parameters["center"].value = self.window.center + if self.center.value is None: + self.center.value = window.center else: return - if parameters["center"].locked: + if self.center.locked: return # Convert center coordinates to target area array indices diff --git a/astrophot/param/__init__.py b/astrophot/param/__init__.py index fd67d0b9..b3fe188e 100644 --- a/astrophot/param/__init__.py +++ b/astrophot/param/__init__.py @@ -1,3 +1,111 @@ -from .parameter import * -from .param_context import * -from .base import * +from typing import Union + +from caskade import Param, ActiveStateError +import torch +from torch import Tensor + + +class APParam(Param): + + def __init__(self, *args, uncertainty=None, default_value=None, locked=False, **kwargs): + super().__init__(*args, **kwargs) + self.uncertainty = uncertainty + self.default_value = default_value + self.locked = locked + + @property + def uncertainty(self): + if self._uncertainty is None: + try: + return torch.zeros_like(self.value) + except TypeError: + pass + return self._uncertainty + + @uncertainty.setter + def uncertainty(self, value): + if value is not None: + self._uncertainty = torch.as_tensor(value) + else: + self._uncertainty = None + + @property + def default_value(self): + return self._default_value + + @default_value.setter + def default_value(self, value): + if value is not None: + self._default_value = torch.as_tensor(value) + else: + self._default_value = None + + @property + def value(self) -> Union[Tensor, None]: + if self.pointer and self._value is None: + if self.active: + self._value = self._pointer_func(self) + else: + return self._pointer_func(self) + + if self._value is None: + return self._default_value + return self._value + + @property + def locked(self): + return self._locked + + @locked.setter + def locked( + self, value + ): # fixme still working on the logic here. Static should always be locked, but dynamic may go either way, I think? + self._locked = value + if self._locked and self._value is None and self._default_value is not None: + self.value = self.default_value + if not self._locked and self._value is not None: + self.default_value = self._value + + @value.setter + def value(self, value): + # While active no value can be set + if self.active: + raise ActiveStateError(f"Cannot set value of parameter {self.name} while active") + + # unlink if pointer to avoid floating references + if self.pointer: + for child in tuple(self.children.values()): + self.unlink(child) + + if value is None: + self._type = "dynamic" + self._pointer_func = None + self._value = None + elif isinstance(value, Param): + self._type = "pointer" + self.link(str(id(value)), value) + self._pointer_func = lambda p: p[str(id(value))].value + self._shape = None + self._value = None + elif callable(value): + self._type = "pointer" + self._shape = None + self._pointer_func = value + self._value = None + elif self.locked: + self._type = "static" + value = torch.as_tensor(value) + self.shape = value.shape + self._value = value + try: + self.valid = self._valid # re-check valid range + except AttributeError: + pass + else: + self._type = "dynamic" + self._pointer_func = None + self._value = None + if value is not None: + self.default_value = value + + self.update_graph() diff --git a/astrophot/utils/decorators.py b/astrophot/utils/decorators.py index b1596ce1..44002ff9 100644 --- a/astrophot/utils/decorators.py +++ b/astrophot/utils/decorators.py @@ -4,6 +4,13 @@ import numpy as np +from ..image import ( + Image_List, + Model_Image_List, + Target_Image_List, + Window_List, +) + def ignore_numpy_warnings(func): """This decorator is used to turn off numpy warnings. This should @@ -36,16 +43,47 @@ def default_internal(func): """ sig = inspect.signature(func) + handles = sig.parameters.keys() @wraps(func) def wrapper(self, *args, **kwargs): bound = sig.bind(self, *args, **kwargs) bound.apply_defaults() - if bound.arguments.get("image") is None: - bound.arguments["image"] = self.target - if bound.arguments.get("parameters") is None: - bound.arguments["parameters"] = self.parameters + if "window" in handles: + window = bound.arguments.get("window") + if window is None: + bound.arguments["window"] = self.window + + if "image" in handles: + image = bound.arguments.get("image") + if image is None: + bound.arguments["image"] = self.target + elif isinstance(image, Model_Image_List) and not isinstance(self.target, Image_List): + for i, sub_image in enumerate(image): + if sub_image.target_identity == self.target.identity: + bound.arguments["image"] = sub_image + if "window" in bound.arguments and isinstance( + bound.arguments["window"], Window_List + ): + bound.arguments["window"] = bound.arguments["window"].window_list[i] + break + else: + raise RuntimeError(f"{self.name} could not find matching image to sample with") + + if "target" in handles: + target = bound.arguments.get("target") + if target is None: + bound.arguments["target"] = self.target + elif isinstance(target, Target_Image_List) and not isinstance(self.target, Image_List): + for sub_target in target: + if sub_target.identity == self.target.identity: + bound.arguments["target"] = sub_target + break + else: + raise RuntimeError( + f"{self.name} could not find matching target to initialize with" + ) return func(*bound.args, **bound.kwargs) diff --git a/requirements.txt b/requirements.txt index efd85c11..1a4dfb24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ astropy>=5.3 +caskade>=0.6.0 h5py>=3.8.0 matplotlib>=3.7 numpy>=1.24.0,<2.0.0 From 5f8873d2863b9412778494ed59becd9580b4092f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Mar 2025 14:01:24 -0400 Subject: [PATCH 010/191] Fix forced setting of target for group models --- astrophot/models/group_model_object.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index f5f8755a..01bf77c4 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -310,8 +310,17 @@ def target(self, tar): self._target = tar if hasattr(self, "models"): - for model in self.models.values(): - model.target = tar + if not isinstance(tar, Image_List): + for model in self.models.values(): + if model.target is None: + model.target = tar + elif ( + isinstance(model.target, Image_List) + or model.target.identity != tar.identity + ): + AP_config.ap_logger.warning( + f"Group_Model target does not match model {model.name} target. This may cause issues. Use the same Target_Image object for all relevant models." + ) def get_state(self, save_params=True): """Returns a dictionary with information about the state of the model From 664c06aa95120c99e6feb03955acac1f894a61bd Mon Sep 17 00:00:00 2001 From: "Connor Stone, PhD" Date: Thu, 10 Apr 2025 16:56:33 -0700 Subject: [PATCH 011/191] fix: Plot target wasnt working for pure noise image, also added total magnitude and uncertainty (#260) --- astrophot/models/core_model.py | 26 +++++++++++- astrophot/models/moffat_model.py | 46 ++-------------------- astrophot/models/sersic_model.py | 27 +------------ astrophot/plots/image.py | 19 ++++----- astrophot/utils/conversions/functions.py | 36 ----------------- docs/source/tutorials/GettingStarted.ipynb | 23 +++++++++++ tests/test_fit.py | 8 +++- tests/test_utils.py | 15 ------- tests/utils.py | 1 + 9 files changed, 70 insertions(+), 131 deletions(-) diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index 2af4528e..00ce26a6 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -3,6 +3,7 @@ import torch import yaml +import numpy as np from ..utils.conversions.dict_to_hdf5 import dict_to_hdf5, hdf5_to_dict from ..utils.decorators import ignore_numpy_warnings, default_internal @@ -262,10 +263,31 @@ def jacobian( raise NotImplementedError("please use a subclass of AstroPhot_Model") @default_internal - def total_flux(self, parameters=None, window=None, image=None): - F = self(parameters=parameters, window=None, image=None) + def total_flux(self, parameters=None, window=None): + F = self(parameters=parameters, window=window, image=None) return torch.sum(F.data) + @default_internal + def total_flux_uncertainty(self, parameters=None, window=None): + current_state = parameters.vector_values() + jac = self.jacobian(parameters=current_state, window=window).flatten("data") + dF = torch.sum(jac, dim=0) # VJP for sum(total_flux) + current_uncertainty = self.parameters.vector_uncertainty() + return torch.sqrt(torch.sum((dF * current_uncertainty) ** 2)) + + @default_internal + def total_magnitude(self, parameters=None, window=None): + """Returns the total magnitude of the model in the given window.""" + F = self.total_flux(parameters=parameters, window=window) + return -2.5 * torch.log10(F) + self.target.header.zeropoint + + @default_internal + def total_magnitude_uncertainty(self, parameters=None, window=None): + """Returns the uncertainty in the total magnitude of the model in the given window.""" + F = self.total_flux(parameters=parameters, window=window) + dF = self.total_flux_uncertainty(parameters=parameters, window=window) + return torch.abs(2.5 * dF / (F * np.log(10))) + @property def window(self): """The window defines a region on the sky in which this model will be diff --git a/astrophot/models/moffat_model.py b/astrophot/models/moffat_model.py index 06961c8c..51122628 100644 --- a/astrophot/models/moffat_model.py +++ b/astrophot/models/moffat_model.py @@ -6,7 +6,7 @@ from ._shared_methods import parametric_initialize, select_target from ..utils.decorators import ignore_numpy_warnings, default_internal from ..utils.parametric_profiles import moffat_np -from ..utils.conversions.functions import moffat_I0_to_flux, general_uncertainty_prop +from ..utils.conversions.functions import moffat_I0_to_flux from ..param import Param_Unlock, Param_SoftLimits __all__ = ["Moffat_Galaxy", "Moffat_PSF"] @@ -57,7 +57,7 @@ def initialize(self, target=None, parameters=None, **kwargs): parametric_initialize(self, parameters, target, _wrap_moffat, ("n", "Rd", "I0"), _x0_func) @default_internal - def total_flux(self, parameters=None): + def total_flux(self, parameters=None, window=None): return moffat_I0_to_flux( 10 ** parameters["I0"].value, parameters["n"].value, @@ -65,26 +65,6 @@ def total_flux(self, parameters=None): parameters["q"].value, ) - @default_internal - def total_flux_uncertainty(self, parameters=None): - return general_uncertainty_prop( - ( - 10 ** parameters["I0"].value, - parameters["n"].value, - parameters["Rd"].value, - parameters["q"].value, - ), - ( - (10 ** parameters["I0"].value) - * parameters["I0"].uncertainty - * torch.log(10 * torch.ones_like(parameters["I0"].value)), - parameters["n"].uncertainty, - parameters["Rd"].uncertainty, - parameters["q"].uncertainty, - ), - moffat_I0_to_flux, - ) - from ._shared_methods import moffat_radial_model as radial_model @@ -128,7 +108,7 @@ def initialize(self, target=None, parameters=None, **kwargs): from ._shared_methods import moffat_radial_model as radial_model @default_internal - def total_flux(self, parameters=None): + def total_flux(self, parameters=None, window=None): return moffat_I0_to_flux( 10 ** parameters["I0"].value, parameters["n"].value, @@ -136,26 +116,6 @@ def total_flux(self, parameters=None): torch.ones_like(parameters["n"].value), ) - @default_internal - def total_flux_uncertainty(self, parameters=None): - return general_uncertainty_prop( - ( - 10 ** parameters["I0"].value, - parameters["n"].value, - parameters["Rd"].value, - torch.ones_like(parameters["n"].value), - ), - ( - (10 ** parameters["I0"].value) - * parameters["I0"].uncertainty - * torch.log(10 * torch.ones_like(parameters["I0"].value)), - parameters["n"].uncertainty, - parameters["Rd"].uncertainty, - torch.zeros_like(parameters["n"].value), - ), - moffat_I0_to_flux, - ) - from ._shared_methods import radial_evaluate_model as evaluate_model diff --git a/astrophot/models/sersic_model.py b/astrophot/models/sersic_model.py index 20d35658..3bd1ae90 100644 --- a/astrophot/models/sersic_model.py +++ b/astrophot/models/sersic_model.py @@ -14,10 +14,7 @@ ) from ..utils.decorators import ignore_numpy_warnings, default_internal from ..utils.parametric_profiles import sersic_np -from ..utils.conversions.functions import ( - sersic_Ie_to_flux_torch, - general_uncertainty_prop, -) +from ..utils.conversions.functions import sersic_Ie_to_flux_torch __all__ = [ @@ -79,7 +76,7 @@ def initialize(self, target=None, parameters=None, **kwargs): parametric_initialize(self, parameters, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) @default_internal - def total_flux(self, parameters=None): + def total_flux(self, parameters=None, window=None): return sersic_Ie_to_flux_torch( 10 ** parameters["Ie"].value, parameters["n"].value, @@ -87,26 +84,6 @@ def total_flux(self, parameters=None): parameters["q"].value, ) - @default_internal - def total_flux_uncertainty(self, parameters=None): - return general_uncertainty_prop( - ( - 10 ** parameters["Ie"].value, - parameters["n"].value, - parameters["Re"].value, - parameters["q"].value, - ), - ( - (10 ** parameters["Ie"].value) - * parameters["Ie"].uncertainty - * torch.log(10 * torch.ones_like(parameters["Ie"].value)), - parameters["n"].uncertainty, - parameters["Re"].uncertainty, - parameters["q"].uncertainty, - ), - sersic_Ie_to_flux_torch, - ) - def _integrate_reference(self, image_data, image_header, parameters): tot = self.total_flux(parameters) return tot / image_data.numel() diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index ec7d0f35..9a3cb89d 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -82,15 +82,16 @@ def target_image(fig, ax, target, window=None, **kwargs): vmin=np.nanmin(dat), ), ) - - im = ax.pcolormesh( - X, - Y, - np.ma.masked_where(dat < (sky + 3 * noise), dat), - cmap=cmap_grad, - norm=matplotlib.colors.LogNorm(), - clim=[sky + 3 * noise, None], - ) + pickhist = dat < (sky + 3 * noise) + if np.sum(~pickhist) > 5: # only draw log if multiple pixels above noise + im = ax.pcolormesh( + X, + Y, + np.ma.masked_where(pickhist, dat), + cmap=cmap_grad, + norm=matplotlib.colors.LogNorm(), + clim=[sky + 3 * noise, None], + ) ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") diff --git a/astrophot/utils/conversions/functions.py b/astrophot/utils/conversions/functions.py index e282bdcb..98540df4 100644 --- a/astrophot/utils/conversions/functions.py +++ b/astrophot/utils/conversions/functions.py @@ -235,39 +235,3 @@ def moffat_I0_to_flux(I0, n, rd, q): q: axis ratio """ return I0 * np.pi * rd**2 * q / (n - 1) - - -def general_uncertainty_prop( - param_tuple, # tuple of parameter values - param_err_tuple, # tuple of parameter uncertainties - forward, # forward function through which to get uncertainty -): - """Simple function to propagate uncertainty using the standard first - order error propagation method with autodiff derivatives. The encodes: - - .. math:: - - \\sigma_f^2 = \sum_i \\left(\\frac{df}{dx_i}\\sigma_i\\right)^2 - - where `i` indexes over all the parameters of the function `f` - - Args: - param_tuple (tuple): A tuple of the inputs to the function as pytorch tensors. - param_err_tuple (tuple): A tuple of uncertainties (sigma) for the input parameters. - forward (func): The function through which to propagate uncertainty, should be of the form: `f(*x) -> y` where `x` is the `param_tuple` as given and `y` is a scalar. - - """ - # Make a new set of parameters which track uncertainty - new_params = [] - for p in param_tuple: - newp = p.detach() - newp.requires_grad = True - new_params.append(newp) - # propagate forward and compute derivatives - f = forward(*new_params) - f.backward() - # Add all the error contributions in quadrature - x = torch.zeros_like(f) - for i in range(len(new_params)): - x = x + (new_params[i].grad * param_err_tuple[i]) ** 2 - return x.sqrt() diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index cd14a6fb..f2512b90 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -233,6 +233,29 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Record the total flux/magnitude\n", + "\n", + "Often the parameter of interest is the total flux or magnitude, even if this isn't one of the core parameters of the model, it can be computed. For Sersic and Moffat models with analytic total fluxes it will be integrated to infinity, for most other models it will simply be the total flux in the window." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\n", + " f\"Total Flux: {model2.total_flux().item():.1f} +- {model2.total_flux_uncertainty().item():.1f}\"\n", + ")\n", + "print(\n", + " f\"Total Magnitude: {model2.total_magnitude().item():.4f} +- {model2.total_magnitude_uncertainty().item():.4f}\"\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/tests/test_fit.py b/tests/test_fit.py index e16dbced..3f0f43f8 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -152,7 +152,13 @@ def test_sersic_fit_lm(self): 1, "LM should accurately recover parameters in simple cases", ) - cov = res.covariance_matrix + res.covariance_matrix + + # check for crash + mod.total_flux() + mod.total_flux_uncertainty() + mod.total_magnitude() + mod.total_magnitude_uncertainty() class TestGroupModelFits(unittest.TestCase): diff --git a/tests/test_utils.py b/tests/test_utils.py index b5c8a2fe..3ef9c9e6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -433,21 +433,6 @@ def test_conversion_functions(self): msg="Error computing inverse sersic function (torch)", ) - def test_general_derivative(self): - - res = ap.utils.conversions.functions.general_uncertainty_prop( - tuple(torch.tensor(a) for a in (1.0, 1.0, 1.0, 0.5)), - tuple(torch.tensor(a) for a in (0.1, 0.1, 0.1, 0.1)), - ap.utils.conversions.functions.sersic_Ie_to_flux_torch, - ) - - self.assertAlmostEqual( - res.detach().cpu().numpy(), - 1.8105, - 3, - "General uncertianty prop should compute uncertainty", - ) - class TestInterpolate(unittest.TestCase): def test_interpolate_functions(self): diff --git a/tests/utils.py b/tests/utils.py index 72109c94..2fd94f8f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -47,6 +47,7 @@ def make_basic_sersic( pixelscale=pixelscale, psf=ap.utils.initialize.gaussian_psf(2 / pixelscale, 11, pixelscale), mask=mask, + zeropoint=21.5, ) MODEL = ap.models.Sersic_Galaxy( From a16f975d0bb9138d99b5fafdb79dfc72ad8db85f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 15 Apr 2025 10:08:52 -0400 Subject: [PATCH 012/191] still moving to caskade --- astrophot/models/_model_methods.py | 27 +- astrophot/models/_shared_methods.py | 64 +- astrophot/models/core_model.py | 2 +- astrophot/models/galaxy_model_object.py | 39 +- astrophot/models/model_object.py | 30 +- astrophot/models/sersic_model.py | 43 +- astrophot/param/__init__.py | 111 ---- astrophot/param/base.py | 201 ------- astrophot/param/param_context.py | 102 ---- astrophot/param/parameter.py | 742 ------------------------ 10 files changed, 78 insertions(+), 1283 deletions(-) delete mode 100644 astrophot/param/__init__.py delete mode 100644 astrophot/param/base.py delete mode 100644 astrophot/param/param_context.py delete mode 100644 astrophot/param/parameter.py diff --git a/astrophot/models/_model_methods.py b/astrophot/models/_model_methods.py index af46cdc3..85945943 100644 --- a/astrophot/models/_model_methods.py +++ b/astrophot/models/_model_methods.py @@ -54,11 +54,11 @@ def build_parameter_specs(self, kwargs): return parameter_specs -def _sample_init(self, image, parameters, center): +def _sample_init(self, image, center): if self.sampling_mode == "midpoint": Coords = image.get_coordinate_meshgrid() X, Y = Coords - center[..., None, None] - mid = self.evaluate_model(X=X, Y=Y, image=image, parameters=parameters) + mid = self.evaluate_model(X=X, Y=Y, image=image) kernel = curvature_kernel(AP_config.ap_dtype, AP_config.ap_device) # convolve curvature kernel to numericall compute second derivative curvature = torch.nn.functional.pad( @@ -74,7 +74,7 @@ def _sample_init(self, image, parameters, center): elif self.sampling_mode == "simpsons": Coords = image.get_coordinate_simps_meshgrid() X, Y = Coords - center[..., None, None] - dens = self.evaluate_model(X=X, Y=Y, image=image, parameters=parameters) + dens = self.evaluate_model(X=X, Y=Y, image=image) kernel = simpsons_kernel(dtype=AP_config.ap_dtype, device=AP_config.ap_device) # midpoint is just every other sample in the simpsons grid mid = dens[1::2, 1::2] @@ -91,7 +91,6 @@ def _sample_init(self, image, parameters, center): Y=Y, image_header=image.header, eval_brightness=self.evaluate_model, - eval_parameters=parameters, dtype=AP_config.ap_dtype, device=AP_config.ap_device, quad_level=quad_level, @@ -100,7 +99,7 @@ def _sample_init(self, image, parameters, center): elif self.sampling_mode == "trapezoid": Coords = image.get_coordinate_corner_meshgrid() X, Y = Coords - center[..., None, None] - dens = self.evaluate_model(X=X, Y=Y, image=image, parameters=parameters) + dens = self.evaluate_model(X=X, Y=Y, image=image) kernel = ( torch.ones((1, 1, 2, 2), dtype=AP_config.ap_dtype, device=AP_config.ap_device) / 4.0 ) @@ -123,19 +122,17 @@ def _sample_init(self, image, parameters, center): ) -def _integrate_reference(self, image_data, image_header, parameters): +def _integrate_reference(self, image_data, image_header): return torch.sum(image_data) / image_data.numel() -def _sample_integrate(self, deep, reference, image, parameters, center): +def _sample_integrate(self, deep, reference, image, center): if self.integrate_mode == "none": pass elif self.integrate_mode == "threshold": Coords = image.get_coordinate_meshgrid() X, Y = Coords - center[..., None, None] - ref = self._integrate_reference( - deep, image.header, parameters - ) # fixme, error can be over 100% on initial sampling reference is invalid + ref = self._integrate_reference(deep, image.header) error = torch.abs((deep - reference)) select = error > (self.sampling_tolerance * ref) intdeep = grid_integrate( @@ -143,7 +140,6 @@ def _sample_integrate(self, deep, reference, image, parameters, center): Y=Y[select], image_header=image.header, eval_brightness=self.evaluate_model, - eval_parameters=parameters, dtype=AP_config.ap_dtype, device=AP_config.ap_device, quad_level=self.integrate_quad_level, @@ -233,9 +229,9 @@ def _sample_convolve(self, image, shift, psf, shift_method="bilinear"): @torch.no_grad() +@forward def jacobian( self, - parameters: Optional[torch.Tensor] = None, as_representation: bool = False, window: Optional[Window] = None, pass_jacobian: Optional[Jacobian_Image] = None, @@ -278,11 +274,6 @@ def jacobian( return self.target[window].jacobian_image() # Set the parameters if provided and check the size of the parameter list - if parameters is not None: - if as_representation: - self.parameters.vector_set_representation(parameters) - else: - self.parameters.vector_set_values(parameters) if torch.sum(self.parameters.vector_mask()) > self.jacobian_chunksize: return self._chunk_jacobian( as_representation=as_representation, @@ -305,7 +296,7 @@ def jacobian( window=window, ).data, ( - self.parameters.vector_representation().detach() + self.parameters.vector_representation().detach() # need valid context if as_representation else self.parameters.vector_values().detach() ), diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 3c0115e7..005bc07b 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -4,6 +4,7 @@ import numpy as np import torch from scipy.optimize import minimize +from caskade import forward from ..utils.initialize import isophotes from ..utils.parametric_profiles import ( @@ -18,11 +19,10 @@ Rotate_Cartesian, ) from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..param import Param_Unlock, Param_SoftLimits from .. import AP_config -def _sample_image(image, transform, metric, parameters, rad_bins=None): +def _sample_image(image, transform, metric, center, rad_bins=None): dat = image.data.detach().cpu().clone().numpy() # Fill masked pixels if image.has_mask: @@ -33,9 +33,9 @@ def _sample_image(image, transform, metric, parameters, rad_bins=None): dat -= np.median(edge) # Get the radius of each pixel relative to object center Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - X, Y = transform(X, Y, image, parameters) - R = metric(X, Y, image, parameters).detach().cpu().numpy().flatten() + X, Y = Coords - center[..., None, None] + X, Y = transform(X, Y, image) + R = metric(X, Y, image).detach().cpu().numpy().flatten() # Bin fluxes by radius if rad_bins is None: @@ -74,21 +74,19 @@ def _sample_image(image, transform, metric, parameters, rad_bins=None): ###################################################################### @torch.no_grad() @ignore_numpy_warnings -def parametric_initialize( - model, parameters, target, prof_func, params, x0_func, force_uncertainty=None -): - if all(list(parameters[param].value is not None for param in params)): +def parametric_initialize(model, target, prof_func, params, x0_func, force_uncertainty=None): + if all(list(model[param].value is not None for param in params)): return # Get the sub-image area corresponding to the model image target_area = target[model.window] R, I, S = _sample_image( - target_area, model.transform_coordinates, model.radius_metric, parameters + target_area, model.transform_coordinates, model.radius_metric, model.center.value ) x0 = list(x0_func(model, R, I)) for i, param in enumerate(params): - x0[i] = x0[i] if parameters[param].value is None else parameters[param].value.item() + x0[i] = x0[i] if model[param].value is None else model[param].value.item() def optim(x, r, f): residual = (f - np.log10(prof_func(r, *x))) ** 2 @@ -107,15 +105,14 @@ def optim(x, r, f): N = np.random.randint(0, len(R), len(R)) reses.append(minimize(optim, x0=x0, args=(R[N], I[N]), method="Nelder-Mead")) for param, resx, x0x in zip(params, res.x, x0): - with Param_Unlock(parameters[param]), Param_SoftLimits(parameters[param]): - if parameters[param].value is None: - parameters[param].value = resx if res.success else x0x - if force_uncertainty is None and parameters[param].uncertainty is None: - parameters[param].uncertainty = np.std( - list(subres.x[params.index(param)] for subres in reses) - ) - elif force_uncertainty is not None: - parameters[param].uncertainty = force_uncertainty[params.index(param)] + if model[param].value is None: + model[param].value = resx if res.success else x0x + if force_uncertainty is None and model[param].uncertainty is None: + model[param].uncertainty = np.std( + list(subres.x[params.index(param)] for subres in reses) + ) + elif force_uncertainty is not None: + model[param].uncertainty = force_uncertainty[params.index(param)] @torch.no_grad() @@ -237,11 +234,14 @@ def radial_evaluate_model(self, X=None, Y=None, image=None, parameters=None): ) +@forward @default_internal -def transformed_evaluate_model(self, X=None, Y=None, image=None, parameters=None, **kwargs): +def transformed_evaluate_model( + self, X=None, Y=None, image=None, parameters=None, center=None, **kwargs +): if X is None or Y is None: Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] + X, Y = Coords - center[..., None, None] X, Y = self.transform_coordinates(X, Y, image, parameters) return self.radial_model( self.radius_metric(X, Y, image=image, parameters=parameters), @@ -252,13 +252,11 @@ def transformed_evaluate_model(self, X=None, Y=None, image=None, parameters=None # Transform Coordinates ###################################################################### +@forward @default_internal -def inclined_transform_coordinates(self, X, Y, image=None, parameters=None): - X, Y = Rotate_Cartesian(-(parameters["PA"].value - image.north), X, Y) - return ( - X, - Y / parameters["q"].value, - ) +def inclined_transform_coordinates(self, X, Y, image=None, PA=None, q=None): + X, Y = Rotate_Cartesian(-(PA - image.north), X, Y) + return X, Y / q # Exponential @@ -283,14 +281,10 @@ def exponential_iradial_model(self, i, R, image=None, parameters=None): # Sersic ###################################################################### +@forward @default_internal -def sersic_radial_model(self, R, image=None, parameters=None): - return sersic_torch( - R, - parameters["n"].value, - parameters["Re"].value, - image.pixel_area * 10 ** parameters["Ie"].value, - ) +def sersic_radial_model(self, R, image=None, n=None, Re=None, Ie=None): + return sersic_torch(R, n, Re, image.pixel_area * 10**Ie) @default_internal diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index 137b675b..98300709 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -367,8 +367,8 @@ def __del__(self): except: pass - @select_sample @forward + @select_sample def __call__( self, image=None, diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index 7bad13b8..cf3f7272 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -3,6 +3,7 @@ import torch import numpy as np from scipy.stats import iqr +from caskade import Param, forward from ..utils.initialize import isophotes from ..utils.decorators import ignore_numpy_warnings, default_internal @@ -10,7 +11,6 @@ from ..utils.conversions.coordinates import ( Rotate_Cartesian, ) -from ..param import Param_Unlock, Param_SoftLimits, Parameter_Node from .model_object import Component_Model from ._shared_methods import select_target @@ -50,17 +50,16 @@ class Galaxy_Model(Component_Model): "uncertainty": 0.06, }, } - _parameter_order = Component_Model._parameter_order + ("q", "PA") usable = False @torch.no_grad() @ignore_numpy_warnings @select_target @default_internal - def initialize(self, target=None, parameters: Optional[Parameter_Node] = None, **kwargs): - super().initialize(target=target, parameters=parameters) + def initialize(self, target=None, **kwargs): + super().initialize(target=target) - if not (parameters["PA"].value is None or parameters["q"].value is None): + if not (self.PA.value is None or self.q.value is None): return target_area = target[self.window] target_dat = target_area.data.detach().cpu().numpy() @@ -77,12 +76,12 @@ def initialize(self, target=None, parameters: Optional[Parameter_Node] = None, * ) edge_average = np.nanmedian(edge) edge_scatter = iqr(edge[np.isfinite(edge)], rng=(16, 84)) / 2 - icenter = target_area.plane_to_pixel(parameters["center"].value) + icenter = target_area.plane_to_pixel(self.center.value) - if parameters["PA"].value is None: + if self.PA.value is None: weights = target_dat - edge_average Coords = target_area.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] + X, Y = Coords - self.center.value[..., None, None] X, Y = X.detach().cpu().numpy(), Y.detach().cpu().numpy() if target_area.has_mask: seg = np.logical_not(target_area.mask.detach().cpu().numpy()) @@ -90,27 +89,23 @@ def initialize(self, target=None, parameters: Optional[Parameter_Node] = None, * else: PA = Angle_COM_PA(weights, X, Y) - with Param_Unlock(parameters["PA"]), Param_SoftLimits(parameters["PA"]): - parameters["PA"].value = (PA + target_area.north) % np.pi - if parameters["PA"].uncertainty is None: - parameters["PA"].uncertainty = (5 * np.pi / 180) * torch.ones_like( - parameters["PA"].value - ) # default uncertainty of 5 degrees is assumed - if parameters["q"].value is None: + self.PA.value = (PA + target_area.north) % np.pi + if self.PA.uncertainty is None: + self.PA.uncertainty = (5 * np.pi / 180) * torch.ones_like( + self.PA.value + ) # default uncertainty of 5 degrees is assumed + if self.q.value is None: q_samples = np.linspace(0.2, 0.9, 15) iso_info = isophotes( target_area.data.detach().cpu().numpy() - edge_average, (icenter[1].detach().cpu().item(), icenter[0].detach().cpu().item()), threshold=3 * edge_scatter, - pa=(parameters["PA"].value - target.north).detach().cpu().item(), + pa=(self.PA.value - target.north).detach().cpu().item(), q=q_samples, ) - with Param_Unlock(parameters["q"]), Param_SoftLimits(parameters["q"]): - parameters["q"].value = q_samples[ - np.argmin(list(iso["amplitude2"] for iso in iso_info)) - ] - if parameters["q"].uncertainty is None: - parameters["q"].uncertainty = parameters["q"].value * self.default_uncertainty + self.q.value = q_samples[np.argmin(list(iso["amplitude2"] for iso in iso_info))] + if self.q.uncertainty is None: + self.q.uncertainty = self.q.value * self.default_uncertainty from ._shared_methods import inclined_transform_coordinates as transform_coordinates from ._shared_methods import transformed_evaluate_model as evaluate_model diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 1b4573d9..580f51c7 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -130,7 +130,8 @@ def __init__(self, *, name=None, **kwargs): return self.parameter_specs = self.build_parameter_specs(kwargs) - self.center = Param("center", **self.parameter_specs["center"]) + for key in self.parameter_specs: + setattr(self, key, Param(key, **self.parameter_specs[key])) def set_aux_psf(self, aux_psf, add_parameters=True): """Set the PSF for this model as an auxiliary psf model. This psf @@ -207,7 +208,7 @@ def initialize( return # Convert center coordinates to target area array indices - init_icenter = target_area.plane_to_pixel(parameters["center"].value) + init_icenter = target_area.plane_to_pixel(self.center.value) # Compute center of mass in window COM = center_of_mass( @@ -227,16 +228,17 @@ def initialize( ) # Set the new coordinates as the model center - parameters["center"].value = COM_center + self.center.value = COM_center # Fit loop functions ###################################################################### + @forward def evaluate_model( self, X: Optional[torch.Tensor] = None, Y: Optional[torch.Tensor] = None, image: Optional[Image] = None, - parameters: Parameter_Node = None, + center=None, **kwargs, ): """Evaluate the model on every pixel in the given image. The @@ -249,14 +251,15 @@ def evaluate_model( """ if X is None or Y is None: Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] + X, Y = Coords - center[..., None, None] return torch.zeros_like(X) # do nothing in base model + @forward def sample( self, image: Optional[Image] = None, window: Optional[Window] = None, - parameters: Optional[Parameter_Node] = None, + center=None, ): """Evaluate the model on the space covered by an image object. This function properly calls integration methods and PSF @@ -314,7 +317,7 @@ def sample( working_image = Model_Image(window=working_window) # Sub pixel shift to align the model with the center of a pixel if self.psf_subpixel_shift != "none": - pixel_center = working_image.plane_to_pixel(parameters["center"].value) + pixel_center = working_image.plane_to_pixel(center) center_shift = pixel_center - torch.round(pixel_center) working_image.header.pixel_shift(center_shift) else: @@ -323,13 +326,10 @@ def sample( # Evaluate the model at the current resolution reference, deep = self._sample_init( image=working_image, - parameters=parameters, - center=parameters["center"].value, + center=center, ) # If needed, super-resolve the image in areas of high curvature so pixels are properly sampled - deep = self._sample_integrate( - deep, reference, working_image, parameters, parameters["center"].value - ) + deep = self._sample_integrate(deep, reference, working_image, parameters, center) # update the image with the integrated pixels working_image.data += deep @@ -349,8 +349,7 @@ def sample( # Evaluate the model on the image reference, deep = self._sample_init( image=working_image, - parameters=parameters, - center=parameters["center"].value, + center=center, ) # Super-resolve and integrate where needed deep = self._sample_integrate( @@ -358,7 +357,7 @@ def sample( reference, working_image, parameters, - center=parameters["center"].value, + center=center, ) # Add the sampled/integrated pixels to the requested image working_image.data += deep @@ -433,7 +432,6 @@ def get_state(self, save_params=True): from ._model_methods import _integrate_reference from ._model_methods import _shift_psf from ._model_methods import build_parameter_specs - from ._model_methods import build_parameters from ._model_methods import jacobian from ._model_methods import _chunk_jacobian from ._model_methods import _chunk_image_jacobian diff --git a/astrophot/models/sersic_model.py b/astrophot/models/sersic_model.py index 20d35658..c67e47a6 100644 --- a/astrophot/models/sersic_model.py +++ b/astrophot/models/sersic_model.py @@ -1,4 +1,5 @@ import torch +from caskade import Param, forward from .galaxy_model_object import Galaxy_Model from .warp_model import Warp_Galaxy @@ -14,10 +15,7 @@ ) from ..utils.decorators import ignore_numpy_warnings, default_internal from ..utils.parametric_profiles import sersic_np -from ..utils.conversions.functions import ( - sersic_Ie_to_flux_torch, - general_uncertainty_prop, -) +from ..utils.conversions.functions import sersic_Ie_to_flux_torch __all__ = [ @@ -66,46 +64,21 @@ class Sersic_Galaxy(Galaxy_Model): "Re": {"units": "arcsec", "limits": (0, None)}, "Ie": {"units": "log10(flux/arcsec^2)"}, } - _parameter_order = Galaxy_Model._parameter_order + ("n", "Re", "Ie") usable = True @torch.no_grad() @ignore_numpy_warnings @select_target @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) + def initialize(self, target=None, **kwargs): + super().initialize(target=target) - @default_internal - def total_flux(self, parameters=None): - return sersic_Ie_to_flux_torch( - 10 ** parameters["Ie"].value, - parameters["n"].value, - parameters["Re"].value, - parameters["q"].value, - ) + parametric_initialize(self, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) + @forward @default_internal - def total_flux_uncertainty(self, parameters=None): - return general_uncertainty_prop( - ( - 10 ** parameters["Ie"].value, - parameters["n"].value, - parameters["Re"].value, - parameters["q"].value, - ), - ( - (10 ** parameters["Ie"].value) - * parameters["Ie"].uncertainty - * torch.log(10 * torch.ones_like(parameters["Ie"].value)), - parameters["n"].uncertainty, - parameters["Re"].uncertainty, - parameters["q"].uncertainty, - ), - sersic_Ie_to_flux_torch, - ) + def total_flux(self, Ie, n, Re, q): + return sersic_Ie_to_flux_torch(10**Ie, n, Re, q) def _integrate_reference(self, image_data, image_header, parameters): tot = self.total_flux(parameters) diff --git a/astrophot/param/__init__.py b/astrophot/param/__init__.py deleted file mode 100644 index b3fe188e..00000000 --- a/astrophot/param/__init__.py +++ /dev/null @@ -1,111 +0,0 @@ -from typing import Union - -from caskade import Param, ActiveStateError -import torch -from torch import Tensor - - -class APParam(Param): - - def __init__(self, *args, uncertainty=None, default_value=None, locked=False, **kwargs): - super().__init__(*args, **kwargs) - self.uncertainty = uncertainty - self.default_value = default_value - self.locked = locked - - @property - def uncertainty(self): - if self._uncertainty is None: - try: - return torch.zeros_like(self.value) - except TypeError: - pass - return self._uncertainty - - @uncertainty.setter - def uncertainty(self, value): - if value is not None: - self._uncertainty = torch.as_tensor(value) - else: - self._uncertainty = None - - @property - def default_value(self): - return self._default_value - - @default_value.setter - def default_value(self, value): - if value is not None: - self._default_value = torch.as_tensor(value) - else: - self._default_value = None - - @property - def value(self) -> Union[Tensor, None]: - if self.pointer and self._value is None: - if self.active: - self._value = self._pointer_func(self) - else: - return self._pointer_func(self) - - if self._value is None: - return self._default_value - return self._value - - @property - def locked(self): - return self._locked - - @locked.setter - def locked( - self, value - ): # fixme still working on the logic here. Static should always be locked, but dynamic may go either way, I think? - self._locked = value - if self._locked and self._value is None and self._default_value is not None: - self.value = self.default_value - if not self._locked and self._value is not None: - self.default_value = self._value - - @value.setter - def value(self, value): - # While active no value can be set - if self.active: - raise ActiveStateError(f"Cannot set value of parameter {self.name} while active") - - # unlink if pointer to avoid floating references - if self.pointer: - for child in tuple(self.children.values()): - self.unlink(child) - - if value is None: - self._type = "dynamic" - self._pointer_func = None - self._value = None - elif isinstance(value, Param): - self._type = "pointer" - self.link(str(id(value)), value) - self._pointer_func = lambda p: p[str(id(value))].value - self._shape = None - self._value = None - elif callable(value): - self._type = "pointer" - self._shape = None - self._pointer_func = value - self._value = None - elif self.locked: - self._type = "static" - value = torch.as_tensor(value) - self.shape = value.shape - self._value = value - try: - self.valid = self._valid # re-check valid range - except AttributeError: - pass - else: - self._type = "dynamic" - self._pointer_func = None - self._value = None - if value is not None: - self.default_value = value - - self.update_graph() diff --git a/astrophot/param/base.py b/astrophot/param/base.py deleted file mode 100644 index 3bea49a7..00000000 --- a/astrophot/param/base.py +++ /dev/null @@ -1,201 +0,0 @@ -from collections import OrderedDict -from abc import ABC, abstractmethod -from ..errors import InvalidParameter - -__all__ = ["Node"] - - -class Node(ABC): - """Base node object in the Directed Acyclic Graph (DAG). - - The base Node object handles storing the DAG nodes and links - between them. An important part of the DAG system is to be able to - find all the leaf nodes, which is done using the `flat` function. - - Args: - name (str): The name of the node, this should identify it uniquely in the local context it will be used in. - locked (bool): Records if the node is locked, this is relevant for some other operations which only act on unlocked nodes. - link (tuple[Node]): A tuple of node objects which this node will be linked to on initialization. - - """ - - global_unlock = False - - def __init__(self, name, **kwargs): - if ":" in name: - raise ValueError(f"Node names must not have ':' character. Cannot use name: {name}") - self.name = name - self.nodes = OrderedDict() - if "state" in kwargs: - self.set_state(kwargs["state"]) - return - if "link" in kwargs: - self.link(*kwargs["link"]) - self.locked = kwargs.get("locked", False) - - def link(self, *nodes): - """Creates a directed link from the current node to the provided - node(s) in the input. This function will also check that the - linked node does not exist higher up in the DAG to the current - node, if that is the case then a cycle has formed which breaks - the DAG structure and could cause problems. An error will be - thrown in this case. - - The linked node is added to a ``nodes`` dictionary that each - node stores. This makes it easy to check which nodes are - linked to each other. - - """ - for node in nodes: - for subnode_id in node.flat(include_locked=True, include_links=True).keys(): - if self.identity == subnode_id: - raise InvalidParameter( - "Parameter structure must be Directed Acyclic Graph! Adding this node would create a cycle" - ) - self.nodes[node.name] = node - - def unlink(self, *nodes): - """Undoes the linking of two nodes. Note that this could sever the - connection of many nodes to each other if the current node was - the only link between two branches. - - """ - for node in nodes: - del self.nodes[node.name] - - def dump(self): - """Simply unlinks all nodes that the current node is linked with.""" - self.unlink(*self.nodes.values()) - - @property - def leaf(self): - """Returns True when the current node is a leaf node.""" - return len(self.nodes) == 0 - - @property - def branch(self): - """Returns True when the current node is a branch node (not a leaf node, is linked to more nodes).""" - return len(self.nodes) > 0 - - def __getitem__(self, key): - """Used to get a node from the DAG relative to the current node. It - is possible to collect nodes from deeper in the DAG by - separating the names of the nodes along the path with a colon - (:). For example:: - - first_node["second_node:third_node"] - - returns a node that is actually linked to ``second_node`` - without needing to first get ``second_node`` then call - ``second_node['third_node']``. - - """ - if key == self.name: - return self - if key in self.nodes: - return self.nodes[key] - if isinstance(key, str) and ":" in key: - base, stem = key.split(":", 1) - return self.nodes[base][stem] - if isinstance(key, int): - for node in self.nodes.values(): - if key == node.identity: - return node - raise KeyError(f"Unrecognized key for '{self.name}': {key}") - - def __contains__(self, key): - """Check if a node has a link directly to another node. A check like - ``"second_node" in first_node`` would return true only if - ``first_node`` was linked to ``second_node``. - - """ - return key in self.nodes - - def __eq__(self, other): - """Equality check for nodes only returns true if they are in fact the - same node. - - """ - return self is other - - @property - def identity(self): - """A read only property of the node which does not change over it's - lifetime that uniquely identifies it relative to other - nodes. By default this just uses the ``id(self)`` though for - the purpose of saving/loading it may not always be this way. - - """ - try: - return self._identity - except AttributeError: - return id(self) - - def get_state(self): - """Returns a dictionary with state information about this node. From - that dictionary the node can reconstruct itself, or form - another node which is a copy of this one. - - """ - state = { - "name": self.name, - "identity": self.identity, - } - if self.locked: - state["locked"] = self.locked - if len(self.nodes) > 0: - state["nodes"] = list(node.get_state() for node in self.nodes.values()) - return state - - def set_state(self, state): - """Used to set the state of the node for the purpose of - loading/copying. This uses the dictionary produced by - ``get_state`` to re-create itself. - - """ - self.name = state["name"] - self._identity = state["identity"] - if "nodes" in state: - for node in state["nodes"]: - self.link(self.__class__(name=node["name"], state=node)) - self.locked = state.get("locked", False) - - def __iter__(self): - return filter(lambda n: not n.locked, self.nodes.values()) - - @property - @abstractmethod - def value(self): ... - - def flat(self, include_locked=True, include_links=False): - """Searches the DAG from this node and collects other nodes in the - graph. By default it will include all leaf nodes only, however - it can be directed to only collect leaf nodes that are not - locked, it can also be directed to collect all nodes instead - of just leaf nodes. - - """ - flat = OrderedDict() - if self.leaf and self.value is not None: - if (not self.locked) or include_locked or Node.global_unlock: - flat[self.identity] = self - for node in self.nodes.values(): - if node.locked and not (include_locked or Node.global_unlock): - continue - if node.leaf and node.value is not None: - flat[node.identity] = node - else: - if include_links and ((not node.locked) or include_locked or Node.global_unlock): - flat[node.identity] = node - flat.update(node.flat(include_locked)) - return flat - - def __str__(self): - return f"Node: {self.name}" - - def __repr__(self): - return ( - f"Node: {self.name} " - + ("locked" if self.locked else "unlocked") - + ("" if self.leaf else " {" + ";".join(repr(node) for node in self.nodes) + "}") - ) diff --git a/astrophot/param/param_context.py b/astrophot/param/param_context.py deleted file mode 100644 index 6a217486..00000000 --- a/astrophot/param/param_context.py +++ /dev/null @@ -1,102 +0,0 @@ -from .base import Node - -__all__ = ("Param_Unlock", "Param_SoftLimits", "Param_Mask") - - -class Param_Unlock: - """Temporarily unlock a parameter. - - Context manager to unlock a parameter temporarily. Inside the - context, the parameter will behave as unlocked regardless of its - initial condition. Upon exiting the context, the parameter will - return to its previous locked state regardless of any changes - made by the user to the lock state. - - """ - - def __init__(self, param=None): - self.param = param - - def __enter__(self): - if self.param is None: - Node.global_unlock = True - else: - self.original_locked = self.param.locked - self.param.locked = False - - def __exit__(self, *args, **kwargs): - if self.param is None: - Node.global_unlock = False - else: - self.param.locked = self.original_locked - - -class Param_SoftLimits: - """Temporarily allow writing parameter values outside limits. - - Values outside the limits will be quietly (no error/warning - raised) shifted until they are within the boundaries of the - parameter limits. Since the limits are non-inclusive, the soft - limits will actually move a parameter by 0.001 into the parameter - range. For example the axis ratio ``q`` has limits from (0,1) so - if one were to write: ``q.value = 2`` then the actual value that - gets written would be ``0.999``. - - Cyclic parameters are not affected by this, any value outside the - range is always (Param_SoftLimits context or not) wrapped back - into the range using modulo arithmetic. - - """ - - def __init__(self, param): - self.param = param - - def __enter__(self, *args, **kwargs): - self.original_setter = self.param._set_val_self - self.param._set_val_self = self.param._soft_set_val_self - - def __exit__(self, *args, **kwargs): - self.param._set_val_self = self.original_setter - - -class Param_Mask: - """Temporarily mask parameters. - - Select a subset of parameters to be used through the "vector" - interface of the DAG. The context is initialized with a - Parameter_Node object (``P``) and a torch tensor (``M``) where the - size of the mask should be equal to the current vector - representation of the parameter (``M.numel() == - P.vector_values().numel()``). The mask tensor should be of - ``torch.bool`` dtype where ``True`` indicates to keep using that - parameter and ``False`` indicates to hide that parameter value. - - Note that ``Param_Mask`` contexts can be nested and will behave - accordingly (the mask tensor will need to match the vector size - within the previous context). As an example, imagine there is a - parameter node ``P`` which has five sub-nodes each with a single - value, one could nest contexts like:: - - M1 = torch.tensor((1,1,0,1,0), dtype = torch.bool) - with Param_Mask(P, M1): - # Now P behaves as if it only has 3 elements - M2 = torch.tensor([0,1,1], dtype = torch.bool) - with Param_Mask(P, M2): - # Now P behaves as if it only has 2 elements - P.vector_values() # returns tensor with 2 elements - - """ - - def __init__(self, param, new_mask): - self.param = param - self.new_mask = new_mask - - def __enter__(self): - - self.old_mask = self.param.vector_mask() - self.mask = self.param.vector_mask() - self.mask[self.mask.clone()] = self.new_mask - self.param.vector_set_mask(self.mask) - - def __exit__(self, *args, **kwargs): - self.param.vector_set_mask(self.old_mask) diff --git a/astrophot/param/parameter.py b/astrophot/param/parameter.py deleted file mode 100644 index 7c772ab0..00000000 --- a/astrophot/param/parameter.py +++ /dev/null @@ -1,742 +0,0 @@ -from types import FunctionType - -import torch -import numpy as np - -from ..utils.conversions.optimization import ( - boundaries, - inv_boundaries, - cyclic_boundaries, -) -from .. import AP_config -from .base import Node -from ..errors import InvalidParameter - -__all__ = ["Parameter_Node"] - - -class Parameter_Node(Node): - """A node representing parameters and their relative structure. - - The Parameter_Node object stores all information relevant for the - parameters of a model. At a high level the Parameter_Node - accomplishes two tasks. The first task is to store the actual - parameter values, these are represented as pytorch tensors which - can have any shape; these are leaf nodes. The second task is to - store the relationship between parameters in a graph structure; - these are branch nodes. The two tasks are handled by the same type - of object since there is some overlap between them where a branch - node acts like a leaf node in certain contexts. - - There are various quantities that a Parameter_Node tracks which - can be provided as arguments or updated later. - - Args: - value: The value of a node represents the tensor which will be used by models to compute their projection into the pixels of an image. These can be quite complex, see further down for more details. - cyclic (bool): Records if the value of a node is cyclic, meaning that if it is updated outside it's limits it should be wrapped back into the limits. - limits (Tuple[Tensor or None, Tensor or None]): Tracks if a parameter has constraints on the range of values it can take. The first element is the lower limit, the second element is the upper limit. The two elements should either be None (no limit) or tensors with the same shape as the value. - units (str): The units of the parameter value. - uncertainty (Tensor or None): represents the uncertainty of the parameter value. This should be None (no uncertainty) or a Tensor with the same shape as the value. - prof (Tensor or None): This is a profile of values which has no explicit meaning, but can be used to store information which should be kept alongside the value. For example in a spline model the position of the spline points may be a ``prof`` while the flux at each node is the value to be optimized. - shape (Tuple or None): Can be used to set the shape of the value (number of elements/dimensions). If not provided then the shape will be set by the first time a value is given. Once a shape has been set, if a value is given which cannot be coerced into that shape, then an error will be thrown. - - The ``value`` of a Parameter_Node is somewhat complicated, there - are a number of states it can take on. The most straightforward is - just a Tensor, if a Tensor (or just an iterable like a list or - numpy.ndarray) is provided then the node is required to be a leaf - node and it will store the value to be accessed later by other - parts of AstroPhot. Another option is to set the value as another - node (they will automatically be linked), in this case the node's - ``value`` is just a wrapper to call for the ``value`` of the - linked node. Finally, the value may be a function which allows for - arbitrarily complex values to be computed from other node's - values. The function must take as an argument the current - Parameter_Node instance and return a Tensor. Here are some - examples of the various ways of interacting with the ``value`` for a hypothetical parameter ``P``:: - - P.value = 1. # Will create a tensor with value 1. - P.value = P2 # calling P.value will actually call P2.value - def compute_value(param): - return param["P2"].value**2 - P.value = compute_value # calling P.value will call the function as: compute_value(P) which will return P2.value**2 - - """ - - def __init__(self, name, **kwargs): - - super().__init__(name, **kwargs) - if "state" in kwargs: - return - temp_locked = self.locked - self.locked = False - self._value = None - self.prof = kwargs.get("prof", None) - self.limits = kwargs.get("limits", [None, None]) - self.cyclic = kwargs.get("cyclic", False) - self.shape = kwargs.get("shape", None) - self.value = kwargs.get("value", None) - self.units = kwargs.get("units", "none") - self.uncertainty = kwargs.get("uncertainty", None) - self.to() - self.locked = temp_locked - - @property - def value(self): - """The ``value`` of a Parameter_Node is somewhat complicated, there - are a number of states it can take on. The most - straightforward is just a Tensor, if a Tensor (or just an - iterable like a list or numpy.ndarray) is provided then the - node is required to be a leaf node and it will store the value - to be accessed later by other parts of AstroPhot. Another - option is to set the value as another node (they will - automatically be linked), in this case the node's ``value`` is - just a wrapper to call for the ``value`` of the linked - node. Finally, the value may be a function which allows for - arbitrarily complex values to be computed from other node's - values. The function must take as an argument the current - Parameter_Node instance and return a Tensor. Here are some - examples of the various ways of interacting with the ``value`` - for a hypothetical parameter ``P``:: - - P.value = 1. # Will create a tensor with value 1. - P.value = P2 # calling P.value will actually call P2.value - def compute_value(param): - return param["P2"].value**2 - P.value = compute_value # calling P.value will call the function as: compute_value(P) which will return P2.value**2 - - """ - if isinstance(self._value, Parameter_Node): - return self._value.value - if isinstance(self._value, FunctionType): - return self._value(self) - - return self._value - - @property - def mask(self): - """The mask tensor is stored internally and it cuts out some values - from the parameter. This is used by the ``vector`` methods in - the class to give the parameter DAG a dynamic shape. - - """ - if not self.leaf: - return self.vector_mask() - try: - return self._mask - except AttributeError: - return torch.ones(self.shape, dtype=torch.bool, device=AP_config.ap_device) - - @property - def identities(self): - """This creates a numpy array of strings which uniquely identify - every element in the parameter vector. For example a - ``center`` parameter with two components [x,y] would have - identities be ``np.array(["123456:0", "123456:1"])`` where the - first part is the unique id for the Parameter_Node object and - the second number indexes where in the value tensor it refers - to. - - """ - if self.leaf: - idstr = str(self.identity) - return np.array(tuple(f"{idstr}:{i}" for i in range(self.size))) - flat = self.flat(include_locked=False, include_links=False) - vec = tuple(node.identities for node in flat.values()) - if len(vec) > 0: - return np.concatenate(vec) - return np.array(()) - - @property - def names(self): - """Returns a numpy array of names for all the elements of the - ``vector`` representation where the name is determined by the - name of the parameters. Note that this does not create a - unique name for each element and this should only be used for - graphical purposes on small parameter DAGs. - - """ - if self.leaf: - S = self.size - if S == 1: - return np.array((self.name,)) - return np.array(tuple(f"{self.name}:{i}" for i in range(self.size))) - flat = self.flat(include_locked=False, include_links=False) - vec = tuple(node.names for node in flat.values()) - if len(vec) > 0: - return np.concatenate(vec) - return np.array(()) - - def vector_values(self): - """The vector representation is for values which correspond to - fundamental inputs to the parameter DAG. Since the DAG may - have linked nodes, or functions which produce values derived - from other node values, the collection of all "values" is not - necessarily of use for some methods such as fitting - algorithms. The vector representation is useful for optimizers - as it gives a fundamental representation of the parameter - DAG. The vector_values function returns a vector of the - ``value`` for each leaf node. - - """ - - if self.leaf: - return self.value[self.mask].flatten() - - flat = self.flat(include_locked=False, include_links=False) - vec = tuple(node.vector_values() for node in flat.values()) - if len(vec) > 0: - return torch.cat(vec) - return torch.tensor((), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - def vector_uncertainty(self): - """This returns a vector (see vector_values) with the uncertainty for - each leaf node. - - """ - if self.leaf: - if self._uncertainty is None: - self.uncertainty = torch.ones_like(self.value) - return self.uncertainty[self.mask].flatten() - - flat = self.flat(include_locked=False, include_links=False) - vec = tuple(node.vector_uncertainty() for node in flat.values()) - if len(vec) > 0: - return torch.cat(vec) - return torch.tensor((), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - def vector_representation(self): - """This returns a vector (see vector_values) with the representation - for each leaf node. The representation is an alternative view - of each value which is mapped into the (-inf, inf) range where - optimization is more stable. - - """ - return self.vector_transform_val_to_rep(self.vector_values()) - - def vector_mask(self): - """This returns a vector (see vector_values) with the mask for each - leaf node. Note however that the mask is not itself masked, - this vector is always the full size of the unmasked parameter - DAG. - - """ - if self.leaf: - return self.mask.flatten() - - flat = self.flat(include_locked=False, include_links=False) - vec = tuple(node.vector_mask() for node in flat.values()) - if len(vec) > 0: - return torch.cat(vec) - return torch.tensor((), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - def vector_identities(self): - """This returns a vector (see vector_values) with the identities for - each leaf node. - - """ - if self.leaf: - return self.identities[self.vector_mask().detach().cpu().numpy()].flatten() - flat = self.flat(include_locked=False, include_links=False) - vec = tuple(node.vector_identities() for node in flat.values()) - if len(vec) > 0: - return np.concatenate(vec) - return np.array(()) - - def vector_names(self): - """This returns a vector (see vector_values) with the names for each - leaf node. - - """ - if self.leaf: - return self.names[self.vector_mask().detach().cpu().numpy()].flatten() - flat = self.flat(include_locked=False, include_links=False) - vec = tuple(node.vector_names() for node in flat.values()) - if len(vec) > 0: - return np.concatenate(vec) - return np.array(()) - - def vector_set_values(self, values): - """This function allows one to update the full vector of values in a - single call by providing a tensor of the appropriate size. The - input will be separated so that the correct elements are - passed to the correct leaf nodes. - - """ - values = torch.as_tensor( - values, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ).flatten() - if self.leaf: - self._value[self.mask] = values - return - - mask = self.vector_mask() - flat = self.flat(include_locked=False, include_links=False) - - loc = 0 - for node in flat.values(): - node.vector_set_values( - values[mask[:loc].sum().int() : mask[: loc + node.size].sum().int()] - ) - loc += node.size - - def vector_set_uncertainty(self, uncertainty): - """Update the uncertainty vector for this parameter DAG (see - vector_set_values). - - """ - uncertainty = torch.as_tensor( - uncertainty, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - if self.leaf: - if self._uncertainty is None: - self._uncertainty = torch.ones_like(self.value) - self._uncertainty[self.mask] = uncertainty - return - - mask = self.vector_mask() - flat = self.flat(include_locked=False, include_links=False) - - loc = 0 - for node in flat.values(): - node.vector_set_uncertainty( - uncertainty[mask[:loc].sum().int() : mask[: loc + node.size].sum().int()] - ) - loc += node.size - - def vector_set_mask(self, mask): - """Update the mask vector for this parameter DAG (see - vector_set_values). Note again that the mask vector is always - the full size of the DAG. - - """ - mask = torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device) - if self.leaf: - self._mask = mask.reshape(self.shape) - return - flat = self.flat(include_locked=False, include_links=False) - - loc = 0 - for node in flat.values(): - node.vector_set_mask(mask[loc : loc + node.size]) - loc += node.size - - def vector_set_representation(self, rep): - """Update the representation vector for this parameter DAG (see - vector_set_values). - - """ - self.vector_set_values(self.vector_transform_rep_to_val(rep)) - - def vector_transform_rep_to_val(self, rep): - """Used to transform between the ``vector_values`` and - ``vector_representation`` views of the elements in the DAG - leafs. This transforms from representation to value. - - The transformation is done based on the limits of each - parameter leaf. If no limits are provided then the - representation and value are equivalent. If both are given - then a ``tan`` and ``arctan`` are used to convert between the - finite range and the infinite range. If the limits are - one-sided then the transformation: ``newvalue = value - 1 / - (value - limit)`` is used. - - """ - rep = torch.as_tensor(rep, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - if self.leaf: - if self.cyclic: - val = cyclic_boundaries(rep, (self.limits[0], self.limits[1])) - elif self.limits[0] is None and self.limits[1] is None: - val = rep - else: - val = inv_boundaries( - rep, - ( - None if self.limits[0] is None else self.limits[0], - None if self.limits[1] is None else self.limits[1], - ), - ) - return val - - mask = self.vector_mask() - flat = self.flat(include_locked=False, include_links=False) - - loc = 0 - vals = [] - for node in flat.values(): - vals.append( - node.vector_transform_rep_to_val( - rep[mask[:loc].sum().int() : mask[: loc + node.size].sum().int()] - ) - ) - loc += node.size - if len(vals) > 0: - return torch.cat(vals) - return torch.tensor((), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - def vector_transform_val_to_rep(self, val): - """Used to transform between the ``vector_values`` and - ``vector_representation`` views of the elements in the DAG - leafs. This transforms from value to representation. - - The transformation is done based on the limits of each - parameter leaf. If no limits are provided then the - representation and value are equivalent. If both are given - then a ``tan`` and ``arctan`` are used to convert between the - finite range and the infinite range. If the limits are - one-sided then the transformation: ``newvalue = value - 1 / - (value - limit)`` is used. - - """ - val = torch.as_tensor(val, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - if self.leaf: - if self.cyclic: - rep = cyclic_boundaries(val, (self.limits[0], self.limits[1])) - elif self.limits[0] is None and self.limits[1] is None: - rep = val - else: - rep = boundaries( - val, - ( - None if self.limits[0] is None else self.limits[0], - None if self.limits[1] is None else self.limits[1], - ), - ) - return rep - - mask = self.vector_mask() - flat = self.flat(include_locked=False, include_links=False) - - loc = 0 - reps = [] - for node in flat.values(): - reps.append( - node.vector_transform_val_to_rep( - val[mask[:loc].sum().int() : mask[: loc + node.size].sum().int()] - ) - ) - loc += node.size - if len(reps) > 0: - return torch.cat(reps) - return torch.tensor((), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - def _set_val_self(self, val): - """Handles the setting of the value for a leaf node. Ensures the - value is a Tensor and that it has the right shape. Will also - check the limits of the value which has different behaviour - depending on if it is cyclic, one sided, or two sided. - - """ - val = torch.as_tensor(val, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - if self.shape is not None: - self._value = val.reshape(self.shape) - else: - self._value = val - self.shape = self._value.shape - - if self.cyclic: - self._value = self.limits[0] + ( - (self._value - self.limits[0]) % (self.limits[1] - self.limits[0]) - ) - return - if self.limits[0] is not None: - if not torch.all(self._value > self.limits[0]): - raise InvalidParameter( - f"{self.name} has lower limit {self.limits[0].detach().cpu().tolist()}" - ) - if self.limits[1] is not None: - if not torch.all(self._value < self.limits[1]): - raise InvalidParameter( - f"{self.name} has upper limit {self.limits[1].detach().cpu().tolist()}" - ) - - def _soft_set_val_self(self, val): - """The same as ``_set_val_self`` except that it doesn't raise an - error when the values are set outside their range, instead it - will push the values into the range defined by the limits. - - """ - val = torch.as_tensor(val, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - if self.shape is not None: - self._value = val.reshape(self.shape) - else: - self._value = val - self.shape = self._value.shape - - if self.cyclic: - self._value = self.limits[0] + ( - (self._value - self.limits[0]) % (self.limits[1] - self.limits[0]) - ) - return - if self.limits[0] is not None: - self._value = torch.maximum( - self._value, self.limits[0] + torch.ones_like(self._value) * 1e-3 - ) - if self.limits[1] is not None: - self._value = torch.minimum( - self._value, self.limits[1] - torch.ones_like(self._value) * 1e-3 - ) - - @value.setter - def value(self, val): - if self.locked and not Node.global_unlock: - return - if val is None: - self._value = None - self.shape = None - return - if isinstance(val, str): - self._value = val - return - if isinstance(val, Parameter_Node): - self._value = val - self.shape = None - # Link only to the pointed node - self.dump() - self.link(val) - return - if isinstance(val, FunctionType): - self._value = val - self.shape = None - return - if len(self.nodes) > 0: - self.vector_set_values(val) - self.shape = None - return - self._set_val_self(val) - self.dump() - - @property - def shape(self): - try: - if isinstance(self._value, Parameter_Node): - return self._value.shape - if isinstance(self._value, FunctionType): - return self.value.shape - if self.leaf: - return self._shape - except AttributeError: - pass - return None - - @shape.setter - def shape(self, shape): - self._shape = shape - - @property - def prof(self): - return self._prof - - @prof.setter - def prof(self, prof): - if self.locked and not Node.global_unlock: - return - if prof is None: - self._prof = None - return - self._prof = torch.as_tensor(prof, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - @property - def uncertainty(self): - return self._uncertainty - - @uncertainty.setter - def uncertainty(self, unc): - if self.locked and not Node.global_unlock: - return - if unc is None: - self._uncertainty = None - return - - self._uncertainty = torch.as_tensor( - unc, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - # Ensure that the uncertainty tensor has the same shape as the data - if self.shape is not None: - if self._uncertainty.shape != self.shape: - self._uncertainty = self._uncertainty * torch.ones( - self.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - - @property - def limits(self): - return self._limits - - @limits.setter - def limits(self, limits): - if self.locked and not Node.global_unlock: - return - if limits[0] is None: - low = None - else: - low = torch.as_tensor(limits[0], dtype=AP_config.ap_dtype, device=AP_config.ap_device) - if limits[1] is None: - high = None - else: - high = torch.as_tensor(limits[1], dtype=AP_config.ap_dtype, device=AP_config.ap_device) - self._limits = (low, high) - - def to(self, dtype=None, device=None): - """ - updates the datatype or device of this parameter - """ - if dtype is not None: - dtype = AP_config.ap_dtype - if device is not None: - device = AP_config.ap_device - - if isinstance(self._value, torch.Tensor): - self._value = self._value.to(dtype=dtype, device=device) - elif len(self.nodes) > 0: - for node in self.nodes.values(): - node.to(dtype, device) - if isinstance(self._uncertainty, torch.Tensor): - self._uncertainty = self._uncertainty.to(dtype=dtype, device=device) - if isinstance(self.prof, torch.Tensor): - self.prof = self.prof.to(dtype=dtype, device=device) - return self - - def get_state(self): - """Return the values representing the current state of the parameter, - this can be used to re-load the state later from memory. - - """ - state = super().get_state() - if self.value is not None: - if isinstance(self._value, Node): - state["value"] = "NODE:" + str(self._value.identity) - elif isinstance(self._value, FunctionType): - state["value"] = "FUNCTION:" + self._value.__name__ - else: - state["value"] = self.value.detach().cpu().numpy().tolist() - if self.shape is not None: - state["shape"] = list(self.shape) - if self.units is not None: - state["units"] = self.units - if self.uncertainty is not None: - state["uncertainty"] = self.uncertainty.detach().cpu().numpy().tolist() - if not (self.limits[0] is None and self.limits[1] is None): - save_lim = [] - for i in [0, 1]: - if self.limits[i] is None: - save_lim.append(None) - else: - save_lim.append(self.limits[i].detach().cpu().tolist()) - state["limits"] = save_lim - if self.cyclic: - state["cyclic"] = self.cyclic - if self.prof is not None: - state["prof"] = self.prof.detach().cpu().tolist() - - return state - - def set_state(self, state): - """Update the state of the parameter given a state variable which - holds all information about a variable. - - """ - - super().set_state(state) - save_locked = self.locked - self.locked = False - self.units = state.get("units", None) - self.limits = state.get("limits", (None, None)) - self.cyclic = state.get("cyclic", False) - self.value = state.get("value", None) - self.uncertainty = state.get("uncertainty", None) - self.prof = state.get("prof", None) - self.locked = save_locked - - def flat_detach(self): - """Due to the system used to track and update values in the DAG, some - parts of the computational graph used to determine gradients - may linger after calling .backward on a model using the - parameters. This function essentially resets all the leaf - values so that the full computational graph is freed. - - """ - for P in self.flat().values(): - P.value = P.value.detach() - if P.uncertainty is not None: - P.uncertainty = P.uncertainty.detach() - if P.prof is not None: - P.prof = P.prof.detach() - - @property - def size(self): - if self.leaf: - return self.value.numel() - return self.vector_values().numel() - - def __len__(self): - """The number of elements required to fully describe the DAG. This is - the number of elements in the vector_values tensor. - - """ - return self.size - - def print_params(self, include_locked=True, include_prof=True, include_id=True): - if self.leaf: - return ( - f"{self.name}" - + (f" (id-{self.identity})" if include_id else "") - + f": {self.value.detach().cpu().tolist()}" - + ( - "" - if self.uncertainty is None - else f" +- {self.uncertainty.detach().cpu().tolist()}" - ) - + f" [{self.units}]" - + ( - "" - if self.limits[0] is None and self.limits[1] is None - else f", limits: ({None if self.limits[0] is None else self.limits[0].detach().cpu().tolist()}, {None if self.limits[1] is None else self.limits[1].detach().cpu().tolist()})" - ) - + (", cyclic" if self.cyclic else "") - + (", locked" if self.locked else "") - + ( - f", prof: {self.prof.detach().cpu().tolist()}" - if include_prof and self.prof is not None - else "" - ) - ) - elif isinstance(self._value, Parameter_Node): - return ( - self.name - + (f" (id-{self.identity})" if include_id else "") - + " points to: " - + self._value.print_params( - include_locked=include_locked, - include_prof=include_prof, - include_id=include_id, - ) - ) - return ( - self.name - + ( - f" (id-{self.identity}, {('function node, '+self._value.__name__) if isinstance(self._value, FunctionType) else 'branch node'})" - if include_id - else "" - ) - + ":\n" - ) - - def __str__(self): - reply = self.print_params(include_locked=True, include_prof=False, include_id=False) - if self.leaf or isinstance(self._value, Parameter_Node): - return reply - reply += "\n".join( - node.print_params(include_locked=True, include_prof=False, include_id=False) - for node in self.flat(include_locked=True, include_links=False).values() - ) - return reply - - def __repr__(self, level=0, indent=" "): - reply = indent * level + self.print_params( - include_locked=True, include_prof=False, include_id=True - ) - if self.leaf or isinstance(self._value, Parameter_Node): - return reply - reply += "\n".join( - node.__repr__(level=level + 1, indent=indent) for node in self.nodes.values() - ) - return reply From 9a20d08f2f1ae7fdfb8585bb29a10aaeecadbe19 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 22 May 2025 13:18:48 -0400 Subject: [PATCH 013/191] working on caskade wcs --- astrophot/image/func/wcs.py | 215 ++++++++++++++++++++++++++++++++ astrophot/image/func/window.py | 53 ++++++++ astrophot/image/image_object.py | 3 +- astrophot/image/wcs.py | 145 ++++++++++++++++----- astrophot/models/core_model.py | 1 - 5 files changed, 385 insertions(+), 32 deletions(-) create mode 100644 astrophot/image/func/wcs.py create mode 100644 astrophot/image/func/window.py diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py new file mode 100644 index 00000000..7caf1683 --- /dev/null +++ b/astrophot/image/func/wcs.py @@ -0,0 +1,215 @@ +import numpy as np +import torch + +deg_to_rad = np.pi / 180 +rad_to_deg = 180 / np.pi +rad_to_arcsec = rad_to_deg * 3600 +arcsec_to_rad = deg_to_rad / 3600 + + +def world_to_plane_gnomonic(ra, dec, ra0, dec0, x0=0.0, y0=0.0): + """ + Convert world coordinates (RA, Dec) to plane coordinates (x, y) using the gnomonic projection. + + Parameters + ---------- + ra : torch.Tensor + Right Ascension in degrees. + dec : torch.Tensor + Declination in degrees. + ra0 : torch.Tensor + Reference Right Ascension in degrees. + dec0 : torch.Tensor + Reference Declination in degrees. + + Returns + ------- + x : torch.Tensor + x coordinate in arcseconds. + y : torch.Tensor + y coordinate in arcseconds. + """ + ra = ra * deg_to_rad + dec = dec * deg_to_rad + ra0 = ra0 * deg_to_rad + dec0 = dec0 * deg_to_rad + + cosc = torch.sin(dec0) * torch.sin(dec) + torch.cos(dec0) * torch.cos(dec) * torch.cos(ra - ra0) + + x = torch.cos(dec) * torch.sin(ra - ra0) + + y = torch.cos(dec0) * torch.sin(dec) - torch.sin(dec0) * torch.cos(dec) * torch.cos(ra - ra0) + + return x * rad_to_arcsec / cosc + x0, y * rad_to_arcsec / cosc + y0 + + +def plane_to_world_gnomonic(x, y, ra0, dec0, x0=0.0, y0=0.0, s=1e-3): + """ + Convert plane coordinates (x, y) to world coordinates (RA, Dec) using the gnomonic projection. + Parameters + ---------- + x : torch.Tensor + x coordinate in arcseconds. + y : torch.Tensor + y coordinate in arcseconds. + ra0 : torch.Tensor + Reference Right Ascension in degrees. + dec0 : torch.Tensor + Reference Declination in degrees. + s : float + Small constant to avoid division by zero. + + Returns + ------- + ra : torch.Tensor + Right Ascension in degrees. + dec : torch.Tensor + Declination in degrees. + """ + x = (x - x0) * arcsec_to_rad + y = (y - y0) * arcsec_to_rad + ra0 = ra0 * deg_to_rad + dec0 = dec0 * deg_to_rad + + rho = torch.sqrt(x**2 + y**2) + s + c = torch.arctan(rho) + + ra = ra0 + torch.arctan2( + x * torch.sin(c), + rho * torch.cos(dec0) * torch.cos(c) - y * torch.sin(dec0) * torch.sin(c), + ) + + dec = torch.arcsin(torch.cos(c) * torch.sin(dec0) + y * torch.sin(c) * torch.cos(dec0) / rho) + + return ra * rad_to_deg, dec * rad_to_deg + + +def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): + """ + Convert pixel coordinates to a tangent plane using the WCS information. This + matches the FITS convention for linear transformations. + + Parameters + ---------- + i: Tensor + The first coordinate of the pixel in pixel units. + j: Tensor + The second coordinate of the pixel in pixel units. + i0: Tensor + The i reference pixel coordinate in pixel units. + j0: Tensor + The j reference pixel coordinate in pixel units. + CD: Tensor + The CD matrix in arcsec per pixel. This 2x2 matrix is used to convert + from pixel to arcsec units and also handles rotation/skew. + x0: float + The x reference coordinate in arcsec. + y0: float + The y reference coordinate in arcsec. + + Returns + ------- + Tuple: [Tensor, Tensor] + Tuple containing the x and y tangent plane coordinates in arcsec. + """ + uv = torch.stack((i.reshape(-1) - i0, j.reshape(-1) - j0), dim=1) + xy = CD.T @ uv + + return xy[:, 0].reshape(i.shape) + x0, xy[:, 1].reshape(j.shape) + y0 + + +def pixel_to_plane_sip(i, j, i0, j0, CD, sip_powers=[], sip_coefs=[], x0=0.0, y0=0.0): + """ + Convert pixel coordinates to a tangent plane using the WCS information. This + matches the FITS convention for SIP transformations. + + For more information see: + + * FITS World Coordinate System (WCS): + https://fits.gsfc.nasa.gov/fits_wcs.html + * Representations of world coordinates in FITS, 2002, by Geisen and + Calabretta + * The SIP Convention for Representing Distortion in FITS Image Headers, + 2008, by Shupe and Hook + + Parameters + ---------- + i: Tensor + The first coordinate of the pixel in pixel units. + j: Tensor + The second coordinate of the pixel in pixel units. + i0: Tensor + The i reference pixel coordinate in pixel units. + j0: Tensor + The j reference pixel coordinate in pixel units. + CD: Tensor + The CD matrix in degrees per pixel. This 2x2 matrix is used to convert + from pixel to degree units and also handles rotation/skew. + sip_powers: Tensor + The powers of the pixel coordinates for the SIP distortion, should be a + shape (N orders, 2) tensor. ``N orders`` is the number of non-zero + polynomial coefficients. The second axis has the powers in order ``i, + j``. + sip_coefs: Tensor + The coefficients of the pixel coordinates for the SIP distortion, should + be a shape (N orders, 2) tensor. ``N orders`` is the number of non-zero + polynomial coefficients. The second axis has the coefficients in order + ``delta_x, delta_y``. + x0: float + The x reference coordinate in arcsec. + y0: float + The y reference coordinate in arcsec. + + Note + ---- + The representation of the SIP powers and coefficients assumes that the SIP + polynomial will use the same orders for both the x and y coordinates. If + this is not the case you may use zeros for the coefficients to ensure all + polynomial combinations are evaluated. However, it is very common to have + the same orders for both. + + Returns + ------- + Tuple: [Tensor, Tensor] + Tuple containing the x and y tangent plane coordinates in arcsec. + """ + uv = torch.stack((i - i0, j - j0), -1) + delta_p = torch.zeros_like(uv) + for p in range(len(sip_powers)): + delta_p += sip_coefs[p] * torch.prod(uv ** sip_powers[p], dim=-1).unsqueeze(-1) + plane = torch.einsum("ij,...j->...i", CD, uv + delta_p) + return plane[..., 0] + x0, plane[..., 1] + y0 + + +def plane_to_pixel_linear(x, y, i0, j0, iCD, x0=0.0, y0=0.0): + """ + Convert tangent plane coordinates to pixel coordinates using the WCS + information. This matches the FITS convention for linear transformations. + + Parameters + ---------- + x: Tensor + The first coordinate of the pixel in arcsec. + y: Tensor + The second coordinate of the pixel in arcsec. + i0: Tensor + The i reference pixel coordinate in pixel units. + j0: Tensor + The j reference pixel coordinate in pixel units. + iCD: Tensor + The inverse CD matrix in arcsec per pixel. This 2x2 matrix is used to convert + from pixel to arcsec units and also handles rotation/skew. + x0: float + The x reference coordinate in arcsec. + y0: float + The y reference coordinate in arcsec. + + Returns + ------- + Tuple: [Tensor, Tensor] + Tuple containing the i and j pixel coordinates in pixel units. + """ + xy = torch.stack((x.reshape(-1) - x0, y.reshape(-1) - y0), dim=1) + uv = iCD.T @ xy + + return uv[:, 0].reshape(x.shape) + i0, uv[:, 1].reshape(y.shape) + j0 diff --git a/astrophot/image/func/window.py b/astrophot/image/func/window.py new file mode 100644 index 00000000..fb8d0c40 --- /dev/null +++ b/astrophot/image/func/window.py @@ -0,0 +1,53 @@ +import torch + + +def pixel_center_meshgrid(shape, dtype, device): + + i = torch.arange(shape[0], dtype=dtype, device=device) + j = torch.arange(shape[1], dtype=dtype, device=device) + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_corner_meshgrid(shape, dtype, device): + + i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_simpsons_meshgrid(shape, dtype, device): + """ + Create a meshgrid for Simpson's rule integration over pixel corners. + + Parameters + ---------- + shape : tuple + Shape of the grid (height, width). + dtype : torch.dtype + Data type of the tensor. + device : torch.device + Device to create the tensor on. + + Returns + ------- + tuple + Meshgrid tensors for x and y coordinates. + """ + i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 + return torch.meshgrid(i, j, indexing="xy") + + +def window_or(other_origin, self_end, other_end): + + new_origin = torch.minimum(-0.5 * torch.ones_like(other_origin), other_origin) + new_end = torch.maximum(self_end, other_end) + + return new_origin, new_end + + +def window_and(other_origin, self_end, other_end): + new_origin = torch.maximum(-0.5 * torch.ones_like(other_origin), other_origin) + new_end = torch.minimum(self_end, other_end) + + return new_origin, new_end diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index e94b4caf..1900d1af 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -5,6 +5,7 @@ import numpy as np from astropy.io import fits from astropy.wcs import WCS as AstropyWCS +from caskade import Module, Param from .window_object import Window, Window_List from .image_header import Image_Header @@ -14,7 +15,7 @@ __all__ = ["Image", "Image_List"] -class Image(object): +class Image(Module): """Core class to represent images with pixel values, pixel scale, and a window defining the spatial coordinates on the sky. It supports arithmetic operations with other image objects while preserving logical image boundaries. diff --git a/astrophot/image/wcs.py b/astrophot/image/wcs.py index 6f0f71a6..0b722820 100644 --- a/astrophot/image/wcs.py +++ b/astrophot/image/wcs.py @@ -1,9 +1,12 @@ import torch import numpy as np +from caskade import Module, Param, forward from .. import AP_config from ..utils.conversions.units import deg_to_arcsec from ..errors import InvalidWCS +from . import func + __all__ = ("WPCS", "PPCS", "WCS") @@ -702,18 +705,23 @@ def __repr__(self): return f"PPCS reference_imageij: {self.reference_imageij.detach().cpu().tolist()}, reference_imagexy: {self.reference_imagexy.detach().cpu().tolist()}, pixelscale: {self.pixelscale.detach().cpu().tolist()}" -class WCS(WPCS, PPCS): +class WCS(Module): """ Full world coordinate system defines mappings from world to tangent plane to pixel grid and all other variations. """ - def __init__(self, *args, wcs=None, **kwargs): + default_i0_j0 = (-0.5, -0.5) + default_x0_y0 = (0, 0) + default_ra0_dec0 = (0, 0) + default_pixelscale = 1 + + def __init__(self, *, wcs=None, pixelscale=None, **kwargs): if kwargs.get("state", None) is not None: self.set_state(kwargs["state"]) return if wcs is not None: - if wcs.wcs.ctype[0] != "RA---TAN": + if wcs.wcs.ctype[0] != "RA---TAN": # fixme handle sip AP_config.ap_logger.warning( "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." ) @@ -722,51 +730,120 @@ def __init__(self, *args, wcs=None, **kwargs): "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." ) - if wcs is not None: - kwargs["reference_radec"] = kwargs.get("reference_radec", wcs.wcs.crval) - kwargs["reference_imageij"] = wcs.wcs.crpix - WPCS.__init__(self, *args, wcs=wcs, **kwargs) - sky_coord = wcs.pixel_to_world(*wcs.wcs.crpix) - kwargs["reference_imagexy"] = self.world_to_plane( - torch.tensor( - (sky_coord.ra.deg, sky_coord.dec.deg), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + kwargs["ra0"] = wcs.wcs.crval[0] + kwargs["dec0"] = wcs.wcs.crval[1] + kwargs["i0"] = wcs.wcs.crpix[0] + kwargs["j0"] = wcs.wcs.crpix[1] + # fixme + # sky_coord = wcs.pixel_to_world(*wcs.wcs.crpix) + # kwargs["x0_y0"] = self.world_to_plane( + # torch.tensor( + # (sky_coord.ra.deg, sky_coord.dec.deg), + # dtype=AP_config.ap_dtype, + # device=AP_config.ap_device, + # ) + # ) + + self.projection = kwargs.get("projection", self.default_projection) + self.ra0 = Param("ra0", kwargs.get("ra0", self.default_ra0_dec0[0]), units="deg") + self.dec0 = Param("dec0", kwargs.get("dec0", self.default_ra0_dec0[1]), units="deg") + self.x0 = Param("x0", kwargs.get("x0", self.default_x0_y0[0]), units="arcsec") + self.y0 = Param("y0", kwargs.get("y0", self.default_x0_y0[1]), units="arcsec") + self.i0 = Param("i0", kwargs.get("i0", self.default_i0_j0[0]), units="pixel") + self.j0 = Param("j0", kwargs.get("j0", self.default_i0_j0[1]), units="pixel") + + # Collect the pixelscale of the pixel grid + if wcs is not None and pixelscale is None: + self.pixelscale = deg_to_arcsec * wcs.pixel_scale_matrix + elif pixelscale is not None: + if wcs is not None and isinstance(pixelscale, float): + AP_config.ap_logger.warning( + "Overriding WCS pixelscale with manual input! To remove this message, either let WCS define pixelscale, or input full pixelscale matrix" ) - ) + self.pixelscale = pixelscale else: - WPCS.__init__(self, *args, **kwargs) + AP_config.ap_logger.warning( + "Assuming pixelscale of 1! To remove this message please provide the pixelscale explicitly" + ) + self.pixelscale = self.default_pixelscale + + @property + def pixelscale(self): + """Matrix defining the shape of pixels in the tangent plane, these + can be any parallelogram defined by the matrix. + + """ + return self._pixelscale + + @pixelscale.setter + def pixelscale(self, pix): + if pix is None: + self._pixelscale = None + return + + self._pixelscale = ( + torch.as_tensor(pix, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + .clone() + .detach() + ) + if self._pixelscale.numel() == 1: + self._pixelscale = torch.tensor( + [[self._pixelscale.item(), 0.0], [0.0, self._pixelscale.item()]], + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + self._pixel_area = torch.linalg.det(self.pixelscale).abs() + self._pixel_length = self._pixel_area.sqrt() + self._pixelscale_inv = torch.linalg.inv(self.pixelscale) + + @forward + def pixel_to_plane(self, i, j, i0, j0, x0, y0): + return func.pixel_to_plane_linear(i, j, i0, j0, self.pixelscale, x0, y0) + + @forward + def plane_to_pixel(self, x, y, i0, j0, x0, y0): + return func.plane_to_pixel_linear(x, y, i0, j0, self._pixelscale_inv, x0, y0) - PPCS.__init__(self, *args, wcs=wcs, **kwargs) + @forward + def plane_to_world(self, x, y, ra0, dec0, x0, y0): + return func.plane_to_world_gnomonic(x, y, ra0, dec0, x0, y0) - def world_to_pixel(self, world_RA, world_DEC=None): + @forward + def world_to_plane(self, ra, dec, ra0, dec0, x0, y0): + return func.world_to_plane_gnomonic(ra, dec, ra0, dec0, x0, y0) + + @forward + def world_to_pixel(self, ra, dec=None): """A wrapper which applies :meth:`world_to_plane` then :meth:`plane_to_pixel`, see those methods for further information. """ - if world_DEC is None: - return torch.stack(self.world_to_pixel(*world_RA)) - return self.plane_to_pixel(*self.world_to_plane(world_RA, world_DEC)) + if dec is None: + ra, dec = ra[0], ra[1] + return self.plane_to_pixel(*self.world_to_plane(ra, dec)) - def pixel_to_world(self, pixel_i, pixel_j=None): + @forward + def pixel_to_world(self, i, j=None): """A wrapper which applies :meth:`pixel_to_plane` then :meth:`plane_to_world`, see those methods for further information. """ - if pixel_j is None: - return torch.stack(self.pixel_to_world(*pixel_i)) - return self.plane_to_world(*self.pixel_to_plane(pixel_i, pixel_j)) + if j is None: + i, j = i[0], i[1] + return self.plane_to_world(*self.pixel_to_plane(i, j)) def copy(self, **kwargs): copy_kwargs = { "pixelscale": self.pixelscale, - "reference_imageij": self.reference_imageij, - "reference_imagexy": self.reference_imagexy, + "i0": self.i0.value, + "j0": self.j0.value, + "x0": self.x0.value, + "y0": self.y0.value, + "ra0": self.ra0.value, + "dec0": self.dec0.value, "projection": self.projection, - "reference_radec": self.reference_radec, - "reference_planexy": self.reference_planexy, } copy_kwargs.update(kwargs) return self.__class__( @@ -774,8 +851,16 @@ def copy(self, **kwargs): ) def to(self, dtype=None, device=None): - WPCS.to(self, dtype, device) - PPCS.to(self, dtype, device) + if dtype is None: + dtype = AP_config.ap_dtype + if device is None: + device = AP_config.ap_device + super().to(dtype=dtype, device=device) + self._pixelscale = self._pixelscale.to(dtype=dtype, device=device) + self._pixel_area = self._pixel_area.to(dtype=dtype, device=device) + self._pixel_length = self._pixel_length.to(dtype=dtype, device=device) + self._pixelscale_inv = self._pixelscale_inv.to(dtype=dtype, device=device) + return self def get_state(self): state = WPCS.get_state(self) diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index 98300709..39d0794a 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -166,7 +166,6 @@ def fit_mask(self): @forward def negative_log_likelihood( self, - as_representation=False, ): """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. From 916efb25af0487bd073ba1560a0a1b8d525f7cdd Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 9 Jun 2025 16:20:39 -0400 Subject: [PATCH 014/191] Image object in semi ready state --- astrophot/image/func/__init__.py | 20 + astrophot/image/func/image.py | 19 + astrophot/image/func/window.py | 37 -- astrophot/image/image_header.py | 334 ------------ astrophot/image/image_object.py | 662 ++++++++++------------- astrophot/image/target_image.py | 4 +- astrophot/image/wcs.py | 893 ------------------------------- astrophot/image/window_object.py | 668 ----------------------- 8 files changed, 336 insertions(+), 2301 deletions(-) create mode 100644 astrophot/image/func/__init__.py create mode 100644 astrophot/image/func/image.py delete mode 100644 astrophot/image/image_header.py delete mode 100644 astrophot/image/wcs.py delete mode 100644 astrophot/image/window_object.py diff --git a/astrophot/image/func/__init__.py b/astrophot/image/func/__init__.py new file mode 100644 index 00000000..51b4d8fb --- /dev/null +++ b/astrophot/image/func/__init__.py @@ -0,0 +1,20 @@ +from .image import pixel_center_meshgrid, pixel_corner_meshgrid, pixel_simpsons_meshgrid +from .wcs import ( + world_to_plane_gnomonic, + plane_to_world_gnomonic, + pixel_to_plane_linear, + plane_to_pixel_linear, +) +from .window import window_or, window_and + +__all__ = ( + "pixel_center_meshgrid", + "pixel_corner_meshgrid", + "pixel_simpsons_meshgrid", + "world_to_plane_gnomonic", + "plane_to_world_gnomonic", + "pixel_to_plane_linear", + "plane_to_pixel_linear", + "window_or", + "window_and", +) diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py new file mode 100644 index 00000000..f901ed43 --- /dev/null +++ b/astrophot/image/func/image.py @@ -0,0 +1,19 @@ +import torch + + +def pixel_center_meshgrid(shape, dtype, device): + i = torch.arange(shape[0], dtype=dtype, device=device) + j = torch.arange(shape[1], dtype=dtype, device=device) + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_corner_meshgrid(shape, dtype, device): + i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_simpsons_meshgrid(shape, dtype, device): + i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 + return torch.meshgrid(i, j, indexing="xy") diff --git a/astrophot/image/func/window.py b/astrophot/image/func/window.py index fb8d0c40..132370e1 100644 --- a/astrophot/image/func/window.py +++ b/astrophot/image/func/window.py @@ -1,43 +1,6 @@ import torch -def pixel_center_meshgrid(shape, dtype, device): - - i = torch.arange(shape[0], dtype=dtype, device=device) - j = torch.arange(shape[1], dtype=dtype, device=device) - return torch.meshgrid(i, j, indexing="xy") - - -def pixel_corner_meshgrid(shape, dtype, device): - - i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="xy") - - -def pixel_simpsons_meshgrid(shape, dtype, device): - """ - Create a meshgrid for Simpson's rule integration over pixel corners. - - Parameters - ---------- - shape : tuple - Shape of the grid (height, width). - dtype : torch.dtype - Data type of the tensor. - device : torch.device - Device to create the tensor on. - - Returns - ------- - tuple - Meshgrid tensors for x and y coordinates. - """ - i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="xy") - - def window_or(other_origin, self_end, other_end): new_origin = torch.minimum(-0.5 * torch.ones_like(other_origin), other_origin) diff --git a/astrophot/image/image_header.py b/astrophot/image/image_header.py deleted file mode 100644 index ea74e127..00000000 --- a/astrophot/image/image_header.py +++ /dev/null @@ -1,334 +0,0 @@ -from typing import Optional, Union, Any - -import torch -import numpy as np -from astropy.io import fits -from astropy.wcs import WCS as AstropyWCS - -from .window_object import Window -from .. import AP_config - -__all__ = ["Image_Header"] - - -class Image_Header: - """Store meta-information for images to be used in AstroPhot. - - The Image_Header object stores all meta information which tells - AstroPhot what is contained in an image array of pixels. This - includes coordinate systems and how to transform between them (see - :doc:`coordinates`). The image header will also know the image - zeropoint if that data is available. - - Args: - window : Window or None, optional - A Window object defining the area of the image in the coordinate - systems. Default is None. - filename : str or None, optional - The name of a file containing the image data. Default is None. - zeropoint : float or None, optional - The image's zeropoint, used for flux calibration. Default is None. - metadata : dict or None, optional - Any information the user wishes to associate with this image, stored in a python dictionary. Default is None. - - """ - - north = np.pi / 2.0 - - def __init__( - self, - *, - data_shape: Optional[torch.Tensor] = None, - wcs: Optional[AstropyWCS] = None, - window: Optional[Window] = None, - filename: Optional[str] = None, - zeropoint: Optional[Union[float, torch.Tensor]] = None, - metadata: Optional[dict] = None, - identity: str = None, - state: Optional[dict] = None, - fits_state: Optional[dict] = None, - **kwargs: Any, - ) -> None: - # Record identity - if identity is None: - self.identity = str(id(self)) - else: - self.identity = identity - - # set Zeropoint - self.zeropoint = zeropoint - - # set metadata for the image - self.metadata = metadata - - if filename is not None: - self.load(filename) - return - elif state is not None: - self.set_state(state) - return - elif fits_state is not None: - self.set_fits_state(fits_state) - return - - # Set Window - if window is None: - data_shape = torch.as_tensor(data_shape, dtype=torch.int32, device=AP_config.ap_device) - # If window is not provided, create one based on provided information - self.window = Window( - pixel_shape=torch.flip(data_shape, (0,)), - wcs=wcs, - **kwargs, - ) - else: - # When the Window object is provided - self.window = window - - @property - def zeropoint(self): - """The photometric zeropoint of the image, used as a flux reference - point. - - """ - return self._zeropoint - - @zeropoint.setter - def zeropoint(self, zp): - if zp is None: - self._zeropoint = None - return - - self._zeropoint = ( - torch.as_tensor(zp, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - .clone() - .detach() - ) - - @property - def origin(self) -> torch.Tensor: - """ - Returns the location of the origin (pixel coordinate -0.5, -0.5) of the image window in the tangent plane (arcsec). - - Returns: - torch.Tensor: A 1D tensor of shape (2,) containing the (x, y) coordinates of the origin. - """ - return self.window.origin - - @property - def shape(self) -> torch.Tensor: - """ - Returns the shape (size) of the image window (arcsec, arcsec). - - Returns: - torch.Tensor: A 1D tensor of shape (2,) containing the (width, height) of the window in arcsec. - """ - return self.window.shape - - @property - def center(self) -> torch.Tensor: - """ - Returns the center of the image window (arcsec). - - Returns: - torch.Tensor: A 1D tensor of shape (2,) containing the (x, y) coordinates of the center. - """ - return self.window.center - - def world_to_plane(self, *args, **kwargs): - return self.window.world_to_plane(*args, **kwargs) - - def plane_to_world(self, *args, **kwargs): - return self.window.plane_to_world(*args, **kwargs) - - def plane_to_pixel(self, *args, **kwargs): - return self.window.plane_to_pixel(*args, **kwargs) - - def pixel_to_plane(self, *args, **kwargs): - return self.window.pixel_to_plane(*args, **kwargs) - - def plane_to_pixel_delta(self, *args, **kwargs): - return self.window.plane_to_pixel_delta(*args, **kwargs) - - def pixel_to_plane_delta(self, *args, **kwargs): - return self.window.pixel_to_plane_delta(*args, **kwargs) - - def world_to_pixel(self, *args, **kwargs): - return self.window.world_to_pixel(*args, **kwargs) - - def pixel_to_world(self, *args, **kwargs): - return self.window.pixel_to_world(*args, **kwargs) - - def get_coordinate_meshgrid(self): - return self.window.get_coordinate_meshgrid() - - def get_coordinate_corner_meshgrid(self): - return self.window.get_coordinate_corner_meshgrid() - - def get_coordinate_simps_meshgrid(self): - return self.window.get_coordinate_simps_meshgrid() - - @property - def pixelscale(self): - return self.window.pixelscale - - @property - def pixel_length(self): - return self.window.pixel_length - - @property - def pixel_area(self): - return self.window.pixel_area - - def shift(self, shift): - """Adjust the position of the image described by the header. This will - not adjust the data represented by the header, only the - coordinate system that maps pixel coordinates to the plane - coordinates. - - """ - self.window.shift(shift) - - def pixel_shift(self, shift): - self.window.pixel_shift(shift) - - def copy(self, **kwargs): - """Produce a copy of this image with all of the same properties. This - can be used when one wishes to make temporary modifications to - an image and then will want the original again. - - """ - copy_kwargs = { - "zeropoint": self.zeropoint, - "metadata": self.metadata, - "window": self.window.copy(), - "identity": self.identity, - } - copy_kwargs.update(kwargs) - return self.__class__(**copy_kwargs) - - def get_window(self, window, **kwargs): - """Get a sub-region of the image as defined by a window on the sky.""" - copy_kwargs = { - "window": self.window & window, - } - copy_kwargs.update(kwargs) - return self.copy(**copy_kwargs) - - def to(self, dtype=None, device=None): - if dtype is None: - dtype = AP_config.ap_dtype - if device is None: - device = AP_config.ap_device - self.window.to(dtype=dtype, device=device) - if self.zeropoint is not None: - self.zeropoint.to(dtype=dtype, device=device) - return self - - def crop(self, pixels): # fixme data_shape? - """Reduce the size of an image by cropping some number of pixels off - the borders. If pixels is a single value, that many pixels are - cropped off all sides. If pixels is two values then a different - crop is done in x vs y. If pixels is four values then crop on - all sides are specified explicitly. - - formatted as: - [crop all sides] or - [crop x, crop y] or - [crop x low, crop y low, crop x high, crop y high] - - """ - self.window.crop_pixel(pixels) - return self - - def rescale_pixel(self, scale: int, **kwargs): - if scale == 1: - return self - - return self.copy( - window=self.window.rescale_pixel(scale), - **kwargs, - ) - - def get_state(self): - """Returns a dictionary with necessary information to recreate the - Image_Header object. - - """ - state = {} - if self.zeropoint is not None: - state["zeropoint"] = self.zeropoint.item() - state["window"] = self.window.get_state() - if self.metadata is not None: - state["metadata"] = self.metadata - return state - - def set_state(self, state): - self.zeropoint = state.get("zeropoint", self.zeropoint) - self.window = Window(state=state["window"]) - self.metadata = state.get("metadata", self.metadata) - - def get_fits_state(self): - state = {} - state.update(self.window.get_fits_state()) - if self.zeropoint is not None: - state["ZEROPNT"] = str(self.zeropoint.detach().cpu().item()) - if self.metadata is not None: - state["METADATA"] = str(self.metadata) - return state - - def set_fits_state(self, state): - """ - Updates the state of the Image_Header using information saved in a FITS header (more generally, a properly formatted dictionary will also work but not yet). - """ - self.zeropoint = eval(state.get("ZEROPNT", "None")) - self.metadata = state.get("METADATA", None) - self.window = Window(fits_state=state) - - def _save_image_list(self): - """ - Constructs a FITS header object which has the necessary information to recreate the Image_Header object. - """ - img_header = fits.Header() - img_header["IMAGE"] = "PRIMARY" - img_header["WINDOW"] = str(self.window.get_state()) - if self.zeropoint is not None: - img_header["ZEROPNT"] = str(self.zeropoint.detach().cpu().item()) - if self.metadata is not None: - img_header["METADATA"] = str(self.metadata) - return img_header - - def save(self, filename=None, overwrite=True): - """ - Save header to a FITS file. - """ - image_list = self._save_image_list() - hdul = fits.HDUList(image_list) - if filename is not None: - hdul.writeto(filename, overwrite=overwrite) - return hdul - - def load(self, filename): - """ - load header from a FITS file. - """ - hdul = fits.open(filename) - for hdu in hdul: - if "IMAGE" in hdu.header and hdu.header["IMAGE"] == "PRIMARY": - self.set_fits_state(hdu.header) - break - return hdul - - def __str__(self): - state = self.get_state() - state.update(self.window.get_state()) - keys = ["pixel_shape", "pixelscale", "reference_imageij", "reference_imagexy"] - if "zeropoint" in state: - keys.append("zeropoint") - if "metadata" in state: - keys.append("metadata") - return "\n".join(f"{key}: {state[key]}" for key in keys) - - def __repr__(self): - state = self.get_state() - state.update(self.window.get_state()) - return "\n".join(f"{key}: {state[key]}" for key in sorted(state.keys())) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 1900d1af..99b6dd0b 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -1,16 +1,15 @@ -from typing import Optional, Union, Any, Sequence, Tuple +from typing import Optional, Union, Any import torch -from torch.nn.functional import pad import numpy as np from astropy.io import fits from astropy.wcs import WCS as AstropyWCS -from caskade import Module, Param +from caskade import Module, Param, forward -from .window_object import Window, Window_List -from .image_header import Image_Header from .. import AP_config -from ..errors import SpecificationConflict, ConflicingWCS, InvalidData, InvalidWindow +from ..utils.conversions.units import deg_to_arcsec +from ..errors import SpecificationConflict, InvalidWindow +from . import func __all__ = ["Image", "Image_List"] @@ -31,22 +30,23 @@ class Image(Module): origin: The origin of the image in the coordinate system. """ + default_crpix = (-0.5, -0.5) + default_crtan = (0.0, 0.0) + default_crval = (0.0, 0.0) + default_pixelscale = ((1.0, 0.0), (0.0, 1.0)) + def __init__( self, *, data: Optional[torch.Tensor] = None, - header: Optional[Image_Header] = None, - wcs: Optional[AstropyWCS] = None, pixelscale: Optional[Union[float, torch.Tensor]] = None, - window: Optional[Window] = None, - filename: Optional[str] = None, zeropoint: Optional[Union[float, torch.Tensor]] = None, - metadata: Optional[dict] = None, - origin: Optional[Sequence] = None, - center: Optional[Sequence] = None, + wcs: Optional[AstropyWCS] = None, + filename: Optional[str] = None, identity: str = None, state: Optional[dict] = None, fits_state: Optional[dict] = None, + name: Optional[str] = None, **kwargs: Any, ) -> None: """Initialize an instance of the APImage class. @@ -59,175 +59,161 @@ def __init__( A WCS object which defines a coordinate system for the image. Note that AstroPhot only handles basic WCS conventions. It will use the WCS object to get `wcs.pixel_to_world(-0.5, -0.5)` to determine the position of the origin in world coordinates. It will also extract the `pixel_scale_matrix` to index pixels going forward. pixelscale : float or None, optional The physical scale of the pixels in the image, in units of arcseconds. Default is None. - window : Window or None, optional - A Window object defining the area of the image to use. Default is None. filename : str or None, optional The name of a file containing the image data. Default is None. zeropoint : float or None, optional The image's zeropoint, used for flux calibration. Default is None. - metadata : dict or None, optional - Any information the user wishes to associate with this image, stored in a python dictionary. Default is None. - origin : numpy.ndarray or None, optional - The origin of the image in the coordinate system, as a 1D array of length 2. Default is None. - center : numpy.ndarray or None, optional - The center of the image in the coordinate system, as a 1D array of length 2. Default is None. - - Returns: - -------- - None - """ - self._data = None + """ + super().__init__(name=name) if state is not None: - self.header = Image_Header(state=state["header"]) - elif fits_state is not None: + self.set_state(state) + return + if fits_state is not None: self.set_fits_state(fits_state) return - elif header is None: - if data is None and window is None and filename is None: - raise InvalidData("Image must have either data or a window to construct itself.") - self.header = Image_Header( - data_shape=None if data is None else data.shape, - pixelscale=pixelscale, - wcs=wcs, - window=window, - filename=filename, - zeropoint=zeropoint, - metadata=metadata, - origin=origin, - center=center, - identity=identity, - **kwargs, - ) - else: - self.header = header - if filename is not None: self.load(filename) - elif state is not None: - self.set_state(state) - elif fits_state is not None: - self.data = fits_state[0]["DATA"] + return + + if identity is None: + self.identity = id(self) else: - # set the data - if data is None: - self.data = torch.zeros( - torch.flip(self.window.pixel_shape, (0,)).detach().cpu().tolist(), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + self.identity = identity + + if wcs is not None: + if wcs.wcs.ctype[0] != "RA---TAN": # fixme handle sip + AP_config.ap_logger.warning( + "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." + ) + if wcs.wcs.ctype[1] != "DEC--TAN": + AP_config.ap_logger.warning( + "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." ) - else: - self.data = data - self.to() + if "crpix" in kwargs or "crval" in kwargs: + AP_config.ap_logger.warning( + "WCS crpix/crval set with supplied WCS, ignoring user supplied crpix/crval!" + ) + kwargs["crval"] = wcs.wcs.crval + kwargs["crpix"] = wcs.wcs.crpix - # # Check that image data and header are in agreement (this requires talk back from GPU to CPU so is only used for testing) - # assert np.all(np.flip(np.array(self.data.shape)[:2]) == self.window.pixel_shape.numpy()), f"data shape {np.flip(np.array(self.data.shape)[:2])}, window shape {self.window.pixel_shape.numpy()}" + if pixelscale is not None: + AP_config.ap_logger.warning( + "WCS pixelscale set with supplied WCS, ignoring user supplied pixelscale!" + ) + pixelscale = deg_to_arcsec * wcs.pixel_scale_matrix + + self.crval = Param("crval", kwargs.get("crval", self.default_crval), units="deg") + self.crtan = Param("crtan", kwargs.get("crtan", self.default_crtan), units="arcsec") + self.crpix = Param("crpix", kwargs.get("crpix", self.default_crpix), units="pixel") + if pixelscale is None: + pixelscale = self.default_pixelscale + elif isinstance(pixelscale, (float, int)): + AP_config.ap_logger.warning( + "Assuming diagonal pixelscale with the same value on both axes, please provide a full matrix to remove this message!" + ) + pixelscale = ((pixelscale, 0.0), (0.0, pixelscale)) + self.pixelscale = Param("pixelscale", pixelscale, shape=(2, 2), units="arcsec/pixel") - @property - def north(self): - return self.header.north + self.zeropoint = zeropoint - @property - def pixel_area(self): - return self.header.pixel_area + # set the data + if data is None: + self.data = torch.zeros( + torch.flip(self.window.pixel_shape, (0,)).detach().cpu().tolist(), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + else: + self.data = data @property - def pixel_length(self): - return self.header.pixel_length - - def world_to_plane(self, *args, **kwargs): - return self.window.world_to_plane(*args, **kwargs) - - def plane_to_world(self, *args, **kwargs): - return self.window.plane_to_world(*args, **kwargs) + @forward + def pixel_area(self, pixelscale): + """The area inside a pixel in arcsec^2""" + return torch.linalg.det(pixelscale).abs() - def plane_to_pixel(self, *args, **kwargs): - return self.window.plane_to_pixel(*args, **kwargs) - - def pixel_to_plane(self, *args, **kwargs): - return self.window.pixel_to_plane(*args, **kwargs) - - def plane_to_pixel_delta(self, *args, **kwargs): - return self.window.plane_to_pixel_delta(*args, **kwargs) - - def pixel_to_plane_delta(self, *args, **kwargs): - return self.window.pixel_to_plane_delta(*args, **kwargs) - - def world_to_pixel(self, *args, **kwargs): - return self.window.world_to_pixel(*args, **kwargs) - - def pixel_to_world(self, *args, **kwargs): - return self.window.pixel_to_world(*args, **kwargs) - - def get_coordinate_meshgrid(self): - return self.window.get_coordinate_meshgrid() + @property + @forward + def pixel_length(self, pixelscale): + """The approximate length of a pixel, which is just + sqrt(pixel_area). For square pixels this is the actual pixel + length, for rectangular pixels it is a kind of average. - def get_coordinate_corner_meshgrid(self): - return self.window.get_coordinate_corner_meshgrid() + The pixel_length is typically not used for exact calculations + and instead sets a size scale within an image. - def get_coordinate_simps_meshgrid(self): - return self.window.get_coordinate_simps_meshgrid() + """ + return torch.linalg.det(pixelscale).abs().sqrt() @property - def origin(self) -> torch.Tensor: - """ - Returns the origin (bottom-left corner) of the image window. + @forward + def pixelscale_inv(self, pixelscale): + """The inverse of the pixel scale matrix, which is used to + transform tangent plane coordinates into pixel coordinates. - Returns: - torch.Tensor: A 1D tensor of shape (2,) containing the (x, y) coordinates of the origin. """ - return self.header.window.origin + return torch.linalg.inv(pixelscale) - @property - def shape(self) -> torch.Tensor: - """ - Returns the shape (size) of the image window. + @forward + def pixel_to_plane(self, i, j, crpix, crtan, pixelscale): + return func.pixel_to_plane_linear(i, j, *crpix, pixelscale, *crtan) - Returns: - torch.Tensor: A 1D tensor of shape (2,) containing the (width, height) of the window in pixels. - """ - return self.header.window.shape + @forward + def plane_to_pixel(self, x, y, crpix, crtan): + return func.plane_to_pixel_linear(x, y, *crpix, self.pixelscale_inv, *crtan) - @property - def center(self) -> torch.Tensor: - """ - Returns the center of the image window. + @forward + def plane_to_world(self, x, y, crval, crtan): + return func.plane_to_world_gnomonic(x, y, *crval, *crtan) - Returns: - torch.Tensor: A 1D tensor of shape (2,) containing the (x, y) coordinates of the center. - """ - return self.header.window.center + @forward + def world_to_plane(self, ra, dec, crval, crtan): + return func.world_to_plane_gnomonic(ra, dec, *crval, *crtan) - @property - def size(self) -> torch.Tensor: - """ - Returns the size of the image window, the number of pixels in the image. + @forward + def world_to_pixel(self, ra, dec=None): + """A wrapper which applies :meth:`world_to_plane` then + :meth:`plane_to_pixel`, see those methods for further + information. - Returns: - torch.Tensor: A 0D tensor containing the number of pixels. """ - return self.header.window.size + if dec is None: + ra, dec = ra[0], ra[1] + return self.plane_to_pixel(*self.world_to_plane(ra, dec)) - @property - def window(self): - return self.header.window + @forward + def pixel_to_world(self, i, j=None): + """A wrapper which applies :meth:`pixel_to_plane` then + :meth:`plane_to_world`, see those methods for further + information. - @property - def pixelscale(self): - return self.header.pixelscale - - @property - def zeropoint(self): - return self.header.zeropoint + """ + if j is None: + i, j = i[0], i[1] + return self.plane_to_world(*self.pixel_to_plane(i, j)) + + @forward + def get_pixel_center_meshgrid(self): + i, j = func.pixel_center_meshgrid( + self.data.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + return self.pixel_to_plane(i, j) - @property - def metadata(self): - return self.header.metadata + @forward + def get_pixel_corner_meshgrid(self): + i, j = func.pixel_corner_meshgrid( + self.data.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + return self.pixel_to_plane(i, j) - @property - def identity(self): - return self.header.identity + @forward + def get_pixel_simps_meshgrid(self): + i, j = func.pixel_simpsons_meshgrid( + self.data.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + return self.pixel_to_plane(i, j) @property def data(self) -> torch.Tensor: @@ -237,30 +223,11 @@ def data(self) -> torch.Tensor: return self._data @data.setter - def data(self, data) -> None: + def data(self, data): """Set the image data.""" - self.set_data(data) - - def set_data(self, data: Union[torch.Tensor, np.ndarray], require_shape: bool = True): - """ - Set the image data. - - Args: - data (torch.Tensor or numpy.ndarray): The image data. - require_shape (bool): Whether to check that the shape of the data is the same as the current data. - - Raises: - SpecificationConflict: If `require_shape` is `True` and the shape of the data is different from the current data. - """ - if self._data is not None and require_shape and data.shape != self._data.shape: - raise SpecificationConflict( - f"Attempting to change image data with tensor that has a different shape! ({data.shape} vs {self._data.shape}) Use 'require_shape = False' if this is desired behaviour." - ) if data is None: - self.data = torch.tensor((), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - elif isinstance(data, torch.Tensor): - self._data = data.to(dtype=AP_config.ap_dtype, device=AP_config.ap_device) + self._data = torch.tensor((), dtype=AP_config.ap_dtype, device=AP_config.ap_device) else: self._data = torch.as_tensor(data, dtype=AP_config.ap_dtype, device=AP_config.ap_device) @@ -270,82 +237,83 @@ def copy(self, **kwargs): an image and then will want the original again. """ - return self.__class__( - data=torch.clone(self.data), - header=self.header.copy(**kwargs), - **kwargs, - ) + copy_kwargs = { + "data": torch.clone(self.data), + "pixelscale": self.pixelscale.value, + "crpix": self.crpix.value, + "crval": self.crval.value, + "crtan": self.crtan.value, + "zeropoint": self.zeropoint, + "identity": self.identity, + } + copy_kwargs.update(kwargs) + return self.__class__(**copy_kwargs) def blank_copy(self, **kwargs): """Produces a blank copy of the image which has the same properties except that its data is now filled with zeros. """ - return self.__class__( - data=torch.zeros_like(self.data), - header=self.header.copy(**kwargs), - **kwargs, - ) - - def get_window(self, window, **kwargs): - """Get a sub-region of the image as defined by a window on the sky.""" - return self.__class__( - data=self.data[self.window.get_self_indices(window)], - header=self.header.get_window(window, **kwargs), - **kwargs, - ) + copy_kwargs = { + "data": torch.zeros_like(self.data), + "pixelscale": self.pixelscale.value, + "crpix": self.crpix.value, + "crval": self.crval.value, + "crtan": self.crtan.value, + "zeropoint": self.zeropoint, + "identity": self.identity, + } + copy_kwargs.update(kwargs) + return self.__class__(**copy_kwargs) def to(self, dtype=None, device=None): if dtype is None: dtype = AP_config.ap_dtype if device is None: device = AP_config.ap_device + super().to(dtype=dtype, device=device) if self._data is not None: self._data = self._data.to(dtype=dtype, device=device) - self.header.to(dtype=dtype, device=device) return self - def crop(self, pixels): - # does this show up? - if len(pixels) == 1: # same crop in all dimension - self.set_data( - self.data[ - pixels[0].int() : (self.data.shape[0] - pixels[0]).int(), - pixels[0].int() : (self.data.shape[1] - pixels[0]).int(), - ], - require_shape=False, - ) + def crop(self, pixels): # fixme move to func + """Crop the image by the number of pixels given. This will crop + the image in all four directions by the number of pixels given. + + given data shape (N, M) the new shape will be: + + crop - int: crop the same number of pixels on all sides. new shape (N - 2*crop, M - 2*crop) + crop - (int, int): crop each dimension by the number of pixels given. new shape (N - 2*crop[1], M - 2*crop[0]) + crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) + """ + if isinstance(pixels, int) or len(pixels) == 1: # same crop in all dimension + crop = pixels if isinstance(pixels, int) else pixels[0] + self.data = self.data[ + crop : self.data.shape[0] - crop, + crop : self.data.shape[1] - crop, + ] + self.crpix = self.crpix.value - crop elif len(pixels) == 2: # different crop in each dimension - self.set_data( - self.data[ - pixels[1].int() : (self.data.shape[0] - pixels[1]).int(), - pixels[0].int() : (self.data.shape[1] - pixels[0]).int(), - ], - require_shape=False, - ) + self.data = self.data[ + pixels[1] : self.data.shape[0] - pixels[1], + pixels[0] : self.data.shape[1] - pixels[0], + ] + self.crpix = self.crpix.value - pixels elif len(pixels) == 4: # different crop on all sides - self.set_data( - self.data[ - pixels[2].int() : (self.data.shape[0] - pixels[3]).int(), - pixels[0].int() : (self.data.shape[1] - pixels[1]).int(), - ], - require_shape=False, + self.data = self.data[ + pixels[2] : self.data.shape[0] - pixels[3], + pixels[0] : self.data.shape[1] - pixels[1], + ] + self.crpix = self.crpix.value - pixels[0::2] # fixme + else: + raise ValueError( + f"Invalid crop shape {pixels}, must be int, (int,), (int, int), or (int, int, int, int)!" ) - self.header = self.header.crop(pixels) return self def flatten(self, attribute: str = "data") -> np.ndarray: return getattr(self, attribute).reshape(-1) - def get_coordinate_meshgrid(self): - return self.header.get_coordinate_meshgrid() - - def get_coordinate_corner_meshgrid(self): - return self.header.get_coordinate_corner_meshgrid() - - def get_coordinate_simps_meshgrid(self): - return self.header.get_coordinate_simps_meshgrid() - def reduce(self, scale: int, **kwargs): """This operation will downsample an image by the factor given. If scale = 2 then 2x2 blocks of pixels will be summed together to @@ -368,36 +336,33 @@ def reduce(self, scale: int, **kwargs): MS = self.data.shape[0] // scale NS = self.data.shape[1] // scale - return self.__class__( - data=self.data[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .sum(axis=(1, 3)), - header=self.header.rescale_pixel(scale, **kwargs), - **kwargs, - ) - def expand(self, padding: Tuple[float]) -> None: - """ - Args: - padding tuple[float]: length 4 tuple with amounts to pad each dimension in physical units - """ - padding = np.array(padding) - if np.any(padding < 0): - raise SpecificationConflict("negative padding not allowed in expand method") - pad_boundaries = tuple(np.int64(np.round(np.array(padding) / self.pixelscale))) - self.data = pad(self.data, pad=pad_boundaries, mode="constant", value=0) - self.header.expand(padding) + self.data = ( + self.data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) + ) + self.pixelscale = self.pixelscale.value * scale + self.crpix = (self.crpix.value + 0.5) / scale - 0.5 def get_state(self): state = {} state["type"] = self.__class__.__name__ state["data"] = self.data.detach().cpu().tolist() - state["header"] = self.header.get_state() + state["crpix"] = self.crpix.npvalue + state["crtan"] = self.crtan.npvalue + state["crval"] = self.crval.npvalue + state["pixelscale"] = self.pixelscale.npvalue + state["zeropoint"] = self.zeropoint + state["identity"] = self.identity return state def set_state(self, state): - self.set_data(state["data"], require_shape=False) - self.header.set_state(state["header"]) + self.data = state["data"] + self.crpix = state["crpix"] + self.crtan = state["crtan"] + self.crval = state["crval"] + self.pixelscale = state["pixelscale"] + self.zeropoint = state["zeropoint"] + self.identity = state["identity"] def get_fits_state(self): states = [{}] @@ -413,6 +378,25 @@ def set_fits_state(self, states): self.header.set_fits_state(state["HEADER"]) break + def get_astropywcs(self, **kwargs): + wargs = { + "NAXIS": 2, + "NAXIS1": self.pixel_shape[0].item(), + "NAXIS2": self.pixel_shape[1].item(), + "CTYPE1": "RA---TAN", + "CTYPE2": "DEC--TAN", + "CRVAL1": self.pixel_to_world(self.reference_imageij)[0].item(), + "CRVAL2": self.pixel_to_world(self.reference_imageij)[1].item(), + "CRPIX1": self.reference_imageij[0].item(), + "CRPIX2": self.reference_imageij[1].item(), + "CD1_1": self.pixelscale[0][0].item(), + "CD1_2": self.pixelscale[0][1].item(), + "CD2_1": self.pixelscale[1][0].item(), + "CD2_2": self.pixelscale[1][1].item(), + } + wargs.update(kwargs) + return AstropyWCS(wargs) + def save(self, filename=None, overwrite=True): states = self.get_fits_state() img_list = [fits.PrimaryHDU(states[0]["DATA"], header=fits.Header(states[0]["HEADER"]))] @@ -428,10 +412,40 @@ def load(self, filename): states = list({"DATA": hdu.data, "HEADER": hdu.header} for hdu in hdul) self.set_fits_state(states) + @torch.no_grad() + def get_indices(self, other: "Image"): + origin_pix = torch.round(self.plane_to_pixel(other.pixel_to_plane(-0.5, -0.5)) + 0.5).int() + new_origin_pix = torch.maximum(torch.zeros_like(origin_pix), origin_pix) + + end_pix = torch.round( + self.plane_to_pixel( + other.pixel_to_plane(other.data.shape[0] - 0.5, other.data.shape[1] - 0.5) + ) + + 0.5 + ).int() + new_end_pix = torch.minimum(self.data.shape, end_pix) + return slice(new_origin_pix[1], new_end_pix[1]), slice(new_origin_pix[0], new_end_pix[0]) + + def get_window(self, other: "Image"): + """Get a new image object which is a window of this image + corresponding to the other image's window. This will return a + new image object with the same properties as this one, but with + the data cropped to the other image's window. + + """ + if not isinstance(other, Image): + raise InvalidWindow("get_window only works with Image objects!") + indices = self.get_indices(other) + new_img = self.copy( + data=self.data[indices], + crpix=self.crpix.value - (indices[0].start, indices[1].start), + ) + return new_img + def __sub__(self, other): if isinstance(other, Image): - new_img = self[other.window].copy() - new_img.data -= other.data[self.window.get_other_indices(other)] + new_img = self[other] + new_img.data -= other[self].data return new_img else: new_img = self.copy() @@ -440,8 +454,8 @@ def __sub__(self, other): def __add__(self, other): if isinstance(other, Image): - new_img = self[other.window].copy() - new_img.data += other.data[self.window.get_other_indices(other)] + new_img = self[other] + new_img.data += other[self].data return new_img else: new_img = self.copy() @@ -450,82 +464,31 @@ def __add__(self, other): def __iadd__(self, other): if isinstance(other, Image): - self.data[other.window.get_other_indices(self)] += other.data[ - self.window.get_other_indices(other) - ] + self.data[self.get_indices(other)] += other.data[other.get_indices(self)] else: self.data += other return self def __isub__(self, other): if isinstance(other, Image): - self.data[other.window.get_other_indices(self)] -= other.data[ - self.window.get_other_indices(other) - ] + self.data[self.get_indices(other)] -= other.data[other.get_indices(self)] else: self.data -= other return self def __getitem__(self, *args): - if len(args) == 1 and isinstance(args[0], Window): - return self.get_window(args[0]) if len(args) == 1 and isinstance(args[0], Image): - return self.get_window(args[0].window) + return self.get_window(args[0]) raise ValueError("Unrecognized Image getitem request!") - def __str__(self): - return f"image pixelscale: {self.pixelscale.detach().cpu().numpy()} origin: {self.origin.detach().cpu().numpy()} shape: {self.shape.detach().cpu().numpy()}" - - def __repr__(self): - return f"image pixelscale: {self.pixelscale.detach().cpu().numpy()} origin: {self.origin.detach().cpu().numpy()} shape: {self.shape.detach().cpu().numpy()} center: {self.center.detach().cpu().numpy()}\ndata: {self.data.detach().cpu().numpy()}" - -class Image_List(Image): - def __init__(self, image_list, window=None): +class Image_List(Module): + def __init__(self, image_list): self.image_list = list(image_list) - self.check_wcs() - self.window = window - - def check_wcs(self): - """Ensure the WCS systems being used by all the windows in this list - are consistent with each other. They should all project world - coordinates onto the same tangent plane. - - """ - ref = torch.stack(tuple(I.window.reference_radec for I in self.image_list)) - if not torch.allclose(ref, ref[0]): - raise ConflicingWCS( - "Reference (world) coordinate mismatch! All images in Image_List are not on the same tangent plane! Likely serious coordinate mismatch problems. See the coordinates page in the documentation for what this means." - ) - ref = torch.stack(tuple(I.window.reference_planexy for I in self.image_list)) - if not torch.allclose(ref, ref[0]): - raise ConflicingWCS( - "Reference (tangent plane) coordinate mismatch! All images in Image_List are not on the same tangent plane! Likely serious coordinate mismatch problems. See the coordinates page in the documentation for what this means." - ) - - if len(set(I.window.projection for I in self.image_list)) > 1: - raise ConflicingWCS( - "Projection mismatch! All images in Image_List are not on the same tangent plane! Likely serious coordinate mismatch problems. See the coordinates page in the documentation for what this means." - ) - - @property - def window(self): - return Window_List(list(image.window for image in self.image_list)) - - @window.setter - def window(self, window): - if window is None: - return - - if not isinstance(window, Window_List): - raise InvalidWindow("Target_List must take a Window_List object as its window") - - for i in range(len(self.image_list)): - self.image_list[i] = self.image_list[i][window.window_list[i]] @property def pixelscale(self): - return tuple(image.pixelscale for image in self.image_list) + return tuple(image.pixelscale.value for image in self.image_list) @property def zeropoint(self): @@ -550,92 +513,72 @@ def blank_copy(self): tuple(image.blank_copy() for image in self.image_list), ) - def get_window(self, window): + def get_window(self, other: "Image_List"): return self.__class__( - tuple(image[win] for image, win in zip(self.image_list, window)), + tuple(image[win] for image, win in zip(self.image_list, other.image_list)), ) def index(self, other): - if isinstance(other, Image) and hasattr(other, "identity"): - for i, self_image in enumerate(self.image_list): - if other.identity == self_image.identity: - return i - else: - raise ValueError("Could not find identity match between image list and input image") - raise NotImplementedError(f"Image_List cannot get index for {type(other)}") + for i, image in enumerate(self.image_list): + if other.identity == image.identity: + return i + else: + raise ValueError("Could not find identity match between image list and input image") def to(self, dtype=None, device=None): if dtype is not None: dtype = AP_config.ap_dtype if device is not None: device = AP_config.ap_device - for image in self.image_list: - image.to(dtype=dtype, device=device) + super().to(dtype=dtype, device=device) return self def crop(self, *pixels): raise NotImplementedError("Crop function not available for Image_List object") - def get_coordinate_meshgrid(self): - return tuple(image.get_coordinate_meshgrid() for image in self.image_list) - - def get_coordinate_corner_meshgrid(self): - return tuple(image.get_coordinate_corner_meshgrid() for image in self.image_list) - - def get_coordinate_simps_meshgrid(self): - return tuple(image.get_coordinate_simps_meshgrid() for image in self.image_list) - def flatten(self, attribute="data"): return torch.cat(tuple(image.flatten(attribute) for image in self.image_list)) - def reduce(self, scale): - if scale == 1: - return self - - return self.__class__( - tuple(image.reduce(scale) for image in self.image_list), - ) - def __sub__(self, other): if isinstance(other, Image_List): new_list = [] - for self_image, other_image in zip(self.image_list, other.image_list): + for other_image in other.image_list: + i = self.index(other_image) + self_image = self.image_list[i] new_list.append(self_image - other_image) return self.__class__(new_list) else: - new_list = [] - for self_image, other_image in zip(self.image_list, other): - new_list.append(self_image - other_image) - return self.__class__(new_list) + raise ValueError("Subtraction of Image_List only works with another Image_List object!") def __add__(self, other): if isinstance(other, Image_List): new_list = [] - for self_image, other_image in zip(self.image_list, other.image_list): + for other_image in other.image_list: + i = self.index(other_image) + self_image = self.image_list[i] new_list.append(self_image + other_image) return self.__class__(new_list) else: - new_list = [] - for self_image, other_image in zip(self.image_list, other): - new_list.append(self_image + other_image) - return self.__class__(new_list) + raise ValueError("Addition of Image_List only works with another Image_List object!") def __isub__(self, other): if isinstance(other, Image_List): - for self_image, other_image in zip(self.image_list, other.image_list): + for other_image in other.image_list: + i = self.index(other_image) + self_image = self.image_list[i] self_image -= other_image else: - for self_image, other_image in zip(self.image_list, other): - self_image -= other_image + raise ValueError("Subtraction of Image_List only works with another Image_List object!") return self def __iadd__(self, other): if isinstance(other, Image_List): - for self_image, other_image in zip(self.image_list, other.image_list): + for other_image in other.image_list: + i = self.index(other_image) + self_image = self.image_list[i] self_image += other_image else: - for self_image, other_image in zip(self.image_list, other): - self_image += other_image + raise ValueError("Addition of Image_List only works with another Image_List object!") return self def save(self, filename=None, overwrite=True): @@ -645,29 +588,14 @@ def load(self, filename): raise NotImplementedError("Save/load not yet available for image lists") def __getitem__(self, *args): - if len(args) == 1 and isinstance(args[0], Window): - return self.get_window(args[0]) - if len(args) == 1 and isinstance(args[0], Image): - return self.get_window(args[0].window) - if all(isinstance(arg, (int, slice)) for arg in args): - return self.image_list.__getitem__(*args) + if len(args) == 1 and isinstance(args[0], Image_List): + new_list = [] + for other_image in args[0].image_list: + i = self.index(other_image) + self_image = self.image_list[i] + new_list.append(self_image.get_window(other_image)) + return self.__class__(new_list) raise ValueError("Unrecognized Image_List getitem request!") - def __str__(self): - return "image list of:\n" + "\n".join(image.__str__() for image in self.image_list) - - def __repr__(self): - return "image list of:\n" + "\n".join(image.__repr__() for image in self.image_list) - def __iter__(self): return (img for img in self.image_list) - - # self._index = 0 - # return self - - # def __next__(self): - # if self._index >= len(self.image_list): - # raise StopIteration - # img = self.image_list[self._index] - # self._index += 1 - # return img diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 94408723..110a68c1 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -88,10 +88,10 @@ def __init__(self, *args, **kwargs): self.set_mask(kwargs.get("mask", None)) if not self.has_weight and "weight" in kwargs: self.set_weight(kwargs.get("weight", None)) - elif not self.has_variance and "variance" in kwargs: + elif not self.has_variance: self.set_variance(kwargs.get("variance", None)) if not self.has_psf: - self.set_psf(kwargs.get("psf", None), kwargs.get("psf_upscale", 1)) + self.set_psf(kwargs.get("psf", None)) # Set nan pixels to be masked automatically if torch.any(torch.isnan(self.data)).item(): diff --git a/astrophot/image/wcs.py b/astrophot/image/wcs.py deleted file mode 100644 index 0b722820..00000000 --- a/astrophot/image/wcs.py +++ /dev/null @@ -1,893 +0,0 @@ -import torch -import numpy as np -from caskade import Module, Param, forward - -from .. import AP_config -from ..utils.conversions.units import deg_to_arcsec -from ..errors import InvalidWCS -from . import func - - -__all__ = ("WPCS", "PPCS", "WCS") - -deg_to_rad = np.pi / 180 -rad_to_deg = 180 / np.pi -rad_to_arcsec = rad_to_deg * 3600 -arcsec_to_rad = deg_to_rad / 3600 - - -class WPCS: - """World to Plane Coordinate System in AstroPhot. - - AstroPhot performs its operations on a tangent plane to the - celestial sphere, this class handles projections between the sphere and the - tangent plane. It holds variables for the reference (RA,DEC) where - the tangent plane contacts the sphere, and the type of projection - being performed. Note that (RA,DEC) coordinates should always be - in degrees while the tangent plane is in arcsecs. - - Attributes: - reference_radec: The reference (RA,DEC) coordinates in degrees where the tangent plane contacts the sphere. - reference_planexy: The reference tangent plane coordinates in arcsec where the tangent plane contacts the sphere. - projection: The projection system used to convert from (RA,DEC) onto the tangent plane. Should be one of: gnomonic (default), orthographic, steriographic - - """ - - # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0). This is in units of arcsec. - softening = 1e-3 - - default_reference_radec = (0, 0) - default_reference_planexy = (0, 0) - default_projection = "gnomonic" - - def __init__(self, **kwargs): - self.projection = kwargs.get("projection", self.default_projection) - self.reference_radec = kwargs.get("reference_radec", self.default_reference_radec) - self.reference_planexy = kwargs.get("reference_planexy", self.default_reference_planexy) - - def world_to_plane(self, world_RA, world_DEC=None): - """Take a coordinate on the world coordinate system, also called the - celesial sphere, (RA, DEC in degrees) and transform it to the - corresponding tangent plane coordinate - (arcsec). Transformation is done based on the chosen - projection (default gnomonic) and reference positions. See the - :doc:`coordinates` documentation for more details on how the - transformation is performed. - - """ - - if world_DEC is None: - return torch.stack(self.world_to_plane(*world_RA)) - - world_RA = torch.as_tensor(world_RA, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - world_DEC = torch.as_tensor(world_DEC, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - if self.projection == "gnomonic": - coords = self._world_to_plane_gnomonic( - world_RA, - world_DEC, - ) - elif self.projection == "orthographic": - coords = self._world_to_plane_orthographic( - world_RA, - world_DEC, - ) - elif self.projection == "steriographic": - coords = self._world_to_plane_steriographic( - world_RA, - world_DEC, - ) - return ( - coords[0] + self.reference_planexy[0], - coords[1] + self.reference_planexy[1], - ) - - def plane_to_world(self, plane_x, plane_y=None): - """Take a coordinate on the tangent plane (arcsec), and transform it - to the corresponding world coordinate (RA, DEC in - degrees). Transformation is done based on the chosen - projection (default gnomonic) and reference positions. See the - :doc:`coordinates` documentation for more details on how the - transformation is performed. - - """ - - if plane_y is None: - return torch.stack(self.plane_to_world(*plane_x)) - plane_x = torch.as_tensor( - plane_x - self.reference_planexy[0], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - plane_y = torch.as_tensor( - plane_y - self.reference_planexy[1], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - if self.projection == "gnomonic": - return self._plane_to_world_gnomonic( - plane_x, - plane_y, - ) - if self.projection == "orthographic": - return self._plane_to_world_orthographic( - plane_x, - plane_y, - ) - if self.projection == "steriographic": - return self._plane_to_world_steriographic( - plane_x, - plane_y, - ) - - @property - def projection(self): - """ - The mathematical projection formula which described how world coordinates are mapped to the tangent plane. - """ - return self._projection - - @projection.setter - def projection(self, proj): - if proj not in ( - "gnomonic", - "orthographic", - "steriographic", - ): - raise InvalidWCS( - f"Unrecognized projection: {proj}. Should be one of: gnomonic, orthographic, steriographic" - ) - self._projection = proj - - @property - def reference_radec(self): - """ - RA DEC (world) coordinates where the tangent plane meets the celestial sphere. These should be in degrees. - """ - return self._reference_radec - - @reference_radec.setter - def reference_radec(self, radec): - self._reference_radec = torch.as_tensor( - radec, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - - @property - def reference_planexy(self): - """ - x y tangent plane coordinates where the tangent plane meets the celestial sphere. These should be in arcsec. - """ - return self._reference_planexy - - @reference_planexy.setter - def reference_planexy(self, planexy): - self._reference_planexy = torch.as_tensor( - planexy, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - - def _project_world_to_plane(self, world_RA, world_DEC): - """ - Recurring core calculation in all the projections from world to plane. - - Args: - world_RA: Right ascension in degrees - world_DEC: Declination in degrees - """ - return ( - torch.cos(world_DEC * deg_to_rad) - * torch.sin((world_RA - self.reference_radec[0]) * deg_to_rad) - * rad_to_arcsec, - ( - torch.cos(self.reference_radec[1] * deg_to_rad) * torch.sin(world_DEC * deg_to_rad) - - torch.sin(self.reference_radec[1] * deg_to_rad) - * torch.cos(world_DEC * deg_to_rad) - * torch.cos((world_RA - self.reference_radec[0]) * deg_to_rad) - ) - * rad_to_arcsec, - ) - - def _project_plane_to_world(self, plane_x, plane_y, rho, c): - """ - Recurring core calculation in all the projections from plane to world. - - Args: - plane_x: tangent plane x coordinate in arcseconds. - plane_y: tangent plane y coordinate in arcseconds. - rho: polar radius on tangent plane. - c: coordinate term dependent on the projection. - """ - return ( - ( - self._reference_radec[0] * deg_to_rad - + torch.arctan2( - plane_x * arcsec_to_rad * torch.sin(c), - rho * torch.cos(self.reference_radec[1] * deg_to_rad) * torch.cos(c) - - plane_y - * arcsec_to_rad - * torch.sin(self.reference_radec[1] * deg_to_rad) - * torch.sin(c), - ) - ) - * rad_to_deg, - torch.arcsin( - torch.cos(c) * torch.sin(self.reference_radec[1] * deg_to_rad) - + plane_y - * arcsec_to_rad - * torch.sin(c) - * torch.cos(self.reference_radec[1] * deg_to_rad) - / rho - ) - * rad_to_deg, - ) - - def _world_to_plane_gnomonic(self, world_RA, world_DEC): - """Gnomonic projection: (RA,DEC) to tangent plane. - - Performs Gnomonic projection of (RA,DEC) coordinates onto a - tangent plane. The tangent plane makes contact at the location - of the `reference_radec` variable. In a gnomonic projection, - great circles are mapped to straight lines. The gnomonic - projection represents the image formed by a spherical lens, - and is sometimes known as the rectilinear projection. - - Args: - world_RA: Right ascension in degrees - world_DEC: Declination in degrees - - See: https://mathworld.wolfram.com/GnomonicProjection.html - - """ - C = torch.sin(self.reference_radec[1] * deg_to_rad) * torch.sin( - world_DEC * deg_to_rad - ) + torch.cos(self.reference_radec[1] * deg_to_rad) * torch.cos( - world_DEC * deg_to_rad - ) * torch.cos( - (world_RA - self.reference_radec[0]) * deg_to_rad - ) - x, y = self._project_world_to_plane(world_RA, world_DEC) - return x / C, y / C - - def _plane_to_world_gnomonic(self, plane_x, plane_y): - """Inverse Gnomonic projection: tangent plane to (RA,DEC). - - Performs the inverse Gnomonic projection of tangent plane - coordinates into (RA,DEC) coordinates. The tangent plane makes - contact at the location of the `reference_radec` variable. In - a gnomonic projection, great circles are mapped to straight - lines. The gnomonic projection represents the image formed by - a spherical lens, and is sometimes known as the rectilinear - projection. - - Args: - plane_x: tangent plane x coordinate in arcseconds. - plane_y: tangent plane y coordinate in arcseconds. - - See: https://mathworld.wolfram.com/GnomonicProjection.html - - """ - rho = (torch.sqrt(plane_x**2 + plane_y**2) + self.softening) * arcsec_to_rad - c = torch.arctan(rho) - - ra, dec = self._project_plane_to_world(plane_x, plane_y, rho, c) - return ra, dec - - def _world_to_plane_steriographic(self, world_RA, world_DEC): - """Steriographic projection: (RA,DEC) to tangent plane - - Performs Steriographic projection of (RA,DEC) coordinates onto - a tangent plane. The tangent plane makes contact at the - location of the `reference_radec` variable. The steriographic - projection preserves circles and angle measures. - - Args: - world_RA: Right ascension in degrees - world_DEC: Declination in degrees - - See: https://mathworld.wolfram.com/StereographicProjection.html - - """ - C = ( - 1 - + torch.sin(world_DEC * deg_to_rad) * torch.sin(self._reference_radec[1] * deg_to_rad) - + torch.cos(world_DEC * deg_to_rad) - * torch.cos(self._reference_radec[1] * deg_to_rad) - * torch.cos((world_RA - self._reference_radec[0]) * deg_to_rad) - ) / 2 - x, y = self._project_world_to_plane(world_RA, world_DEC) - return x / C, y / C - - def _plane_to_world_steriographic(self, plane_x, plane_y): - """Inverse Steriographic projection: tangent plane to (RA,DEC). - - Performs the inverse Steriographic projection of tangent plane - coordinates into (RA,DEC) coordinates. The tangent plane makes - contact at the location of the `reference_radec` variable. The - steriographic projection preserves circles and angle measures. - - Args: - plane_x: tangent plane x coordinate in arcseconds. The origin of the tangent plane is the contact point with the sphere, represented by `reference_radec`. - plane_y: tangent plane y coordinate in arcseconds. The origin of the tangent plane is the contact point with the sphere, represented by `reference_radec`. - - See: https://mathworld.wolfram.com/StereographicProjection.html - - """ - rho = (torch.sqrt(plane_x**2 + plane_y**2) + self.softening) * arcsec_to_rad - c = 2 * torch.arctan(rho / 2) - ra, dec = self._project_plane_to_world(plane_x, plane_y, rho, c) - return ra, dec - - def _world_to_plane_orthographic(self, world_RA, world_DEC): - """Orthographic projection: (RA,DEC) to tangent plane - - Performs Orthographic projection of (RA,DEC) coordinates onto - a tangent plane. The tangent plane makes contact at the - location of the `reference_radec` variable. The point of - perspective for the orthographic projection is at infinite - distance. This projection is perhaps better suited to - represent the view of an exoplanet, however it is included - here for completeness. - - Args: - world_RA: Right ascension in degrees - world_DEC: Declination in degrees - - See: https://mathworld.wolfram.com/OrthographicProjection.html - - """ - x, y = self._project_world_to_plane(world_RA, world_DEC) - return x, y - - def _plane_to_world_orthographic(self, plane_x, plane_y): - """Inverse Orthographic projection: tangent plane to (RA,DEC). - - Performs the inverse Orthographic projection of tangent plane - coordinates into (RA,DEC) coordinates. The tangent plane makes - contact at the location of the `reference_radec` variable. The - point of perspective for the orthographic projection is at - infinite distance. This projection is perhaps better suited to - represent the view of an exoplanet, however it is included - here for completeness. - - Args: - plane_x: tangent plane x coordinate in arcseconds. The origin of the tangent plane is the contact point with the sphere, represented by `reference_radec`. - plane_y: tangent plane y coordinate in arcseconds. The origin of the tangent plane is the contact point with the sphere, represented by `reference_radec`. - - See: https://mathworld.wolfram.com/OrthographicProjection.html - - """ - rho = (torch.sqrt(plane_x**2 + plane_y**2) + self.softening) * arcsec_to_rad - c = torch.arcsin(rho) - - ra, dec = self._project_plane_to_world(plane_x, plane_y, rho, c) - return ra, dec - - def get_state(self): - """Returns a dictionary with the information needed to recreate the - WPCS object. - - """ - return { - "projection": self.projection, - "reference_radec": self.reference_radec.detach().cpu().tolist(), - "reference_planexy": self.reference_planexy.detach().cpu().tolist(), - } - - def set_state(self, state): - """Takes a state dictionary and re-creates the state of the WPCS - object. - - """ - self.projection = state.get("projection", self.default_projection) - self.reference_radec = state.get("reference_radec", self.default_reference_radec) - self.reference_planexy = state.get("reference_planexy", self.default_reference_planexy) - - def get_fits_state(self): - """ - Similar to get_state, except specifically tailored to be stored in a FITS format. - """ - return { - "PROJ": self.projection, - "REFRADEC": str(self.reference_radec.detach().cpu().tolist()), - "REFPLNXY": str(self.reference_planexy.detach().cpu().tolist()), - } - - def set_fits_state(self, state): - """ - Reads and applies the state from the get_fits_state function. - """ - self.projection = state["PROJ"] - self.reference_radec = eval(state["REFRADEC"]) - self.reference_planexy = eval(state["REFPLNXY"]) - - def copy(self, **kwargs): - """Create a copy of the WPCS object with the same projection - parameters. - - """ - copy_kwargs = { - "projection": self.projection, - "reference_radec": self.reference_radec, - "reference_planexy": self.reference_planexy, - } - copy_kwargs.update(kwargs) - return self.__class__( - **copy_kwargs, - ) - - def to(self, dtype=None, device=None): - """ - Convert all stored tensors to a new device and data type - """ - if dtype is None: - dtype = AP_config.ap_dtype - if device is None: - device = AP_config.ap_device - self._reference_radec = self._reference_radec.to(dtype=dtype, device=device) - self._reference_planexy = self._reference_planexy.to(dtype=dtype, device=device) - - def __str__(self): - return f"WPCS reference_radec: {self.reference_radec.detach().cpu().tolist()}, reference_planexy: {self.reference_planexy.detach().cpu().tolist()}" - - def __repr__(self): - return f"WPCS reference_radec: {self.reference_radec.detach().cpu().tolist()}, reference_planexy: {self.reference_planexy.detach().cpu().tolist()}, projection: {self.projection}" - - -class PPCS: - """ - plane to pixel coordinate system - - - Args: - pixelscale : float or None, optional - The physical scale of the pixels in the image, this is - represented as a matrix which projects pixel units into sky - units: ``pixelscale @ pixel_vec = sky_vec``. The pixel - scale matrix can be thought of in four components: - :math:`\\vec{s} @ F @ R @ S` where :math:`\\vec{s}` is the side - length of the pixels, :math:`F` is a diagonal matrix of {1,-1} - which flips the axes orientation, :math:`R` is a rotation - matrix, and :math:`S` is a shear matrix which turns - rectangular pixels into parallelograms. Default is None. - reference_imageij : Sequence or None, optional - The pixel coordinate at which the image is fixed to the - tangent plane. By default this is (-0.5, -0.5) or the bottom - corner of the [0,0] indexed pixel. - reference_imagexy : Sequence or None, optional - The tangent plane coordinate at which the image is fixed, - corresponding to the reference_imageij coordinate. These two - reference points ar pinned together, any rotations would occur - about this point. By default this is (0., 0.). - - """ - - default_reference_imageij = (-0.5, -0.5) - default_reference_imagexy = (0, 0) - default_pixelscale = 1 - - def __init__(self, *, wcs=None, pixelscale=None, **kwargs): - - self.reference_imageij = kwargs.get("reference_imageij", self.default_reference_imageij) - self.reference_imagexy = kwargs.get("reference_imagexy", self.default_reference_imagexy) - - # Collect the pixelscale of the pixel grid - if wcs is not None and pixelscale is None: - self.pixelscale = deg_to_arcsec * wcs.pixel_scale_matrix - elif pixelscale is not None: - if wcs is not None and isinstance(pixelscale, float): - AP_config.ap_logger.warning( - "Overriding WCS pixelscale with manual input! To remove this message, either let WCS define pixelscale, or input full pixelscale matrix" - ) - self.pixelscale = pixelscale - else: - AP_config.ap_logger.warning( - "Assuming pixelscale of 1! To remove this message please provide the pixelscale explicitly" - ) - self.pixelscale = self.default_pixelscale - - @property - def pixelscale(self): - """Matrix defining the shape of pixels in the tangent plane, these - can be any parallelogram defined by the matrix. - - """ - return self._pixelscale - - @pixelscale.setter - def pixelscale(self, pix): - if pix is None: - self._pixelscale = None - return - - self._pixelscale = ( - torch.as_tensor(pix, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - .clone() - .detach() - ) - if self._pixelscale.numel() == 1: - self._pixelscale = torch.tensor( - [[self._pixelscale.item(), 0.0], [0.0, self._pixelscale.item()]], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - self._pixel_area = torch.linalg.det(self.pixelscale).abs() - self._pixel_length = self._pixel_area.sqrt() - self._pixelscale_inv = torch.linalg.inv(self.pixelscale) - - @property - def pixel_area(self): - """The area inside a pixel in arcsec^2""" - return self._pixel_area - - @property - def pixel_length(self): - """The approximate length of a pixel, which is just - sqrt(pixel_area). For square pixels this is the actual pixel - length, for rectangular pixels it is a kind of average. - - The pixel_length is typically not used for exact calculations - and instead sets a size scale within an image. - - """ - return self._pixel_length - - @property - def reference_imageij(self): - """pixel coordinates where the pixel grid is fixed to the tangent - plane. These should be in pixel units where (0,0) is the - center of the [0,0] indexed pixel. However, it is still in xy - format, meaning that the first index gives translations in the - x-axis (horizontal-axis) of the image. - - """ - return self._reference_imageij - - @reference_imageij.setter - def reference_imageij(self, imageij): - self._reference_imageij = torch.as_tensor( - imageij, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - - @property - def reference_imagexy(self): - """plane coordinates where the image grid is fixed to the tangent - plane. These should be in arcsec. - - """ - return self._reference_imagexy - - @reference_imagexy.setter - def reference_imagexy(self, imagexy): - self._reference_imagexy = torch.as_tensor( - imagexy, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - - def pixel_to_plane(self, pixel_i, pixel_j=None): - """Take in a coordinate on the regular pixel grid, where 0,0 is the - center of the [0,0] indexed pixel. This coordinate is - transformed into the tangent plane coordinate system (arcsec) - based on the pixel scale and reference positions. If the pixel - scale matrix is :math:`P`, the reference pixel is - :math:`\\vec{r}_{pix}`, the reference tangent plane point is - :math:`\\vec{r}_{tan}`, and the coordinate to transform is - :math:`\\vec{c}_{pix}` then the coordinate in the tangent plane - is: - - .. math:: - - \\vec{c}_{tan} = [P(\\vec{c}_{pix} - \\vec{r}_{pix})] + \\vec{r}_{tan} - - """ - if pixel_j is None: - return torch.stack(self.pixel_to_plane(*pixel_i)) - coords = torch.mm( - self.pixelscale, - torch.stack((pixel_i.reshape(-1), pixel_j.reshape(-1))) - - self.reference_imageij.view(2, 1), - ) + self.reference_imagexy.view(2, 1) - return coords[0].reshape(pixel_i.shape), coords[1].reshape(pixel_j.shape) - - def plane_to_pixel(self, plane_x, plane_y=None): - """Take a coordinate on the tangent plane (arcsec) and transform it to - the corresponding pixel grid coordinate (pixel units where - (0,0) is the [0,0] indexed pixel). Transformation is done - based on the pixel scale and reference positions. If the pixel - scale matrix is :math:`P`, the reference pixel is - :math:`\\vec{r}_{pix}`, the reference tangent plane point is - :math:`\\vec{r}_{tan}`, and the coordinate to transform is - :math:`\\vec{c}_{tan}` then the coordinate in the pixel grid - is: - - .. math:: - - \\vec{c}_{pix} = [P^{-1}(\\vec{c}_{tan} - \\vec{r}_{tan})] + \\vec{r}_{pix} - - """ - if plane_y is None: - return torch.stack(self.plane_to_pixel(*plane_x)) - coords = torch.mm( - self._pixelscale_inv, - torch.stack((plane_x.reshape(-1), plane_y.reshape(-1))) - - self.reference_imagexy.view(2, 1), - ) + self.reference_imageij.view(2, 1) - return coords[0].reshape(plane_x.shape), coords[1].reshape(plane_y.shape) - - def pixel_to_plane_delta(self, pixel_delta_i, pixel_delta_j=None): - """Take a translation in pixel space and determine the corresponding - translation in the tangent plane (arcsec). Essentially this performs - the pixel scale matrix multiplication without any reference - coordinates applied. - - """ - if pixel_delta_j is None: - return torch.stack(self.pixel_to_plane_delta(*pixel_delta_i)) - coords = torch.mm( - self.pixelscale, - torch.stack((pixel_delta_i.reshape(-1), pixel_delta_j.reshape(-1))), - ) - return coords[0].reshape(pixel_delta_i.shape), coords[1].reshape(pixel_delta_j.shape) - - def plane_to_pixel_delta(self, plane_delta_x, plane_delta_y=None): - """Take a translation in tangent plane space (arcsec) and determine - the corresponding translation in pixel space. Essentially this - performs the pixel scale matrix multiplication without any - reference coordinates applied. - - """ - if plane_delta_y is None: - return torch.stack(self.plane_to_pixel_delta(*plane_delta_x)) - coords = torch.mm( - self._pixelscale_inv, - torch.stack((plane_delta_x.reshape(-1), plane_delta_y.reshape(-1))), - ) - return coords[0].reshape(plane_delta_x.shape), coords[1].reshape(plane_delta_y.shape) - - def copy(self, **kwargs): - """Create a copy of the PPCS object with the same projection - parameters. - - """ - copy_kwargs = { - "pixelscale": self.pixelscale, - "reference_imageij": self.reference_imageij, - "reference_imagexy": self.reference_imagexy, - } - copy_kwargs.update(kwargs) - return self.__class__( - **copy_kwargs, - ) - - def get_state(self): - return { - "pixelscale": self.pixelscale.detach().cpu().tolist(), - "reference_imageij": self.reference_imageij.detach().cpu().tolist(), - "reference_imagexy": self.reference_imagexy.detach().cpu().tolist(), - } - - def set_state(self, state): - self.pixelscale = state.get("pixelscale", self.default_pixelscale) - self.reference_imageij = state.get("reference_imageij", self.default_reference_imageij) - self.reference_imagexy = state.get("reference_imagexy", self.default_reference_imagexy) - - def get_fits_state(self): - """ - Similar to get_state, except specifically tailored to be stored in a FITS format. - """ - return { - "PXLSCALE": str(self.pixelscale.detach().cpu().tolist()), - "REFIMGIJ": str(self.reference_imageij.detach().cpu().tolist()), - "REFIMGXY": str(self.reference_imagexy.detach().cpu().tolist()), - } - - def set_fits_state(self, state): - """ - Reads and applies the state from the get_fits_state function. - """ - self.pixelscale = eval(state["PXLSCALE"]) - self.reference_imageij = eval(state["REFIMGIJ"]) - self.reference_imagexy = eval(state["REFIMGXY"]) - - def to(self, dtype=None, device=None): - """ - Convert all stored tensors to a new device and data type - """ - if dtype is None: - dtype = AP_config.ap_dtype - if device is None: - device = AP_config.ap_device - self._pixelscale = self._pixelscale.to(dtype=dtype, device=device) - self._reference_imageij = self._reference_imageij.to(dtype=dtype, device=device) - self._reference_imagexy = self._reference_imagexy.to(dtype=dtype, device=device) - - def __str__(self): - return f"PPCS reference_imageij: {self.reference_imageij.detach().cpu().tolist()}, reference_imagexy: {self.reference_imagexy.detach().cpu().tolist()}" - - def __repr__(self): - return f"PPCS reference_imageij: {self.reference_imageij.detach().cpu().tolist()}, reference_imagexy: {self.reference_imagexy.detach().cpu().tolist()}, pixelscale: {self.pixelscale.detach().cpu().tolist()}" - - -class WCS(Module): - """ - Full world coordinate system defines mappings from world to tangent plane to pixel grid and all other variations. - """ - - default_i0_j0 = (-0.5, -0.5) - default_x0_y0 = (0, 0) - default_ra0_dec0 = (0, 0) - default_pixelscale = 1 - - def __init__(self, *, wcs=None, pixelscale=None, **kwargs): - if kwargs.get("state", None) is not None: - self.set_state(kwargs["state"]) - return - - if wcs is not None: - if wcs.wcs.ctype[0] != "RA---TAN": # fixme handle sip - AP_config.ap_logger.warning( - "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." - ) - if wcs.wcs.ctype[1] != "DEC--TAN": - AP_config.ap_logger.warning( - "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." - ) - - kwargs["ra0"] = wcs.wcs.crval[0] - kwargs["dec0"] = wcs.wcs.crval[1] - kwargs["i0"] = wcs.wcs.crpix[0] - kwargs["j0"] = wcs.wcs.crpix[1] - # fixme - # sky_coord = wcs.pixel_to_world(*wcs.wcs.crpix) - # kwargs["x0_y0"] = self.world_to_plane( - # torch.tensor( - # (sky_coord.ra.deg, sky_coord.dec.deg), - # dtype=AP_config.ap_dtype, - # device=AP_config.ap_device, - # ) - # ) - - self.projection = kwargs.get("projection", self.default_projection) - self.ra0 = Param("ra0", kwargs.get("ra0", self.default_ra0_dec0[0]), units="deg") - self.dec0 = Param("dec0", kwargs.get("dec0", self.default_ra0_dec0[1]), units="deg") - self.x0 = Param("x0", kwargs.get("x0", self.default_x0_y0[0]), units="arcsec") - self.y0 = Param("y0", kwargs.get("y0", self.default_x0_y0[1]), units="arcsec") - self.i0 = Param("i0", kwargs.get("i0", self.default_i0_j0[0]), units="pixel") - self.j0 = Param("j0", kwargs.get("j0", self.default_i0_j0[1]), units="pixel") - - # Collect the pixelscale of the pixel grid - if wcs is not None and pixelscale is None: - self.pixelscale = deg_to_arcsec * wcs.pixel_scale_matrix - elif pixelscale is not None: - if wcs is not None and isinstance(pixelscale, float): - AP_config.ap_logger.warning( - "Overriding WCS pixelscale with manual input! To remove this message, either let WCS define pixelscale, or input full pixelscale matrix" - ) - self.pixelscale = pixelscale - else: - AP_config.ap_logger.warning( - "Assuming pixelscale of 1! To remove this message please provide the pixelscale explicitly" - ) - self.pixelscale = self.default_pixelscale - - @property - def pixelscale(self): - """Matrix defining the shape of pixels in the tangent plane, these - can be any parallelogram defined by the matrix. - - """ - return self._pixelscale - - @pixelscale.setter - def pixelscale(self, pix): - if pix is None: - self._pixelscale = None - return - - self._pixelscale = ( - torch.as_tensor(pix, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - .clone() - .detach() - ) - if self._pixelscale.numel() == 1: - self._pixelscale = torch.tensor( - [[self._pixelscale.item(), 0.0], [0.0, self._pixelscale.item()]], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - self._pixel_area = torch.linalg.det(self.pixelscale).abs() - self._pixel_length = self._pixel_area.sqrt() - self._pixelscale_inv = torch.linalg.inv(self.pixelscale) - - @forward - def pixel_to_plane(self, i, j, i0, j0, x0, y0): - return func.pixel_to_plane_linear(i, j, i0, j0, self.pixelscale, x0, y0) - - @forward - def plane_to_pixel(self, x, y, i0, j0, x0, y0): - return func.plane_to_pixel_linear(x, y, i0, j0, self._pixelscale_inv, x0, y0) - - @forward - def plane_to_world(self, x, y, ra0, dec0, x0, y0): - return func.plane_to_world_gnomonic(x, y, ra0, dec0, x0, y0) - - @forward - def world_to_plane(self, ra, dec, ra0, dec0, x0, y0): - return func.world_to_plane_gnomonic(ra, dec, ra0, dec0, x0, y0) - - @forward - def world_to_pixel(self, ra, dec=None): - """A wrapper which applies :meth:`world_to_plane` then - :meth:`plane_to_pixel`, see those methods for further - information. - - """ - if dec is None: - ra, dec = ra[0], ra[1] - return self.plane_to_pixel(*self.world_to_plane(ra, dec)) - - @forward - def pixel_to_world(self, i, j=None): - """A wrapper which applies :meth:`pixel_to_plane` then - :meth:`plane_to_world`, see those methods for further - information. - - """ - if j is None: - i, j = i[0], i[1] - return self.plane_to_world(*self.pixel_to_plane(i, j)) - - def copy(self, **kwargs): - copy_kwargs = { - "pixelscale": self.pixelscale, - "i0": self.i0.value, - "j0": self.j0.value, - "x0": self.x0.value, - "y0": self.y0.value, - "ra0": self.ra0.value, - "dec0": self.dec0.value, - "projection": self.projection, - } - copy_kwargs.update(kwargs) - return self.__class__( - **copy_kwargs, - ) - - def to(self, dtype=None, device=None): - if dtype is None: - dtype = AP_config.ap_dtype - if device is None: - device = AP_config.ap_device - super().to(dtype=dtype, device=device) - self._pixelscale = self._pixelscale.to(dtype=dtype, device=device) - self._pixel_area = self._pixel_area.to(dtype=dtype, device=device) - self._pixel_length = self._pixel_length.to(dtype=dtype, device=device) - self._pixelscale_inv = self._pixelscale_inv.to(dtype=dtype, device=device) - return self - - def get_state(self): - state = WPCS.get_state(self) - state.update(PPCS.get_state(self)) - return state - - def set_state(self, state): - WPCS.set_state(self, state) - PPCS.set_state(self, state) - - def get_fits_state(self): - """ - Similar to get_state, except specifically tailored to be stored in a FITS format. - """ - state = WPCS.get_fits_state(self) - state.update(PPCS.get_fits_state(self)) - return state - - def set_fits_state(self, state): - """ - Reads and applies the state from the get_fits_state function. - """ - WPCS.set_fits_state(self, state) - PPCS.set_fits_state(self, state) - - def __str__(self): - return f"WCS:\n{WPCS.__str__(self)}\n{PPCS.__str__(self)}" - - def __repr__(self): - return f"WCS:\n{WPCS.__repr__(self)}\n{PPCS.__repr__(self)}" diff --git a/astrophot/image/window_object.py b/astrophot/image/window_object.py deleted file mode 100644 index d237d016..00000000 --- a/astrophot/image/window_object.py +++ /dev/null @@ -1,668 +0,0 @@ -import torch -from astropy.wcs import WCS as AstropyWCS - -from .. import AP_config -from .wcs import WCS -from ..errors import ConflicingWCS, SpecificationConflict - -__all__ = ["Window", "Window_List"] - - -class Window(WCS): - """class to define a window on the sky in coordinate space. These - windows can undergo arithmetic and preserve logical behavior. Image - objects can also be indexed using windows and will return an - appropriate subsection of their data. - - There are several ways to tell a Window object where to - place itself. The simplest method is to pass an - Astropy WCS object such as:: - - H = ap.image.Window(wcs = wcs) - - this will automatically place your image at the correct RA, DEC - and assign the correct pixel scale, etc. WARNING, it will default to - setting the reference RA DEC at the reference RA DEC of the wcs - object; if you have multiple images you should force them all to - have the same reference world coordinate by passing - ``reference_radec = (ra, dec)``. See the :doc:`coordinates` - documentation for more details. There are several other ways to - initialize a window. If you provide ``origin_radec`` then - it will place the image origin at the requested RA DEC - coordinates. If you provide ``center_radec`` then it will place - the image center at the requested RA DEC coordinates. Note that in - these cases the fixed point between the pixel grid and image plane - is different (pixel origin and center respectively); so if you - have rotated pixels in your pixel scale matrix then everything - will be rotated about different points (pixel origin and center - respectively). If you provide ``origin`` or ``center`` then those - are coordinates in the tangent plane (arcsec) and they will - correspondingly become fixed points. For arbitrary control over - the pixel positioning, use ``reference_imageij`` and - ``reference_imagexy`` to fix the pixel and tangent plane - coordinates respectively to each other, any rotation or shear will - happen about that fixed point. - - Args: - origin : Sequence or None, optional - The origin of the image in the tangent plane coordinate system - (arcsec), as a 1D array of length 2. Default is None. - origin_radec : Sequence or None, optional - The origin of the image in the world coordinate system (RA, - DEC in degrees), as a 1D array of length 2. Default is None. - center : Sequence or None, optional - The center of the image in the tangent plane coordinate system - (arcsec), as a 1D array of length 2. Default is None. - center_radec : Sequence or None, optional - The center of the image in the world coordinate system (RA, - DEC in degrees), as a 1D array of length 2. Default is None. - wcs: An astropy.wcs.WCS object which gives information about the - origin and orientation of the window. - reference_radec: world coordinates on the celestial sphere (RA, - DEC in degrees) where the tangent plane makes contact. This should - be the same for every image in multi-image analysis. - reference_planexy: tangent plane coordinates (arcsec) where it - makes contact with the celesial sphere. This should typically be - (0,0) though that is not stricktly enforced (it is assumed if not - given). This reference coordinate should be the same for all - images in multi-image analysis. - reference_imageij: pixel coordinates about which the image is - defined. For example in an Astropy WCS object the wcs.wcs.crpix - array gives the pixel coordinate reference point for which the - world coordinate mapping (wcs.wcs.crval) is defined. One may think - of the referenced pixel location as being "pinned" to the tangent - plane. This may be different for each image in multi-image - analysis.. - reference_imagexy: tangent plane coordinates (arcsec) about - which the image is defined. This is the pivot point about which the - pixelscale matrix operates, therefore if the pixelscale matrix - defines a rotation then this is the coordinate about which the - rotation will be performed. This may be different for each image in - multi-image analysis. - - """ - - def __init__( - self, - *, - pixel_shape=None, - origin=None, - origin_radec=None, - center=None, - center_radec=None, - state=None, - fits_state=None, - wcs=None, - **kwargs, - ): - # If loading from a previous state, simply update values and end init - if state is not None: - self.set_state(state) - return - if fits_state is not None: - self.set_fits_state(fits_state) - return - - # Collect the shape of the window - if pixel_shape is not None: - self.pixel_shape = pixel_shape - else: - self.pixel_shape = wcs.pixel_shape - - # Determine relative positioning of tangent plane and pixel grid. Also world coordinates and tangent plane - if not sum(C is not None for C in [wcs, origin_radec, center_radec, origin, center]) <= 1: - raise SpecificationConflict( - "Please provide only one reference position for the window, otherwise the placement is ambiguous" - ) - - # Image coordinates provided by WCS - if wcs is not None: - super().__init__(wcs=wcs, **kwargs) - # Image reference position from RA and DEC of image origin - elif origin_radec is not None: - # Origin given, it is reference point - origin_radec = torch.as_tensor( - origin_radec, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - kwargs["reference_radec"] = kwargs.get("reference_radec", origin_radec) - super().__init__(**kwargs) - self.reference_imageij = (-0.5, -0.5) - self.reference_imagexy = self.world_to_plane(origin_radec) - # Image reference position from RA and DEC of image center - elif center_radec is not None: - pix_center = self.pixel_shape.to(dtype=AP_config.ap_dtype) / 2 - 0.5 - center_radec = torch.as_tensor( - center_radec, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - kwargs["reference_radec"] = kwargs.get("reference_radec", center_radec) - super().__init__(**kwargs) - center = self.world_to_plane(center_radec) - self.reference_imageij = pix_center - self.reference_imagexy = center - # Image reference position from tangent plane position of image origin - elif origin is not None: - kwargs.update( - { - "reference_imageij": (-0.5, -0.5), - "reference_imagexy": origin, - } - ) - super().__init__(**kwargs) - # Image reference position from tangent plane position of image center - elif center is not None: - pix_center = self.pixel_shape.to(dtype=AP_config.ap_dtype) / 2 - 0.5 - kwargs.update( - { - "reference_imageij": pix_center, - "reference_imagexy": center, - } - ) - super().__init__(**kwargs) - # Image origin assumed to be at tangent plane origin - else: - super().__init__(**kwargs) - - @property - def shape(self): - dtype, device = self.pixelscale.dtype, self.pixelscale.device - S1 = self.pixel_shape.to(dtype=dtype, device=device) - S1[1] = 0.0 - S2 = self.pixel_shape.to(dtype=dtype, device=device) - S2[0] = 0.0 - return torch.stack( - ( - torch.linalg.norm(self.pixelscale @ S1), - torch.linalg.norm(self.pixelscale @ S2), - ) - ) - - @shape.setter - def shape(self, shape): - if shape is None: - self._pixel_shape = None - return - shape = torch.as_tensor(shape, dtype=self.pixelscale.dtype, device=self.pixelscale.device) - self.pixel_shape = shape / torch.sqrt(torch.sum(self.pixelscale**2, dim=0)) - - @property - def pixel_shape(self): - return self._pixel_shape - - @pixel_shape.setter - def pixel_shape(self, shape): - if shape is None: - self._pixel_shape = None - return - self._pixel_shape = torch.as_tensor(shape, device=AP_config.ap_device) - self._pixel_shape = torch.round(self.pixel_shape).to( - dtype=torch.int32, device=AP_config.ap_device - ) - - @property - def size(self): - """The number of pixels in the window""" - return torch.prod(self.pixel_shape) - - @property - def end(self): - return self.pixel_to_plane_delta( - self.pixel_shape.to(dtype=self.pixelscale.dtype, device=self.pixelscale.device) - ) - - @property - def origin(self): - return self.pixel_to_plane(-0.5 * torch.ones_like(self.reference_imageij)) - - @property - def center(self): - return self.origin + self.end / 2 - - def copy(self, **kwargs): - copy_kwargs = {"pixel_shape": torch.clone(self.pixel_shape)} - copy_kwargs.update(kwargs) - return super().copy(**copy_kwargs) - - def to(self, dtype=None, device=None): - if dtype is None: - dtype = AP_config.ap_dtype - if device is None: - device = AP_config.ap_device - super().to(dtype=dtype, device=device) - self.pixel_shape = self.pixel_shape.to(dtype=dtype, device=device) - - def rescale_pixel(self, scale, **kwargs): - return self.copy( - pixelscale=self.pixelscale * scale, - pixel_shape=self.pixel_shape // scale, - reference_imageij=(self.reference_imageij + 0.5) / scale - 0.5, - **kwargs, - ) - - @staticmethod - @torch.no_grad() - def _get_indices(ref_window, obj_window): - other_origin_pix = torch.round(ref_window.plane_to_pixel(obj_window.origin) + 0.5).int() - new_origin_pix = torch.maximum(torch.zeros_like(other_origin_pix), other_origin_pix) - - other_pixel_end = torch.round( - ref_window.plane_to_pixel(obj_window.origin + obj_window.end) + 0.5 - ).int() - new_pixel_end = torch.minimum(ref_window.pixel_shape, other_pixel_end) - return slice(new_origin_pix[1], new_pixel_end[1]), slice( - new_origin_pix[0], new_pixel_end[0] - ) - - def get_self_indices(self, obj): - """ - Return an index slicing tuple for obj corresponding to this window - """ - if isinstance(obj, Window): - return self._get_indices(self, obj) - return self._get_indices(self, obj.window) - - def get_other_indices(self, obj): - """ - Return an index slicing tuple for obj corresponding to this window - """ - if isinstance(obj, Window): - return self._get_indices(obj, self) - return self._get_indices(obj.window, self) - - def overlap_frac(self, other): - overlap = self & other - overlap_area = torch.prod(overlap.shape) - full_area = torch.prod(self.shape) + torch.prod(other.shape) - overlap_area - return overlap_area / full_area - - def shift(self, shift): - """ - Shift the location of the window by a specified amount in tangent plane coordinates - """ - self.reference_imagexy = self.reference_imagexy + shift - return self - - def pixel_shift(self, shift): - """ - Shift the location of the window by a specified amount in pixel grid coordinates - """ - - self.reference_imageij = self.reference_imageij - shift - return self - - def get_astropywcs(self, **kwargs): - wargs = { - "NAXIS": 2, - "NAXIS1": self.pixel_shape[0].item(), - "NAXIS2": self.pixel_shape[1].item(), - "CTYPE1": "RA---TAN", - "CTYPE2": "DEC--TAN", - "CRVAL1": self.pixel_to_world(self.reference_imageij)[0].item(), - "CRVAL2": self.pixel_to_world(self.reference_imageij)[1].item(), - "CRPIX1": self.reference_imageij[0].item(), - "CRPIX2": self.reference_imageij[1].item(), - "CD1_1": self.pixelscale[0][0].item(), - "CD1_2": self.pixelscale[0][1].item(), - "CD2_1": self.pixelscale[1][0].item(), - "CD2_2": self.pixelscale[1][1].item(), - } - wargs.update(kwargs) - return AstropyWCS(wargs) - - def get_state(self): - state = super().get_state() - state["pixel_shape"] = self.pixel_shape.detach().cpu().tolist() - return state - - def set_state(self, state): - super().set_state(state) - self.pixel_shape = torch.tensor( - state["pixel_shape"], dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - - def get_fits_state(self): - state = super().get_fits_state() - state["PXL_SHPE"] = str(self.pixel_shape.detach().cpu().tolist()) - return state - - def set_fits_state(self, state): - super().set_fits_state(state) - self.pixel_shape = torch.tensor( - eval(state["PXL_SHPE"]), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - def crop_pixel(self, pixels): - """ - [crop all sides] or - [crop x, crop y] or - [crop x low, crop y low, crop x high, crop y high] - """ - if len(pixels) == 1: - self.pixel_shape = self.pixel_shape - 2 * pixels[0] - self.reference_imageij = self.reference_imageij - pixels[0] - elif len(pixels) == 2: - pix_shift = torch.as_tensor( - pixels, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - self.pixel_shape = self.pixel_shape - 2 * pix_shift - self.reference_imageij = self.reference_imageij - pix_shift - elif len(pixels) == 4: # different crop on all sides - pixels = torch.as_tensor(pixels, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - self.pixel_shape = self.pixel_shape - pixels[:2] - pixels[2:] - self.reference_imageij = self.reference_imageij - pixels[:2] - else: - raise ValueError(f"Unrecognized pixel crop format: {pixels}") - return self - - def crop_to_pixel(self, pixels): - """ - format: [[xmin, xmax],[ymin,ymax]] - """ - pixels = torch.tensor(pixels, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - self.reference_imageij = self.reference_imageij - pixels[:, 0] - self.pixel_shape = pixels[:, 1] - pixels[:, 0] - return self - - def pad_pixel(self, pixels): - """ - [pad all sides] or - [pad x, pad y] or - [pad x low, pad y low, pad x high, pad y high] - """ - if len(pixels) == 1: - self.pixel_shape = self.pixel_shape + 2 * pixels[0] - self.reference_imageij = self.reference_imageij + pixels[0] - elif len(pixels) == 2: - pix_shift = torch.as_tensor( - pixels, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - self.pixel_shape = self.pixel_shape + 2 * pix_shift - self.reference_imageij = self.reference_imageij + pix_shift - elif len(pixels) == 4: # different crop on all sides - pixels = torch.as_tensor(pixels, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - self.pixel_shape = self.pixel_shape + pixels[:2] + pixels[2:] - self.reference_imageij = self.reference_imageij + pixels[:2] - else: - raise ValueError(f"Unrecognized pixel crop format: {pixels}") - return self - - @torch.no_grad() - def get_coordinate_meshgrid(self): - """Returns a meshgrid with tangent plane coordinates for the center - of every pixel. - - """ - pix = self.pixel_shape.to(dtype=AP_config.ap_dtype) - xsteps = torch.arange(pix[0], dtype=AP_config.ap_dtype, device=AP_config.ap_device) - ysteps = torch.arange(pix[1], dtype=AP_config.ap_dtype, device=AP_config.ap_device) - meshx, meshy = torch.meshgrid( - xsteps, - ysteps, - indexing="xy", - ) - Coords = self.pixel_to_plane(meshx, meshy) - return torch.stack(Coords) - - @torch.no_grad() - def get_coordinate_corner_meshgrid(self): - """Returns a meshgrid with tangent plane coordinates for the corners - of every pixel. - - """ - pix = self.pixel_shape.to(dtype=AP_config.ap_dtype) - xsteps = ( - torch.arange(pix[0] + 1, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - 0.5 - ) - ysteps = ( - torch.arange(pix[1] + 1, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - 0.5 - ) - meshx, meshy = torch.meshgrid( - xsteps, - ysteps, - indexing="xy", - ) - Coords = self.pixel_to_plane(meshx, meshy) - return torch.stack(Coords) - - @torch.no_grad() - def get_coordinate_simps_meshgrid(self): - """Returns a meshgrid with tangent plane coordinates for performing - simpsons method pixel integration (all corners, centers, and - middle of each edge). This is approximately 4 times more - points than the standard :meth:`get_coordinate_meshgrid`. - - """ - pix = self.pixel_shape.to(dtype=AP_config.ap_dtype) - xsteps = ( - 0.5 - * torch.arange( - 2 * (pix[0]) + 1, - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - 0.5 - ) - ysteps = ( - 0.5 - * torch.arange( - 2 * (pix[1]) + 1, - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - 0.5 - ) - meshx, meshy = torch.meshgrid( - xsteps, - ysteps, - indexing="xy", - ) - Coords = self.pixel_to_plane(meshx, meshy) - return torch.stack(Coords) - - # Window Comparison operators - @torch.no_grad() - def __eq__(self, other): - return ( - torch.all(self.pixel_shape == other.pixel_shape) - and torch.all(self.pixelscale == other.pixelscale) - and (self.projection == other.projection) - and ( - torch.all( - self.pixel_to_plane(torch.zeros_like(self.reference_imageij)) - == other.pixel_to_plane(torch.zeros_like(other.reference_imageij)) - ) - ) - ) # fixme more checks? - - @torch.no_grad() - def __ne__(self, other): - return not self == other - - # Window interaction operators - @torch.no_grad() - def __or__(self, other): - other_origin_pix = self.plane_to_pixel(other.origin) - new_origin_pix = torch.minimum(-0.5 * torch.ones_like(other_origin_pix), other_origin_pix) - - other_pixel_end = self.plane_to_pixel(other.origin + other.end) - new_pixel_end = torch.maximum( - self.pixel_shape.to(dtype=AP_config.ap_dtype), other_pixel_end - ) - return self.copy( - origin=self.pixel_to_plane(new_origin_pix), - pixel_shape=new_pixel_end - new_origin_pix, - ) - - @torch.no_grad() - def __ior__(self, other): - other_origin_pix = self.plane_to_pixel(other.origin) - new_origin_pix = torch.minimum(-0.5 * torch.ones_like(other_origin_pix), other_origin_pix) - - other_pixel_end = self.plane_to_pixel(other.origin + other.end) - new_pixel_end = torch.maximum( - self.pixel_shape.to(dtype=AP_config.ap_dtype), other_pixel_end - ) - - self.reference_imageij = self.reference_imageij - (new_origin_pix + 0.5) - self.pixel_shape = new_pixel_end - new_origin_pix - return self - - @torch.no_grad() - def __and__(self, other): - other_origin_pix = self.plane_to_pixel(other.origin) - new_origin_pix = torch.maximum(-0.5 * torch.ones_like(other_origin_pix), other_origin_pix) - - other_pixel_end = self.plane_to_pixel(other.origin + other.end) - new_pixel_end = torch.minimum( - self.pixel_shape.to(dtype=AP_config.ap_dtype) - 0.5, other_pixel_end - ) - return self.copy( - origin=self.pixel_to_plane(new_origin_pix), - pixel_shape=new_pixel_end - new_origin_pix, - ) - - @torch.no_grad() - def __iand__(self, other): - other_origin_pix = self.plane_to_pixel(other.origin) - new_origin_pix = torch.maximum(-0.5 * torch.ones_like(other_origin_pix), other_origin_pix) - - other_pixel_end = self.plane_to_pixel(other.origin + other.end) - new_pixel_end = torch.minimum( - self.pixel_shape.to(dtype=AP_config.ap_dtype), other_pixel_end - ) - - self.reference_imageij = self.reference_imageij - (new_origin_pix + 0.5) - self.pixel_shape = new_pixel_end - new_origin_pix - return self - - def __str__(self): - return f"window origin: {self.origin.detach().cpu().tolist()}, shape: {self.shape.detach().cpu().tolist()}, center: {self.center.detach().cpu().tolist()}, pixelscale: {self.pixelscale.detach().cpu().tolist()}" - - def __repr__(self): - return ( - f"window pixel_shape: {self.pixel_shape.detach().cpu().tolist()}, shape: {self.shape.detach().cpu().tolist()}\n" - + super().__repr__() - ) - - -class Window_List(Window): - def __init__(self, window_list=None, state=None): - if state is not None: - self.set_state(state) - else: - if window_list is None: - window_list = [] - self.window_list = list(window_list) - - self.check_wcs() - - def check_wcs(self): - """Ensure the WCS systems being used by all the windows in this list - are consistent with each other. They should all project world - coordinates onto the same tangent plane. - - """ - windows = tuple( - W.reference_radec for W in filter(lambda w: w is not None, self.window_list) - ) - if len(windows) == 0: - return - ref = torch.stack(windows) - if not torch.allclose(ref, ref[0]): - raise ConflicingWCS( - "Reference (world) coordinate mismatch! All windows in Window_List are not on the same tangent plane! Likely serious coordinate mismatch problems. See the coordinates page in the documentation for what this means." - ) - - ref = torch.stack( - tuple(W.reference_planexy for W in filter(lambda w: w is not None, self.window_list)) - ) - if not torch.allclose(ref, ref[0]): - raise ConflicingWCS( - "Reference (tangent plane) coordinate mismatch! All windows in Window_List are not on the same tangent plane! Likely serious coordinate mismatch problems. See the coordinates page in the documentation for what this means." - ) - - if len(set(W.projection for W in filter(lambda w: w is not None, self.window_list))) > 1: - raise ConflicingWCS( - "Projection mismatch! All windows in Window_List are not on the same tangent plane! Likely serious coordinate mismatch problems. See the coordinates page in the documentation for what this means." - ) - - @property - @torch.no_grad() - def origin(self): - return tuple(w.origin for w in self) - - @property - @torch.no_grad() - def shape(self): - return tuple(w.shape for w in self) - - @property - @torch.no_grad() - def center(self): - return tuple(w.center for w in self) - - def shift_origin(self, shift): - raise NotImplementedError("shift origin not implemented for window list") - - def copy(self): - return self.__class__(list(w.copy() for w in self)) - - def to(self, dtype=None, device=None): - if dtype is None: - dtype = AP_config.ap_dtype - if device is None: - device = AP_config.ap_device - for window in self: - window.to(dtype, device) - - def get_state(self): - return list(window.get_state() for window in self) - - def set_state(self, state): - self.window_list = list(Window(state=st) for st in state) - - # Window interaction operators - @torch.no_grad() - def __or__(self, other): - new_windows = list((sw | ow) for sw, ow in zip(self, other)) - return self.__class__(window_list=new_windows) - - @torch.no_grad() - def __ior__(self, other): - for sw, ow in zip(self, other): - sw |= ow - return self - - @torch.no_grad() - def __and__(self, other): - new_windows = list((sw & ow) for sw, ow in zip(self, other)) - return self.__class__(window_list=new_windows) - - @torch.no_grad() - def __iand__(self, other): - for sw, ow in zip(self, other): - sw &= ow - return self - - # Window Comparison operators - @torch.no_grad() - def __eq__(self, other): - results = list((sw == ow).view(-1) for sw, ow in zip(self, other)) - return torch.all(torch.cat(results)) - - @torch.no_grad() - def __ne__(self, other): - return not self == other - - def __len__(self): - return len(self.window_list) - - def __iter__(self): - return (win for win in self.window_list) - - def __str__(self): - return "Window List: \n" + ("\n".join(list(str(window) for window in self)) + "\n") - - def __repr__(self): - return "Window List: \n" + ("\n".join(list(repr(window) for window in self)) + "\n") From a630bd9c4b45824b12c7c900c2f2112a87aed8d9 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 10 Jun 2025 12:31:52 -0400 Subject: [PATCH 015/191] getting other image types online --- astrophot/image/image_object.py | 122 +++++++++------- astrophot/image/jacobian_image.py | 83 ++--------- astrophot/image/model_image.py | 123 ++-------------- astrophot/image/psf_image.py | 32 +---- astrophot/image/target_image.py | 230 +++++++++++------------------- 5 files changed, 180 insertions(+), 410 deletions(-) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 99b6dd0b..b6492f82 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -8,7 +8,7 @@ from .. import AP_config from ..utils.conversions.units import deg_to_arcsec -from ..errors import SpecificationConflict, InvalidWindow +from ..errors import SpecificationConflict, InvalidWindow, InvalidImage from . import func __all__ = ["Image", "Image_List"] @@ -119,14 +119,22 @@ def __init__( self.zeropoint = zeropoint # set the data - if data is None: - self.data = torch.zeros( - torch.flip(self.window.pixel_shape, (0,)).detach().cpu().tolist(), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) + self.data = Param("data", data, units="flux") + + @property + def zeropoint(self): + """The zeropoint of the image, which is used to convert from pixel flux to magnitude.""" + return self._zeropoint + + @zeropoint.setter + def zeropoint(self, value): + """Set the zeropoint of the image.""" + if value is None: + self._zeropoint = None else: - self.data = data + self._zeropoint = torch.as_tensor( + value, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) @property @forward @@ -215,22 +223,6 @@ def get_pixel_simps_meshgrid(self): ) return self.pixel_to_plane(i, j) - @property - def data(self) -> torch.Tensor: - """ - Returns the image data. - """ - return self._data - - @data.setter - def data(self, data): - """Set the image data.""" - - if data is None: - self._data = torch.tensor((), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - else: - self._data = torch.as_tensor(data, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - def copy(self, **kwargs): """Produce a copy of this image with all of the same properties. This can be used when one wishes to make temporary modifications to @@ -238,7 +230,7 @@ def copy(self, **kwargs): """ copy_kwargs = { - "data": torch.clone(self.data), + "data": torch.clone(self.data.value), "pixelscale": self.pixelscale.value, "crpix": self.crpix.value, "crval": self.crval.value, @@ -255,7 +247,7 @@ def blank_copy(self, **kwargs): """ copy_kwargs = { - "data": torch.zeros_like(self.data), + "data": torch.zeros_like(self.data.value), "pixelscale": self.pixelscale.value, "crpix": self.crpix.value, "crval": self.crval.value, @@ -272,11 +264,11 @@ def to(self, dtype=None, device=None): if device is None: device = AP_config.ap_device super().to(dtype=dtype, device=device) - if self._data is not None: - self._data = self._data.to(dtype=dtype, device=device) + if self.zeropoint is not None: + self.zeropoint = self.zeropoint.to(dtype=dtype, device=device) return self - def crop(self, pixels): # fixme move to func + def crop(self, pixels, **kwargs): """Crop the image by the number of pixels given. This will crop the image in all four directions by the number of pixels given. @@ -288,28 +280,28 @@ def crop(self, pixels): # fixme move to func """ if isinstance(pixels, int) or len(pixels) == 1: # same crop in all dimension crop = pixels if isinstance(pixels, int) else pixels[0] - self.data = self.data[ + data = self.data.value[ crop : self.data.shape[0] - crop, crop : self.data.shape[1] - crop, ] - self.crpix = self.crpix.value - crop + crpix = self.crpix.value - crop elif len(pixels) == 2: # different crop in each dimension - self.data = self.data[ + data = self.data.value[ pixels[1] : self.data.shape[0] - pixels[1], pixels[0] : self.data.shape[1] - pixels[0], ] - self.crpix = self.crpix.value - pixels + crpix = self.crpix.value - pixels elif len(pixels) == 4: # different crop on all sides - self.data = self.data[ + data = self.data.value[ pixels[2] : self.data.shape[0] - pixels[3], pixels[0] : self.data.shape[1] - pixels[1], ] - self.crpix = self.crpix.value - pixels[0::2] # fixme + crpix = self.crpix.value - pixels[0::2] # fixme else: raise ValueError( f"Invalid crop shape {pixels}, must be int, (int,), (int, int), or (int, int, int, int)!" ) - return self + return self.copy(data=data, crpix=crpix, **kwargs) def flatten(self, attribute: str = "data") -> np.ndarray: return getattr(self, attribute).reshape(-1) @@ -337,11 +329,19 @@ def reduce(self, scale: int, **kwargs): MS = self.data.shape[0] // scale NS = self.data.shape[1] // scale - self.data = ( - self.data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) + data = ( + self.data.value[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .sum(axis=(1, 3)) + ) + pixelscale = self.pixelscale.value * scale + crpix = (self.crpix.value + 0.5) / scale - 0.5 + return self.copy( + data=data, + pixelscale=pixelscale, + crpix=crpix, + **kwargs, ) - self.pixelscale = self.pixelscale.value * scale - self.crpix = (self.crpix.value + 0.5) / scale - 0.5 def get_state(self): state = {} @@ -426,7 +426,7 @@ def get_indices(self, other: "Image"): new_end_pix = torch.minimum(self.data.shape, end_pix) return slice(new_origin_pix[1], new_end_pix[1]), slice(new_origin_pix[0], new_end_pix[0]) - def get_window(self, other: "Image"): + def get_window(self, other: "Image", _indices=None, **kwargs): """Get a new image object which is a window of this image corresponding to the other image's window. This will return a new image object with the same properties as this one, but with @@ -435,45 +435,49 @@ def get_window(self, other: "Image"): """ if not isinstance(other, Image): raise InvalidWindow("get_window only works with Image objects!") - indices = self.get_indices(other) + if _indices is None: + indices = self.get_indices(other) + else: + indices = _indices new_img = self.copy( - data=self.data[indices], + data=self.data.value[indices], crpix=self.crpix.value - (indices[0].start, indices[1].start), + **kwargs, ) return new_img def __sub__(self, other): if isinstance(other, Image): new_img = self[other] - new_img.data -= other[self].data + new_img.data._value -= other[self].data.value return new_img else: new_img = self.copy() - new_img.data -= other + new_img.data._value -= other return new_img def __add__(self, other): if isinstance(other, Image): new_img = self[other] - new_img.data += other[self].data + new_img.data._value += other[self].data.value return new_img else: new_img = self.copy() - new_img.data += other + new_img.data._value += other return new_img def __iadd__(self, other): if isinstance(other, Image): - self.data[self.get_indices(other)] += other.data[other.get_indices(self)] + self.data._value[self.get_indices(other)] += other.data.value[other.get_indices(self)] else: - self.data += other + self.data._value += other return self def __isub__(self, other): if isinstance(other, Image): - self.data[self.get_indices(other)] -= other.data[other.get_indices(self)] + self.data._value[self.get_indices(other)] -= other.data.value[other.get_indices(self)] else: - self.data -= other + self.data._value -= other return self def __getitem__(self, *args): @@ -485,6 +489,10 @@ def __getitem__(self, *args): class Image_List(Module): def __init__(self, image_list): self.image_list = list(image_list) + if not all(isinstance(image, Image) for image in self.image_list): + raise InvalidImage( + f"Image_List can only hold Image objects, not {tuple(type(image) for image in self.image_list)}" + ) @property def pixelscale(self): @@ -565,8 +573,10 @@ def __isub__(self, other): if isinstance(other, Image_List): for other_image in other.image_list: i = self.index(other_image) - self_image = self.image_list[i] - self_image -= other_image + self.image_list[i] -= other_image + elif isinstance(other, Image): + i = self.index(other) + self.image_list[i] -= other else: raise ValueError("Subtraction of Image_List only works with another Image_List object!") return self @@ -575,8 +585,10 @@ def __iadd__(self, other): if isinstance(other, Image_List): for other_image in other.image_list: i = self.index(other_image) - self_image = self.image_list[i] - self_image += other_image + self.image_list[i] += other_image + elif isinstance(other, Image): + i = self.index(other) + self.image_list[i] += other else: raise ValueError("Addition of Image_List only works with another Image_List object!") return self diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index cf8e42ba..2ac0e7b8 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -23,12 +23,10 @@ class Jacobian_Image(Image): def __init__( self, parameters: List[str], - target_identity: str, **kwargs, ): super().__init__(**kwargs) - self.target_identity = target_identity self.parameters = list(parameters) if len(self.parameters) != len(set(self.parameters)): raise SpecificationConflict("Every parameter should be unique upon jacobian creation") @@ -37,9 +35,7 @@ def flatten(self, attribute: str = "data"): return getattr(self, attribute).reshape((-1, len(self.parameters))) def copy(self, **kwargs): - return super().copy( - parameters=self.parameters, target_identity=self.target_identity, **kwargs - ) + return super().copy(parameters=self.parameters, **kwargs) def get_state(self): state = super().get_state() @@ -67,49 +63,31 @@ def set_fits_state(self, states): self.target_identity = state["HEADER"]["TRGTID"] self.parameters = eval(state["HEADER"]["params"]) - def __add__(self, other): - raise NotImplementedError("Jacobian images cannot add like this, use +=") - - def __sub__(self, other): - raise NotImplementedError("Jacobian images cannot subtract") - - def __isub__(self, other): - raise NotImplementedError("Jacobian images cannot subtract") - - def __iadd__(self, other): + def __iadd__(self, other: "Jacobian_Image"): if not isinstance(other, Jacobian_Image): raise InvalidImage("Jacobian images can only add with each other, not: type(other)") # exclude null jacobian images - if other.data is None: + if other.data.value is None: return self - if self.data is None: + if self.data.value is None: return other - full_window = self.window | other.window - - self_indices = other.window.get_other_indices(self) - other_indices = self.window.get_other_indices(other) + self_indices = self.get_indices(other) + other_indices = other.get_indices(self) for i, other_identity in enumerate(other.parameters): if other_identity in self.parameters: other_loc = self.parameters.index(other_identity) else: - self.set_data( - torch.cat( - ( - self.data, - torch.zeros( - self.data.shape[0], - self.data.shape[1], - 1, - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ), - ), - dim=2, - ), - require_shape=False, + data = torch.zeros( + self.data.shape[0], + self.data.shape[1], + self.data.shape[2] + 1, + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, ) + data[:, :, :-1] = self.data.value + self.data = data self.parameters.append(other_identity) other_loc = -1 self.data[self_indices[0], self_indices[1], other_loc] += other.data[ @@ -132,9 +110,6 @@ class Jacobian_Image_List(Image_List, Jacobian_Image): """ - def __init__(self, image_list): - super().__init__(image_list) - def flatten(self, attribute="data"): if len(self.image_list) > 1: for image in self.image_list[1:]: @@ -143,33 +118,3 @@ def flatten(self, attribute="data"): "Jacobian image list sub-images track different parameters. Please initialize with all parameters that will be used." ) return torch.cat(tuple(image.flatten(attribute) for image in self.image_list)) - - def __add__(self, other): - raise NotImplementedError("Jacobian images cannot add like this, use +=") - - def __sub__(self, other): - raise NotImplementedError("Jacobian images cannot subtract") - - def __isub__(self, other): - raise NotImplementedError("Jacobian images cannot subtract") - - def __iadd__(self, other): - if isinstance(other, Jacobian_Image_List): - for other_image in other.image_list: - for self_image in self.image_list: - if other_image.target_identity == self_image.target_identity: - self_image += other_image - break - else: - self.image_list.append(other_image) - elif isinstance(other, Jacobian_Image): - for self_image in self.image_list: - if other.target_identity == self_image.target_identity: - self_image += other - break - else: - self.image_list.append(other_image) - else: - for self_image, other_image in zip(self.image_list, other): - self_image += other_image - return self diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 69e234e3..e845f13d 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -2,7 +2,6 @@ from .. import AP_config from .image_object import Image, Image_List -from .window_object import Window from ..utils.interpolate import shift_Lanczos_torch from ..errors import InvalidImage @@ -19,15 +18,10 @@ class Model_Image(Image): """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.target_identity = kwargs.get("target_identity", None) - self.to() - def clear_image(self): - self.data = torch.zeros_like(self.data) + self.data._value = torch.zeros_like(self.data.value) - def shift_origin(self, shift, is_prepadded=True): + def shift(self, shift, is_prepadded=True): self.window.shift(shift) pix_shift = self.plane_to_pixel_delta(shift) if torch.any(torch.abs(pix_shift) > 1): @@ -42,51 +36,21 @@ def shift_origin(self, shift, is_prepadded=True): img_prepadded=is_prepadded, ) - def get_window(self, window: Window, **kwargs): - return super().get_window(window, target_identity=self.target_identity, **kwargs) - - def reduce(self, scale, **kwargs): - return super().reduce(scale, target_identity=self.target_identity, **kwargs) - - def replace(self, other, data=None): + def replace(self, other): if isinstance(other, Image): - if self.window.overlap_frac(other.window) == 0.0: # fixme control flow - return - other_indices = self.window.get_other_indices(other) - self_indices = other.window.get_other_indices(self) - if self.data[self_indices].nelement() == 0 or other.data[other_indices].nelement() == 0: + self_indices = self.get_indices(other) + other_indices = other.get_indices(self) + sub_self = self.data._value[self_indices] + sub_other = other.data._value[other_indices] + if sub_self.numel() == 0 or sub_other.numel() == 0: return - self.data[self_indices] = other.data[other_indices] - elif isinstance(other, Window): - self.data[self.window.get_self_indices(other)] = data + self.data._value[self_indices] = sub_other else: - self.data = other - - def get_state(self): - state = super().get_state() - state["target_identity"] = self.target_identity - return state - - def set_state(self, state): - super().set_state(state) - self.target_identity = target_identity - - def get_fits_state(self): - states = super().get_fits_state() - for state in states: - if state["HEADER"]["IMAGE"] == "PRIMARY": - state["HEADER"]["TRGTID"] = self.target_identity - return states - - def set_fits_state(self, states): - super().set_fits_state(states) - for state in states: - if state["HEADER"]["IMAGE"] == "PRIMARY": - self.target_identity = state["HEADER"]["TRGTID"] + raise TypeError(f"Model_Image can only replace with Image objects, not {type(other)}") ###################################################################### -class Model_Image_List(Image_List, Model_Image): +class Model_Image_List(Image_List): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not all(isinstance(image, Model_Image) for image in self.image_list): @@ -98,9 +62,6 @@ def clear_image(self): for image in self.image_list: image.clear_image() - def shift_origin(self, shift): - raise NotImplementedError() - def replace(self, other, data=None): if data is None: for image, oth in zip(self.image_list, other): @@ -108,65 +69,3 @@ def replace(self, other, data=None): else: for image, oth, dat in zip(self.image_list, other, data): image.replace(oth, dat) - - @property - def target_identity(self): - targets = tuple(image.target_identity for image in self.image_list) - if any(tar_id is None for tar_id in targets): - return None - return targets - - def __isub__(self, other): - if isinstance(other, Model_Image_List): - for other_image, zip_self_image in zip(other.image_list, self.image_list): - if other_image.target_identity is None or self.target_identity is None: - zip_self_image -= other_image - continue - for self_image in self.image_list: - if other_image.target_identity == self_image.target_identity: - self_image -= other_image - break - else: - self.image_list.append(other_image) - elif isinstance(other, Model_Image): - if other.target_identity is None or zip_self_image.target_identity is None: - zip_self_image -= other_image - else: - for self_image in self.image_list: - if other.target_identity == self_image.target_identity: - self_image -= other - break - else: - self.image_list.append(other) - else: - for self_image, other_image in zip(self.image_list, other): - self_image -= other_image - return self - - def __iadd__(self, other): - if isinstance(other, Model_Image_List): - for other_image, zip_self_image in zip(other.image_list, self.image_list): - if other_image.target_identity is None or self.target_identity is None: - zip_self_image += other_image - continue - for self_image in self.image_list: - if other_image.target_identity == self_image.target_identity: - self_image += other_image - break - else: - self.image_list.append(other_image) - elif isinstance(other, Model_Image): - if other.target_identity is None or self.target_identity is None: - for self_image in self.image_list: - self_image += other - else: - for self_image in self.image_list: - if other.target_identity == self_image.target_identity: - self_image += other - break - else: - self.image_list.append(other) - else: - for self_image, other_image in zip(self.image_list, other): - self_image += other_image - return self diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index ff267270..57782b38 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -2,12 +2,11 @@ import torch import numpy as np +from astropy.io import fits from .image_object import Image -from .image_header import Image_Header from .model_image import Model_Image from .jacobian_image import Jacobian_Image -from astropy.io import fits from .. import AP_config from ..errors import SpecificationConflict @@ -37,36 +36,17 @@ class PSF_Image(Image): has_variance = False def __init__(self, *args, **kwargs): - """ - Initializes the PSF_Image class. - - Args: - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - band (str, optional): The band of the image. Default is None. - """ + kwargs.update({"crval": (0, 0), "crpix": (0, 0), "crtan": (0, 0)}) super().__init__(*args, **kwargs) - - self.window.reference_radec = (0, 0) - self.window.reference_planexy = (0, 0) - self.window.reference_imageij = np.flip(np.array(self.data.shape, dtype=float) - 1.0) / 2 - self.window.reference_imagexy = (0, 0) - - def set_data(self, data: Union[torch.Tensor, np.ndarray], require_shape: bool = True): - super().set_data(data=data, require_shape=require_shape) - - if torch.any((torch.tensor(self.data.shape) % 2) != 1): - raise SpecificationConflict(f"psf must have odd shape, not {self.data.shape}") - if torch.any(self.data < 0): - AP_config.ap_logger.warning("psf data should be non-negative") + self.crpix = np.flip(np.array(self.data.shape, dtype=float) - 1.0) / 2 def normalize(self): """Normalizes the PSF image to have a sum of 1.""" - self.data /= torch.sum(self.data) + self.data._value /= torch.sum(self.data.value) @property def mask(self): - return torch.zeros_like(self.data, dtype=bool) + return torch.zeros_like(self.data.value, dtype=bool) @property def psf_border_int(self): @@ -134,7 +114,7 @@ def model_image(self, data: Optional[torch.Tensor] = None, **kwargs): Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ return Model_Image( - data=torch.zeros_like(self.data) if data is None else data, + data=torch.zeros_like(self.data.value) if data is None else data, header=self.header, target_identity=self.identity, **kwargs, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 110a68c1..a1bc4b59 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -81,21 +81,21 @@ class Target_Image(Image): image_count = 0 - def __init__(self, *args, **kwargs): + def __init__(self, *args, mask=None, variance=None, psf=None, **kwargs): super().__init__(*args, **kwargs) if not self.has_mask: - self.set_mask(kwargs.get("mask", None)) + self.set_mask(mask) if not self.has_weight and "weight" in kwargs: self.set_weight(kwargs.get("weight", None)) elif not self.has_variance: - self.set_variance(kwargs.get("variance", None)) + self.set_variance(variance) if not self.has_psf: - self.set_psf(kwargs.get("psf", None)) + self.set_psf(psf) # Set nan pixels to be masked automatically - if torch.any(torch.isnan(self.data)).item(): - self.set_mask(torch.logical_or(self.mask, torch.isnan(self.data))) + if torch.any(torch.isnan(self.data.value)).item(): + self.set_mask(torch.logical_or(self.mask, torch.isnan(self.data.value))) @property def standard_deviation(self): @@ -112,7 +112,7 @@ def standard_deviation(self): """ if self.has_variance: return torch.sqrt(self.variance) - return torch.ones_like(self.data) + return torch.ones_like(self.data.value) @property def variance(self): @@ -129,7 +129,7 @@ def variance(self): """ if self.has_variance: return torch.where(self._weight == 0, torch.inf, 1 / self._weight) - return torch.ones_like(self.data) + return torch.ones_like(self.data.value) @variance.setter def variance(self, variance): @@ -181,7 +181,7 @@ def weight(self): """ if self.has_weight: return self._weight - return torch.ones_like(self.data) + return torch.ones_like(self.data.value) @weight.setter def weight(self, weight): @@ -217,7 +217,7 @@ def mask(self): """ if self.has_mask: return self._mask - return torch.zeros_like(self.data, dtype=torch.bool) + return torch.zeros_like(self.data.value, dtype=torch.bool) @mask.setter def mask(self, mask): @@ -233,41 +233,6 @@ def has_mask(self): except AttributeError: return False - @property - def psf(self): - """Stores the point-spread-function for this target. This should be a - `PSF_Image` object which represents the scattering of a point - source of light. It can also be an `AstroPhot_Model` object - which will contribute its own parameters to an optimization - problem. - - The PSF stored for a `Target_Image` object is passed to all - models applied to that target which have a `psf_mode` that is - not `none`. This means they will all use the same PSF - model. If one wishes to define a variable PSF across an image, - then they should pass the PSF objects to the `AstroPhot_Model`'s - directly instead of to a `Target_Image`. - - Raises: - - AttributeError: if this is called without a PSF defined - - """ - if self.has_psf: - return self._psf - raise AttributeError("This image does not have a PSF") - - @psf.setter - def psf(self, psf): - self.set_psf(psf) - - @property - def has_psf(self): - try: - return self._psf is not None - except AttributeError: - return False - def set_variance(self, variance): """ Provide a variance tensor for the image. Variance is equal to :math:`\\sigma^2`. This should have the same shape as the data. @@ -289,18 +254,22 @@ def set_weight(self, weight): self._weight = None return if isinstance(weight, str) and weight == "auto": - weight = 1 / auto_variance(self.data, self.mask) + weight = 1 / auto_variance(self.data.value, self.mask) if weight.shape != self.data.shape: raise SpecificationConflict( f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" ) - self._weight = ( - weight.to(dtype=AP_config.ap_dtype, device=AP_config.ap_device) - if isinstance(weight, torch.Tensor) - else torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - ) + self._weight = torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - def set_psf(self, psf, psf_upscale=1): + @property + def has_psf(self): + """Returns True when the target image object has a PSF model.""" + try: + return self.psf is not None + except AttributeError: + return False + + def set_psf(self, psf): """Provide a psf for the `Target_Image`. This is stored and passed to models which need to be convolved. @@ -310,19 +279,28 @@ def set_psf(self, psf, psf_upscale=1): the psf may have a pixelscale of 1, 1/2, 1/3, 1/4 and so on. """ - if psf is None: - self._psf = None - return - if isinstance(psf, PSF_Image): - self._psf = psf - return + if hasattr(self, "psf"): + del self.psf # remove old psf if it exists + from ..models import AstroPhot_Model - self._psf = PSF_Image( - data=psf, - psf_upscale=psf_upscale, - pixelscale=self.pixelscale / psf_upscale, - identity=self.identity, - ) + if psf is None: + self.psf = None + elif isinstance(psf, PSF_Image): + self.psf = psf + elif isinstance(psf, AstroPhot_Model): + self.psf = PSF_Image( + data=lambda p: p.psf_model(), + pixelscale=psf.target.pixelscale, + ) + self.psf.link("psf_model", psf) + else: + AP_config.ap_logger.warning( + "PSF provided is not a PSF_Image or AstroPhot_Model, assuming its pixelscale is the same as this Target_Image." + ) + self.psf = PSF_Image( + data=psf, + pixelscale=self.pixelscale, + ) def set_mask(self, mask): """ @@ -335,43 +313,25 @@ def set_mask(self, mask): raise SpecificationConflict( f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" ) - self._mask = ( - mask.to(dtype=torch.bool, device=AP_config.ap_device) - if isinstance(mask, torch.Tensor) - else torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device) - ) + self._mask = torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device) def to(self, dtype=None, device=None): """Converts the stored `Target_Image` data, variance, psf, etc to a given data type and device. """ - super().to(dtype=dtype, device=device) if dtype is not None: dtype = AP_config.ap_dtype if device is not None: device = AP_config.ap_device + super().to(dtype=dtype, device=device) if self.has_weight: self._weight = self._weight.to(dtype=dtype, device=device) - if self.has_psf: - self._psf = self._psf.to(dtype=dtype, device=device) if self.has_mask: self._mask = self.mask.to(dtype=torch.bool, device=device) return self - def or_mask(self, mask): - """ - Combines the currently stored mask with a provided new mask using the boolean `or` operator. - """ - self._mask = torch.logical_or(self.mask, mask) - - def and_mask(self, mask): - """ - Combines the currently stored mask with a provided new mask using the boolean `and` operator. - """ - self._mask = torch.logical_and(self.mask, mask) - def copy(self, **kwargs): """Produce a copy of this image with all of the same properties. This can be used when one wishes to make temporary modifications to @@ -380,26 +340,26 @@ def copy(self, **kwargs): """ return super().copy( mask=self._mask, - psf=self._psf, + psf=self.psf, weight=self._weight, **kwargs, ) def blank_copy(self, **kwargs): """Produces a blank copy of the image which has the same properties - except that its data is not filled with zeros. + except that its data is now filled with zeros. """ - return super().blank_copy(mask=self._mask, psf=self._psf, **kwargs) + return super().blank_copy(mask=self._mask, psf=self.psf, weight=self._weight, **kwargs) - def get_window(self, window, **kwargs): - """Get a sub-region of the image as defined by a window on the sky.""" - indices = self.window.get_self_indices(window) + def get_window(self, other, **kwargs): + """Get a sub-region of the image as defined by an other image on the sky.""" + indices = self.get_indices(other) return super().get_window( - window=window, weight=self._weight[indices] if self.has_weight else None, mask=self._mask[indices] if self.has_mask else None, - psf=self._psf, + psf=self.psf, + _indices=indices, **kwargs, ) @@ -421,23 +381,37 @@ def jacobian_image( dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) + copy_kwargs = { + "pixelscale": self.pixelscale.value, + "crpix": self.crpix.value, + "crval": self.crval.value, + "crtan": self.crtan.value, + "zeropoint": self.zeropoint, + "identity": self.identity, + } + copy_kwargs.update(kwargs) return Jacobian_Image( parameters=parameters, - target_identity=self.identity, data=data, - header=self.header, - **kwargs, + **copy_kwargs, ) - def model_image(self, data: Optional[torch.Tensor] = None, **kwargs): + def model_image(self, **kwargs): """ Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ + copy_kwargs = { + "data": torch.zeros_like(self.data.value), + "pixelscale": self.pixelscale.value, + "crpix": self.crpix.value, + "crval": self.crval.value, + "crtan": self.crtan.value, + "zeropoint": self.zeropoint, + "identity": self.identity, + } + copy_kwargs.update(kwargs) return Model_Image( - data=torch.zeros_like(self.data) if data is None else data, - header=self.header, - target_identity=self.identity, - **kwargs, + **copy_kwargs, ) def reduce(self, scale, **kwargs): @@ -470,16 +444,10 @@ def reduce(self, scale, **kwargs): if self.has_mask else None ), - psf=self.psf.reduce(scale) if self.has_psf else None, + psf=self.psf if self.has_psf else None, **kwargs, ) - def expand(self, padding): - """ - `Target_Image` doesn't have expand yet. - """ - raise NotImplementedError("expand not available for Target_Image yet") - def get_state(self): state = super().get_state() @@ -532,7 +500,7 @@ def set_fits_state(self, states): self.psf = PSF_Image(fits_state=states) -class Target_Image_List(Image_List, Target_Image): +class Target_Image_List(Image_List): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not all(isinstance(image, Target_Image) for image in self.image_list): @@ -573,12 +541,8 @@ def jacobian_image(self, parameters: List[str], data: Optional[List[torch.Tensor list(image.jacobian_image(parameters, dat) for image, dat in zip(self.image_list, data)) ) - def model_image(self, data: Optional[List[torch.Tensor]] = None): - if data is None: - data = [None] * len(self.image_list) - return Model_Image_List( - list(image.model_image(data=dat) for image, dat in zip(self.image_list, data)) - ) + def model_image(self): + return Model_Image_List(list(image.model_image() for image in self.image_list)) def match_indices(self, other): indices = [] @@ -600,57 +564,33 @@ def match_indices(self, other): return indices def __isub__(self, other): - if isinstance(other, Target_Image_List): + if isinstance(other, Image_List): for other_image in other.image_list: for self_image in self.image_list: if other_image.identity == self_image.identity: self_image -= other_image break - else: - self.image_list.append(other_image) - elif isinstance(other, Target_Image): + elif isinstance(other, Image): for self_image in self.image_list: if other.identity == self_image.identity: self_image -= other break - elif isinstance(other, Model_Image_List): - for other_image in other.image_list: - for self_image in self.image_list: - if other_image.target_identity == self_image.identity: - self_image -= other_image - break - elif isinstance(other, Model_Image): - for self_image in self.image_list: - if other.target_identity == self_image.identity: - self_image -= other else: for self_image, other_image in zip(self.image_list, other): self_image -= other_image return self def __iadd__(self, other): - if isinstance(other, Target_Image_List): + if isinstance(other, Image_List): for other_image in other.image_list: for self_image in self.image_list: if other_image.identity == self_image.identity: self_image += other_image break - else: - self.image_list.append(other_image) - elif isinstance(other, Target_Image): + elif isinstance(other, Image): for self_image in self.image_list: if other.identity == self_image.identity: self_image += other - elif isinstance(other, Model_Image_List): - for other_image in other.image_list: - for self_image in self.image_list: - if other_image.target_identity == self_image.identity: - self_image += other_image - break - elif isinstance(other, Model_Image): - for self_image in self.image_list: - if other.target_identity == self_image.identity: - self_image += other else: for self_image, other_image in zip(self.image_list, other): self_image += other_image @@ -698,9 +638,3 @@ def set_psf(self, psf, img): def set_mask(self, mask, img): self.image_list[img].set_mask(mask) - - def or_mask(self, mask): - raise NotImplementedError() - - def and_mask(self, mask): - raise NotImplementedError() From efa9b2bf779d727b4ae79b5c1359daf67464fc4a Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 11 Jun 2025 13:16:08 -0400 Subject: [PATCH 016/191] getting model sampler online --- astrophot/image/__init__.py | 28 ++- astrophot/image/func/image.py | 19 -- astrophot/image/image_object.py | 125 ++++++----- astrophot/image/model_image.py | 42 ++-- astrophot/image/window.py | 69 ++++++ astrophot/models/_shared_methods.py | 5 +- astrophot/models/core_model.py | 44 ++-- astrophot/models/func/__init__.py | 31 +++ astrophot/models/func/convolution.py | 38 ++++ astrophot/models/func/integration.py | 99 +++++++++ astrophot/models/func/sersic.py | 30 +++ astrophot/models/galaxy_model_object.py | 52 ++--- astrophot/models/model_object.py | 269 +++++++++--------------- astrophot/models/sersic_model.py | 21 +- astrophot/utils/integration.py | 0 15 files changed, 531 insertions(+), 341 deletions(-) create mode 100644 astrophot/image/window.py create mode 100644 astrophot/models/func/__init__.py create mode 100644 astrophot/models/func/convolution.py create mode 100644 astrophot/models/func/integration.py create mode 100644 astrophot/models/func/sersic.py create mode 100644 astrophot/utils/integration.py diff --git a/astrophot/image/__init__.py b/astrophot/image/__init__.py index 68ac134c..635cc859 100644 --- a/astrophot/image/__init__.py +++ b/astrophot/image/__init__.py @@ -1,8 +1,20 @@ -from .image_object import * -from .image_header import * -from .target_image import * -from .jacobian_image import * -from .psf_image import * -from .model_image import * -from .window_object import * -from .wcs import * +from .image_object import Image, Image_List +from .target_image import Target_Image, Target_Image_List +from .jacobian_image import Jacobian_Image, Jacobian_Image_List +from .psf_image import PSF_Image +from .model_image import Model_Image, Model_Image_List +from .window import Window + + +__all__ = ( + "Image", + "Image_List", + "Target_Image", + "Target_Image_List", + "Jacobian_Image", + "Jacobian_Image_List", + "PSF_Image", + "Model_Image", + "Model_Image_List", + "Window", +) diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index f901ed43..e69de29b 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -1,19 +0,0 @@ -import torch - - -def pixel_center_meshgrid(shape, dtype, device): - i = torch.arange(shape[0], dtype=dtype, device=device) - j = torch.arange(shape[1], dtype=dtype, device=device) - return torch.meshgrid(i, j, indexing="xy") - - -def pixel_corner_meshgrid(shape, dtype, device): - i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="xy") - - -def pixel_simpsons_meshgrid(shape, dtype, device): - i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="xy") diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index b6492f82..bc7f204b 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -8,6 +8,7 @@ from .. import AP_config from ..utils.conversions.units import deg_to_arcsec +from .window import Window from ..errors import SpecificationConflict, InvalidWindow, InvalidImage from . import func @@ -30,7 +31,7 @@ class Image(Module): origin: The origin of the image in the coordinate system. """ - default_crpix = (-0.5, -0.5) + default_crpix = (0.0, 0.0) default_crtan = (0.0, 0.0) default_crval = (0.0, 0.0) default_pixelscale = ((1.0, 0.0), (0.0, 1.0)) @@ -104,22 +105,25 @@ def __init__( ) pixelscale = deg_to_arcsec * wcs.pixel_scale_matrix + # set the data + self.data = Param("data", data, units="flux") self.crval = Param("crval", kwargs.get("crval", self.default_crval), units="deg") self.crtan = Param("crtan", kwargs.get("crtan", self.default_crtan), units="arcsec") - self.crpix = Param("crpix", kwargs.get("crpix", self.default_crpix), units="pixel") - if pixelscale is None: - pixelscale = self.default_pixelscale - elif isinstance(pixelscale, (float, int)): - AP_config.ap_logger.warning( - "Assuming diagonal pixelscale with the same value on both axes, please provide a full matrix to remove this message!" - ) - pixelscale = ((pixelscale, 0.0), (0.0, pixelscale)) - self.pixelscale = Param("pixelscale", pixelscale, shape=(2, 2), units="arcsec/pixel") + self.crpix = np.asarray( + kwargs.get( + "crpix", + ( + self.default_crpix + if self.data.value is None + else (self.data.shape[1] // 2, self.data.shape[0] // 2) + ), + ), + dtype=int, + ) - self.zeropoint = zeropoint + self.pixelscale = pixelscale - # set the data - self.data = Param("data", data, units="flux") + self.zeropoint = zeropoint @property def zeropoint(self): @@ -137,14 +141,47 @@ def zeropoint(self, value): ) @property - @forward - def pixel_area(self, pixelscale): + def window(self): + return Window(window=((0, 0), self.data.shape), crpix=self.crpix, image=self) + + @property + def center(self): + return self.pixel_to_plane(*(self.data.shape // 2)) + + @property + def shape(self): + """The shape of the image data.""" + return self.data.shape + + @property + def pixelscale(self): + return self._pixelscale + + @pixelscale.setter + def pixelscale(self, pixelscale): + if pixelscale is None: + pixelscale = self.default_pixelscale + elif isinstance(pixelscale, (float, int)) or ( + isinstance(pixelscale, torch.Tensor) and pixelscale.numel() == 1 + ): + AP_config.ap_logger.warning( + "Assuming diagonal pixelscale with the same value on both axes, please provide a full matrix to remove this message!" + ) + pixelscale = ((pixelscale, 0.0), (0.0, pixelscale)) + self._pixelscale = torch.as_tensor( + pixelscale, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + self._pixel_area = torch.linalg.det(self._pixelscale).abs() + self._pixel_length = self._pixel_area.sqrt() + self._pixelscale_inv = torch.linalg.inv(self._pixelscale) + + @property + def pixel_area(self): """The area inside a pixel in arcsec^2""" - return torch.linalg.det(pixelscale).abs() + return self._pixel_area @property - @forward - def pixel_length(self, pixelscale): + def pixel_length(self): """The approximate length of a pixel, which is just sqrt(pixel_area). For square pixels this is the actual pixel length, for rectangular pixels it is a kind of average. @@ -153,24 +190,23 @@ def pixel_length(self, pixelscale): and instead sets a size scale within an image. """ - return torch.linalg.det(pixelscale).abs().sqrt() + return self._pixel_length @property - @forward - def pixelscale_inv(self, pixelscale): + def pixelscale_inv(self): """The inverse of the pixel scale matrix, which is used to transform tangent plane coordinates into pixel coordinates. """ - return torch.linalg.inv(pixelscale) + return self._pixelscale_inv @forward - def pixel_to_plane(self, i, j, crpix, crtan, pixelscale): - return func.pixel_to_plane_linear(i, j, *crpix, pixelscale, *crtan) + def pixel_to_plane(self, i, j, crtan, pixelscale): + return func.pixel_to_plane_linear(i, j, *self.crpix, pixelscale, *crtan) @forward - def plane_to_pixel(self, x, y, crpix, crtan): - return func.plane_to_pixel_linear(x, y, *crpix, self.pixelscale_inv, *crtan) + def plane_to_pixel(self, x, y, crtan): + return func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale_inv, *crtan) @forward def plane_to_world(self, x, y, crval, crtan): @@ -202,27 +238,6 @@ def pixel_to_world(self, i, j=None): i, j = i[0], i[1] return self.plane_to_world(*self.pixel_to_plane(i, j)) - @forward - def get_pixel_center_meshgrid(self): - i, j = func.pixel_center_meshgrid( - self.data.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - return self.pixel_to_plane(i, j) - - @forward - def get_pixel_corner_meshgrid(self): - i, j = func.pixel_corner_meshgrid( - self.data.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - return self.pixel_to_plane(i, j) - - @forward - def get_pixel_simps_meshgrid(self): - i, j = func.pixel_simpsons_meshgrid( - self.data.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - return self.pixel_to_plane(i, j) - def copy(self, **kwargs): """Produce a copy of this image with all of the same properties. This can be used when one wishes to make temporary modifications to @@ -232,7 +247,7 @@ def copy(self, **kwargs): copy_kwargs = { "data": torch.clone(self.data.value), "pixelscale": self.pixelscale.value, - "crpix": self.crpix.value, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -249,7 +264,7 @@ def blank_copy(self, **kwargs): copy_kwargs = { "data": torch.zeros_like(self.data.value), "pixelscale": self.pixelscale.value, - "crpix": self.crpix.value, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -284,19 +299,19 @@ def crop(self, pixels, **kwargs): crop : self.data.shape[0] - crop, crop : self.data.shape[1] - crop, ] - crpix = self.crpix.value - crop + crpix = self.crpix - crop elif len(pixels) == 2: # different crop in each dimension data = self.data.value[ pixels[1] : self.data.shape[0] - pixels[1], pixels[0] : self.data.shape[1] - pixels[0], ] - crpix = self.crpix.value - pixels + crpix = self.crpix - pixels elif len(pixels) == 4: # different crop on all sides data = self.data.value[ pixels[2] : self.data.shape[0] - pixels[3], pixels[0] : self.data.shape[1] - pixels[1], ] - crpix = self.crpix.value - pixels[0::2] # fixme + crpix = self.crpix - pixels[0::2] # fixme else: raise ValueError( f"Invalid crop shape {pixels}, must be int, (int,), (int, int), or (int, int, int, int)!" @@ -335,7 +350,7 @@ def reduce(self, scale: int, **kwargs): .sum(axis=(1, 3)) ) pixelscale = self.pixelscale.value * scale - crpix = (self.crpix.value + 0.5) / scale - 0.5 + crpix = (self.crpix + 0.5) / scale - 0.5 return self.copy( data=data, pixelscale=pixelscale, @@ -347,7 +362,7 @@ def get_state(self): state = {} state["type"] = self.__class__.__name__ state["data"] = self.data.detach().cpu().tolist() - state["crpix"] = self.crpix.npvalue + state["crpix"] = self.crpix state["crtan"] = self.crtan.npvalue state["crval"] = self.crval.npvalue state["pixelscale"] = self.pixelscale.npvalue @@ -441,7 +456,7 @@ def get_window(self, other: "Image", _indices=None, **kwargs): indices = _indices new_img = self.copy( data=self.data.value[indices], - crpix=self.crpix.value - (indices[0].start, indices[1].start), + crpix=self.crpix - np.array((indices[0].start, indices[1].start)), **kwargs, ) return new_img diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index e845f13d..068802cb 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -18,23 +18,37 @@ class Model_Image(Image): """ + def __init__(self, *args, window=None, upsample=1, pad=0, **kwargs): + if window is not None: + kwargs["pixelscale"] = window.image.pixelscale / upsample + kwargs["crpix"] = (window.crpix + 0.5) * upsample + pad - 0.5 + kwargs["crval"] = window.image.crval + kwargs["crtan"] = window.image.crtan + kwargs["data"] = torch.zeros( + ( + (window.i_high - window.i_low) * upsample + 2 * pad, + (window.j_high - window.j_low) * upsample + 2 * pad, + ), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + kwargs["zeropoint"] = window.image.zeropoint + super().__init__(*args, **kwargs) + def clear_image(self): self.data._value = torch.zeros_like(self.data.value) - def shift(self, shift, is_prepadded=True): - self.window.shift(shift) - pix_shift = self.plane_to_pixel_delta(shift) - if torch.any(torch.abs(pix_shift) > 1): - raise NotImplementedError("Shifts larger than 1 pixel are currently not handled") - self.data = shift_Lanczos_torch( - self.data, - pix_shift[0], - pix_shift[1], - min(min(self.data.shape), 10), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - img_prepadded=is_prepadded, - ) + def shift_crtan(self, shift): + # self.data = shift_Lanczos_torch( + # self.data, + # pix_shift[0], + # pix_shift[1], + # min(min(self.data.shape), 10), + # dtype=AP_config.ap_dtype, + # device=AP_config.ap_device, + # img_prepadded=is_prepadded, + # ) + self.crtan._value += shift def replace(self, other): if isinstance(other, Image): diff --git a/astrophot/image/window.py b/astrophot/image/window.py new file mode 100644 index 00000000..8f2ff44c --- /dev/null +++ b/astrophot/image/window.py @@ -0,0 +1,69 @@ +from typing import Union, Tuple + +import numpy as np + +from ..errors import InvalidWindow + +__all__ = ("Window",) + + +class Window: + def __init__( + self, + window: Union[Tuple[int, int, int, int], Tuple[Tuple[int, int], Tuple[int, int]]], + crpix: Tuple[int, int], + image: "Image", + ): + if len(window) == 4: + self.i_low = window[0] + self.i_high = window[1] + self.j_low = window[2] + self.j_high = window[3] + elif len(window) == 2: + self.i_low, self.j_low = window[0] + self.i_high, self.j_high = window[1] + else: + raise InvalidWindow( + "Window must be a tuple of 4 integers or 2 tuples of 2 integers each" + ) + self.crpix = np.asarray(crpix, dtype=int) + self.image = image + + def get_indices(self, crpix: tuple[int, int] = None): + if crpix is None: + crpix = self.crpix + shift = crpix - self.crpix + return slice(self.i_low - shift[0], self.i_high - shift[0]), slice( + self.j_low - shift[1], self.j_high - shift[1] + ) + + def pad(self, pad: int): + self.i_low -= pad + self.i_high += pad + self.j_low -= pad + self.j_high += pad + + def __or__(self, other: "Window"): + if not isinstance(other, Window): + raise TypeError(f"Cannot combine Window with {type(other)}") + new_i_low = min(self.i_low, other.i_low) + new_i_high = max(self.i_high, other.i_high) + new_j_low = min(self.j_low, other.j_low) + new_j_high = max(self.j_high, other.j_high) + return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.crpix) + + def __and__(self, other: "Window"): + if not isinstance(other, Window): + raise TypeError(f"Cannot intersect Window with {type(other)}") + if ( + self.i_high <= other.i_low + or self.i_low >= other.i_high + or self.j_high <= other.j_low + or self.j_low >= other.j_high + ): + return Window(0, 0, 0, 0, self.crpix) + new_i_low = max(self.i_low, other.i_low) + new_i_high = min(self.i_high, other.i_high) + new_j_low = max(self.j_low, other.j_low) + new_j_high = min(self.j_high, other.j_high) + return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.crpix) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 005bc07b..53a17eb4 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -282,9 +282,8 @@ def exponential_iradial_model(self, i, R, image=None, parameters=None): # Sersic ###################################################################### @forward -@default_internal -def sersic_radial_model(self, R, image=None, n=None, Re=None, Ie=None): - return sersic_torch(R, n, Re, image.pixel_area * 10**Ie) +def sersic_radial_model(self, R, n=None, Re=None, Ie=None): + return sersic_torch(R, n, Re, Ie) @default_internal diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index 39d0794a..3a567a74 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -10,7 +10,7 @@ from caskade import Module, forward from ._shared_methods import select_target, select_sample from .. import AP_config -from ..errors import NameNotAllowed, InvalidTarget, UnrecognizedModel, InvalidWindow +from ..errors import InvalidTarget, UnrecognizedModel, InvalidWindow __all__ = ("AstroPhot_Model",) @@ -22,7 +22,7 @@ def all_subclasses(cls): ###################################################################### -class AstroPhot_Model(Module): +class Model(Module): """Core class for all AstroPhot models and model like objects. This class defines the signatures to interact with AstroPhot models both for users and internal functions. @@ -85,6 +85,7 @@ class defines the signatures to interact with AstroPhot models model_type (str): a model type string can determine which kind of AstroPhot model is instantiated. target (Optional[Target_Image]): A Target_Image object which stores information about the image which the model is trying to fit. filename (Optional[str]): name of a file to load AstroPhot parameters, window, and name. The model will still need to be told its target, device, and other information + window (Optional[Union[Window, tuple]]): A window on the target image in which the model will be optimized and evaluated. If not provided, the model will assume a window equal to the target it is fitting. The window may be formatted as (i_low, i_high, j_low, j_high) or as ((i_low, j_low), (i_high, j_high)). """ @@ -96,31 +97,27 @@ class defines the signatures to interact with AstroPhot models def __new__(cls, *, filename=None, model_type=None, **kwargs): if filename is not None: - state = AstroPhot_Model.load(filename) - MODELS = AstroPhot_Model.List_Models() + state = Model.load(filename) + MODELS = Model.List_Models() for M in MODELS: if M.model_type == state["model_type"]: - return super(AstroPhot_Model, cls).__new__(M) + return super(Model, cls).__new__(M) else: raise UnrecognizedModel(f"Unknown AstroPhot model type: {state['model_type']}") elif model_type is not None: - MODELS = AstroPhot_Model.List_Models() # all_subclasses(AstroPhot_Model) + MODELS = Model.List_Models() # all_subclasses(Model) for M in MODELS: if M.model_type == model_type: - return super(AstroPhot_Model, cls).__new__(M) + return super(Model, cls).__new__(M) else: raise UnrecognizedModel(f"Unknown AstroPhot model type: {model_type}") return super().__new__(cls) - def __init__(self, *, name=None, target=None, window=None, locked=False, **kwargs): - super().__init__() - if not hasattr(self, "_window"): - self._window = None + def __init__(self, *, name=None, target=None, window=None, **kwargs): + super().__init__(name=name) if not hasattr(self, "_target"): self._target = None - self.name = name - AP_config.ap_logger.debug(f"Creating model named: {self.name}") self.target = target self.window = window self.mask = kwargs.get("mask", None) @@ -227,7 +224,7 @@ def window(self): raise ValueError( "This model has no target or window, these must be provided by the user" ) - return self.target.window.copy() + return self.target.window return self._window def set_window(self, window): @@ -237,9 +234,9 @@ def set_window(self, window): elif isinstance(window, Window): # If window object given, use that self._window = window - elif len(window) == 2: + elif len(window) == 2 or len(window) == 4: # If window given in pixels, use relative to target - self._window = self.target.window.copy().crop_to_pixel(window) + self._window = Window(window, crpix=self.target.crpix, image=self.target) else: raise InvalidWindow(f"Unrecognized window format: {str(window)}") @@ -253,8 +250,11 @@ def target(self): @target.setter def target(self, tar): - if not (tar is None or isinstance(tar, Target_Image)): - raise InvalidTarget("AstroPhot_Model target must be a Target_Image instance.") + if tar is None: + self._target = None + return + elif not isinstance(tar, Target_Image): + raise InvalidTarget("AstroPhot Model target must be a Target_Image instance.") self._target = tar def __repr__(self): @@ -358,14 +358,6 @@ def List_Model_Names(cls, usable=None): def __eq__(self, other): return self is other - def __del__(self): - super().__del__() - try: - i = AstroPhot_Model.model_names.index(self.name) - AstroPhot_Model.model_names.pop(i) - except: - pass - @forward @select_sample def __call__( diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py new file mode 100644 index 00000000..ab50c377 --- /dev/null +++ b/astrophot/models/func/__init__.py @@ -0,0 +1,31 @@ +from .integration import ( + quad_table, + pixel_center_meshgrid, + pixel_center_integrator, + pixel_corner_meshgrid, + pixel_corner_integrator, + pixel_simpsons_meshgrid, + pixel_simpsons_integrator, + pixel_quad_meshgrid, + pixel_quad_integrator, +) +from .convolution import ( + lanczos_kernel, + bilinear_kernel, + convolve_and_shift, +) + +__all__ = ( + "quad_table", + "pixel_center_meshgrid", + "pixel_center_integrator", + "pixel_corner_meshgrid", + "pixel_corner_integrator", + "pixel_simpsons_meshgrid", + "pixel_simpsons_integrator", + "pixel_quad_meshgrid", + "pixel_quad_integrator", + "lanczos_kernel", + "bilinear_kernel", + "convolve_and_shift", +) diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py new file mode 100644 index 00000000..df074d45 --- /dev/null +++ b/astrophot/models/func/convolution.py @@ -0,0 +1,38 @@ +import torch + + +def lanczos_1d(x, order): + """1D Lanczos kernel with window size `order`.""" + mask = (x.abs() < order).to(x.dtype) + return torch.sinc(x) * torch.sinc(x / order) * mask + + +def lanczos_kernel(dx, dy, order): + grid = torch.arange(-order, order + 1, dtype=dx.dtype, device=dx.device) + lx = lanczos_1d(grid - dx, order) + ly = lanczos_1d(grid - dy, order) + kernel = torch.outer(ly, lx) + return kernel / kernel.sum() + + +def bilinear_kernel(dx, dy): + """Bilinear kernel for sub-pixel shifting.""" + kernel = torch.tensor( + [ + [1 - dx, dx], + [dy, 1 - dy], + ], + dtype=dx.dtype, + device=dx.device, + ) + return kernel + + +def convolve_and_shift(image, shift_kernel, psf): + + image_fft = torch.fft.rfft2(image, s=image.shape) + psf_fft = torch.fft.rfft2(psf, s=image.shape) + shift_fft = torch.fft.rfft2(shift_kernel, s=image.shape) + + convolved_fft = image_fft * psf_fft * shift_fft + return torch.fft.irfft2(convolved_fft, s=image.shape) diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py new file mode 100644 index 00000000..0ceb03bb --- /dev/null +++ b/astrophot/models/func/integration.py @@ -0,0 +1,99 @@ +import torch +from functools import lru_cache + +from scipy.special import roots_legendre + + +@lru_cache(maxsize=32) +def quad_table(order, dtype, device): + """ + Generate a meshgrid for quadrature points using Legendre-Gauss quadrature. + + Parameters + ---------- + n : int + The number of quadrature points in each dimension. + dtype : torch.dtype + The desired data type of the tensor. + device : torch.device + The device on which to create the tensor. + + Returns + ------- + Tuple[torch.Tensor, torch.Tensor, torch.Tensor] + The generated meshgrid as a tuple of Tensors. + """ + abscissa, weights = roots_legendre(order) + + w = torch.tensor(weights, dtype=dtype, device=device) + a = torch.tensor(abscissa, dtype=dtype, device=device) / 2.0 + di, dj = torch.meshgrid(a, a, indexing="xy") + + w = torch.outer(w, w) / 4.0 + return di, dj, w + + +def pixel_center_meshgrid(shape, dtype, device): + i = torch.arange(shape[0], dtype=dtype, device=device) + j = torch.arange(shape[1], dtype=dtype, device=device) + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_center_integrator(Z: torch.Tensor): + return Z + + +def pixel_corner_meshgrid(shape, dtype, device): + i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_corner_integrator(Z: torch.Tensor): + kernel = torch.ones((1, 1, 2, 2), dtype=Z.dtype, device=Z.device) / 4.0 + Z = torch.nn.functional.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid") + return Z.squeeze(0).squeeze(0) + + +def pixel_simpsons_meshgrid(shape, dtype, device): + i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_simpsons_integrator(Z: torch.Tensor): + kernel = ( + torch.tensor([[[[1, 4, 1], [4, 16, 4], [1, 4, 1]]]], dtype=Z.dtype, device=Z.device) / 36.0 + ) + Z = torch.nn.functional.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid", stride=2) + return Z.squeeze(0).squeeze(0) + + +def pixel_quad_meshgrid(shape, dtype, device, order=3): + i, j = pixel_center_meshgrid(shape, dtype, device) + di, dj, w = quad_table(order, dtype, device) + i = torch.repeat_interleave(i[..., None], order**2, -1) + di + j = torch.repeat_interleave(j[..., None], order**2, -1) + dj + return i, j, w + + +def pixel_quad_integrator(Z: torch.Tensor, w: torch.Tensor = None, order=3): + """ + Integrate the pixel values using quadrature weights. + + Parameters + ---------- + Z : torch.Tensor + The tensor containing pixel values. + w : torch.Tensor + The quadrature weights. + + Returns + ------- + torch.Tensor + The integrated value. + """ + if w is None: + _, _, w = _quad_table(order, Z.dtype, Z.device) + Z = Z * w + return Z.sum(dim=(-2, -1)) diff --git a/astrophot/models/func/sersic.py b/astrophot/models/func/sersic.py new file mode 100644 index 00000000..c14dbb25 --- /dev/null +++ b/astrophot/models/func/sersic.py @@ -0,0 +1,30 @@ +def sersic_n_to_b(n): + """Compute the `b(n)` for a sersic model. This factor ensures that + the :math:`R_e` and :math:`I_e` parameters do in fact correspond + to the half light values and not some other scale + radius/intensity. + + """ + + return ( + 2 * n + + 4 / (405 * n) + + 46 / (25515 * n**2) + + 131 / (1148175 * n**3) + - 2194697 / (30690717750 * n**4) + - 1 / 3 + ) + + +def sersic(R, n, Re, Ie): + """Seric 1d profile function, specifically designed for pytorch + operations + + Parameters: + R: Radii tensor at which to evaluate the sersic function + n: sersic index restricted to n > 0.36 + Re: Effective radius in the same units as R + Ie: Effective surface density + """ + bn = sersic_n_to_b(n) + return Ie * torch.exp(-bn * (torch.pow(R / Re, 1 / n) - 1)) diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index cf3f7272..d4dcb409 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -5,6 +5,7 @@ from scipy.stats import iqr from caskade import Param, forward +from . import func from ..utils.initialize import isophotes from ..utils.decorators import ignore_numpy_warnings, default_internal from ..utils.angle_operations import Angle_COM_PA @@ -54,18 +55,16 @@ class Galaxy_Model(Component_Model): @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, **kwargs): - super().initialize(target=target) + def initialize(self, **kwargs): + super().initialize() if not (self.PA.value is None or self.q.value is None): return - target_area = target[self.window] - target_dat = target_area.data.detach().cpu().numpy() + target_area = self.target[self.window] + target_dat = target_area.data.npvalue if target_area.has_mask: mask = target_area.mask.detach().cpu().numpy() - target_dat[mask] = np.median(target_dat[np.logical_not(mask)]) + target_dat[mask] = np.median(target_dat[~mask]) edge = np.concatenate( ( target_dat[:, 0], @@ -75,37 +74,22 @@ def initialize(self, target=None, **kwargs): ) ) edge_average = np.nanmedian(edge) - edge_scatter = iqr(edge[np.isfinite(edge)], rng=(16, 84)) / 2 + target_dat -= edge_average icenter = target_area.plane_to_pixel(self.center.value) + i, j = func.pixel_center_meshgrid( + target_area.shape, dtype=target_area.data.dtype, device=target_area.data.device + ) + i, j = (i - icenter[0]).detach().cpu().item(), (j - icenter[1]).detach().cpu().item() + mu20 = np.sum(target_dat * i**2) + mu02 = np.sum(target_dat * j**2) + mu11 = np.sum(target_dat * i * j) + M = np.array([[mu20, mu11], [mu11, mu02]]) if self.PA.value is None: - weights = target_dat - edge_average - Coords = target_area.get_coordinate_meshgrid() - X, Y = Coords - self.center.value[..., None, None] - X, Y = X.detach().cpu().numpy(), Y.detach().cpu().numpy() - if target_area.has_mask: - seg = np.logical_not(target_area.mask.detach().cpu().numpy()) - PA = Angle_COM_PA(weights[seg], X[seg], Y[seg]) - else: - PA = Angle_COM_PA(weights, X, Y) - - self.PA.value = (PA + target_area.north) % np.pi - if self.PA.uncertainty is None: - self.PA.uncertainty = (5 * np.pi / 180) * torch.ones_like( - self.PA.value - ) # default uncertainty of 5 degrees is assumed + self.PA.value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi if self.q.value is None: - q_samples = np.linspace(0.2, 0.9, 15) - iso_info = isophotes( - target_area.data.detach().cpu().numpy() - edge_average, - (icenter[1].detach().cpu().item(), icenter[0].detach().cpu().item()), - threshold=3 * edge_scatter, - pa=(self.PA.value - target.north).detach().cpu().item(), - q=q_samples, - ) - self.q.value = q_samples[np.argmin(list(iso["amplitude2"] for iso in iso_info))] - if self.q.uncertainty is None: - self.q.uncertainty = self.q.value * self.default_uncertainty + l = np.sorted(np.linalg.eigvals(M)) + self.q.value = np.sqrt(l[1] / l[0]) from ._shared_methods import inclined_transform_coordinates as transform_coordinates from ._shared_methods import transformed_evaluate_model as evaluate_model diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 580f51c7..b5427c80 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -2,8 +2,10 @@ import numpy as np import torch +from caskade import Param, forward, OverrideParam -from .core_model import AstroPhot_Model +from .core_model import Model +from . import func from ..image import ( Model_Image, Window, @@ -12,16 +14,15 @@ Target_Image_List, Image, ) -from caskade import Param, forward from ..utils.initialize import center_of_mass from ..utils.decorators import ignore_numpy_warnings, default_internal, select_target from .. import AP_config -from ..errors import InvalidTarget +from ..errors import InvalidTarget, SpecificationConflict __all__ = ["Component_Model"] -class Component_Model(AstroPhot_Model): +class Component_Model(Model): """Component_Model(name, target, window, locked, **kwargs) Component_Model is a base class for models that represent single @@ -53,21 +54,17 @@ class Component_Model(AstroPhot_Model): """ # Specifications for the model parameters including units, value, uncertainty, limits, locked, and cyclic - _parameter_specs = AstroPhot_Model._parameter_specs | { + _parameter_specs = Model._parameter_specs | { "center": {"units": "arcsec", "uncertainty": [0.1, 0.1]}, } # Scope for PSF convolution psf_mode = "none" # none, full - # Technique for PSF convolution - psf_convolve_mode = "fft" # fft, direct # Method to use when performing subpixel shifts. bilinear set by default for stability around pixel edges, though lanczos:3 is also fairly stable, and all are stable when away from pixel edges - psf_subpixel_shift = "bilinear" # bilinear, lanczos:2, lanczos:3, lanczos:5, none + psf_subpixel_shift = "lanczos:3" # bilinear, lanczos:2, lanczos:3, lanczos:5, none # Method for initial sampling of model - sampling_mode = ( - "midpoint" # midpoint, trapezoid, simpsons, quad:x (where x is a positive integer) - ) + sampling_mode = "auto" # auto (choose based on image size), midpoint, simpsons, quad:x (where x is a positive integer) # Level to which each pixel should be evaluated sampling_tolerance = 1e-2 @@ -110,7 +107,6 @@ class Component_Model(AstroPhot_Model): usable = False def __init__(self, *, name=None, **kwargs): - self._target_identity = None super().__init__(name=name, **kwargs) self.psf = None @@ -133,22 +129,6 @@ def __init__(self, *, name=None, **kwargs): for key in self.parameter_specs: setattr(self, key, Param(key, **self.parameter_specs[key])) - def set_aux_psf(self, aux_psf, add_parameters=True): - """Set the PSF for this model as an auxiliary psf model. This psf - model will be resampled as part of the model sampling step to - track changes made during fitting. - - Args: - aux_psf: The auxiliary psf model - add_parameters: if true, the parameters of the auxiliary psf model will become model parameters for this model as well. - - """ - - self._psf = aux_psf - - if add_parameters: - self.parameters.link(aux_psf.parameters) - @property def psf(self): if self._psf is None: @@ -164,7 +144,7 @@ def psf(self, val): self._psf = None elif isinstance(val, PSF_Image): self._psf = val - elif isinstance(val, AstroPhot_Model): + elif isinstance(val, Model): self.set_aux_psf(val) else: self._psf = PSF_Image(data=val, pixelscale=self.target.pixelscale) @@ -178,12 +158,8 @@ def psf(self, val): ###################################################################### @torch.no_grad() @ignore_numpy_warnings - @default_internal def initialize( self, - target: Optional[Target_Image] = None, - window: Optional[Window] = None, - **kwargs, ): """Determine initial values for the center coordinates. This is done with a local center of mass search which iterates by finding @@ -194,37 +170,21 @@ def initialize( target (Optional[Target_Image]): A target image object to use as a reference when setting parameter values """ - super().initialize(target=target, window=window) + super().initialize() # Get the sub-image area corresponding to the model image - target_area = target[window] + target_area = self.target[self.window] # Use center of window if a center hasn't been set yet if self.center.value is None: - self.center.value = window.center + self.center.value = target_area.center else: return - if self.center.locked: - return - - # Convert center coordinates to target area array indices - init_icenter = target_area.plane_to_pixel(self.center.value) - # Compute center of mass in window - COM = center_of_mass( - ( - init_icenter[1].detach().cpu().item(), - init_icenter[0].detach().cpu().item(), - ), - target_area.data.detach().cpu().numpy(), - ) - if np.any(np.array(COM) < 0) or np.any(np.array(COM) >= np.array(target_area.data.shape)): - AP_config.ap_logger.warning("center of mass failed, using center of window") - return - COM = (COM[1], COM[0]) + COM = center_of_mass(target_area.data.npvalue) # Convert center of mass indices to coordinates COM_center = target_area.pixel_to_plane( - torch.tensor(COM, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + *torch.tensor(COM, dtype=AP_config.ap_dtype, device=AP_config.ap_device) ) # Set the new coordinates as the model center @@ -233,35 +193,76 @@ def initialize( # Fit loop functions ###################################################################### @forward - def evaluate_model( + def brightness( self, - X: Optional[torch.Tensor] = None, - Y: Optional[torch.Tensor] = None, - image: Optional[Image] = None, - center=None, + x: Optional[torch.Tensor] = None, + y: Optional[torch.Tensor] = None, **kwargs, ): - """Evaluate the model on every pixel in the given image. The - basemodel object simply returns zeros, this function should be - overloaded by subclasses. + """Evaluate the brightness of the model at the exact tangent plane coordinates requested.""" + return torch.zeros_like(x) # do nothing in base model - Args: - image (Image): The image defining the set of pixels on which to evaluate the model + @forward + def sample_image(self, image: Image): + if self.sampling_mode == "auto": + N = np.prod(image.data.shape) + if N <= 100: + sampling_mode = "quad:5" + elif N <= 10000: + sampling_mode = "simpsons" + else: + sampling_mode = "midpoint" + else: + sampling_mode = self.sampling_mode + + if sampling_mode == "midpoint": + i, j = func.pixel_center_meshgrid(image.shape, AP_config.ap_dtype, AP_config.ap_device) + x, y = image.pixel_to_plane(i, j) + res = self.brightness(x, y) + return func.pixel_center_integrator(res) + elif sampling_mode == "simpsons": + i, j = func.pixel_simpsons_meshgrid( + image.shape, AP_config.ap_dtype, AP_config.ap_device + ) + x, y = image.pixel_to_plane(i, j) + res = self.brightness(x, y) + return func.pixel_simpsons_integrator(res) + elif sampling_mode.startswith("quad:"): + order = int(self.sampling_mode.split(":")[1]) + i, j, w = func.pixel_quad_meshgrid( + image.shape, AP_config.ap_dtype, AP_config.ap_device, order=order + ) + x, y = image.pixel_to_plane(i, j) + res = self.brightness(x, y) + return func.pixel_quad_integrator(res, w) + raise SpecificationConflict( + f"Unknown integration mode {self.sampling_mode} for model {self.name}" + ) - """ - if X is None or Y is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - center[..., None, None] - return torch.zeros_like(X) # do nothing in base model + def shift_kernel(self, shift): + if self.psf_subpixel_shift == "bilinear": + return func.bilinear_kernel(shift[0], shift[1]) + elif self.psf_subpixel_shift.startswith("lanczos:"): + order = int(self.psf_subpixel_shift.split(":")[1]) + return func.lanczos_kernel(shift[0], shift[1], order) + elif self.psf_subpixel_shift == "none": + return torch.tensor( + [[0, 0, 0], [0, 1, 0], [0, 0, 0]], + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + else: + raise SpecificationConflict( + f"Unknown PSF subpixel shift mode {self.psf_subpixel_shift} for model {self.name}" + ) @forward def sample( self, - image: Optional[Image] = None, window: Optional[Window] = None, center=None, ): - """Evaluate the model on the space covered by an image object. This + """Evaluate the model on the pixels defined in an image. This function properly calls integration methods and PSF convolution. This should not be overloaded except in special cases. @@ -286,119 +287,52 @@ def sample( Image: The image with the computed model values. """ - # Image on which to evaluate model - if image is None: - image = self.make_model_image(window=window) - # Window within which to evaluate model if window is None: - working_window = image.window.copy() - else: - working_window = window.copy() - - # Parameters with which to evaluate the model - if parameters is None: - parameters = self.parameters + window = self.window if "window" in self.psf_mode: raise NotImplementedError("PSF convolution in sub-window not available yet") if "full" in self.psf_mode: - if isinstance(self.psf, AstroPhot_Model): - psf = self.psf( - parameters=parameters[self.psf.name], - ) - else: - psf = self.psf - psf_upscale = torch.round(image.pixel_length / psf.pixel_length).int() - # Add border for psf convolution edge effects, will be cropped out later - working_window.pad_pixel(psf.psf_border_int) - # Make the image object to which the samples will be tracked - working_image = Model_Image(window=working_window) + psf = self.psf.image.value + psf_upscale = torch.round(self.target.pixel_length / psf.pixel_length).int() + psf_pad = np.max(psf.shape) // 2 + + working_image = Model_Image(window=window, upsample=psf_upscale, pad=psf_pad) + # Sub pixel shift to align the model with the center of a pixel if self.psf_subpixel_shift != "none": pixel_center = working_image.plane_to_pixel(center) - center_shift = pixel_center - torch.round(pixel_center) - working_image.header.pixel_shift(center_shift) + pixel_shift = pixel_center - torch.round(pixel_center) + center_shift = center - working_image.pixel_to_plane(torch.round(pixel_center)) + working_image.crtan = working_image.crtan.value + center_shift else: - center_shift = None + pixel_shift = torch.zeros_like(center) + center_shift = torch.zeros_like(center) - # Evaluate the model at the current resolution - reference, deep = self._sample_init( - image=working_image, - center=center, - ) - # If needed, super-resolve the image in areas of high curvature so pixels are properly sampled - deep = self._sample_integrate(deep, reference, working_image, parameters, center) + sample = self.sample_image(working_image) - # update the image with the integrated pixels - working_image.data += deep + if self.integrate_mode == "threshold": + sample = self.sample_integrate(sample, working_image) - # Convolve the PSF - self._sample_convolve(working_image, center_shift, psf, self.psf_subpixel_shift) + shift_kernel = self.shift_kernel(pixel_shift) + working_image.data = func.convolve_and_shift(sample, shift_kernel, psf) + working_image.crtan = working_image.crtan.value - center_shift - # Shift image back to align with original pixel grid - if self.psf_subpixel_shift != "none": - working_image.header.pixel_shift(-center_shift) - # Add the sampled/integrated/convolved pixels to the requested image - working_image = working_image.reduce(psf_upscale).crop(psf.psf_border_int) + working_image = working_image.crop(psf_pad).reduce(psf_upscale) else: - # Create an image to store pixel samples - working_image = Model_Image(pixelscale=image.pixelscale, window=working_window) - # Evaluate the model on the image - reference, deep = self._sample_init( - image=working_image, - center=center, - ) - # Super-resolve and integrate where needed - deep = self._sample_integrate( - deep, - reference, - working_image, - parameters, - center=center, - ) - # Add the sampled/integrated pixels to the requested image - working_image.data += deep + working_image = Model_Image(window=window) + sample = self.sample_image(working_image) + if self.integrate_mode == "threshold": + sample = self.sample_integrate(sample, working_image) + working_image.data = sample if self.mask is not None: - working_image.data = working_image.data * torch.logical_not(self.mask) - - image += working_image - - return image - - @property - def target(self): - return self._target - - @target.setter - def target(self, tar): - if not (tar is None or isinstance(tar, Target_Image)): - raise InvalidTarget("AstroPhot_Model target must be a Target_Image instance.") - - # If a target image list is assigned, pick out the target appropriate for this model - if isinstance(tar, Target_Image_List) and self._target_identity is not None: - for subtar in tar: - if subtar.identity == self._target_identity: - usetar = subtar - break - else: - raise InvalidTarget( - f"Could not find target in Target_Image_List with matching identity " - f"to {self.name}: {self._target_identity}" - ) - else: - usetar = tar - - self._target = usetar + working_image.data = working_image.data * (~self.mask) - # Remember the target identity to use - try: - self._target_identity = self._target.identity - except AttributeError: - pass + return working_image def get_state(self, save_params=True): """Returns a dictionary with a record of the current state of the @@ -426,13 +360,6 @@ def get_state(self, save_params=True): ###################################################################### from ._model_methods import radius_metric from ._model_methods import angular_metric - from ._model_methods import _sample_init - from ._model_methods import _sample_integrate - from ._model_methods import _sample_convolve - from ._model_methods import _integrate_reference - from ._model_methods import _shift_psf from ._model_methods import build_parameter_specs from ._model_methods import jacobian - from ._model_methods import _chunk_jacobian - from ._model_methods import _chunk_image_jacobian from ._model_methods import load diff --git a/astrophot/models/sersic_model.py b/astrophot/models/sersic_model.py index c67e47a6..3289fe80 100644 --- a/astrophot/models/sersic_model.py +++ b/astrophot/models/sersic_model.py @@ -62,27 +62,26 @@ class Sersic_Galaxy(Galaxy_Model): parameter_specs = { "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, "Re": {"units": "arcsec", "limits": (0, None)}, - "Ie": {"units": "log10(flux/arcsec^2)"}, + "Ie": {"units": "flux/arcsec^2"}, } usable = True @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, **kwargs): - super().initialize(target=target) + def initialize(self, **kwargs): + super().initialize() - parametric_initialize(self, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) + parametric_initialize( + self, self.target[self.window], _wrap_sersic, ("n", "Re", "Ie"), _x0_func + ) @forward - @default_internal def total_flux(self, Ie, n, Re, q): - return sersic_Ie_to_flux_torch(10**Ie, n, Re, q) + return sersic_Ie_to_flux_torch(Ie, n, Re, q) - def _integrate_reference(self, image_data, image_header, parameters): - tot = self.total_flux(parameters) - return tot / image_data.numel() + @forward + def radial_model(self, R, n, Re, Ie): + return sersic_torch(R, n, Re, Ie) from ._shared_methods import sersic_radial_model as radial_model diff --git a/astrophot/utils/integration.py b/astrophot/utils/integration.py new file mode 100644 index 00000000..e69de29b From f709246150a3c08a4f950889087b1c0bdd9de342 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 12 Jun 2025 13:42:08 -0400 Subject: [PATCH 017/191] fill out model template --- astrophot/image/__init__.py | 3 +- astrophot/image/window.py | 38 ++++ astrophot/models/_shared_methods.py | 56 ------ astrophot/models/core_model.py | 65 +++---- astrophot/models/exponential_model.py | 209 ++-------------------- astrophot/models/func/__init__.py | 3 + astrophot/models/func/sersic.py | 9 +- astrophot/models/galaxy_model_object.py | 17 +- astrophot/models/group_model_object.py | 134 +++++--------- astrophot/models/mixins/__init__.py | 12 ++ astrophot/models/mixins/brightness.py | 41 +++++ astrophot/models/mixins/exponential.py | 87 +++++++++ astrophot/models/mixins/sersic.py | 63 +++++++ astrophot/models/model_object.py | 4 +- astrophot/models/sersic_model.py | 225 ++---------------------- astrophot/utils/decorators.py | 8 + 16 files changed, 346 insertions(+), 628 deletions(-) create mode 100644 astrophot/models/mixins/__init__.py create mode 100644 astrophot/models/mixins/brightness.py create mode 100644 astrophot/models/mixins/exponential.py create mode 100644 astrophot/models/mixins/sersic.py diff --git a/astrophot/image/__init__.py b/astrophot/image/__init__.py index 635cc859..61c19c45 100644 --- a/astrophot/image/__init__.py +++ b/astrophot/image/__init__.py @@ -3,7 +3,7 @@ from .jacobian_image import Jacobian_Image, Jacobian_Image_List from .psf_image import PSF_Image from .model_image import Model_Image, Model_Image_List -from .window import Window +from .window import Window, Window_List __all__ = ( @@ -17,4 +17,5 @@ "Model_Image", "Model_Image_List", "Window", + "Window_List", ) diff --git a/astrophot/image/window.py b/astrophot/image/window.py index 8f2ff44c..0965ba07 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -29,6 +29,10 @@ def __init__( self.crpix = np.asarray(crpix, dtype=int) self.image = image + @property + def identity(self): + return self.image.identity + def get_indices(self, crpix: tuple[int, int] = None): if crpix is None: crpix = self.crpix @@ -52,6 +56,15 @@ def __or__(self, other: "Window"): new_j_high = max(self.j_high, other.j_high) return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.crpix) + def __ior__(self, other: "Window"): + if not isinstance(other, Window): + raise TypeError(f"Cannot combine Window with {type(other)}") + self.i_low = min(self.i_low, other.i_low) + self.i_high = max(self.i_high, other.i_high) + self.j_low = min(self.j_low, other.j_low) + self.j_high = max(self.j_high, other.j_high) + return self + def __and__(self, other: "Window"): if not isinstance(other, Window): raise TypeError(f"Cannot intersect Window with {type(other)}") @@ -67,3 +80,28 @@ def __and__(self, other: "Window"): new_j_low = max(self.j_low, other.j_low) new_j_high = min(self.j_high, other.j_high) return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.crpix) + + +class Window_List: + def __init__(self, window_list: list[Window]): + if not all(isinstance(window, Window) for window in window_list): + raise InvalidWindow( + f"Window_List can only hold Window objects, not {tuple(type(window) for window in window_list)}" + ) + self.window_list = window_list + + def index(self, other: Window): + for i, window in enumerate(self.window_list): + if other.identity == window.identity: + return i + else: + raise ValueError("Could not find identity match between window list and input window") + + def __getitem__(self, index): + return self.window_list[index] + + def __len__(self): + return len(self.window_list) + + def __iter__(self): + return iter(self.window_list) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 53a17eb4..d0b6d254 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -220,45 +220,6 @@ def parametric_segment_initialize( model[param].uncertainty = unc[param] -# Evaluate_Model -###################################################################### -@default_internal -def radial_evaluate_model(self, X=None, Y=None, image=None, parameters=None): - if X is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - return self.radial_model( - self.radius_metric(X, Y, image=image, parameters=parameters), - image=image, - parameters=parameters, - ) - - -@forward -@default_internal -def transformed_evaluate_model( - self, X=None, Y=None, image=None, parameters=None, center=None, **kwargs -): - if X is None or Y is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - center[..., None, None] - X, Y = self.transform_coordinates(X, Y, image, parameters) - return self.radial_model( - self.radius_metric(X, Y, image=image, parameters=parameters), - image=image, - parameters=parameters, - ) - - -# Transform Coordinates -###################################################################### -@forward -@default_internal -def inclined_transform_coordinates(self, X, Y, image=None, PA=None, q=None): - X, Y = Rotate_Cartesian(-(PA - image.north), X, Y) - return X, Y / q - - # Exponential ###################################################################### @default_internal @@ -279,23 +240,6 @@ def exponential_iradial_model(self, i, R, image=None, parameters=None): ) -# Sersic -###################################################################### -@forward -def sersic_radial_model(self, R, n=None, Re=None, Ie=None): - return sersic_torch(R, n, Re, Ie) - - -@default_internal -def sersic_iradial_model(self, i, R, image=None, parameters=None): - return sersic_torch( - R, - parameters["n"].value[i], - parameters["Re"].value[i], - image.pixel_area * 10 ** parameters["Ie"].value[i], - ) - - # Moffat ###################################################################### @default_internal diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index 3a567a74..6e4e9ab9 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -5,7 +5,7 @@ import yaml from ..utils.conversions.dict_to_hdf5 import dict_to_hdf5, hdf5_to_dict -from ..utils.decorators import ignore_numpy_warnings, default_internal +from ..utils.decorators import ignore_numpy_warnings, default_internal, classproperty from ..image import Window, Target_Image, Target_Image_List from caskade import Module, forward from ._shared_methods import select_target, select_sample @@ -89,11 +89,10 @@ class defines the signatures to interact with AstroPhot models """ - model_type = "model" + _model_type = "model" _parameter_specs = {} default_uncertainty = 1e-2 # During initialization, uncertainty will be assumed 1% of initial value if no uncertainty is given usable = False - model_names = [] def __new__(cls, *, filename=None, model_type=None, **kwargs): if filename is not None: @@ -122,9 +121,20 @@ def __init__(self, *, name=None, target=None, window=None, **kwargs): self.window = window self.mask = kwargs.get("mask", None) + @classproperty + def model_type(cls): + collected = [] + for subcls in cls.mro(): + if subcls is object: + continue + mt = getattr(subcls, "_model_type", None) + if mt: + collected.append(mt) + # Build the final combined string + return " ".join(collected) + @torch.no_grad() - @select_target - def initialize(self, target=None, **kwargs): + def initialize(self, **kwargs): """When this function finishes, all parameters should have numerical values (non None) that are reasonable estimates of the final values. @@ -132,34 +142,14 @@ def initialize(self, target=None, **kwargs): """ pass - def make_model_image(self, window: Optional[Window] = None): - """This is called to create a blank `Model_Image` object of the - correct format for this model. This is typically used - internally to construct the model image before filling the - pixel values with the model. - - """ - if window is None: - window = self.window - else: - window = self.window & window - return self.target[window].model_image() - @forward - def sample(self, image=None, window=None, *args, **kwargs): + def sample(self, *args, **kwargs): """Calling this function should fill the given image with values sampled from the given model. """ pass - def fit_mask(self): - """ - Return a mask to be used for fitting this model. This will block out - pixels that are not relevant to the model. - """ - return torch.zeros_like(self.target[self.window].mask) - @forward def negative_log_likelihood( self, @@ -197,12 +187,11 @@ def jacobian( self, **kwargs, ): - raise NotImplementedError("please use a subclass of AstroPhot_Model") + raise NotImplementedError("please use a subclass of AstroPhot Model") - @default_internal @forward - def total_flux(self, window=None, image=None): - F = self(window=None, image=None) + def total_flux(self, window=None): + F = self(window=window) return torch.sum(F.data) @property @@ -257,10 +246,6 @@ def target(self, tar): raise InvalidTarget("AstroPhot Model target must be a Target_Image instance.") self._target = tar - def __repr__(self): - """Detailed string representation for the model.""" - return yaml.dump(self.get_state(), indent=2) - def get_state(self, *args, **kwargs): """Returns a dictionary of the state of the model with its name, type, parameters, and other important information. This @@ -347,24 +332,14 @@ def List_Models(cls, usable=None): MODELS.remove(model) return MODELS - @classmethod - def List_Model_Names(cls, usable=None): - MODELS = cls.List_Models(usable=usable) - names = [] - for model in MODELS: - names.append(model.model_type) - return list(sorted(names, key=lambda n: n[::-1])) - def __eq__(self, other): return self is other @forward - @select_sample def __call__( self, - image=None, window=None, **kwargs, ): - return self.sample(image=image, window=window, **kwargs) + return self.sample(window=window, **kwargs) diff --git a/astrophot/models/exponential_model.py b/astrophot/models/exponential_model.py index 1f78bea7..f470cb97 100644 --- a/astrophot/models/exponential_model.py +++ b/astrophot/models/exponential_model.py @@ -1,7 +1,3 @@ -from typing import Optional - -import torch - from .galaxy_model_object import Galaxy_Model from .warp_model import Warp_Galaxy from .ray_model import Ray_Galaxy @@ -9,14 +5,7 @@ from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp from .wedge_model import Wedge_Galaxy -from ._shared_methods import ( - parametric_initialize, - parametric_segment_initialize, - select_target, -) -from ..param import Parameter_Node -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..utils.parametric_profiles import exponential_np +from .mixins import ExponentialMixin, iExponentialMixin __all__ = [ "Exponential_Galaxy", @@ -29,15 +18,7 @@ ] -def _x0_func(model_params, R, F): - return R[4], F[4] - - -def _wrap_exp(R, re, ie): - return exponential_np(R, re, 10**ie) - - -class Exponential_Galaxy(Galaxy_Model): +class Exponential_Galaxy(ExponentialMixin, Galaxy_Model): """basic galaxy model with a exponential profile for the radial light profile. The light profile is defined as: @@ -54,27 +35,10 @@ class Exponential_Galaxy(Galaxy_Model): """ - model_type = f"exponential {Galaxy_Model.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = Galaxy_Model._parameter_order + ("Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters: Optional[Parameter_Node] = None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_exp, ("Re", "Ie"), _x0_func) - - from ._shared_methods import exponential_radial_model as radial_model - -class Exponential_PSF(PSF_Model): +class Exponential_PSF(ExponentialMixin, PSF_Model): """basic point source model with a exponential profile for the radial light profile. @@ -91,29 +55,11 @@ class Exponential_PSF(PSF_Model): """ - model_type = f"exponential {PSF_Model.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)", "value": 0.0, "locked": True}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = PSF_Model._parameter_order + ("Re", "Ie") usable = True model_integrated = False - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_exp, ("Re", "Ie"), _x0_func) - - from ._shared_methods import exponential_radial_model as radial_model - from ._shared_methods import radial_evaluate_model as evaluate_model - -class Exponential_SuperEllipse(SuperEllipse_Galaxy): +class Exponential_SuperEllipse(ExponentialMixin, SuperEllipse_Galaxy): """super ellipse galaxy model with a exponential profile for the radial light profile. @@ -130,27 +76,10 @@ class Exponential_SuperEllipse(SuperEllipse_Galaxy): """ - model_type = f"exponential {SuperEllipse_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = SuperEllipse_Galaxy._parameter_order + ("Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize(self, parameters, target, _wrap_exp, ("Re", "Ie"), _x0_func) - - from ._shared_methods import exponential_radial_model as radial_model - - -class Exponential_SuperEllipse_Warp(SuperEllipse_Warp): +class Exponential_SuperEllipse_Warp(ExponentialMixin, SuperEllipse_Warp): """super ellipse warp galaxy model with a exponential profile for the radial light profile. @@ -167,27 +96,10 @@ class Exponential_SuperEllipse_Warp(SuperEllipse_Warp): """ - model_type = f"exponential {SuperEllipse_Warp.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = SuperEllipse_Warp._parameter_order + ("Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_exp, ("Re", "Ie"), _x0_func) - from ._shared_methods import exponential_radial_model as radial_model - - -class Exponential_FourierEllipse(FourierEllipse_Galaxy): +class Exponential_FourierEllipse(ExponentialMixin, FourierEllipse_Galaxy): """fourier mode perturbations to ellipse galaxy model with an exponential profile for the radial light profile. @@ -204,27 +116,10 @@ class Exponential_FourierEllipse(FourierEllipse_Galaxy): """ - model_type = f"exponential {FourierEllipse_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = FourierEllipse_Galaxy._parameter_order + ("Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_exp, ("Re", "Ie"), _x0_func) - - from ._shared_methods import exponential_radial_model as radial_model - -class Exponential_FourierEllipse_Warp(FourierEllipse_Warp): +class Exponential_FourierEllipse_Warp(ExponentialMixin, FourierEllipse_Warp): """fourier mode perturbations to ellipse galaxy model with a exponential profile for the radial light profile. @@ -241,27 +136,10 @@ class Exponential_FourierEllipse_Warp(FourierEllipse_Warp): """ - model_type = f"exponential {FourierEllipse_Warp.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = FourierEllipse_Warp._parameter_order + ("Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_exp, ("Re", "Ie"), _x0_func) - - from ._shared_methods import exponential_radial_model as radial_model - -class Exponential_Warp(Warp_Galaxy): +class Exponential_Warp(ExponentialMixin, Warp_Galaxy): """warped coordinate galaxy model with a exponential profile for the radial light model. @@ -278,27 +156,10 @@ class Exponential_Warp(Warp_Galaxy): """ - model_type = f"exponential {Warp_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = Warp_Galaxy._parameter_order + ("Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize(self, parameters, target, _wrap_exp, ("Re", "Ie"), _x0_func) - - from ._shared_methods import exponential_radial_model as radial_model - - -class Exponential_Ray(Ray_Galaxy): +class Exponential_Ray(iExponentialMixin, Ray_Galaxy): """ray galaxy model with a sersic profile for the radial light model. The functional form of the Sersic profile is defined as: @@ -315,35 +176,10 @@ class Exponential_Ray(Ray_Galaxy): """ - model_type = f"exponential {Ray_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = Ray_Galaxy._parameter_order + ("Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_segment_initialize( - model=self, - parameters=parameters, - target=target, - prof_func=_wrap_exp, - params=("Re", "Ie"), - x0_func=_x0_func, - segments=self.rays, - ) - from ._shared_methods import exponential_iradial_model as iradial_model - - -class Exponential_Wedge(Wedge_Galaxy): +class Exponential_Wedge(iExponentialMixin, Wedge_Galaxy): """wedge galaxy model with a exponential profile for the radial light model. The functional form of the Sersic profile is defined as: @@ -360,29 +196,4 @@ class Exponential_Wedge(Wedge_Galaxy): """ - model_type = f"exponential {Wedge_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = Wedge_Galaxy._parameter_order + ("Re", "Ie") usable = True - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_segment_initialize( - model=self, - parameters=parameters, - target=target, - prof_func=_wrap_exp, - params=("Re", "Ie"), - x0_func=_x0_func, - segments=self.wedges, - ) - - from ._shared_methods import exponential_iradial_model as iradial_model diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index ab50c377..e9363b59 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -14,6 +14,7 @@ bilinear_kernel, convolve_and_shift, ) +from .sersic import sersic, sersic_n_to_b __all__ = ( "quad_table", @@ -28,4 +29,6 @@ "lanczos_kernel", "bilinear_kernel", "convolve_and_shift", + "sersic", + "sersic_n_to_b", ) diff --git a/astrophot/models/func/sersic.py b/astrophot/models/func/sersic.py index c14dbb25..40fa128b 100644 --- a/astrophot/models/func/sersic.py +++ b/astrophot/models/func/sersic.py @@ -5,14 +5,11 @@ def sersic_n_to_b(n): radius/intensity. """ - + x = 1 / n return ( 2 * n - + 4 / (405 * n) - + 46 / (25515 * n**2) - + 131 / (1148175 * n**3) - - 2194697 / (30690717750 * n**4) - 1 / 3 + + x * (4 / 405 + x * (46 / 25515 + x * (131 / 1148175 - x * 2194697 / 30690717750))) ) @@ -27,4 +24,4 @@ def sersic(R, n, Re, Ie): Ie: Effective surface density """ bn = sersic_n_to_b(n) - return Ie * torch.exp(-bn * (torch.pow(R / Re, 1 / n) - 1)) + return Ie * (-bn * ((R / Re) ** (1 / n) - 1)).exp() diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index d4dcb409..c725208c 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -14,12 +14,13 @@ ) from .model_object import Component_Model from ._shared_methods import select_target +from .mixins import InclinedMixin __all__ = ["Galaxy_Model"] -class Galaxy_Model(Component_Model): +class Galaxy_Model(InclinedMixin, Component_Model): """General galaxy model to be subclassed for any specific representation. Defines a galaxy as an object with a position angle and axis ratio, or effectively a tilted disk. Most @@ -41,16 +42,7 @@ class Galaxy_Model(Component_Model): """ - model_type = f"galaxy {Component_Model.model_type}" - parameter_specs = { - "q": {"units": "b/a", "limits": (0, 1), "uncertainty": 0.03}, - "PA": { - "units": "radians", - "limits": (0, np.pi), - "cyclic": True, - "uncertainty": 0.06, - }, - } + _model_type = "galaxy" usable = False @torch.no_grad() @@ -90,6 +82,3 @@ def initialize(self, **kwargs): if self.q.value is None: l = np.sorted(np.linalg.eigvals(M)) self.q.value = np.sqrt(l[1] / l[0]) - - from ._shared_methods import inclined_transform_coordinates as transform_coordinates - from ._shared_methods import transformed_evaluate_model as evaluate_model diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index a671b61f..0f179f26 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -1,26 +1,25 @@ from typing import Optional, Sequence -from collections import OrderedDict import torch +from caskade import forward -from .core_model import AstroPhot_Model +from .core_model import Model from ..image import ( Image, Target_Image, + Target_Image_List, Image_List, Window, Window_List, Jacobian_Image, ) -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ._shared_methods import select_target -from ..param import Parameter_Node +from ..utils.decorators import ignore_numpy_warnings from ..errors import InvalidTarget __all__ = ["Group_Model"] -class Group_Model(AstroPhot_Model): +class Group_Model(Model): """Model object which represents a list of other models. For each general AstroPhot model method, this calls all the appropriate models from its list and combines their output into a single @@ -36,48 +35,23 @@ class Group_Model(AstroPhot_Model): """ - model_type = f"group {AstroPhot_Model.model_type}" + _model_type = "group" usable = True def __init__( self, *, name: Optional[str] = None, - models: Optional[Sequence[AstroPhot_Model]] = None, + models: Optional[Sequence[Model]] = None, **kwargs, ): super().__init__(name=name, models=models, **kwargs) - self._param_tuple = None - self.models = OrderedDict() - if models is not None: - self.add_model(models) - self._psf_mode = "none" + self.models = models self.update_window() if "filename" in kwargs: self.load(kwargs["filename"], new_name=name) - def add_model(self, model): - """Adds a new model to the group model list. Ensures that the same - model isn't added a second time. - - Parameters: - model: a model object to add to the model list. - - """ - if isinstance(model, (tuple, list)): - for mod in model: - self.add_model(mod) - return - if model.name in self.models and model is not self.models[model.name]: - raise KeyError( - f"{self.name} already has model with name {model.name}, every model must have a unique name." - ) - - self.models[model.name] = model - self.parameters.link(model.parameters) - self.update_window() - - def update_window(self, include_locked: bool = False): + def update_window(self): """Makes a new window object which encloses all the windows of the sub models in this group model object. @@ -85,8 +59,6 @@ def update_window(self, include_locked: bool = False): if isinstance(self.target, Image_List): # Window_List if target is a Target_Image_List new_window = [None] * len(self.target.image_list) for model in self.models.values(): - if model.locked and not include_locked: - continue if isinstance(model.target, Image_List): for target, window in zip(model.target, model.window): index = self.target.index(target) @@ -108,8 +80,6 @@ def update_window(self, include_locked: bool = False): else: new_window = None for model in self.models.values(): - if model.locked and not include_locked: - continue if new_window is None: new_window = model.window.copy() else: @@ -118,22 +88,17 @@ def update_window(self, include_locked: bool = False): @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target: Optional[Image] = None, parameters=None, **kwargs): + def initialize(self, **kwargs): """ Initialize each model in this group. Does this by iteratively initializing a model then subtracting it from a copy of the target. Args: target (Optional["Target_Image"]): A Target_Image instance to use as the source for initializing the model parameters on this image. """ - self._param_tuple = None - super().initialize(target=target, parameters=parameters) + super().initialize() - target_copy = target.copy() for model in self.models.values(): - model.initialize(target=target_copy, parameters=parameters[model.name]) - target_copy -= model(parameters=parameters[model.name]) + model.initialize() def fit_mask(self) -> torch.Tensor: """Returns a mask for the target image which is the combination of all @@ -166,11 +131,10 @@ def fit_mask(self) -> torch.Tensor: mask[group_indices] &= model.fit_mask()[model_indices] return mask + @forward def sample( self, - image: Optional[Image] = None, window: Optional[Window] = None, - parameters: Optional["Parameter_Node"] = None, ): """Sample the group model on an image. Produces the flux values for each pixel associated with the models in this group. Each @@ -181,40 +145,47 @@ def sample( image (Optional["Model_Image"]): Image to sample on, overrides the windows for each sub model, they will all be evaluated over this entire image. If left as none then each sub model will be evaluated in its window. """ - self._param_tuple = None - if image is None: - sample_window = True - image = self.make_model_image(window=window) + if window is None: + image = self.target[self.window].model_image() else: - sample_window = False - if parameters is None: - parameters = self.parameters + image = self.target[window].model_image() for model in self.models.values(): - if window is not None and isinstance(window, Window_List): - indices = self.target.match_indices(model.target) - if isinstance(indices, (tuple, list)): - use_window = Window_List( - window_list=list(window.window_list[ind] for ind in indices) - ) - else: - use_window = window.window_list[indices] - else: + if window is None: + use_window = None + elif isinstance(image, Image_List) and isinstance(model.target, Image_List): + indices = image.match_indices(model.target) + if len(indices) == 0: + continue + use_window = Window_List( + window_list=list(image.image_list[i].window for i in indices) + ) + elif isinstance(image, Image_List) and isinstance(model.target, Image): + try: + image.index(model.target) + except ValueError: + continue + elif isinstance(image, Image) and isinstance(model.target, Image_List): + try: + model.target.index(image) + except ValueError: + continue + elif isinstance(image, Image) and isinstance(model.target, Image): + if image.identity != model.target.identity: + continue use_window = window - if sample_window: - # Will sample the model fit window then add to the image - image += model(window=use_window, parameters=parameters[model.name]) else: - # Will sample the entire image - model(image, window=use_window, parameters=parameters[model.name]) + raise NotImplementedError( + f"Group_Model cannot sample with {type(image)} and {type(model.target)}" + ) + image += model(window=use_window) return image @torch.no_grad() + @forward def jacobian( self, - parameters: Optional[torch.Tensor] = None, - as_representation: bool = False, pass_jacobian: Optional[Jacobian_Image] = None, window: Optional[Window] = None, **kwargs, @@ -231,13 +202,6 @@ def jacobian( """ if window is None: window = self.window - self._param_tuple = None - - if parameters is not None: - if as_representation: - self.parameters.vector_set_representation(parameters) - else: - self.parameters.vector_set_values(parameters) if pass_jacobian is None: jac_img = self.target[window].jacobian_image( @@ -265,16 +229,6 @@ def jacobian( def __iter__(self): return (mod for mod in self.models.values()) - @property - def psf_mode(self): - return self._psf_mode - - @psf_mode.setter - def psf_mode(self, value): - self._psf_mode = value - for model in self.models.values(): - model.psf_mode = value - @property def target(self): try: @@ -284,7 +238,7 @@ def target(self): @target.setter def target(self, tar): - if not (tar is None or isinstance(tar, Target_Image)): + if not (tar is None or isinstance(tar, (Target_Image, Target_Image_List))): raise InvalidTarget("Group_Model target must be a Target_Image instance.") self._target = tar diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py new file mode 100644 index 00000000..27425a9e --- /dev/null +++ b/astrophot/models/mixins/__init__.py @@ -0,0 +1,12 @@ +from .sersic import SersicMixin, iSersicMixin +from .brightness import RadialMixin, InclinedMixin +from .exponential import ExponentialMixin, iExponentialMixin + +__all__ = ( + "SersicMixin", + "iSersicMixin", + "RadialMixin", + "InclinedMixin", + "ExponentialMixin", + "iExponentialMixin", +) diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py new file mode 100644 index 00000000..3bb2c6b7 --- /dev/null +++ b/astrophot/models/mixins/brightness.py @@ -0,0 +1,41 @@ +import numpy as np + + +class RadialMixin: + + def brightness(self, x, y, center): + """ + Calculate the brightness at a given point (x, y) based on radial distance from the center. + """ + x, y = x - center[0], y - center[1] + return self.radial_model(self.radius_metric(x, y)) + + +def rotate(theta, x, y): + """ + Applies a rotation matrix to the X,Y coordinates + """ + s = theta.sin() + c = theta.cos() + return c * x - s * y, s * x + c * y + + +class InclinedMixin: + + parameter_specs = { + "q": {"units": "b/a", "limits": (0, 1), "uncertainty": 0.03}, + "PA": { + "units": "radians", + "limits": (0, np.pi), + "cyclic": True, + "uncertainty": 0.06, + }, + } + + def brightness(self, x, y, center, PA, q): + """ + Calculate the brightness at a given point (x, y) based on radial distance from the center. + """ + x, y = x - center[0], y - center[1] + x, y = rotate(PA, x, y) + return self.radial_model((x**2 + (y / q) ** 2).sqrt()) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py new file mode 100644 index 00000000..1d816013 --- /dev/null +++ b/astrophot/models/mixins/exponential.py @@ -0,0 +1,87 @@ +import torch +from caskade import forward + +from ...utils.decorators import ignore_numpy_warnings +from .._shared_methods import parametric_initialize, parametric_segment_initialize +from ...utils.parametric_profiles import exponential_np +from .. import func + + +def _x0_func(model_params, R, F): + return R[4], F[4] + + +class ExponentialMixin: + """Mixin for models that use an exponential profile for the radial light + profile. The functional form of the exponential profile is defined as: + + I(R) = Ie * exp(- (R / Re)) + + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness at the + effective radius, and Re is the effective radius. + + Parameters: + Re: effective radius in arcseconds + Ie: effective surface density in flux/arcsec^2 + """ + + _model_type = "exponential" + parameter_specs = { + "Re": {"units": "arcsec", "limits": (0, None)}, + "Ie": {"units": "flux/arcsec^2"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self, **kwargs): + super().initialize() + + parametric_initialize( + self, self.target[self.window], exponential_np, ("Re", "Ie"), _x0_func + ) + + @forward + def radial_model(self, R, Re, Ie): + return func.exponential(R, Re, Ie) + + +class iExponentialMixin: + """Mixin for models that use an exponential profile for the radial light + profile. The functional form of the exponential profile is defined as: + + I(R) = Ie * exp(- (R / Re)) + + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness at the + effective radius, and Re is the effective radius. + + Parameters: + Re: effective radius in arcseconds + Ie: effective surface density in flux/arcsec^2 + """ + + _model_type = "exponential" + parameter_specs = { + "Re": {"units": "arcsec", "limits": (0, None)}, + "Ie": {"units": "flux/arcsec^2"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self, target=None, parameters=None, **kwargs): + super().initialize(target=target, parameters=parameters) + + parametric_segment_initialize( + model=self, + target=target, + parameters=parameters, + prof_func=func.exponential, + params=("Re", "Ie"), + x0_func=_x0_func, + segments=self.rays, + ) + + @forward + def radial_model(self, i, R, Re, Ie): + return func.exponential(R, Re[i], Ie[i]) diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py new file mode 100644 index 00000000..dc0e68d4 --- /dev/null +++ b/astrophot/models/mixins/sersic.py @@ -0,0 +1,63 @@ +import torch +from caskade import forward + +from ...utils.decorators import ignore_numpy_warnings +from .._shared_methods import parametric_initialize, parametric_segment_initialize +from ...utils.parametric_profiles import sersic_np +from .. import func + + +def _x0_func(model, R, F): + return 2.0, R[4], F[4] + + +class SersicMixin: + + _model_type = "sersic" + parameter_specs = { + "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, + "Re": {"units": "arcsec", "limits": (0, None)}, + "Ie": {"units": "flux/arcsec^2"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self, **kwargs): + super().initialize() + + parametric_initialize( + self, self.target[self.window], sersic_np, ("n", "Re", "Ie"), _x0_func + ) + + @forward + def radial_model(self, R, n, Re, Ie): + return func.sersic(R, n, Re, Ie) + + +class iSersicMixin: + + _model_type = "sersic" + parameter_specs = { + "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, + "Re": {"units": "arcsec", "limits": (0, None)}, + "Ie": {"units": "flux/arcsec^2"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self, target=None, parameters=None, **kwargs): + super().initialize(target=target, parameters=parameters) + + parametric_segment_initialize( + model=self, + target=target, + parameters=parameters, + prof_func=_wrap_sersic, + params=("n", "Re", "Ie"), + x0_func=_x0_func, + segments=self.rays, + ) + + @forward + def radial_model(self, i, R, n, Re, Ie): + return func.sersic(R, n[i], Re[i], Ie[i]) diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index b5427c80..2fe1fca1 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -2,7 +2,7 @@ import numpy as np import torch -from caskade import Param, forward, OverrideParam +from caskade import Param, forward from .core_model import Model from . import func @@ -358,8 +358,6 @@ def get_state(self, save_params=True): # Extra background methods for the basemodel ###################################################################### - from ._model_methods import radius_metric - from ._model_methods import angular_metric from ._model_methods import build_parameter_specs from ._model_methods import jacobian from ._model_methods import load diff --git a/astrophot/models/sersic_model.py b/astrophot/models/sersic_model.py index 3289fe80..8a1ea4d1 100644 --- a/astrophot/models/sersic_model.py +++ b/astrophot/models/sersic_model.py @@ -1,5 +1,4 @@ -import torch -from caskade import Param, forward +from caskade import forward from .galaxy_model_object import Galaxy_Model from .warp_model import Warp_Galaxy @@ -8,15 +7,8 @@ from .psf_model_object import PSF_Model from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp -from ._shared_methods import ( - parametric_initialize, - parametric_segment_initialize, - select_target, -) -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..utils.parametric_profiles import sersic_np from ..utils.conversions.functions import sersic_Ie_to_flux_torch - +from .mixins import SersicMixin, RadialMixin, iSersicMixin __all__ = [ "Sersic_Galaxy", @@ -31,15 +23,7 @@ ] -def _x0_func(model, R, F): - return 2.0, R[4], F[4] - - -def _wrap_sersic(R, n, r, i): - return sersic_np(R, n, r, 10 ** (i)) - - -class Sersic_Galaxy(Galaxy_Model): +class Sersic_Galaxy(SersicMixin, Galaxy_Model): """basic galaxy model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -58,35 +42,14 @@ class Sersic_Galaxy(Galaxy_Model): """ - model_type = f"sersic {Galaxy_Model.model_type}" - parameter_specs = { - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - "Ie": {"units": "flux/arcsec^2"}, - } usable = True - @torch.no_grad() - @ignore_numpy_warnings - def initialize(self, **kwargs): - super().initialize() - - parametric_initialize( - self, self.target[self.window], _wrap_sersic, ("n", "Re", "Ie"), _x0_func - ) - @forward def total_flux(self, Ie, n, Re, q): return sersic_Ie_to_flux_torch(Ie, n, Re, q) - @forward - def radial_model(self, R, n, Re, Ie): - return sersic_torch(R, n, Re, Ie) - - from ._shared_methods import sersic_radial_model as radial_model - -class Sersic_PSF(PSF_Model): +class Sersic_PSF(SersicMixin, RadialMixin, PSF_Model): """basic point source model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -105,35 +68,11 @@ class Sersic_PSF(PSF_Model): """ - model_type = f"sersic {PSF_Model.model_type}" - parameter_specs = { - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - "Ie": { - "units": "log10(flux/arcsec^2)", - "value": 0.0, - "uncertainty": 0.0, - "locked": True, - }, - } - _parameter_order = PSF_Model._parameter_order + ("n", "Re", "Ie") usable = True model_integrated = False - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) - - from ._shared_methods import sersic_radial_model as radial_model - from ._shared_methods import radial_evaluate_model as evaluate_model - -class Sersic_SuperEllipse(SuperEllipse_Galaxy): +class Sersic_SuperEllipse(SersicMixin, SuperEllipse_Galaxy): """super ellipse galaxy model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -152,28 +91,10 @@ class Sersic_SuperEllipse(SuperEllipse_Galaxy): """ - model_type = f"sersic {SuperEllipse_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = SuperEllipse_Galaxy._parameter_order + ("n", "Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) - - from ._shared_methods import sersic_radial_model as radial_model - -class Sersic_SuperEllipse_Warp(SuperEllipse_Warp): +class Sersic_SuperEllipse_Warp(SersicMixin, SuperEllipse_Warp): """super ellipse warp galaxy model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -193,28 +114,10 @@ class Sersic_SuperEllipse_Warp(SuperEllipse_Warp): """ - model_type = f"sersic {SuperEllipse_Warp.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = SuperEllipse_Warp._parameter_order + ("n", "Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize(self, parameters, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) - - from ._shared_methods import sersic_radial_model as radial_model - - -class Sersic_FourierEllipse(FourierEllipse_Galaxy): +class Sersic_FourierEllipse(SersicMixin, FourierEllipse_Galaxy): """fourier mode perturbations to ellipse galaxy model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -234,28 +137,10 @@ class Sersic_FourierEllipse(FourierEllipse_Galaxy): """ - model_type = f"sersic {FourierEllipse_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = FourierEllipse_Galaxy._parameter_order + ("n", "Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) - from ._shared_methods import sersic_radial_model as radial_model - - -class Sersic_FourierEllipse_Warp(FourierEllipse_Warp): +class Sersic_FourierEllipse_Warp(SersicMixin, FourierEllipse_Warp): """fourier mode perturbations to ellipse galaxy model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -275,28 +160,10 @@ class Sersic_FourierEllipse_Warp(FourierEllipse_Warp): """ - model_type = f"sersic {FourierEllipse_Warp.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = FourierEllipse_Warp._parameter_order + ("n", "Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) - - from ._shared_methods import sersic_radial_model as radial_model - -class Sersic_Warp(Warp_Galaxy): +class Sersic_Warp(SersicMixin, Warp_Galaxy): """warped coordinate galaxy model with a sersic profile for the radial light model. The functional form of the Sersic profile is defined as: @@ -316,28 +183,10 @@ class Sersic_Warp(Warp_Galaxy): """ - model_type = f"sersic {Warp_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = Warp_Galaxy._parameter_order + ("n", "Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_sersic, ("n", "Re", "Ie"), _x0_func) - - from ._shared_methods import sersic_radial_model as radial_model - -class Sersic_Ray(Ray_Galaxy): +class Sersic_Ray(iSersicMixin, Ray_Galaxy): """ray galaxy model with a sersic profile for the radial light model. The functional form of the Sersic profile is defined as: @@ -356,36 +205,10 @@ class Sersic_Ray(Ray_Galaxy): """ - model_type = f"sersic {Ray_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = Ray_Galaxy._parameter_order + ("n", "Re", "Ie") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_segment_initialize( - model=self, - target=target, - parameters=parameters, - prof_func=_wrap_sersic, - params=("n", "Re", "Ie"), - x0_func=_x0_func, - segments=self.rays, - ) - - from ._shared_methods import sersic_iradial_model as iradial_model - - -class Sersic_Wedge(Wedge_Galaxy): +class Sersic_Wedge(iSersicMixin, Wedge_Galaxy): """wedge galaxy model with a sersic profile for the radial light model. The functional form of the Sersic profile is defined as: @@ -404,30 +227,4 @@ class Sersic_Wedge(Wedge_Galaxy): """ - model_type = f"sersic {Wedge_Galaxy.model_type}" - parameter_specs = { - "Ie": {"units": "log10(flux/arcsec^2)"}, - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = Wedge_Galaxy._parameter_order + ("n", "Re", "Ie") usable = True - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_segment_initialize( - model=self, - parameters=parameters, - target=target, - prof_func=_wrap_sersic, - params=("n", "Re", "Ie"), - x0_func=_x0_func, - segments=self.wedges, - ) - - from ._shared_methods import sersic_iradial_model as iradial_model diff --git a/astrophot/utils/decorators.py b/astrophot/utils/decorators.py index 44002ff9..98fb7521 100644 --- a/astrophot/utils/decorators.py +++ b/astrophot/utils/decorators.py @@ -12,6 +12,14 @@ ) +class classproperty: + def __init__(self, fget): + self.fget = fget + + def __get__(self, instance, owner): + return self.fget(owner) + + def ignore_numpy_warnings(func): """This decorator is used to turn off numpy warnings. This should only be used in initialize scripts which often run heuristic code From 51c6bc95391ad06f66f91dd98654911ebb239193 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 12 Jun 2025 20:48:24 -0400 Subject: [PATCH 018/191] mixins simplify a lot of model construction --- astrophot/image/image_object.py | 122 ++++--------- astrophot/image/jacobian_image.py | 34 +--- astrophot/image/model_image.py | 10 +- astrophot/image/psf_image.py | 32 +--- astrophot/image/target_image.py | 66 +++---- astrophot/image/window.py | 37 +++- astrophot/models/_model_methods.py | 29 --- astrophot/models/core_model.py | 195 ++++++++------------- astrophot/models/group_model_object.py | 75 +------- astrophot/models/group_psf_model.py | 20 +-- astrophot/models/mixins/__init__.py | 2 + astrophot/models/mixins/sample.py | 137 +++++++++++++++ astrophot/models/model_object.py | 138 +++------------ astrophot/models/psf_model_object.py | 233 +++---------------------- astrophot/utils/initialize/center.py | 49 +----- 15 files changed, 379 insertions(+), 800 deletions(-) create mode 100644 astrophot/models/mixins/sample.py diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index bc7f204b..5ce19be6 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -2,7 +2,6 @@ import torch import numpy as np -from astropy.io import fits from astropy.wcs import WCS as AstropyWCS from caskade import Module, Param, forward @@ -358,41 +357,6 @@ def reduce(self, scale: int, **kwargs): **kwargs, ) - def get_state(self): - state = {} - state["type"] = self.__class__.__name__ - state["data"] = self.data.detach().cpu().tolist() - state["crpix"] = self.crpix - state["crtan"] = self.crtan.npvalue - state["crval"] = self.crval.npvalue - state["pixelscale"] = self.pixelscale.npvalue - state["zeropoint"] = self.zeropoint - state["identity"] = self.identity - return state - - def set_state(self, state): - self.data = state["data"] - self.crpix = state["crpix"] - self.crtan = state["crtan"] - self.crval = state["crval"] - self.pixelscale = state["pixelscale"] - self.zeropoint = state["zeropoint"] - self.identity = state["identity"] - - def get_fits_state(self): - states = [{}] - states[0]["DATA"] = self.data.detach().cpu().numpy() - states[0]["HEADER"] = self.header.get_fits_state() - states[0]["HEADER"]["IMAGE"] = "PRIMARY" - return states - - def set_fits_state(self, states): - for state in states: - if state["HEADER"]["IMAGE"] == "PRIMARY": - self.set_data(np.array(state["DATA"], dtype=np.float64), require_shape=False) - self.header.set_fits_state(state["HEADER"]) - break - def get_astropywcs(self, **kwargs): wargs = { "NAXIS": 2, @@ -412,21 +376,6 @@ def get_astropywcs(self, **kwargs): wargs.update(kwargs) return AstropyWCS(wargs) - def save(self, filename=None, overwrite=True): - states = self.get_fits_state() - img_list = [fits.PrimaryHDU(states[0]["DATA"], header=fits.Header(states[0]["HEADER"]))] - for state in states[1:]: - img_list.append(fits.ImageHDU(state["DATA"], header=fits.Header(state["HEADER"]))) - hdul = fits.HDUList(img_list) - if filename is not None: - hdul.writeto(filename, overwrite=overwrite) - return hdul - - def load(self, filename): - hdul = fits.open(filename) - states = list({"DATA": hdu.data, "HEADER": hdu.header} for hdu in hdul) - self.set_fits_state(states) - @torch.no_grad() def get_indices(self, other: "Image"): origin_pix = torch.round(self.plane_to_pixel(other.pixel_to_plane(-0.5, -0.5)) + 0.5).int() @@ -502,52 +451,63 @@ def __getitem__(self, *args): class Image_List(Module): - def __init__(self, image_list): - self.image_list = list(image_list) - if not all(isinstance(image, Image) for image in self.image_list): + def __init__(self, images): + self.images = list(images) + if not all(isinstance(image, Image) for image in self.images): raise InvalidImage( - f"Image_List can only hold Image objects, not {tuple(type(image) for image in self.image_list)}" + f"Image_List can only hold Image objects, not {tuple(type(image) for image in self.images)}" ) @property def pixelscale(self): - return tuple(image.pixelscale.value for image in self.image_list) + return tuple(image.pixelscale.value for image in self.images) @property def zeropoint(self): - return tuple(image.zeropoint for image in self.image_list) + return tuple(image.zeropoint for image in self.images) @property def data(self): - return tuple(image.data for image in self.image_list) + return tuple(image.data for image in self.images) @data.setter def data(self, data): - for image, dat in zip(self.image_list, data): + for image, dat in zip(self.images, data): image.data = dat def copy(self): return self.__class__( - tuple(image.copy() for image in self.image_list), + tuple(image.copy() for image in self.images), ) def blank_copy(self): return self.__class__( - tuple(image.blank_copy() for image in self.image_list), + tuple(image.blank_copy() for image in self.images), ) def get_window(self, other: "Image_List"): return self.__class__( - tuple(image[win] for image, win in zip(self.image_list, other.image_list)), + tuple(image[win] for image, win in zip(self.images, other.images)), ) - def index(self, other): - for i, image in enumerate(self.image_list): + def index(self, other: Image): + for i, image in enumerate(self.images): if other.identity == image.identity: return i else: raise ValueError("Could not find identity match between image list and input image") + def match_indices(self, other: "Image_List"): + """Match the indices of the images in this list with those in another Image_List.""" + indices = [] + for other_image in other.images: + try: + i = self.index(other_image) + except ValueError: + continue + indices.append(i) + return indices + def to(self, dtype=None, device=None): if dtype is not None: dtype = AP_config.ap_dtype @@ -560,14 +520,14 @@ def crop(self, *pixels): raise NotImplementedError("Crop function not available for Image_List object") def flatten(self, attribute="data"): - return torch.cat(tuple(image.flatten(attribute) for image in self.image_list)) + return torch.cat(tuple(image.flatten(attribute) for image in self.images)) def __sub__(self, other): if isinstance(other, Image_List): new_list = [] - for other_image in other.image_list: + for other_image in other.images: i = self.index(other_image) - self_image = self.image_list[i] + self_image = self.images[i] new_list.append(self_image - other_image) return self.__class__(new_list) else: @@ -576,9 +536,9 @@ def __sub__(self, other): def __add__(self, other): if isinstance(other, Image_List): new_list = [] - for other_image in other.image_list: + for other_image in other.images: i = self.index(other_image) - self_image = self.image_list[i] + self_image = self.images[i] new_list.append(self_image + other_image) return self.__class__(new_list) else: @@ -586,43 +546,37 @@ def __add__(self, other): def __isub__(self, other): if isinstance(other, Image_List): - for other_image in other.image_list: + for other_image in other.images: i = self.index(other_image) - self.image_list[i] -= other_image + self.images[i] -= other_image elif isinstance(other, Image): i = self.index(other) - self.image_list[i] -= other + self.images[i] -= other else: raise ValueError("Subtraction of Image_List only works with another Image_List object!") return self def __iadd__(self, other): if isinstance(other, Image_List): - for other_image in other.image_list: + for other_image in other.images: i = self.index(other_image) - self.image_list[i] += other_image + self.images[i] += other_image elif isinstance(other, Image): i = self.index(other) - self.image_list[i] += other + self.images[i] += other else: raise ValueError("Addition of Image_List only works with another Image_List object!") return self - def save(self, filename=None, overwrite=True): - raise NotImplementedError("Save/load not yet available for image lists") - - def load(self, filename): - raise NotImplementedError("Save/load not yet available for image lists") - def __getitem__(self, *args): if len(args) == 1 and isinstance(args[0], Image_List): new_list = [] - for other_image in args[0].image_list: + for other_image in args[0].images: i = self.index(other_image) - self_image = self.image_list[i] + self_image = self.images[i] new_list.append(self_image.get_window(other_image)) return self.__class__(new_list) raise ValueError("Unrecognized Image_List getitem request!") def __iter__(self): - return (img for img in self.image_list) + return (img for img in self.images) diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 2ac0e7b8..97051392 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -37,32 +37,6 @@ def flatten(self, attribute: str = "data"): def copy(self, **kwargs): return super().copy(parameters=self.parameters, **kwargs) - def get_state(self): - state = super().get_state() - state["target_identity"] = self.target_identity - state["parameters"] = self.parameters - return state - - def set_state(self, state): - super().set_state(state) - self.target_identity = state["target_identity"] - self.parameters = state["parameters"] - - def get_fits_state(self): - states = super().get_fits_state() - for state in states: - if state["HEADER"]["IMAGE"] == "PRIMARY": - state["HEADER"]["TRGTID"] = self.target_identity - state["HEADER"]["PARAMS"] = str(self.parameters) - return states - - def set_fits_state(self, states): - super().set_fits_state(states) - for state in states: - if state["HEADER"]["IMAGE"] == "PRIMARY": - self.target_identity = state["HEADER"]["TRGTID"] - self.parameters = eval(state["HEADER"]["params"]) - def __iadd__(self, other: "Jacobian_Image"): if not isinstance(other, Jacobian_Image): raise InvalidImage("Jacobian images can only add with each other, not: type(other)") @@ -111,10 +85,10 @@ class Jacobian_Image_List(Image_List, Jacobian_Image): """ def flatten(self, attribute="data"): - if len(self.image_list) > 1: - for image in self.image_list[1:]: - if self.image_list[0].parameters != image.parameters: + if len(self.images) > 1: + for image in self.images[1:]: + if self.images[0].parameters != image.parameters: raise SpecificationConflict( "Jacobian image list sub-images track different parameters. Please initialize with all parameters that will be used." ) - return torch.cat(tuple(image.flatten(attribute) for image in self.image_list)) + return torch.cat(tuple(image.flatten(attribute) for image in self.images)) diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 068802cb..8bf584e8 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -67,19 +67,19 @@ def replace(self, other): class Model_Image_List(Image_List): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not all(isinstance(image, Model_Image) for image in self.image_list): + if not all(isinstance(image, Model_Image) for image in self.images): raise InvalidImage( - f"Model_Image_List can only hold Model_Image objects, not {tuple(type(image) for image in self.image_list)}" + f"Model_Image_List can only hold Model_Image objects, not {tuple(type(image) for image in self.images)}" ) def clear_image(self): - for image in self.image_list: + for image in self.images: image.clear_image() def replace(self, other, data=None): if data is None: - for image, oth in zip(self.image_list, other): + for image, oth in zip(self.images, other): image.replace(oth) else: - for image, oth, dat in zip(self.image_list, other, data): + for image, oth, dat in zip(self.images, other, data): image.replace(oth, dat) diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 57782b38..c5a14185 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -1,14 +1,12 @@ -from typing import List, Optional, Union +from typing import List, Optional import torch import numpy as np -from astropy.io import fits from .image_object import Image from .model_image import Model_Image from .jacobian_image import Jacobian_Image from .. import AP_config -from ..errors import SpecificationConflict __all__ = ["PSF_Image"] @@ -72,17 +70,6 @@ def psf_border_int(self): / 2 ).int() - def _save_image_list(self, image_list): - """Saves the image list to the PSF HDU header. - - Args: - image_list (list): The list of images to be saved. - psf_header (astropy.io.fits.Header): The header of the PSF HDU. - """ - img_header = self.header._save_image_list() - img_header["IMAGE"] = "PSF" - image_list.append(fits.ImageHDU(self.data.detach().cpu().numpy(), header=img_header)) - def jacobian_image( self, parameters: Optional[List[str]] = None, @@ -119,20 +106,3 @@ def model_image(self, data: Optional[torch.Tensor] = None, **kwargs): target_identity=self.identity, **kwargs, ) - - def expand(self, padding): - raise NotImplementedError("expand not available for PSF_Image") - - def get_fits_state(self): - states = [{}] - states[0]["DATA"] = self.data.detach().cpu().numpy() - states[0]["HEADER"] = self.header.get_fits_state() - states[0]["HEADER"]["IMAGE"] = "PSF" - return states - - def set_fits_state(self, states): - for state in states: - if state["HEADER"]["IMAGE"] == "PSF": - self.set_data(np.array(state["DATA"], dtype=np.float64), require_shape=False) - self.header = Image_Header(fits_state=state["HEADER"]) - break diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index a1bc4b59..731541db 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -503,59 +503,59 @@ def set_fits_state(self, states): class Target_Image_List(Image_List): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not all(isinstance(image, Target_Image) for image in self.image_list): + if not all(isinstance(image, Target_Image) for image in self.images): raise InvalidImage( - f"Target_Image_List can only hold Target_Image objects, not {tuple(type(image) for image in self.image_list)}" + f"Target_Image_List can only hold Target_Image objects, not {tuple(type(image) for image in self.images)}" ) @property def variance(self): - return tuple(image.variance for image in self.image_list) + return tuple(image.variance for image in self.images) @variance.setter def variance(self, variance): - for image, var in zip(self.image_list, variance): + for image, var in zip(self.images, variance): image.set_variance(var) @property def has_variance(self): - return any(image.has_variance for image in self.image_list) + return any(image.has_variance for image in self.images) @property def weight(self): - return tuple(image.weight for image in self.image_list) + return tuple(image.weight for image in self.images) @weight.setter def weight(self, weight): - for image, wgt in zip(self.image_list, weight): + for image, wgt in zip(self.images, weight): image.set_weight(wgt) @property def has_weight(self): - return any(image.has_weight for image in self.image_list) + return any(image.has_weight for image in self.images) def jacobian_image(self, parameters: List[str], data: Optional[List[torch.Tensor]] = None): if data is None: - data = [None] * len(self.image_list) + data = [None] * len(self.images) return Jacobian_Image_List( - list(image.jacobian_image(parameters, dat) for image, dat in zip(self.image_list, data)) + list(image.jacobian_image(parameters, dat) for image, dat in zip(self.images, data)) ) def model_image(self): - return Model_Image_List(list(image.model_image() for image in self.image_list)) + return Model_Image_List(list(image.model_image() for image in self.images)) def match_indices(self, other): indices = [] if isinstance(other, Target_Image_List): - for other_image in other.image_list: - for isi, self_image in enumerate(self.image_list): + for other_image in other.images: + for isi, self_image in enumerate(self.images): if other_image.identity == self_image.identity: indices.append(isi) break else: indices.append(None) elif isinstance(other, Target_Image): - for isi, self_image in enumerate(self.image_list): + for isi, self_image in enumerate(self.images): if other.identity == self_image.identity: indices = isi break @@ -565,76 +565,76 @@ def match_indices(self, other): def __isub__(self, other): if isinstance(other, Image_List): - for other_image in other.image_list: - for self_image in self.image_list: + for other_image in other.images: + for self_image in self.images: if other_image.identity == self_image.identity: self_image -= other_image break elif isinstance(other, Image): - for self_image in self.image_list: + for self_image in self.images: if other.identity == self_image.identity: self_image -= other break else: - for self_image, other_image in zip(self.image_list, other): + for self_image, other_image in zip(self.images, other): self_image -= other_image return self def __iadd__(self, other): if isinstance(other, Image_List): - for other_image in other.image_list: - for self_image in self.image_list: + for other_image in other.images: + for self_image in self.images: if other_image.identity == self_image.identity: self_image += other_image break elif isinstance(other, Image): - for self_image in self.image_list: + for self_image in self.images: if other.identity == self_image.identity: self_image += other else: - for self_image, other_image in zip(self.image_list, other): + for self_image, other_image in zip(self.images, other): self_image += other_image return self @property def mask(self): - return tuple(image.mask for image in self.image_list) + return tuple(image.mask for image in self.images) @mask.setter def mask(self, mask): - for image, M in zip(self.image_list, mask): + for image, M in zip(self.images, mask): image.set_mask(M) @property def has_mask(self): - return any(image.has_mask for image in self.image_list) + return any(image.has_mask for image in self.images) @property def psf(self): - return tuple(image.psf for image in self.image_list) + return tuple(image.psf for image in self.images) @psf.setter def psf(self, psf): - for image, P in zip(self.image_list, psf): + for image, P in zip(self.images, psf): image.set_psf(P) @property def has_psf(self): - return any(image.has_psf for image in self.image_list) + return any(image.has_psf for image in self.images) @property def psf_border(self): - return tuple(image.psf_border for image in self.image_list) + return tuple(image.psf_border for image in self.images) @property def psf_border_int(self): - return tuple(image.psf_border_int for image in self.image_list) + return tuple(image.psf_border_int for image in self.images) def set_variance(self, variance, img): - self.image_list[img].set_variance(variance) + self.images[img].set_variance(variance) def set_psf(self, psf, img): - self.image_list[img].set_psf(psf) + self.images[img].set_psf(psf) def set_mask(self, mask, img): - self.image_list[img].set_mask(mask) + self.images[img].set_mask(mask) diff --git a/astrophot/image/window.py b/astrophot/image/window.py index 0965ba07..8d8ac16a 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -33,6 +33,27 @@ def __init__( def identity(self): return self.image.identity + def chunk(self, chunk_size: int): + # number of pixels on each axis + px = self.i_high - self.i_low + py = self.j_high - self.j_low + # total number of chunks desired + chunk_tot = int(np.ceil((px * py) / chunk_size)) + # number of chunks on each axis + cx = int(np.ceil(np.sqrt(chunk_tot * px / py))) + cy = int(np.ceil(chunk_size / cx)) + # number of pixels on each axis per chunk + stepx = int(np.ceil(px / cx)) + stepy = int(np.ceil(py / cy)) + # create the windows + windows = [] + for i in range(self.i_low, self.i_high, stepx): + for j in range(self.j_low, self.j_high, stepy): + i_high = min(i + stepx, self.i_high) + j_high = min(j + stepy, self.j_high) + windows.append(Window((i, i_high, j, j_high), self.crpix, self.image)) + return windows + def get_indices(self, crpix: tuple[int, int] = None): if crpix is None: crpix = self.crpix @@ -83,25 +104,25 @@ def __and__(self, other: "Window"): class Window_List: - def __init__(self, window_list: list[Window]): - if not all(isinstance(window, Window) for window in window_list): + def __init__(self, windows: list[Window]): + if not all(isinstance(window, Window) for window in windows): raise InvalidWindow( - f"Window_List can only hold Window objects, not {tuple(type(window) for window in window_list)}" + f"Window_List can only hold Window objects, not {tuple(type(window) for window in windows)}" ) - self.window_list = window_list + self.windows = windows def index(self, other: Window): - for i, window in enumerate(self.window_list): + for i, window in enumerate(self.windows): if other.identity == window.identity: return i else: raise ValueError("Could not find identity match between window list and input window") def __getitem__(self, index): - return self.window_list[index] + return self.windows[index] def __len__(self): - return len(self.window_list) + return len(self.windows) def __iter__(self): - return iter(self.window_list) + return iter(self.windows) diff --git a/astrophot/models/_model_methods.py b/astrophot/models/_model_methods.py index 85945943..0571c5cb 100644 --- a/astrophot/models/_model_methods.py +++ b/astrophot/models/_model_methods.py @@ -1,13 +1,10 @@ from typing import Optional, Union import io -from copy import deepcopy import numpy as np import torch from torch.autograd.functional import jacobian as torchjac -from ..param import Parameter_Node, Param_Mask -from ..utils.decorators import default_internal from ..utils.interpolate import ( _shift_Lanczos_kernel_torch, simpsons_kernel, @@ -26,34 +23,9 @@ single_quad_integrate, ) from ..errors import SpecificationConflict -from .core_model import AstroPhot_Model from .. import AP_config -@default_internal -def angular_metric(self, X, Y, image=None): - return torch.atan2(Y, X) - - -@default_internal -def radius_metric(self, X, Y, image=None): - return torch.sqrt(X**2 + Y**2) - - -def build_parameter_specs(self, kwargs): - parameter_specs = deepcopy(self._parameter_specs) - - for p in kwargs: - if p not in self._parameter_specs: - continue - if isinstance(kwargs[p], dict): - parameter_specs[p].update(kwargs[p]) - else: - parameter_specs[p]["value"] = kwargs[p] - - return parameter_specs - - def _sample_init(self, image, center): if self.sampling_mode == "midpoint": Coords = image.get_coordinate_meshgrid() @@ -229,7 +201,6 @@ def _sample_convolve(self, image, shift, psf, shift_method="bilinear"): @torch.no_grad() -@forward def jacobian( self, as_representation: bool = False, diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index 6e4e9ab9..9d783375 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -1,16 +1,12 @@ -import io -from typing import Optional +from typing import Optional, Union +from copy import deepcopy import torch -import yaml +from caskade import Module, forward, Param -from ..utils.conversions.dict_to_hdf5 import dict_to_hdf5, hdf5_to_dict -from ..utils.decorators import ignore_numpy_warnings, default_internal, classproperty -from ..image import Window, Target_Image, Target_Image_List -from caskade import Module, forward -from ._shared_methods import select_target, select_sample -from .. import AP_config -from ..errors import InvalidTarget, UnrecognizedModel, InvalidWindow +from ..utils.decorators import classproperty +from ..image import Window, Target_Image_List +from ..errors import UnrecognizedModel, InvalidWindow __all__ = ("AstroPhot_Model",) @@ -120,6 +116,21 @@ def __init__(self, *, name=None, target=None, window=None, **kwargs): self.target = target self.window = window self.mask = kwargs.get("mask", None) + # Set any user defined attributes for the model + for kwarg in kwargs: # fixme move to core model? + # Skip parameters with special behaviour + if kwarg in self.special_kwargs: + continue + # Set the model parameter + setattr(self, kwarg, kwargs[kwarg]) + + # If loading from a file, get model configuration then exit __init__ + if "filename" in kwargs: + self.load(kwargs["filename"], new_name=name) + return + self.parameter_specs = self.build_parameter_specs(kwargs) + for key in self.parameter_specs: + setattr(self, key, Param(key, **self.parameter_specs[key])) @classproperty def model_type(cls): @@ -130,9 +141,21 @@ def model_type(cls): mt = getattr(subcls, "_model_type", None) if mt: collected.append(mt) - # Build the final combined string return " ".join(collected) + def build_parameter_specs(self, kwargs): + parameter_specs = deepcopy(self._parameter_specs) + + for p in kwargs: + if p not in self._parameter_specs: + continue + if isinstance(kwargs[p], dict): + parameter_specs[p].update(kwargs[p]) + else: + parameter_specs[p]["value"] = kwargs[p] + + return parameter_specs + @torch.no_grad() def initialize(self, **kwargs): """When this function finishes, all parameters should have numerical @@ -151,43 +174,55 @@ def sample(self, *args, **kwargs): pass @forward - def negative_log_likelihood( + def gaussian_negative_log_likelihood( self, + window: Optional[Window] = None, ): """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. """ - model = self.sample() - data = self.target[self.window] + if window is None: + window = self.window + model = self(window=window).data + data = self.target[window] weight = data.weight - if self.target.has_mask: - if isinstance(data, Target_Image_List): - mask = tuple(torch.logical_not(submask) for submask in data.mask) - chi2 = sum( - torch.sum(((mo - da).data ** 2 * wgt)[ma]) / 2.0 - for mo, da, wgt, ma in zip(model, data, weight, mask) - ) - else: - mask = torch.logical_not(data.mask) - chi2 = torch.sum(((model - data).data ** 2 * weight)[mask]) / 2.0 + mask = data.mask + data = data.data + if isinstance(data, Target_Image_List): + nll = sum( + torch.sum(((mo - da) ** 2 * wgt)[~ma]) / 2.0 + for mo, da, wgt, ma in zip(model, data, weight, mask) + ) else: - if isinstance(data, Target_Image_List): - chi2 = sum( - torch.sum(((mo - da).data ** 2 * wgt)) / 2.0 - for mo, da, wgt in zip(model, data, weight) - ) - else: - chi2 = torch.sum(((model - data).data ** 2 * weight)) / 2.0 + nll = torch.sum(((model - data) ** 2 * weight)[~mask]) / 2.0 - return chi2 + return nll @forward - def jacobian( + def poisson_negative_log_likelihood( self, - **kwargs, + window: Optional[Window] = None, ): - raise NotImplementedError("please use a subclass of AstroPhot Model") + """ + Compute the negative log likelihood of the model wrt the target image in the appropriate window. + """ + if window is None: + window = self.window + model = self(window=window).data + data = self.target[window] + mask = data.mask + data = data.data + + if isinstance(data, Target_Image_List): + nll = sum( + torch.sum((mo - da * (mo + 1e-10).log() + torch.lgamma(da + 1))[~ma]) + for mo, da, ma in zip(model, data, mask) + ) + else: + nll = torch.sum((model - data * (model + 1e-10).log() + torch.lgamma(data + 1))[~mask]) + + return nll @forward def total_flux(self, window=None): @@ -233,96 +268,6 @@ def set_window(self, window): def window(self, window): self.set_window(window) - @property - def target(self): - return self._target - - @target.setter - def target(self, tar): - if tar is None: - self._target = None - return - elif not isinstance(tar, Target_Image): - raise InvalidTarget("AstroPhot Model target must be a Target_Image instance.") - self._target = tar - - def get_state(self, *args, **kwargs): - """Returns a dictionary of the state of the model with its name, - type, parameters, and other important information. This - dictionary is what gets saved when a model saves to disk. - - """ - state = { - "name": self.name, - "model_type": self.model_type, - } - return state - - def save(self, filename="AstroPhot.yaml"): - """Saves a model object to disk. By default the file type should be - yaml, this is the only file type which gets tested, though - other file types such as json and hdf5 should work. - - """ - if filename.endswith(".yaml"): - state = self.get_state() - with open(filename, "w") as f: - yaml.dump(state, f, indent=2) - elif filename.endswith(".json"): - import json - - state = self.get_state() - with open(filename, "w") as f: - json.dump(state, f, indent=2) - elif filename.endswith(".hdf5"): - import h5py - - state = self.get_state() - with h5py.File(filename, "w") as F: - dict_to_hdf5(F, state) - else: - if isinstance(filename, str) and "." in filename: - raise ValueError( - f"Unrecognized filename format: {filename[filename.find('.'):]}, must be one of: .json, .yaml, .hdf5" - ) - else: - raise ValueError( - f"Unrecognized filename format: {str(filename)}, must be one of: .json, .yaml, .hdf5" - ) - - @classmethod - def load(cls, filename="AstroPhot.yaml"): - """ - Loads a saved model object. - """ - if isinstance(filename, dict): - state = filename - elif isinstance(filename, io.TextIOBase): - state = yaml.load(filename, Loader=yaml.FullLoader) - elif filename.endswith(".yaml"): - with open(filename, "r") as f: - state = yaml.load(f, Loader=yaml.FullLoader) - elif filename.endswith(".json"): - import json - - with open(filename, "r") as f: - state = json.load(f) - elif filename.endswith(".hdf5"): - import h5py - - with h5py.File(filename, "r") as F: - state = hdf5_to_dict(F) - else: - if isinstance(filename, str) and "." in filename: - raise ValueError( - f"Unrecognized filename format: {filename[filename.find('.'):]}, must be one of: .json, .yaml, .hdf5" - ) - else: - raise ValueError( - f"Unrecognized filename format: {str(filename)}, must be one of: .json, .yaml, .hdf5 or python dictionary." - ) - return state - @classmethod def List_Models(cls, usable=None): MODELS = all_subclasses(cls) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 0f179f26..b9e699ab 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -57,7 +57,7 @@ def update_window(self): """ if isinstance(self.target, Image_List): # Window_List if target is a Target_Image_List - new_window = [None] * len(self.target.image_list) + new_window = [None] * len(self.target.images) for model in self.models.values(): if isinstance(model.target, Image_List): for target, window in zip(model.target, model.window): @@ -152,14 +152,12 @@ def sample( for model in self.models.values(): if window is None: - use_window = None + use_window = model.window elif isinstance(image, Image_List) and isinstance(model.target, Image_List): indices = image.match_indices(model.target) if len(indices) == 0: continue - use_window = Window_List( - window_list=list(image.image_list[i].window for i in indices) - ) + use_window = Window_List(window_list=list(image.images[i].window for i in indices)) elif isinstance(image, Image_List) and isinstance(model.target, Image): try: image.index(model.target) @@ -178,12 +176,11 @@ def sample( raise NotImplementedError( f"Group_Model cannot sample with {type(image)} and {type(model.target)}" ) - image += model(window=use_window) + image += model(window=model.window & use_window) return image @torch.no_grad() - @forward def jacobian( self, pass_jacobian: Optional[Jacobian_Image] = None, @@ -195,8 +192,6 @@ def jacobian( jacobian method of each sub model and add it in to the total. Args: - parameters (Optional[torch.Tensor]): 1D parameter vector to overwrite current values - as_representation (bool): Indicates if the "parameters" argument is in the form of the real values, or as representations in the (-inf,inf) range. Default False pass_jacobian (Optional["Jacobian_Image"]): A Jacobian image pre-constructed to be passed along instead of constructing new Jacobians """ @@ -211,18 +206,10 @@ def jacobian( jac_img = pass_jacobian for model in self.models.values(): - if isinstance(model, Group_Model): - model.jacobian( - as_representation=as_representation, - pass_jacobian=jac_img, - window=window, - ) - else: # fixme, maybe make pass_jacobian be filled internally to each model - jac_img += model.jacobian( - as_representation=as_representation, - pass_jacobian=jac_img, - window=window, - ) + model.jacobian( + pass_jacobian=jac_img, + window=window, + ) return jac_img @@ -241,49 +228,3 @@ def target(self, tar): if not (tar is None or isinstance(tar, (Target_Image, Target_Image_List))): raise InvalidTarget("Group_Model target must be a Target_Image instance.") self._target = tar - - if hasattr(self, "models"): - for model in self.models.values(): - model.target = tar - - def get_state(self, save_params=True): - """Returns a dictionary with information about the state of the model - and its parameters. - - """ - state = super().get_state(save_params=save_params) - if save_params: - state["parameters"] = self.parameters.get_state() - if "models" not in state: - state["models"] = {} - for model in self.models.values(): - state["models"][model.name] = model.get_state(save_params=False) - return state - - def load(self, filename="AstroPhot.yaml", new_name=None): - """Loads an AstroPhot state file and updates this model with the - loaded parameters. - - """ - state = AstroPhot_Model.load(filename) - - if new_name is None: - new_name = state["name"] - self.name = new_name - - if isinstance(state["parameters"], Parameter_Node): - self.parameters = state["parameters"] - else: - self.parameters = Parameter_Node(self.name, state=state["parameters"]) - - for model in state["models"]: - state["models"][model]["parameters"] = self.parameters[model] - for own_model in self.models.values(): - if model == own_model.name: - own_model.load(state["models"][model]) - break - else: - self.add_model( - AstroPhot_Model(name=model, filename=state["models"][model], target=self.target) - ) - self.update_window() diff --git a/astrophot/models/group_psf_model.py b/astrophot/models/group_psf_model.py index 4f92c969..0383d538 100644 --- a/astrophot/models/group_psf_model.py +++ b/astrophot/models/group_psf_model.py @@ -7,17 +7,9 @@ class PSF_Group_Model(Group_Model): - model_type = f"psf {Group_Model.model_type}" + _model_type = "psf" usable = True - @property - def psf_mode(self): - return "none" - - @psf_mode.setter - def psf_mode(self, value): - pass - @property def target(self): try: @@ -26,11 +18,7 @@ def target(self): return None @target.setter - def target(self, tar): - if not (tar is None or isinstance(tar, PSF_Image)): + def target(self, target): + if not (target is None or isinstance(target, PSF_Image)): raise InvalidTarget("Group_Model target must be a PSF_Image instance.") - self._target = tar - - if hasattr(self, "models"): - for model in self.models.values(): - model.target = tar + self._target = target diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index 27425a9e..aece3494 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -1,6 +1,7 @@ from .sersic import SersicMixin, iSersicMixin from .brightness import RadialMixin, InclinedMixin from .exponential import ExponentialMixin, iExponentialMixin +from .sample import SampleMixin __all__ = ( "SersicMixin", @@ -9,4 +10,5 @@ "InclinedMixin", "ExponentialMixin", "iExponentialMixin", + "SampleMixin", ) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py new file mode 100644 index 00000000..88c452ee --- /dev/null +++ b/astrophot/models/mixins/sample.py @@ -0,0 +1,137 @@ +from typing import Optional, Literal + +import numpy as np +from caskade import forward +from torch.autograd.functional import jacobian +import torch +from torch import Tensor + +from ... import AP_config +from ...image import Image, Window, Jacobian_Image +from .. import func +from ...errors import SpecificationConflict + + +class SampleMixin: + # Method for initial sampling of model + sampling_mode = "auto" # auto (choose based on image size), midpoint, simpsons, quad:x (where x is a positive integer) + + # Maximum size of parameter list before jacobian will be broken into smaller chunks, this is helpful for limiting the memory requirements to build a model, lower jacobian_chunksize is slower but uses less memory + jacobian_maxparams = 10 + jacobian_maxpixels = 1000**2 + + @forward + def sample_image(self, image: Image): + if self.sampling_mode == "auto": + N = np.prod(image.data.shape) + if N <= 100: + sampling_mode = "quad:5" + elif N <= 10000: + sampling_mode = "simpsons" + else: + sampling_mode = "midpoint" + else: + sampling_mode = self.sampling_mode + + if sampling_mode == "midpoint": + i, j = func.pixel_center_meshgrid(image.shape, AP_config.ap_dtype, AP_config.ap_device) + x, y = image.pixel_to_plane(i, j) + res = self.brightness(x, y) + return func.pixel_center_integrator(res) + elif sampling_mode == "simpsons": + i, j = func.pixel_simpsons_meshgrid( + image.shape, AP_config.ap_dtype, AP_config.ap_device + ) + x, y = image.pixel_to_plane(i, j) + res = self.brightness(x, y) + return func.pixel_simpsons_integrator(res) + elif sampling_mode.startswith("quad:"): + order = int(self.sampling_mode.split(":")[1]) + i, j, w = func.pixel_quad_meshgrid( + image.shape, AP_config.ap_dtype, AP_config.ap_device, order=order + ) + x, y = image.pixel_to_plane(i, j) + res = self.brightness(x, y) + return func.pixel_quad_integrator(res, w) + raise SpecificationConflict( + f"Unknown sampling mode {self.sampling_mode} for model {self.name}" + ) + + def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor): + return jacobian( + lambda x: self.sample( + window=window, params=torch.cat((params_pre, x, params_post), dim=-1) + ).data, + params, + strategy="forward-mode", + vectorize=True, + create_graph=False, + ) + + def jacobian( + self, + window: Optional[Window] = None, + pass_jacobian: Optional[Jacobian_Image] = None, + ): + if window is None: + window = self.window + + if pass_jacobian is None: + jac_img = self.target[window].jacobian_image( + parameters=self.parameters.vector_identities() + ) + else: + jac_img = pass_jacobian + + # handle large images + n_pixels = np.prod(window.shape) + if n_pixels > self.jacobian_maxpixels: + for chunk in window.chunk(self.jacobian_maxpixels): + self.jacobian(window=chunk, pass_jacobian=jac_img) + return jac_img + + # handle large number of parameters + params = self.build_params_array() + if len(params) > self.jacobian_maxparams: + chunksize = len(params) // self.jacobian_maxparams + 1 + for i in range(chunksize, len(params), chunksize): + params_pre = params[:i] + params_post = params[i + chunksize :] + params_chunk = params[i : i + chunksize] + jac_chunk = self._jacobian(window, params_pre, params_chunk, params_post) + jac_img += self.target[window].jacobian_image( + parameters=self.parameters.vector_identities(), + data=jac_chunk, + ) + else: + jac = self._jacobian(window, params[:0], params, params[0:0]) + jac_img += self.target[window].jacobian_image( + parameters=self.parameters.vector_identities(), + data=jac, + ) + + return jac_img + + def gradient( + self, + window: Optional[Window] = None, + likelihood: Literal["gaussian", "poisson"] = "gaussian", + ): + """Compute the gradient of the model with respect to its parameters.""" + if window is None: + window = self.window + + jacobian_image = self.jacobian(window=window) + + data = self.target[window].data.value + model = self.sample(window=window).data.value + if likelihood == "gaussian": + weight = self.target[window].weight + gradient = torch.sum(jacobian_image.data.value * (data - model) * weight, dim=(0, 1)) + elif likelihood == "poisson": + gradient = torch.sum( + jacobian_image.data.value * (1 - data / model), + dim=(0, 1), + ) + + return gradient diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 2fe1fca1..f3b21476 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -2,27 +2,26 @@ import numpy as np import torch -from caskade import Param, forward +from caskade import forward from .core_model import Model from . import func from ..image import ( Model_Image, + Target_Image, Window, PSF_Image, - Target_Image, - Target_Image_List, - Image, ) from ..utils.initialize import center_of_mass -from ..utils.decorators import ignore_numpy_warnings, default_internal, select_target +from ..utils.decorators import ignore_numpy_warnings from .. import AP_config -from ..errors import InvalidTarget, SpecificationConflict +from ..errors import SpecificationConflict, InvalidTarget +from .mixins import SampleMixin __all__ = ["Component_Model"] -class Component_Model(Model): +class Component_Model(SampleMixin, Model): """Component_Model(name, target, window, locked, **kwargs) Component_Model is a base class for models that represent single @@ -63,9 +62,6 @@ class Component_Model(Model): # Method to use when performing subpixel shifts. bilinear set by default for stability around pixel edges, though lanczos:3 is also fairly stable, and all are stable when away from pixel edges psf_subpixel_shift = "lanczos:3" # bilinear, lanczos:2, lanczos:3, lanczos:5, none - # Method for initial sampling of model - sampling_mode = "auto" # auto (choose based on image size), midpoint, simpsons, quad:x (where x is a positive integer) - # Level to which each pixel should be evaluated sampling_tolerance = 1e-2 @@ -81,10 +77,6 @@ class Component_Model(Model): # The initial quadrature level for sub pixel integration. Please always choose an odd number 3 or higher integrate_quad_level = 3 - # Maximum size of parameter list before jacobian will be broken into smaller chunks, this is helpful for limiting the memory requirements to build a model, lower jacobian_chunksize is slower but uses less memory - jacobian_chunksize = 10 - image_chunksize = 1000 - # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) softening = 1e-3 @@ -106,29 +98,6 @@ class Component_Model(Model): ] usable = False - def __init__(self, *, name=None, **kwargs): - super().__init__(name=name, **kwargs) - - self.psf = None - self.psf_aux_image = None - - # Set any user defined attributes for the model - for kwarg in kwargs: # fixme move to core model? - # Skip parameters with special behaviour - if kwarg in self.special_kwargs: - continue - # Set the model parameter - setattr(self, kwarg, kwargs[kwarg]) - - # If loading from a file, get model configuration then exit __init__ - if "filename" in kwargs: - self.load(kwargs["filename"], new_name=name) - return - - self.parameter_specs = self.build_parameter_specs(kwargs) - for key in self.parameter_specs: - setattr(self, key, Param(key, **self.parameter_specs[key])) - @property def psf(self): if self._psf is None: @@ -154,6 +123,19 @@ def psf(self, val): "or ap.models.AstroPhot_Model object instead." ) + @property + def target(self): + return self._target + + @target.setter + def target(self, tar): + if tar is None: + self._target = None + return + elif not isinstance(tar, Target_Image): + raise InvalidTarget("AstroPhot Model target must be a Target_Image instance.") + self._target = tar + # Initialization functions ###################################################################### @torch.no_grad() @@ -171,7 +153,6 @@ def initialize( """ super().initialize() - # Get the sub-image area corresponding to the model image target_area = self.target[self.window] # Use center of window if a center hasn't been set yet @@ -180,65 +161,15 @@ def initialize( else: return - # Compute center of mass in window COM = center_of_mass(target_area.data.npvalue) - # Convert center of mass indices to coordinates COM_center = target_area.pixel_to_plane( *torch.tensor(COM, dtype=AP_config.ap_dtype, device=AP_config.ap_device) ) - # Set the new coordinates as the model center self.center.value = COM_center # Fit loop functions ###################################################################### - @forward - def brightness( - self, - x: Optional[torch.Tensor] = None, - y: Optional[torch.Tensor] = None, - **kwargs, - ): - """Evaluate the brightness of the model at the exact tangent plane coordinates requested.""" - return torch.zeros_like(x) # do nothing in base model - - @forward - def sample_image(self, image: Image): - if self.sampling_mode == "auto": - N = np.prod(image.data.shape) - if N <= 100: - sampling_mode = "quad:5" - elif N <= 10000: - sampling_mode = "simpsons" - else: - sampling_mode = "midpoint" - else: - sampling_mode = self.sampling_mode - - if sampling_mode == "midpoint": - i, j = func.pixel_center_meshgrid(image.shape, AP_config.ap_dtype, AP_config.ap_device) - x, y = image.pixel_to_plane(i, j) - res = self.brightness(x, y) - return func.pixel_center_integrator(res) - elif sampling_mode == "simpsons": - i, j = func.pixel_simpsons_meshgrid( - image.shape, AP_config.ap_dtype, AP_config.ap_device - ) - x, y = image.pixel_to_plane(i, j) - res = self.brightness(x, y) - return func.pixel_simpsons_integrator(res) - elif sampling_mode.startswith("quad:"): - order = int(self.sampling_mode.split(":")[1]) - i, j, w = func.pixel_quad_meshgrid( - image.shape, AP_config.ap_dtype, AP_config.ap_device, order=order - ) - x, y = image.pixel_to_plane(i, j) - res = self.brightness(x, y) - return func.pixel_quad_integrator(res, w) - raise SpecificationConflict( - f"Unknown integration mode {self.sampling_mode} for model {self.name}" - ) - def shift_kernel(self, shift): if self.psf_subpixel_shift == "bilinear": return func.bilinear_kernel(shift[0], shift[1]) @@ -329,35 +260,10 @@ def sample( sample = self.sample_integrate(sample, working_image) working_image.data = sample + # Units from flux/arcsec^2 to flux + working_image.data = working_image.data.value * working_image.pixel_area + if self.mask is not None: working_image.data = working_image.data * (~self.mask) return working_image - - def get_state(self, save_params=True): - """Returns a dictionary with a record of the current state of the - model. - - Specifically, the current parameter settings and the window for - this model. From this information it is possible for the model to - re-build itself lated when loading from disk. Note that the target - image is not saved, this must be reset when loading the model. - - """ - state = super().get_state() - state["window"] = self.window.get_state() - if save_params: - state["parameters"] = self.parameters.get_state() - state["target_identity"] = self._target_identity - if isinstance(self._psf, PSF_Image) or isinstance(self._psf, AstroPhot_Model): - state["psf"] = self._psf.get_state() - for key in self.track_attrs: - if getattr(self, key) != getattr(self.__class__, key): - state[key] = getattr(self, key) - return state - - # Extra background methods for the basemodel - ###################################################################### - from ._model_methods import build_parameter_specs - from ._model_methods import jacobian - from ._model_methods import load diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 18614e68..a823678f 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -1,25 +1,22 @@ from typing import Optional import torch +from caskade import forward -from .core_model import AstroPhot_Model +from .core_model import Model from ..image import ( - Image, Model_Image, Window, PSF_Image, - Image_List, ) -from ._shared_methods import select_target -from ..utils.decorators import default_internal, ignore_numpy_warnings -from ..param import Parameter_Node -from ..errors import SpecificationConflict +from ..errors import InvalidTarget +from .mixins import SampleMixin __all__ = ["PSF_Model"] -class PSF_Model(AstroPhot_Model): +class PSF_Model(SampleMixin, Model): """Prototype point source (typically a star) model, to be subclassed by other point source models which define specific behavior. @@ -38,21 +35,14 @@ class PSF_Model(AstroPhot_Model): "units": "arcsec", "value": (0.0, 0.0), "uncertainty": (0.1, 0.1), - "locked": True, }, } - # Fixed order of parameters for all methods that interact with the list of parameters - _parameter_order = ("center",) - model_type = f"psf {AstroPhot_Model.model_type}" + _model_type = "psf" usable = False - model_integrated = None # The sampled PSF will be normalized to a total flux of 1 within the window normalize_psf = True - # Method for initial sampling of model - sampling_mode = "simpsons" # midpoint, trapezoid, simpson - # Level to which each pixel should be evaluated sampling_tolerance = 1e-3 @@ -68,10 +58,6 @@ class PSF_Model(AstroPhot_Model): # The initial quadrature level for sub pixel integration. Please always choose an odd number 3 or higher integrate_quad_level = 3 - # Maximum size of parameter list before jacobian will be broken into smaller chunks, this is helpful for limiting the memory requirements to build a model, lower jacobian_chunksize is slower but uses less memory - jacobian_chunksize = 10 - image_chunksize = 1000 - # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) softening = 1e-3 @@ -88,100 +74,12 @@ class PSF_Model(AstroPhot_Model): "softening", ] - def __init__(self, *, name=None, **kwargs): - self._target_identity = None - super().__init__(name=name, **kwargs) - - # Set any user defined attributes for the model - for kwarg in kwargs: # fixme move to core model? - # Skip parameters with special behaviour - if kwarg in self.special_kwargs: - continue - # Set the model parameter - setattr(self, kwarg, kwargs[kwarg]) - - # If loading from a file, get model configuration then exit __init__ - if "filename" in kwargs: - self.load(kwargs["filename"], new_name=name) - return - - self.parameter_specs = self.build_parameter_specs(kwargs.get("parameters", None)) - with torch.no_grad(): - self.build_parameters() - if isinstance(kwargs.get("parameters", None), torch.Tensor): - self.parameters.value = kwargs["parameters"] - assert torch.allclose( - self.window.center, torch.zeros_like(self.window.center) - ), "PSF models must always be centered at (0,0)" - - # Initialization functions - ###################################################################### - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize( - self, - target: Optional["PSF_Image"] = None, - parameters: Optional[Parameter_Node] = None, - **kwargs, - ): - """Determine initial values for the center coordinates. This is done - with a local center of mass search which iterates by finding - the center of light in a window, then iteratively updates - until the iterations move by less than a pixel. - - Args: - target (Optional[Target_Image]): A target image object to use as a reference when setting parameter values - - """ - super().initialize(target=target, parameters=parameters) - - @default_internal - def transform_coordinates(self, X, Y, image=None, parameters=None): - return X, Y - # Fit loop functions ###################################################################### - def evaluate_model( - self, - X: Optional[torch.Tensor] = None, - Y: Optional[torch.Tensor] = None, - image: Optional[Image] = None, - parameters: "Parameter_Node" = None, - **kwargs, - ): - """Evaluate the model on every pixel in the given image. The - basemodel object simply returns zeros, this function should be - overloaded by subclasses. - - Args: - image (Image): The image defining the set of pixels on which to evaluate the model - - """ - if X is None or Y is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - return torch.zeros_like(X) # do nothing in base model - - def make_model_image(self, window: Optional[Window] = None): - """This is called to create a blank `Model_Image` object of the - correct format for this model. This is typically used - internally to construct the model image before filling the - pixel values with the model. - - """ - if window is None: - window = self.window - else: - window = self.window & window - return self.target[window].blank_copy() - + @forward def sample( self, - image: Optional[Image] = None, window: Optional[Window] = None, - parameters: Optional[Parameter_Node] = None, ): """Evaluate the model on the space covered by an image object. This function properly calls integration methods. This should not @@ -208,60 +106,24 @@ def sample( """ # Image on which to evaluate model - if image is None: - image = self.make_model_image(window=window) - - # Window within which to evaluate model if window is None: - working_window = image.window.copy() - else: - working_window = window.copy() - - # Parameters with which to evaluate the model - if parameters is None: - parameters = self.parameters + window = self.window # Create an image to store pixel samples - working_image = Model_Image(window=working_window) - if self.model_integrated is True: - # Evaluate the model on the image - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - working_image.data = self.evaluate_model( - X=X, Y=Y, image=working_image, parameters=parameters - ) - elif self.model_integrated is False: - # Evaluate the model on the image - reference, deep = self._sample_init( - image=working_image, - parameters=parameters, - center=parameters["center"].value, - ) - # Super-resolve and integrate where needed - deep = self._sample_integrate( - deep, - reference, - working_image, - parameters, - center=torch.zeros_like(working_image.center), - ) - # Add the sampled/integrated pixels to the requested image - working_image.data += deep - else: - raise SpecificationConflict( - "PSF model 'model_integrated' should be either True or False" - ) + working_image = Model_Image(window=window) + sample = self.sample_image(working_image) + if self.integrate_mode == "threshold": + sample = self.sample_integrate(sample, working_image) + working_image.data = sample # normalize to total flux 1 if self.normalize_psf: - working_image.data /= torch.sum(working_image.data) + working_image.data /= torch.sum(working_image.data.value) if self.mask is not None: - working_image.data = working_image.data * torch.logical_not(self.mask) + working_image.data = working_image.data.value * torch.logical_not(self.mask) - image += working_image - - return image + return working_image @property def target(self): @@ -271,60 +133,9 @@ def target(self): return None @target.setter - def target(self, tar): - assert tar is None or isinstance(tar, PSF_Image) - - # If a target image list is assigned, pick out the target appropriate for this model - if isinstance(tar, Image_List) and self._target_identity is not None: - for subtar in tar: - if subtar.identity == self._target_identity: - usetar = subtar - break - else: - raise KeyError( - f"Could not find target in Target_Image_List with matching identity to {self.name}: {self._target_identity}" - ) - else: - usetar = tar - - self._target = usetar - - # Remember the target identity to use - try: - self._target_identity = self._target.identity - except AttributeError: - pass - - def get_state(self, save_params=True): - """Returns a dictionary with a record of the current state of the - model. - - Specifically, the current parameter settings and the window for - this model. From this information it is possible for the model to - re-build itself lated when loading from disk. Note that the target - image is not saved, this must be reset when loading the model. - - """ - state = super().get_state() - state["window"] = self.window.get_state() - if save_params: - state["parameters"] = self.parameters.get_state() - state["target_identity"] = self._target_identity - for key in self.track_attrs: - if getattr(self, key) != getattr(self.__class__, key): - state[key] = getattr(self, key) - return state - - # Extra background methods for the basemodel - ###################################################################### - from ._model_methods import radius_metric - from ._model_methods import angular_metric - from ._model_methods import _sample_init - from ._model_methods import _sample_integrate - from ._model_methods import _integrate_reference - from ._model_methods import build_parameter_specs - from ._model_methods import build_parameters - from ._model_methods import jacobian - from ._model_methods import _chunk_jacobian - from ._model_methods import _chunk_image_jacobian - from ._model_methods import load + def target(self, target): + if target is None: + self._target = None + elif not isinstance(target, PSF_Image): + raise InvalidTarget(f"Target for PSF_Model must be a PSF_Image, not {type(target)}") + self._target = target diff --git a/astrophot/utils/initialize/center.py b/astrophot/utils/initialize/center.py index c895339f..fc2f1c32 100644 --- a/astrophot/utils/initialize/center.py +++ b/astrophot/utils/initialize/center.py @@ -2,53 +2,12 @@ from scipy.optimize import minimize from ..interpolate import point_Lanczos -from ... import AP_config -def center_of_mass(center, image, window=None): - """Iterative light weighted center of mass optimization. Each step - determines the light weighted center of mass within a small - window. The new center is used to create a new window. This - continues until the center no longer updates or an image boundary - is reached. - - """ - if window is None: - window = max(min(int(min(image.shape) / 10), 30), 6) - init_center = center - window += window % 2 - xx, yy = np.meshgrid(np.arange(window), np.arange(window)) - for iteration in range(100): - # Determine the image window to calculate COM - ranges = [ - [int(round(center[0]) - window / 2), int(round(center[0]) + window / 2)], - [int(round(center[1]) - window / 2), int(round(center[1]) + window / 2)], - ] - # Avoid edge of image - if ( - ranges[0][0] < 0 - or ranges[1][0] < 0 - or ranges[0][1] >= image.shape[0] - or ranges[1][1] >= image.shape[1] - ): - AP_config.ap_logger.warning("Image edge!") - return init_center - - # Compute COM - denom = np.sum(image[ranges[0][0] : ranges[0][1], ranges[1][0] : ranges[1][1]]) - new_center = [ - ranges[0][0] - + np.sum(image[ranges[0][0] : ranges[0][1], ranges[1][0] : ranges[1][1]] * yy) / denom, - ranges[1][0] - + np.sum(image[ranges[0][0] : ranges[0][1], ranges[1][0] : ranges[1][1]] * xx) / denom, - ] - new_center = np.array(new_center) - # Check for convergence - if np.sum(np.abs(np.array(center) - new_center)) < 0.1: - break - - center = new_center - +def center_of_mass(image): + """Determines the light weighted center of mass""" + xx, yy = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij") + center = np.array((np.sum(image * xx), np.sum(image * yy))) / np.sum(image) return center From 657a68db75e985e7ee24737687c5c1e945772d79 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 13 Jun 2025 15:01:23 -0400 Subject: [PATCH 019/191] handle windows, start optimizers --- astrophot/fit/base.py | 19 ++-------- astrophot/fit/func/lm.py | 52 ++++++++++++++++++++++++++ astrophot/fit/gp.py | 1 - astrophot/fit/lm.py | 5 ++- astrophot/image/image_object.py | 18 ++++++--- astrophot/image/target_image.py | 51 ------------------------- astrophot/image/window.py | 10 +---- astrophot/models/core_model.py | 9 +++-- astrophot/models/group_model_object.py | 29 +++++++------- astrophot/models/mixins/sample.py | 38 +++++++++++++------ astrophot/models/model_object.py | 3 ++ 11 files changed, 124 insertions(+), 111 deletions(-) create mode 100644 astrophot/fit/func/lm.py delete mode 100644 astrophot/fit/gp.py diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index be9a2e56..de916c77 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -6,6 +6,8 @@ from scipy.special import gammainc from .. import AP_config +from ..models import Model +from ..image import Window __all__ = ["BaseOptimizer"] @@ -25,10 +27,10 @@ class BaseOptimizer(object): def __init__( self, - model: "AstroPhot_Model", + model: Model, initial_state: Sequence = None, relative_tolerance: float = 1e-3, - fit_window: Optional["Window"] = None, + fit_window: Optional[Window] = None, **kwargs, ) -> None: """ @@ -63,19 +65,6 @@ def __init__( else: self.fit_window = fit_window & self.model.window - if initial_state is None: - self.model.initialize() - initial_state = self.model.parameters.vector_representation() - else: - initial_state = torch.as_tensor( - initial_state, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - - self.current_state = torch.as_tensor( - initial_state, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - if self.verbose > 1: - AP_config.ap_logger.info(f"initial state: {self.current_state}") self.max_iter = kwargs.get("max_iter", 100 * len(initial_state)) self.iteration = 0 self.save_steps = kwargs.get("save_steps", None) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py new file mode 100644 index 00000000..42093539 --- /dev/null +++ b/astrophot/fit/func/lm.py @@ -0,0 +1,52 @@ +import torch +import numpy as np + + +def hessian(J, W): + return J.T @ (W * J) + + +def gradient(J, W, R): + return -J.T @ (W * R) + + +def step(L, grad, hess): + I = torch.eye(len(grad), dtype=grad.dtype, device=grad.device) + D = torch.ones_like(hess) - I + + h = torch.linalg.solve( + hess * (I + D / (1 + L)) + L * I * (1 + torch.diag(hess)), + grad, + ) + + return h + + +def step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): + + M0 = model(x) + J = jacobian(x) + R = data - M0 + grad = gradient(J, weight, R) + hess = hessian(J, weight) + + best = {"h": torch.zeros_like(x), "chi2": chi2, "L": L} + scary = {"h": None, "chi2": chi2, "L": L} + + improving = None + for i in range(10): + h = step(L, grad, hess) + M1 = model(x + h) + + chi2 = torch.sum(weight * (data - M1) ** 2).item() / ndf + + # Handle nan chi2 + if not np.isfinite(chi2): + L *= Lup + if improving is True: + break + improving = False + continue + + if chi2 < scary["chi2"]: + scary = {"h": h, "chi2": chi2, "L": L} diff --git a/astrophot/fit/gp.py b/astrophot/fit/gp.py deleted file mode 100644 index f01212f3..00000000 --- a/astrophot/fit/gp.py +++ /dev/null @@ -1 +0,0 @@ -# Gaussian Process Regression diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 44d40460..f900f52a 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -209,13 +209,14 @@ def __init__( fit_mask = fit_mask.flatten() if torch.sum(fit_mask).item() == 0: fit_mask = None + if model.target.has_mask: mask = self.model.target[self.fit_window].flatten("mask") if fit_mask is not None: mask = mask | fit_mask - self.mask = torch.logical_not(mask) + self.mask = ~mask elif fit_mask is not None: - self.mask = torch.logical_not(fit_mask) + self.mask = ~fit_mask else: self.mask = None if self.mask is not None and torch.sum(self.mask).item() == 0: diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 5ce19be6..a979b201 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -377,7 +377,17 @@ def get_astropywcs(self, **kwargs): return AstropyWCS(wargs) @torch.no_grad() - def get_indices(self, other: "Image"): + def get_indices(self, other: Union[Window, "Image"]): + if isinstance(other, Window): + shift = self.crpix - other.crpix + return slice( + min(max(0, other.i_low - shift[0]), self.shape[0]), + max(0, min(other.i_high - shift[0], self.shape[0])), + ), slice( + min(max(0, other.j_low - shift[1]), self.shape[1]), + max(0, min(other.j_high - shift[1], self.shape[1])), + ) + origin_pix = torch.round(self.plane_to_pixel(other.pixel_to_plane(-0.5, -0.5)) + 0.5).int() new_origin_pix = torch.maximum(torch.zeros_like(origin_pix), origin_pix) @@ -390,15 +400,13 @@ def get_indices(self, other: "Image"): new_end_pix = torch.minimum(self.data.shape, end_pix) return slice(new_origin_pix[1], new_end_pix[1]), slice(new_origin_pix[0], new_end_pix[0]) - def get_window(self, other: "Image", _indices=None, **kwargs): + def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): """Get a new image object which is a window of this image corresponding to the other image's window. This will return a new image object with the same properties as this one, but with the data cropped to the other image's window. """ - if not isinstance(other, Image): - raise InvalidWindow("get_window only works with Image objects!") if _indices is None: indices = self.get_indices(other) else: @@ -445,7 +453,7 @@ def __isub__(self, other): return self def __getitem__(self, *args): - if len(args) == 1 and isinstance(args[0], Image): + if len(args) == 1 and isinstance(args[0], (Image, Window)): return self.get_window(args[0]) raise ValueError("Unrecognized Image getitem request!") diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 731541db..5b9f9960 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -448,57 +448,6 @@ def reduce(self, scale, **kwargs): **kwargs, ) - def get_state(self): - state = super().get_state() - - if self.has_weight: - state["weight"] = self.weight.detach().cpu().tolist() - if self.has_mask: - state["mask"] = self.mask.detach().cpu().tolist() - if self.has_psf: - state["psf"] = self.psf.get_state() - - return state - - def set_state(self, state): - super().set_state(state) - - self.weight = state.get("weight", None) - self.mask = state.get("mask", None) - if "psf" in state: - self.psf = PSF_Image(state=state["psf"]) - - def get_fits_state(self): - states = super().get_fits_state() - if self.has_weight: - states.append( - { - "DATA": self.weight.detach().cpu().numpy(), - "HEADER": {"IMAGE": "WEIGHT"}, - } - ) - if self.has_mask: - states.append( - { - "DATA": self.mask.detach().cpu().numpy().astype(int), - "HEADER": {"IMAGE": "MASK"}, - } - ) - if self.has_psf: - states += self.psf.get_fits_state() - - return states - - def set_fits_state(self, states): - super().set_fits_state(states) - for state in states: - if state["HEADER"]["IMAGE"] == "WEIGHT": - self.weight = np.array(state["DATA"], dtype=np.float64) - if state["HEADER"]["IMAGE"] == "mask": - self.mask = np.array(state["DATA"], dtype=bool) - if state["HEADER"]["IMAGE"] == "PSF": - self.psf = PSF_Image(fits_state=states) - class Target_Image_List(Image_List): def __init__(self, *args, **kwargs): diff --git a/astrophot/image/window.py b/astrophot/image/window.py index 8d8ac16a..b26c9ac6 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -41,7 +41,7 @@ def chunk(self, chunk_size: int): chunk_tot = int(np.ceil((px * py) / chunk_size)) # number of chunks on each axis cx = int(np.ceil(np.sqrt(chunk_tot * px / py))) - cy = int(np.ceil(chunk_size / cx)) + cy = int(np.ceil(chunk_tot / cx)) # number of pixels on each axis per chunk stepx = int(np.ceil(px / cx)) stepy = int(np.ceil(py / cy)) @@ -54,14 +54,6 @@ def chunk(self, chunk_size: int): windows.append(Window((i, i_high, j, j_high), self.crpix, self.image)) return windows - def get_indices(self, crpix: tuple[int, int] = None): - if crpix is None: - crpix = self.crpix - shift = crpix - self.crpix - return slice(self.i_low - shift[0], self.i_high - shift[0]), slice( - self.j_low - shift[1], self.j_high - shift[1] - ) - def pad(self, pad: int): self.i_low -= pad self.i_high += pad diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index 9d783375..d7ffa1ae 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -8,7 +8,7 @@ from ..image import Window, Target_Image_List from ..errors import UnrecognizedModel, InvalidWindow -__all__ = ("AstroPhot_Model",) +__all__ = ("Model",) def all_subclasses(cls): @@ -124,13 +124,14 @@ def __init__(self, *, name=None, target=None, window=None, **kwargs): # Set the model parameter setattr(self, kwarg, kwargs[kwarg]) + self.parameter_specs = self.build_parameter_specs(kwargs) + for key in self.parameter_specs: + setattr(self, key, Param(key, **self.parameter_specs[key])) + # If loading from a file, get model configuration then exit __init__ if "filename" in kwargs: self.load(kwargs["filename"], new_name=name) return - self.parameter_specs = self.build_parameter_specs(kwargs) - for key in self.parameter_specs: - setattr(self, key, Param(key, **self.parameter_specs[key])) @classproperty def model_type(cls): diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index b9e699ab..3ea7fa10 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -108,26 +108,29 @@ def fit_mask(self) -> torch.Tensor: reason to be fit. """ + subtarget = self.target[self.window] if isinstance(self.target, Image_List): - mask = tuple(torch.ones_like(submask) for submask in self.target[self.window].mask) + mask = tuple(torch.ones_like(submask) for submask in subtarget.mask) for model in self.models.values(): - model_flat_mask = model.fit_mask() + model_subtarget = model.target[model.window] + model_fit_mask = model.fit_mask() if isinstance(model.target, Image_List): - for target, window, submask in zip(model.target, model.window, model_flat_mask): - index = self.target.index(target) - group_indices = self.window.window_list[index].get_self_indices(window) - model_indices = window.get_self_indices(self.window.window_list[index]) + for target, submask in zip(model_subtarget, model_fit_mask): + index = subtarget.index(target) + group_indices = subtarget.images[index].get_indices(target) + model_indices = target.get_indices(subtarget.images[index]) mask[index][group_indices] &= submask[model_indices] else: - index = self.target.index(model.target) - group_indices = self.window.window_list[index].get_self_indices(model.window) - model_indices = model.window.get_self_indices(self.window.window_list[index]) - mask[index][group_indices] &= model_flat_mask[model_indices] + index = subtarget.index(model_subtarget) + group_indices = subtarget.images[index].get_indices(model_subtarget) + model_indices = model_subtarget.get_indices(subtarget.images[index]) + mask[index][group_indices] &= model_fit_mask[model_indices] else: - mask = torch.ones_like(self.target[self.window].mask) + mask = torch.ones_like(subtarget.mask) for model in self.models.values(): - group_indices = self.window.get_self_indices(model.window) - model_indices = model.window.get_self_indices(self.window) + model_subtarget = model.target[model.window] + group_indices = subtarget.get_indices(model_subtarget) + model_indices = model_subtarget.get_indices(subtarget) mask[group_indices] &= model.fit_mask()[model_indices] return mask diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 88c452ee..659222e9 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -57,6 +57,14 @@ def sample_image(self, image: Image): f"Unknown sampling mode {self.sampling_mode} for model {self.name}" ) + def build_params_array_identities(self): + identities = [] + for param in self.dynamic_params: + numel = max(1, np.prod(param.shape)) + for i in range(numel): + identities.append(f"{id(param)}_{i}") + return identities + def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor): return jacobian( lambda x: self.sample( @@ -72,17 +80,25 @@ def jacobian( self, window: Optional[Window] = None, pass_jacobian: Optional[Jacobian_Image] = None, + params: Optional[Tensor] = None, ): if window is None: window = self.window + if params is not None: + self.fill_dynamic_params(params) + if pass_jacobian is None: jac_img = self.target[window].jacobian_image( - parameters=self.parameters.vector_identities() + parameters=self.build_params_array_identities() ) else: jac_img = pass_jacobian + # No dynamic params + if len(self.build_params_list()) == 0: + return jac_img + # handle large images n_pixels = np.prod(window.shape) if n_pixels > self.jacobian_maxpixels: @@ -90,9 +106,9 @@ def jacobian( self.jacobian(window=chunk, pass_jacobian=jac_img) return jac_img - # handle large number of parameters params = self.build_params_array() - if len(params) > self.jacobian_maxparams: + identities = self.build_params_array_identities() + if len(params) > self.jacobian_maxparams: # handle large number of parameters chunksize = len(params) // self.jacobian_maxparams + 1 for i in range(chunksize, len(params), chunksize): params_pre = params[:i] @@ -100,37 +116,37 @@ def jacobian( params_chunk = params[i : i + chunksize] jac_chunk = self._jacobian(window, params_pre, params_chunk, params_post) jac_img += self.target[window].jacobian_image( - parameters=self.parameters.vector_identities(), + parameters=identities[i : i + chunksize], data=jac_chunk, ) else: jac = self._jacobian(window, params[:0], params, params[0:0]) - jac_img += self.target[window].jacobian_image( - parameters=self.parameters.vector_identities(), - data=jac, - ) + jac_img += self.target[window].jacobian_image(parameters=identities, data=jac) return jac_img def gradient( self, window: Optional[Window] = None, + params: Optional[Tensor] = None, likelihood: Literal["gaussian", "poisson"] = "gaussian", ): """Compute the gradient of the model with respect to its parameters.""" if window is None: window = self.window - jacobian_image = self.jacobian(window=window) + jacobian_image = self.jacobian(window=window, params=params) data = self.target[window].data.value model = self.sample(window=window).data.value if likelihood == "gaussian": weight = self.target[window].weight - gradient = torch.sum(jacobian_image.data.value * (data - model) * weight, dim=(0, 1)) + gradient = torch.sum( + jacobian_image.data.value * ((data - model) * weight).unsqueeze(-1), dim=(0, 1) + ) elif likelihood == "poisson": gradient = torch.sum( - jacobian_image.data.value * (1 - data / model), + jacobian_image.data.value * (1 - data / model).unsqueeze(-1), dim=(0, 1), ) diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index f3b21476..f2df4b83 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -168,6 +168,9 @@ def initialize( self.center.value = COM_center + def fit_mask(self): + return torch.zeros_like(self.target[self.window].mask, dtype=torch.bool) + # Fit loop functions ###################################################################### def shift_kernel(self, shift): From 870af0fd4d7a23676f9dd92c61f43fd872e359a2 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 16 Jun 2025 09:38:13 -0400 Subject: [PATCH 020/191] starting on LM --- astrophot/fit/func/lm.py | 55 ++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 42093539..b3793c18 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -10,43 +10,66 @@ def gradient(J, W, R): return -J.T @ (W * R) -def step(L, grad, hess): - I = torch.eye(len(grad), dtype=grad.dtype, device=grad.device) +def damp_hessian(hess, L): + I = torch.eye(len(hess), dtype=hess.dtype, device=hess.device) D = torch.ones_like(hess) - I - - h = torch.linalg.solve( - hess * (I + D / (1 + L)) + L * I * (1 + torch.diag(hess)), - grad, - ) - - return h + return hess * (I + D / (1 + L)) + L * I * (1 + torch.diag(hess)) def step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): + chi20 = chi2 M0 = model(x) J = jacobian(x) R = data - M0 grad = gradient(J, weight, R) hess = hessian(J, weight) - best = {"h": torch.zeros_like(x), "chi2": chi2, "L": L} - scary = {"h": None, "chi2": chi2, "L": L} + best = {"h": torch.zeros_like(x), "chi2": chi20, "L": L} + scary = {"h": None, "chi2": chi20, "L": L} + nostep = True improving = None for i in range(10): - h = step(L, grad, hess) + hessD = damp_hessian(hess, L) + h = torch.linalg.solve(hessD, grad) M1 = model(x + h) - chi2 = torch.sum(weight * (data - M1) ** 2).item() / ndf + chi21 = torch.sum(weight * (data - M1) ** 2).item() / ndf # Handle nan chi2 - if not np.isfinite(chi2): + if not np.isfinite(chi21): + L *= Lup + if improving is True: + break + improving = False + continue + + if chi21 < scary["chi2"]: + scary = {"h": h, "chi2": chi21, "L": L} + + rho = (chi20 - chi21) / torch.abs(h.T @ hessD @ h - 2 * grad @ h) + + # Larger higher order terms + if rho < 0.1: L *= Lup if improving is True: break improving = False continue - if chi2 < scary["chi2"]: - scary = {"h": h, "chi2": chi2, "L": L} + if chi21 < best["chi2"]: # new best + best = {"h": h, "chi2": chi21, "L": L} + improving = True + nostep = False + L /= Ldn + if L < 1e-8 or improving is False: + break + improving = True + elif improving is True: + break + else: # not improving and bad chi2, damp more + L *= Lup + if L >= 1e9: + break + improving = False From 59d759f6f1bb14eada01e87b168be1f4d1ac0505 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 18 Jun 2025 11:12:31 -0400 Subject: [PATCH 021/191] basic sample now online for sersic --- astrophot/__init__.py | 2 +- astrophot/fit/__init__.py | 22 +- astrophot/fit/func/lm.py | 23 +- astrophot/image/func/__init__.py | 8 +- astrophot/image/func/image.py | 29 ++ astrophot/image/func/wcs.py | 4 +- astrophot/image/image_object.py | 90 +++- astrophot/image/model_image.py | 1 - astrophot/image/target_image.py | 17 +- astrophot/models/__init__.py | 48 +- astrophot/models/_model_methods.py | 419 ---------------- astrophot/models/_shared_methods.py | 530 ++++++--------------- astrophot/models/core_model.py | 76 ++- astrophot/models/func/__init__.py | 18 +- astrophot/models/func/convolution.py | 16 + astrophot/models/func/gaussian.py | 14 + astrophot/models/func/integration.py | 121 ++--- astrophot/models/func/moffat.py | 11 + astrophot/models/func/nuker.py | 18 + astrophot/models/func/sersic.py | 12 +- astrophot/models/func/spline.py | 63 +++ astrophot/models/galaxy_model_object.py | 24 +- astrophot/models/mixins/__init__.py | 5 +- astrophot/models/mixins/brightness.py | 39 +- astrophot/models/mixins/exponential.py | 2 +- astrophot/models/mixins/moffat.py | 62 +++ astrophot/models/mixins/sample.py | 52 +- astrophot/models/mixins/sersic.py | 25 +- astrophot/models/mixins/transform.py | 34 ++ astrophot/models/model_object.py | 36 +- astrophot/models/moffat_model.py | 163 +------ astrophot/models/relspline_model.py | 78 --- astrophot/models/sersic_model.py | 288 +++++------ astrophot/param/__init__.py | 5 + astrophot/param/module.py | 12 + astrophot/param/param.py | 24 + astrophot/plots/image.py | 33 +- astrophot/plots/profile.py | 55 +-- astrophot/plots/shared_elements.py | 111 ----- astrophot/utils/integration.py | 33 ++ docs/source/tutorials/GettingStarted.ipynb | 20 +- 41 files changed, 1023 insertions(+), 1620 deletions(-) delete mode 100644 astrophot/models/_model_methods.py create mode 100644 astrophot/models/func/gaussian.py create mode 100644 astrophot/models/func/moffat.py create mode 100644 astrophot/models/func/nuker.py create mode 100644 astrophot/models/func/spline.py create mode 100644 astrophot/models/mixins/moffat.py create mode 100644 astrophot/models/mixins/transform.py delete mode 100644 astrophot/models/relspline_model.py create mode 100644 astrophot/param/__init__.py create mode 100644 astrophot/param/module.py create mode 100644 astrophot/param/param.py delete mode 100644 astrophot/plots/shared_elements.py diff --git a/astrophot/__init__.py b/astrophot/__init__.py index 9a0ad067..43d99468 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -1,7 +1,7 @@ import argparse import requests import torch -from . import models, image, plots, utils, fit, param, AP_config +from . import models, image, plots, utils, fit, AP_config try: from ._version import version as VERSION # noqa diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 9d4027c9..fe88b755 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,15 +1,15 @@ -from .base import * -from .lm import * -from .gradient import * -from .iterative import * -from .minifit import * +# from .base import * +# from .lm import * +# from .gradient import * +# from .iterative import * +# from .minifit import * -try: - from .hmc import * - from .nuts import * -except AssertionError as e: - print("Could not load HMC or NUTS due to:", str(e)) -from .mhmcmc import * +# try: +# from .hmc import * +# from .nuts import * +# except AssertionError as e: +# print("Could not load HMC or NUTS due to:", str(e)) +# from .mhmcmc import * """ base: This module defines the base class BaseOptimizer, diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index b3793c18..7b78f4f2 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -1,6 +1,8 @@ import torch import numpy as np +from ...errors import OptimizeStop + def hessian(J, W): return J.T @ (W * J) @@ -30,7 +32,7 @@ def step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): nostep = True improving = None - for i in range(10): + for _ in range(10): hessD = damp_hessian(hess, L) h = torch.linalg.solve(hessD, grad) M1 = model(x + h) @@ -48,10 +50,11 @@ def step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): if chi21 < scary["chi2"]: scary = {"h": h, "chi2": chi21, "L": L} - rho = (chi20 - chi21) / torch.abs(h.T @ hessD @ h - 2 * grad @ h) + # actual chi2 improvement vs expected from linearization + rho = (chi20 - chi21) / torch.abs(h.T @ hessD @ h - 2 * grad @ h).item() - # Larger higher order terms - if rho < 0.1: + # Avoid highly non-linear regions + if rho < 0.1 or rho > 10: L *= Lup if improving is True: break @@ -60,7 +63,6 @@ def step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): if chi21 < best["chi2"]: # new best best = {"h": h, "chi2": chi21, "L": L} - improving = True nostep = False L /= Ldn if L < 1e-8 or improving is False: @@ -73,3 +75,14 @@ def step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): if L >= 1e9: break improving = False + + if (best["chi2"] - chi20) / chi20 < -0.1: + # If we are improving chi2 by more than 10% then we can stop + break + + if nostep: + if scary["h"] is not None: + return scary + raise OptimizeStop("Could not find step to improve chi^2") + + return best diff --git a/astrophot/image/func/__init__.py b/astrophot/image/func/__init__.py index 51b4d8fb..f346ed70 100644 --- a/astrophot/image/func/__init__.py +++ b/astrophot/image/func/__init__.py @@ -1,4 +1,9 @@ -from .image import pixel_center_meshgrid, pixel_corner_meshgrid, pixel_simpsons_meshgrid +from .image import ( + pixel_center_meshgrid, + pixel_corner_meshgrid, + pixel_simpsons_meshgrid, + pixel_quad_meshgrid, +) from .wcs import ( world_to_plane_gnomonic, plane_to_world_gnomonic, @@ -11,6 +16,7 @@ "pixel_center_meshgrid", "pixel_corner_meshgrid", "pixel_simpsons_meshgrid", + "pixel_quad_meshgrid", "world_to_plane_gnomonic", "plane_to_world_gnomonic", "pixel_to_plane_linear", diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index e69de29b..c67ddbcd 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -0,0 +1,29 @@ +import torch + +from ...utils.integration import quad_table + + +def pixel_center_meshgrid(shape, dtype, device): + i = torch.arange(shape[0], dtype=dtype, device=device) + j = torch.arange(shape[1], dtype=dtype, device=device) + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_corner_meshgrid(shape, dtype, device): + i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_simpsons_meshgrid(shape, dtype, device): + i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 + return torch.meshgrid(i, j, indexing="xy") + + +def pixel_quad_meshgrid(shape, dtype, device, order=3): + i, j = pixel_center_meshgrid(shape, dtype, device) + di, dj, w = quad_table(order, dtype, device) + i = torch.repeat_interleave(i[..., None], order**2, -1) + di.flatten() + j = torch.repeat_interleave(j[..., None], order**2, -1) + dj.flatten() + return i, j, w.flatten() diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 7caf1683..143a8250 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -113,7 +113,7 @@ def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): Tuple containing the x and y tangent plane coordinates in arcsec. """ uv = torch.stack((i.reshape(-1) - i0, j.reshape(-1) - j0), dim=1) - xy = CD.T @ uv + xy = (CD @ uv.T).T return xy[:, 0].reshape(i.shape) + x0, xy[:, 1].reshape(j.shape) + y0 @@ -210,6 +210,6 @@ def plane_to_pixel_linear(x, y, i0, j0, iCD, x0=0.0, y0=0.0): Tuple containing the i and j pixel coordinates in pixel units. """ xy = torch.stack((x.reshape(-1) - x0, y.reshape(-1) - y0), dim=1) - uv = iCD.T @ xy + uv = (iCD @ xy.T).T return uv[:, 0].reshape(x.shape) + i0, uv[:, 1].reshape(y.shape) + j0 diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index a979b201..80460a5c 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -3,8 +3,8 @@ import torch import numpy as np from astropy.wcs import WCS as AstropyWCS -from caskade import Module, Param, forward +from ..param import Module, Param, forward from .. import AP_config from ..utils.conversions.units import deg_to_arcsec from .window import Window @@ -30,7 +30,7 @@ class Image(Module): origin: The origin of the image in the coordinate system. """ - default_crpix = (0.0, 0.0) + default_crpix = (0, 0) default_crtan = (0.0, 0.0) default_crval = (0.0, 0.0) default_pixelscale = ((1.0, 0.0), (0.0, 1.0)) @@ -109,14 +109,7 @@ def __init__( self.crval = Param("crval", kwargs.get("crval", self.default_crval), units="deg") self.crtan = Param("crtan", kwargs.get("crtan", self.default_crtan), units="arcsec") self.crpix = np.asarray( - kwargs.get( - "crpix", - ( - self.default_crpix - if self.data.value is None - else (self.data.shape[1] // 2, self.data.shape[0] // 2) - ), - ), + kwargs.get("crpix", self.default_crpix), dtype=int, ) @@ -145,7 +138,10 @@ def window(self): @property def center(self): - return self.pixel_to_plane(*(self.data.shape // 2)) + shape = torch.as_tensor( + self.data.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + return self.pixel_to_plane(*((shape - 1) / 2)) @property def shape(self): @@ -200,8 +196,8 @@ def pixelscale_inv(self): return self._pixelscale_inv @forward - def pixel_to_plane(self, i, j, crtan, pixelscale): - return func.pixel_to_plane_linear(i, j, *self.crpix, pixelscale, *crtan) + def pixel_to_plane(self, i, j, crtan): + return func.pixel_to_plane_linear(i, j, *self.crpix, self.pixelscale, *crtan) @forward def plane_to_pixel(self, x, y, crtan): @@ -237,40 +233,82 @@ def pixel_to_world(self, i, j=None): i, j = i[0], i[1] return self.plane_to_world(*self.pixel_to_plane(i, j)) + def pixel_center_meshgrid(self): + """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" + return func.pixel_center_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + + def pixel_corner_meshgrid(self): + """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" + return func.pixel_corner_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + + def pixel_simpsons_meshgrid(self): + """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" + return func.pixel_simpsons_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + + def pixel_quad_meshgrid(self, order=3): + """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" + return func.pixel_quad_meshgrid( + self.shape, AP_config.ap_dtype, AP_config.ap_device, order=order + ) + + @forward + def coordinate_center_meshgrid(self): + """Get a meshgrid of coordinate locations in the image, centered on the pixel grid.""" + i, j = self.pixel_center_meshgrid() + return self.pixel_to_plane(i, j) + + @forward + def coordinate_corner_meshgrid(self): + """Get a meshgrid of coordinate locations in the image, with corners at the pixel grid.""" + i, j = self.pixel_corner_meshgrid() + return self.pixel_to_plane(i, j) + + @forward + def coordinate_simpsons_meshgrid(self): + """Get a meshgrid of coordinate locations in the image, with Simpson's rule sampling.""" + i, j = self.pixel_simpsons_meshgrid() + return self.pixel_to_plane(i, j) + + @forward + def coordinate_quad_meshgrid(self, order=3): + """Get a meshgrid of coordinate locations in the image, with quadrature sampling.""" + i, j, _ = self.pixel_quad_meshgrid(order=order) + return self.pixel_to_plane(i, j) + def copy(self, **kwargs): """Produce a copy of this image with all of the same properties. This can be used when one wishes to make temporary modifications to an image and then will want the original again. """ - copy_kwargs = { + kwargs = { "data": torch.clone(self.data.value), - "pixelscale": self.pixelscale.value, + "pixelscale": self.pixelscale, "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, "identity": self.identity, + **kwargs, } - copy_kwargs.update(kwargs) - return self.__class__(**copy_kwargs) + return self.__class__(**kwargs) def blank_copy(self, **kwargs): """Produces a blank copy of the image which has the same properties except that its data is now filled with zeros. """ - copy_kwargs = { + kwargs = { "data": torch.zeros_like(self.data.value), - "pixelscale": self.pixelscale.value, + "pixelscale": self.pixelscale, "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, "identity": self.identity, + **kwargs, } - copy_kwargs.update(kwargs) - return self.__class__(**copy_kwargs) + return self.__class__(**kwargs) def to(self, dtype=None, device=None): if dtype is None: @@ -348,7 +386,7 @@ def reduce(self, scale: int, **kwargs): .reshape(MS, scale, NS, scale) .sum(axis=(1, 3)) ) - pixelscale = self.pixelscale.value * scale + pixelscale = self.pixelscale * scale crpix = (self.crpix + 0.5) / scale - 0.5 return self.copy( data=data, @@ -455,7 +493,7 @@ def __isub__(self, other): def __getitem__(self, *args): if len(args) == 1 and isinstance(args[0], (Image, Window)): return self.get_window(args[0]) - raise ValueError("Unrecognized Image getitem request!") + return super().__getitem__(*args) class Image_List(Module): @@ -468,7 +506,7 @@ def __init__(self, images): @property def pixelscale(self): - return tuple(image.pixelscale.value for image in self.images) + return tuple(image.pixelscale for image in self.images) @property def zeropoint(self): @@ -476,7 +514,7 @@ def zeropoint(self): @property def data(self): - return tuple(image.data for image in self.images) + return tuple(image.data.value for image in self.images) @data.setter def data(self, data): @@ -584,7 +622,7 @@ def __getitem__(self, *args): self_image = self.images[i] new_list.append(self_image.get_window(other_image)) return self.__class__(new_list) - raise ValueError("Unrecognized Image_List getitem request!") + super().__getitem__(*args) def __iter__(self): return (img for img in self.images) diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 8bf584e8..f57f28ee 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -2,7 +2,6 @@ from .. import AP_config from .image_object import Image, Image_List -from ..utils.interpolate import shift_Lanczos_torch from ..errors import InvalidImage __all__ = ["Model_Image", "Model_Image_List"] diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 5b9f9960..e8a46e36 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -1,7 +1,6 @@ from typing import List, Optional import torch -import numpy as np from .image_object import Image, Image_List from .jacobian_image import Jacobian_Image, Jacobian_Image_List @@ -281,13 +280,13 @@ def set_psf(self, psf): """ if hasattr(self, "psf"): del self.psf # remove old psf if it exists - from ..models import AstroPhot_Model + from ..models import Model if psf is None: self.psf = None elif isinstance(psf, PSF_Image): self.psf = psf - elif isinstance(psf, AstroPhot_Model): + elif isinstance(psf, Model): self.psf = PSF_Image( data=lambda p: p.psf_model(), pixelscale=psf.target.pixelscale, @@ -338,24 +337,22 @@ def copy(self, **kwargs): an image and then will want the original again. """ - return super().copy( - mask=self._mask, - psf=self.psf, - weight=self._weight, - **kwargs, - ) + kwargs = {"mask": self._mask, "psf": self.psf, "weight": self._weight, **kwargs} + return super().copy(**kwargs) def blank_copy(self, **kwargs): """Produces a blank copy of the image which has the same properties except that its data is now filled with zeros. """ - return super().blank_copy(mask=self._mask, psf=self.psf, weight=self._weight, **kwargs) + kwargs = {"mask": self._mask, "psf": self.psf, "weight": self._weight, **kwargs} + return super().blank_copy(**kwargs) def get_window(self, other, **kwargs): """Get a sub-region of the image as defined by an other image on the sky.""" indices = self.get_indices(other) return super().get_window( + other, weight=self._weight[indices] if self.has_weight else None, mask=self._mask[indices] if self.has_mask else None, psf=self.psf, diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 81edb2c8..f3ecbd3d 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -1,28 +1,28 @@ from .core_model import * from .model_object import * from .galaxy_model_object import * -from .ray_model import * from .sersic_model import * -from .group_model_object import * -from .sky_model_object import * -from .flatsky_model import * -from .planesky_model import * -from .gaussian_model import * -from .multi_gaussian_expansion_model import * -from .spline_model import * -from .relspline_model import * -from .psf_model_object import * -from .pixelated_psf_model import * -from .eigen_psf import * -from .superellipse_model import * -from .edgeon_model import * -from .exponential_model import * -from .foureirellipse_model import * -from .wedge_model import * -from .warp_model import * -from .moffat_model import * -from .nuker_model import * -from .zernike_model import * -from .airy_psf import * -from .point_source import * -from .group_psf_model import * + +# from .group_model_object import * +# from .ray_model import * +# from .sky_model_object import * +# from .flatsky_model import * +# from .planesky_model import * +# from .gaussian_model import * +# from .multi_gaussian_expansion_model import * +# from .spline_model import * +# from .psf_model_object import * +# from .pixelated_psf_model import * +# from .eigen_psf import * +# from .superellipse_model import * +# from .edgeon_model import * +# from .exponential_model import * +# from .foureirellipse_model import * +# from .wedge_model import * +# from .warp_model import * +# from .moffat_model import * +# from .nuker_model import * +# from .zernike_model import * +# from .airy_psf import * +# from .point_source import * +# from .group_psf_model import * diff --git a/astrophot/models/_model_methods.py b/astrophot/models/_model_methods.py deleted file mode 100644 index 0571c5cb..00000000 --- a/astrophot/models/_model_methods.py +++ /dev/null @@ -1,419 +0,0 @@ -from typing import Optional, Union -import io - -import numpy as np -import torch -from torch.autograd.functional import jacobian as torchjac - -from ..utils.interpolate import ( - _shift_Lanczos_kernel_torch, - simpsons_kernel, - curvature_kernel, - interp2d, -) -from ..image import ( - Window, - Jacobian_Image, - Window_List, - PSF_Image, -) -from ..utils.operations import ( - fft_convolve_torch, - grid_integrate, - single_quad_integrate, -) -from ..errors import SpecificationConflict -from .. import AP_config - - -def _sample_init(self, image, center): - if self.sampling_mode == "midpoint": - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - center[..., None, None] - mid = self.evaluate_model(X=X, Y=Y, image=image) - kernel = curvature_kernel(AP_config.ap_dtype, AP_config.ap_device) - # convolve curvature kernel to numericall compute second derivative - curvature = torch.nn.functional.pad( - torch.nn.functional.conv2d( - mid.view(1, 1, *mid.shape), - kernel.view(1, 1, *kernel.shape), - padding="valid", - ), - (1, 1, 1, 1), - mode="replicate", - ).squeeze() - return mid + curvature, mid - elif self.sampling_mode == "simpsons": - Coords = image.get_coordinate_simps_meshgrid() - X, Y = Coords - center[..., None, None] - dens = self.evaluate_model(X=X, Y=Y, image=image) - kernel = simpsons_kernel(dtype=AP_config.ap_dtype, device=AP_config.ap_device) - # midpoint is just every other sample in the simpsons grid - mid = dens[1::2, 1::2] - simps = torch.nn.functional.conv2d( - dens.view(1, 1, *dens.shape), kernel, stride=2, padding="valid" - ) - return mid.squeeze(), simps.squeeze() - elif "quad" in self.sampling_mode: - quad_level = int(self.sampling_mode[self.sampling_mode.find(":") + 1 :]) - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - center[..., None, None] - res, ref = single_quad_integrate( - X=X, - Y=Y, - image_header=image.header, - eval_brightness=self.evaluate_model, - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - quad_level=quad_level, - ) - return ref, res - elif self.sampling_mode == "trapezoid": - Coords = image.get_coordinate_corner_meshgrid() - X, Y = Coords - center[..., None, None] - dens = self.evaluate_model(X=X, Y=Y, image=image) - kernel = ( - torch.ones((1, 1, 2, 2), dtype=AP_config.ap_dtype, device=AP_config.ap_device) / 4.0 - ) - trapz = torch.nn.functional.conv2d(dens.view(1, 1, *dens.shape), kernel, padding="valid") - trapz = trapz.squeeze() - kernel = curvature_kernel(AP_config.ap_dtype, AP_config.ap_device) - curvature = torch.nn.functional.pad( - torch.nn.functional.conv2d( - trapz.view(1, 1, *trapz.shape), - kernel.view(1, 1, *kernel.shape), - padding="valid", - ), - (1, 1, 1, 1), - mode="replicate", - ).squeeze() - return trapz + curvature, trapz - - raise SpecificationConflict( - f"{self.name} has unknown sampling mode: {self.sampling_mode}. Should be one of: midpoint, simpsons, quad:level, trapezoid" - ) - - -def _integrate_reference(self, image_data, image_header): - return torch.sum(image_data) / image_data.numel() - - -def _sample_integrate(self, deep, reference, image, center): - if self.integrate_mode == "none": - pass - elif self.integrate_mode == "threshold": - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - center[..., None, None] - ref = self._integrate_reference(deep, image.header) - error = torch.abs((deep - reference)) - select = error > (self.sampling_tolerance * ref) - intdeep = grid_integrate( - X=X[select], - Y=Y[select], - image_header=image.header, - eval_brightness=self.evaluate_model, - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - quad_level=self.integrate_quad_level, - gridding=self.integrate_gridding, - max_depth=self.integrate_max_depth, - reference=self.sampling_tolerance * ref, - ) - deep[select] = intdeep - else: - raise SpecificationConflict( - f"{self.name} has unknown integration mode: {self.integrate_mode}. Should be one of: none, threshold" - ) - return deep - - -def _shift_psf(self, psf, shift, shift_method="bilinear", keep_pad=True): - if shift_method == "bilinear": - psf_data = torch.nn.functional.pad(psf.data, (1, 1, 1, 1)) - X, Y = torch.meshgrid( - torch.arange( - psf_data.shape[1], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - shift[0], - torch.arange( - psf_data.shape[0], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - shift[1], - indexing="xy", - ) - shift_psf = interp2d(psf_data, X.clone(), Y.clone()) - if not keep_pad: - shift_psf = shift_psf[1:-1, 1:-1] - - elif "lanczos" in shift_method: - lanczos_order = int(shift_method[shift_method.find(":") + 1 :]) - psf_data = torch.nn.functional.pad( - psf.data, (lanczos_order, lanczos_order, lanczos_order, lanczos_order) - ) - LL = _shift_Lanczos_kernel_torch( - -shift[0], - -shift[1], - lanczos_order, - AP_config.ap_dtype, - AP_config.ap_device, - ) - shift_psf = torch.nn.functional.conv2d( - psf_data.view(1, 1, *psf_data.shape), - LL.view(1, 1, *LL.shape), - padding="same", - ).squeeze() - if not keep_pad: - shift_psf = shift_psf[lanczos_order:-lanczos_order, lanczos_order:-lanczos_order] - else: - raise SpecificationConflict(f"unrecognized subpixel shift method: {shift_method}") - return shift_psf - - -def _sample_convolve(self, image, shift, psf, shift_method="bilinear"): - """ - image: Image object with image.data pixel matrix - shift: the amount of shifting to do in pixel units - psf: a PSF_Image object - """ - if shift is not None: - shift_psf = self._shift_psf(psf, shift, shift_method) - else: - shift_psf = psf.data - shift_psf = shift_psf / torch.sum(shift_psf) - - if self.psf_convolve_mode == "fft": - image.data = fft_convolve_torch(image.data, shift_psf, img_prepadded=True) - elif self.psf_convolve_mode == "direct": - image.data = torch.nn.functional.conv2d( - image.data.view(1, 1, *image.data.shape), - torch.flip( - shift_psf.view(1, 1, *shift_psf.shape), - dims=(2, 3), - ), - padding="same", - ).squeeze() - else: - raise ValueError(f"unrecognized psf_convolve_mode: {self.psf_convolve_mode}") - - -@torch.no_grad() -def jacobian( - self, - as_representation: bool = False, - window: Optional[Window] = None, - pass_jacobian: Optional[Jacobian_Image] = None, - **kwargs, -): - """Compute the Jacobian matrix for this model. - - The Jacobian matrix represents the partial derivatives of the - model's output with respect to its input parameters. It is useful - in optimization and model fitting processes. This method - simplifies the process of computing the Jacobian matrix for - astronomical image models and is primarily used by the - Levenberg-Marquardt algorithm for model fitting tasks. - - Args: - parameters (Optional[torch.Tensor]): A 1D parameter tensor to override the - current model's parameters. - as_representation (bool): Indicates if the parameters argument is - provided as real values or representations - in the (-inf, inf) range. Default is False. - parameters_identity (Optional[tuple]): Specifies which parameters are to be - considered in the computation. - window (Optional[Window]): A window object specifying the region of interest - in the image. - **kwargs: Additional keyword arguments. - - Returns: - Jacobian_Image: A Jacobian_Image object containing the computed Jacobian matrix. - - """ - if window is None: - window = self.window - else: - if isinstance(window, Window_List): - window = window.window_list[pass_jacobian.index(self.target)] - window = self.window & window - - # skip jacobian calculation if no parameters match criteria - if torch.sum(self.parameters.vector_mask()) == 0 or window.overlap_frac(self.window) <= 0: - return self.target[window].jacobian_image() - - # Set the parameters if provided and check the size of the parameter list - if torch.sum(self.parameters.vector_mask()) > self.jacobian_chunksize: - return self._chunk_jacobian( - as_representation=as_representation, - window=window, - **kwargs, - ) - if torch.max(window.pixel_shape) > self.image_chunksize: - return self._chunk_image_jacobian( - as_representation=as_representation, - window=window, - **kwargs, - ) - - # Compute the jacobian - full_jac = torchjac( - lambda P: self( - image=None, - parameters=P, - as_representation=as_representation, - window=window, - ).data, - ( - self.parameters.vector_representation().detach() # need valid context - if as_representation - else self.parameters.vector_values().detach() - ), - strategy="forward-mode", - vectorize=True, - create_graph=False, - ) - - # Store the jacobian as a Jacobian_Image object - jac_img = self.target[window].jacobian_image( - parameters=self.parameters.vector_identities(), - data=full_jac, - ) - return jac_img - - -@torch.no_grad() -def _chunk_image_jacobian( - self, - as_representation: bool = False, - parameters_identity: Optional[tuple] = None, - window: Optional[Window] = None, - **kwargs, -): - """Evaluates the Jacobian in smaller chunks to reduce memory usage. - - For models acting on large windows it can be prohibitive to build - the full Jacobian in a single pass. Instead this function breaks - the image into chunks as determined by `self.image_chunksize` - evaluates the Jacobian only for the sub-images, it then builds up - the full Jacobian as a separate tensor. - - This is for internal use and should be called by the - `self.jacobian` function when appropriate. - - """ - - pids = self.parameters.vector_identities() - jac_img = self.target[window].jacobian_image( - parameters=pids, - ) - - pixel_shape = window.pixel_shape.detach().cpu().numpy() - Ncells = np.int64(np.round(np.ceil(pixel_shape / self.image_chunksize))) - cellsize = np.int64(np.round(window.pixel_shape / Ncells)) - - for nx in range(Ncells[0]): - for ny in range(Ncells[1]): - subwindow = window.copy() - subwindow.crop_to_pixel( - ( - (cellsize[0] * nx, min(pixel_shape[0], cellsize[0] * (nx + 1))), - (cellsize[1] * ny, min(pixel_shape[1], cellsize[1] * (ny + 1))), - ) - ) - jac_img += self.jacobian( - parameters=None, - as_representation=as_representation, - window=subwindow, - **kwargs, - ) - - return jac_img - - -@torch.no_grad() -def _chunk_jacobian( - self, - as_representation: bool = False, - parameters_identity: Optional[tuple] = None, - window: Optional[Window] = None, - **kwargs, -): - """Evaluates the Jacobian in small chunks to reduce memory usage. - - For models with many parameters it can be prohibitive to build the - full Jacobian in a single pass. Instead this function breaks the - list of parameters into chunks as determined by - `self.jacobian_chunksize` evaluates the Jacobian only for those, - it then builds up the full Jacobian as a separate tensor. This is - for internal use and should be called by the `self.jacobian` - function when appropriate. - - """ - pids = self.parameters.vector_identities() - jac_img = self.target[window].jacobian_image( - parameters=pids, - ) - - for ichunk in range(0, len(pids), self.jacobian_chunksize): - mask = torch.zeros(len(pids), dtype=torch.bool, device=AP_config.ap_device) - mask[ichunk : ichunk + self.jacobian_chunksize] = True - with Param_Mask(self.parameters, mask): - jac_img += self.jacobian( - parameters=None, - as_representation=as_representation, - window=window, - **kwargs, - ) - - return jac_img - - -def load(self, filename: Union[str, dict, io.TextIOBase] = "AstroPhot.yaml", new_name=None): - """Used to load the model from a saved state. - - Sets the model window to the saved value and updates all - parameters with the saved information. This overrides the - current parameter settings. - - Args: - filename: The source from which to load the model parameters. Can be a string (the name of the file on disc), a dictionary (formatted as if from self.get_state), or an io.TextIOBase (a file stream to load the file from). - - """ - state = AstroPhot_Model.load(filename) - if new_name is None: - new_name = state["name"] - self.name = new_name - # Use window saved state to initialize model window - self.window = Window(**state["window"]) - # reassign target in case a target list was given - self._target_identity = state["target_identity"] - self.target = self.target - # Set any attributes which were not default - for key in self.track_attrs: - if key in state: - setattr(self, key, state[key]) - # Load the parameter group, this is handled by the parameter group object - if isinstance(state["parameters"], Parameter_Node): - self.parameters = state["parameters"] - else: - self.parameters = Parameter_Node(self.name, state=state["parameters"]) - # Move parameters to the appropriate device and dtype - self.parameters.to(dtype=AP_config.ap_dtype, device=AP_config.ap_device) - # Re-create the aux PSF model if there was one - if "psf" in state: - if state["psf"].get("type", "AstroPhot_Model") == "PSF_Image": - self.psf = PSF_Image(state=state["psf"]) - else: - print(state["psf"]) - state["psf"]["parameters"] = self.parameters[state["psf"]["name"]] - self.set_aux_psf( - AstroPhot_Model( - name=state["psf"]["name"], - filename=state["psf"], - target=self.target, - ) - ) - return state diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index d0b6d254..3a1ec9ef 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -1,41 +1,27 @@ -import functools - from scipy.stats import binned_statistic, iqr import numpy as np import torch from scipy.optimize import minimize -from caskade import forward from ..utils.initialize import isophotes -from ..utils.parametric_profiles import ( - sersic_torch, - gaussian_torch, - exponential_torch, - spline_torch, - moffat_torch, - nuker_torch, -) -from ..utils.conversions.coordinates import ( - Rotate_Cartesian, -) from ..utils.decorators import ignore_numpy_warnings, default_internal +from . import func from .. import AP_config -def _sample_image(image, transform, metric, center, rad_bins=None): - dat = image.data.detach().cpu().clone().numpy() +def _sample_image(image, transform): + dat = image.data.npvalue.copy() # Fill masked pixels if image.has_mask: mask = image.mask.detach().cpu().numpy() - dat[mask] = np.median(dat[np.logical_not(mask)]) + dat[mask] = np.median(dat[~mask]) # Subtract median of edge pixels to avoid effect of nearby sources edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) dat -= np.median(edge) # Get the radius of each pixel relative to object center - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - center[..., None, None] - X, Y = transform(X, Y, image) - R = metric(X, Y, image).detach().cpu().numpy().flatten() + x, y = transform(*image.coordinate_center_meshgrid()) + + R = torch.sqrt(x**2 + y**2).detach().cpu().numpy() # Bin fluxes by radius if rad_bins is None: @@ -51,21 +37,24 @@ def _sample_image(image, transform, metric, center, rad_bins=None): R = (rad_bins[:-1] + rad_bins[1:]) / 2 # Ensure enough values are positive - I[I <= 0] = np.min(I[np.logical_and(np.isfinite(I), I > 0)]) + I[~np.isfinite(I)] = np.median(I[np.isfinite(I)]) + if np.sum(I > 0) <= 3: + I = I - np.min(I) + I[I <= 0] = np.min(I[I > 0]) # Ensure decreasing brightness with radius in outer regions for i in range(5, len(I)): - if I[i] >= I[i - 1] and np.isfinite(I[i - 1]): - I[i] = I[i - 1] - np.abs(I[i - 1] * 0.1) + if I[i] >= I[i - 1]: + I[i] = I[i - 1] * 0.9 # Convert to log scale S = S / (I * np.log(10)) I = np.log10(I) # Ensure finite N = np.isfinite(I) if not np.all(N): - I[np.logical_not(N)] = np.interp(R[np.logical_not(N)], R[N], I[N]) + I[~N] = np.interp(R[~N], R[N], I[N]) N = np.isfinite(S) if not np.all(N): - S[np.logical_not(N)] = np.abs(np.interp(R[np.logical_not(N)], R[N], S[N])) + S[~N] = np.abs(np.interp(R[~N], R[N], S[N])) return R, I, S @@ -74,52 +63,48 @@ def _sample_image(image, transform, metric, center, rad_bins=None): ###################################################################### @torch.no_grad() @ignore_numpy_warnings -def parametric_initialize(model, target, prof_func, params, x0_func, force_uncertainty=None): +def parametric_initialize(model, target, prof_func, params, x0_func): if all(list(model[param].value is not None for param in params)): return # Get the sub-image area corresponding to the model image - target_area = target[model.window] - R, I, S = _sample_image( - target_area, model.transform_coordinates, model.radius_metric, model.center.value - ) + R, I, S = _sample_image(target, model.transform_coordinates) x0 = list(x0_func(model, R, I)) for i, param in enumerate(params): - x0[i] = x0[i] if model[param].value is None else model[param].value.item() + x0[i] = x0[i] if model[param].value is None else model[param].npvalue - def optim(x, r, f): - residual = (f - np.log10(prof_func(r, *x))) ** 2 + def optim(x, r, f, u): + residual = ((f - np.log10(prof_func(r, *x))) / u) ** 2 N = np.argsort(residual) return np.mean(residual[N][:-2]) - res = minimize(optim, x0=x0, args=(R, I), method="Nelder-Mead") - if not res.success and AP_config.ap_verbose >= 2: - AP_config.ap_logger.warning( - f"initialization fit not successful for {model.name}, falling back to defaults" - ) + res = minimize(optim, x0=x0, args=(R, I, S), method="Nelder-Mead") + if not res.success: + if AP_config.ap_verbose >= 2: + AP_config.ap_logger.warning( + f"initialization fit not successful for {model.name}, falling back to defaults" + ) + else: + x0 = res.x - if force_uncertainty is None: - reses = [] - for i in range(10): - N = np.random.randint(0, len(R), len(R)) - reses.append(minimize(optim, x0=x0, args=(R[N], I[N]), method="Nelder-Mead")) - for param, resx, x0x in zip(params, res.x, x0): + reses = [] + for i in range(10): + N = np.random.randint(0, len(R), len(R)) + reses.append(minimize(optim, x0=x0, args=(R[N], I[N], S[N]), method="Nelder-Mead")) + for param, x0x in zip(params, x0): if model[param].value is None: - model[param].value = resx if res.success else x0x - if force_uncertainty is None and model[param].uncertainty is None: + model[param].value = x0x + if model[param].uncertainty is None: model[param].uncertainty = np.std( list(subres.x[params.index(param)] for subres in reses) ) - elif force_uncertainty is not None: - model[param].uncertainty = force_uncertainty[params.index(param)] @torch.no_grad() @ignore_numpy_warnings def parametric_segment_initialize( model=None, - parameters=None, target=None, prof_func=None, params=None, @@ -220,325 +205,126 @@ def parametric_segment_initialize( model[param].uncertainty = unc[param] -# Exponential -###################################################################### -@default_internal -def exponential_radial_model(self, R, image=None, parameters=None): - return exponential_torch( - R, - parameters["Re"].value, - image.pixel_area * 10 ** parameters["Ie"].value, - ) - - -@default_internal -def exponential_iradial_model(self, i, R, image=None, parameters=None): - return exponential_torch( - R, - parameters["Re"].value[i], - image.pixel_area * 10 ** parameters["Ie"].value[i], - ) - - -# Moffat -###################################################################### -@default_internal -def moffat_radial_model(self, R, image=None, parameters=None): - return moffat_torch( - R, - parameters["n"].value, - parameters["Rd"].value, - image.pixel_area * 10 ** parameters["I0"].value, - ) - - -@default_internal -def moffat_iradial_model(self, i, R, image=None, parameters=None): - return moffat_torch( - R, - parameters["n"].value[i], - parameters["Rd"].value[i], - image.pixel_area * 10 ** parameters["I0"].value[i], - ) - - -# Nuker Profile -###################################################################### -@default_internal -def nuker_radial_model(self, R, image=None, parameters=None): - return nuker_torch( - R, - parameters["Rb"].value, - image.pixel_area * 10 ** parameters["Ib"].value, - parameters["alpha"].value, - parameters["beta"].value, - parameters["gamma"].value, - ) - - -@default_internal -def nuker_iradial_model(self, i, R, image=None, parameters=None): - return nuker_torch( - R, - parameters["Rb"].value[i], - image.pixel_area * 10 ** parameters["Ib"].value[i], - parameters["alpha"].value[i], - parameters["beta"].value[i], - parameters["gamma"].value[i], - ) - - -# Gaussian -###################################################################### -@default_internal -def gaussian_radial_model(self, R, image=None, parameters=None): - return gaussian_torch( - R, - parameters["sigma"].value, - image.pixel_area * 10 ** parameters["flux"].value, - ) - - -@default_internal -def gaussian_iradial_model(self, i, R, image=None, parameters=None): - return gaussian_torch( - R, - parameters["sigma"].value[i], - image.pixel_area * 10 ** parameters["flux"].value[i], - ) - - -# Spline -###################################################################### -@torch.no_grad() -@ignore_numpy_warnings -@select_target -@default_internal -def spline_initialize(self, target=None, parameters=None, **kwargs): - super(self.__class__, self).initialize(target=target, parameters=parameters) - - if parameters["I(R)"].value is not None and parameters["I(R)"].prof is not None: - return - - # Create the I(R) profile radii if needed - if parameters["I(R)"].prof is None: - new_prof = [0, 2 * target.pixel_length] - while new_prof[-1] < torch.max(self.window.shape / 2): - new_prof.append(new_prof[-1] + torch.max(2 * target.pixel_length, new_prof[-1] * 0.2)) - new_prof.pop() - new_prof.pop() - new_prof.append(torch.sqrt(torch.sum((self.window.shape / 2) ** 2))) - parameters["I(R)"].prof = new_prof - - profR = parameters["I(R)"].prof.detach().cpu().numpy() - target_area = target[self.window] - R, I, S = _sample_image( - target_area, - self.transform_coordinates, - self.radius_metric, - parameters, - rad_bins=[profR[0]] + list((profR[:-1] + profR[1:]) / 2) + [profR[-1] * 100], - ) - with Param_Unlock(parameters["I(R)"]), Param_SoftLimits(parameters["I(R)"]): - parameters["I(R)"].value = I - parameters["I(R)"].uncertainty = S - - -@torch.no_grad() -@ignore_numpy_warnings -@select_target -@default_internal -def spline_segment_initialize( - self, target=None, parameters=None, segments=1, symmetric=True, **kwargs -): - super(self.__class__, self).initialize(target=target, parameters=parameters) - - if parameters["I(R)"].value is not None and parameters["I(R)"].prof is not None: - return - - # Create the I(R) profile radii if needed - if parameters["I(R)"].prof is None: - new_prof = [0, 2 * target.pixel_length] - while new_prof[-1] < torch.max(self.window.shape / 2): - new_prof.append(new_prof[-1] + torch.max(2 * target.pixel_length, new_prof[-1] * 0.2)) - new_prof.pop() - new_prof.pop() - new_prof.append(torch.sqrt(torch.sum((self.window.shape / 2) ** 2))) - parameters["I(R)"].prof = new_prof - - profR = parameters["I(R)"].prof.detach().cpu().numpy() - target_area = target[self.window] - target_dat = target_area.data.detach().cpu().numpy() - if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() - target_dat[mask] = np.median(target_dat[np.logical_not(mask)]) - Coords = target_area.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - X, Y = self.transform_coordinates(X, Y, target, parameters) - R = self.radius_metric(X, Y, target, parameters).detach().cpu().numpy() - T = self.angular_metric(X, Y, target, parameters).detach().cpu().numpy() - rad_bins = [profR[0]] + list((profR[:-1] + profR[1:]) / 2) + [profR[-1] * 100] - raveldat = target_dat.ravel() - val = np.zeros((segments, len(parameters["I(R)"].prof))) - unc = np.zeros((segments, len(parameters["I(R)"].prof))) - for s in range(segments): - if segments % 2 == 0 and symmetric: - angles = (T - (s * np.pi / segments)) % np.pi - TCHOOSE = np.logical_or( - angles < (np.pi / segments), angles >= (np.pi * (1 - 1 / segments)) - ) - elif segments % 2 == 1 and symmetric: - angles = (T - (s * np.pi / segments)) % (2 * np.pi) - TCHOOSE = np.logical_or( - angles < (np.pi / segments), angles >= (np.pi * (2 - 1 / segments)) - ) - angles = (T - (np.pi + s * np.pi / segments)) % (2 * np.pi) - TCHOOSE = np.logical_or( - TCHOOSE, - np.logical_or(angles < (np.pi / segments), angles >= (np.pi * (2 - 1 / segments))), - ) - elif segments % 2 == 0 and not symmetric: - angles = (T - (s * 2 * np.pi / segments)) % (2 * np.pi) - TCHOOSE = torch.logical_or( - angles < (2 * np.pi / segments), - angles >= (2 * np.pi * (1 - 1 / segments)), - ) - else: - angles = (T - (s * 2 * np.pi / segments)) % (2 * np.pi) - TCHOOSE = torch.logical_or( - angles < (2 * np.pi / segments), angles >= (np.pi * (2 - 1 / segments)) - ) - TCHOOSE = TCHOOSE.ravel() - I = ( - binned_statistic( - R.ravel()[TCHOOSE], raveldat[TCHOOSE], statistic="median", bins=rad_bins - )[0] - ) / target.pixel_area.item() - N = np.isfinite(I) - if not np.all(N): - I[np.logical_not(N)] = np.interp(profR[np.logical_not(N)], profR[N], I[N]) - S = binned_statistic( - R.ravel(), - raveldat, - statistic=lambda d: iqr(d, rng=[16, 84]) / 2, - bins=rad_bins, - )[0] - N = np.isfinite(S) - if not np.all(N): - S[np.logical_not(N)] = np.interp(profR[np.logical_not(N)], profR[N], S[N]) - val[s] = np.log10(np.abs(I)) - unc[s] = S / (np.abs(I) * np.log(10)) - with Param_Unlock(parameters["I(R)"]), Param_SoftLimits(parameters["I(R)"]): - parameters["I(R)"].value = val - parameters["I(R)"].uncertainty = unc - - -@default_internal -def spline_radial_model(self, R, image=None, parameters=None): - return ( - spline_torch( - R, - parameters["I(R)"].prof, - parameters["I(R)"].value, - extend=self.extend_profile, - ) - * image.pixel_area - ) - - -@default_internal -def spline_iradial_model(self, i, R, image=None, parameters=None): - return ( - spline_torch( - R, - parameters["I(R)"].prof, - parameters["I(R)"].value[i], - extend=self.extend_profile, - ) - * image.pixel_area - ) - - -# RelSpline -###################################################################### -@torch.no_grad() -@ignore_numpy_warnings -@select_target -@default_internal -def relspline_initialize(self, target=None, parameters=None, **kwargs): - super(self.__class__, self).initialize(target=target, parameters=parameters) - - target_area = target[self.window] - target_dat = target_area.data.detach().cpu().numpy() - if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() - target_dat[mask] = np.median(target_dat[np.logical_not(mask)]) - if parameters["I0"].value is None: - center = target_area.plane_to_pixel(parameters["center"].value) - flux = target_dat[center[1].int().item(), center[0].int().item()] - with Param_Unlock(parameters["I0"]), Param_SoftLimits(parameters["I0"]): - parameters["I0"].value = np.log10(np.abs(flux) / target_area.pixel_area.item()) - parameters["I0"].uncertainty = 0.01 - - if parameters["dI(R)"].value is not None and parameters["dI(R)"].prof is not None: - return - - # Create the I(R) profile radii if needed - if parameters["dI(R)"].prof is None: - new_prof = [2 * target.pixel_length] - while new_prof[-1] < torch.max(self.window.shape / 2): - new_prof.append(new_prof[-1] + torch.max(2 * target.pixel_length, new_prof[-1] * 0.2)) - new_prof.pop() - new_prof.pop() - new_prof.append(torch.sqrt(torch.sum((self.window.shape / 2) ** 2))) - parameters["dI(R)"].prof = new_prof - - profR = parameters["dI(R)"].prof.detach().cpu().numpy() - - Coords = target_area.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - X, Y = self.transform_coordinates(X, Y, target, parameters) - R = self.radius_metric(X, Y, target, parameters).detach().cpu().numpy() - rad_bins = [profR[0]] + list((profR[:-1] + profR[1:]) / 2) + [profR[-1] * 100] - raveldat = target_dat.ravel() - - I = ( - binned_statistic(R.ravel(), raveldat, statistic="median", bins=rad_bins)[0] - ) / target.pixel_area.item() - N = np.isfinite(I) - if not np.all(N): - I[np.logical_not(N)] = np.interp(profR[np.logical_not(N)], profR[N], I[N]) - if I[-1] >= I[-2]: - I[-1] = I[-2] / 2 - S = binned_statistic( - R.ravel(), raveldat, statistic=lambda d: iqr(d, rng=[16, 84]) / 2, bins=rad_bins - )[0] - N = np.isfinite(S) - if not np.all(N): - S[np.logical_not(N)] = np.interp(profR[np.logical_not(N)], profR[N], S[N]) - with Param_Unlock(parameters["dI(R)"]), Param_SoftLimits(parameters["dI(R)"]): - parameters["dI(R)"].value = np.log10(np.abs(I)) - parameters["I0"].value.item() - parameters["dI(R)"].uncertainty = S / (np.abs(I) * np.log(10)) - - -@default_internal -def relspline_radial_model(self, R, image=None, parameters=None): - return ( - spline_torch( - R, - torch.cat( - ( - torch.zeros_like(parameters["I0"].value).unsqueeze(-1), - parameters["dI(R)"].prof, - ) - ), - torch.cat( - ( - parameters["I0"].value.unsqueeze(-1), - parameters["I0"].value + parameters["dI(R)"].value, - ) - ), - extend=self.extend_profile, - ) - * image.pixel_area - ) +# # Spline +# ###################################################################### +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def spline_initialize(self, target=None, parameters=None, **kwargs): +# super(self.__class__, self).initialize(target=target, parameters=parameters) + +# if parameters["I(R)"].value is not None and parameters["I(R)"].prof is not None: +# return + +# # Create the I(R) profile radii if needed +# if parameters["I(R)"].prof is None: +# new_prof = [0, 2 * target.pixel_length] +# while new_prof[-1] < torch.max(self.window.shape / 2): +# new_prof.append(new_prof[-1] + torch.max(2 * target.pixel_length, new_prof[-1] * 0.2)) +# new_prof.pop() +# new_prof.pop() +# new_prof.append(torch.sqrt(torch.sum((self.window.shape / 2) ** 2))) +# parameters["I(R)"].prof = new_prof + +# profR = parameters["I(R)"].prof.detach().cpu().numpy() +# target_area = target[self.window] +# R, I, S = _sample_image( +# target_area, +# self.transform_coordinates, +# self.radius_metric, +# parameters, +# rad_bins=[profR[0]] + list((profR[:-1] + profR[1:]) / 2) + [profR[-1] * 100], +# ) +# with Param_Unlock(parameters["I(R)"]), Param_SoftLimits(parameters["I(R)"]): +# parameters["I(R)"].value = I +# parameters["I(R)"].uncertainty = S + + +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def spline_segment_initialize( +# self, target=None, parameters=None, segments=1, symmetric=True, **kwargs +# ): +# super(self.__class__, self).initialize(target=target, parameters=parameters) + +# if parameters["I(R)"].value is not None and parameters["I(R)"].prof is not None: +# return + +# # Create the I(R) profile radii if needed +# if parameters["I(R)"].prof is None: +# new_prof = [0, 2 * target.pixel_length] +# while new_prof[-1] < torch.max(self.window.shape / 2): +# new_prof.append(new_prof[-1] + torch.max(2 * target.pixel_length, new_prof[-1] * 0.2)) +# new_prof.pop() +# new_prof.pop() +# new_prof.append(torch.sqrt(torch.sum((self.window.shape / 2) ** 2))) +# parameters["I(R)"].prof = new_prof + +# profR = parameters["I(R)"].prof.detach().cpu().numpy() +# target_area = target[self.window] +# target_dat = target_area.data.detach().cpu().numpy() +# if target_area.has_mask: +# mask = target_area.mask.detach().cpu().numpy() +# target_dat[mask] = np.median(target_dat[np.logical_not(mask)]) +# Coords = target_area.get_coordinate_meshgrid() +# X, Y = Coords - parameters["center"].value[..., None, None] +# X, Y = self.transform_coordinates(X, Y, target, parameters) +# R = self.radius_metric(X, Y, target, parameters).detach().cpu().numpy() +# T = self.angular_metric(X, Y, target, parameters).detach().cpu().numpy() +# rad_bins = [profR[0]] + list((profR[:-1] + profR[1:]) / 2) + [profR[-1] * 100] +# raveldat = target_dat.ravel() +# val = np.zeros((segments, len(parameters["I(R)"].prof))) +# unc = np.zeros((segments, len(parameters["I(R)"].prof))) +# for s in range(segments): +# if segments % 2 == 0 and symmetric: +# angles = (T - (s * np.pi / segments)) % np.pi +# TCHOOSE = np.logical_or( +# angles < (np.pi / segments), angles >= (np.pi * (1 - 1 / segments)) +# ) +# elif segments % 2 == 1 and symmetric: +# angles = (T - (s * np.pi / segments)) % (2 * np.pi) +# TCHOOSE = np.logical_or( +# angles < (np.pi / segments), angles >= (np.pi * (2 - 1 / segments)) +# ) +# angles = (T - (np.pi + s * np.pi / segments)) % (2 * np.pi) +# TCHOOSE = np.logical_or( +# TCHOOSE, +# np.logical_or(angles < (np.pi / segments), angles >= (np.pi * (2 - 1 / segments))), +# ) +# elif segments % 2 == 0 and not symmetric: +# angles = (T - (s * 2 * np.pi / segments)) % (2 * np.pi) +# TCHOOSE = torch.logical_or( +# angles < (2 * np.pi / segments), +# angles >= (2 * np.pi * (1 - 1 / segments)), +# ) +# else: +# angles = (T - (s * 2 * np.pi / segments)) % (2 * np.pi) +# TCHOOSE = torch.logical_or( +# angles < (2 * np.pi / segments), angles >= (np.pi * (2 - 1 / segments)) +# ) +# TCHOOSE = TCHOOSE.ravel() +# I = ( +# binned_statistic( +# R.ravel()[TCHOOSE], raveldat[TCHOOSE], statistic="median", bins=rad_bins +# )[0] +# ) / target.pixel_area.item() +# N = np.isfinite(I) +# if not np.all(N): +# I[np.logical_not(N)] = np.interp(profR[np.logical_not(N)], profR[N], I[N]) +# S = binned_statistic( +# R.ravel(), +# raveldat, +# statistic=lambda d: iqr(d, rng=[16, 84]) / 2, +# bins=rad_bins, +# )[0] +# N = np.isfinite(S) +# if not np.all(N): +# S[np.logical_not(N)] = np.interp(profR[np.logical_not(N)], profR[N], S[N]) +# val[s] = np.log10(np.abs(I)) +# unc[s] = S / (np.abs(I) * np.log(10)) +# with Param_Unlock(parameters["I(R)"]), Param_SoftLimits(parameters["I(R)"]): +# parameters["I(R)"].value = val +# parameters["I(R)"].uncertainty = unc diff --git a/astrophot/models/core_model.py b/astrophot/models/core_model.py index d7ffa1ae..0f387123 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/core_model.py @@ -2,8 +2,8 @@ from copy import deepcopy import torch -from caskade import Module, forward, Param +from ..param import Module, forward, Param from ..utils.decorators import classproperty from ..image import Window, Target_Image_List from ..errors import UnrecognizedModel, InvalidWindow @@ -88,6 +88,7 @@ class defines the signatures to interact with AstroPhot models _model_type = "model" _parameter_specs = {} default_uncertainty = 1e-2 # During initialization, uncertainty will be assumed 1% of initial value if no uncertainty is given + _options = ("default_uncertainty",) usable = False def __new__(cls, *, filename=None, model_type=None, **kwargs): @@ -111,22 +112,19 @@ def __new__(cls, *, filename=None, model_type=None, **kwargs): def __init__(self, *, name=None, target=None, window=None, **kwargs): super().__init__(name=name) - if not hasattr(self, "_target"): - self._target = None self.target = target self.window = window self.mask = kwargs.get("mask", None) - # Set any user defined attributes for the model - for kwarg in kwargs: # fixme move to core model? - # Skip parameters with special behaviour - if kwarg in self.special_kwargs: - continue - # Set the model parameter - setattr(self, kwarg, kwargs[kwarg]) - self.parameter_specs = self.build_parameter_specs(kwargs) - for key in self.parameter_specs: - setattr(self, key, Param(key, **self.parameter_specs[key])) + # Set any user defined options for the model + for kwarg in kwargs: + if kwarg in self.options: + setattr(self, kwarg, kwargs[kwarg]) + + # Create Param objects for this Module + parameter_specs = self.build_parameter_specs(kwargs) + for key in parameter_specs: + setattr(self, key, Param(key, **parameter_specs[key])) # If loading from a file, get model configuration then exit __init__ if "filename" in kwargs: @@ -139,16 +137,35 @@ def model_type(cls): for subcls in cls.mro(): if subcls is object: continue - mt = getattr(subcls, "_model_type", None) + mt = subcls.__dict__.get("_model_type", None) if mt: collected.append(mt) return " ".join(collected) + @classproperty + def options(cls): + options = set() + for subcls in cls.mro(): + if subcls is object: + continue + options.update(getattr(subcls, "_options", [])) + return options + + @classproperty + def parameter_specs(cls): + """Collects all parameter specifications from the class hierarchy.""" + specs = {} + for subcls in reversed(cls.mro()): + if subcls is object: + continue + specs.update(getattr(subcls, "_parameter_specs", {})) + return specs + def build_parameter_specs(self, kwargs): - parameter_specs = deepcopy(self._parameter_specs) + parameter_specs = deepcopy(self.parameter_specs) for p in kwargs: - if p not in self._parameter_specs: + if p not in parameter_specs: continue if isinstance(kwargs[p], dict): parameter_specs[p].update(kwargs[p]) @@ -157,23 +174,6 @@ def build_parameter_specs(self, kwargs): return parameter_specs - @torch.no_grad() - def initialize(self, **kwargs): - """When this function finishes, all parameters should have numerical - values (non None) that are reasonable estimates of the final - values. - - """ - pass - - @forward - def sample(self, *args, **kwargs): - """Calling this function should fill the given image with values - sampled from the given model. - - """ - pass - @forward def gaussian_negative_log_likelihood( self, @@ -252,7 +252,8 @@ def window(self): return self.target.window return self._window - def set_window(self, window): + @window.setter + def window(self, window): if window is None: # If no window given, set to none self._window = None @@ -265,10 +266,6 @@ def set_window(self, window): else: raise InvalidWindow(f"Unrecognized window format: {str(window)}") - @window.setter - def window(self, window): - self.set_window(window) - @classmethod def List_Models(cls, usable=None): MODELS = all_subclasses(cls) @@ -278,9 +275,6 @@ def List_Models(cls, usable=None): MODELS.remove(model) return MODELS - def __eq__(self, other): - return self is other - @forward def __call__( self, diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index e9363b59..795df64e 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -1,34 +1,36 @@ from .integration import ( quad_table, - pixel_center_meshgrid, pixel_center_integrator, - pixel_corner_meshgrid, pixel_corner_integrator, - pixel_simpsons_meshgrid, pixel_simpsons_integrator, - pixel_quad_meshgrid, pixel_quad_integrator, + single_quad_integrate, + recursive_quad_integrate, + upsample, ) from .convolution import ( lanczos_kernel, bilinear_kernel, convolve_and_shift, + curvature_kernel, ) from .sersic import sersic, sersic_n_to_b +from .moffat import moffat __all__ = ( "quad_table", - "pixel_center_meshgrid", "pixel_center_integrator", - "pixel_corner_meshgrid", "pixel_corner_integrator", - "pixel_simpsons_meshgrid", "pixel_simpsons_integrator", - "pixel_quad_meshgrid", "pixel_quad_integrator", "lanczos_kernel", "bilinear_kernel", "convolve_and_shift", + "curvature_kernel", "sersic", "sersic_n_to_b", + "moffat", + "single_quad_integrate", + "recursive_quad_integrate", + "upsample", ) diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index df074d45..0e127c68 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -1,3 +1,5 @@ +from functools import lru_cache + import torch @@ -36,3 +38,17 @@ def convolve_and_shift(image, shift_kernel, psf): convolved_fft = image_fft * psf_fft * shift_fft return torch.fft.irfft2(convolved_fft, s=image.shape) + + +@lru_cache(maxsize=32) +def curvature_kernel(dtype, device): + kernel = torch.tensor( + [ + [0.0, 1.0, 0.0], + [1.0, -4.0, 1.0], + [0.0, 1.0, 0.0], + ], # [[1., -2.0, 1.], [-2.0, 4, -2.0], [1.0, -2.0, 1.0]], + device=device, + dtype=dtype, + ) + return kernel diff --git a/astrophot/models/func/gaussian.py b/astrophot/models/func/gaussian.py new file mode 100644 index 00000000..073c73a0 --- /dev/null +++ b/astrophot/models/func/gaussian.py @@ -0,0 +1,14 @@ +import torch +import numpy as np + + +def gaussian(R, sigma, I0): + """Gaussian 1d profile function, specifically designed for pytorch + operations. + + Parameters: + R: Radii tensor at which to evaluate the sersic function + sigma: standard deviation of the gaussian in the same units as R + I0: central surface density + """ + return (I0 / torch.sqrt(2 * np.pi * sigma**2)) * torch.exp(-0.5 * torch.pow(R / sigma, 2)) diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index 0ceb03bb..3cf32eb8 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -1,66 +1,18 @@ import torch -from functools import lru_cache -from scipy.special import roots_legendre - - -@lru_cache(maxsize=32) -def quad_table(order, dtype, device): - """ - Generate a meshgrid for quadrature points using Legendre-Gauss quadrature. - - Parameters - ---------- - n : int - The number of quadrature points in each dimension. - dtype : torch.dtype - The desired data type of the tensor. - device : torch.device - The device on which to create the tensor. - - Returns - ------- - Tuple[torch.Tensor, torch.Tensor, torch.Tensor] - The generated meshgrid as a tuple of Tensors. - """ - abscissa, weights = roots_legendre(order) - - w = torch.tensor(weights, dtype=dtype, device=device) - a = torch.tensor(abscissa, dtype=dtype, device=device) / 2.0 - di, dj = torch.meshgrid(a, a, indexing="xy") - - w = torch.outer(w, w) / 4.0 - return di, dj, w - - -def pixel_center_meshgrid(shape, dtype, device): - i = torch.arange(shape[0], dtype=dtype, device=device) - j = torch.arange(shape[1], dtype=dtype, device=device) - return torch.meshgrid(i, j, indexing="xy") +from ...utils.integration import quad_table def pixel_center_integrator(Z: torch.Tensor): return Z -def pixel_corner_meshgrid(shape, dtype, device): - i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="xy") - - def pixel_corner_integrator(Z: torch.Tensor): kernel = torch.ones((1, 1, 2, 2), dtype=Z.dtype, device=Z.device) / 4.0 Z = torch.nn.functional.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid") return Z.squeeze(0).squeeze(0) -def pixel_simpsons_meshgrid(shape, dtype, device): - i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="xy") - - def pixel_simpsons_integrator(Z: torch.Tensor): kernel = ( torch.tensor([[[[1, 4, 1], [4, 16, 4], [1, 4, 1]]]], dtype=Z.dtype, device=Z.device) / 36.0 @@ -69,14 +21,6 @@ def pixel_simpsons_integrator(Z: torch.Tensor): return Z.squeeze(0).squeeze(0) -def pixel_quad_meshgrid(shape, dtype, device, order=3): - i, j = pixel_center_meshgrid(shape, dtype, device) - di, dj, w = quad_table(order, dtype, device) - i = torch.repeat_interleave(i[..., None], order**2, -1) + di - j = torch.repeat_interleave(j[..., None], order**2, -1) + dj - return i, j, w - - def pixel_quad_integrator(Z: torch.Tensor, w: torch.Tensor = None, order=3): """ Integrate the pixel values using quadrature weights. @@ -94,6 +38,65 @@ def pixel_quad_integrator(Z: torch.Tensor, w: torch.Tensor = None, order=3): The integrated value. """ if w is None: - _, _, w = _quad_table(order, Z.dtype, Z.device) + _, _, w = quad_table(order, Z.dtype, Z.device) Z = Z * w - return Z.sum(dim=(-2, -1)) + return Z.sum(dim=(-1)) + + +def upsample(i, j, order, scale): + dp = torch.linspace(-1, 1, order, dtype=i.dtype, device=i.device) * (order - 1) / (2.0 * order) + di, dj = torch.meshgrid(dp, dp, indexing="xy") + + si = torch.repeat_interleave(i.unsqueeze(-1), order**2, -1) + scale * di.flatten() + sj = torch.repeat_interleave(j.unsqueeze(-1), order**2, -1) + scale * dj.flatten() + return si, sj + + +def single_quad_integrate(i, j, brightness_ij, scale, quad_order=3): + di, dj, w = quad_table(quad_order, i.dtype, i.device) + qi = torch.repeat_interleave(i.unsqueeze(-1), quad_order**2, -1) + scale * di.flatten() + qj = torch.repeat_interleave(j.unsqueeze(-1), quad_order**2, -1) + scale * dj.flatten() + z = brightness_ij(qi, qj) + z0 = torch.mean(z, dim=-1) + z = torch.sum(z * w.flatten(), dim=-1) + return z, z0 + + +def recursive_quad_integrate( + i, + j, + brightness_ij, + threshold, + scale=1.0, + quad_order=3, + gridding=5, + _current_depth=0, + max_depth=2, +): + + scale = 1.0 if _current_depth == 0 else 1 / (_current_depth * gridding) + z, z0 = single_quad_integrate(i, j, brightness_ij, scale, quad_order) + + if _current_depth >= max_depth: + return z + + select = torch.abs(z - z0) > threshold + + integral = torch.zeros_like(z) + integral[~select] = z[~select] + + si, sj = upsample(i[select], j[select], quad_order, scale) + + integral[select] = recursive_quad_integrate( + si, + sj, + brightness_ij, + threshold, + scale=scale, + quad_order=quad_order, + gridding=gridding, + _current_depth=_current_depth + 1, + max_depth=max_depth, + ).sum(dim=-1) + + return integral diff --git a/astrophot/models/func/moffat.py b/astrophot/models/func/moffat.py new file mode 100644 index 00000000..274b73fe --- /dev/null +++ b/astrophot/models/func/moffat.py @@ -0,0 +1,11 @@ +def moffat(R, n, Rd, I0): + """Moffat 1d profile function + + Parameters: + R: Radii tensor at which to evaluate the moffat function + n: concentration index + Rd: scale length in the same units as R + I0: central surface density + + """ + return I0 / (1 + (R / Rd) ** 2) ** n diff --git a/astrophot/models/func/nuker.py b/astrophot/models/func/nuker.py new file mode 100644 index 00000000..556135b2 --- /dev/null +++ b/astrophot/models/func/nuker.py @@ -0,0 +1,18 @@ +def nuker(R, Rb, Ib, alpha, beta, gamma): + """Nuker 1d profile function + + Parameters: + R: Radii tensor at which to evaluate the nuker function + Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. + Rb: scale length radius + alpha: sharpness of transition between power law slopes + beta: outer power law slope + gamma: inner power law slope + + """ + return ( + Ib + * (2 ** ((beta - gamma) / alpha)) + * ((R / Rb) ** (-gamma)) + * ((1 + (R / Rb) ** alpha) ** ((gamma - beta) / alpha)) + ) diff --git a/astrophot/models/func/sersic.py b/astrophot/models/func/sersic.py index 40fa128b..3244f019 100644 --- a/astrophot/models/func/sersic.py +++ b/astrophot/models/func/sersic.py @@ -1,3 +1,9 @@ +C1 = 4 / 405 +C2 = 46 / 25515 +C3 = 131 / 1148175 +C4 = -2194697 / 30690717750 + + def sersic_n_to_b(n): """Compute the `b(n)` for a sersic model. This factor ensures that the :math:`R_e` and :math:`I_e` parameters do in fact correspond @@ -6,11 +12,7 @@ def sersic_n_to_b(n): """ x = 1 / n - return ( - 2 * n - - 1 / 3 - + x * (4 / 405 + x * (46 / 25515 + x * (131 / 1148175 - x * 2194697 / 30690717750))) - ) + return 2 * n - 1 / 3 + x * (C1 + x * (C2 + x * (C3 + C4 * x))) def sersic(R, n, Re, Ie): diff --git a/astrophot/models/func/spline.py b/astrophot/models/func/spline.py new file mode 100644 index 00000000..deef0c44 --- /dev/null +++ b/astrophot/models/func/spline.py @@ -0,0 +1,63 @@ +import torch + + +def _h_poly(t): + """Helper function to compute the 'h' polynomial matrix used in the + cubic spline. + + Args: + t (Tensor): A 1D tensor representing the normalized x values. + + Returns: + Tensor: A 2D tensor of size (4, len(t)) representing the 'h' polynomial matrix. + + """ + + tt = t[None, :] ** (torch.arange(4, device=t.device)[:, None]) + A = torch.tensor( + [[1, 0, -3, 2], [0, 1, -2, 1], [0, 0, 3, -2], [0, 0, -1, 1]], + dtype=t.dtype, + device=t.device, + ) + return A @ tt + + +def cubic_spline_torch(x: torch.Tensor, y: torch.Tensor, xs: torch.Tensor) -> torch.Tensor: + """Compute the 1D cubic spline interpolation for the given data points + using PyTorch. + + Args: + x (Tensor): A 1D tensor representing the x-coordinates of the known data points. + y (Tensor): A 1D tensor representing the y-coordinates of the known data points. + xs (Tensor): A 1D tensor representing the x-coordinates of the positions where + the cubic spline function should be evaluated. + extend (str, optional): The method for handling extrapolation, either "const" or "linear". + Default is "const". + "const": Use the value of the last known data point for extrapolation. + "linear": Use linear extrapolation based on the last two known data points. + + Returns: + Tensor: A 1D tensor representing the interpolated values at the specified positions (xs). + + """ + m = (y[1:] - y[:-1]) / (x[1:] - x[:-1]) + m = torch.cat([m[[0]], (m[1:] + m[:-1]) / 2, m[[-1]]]) + idxs = torch.searchsorted(x[:-1], xs) - 1 + dx = x[idxs + 1] - x[idxs] + hh = _h_poly((xs - x[idxs]) / dx) + ret = hh[0] * y[idxs] + hh[1] * m[idxs] * dx + hh[2] * y[idxs + 1] + hh[3] * m[idxs + 1] * dx + return ret + + +def spline(R, profR, profI): + """Spline 1d profile function, cubic spline between points up + to second last point beyond which is linear + + Parameters: + R: Radii tensor at which to evaluate the sersic function + profR: radius values for the surface density profile in the same units as R + profI: surface density values for the surface density profile + """ + I = cubic_spline_torch(profR, profI, R.view(-1)).reshape(*R.shape) + I[R > profR[-1]] = 0 + return I diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index c725208c..6dd792ab 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -1,19 +1,9 @@ -from typing import Optional - import torch import numpy as np -from scipy.stats import iqr -from caskade import Param, forward from . import func -from ..utils.initialize import isophotes -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..utils.angle_operations import Angle_COM_PA -from ..utils.conversions.coordinates import ( - Rotate_Cartesian, -) +from ..utils.decorators import ignore_numpy_warnings from .model_object import Component_Model -from ._shared_methods import select_target from .mixins import InclinedMixin @@ -67,12 +57,9 @@ def initialize(self, **kwargs): ) edge_average = np.nanmedian(edge) target_dat -= edge_average - icenter = target_area.plane_to_pixel(self.center.value) - - i, j = func.pixel_center_meshgrid( - target_area.shape, dtype=target_area.data.dtype, device=target_area.data.device - ) - i, j = (i - icenter[0]).detach().cpu().item(), (j - icenter[1]).detach().cpu().item() + icenter = target_area.plane_to_pixel(*self.center.value) + i, j = target_area.pixel_center_meshgrid() + i, j = (i - icenter[0]).detach().cpu().numpy(), (j - icenter[1]).detach().cpu().numpy() mu20 = np.sum(target_dat * i**2) mu02 = np.sum(target_dat * j**2) mu11 = np.sum(target_dat * i * j) @@ -80,5 +67,6 @@ def initialize(self, **kwargs): if self.PA.value is None: self.PA.value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi if self.q.value is None: - l = np.sorted(np.linalg.eigvals(M)) + print(M) + l = np.sort(np.linalg.eigvals(M)) self.q.value = np.sqrt(l[1] / l[0]) diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index aece3494..cc37ab4f 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -1,6 +1,8 @@ from .sersic import SersicMixin, iSersicMixin -from .brightness import RadialMixin, InclinedMixin +from .brightness import RadialMixin +from .transform import InclinedMixin from .exponential import ExponentialMixin, iExponentialMixin +from .moffat import MoffatMixin from .sample import SampleMixin __all__ = ( @@ -10,5 +12,6 @@ "InclinedMixin", "ExponentialMixin", "iExponentialMixin", + "MoffatMixin", "SampleMixin", ) diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index 3bb2c6b7..11b861c1 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -1,41 +1,12 @@ -import numpy as np +from ...param import forward class RadialMixin: - def brightness(self, x, y, center): + @forward + def brightness(self, x, y): """ Calculate the brightness at a given point (x, y) based on radial distance from the center. """ - x, y = x - center[0], y - center[1] - return self.radial_model(self.radius_metric(x, y)) - - -def rotate(theta, x, y): - """ - Applies a rotation matrix to the X,Y coordinates - """ - s = theta.sin() - c = theta.cos() - return c * x - s * y, s * x + c * y - - -class InclinedMixin: - - parameter_specs = { - "q": {"units": "b/a", "limits": (0, 1), "uncertainty": 0.03}, - "PA": { - "units": "radians", - "limits": (0, np.pi), - "cyclic": True, - "uncertainty": 0.06, - }, - } - - def brightness(self, x, y, center, PA, q): - """ - Calculate the brightness at a given point (x, y) based on radial distance from the center. - """ - x, y = x - center[0], y - center[1] - x, y = rotate(PA, x, y) - return self.radial_model((x**2 + (y / q) ** 2).sqrt()) + x, y = self.transform_coordinates(x, y) + return self.radial_model((x**2 + y**2).sqrt()) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 1d816013..78cfeeff 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -1,6 +1,6 @@ import torch -from caskade import forward +from ...param import forward from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import parametric_initialize, parametric_segment_initialize from ...utils.parametric_profiles import exponential_np diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py new file mode 100644 index 00000000..214eecb2 --- /dev/null +++ b/astrophot/models/mixins/moffat.py @@ -0,0 +1,62 @@ +import torch + +from ...param import forward +from ...utils.decorators import ignore_numpy_warnings +from .._shared_methods import parametric_initialize, parametric_segment_initialize +from ...utils.parametric_profiles import moffat_np +from .. import func + + +def _x0_func(model_params, R, F): + return 2.0, R[4], F[0] + + +class MoffatMixin: + + _model_type = "moffat" + _parameter_specs = { + "n": {"units": "none", "limits": (0.1, 10), "uncertainty": 0.05}, + "Rd": {"units": "arcsec", "limits": (0, None)}, + "I0": {"units": "flux/arcsec^2"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self, **kwargs): + super().initialize() + + parametric_initialize( + self, self.target[self.window], moffat_np, ("n", "Re", "Ie"), _x0_func + ) + + @forward + def radial_model(self, R, n, Rd, I0): + return func.moffat(R, n, Rd, I0) + + +class iMoffatMixin: + + _model_type = "moffat" + _parameter_specs = { + "n": {"units": "none", "limits": (0.1, 10), "uncertainty": 0.05}, + "Rd": {"units": "arcsec", "limits": (0, None)}, + "I0": {"units": "flux/arcsec^2"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self, **kwargs): + super().initialize() + + parametric_segment_initialize( + model=self, + target=self.target[self.window], + prof_func=moffat_np, + params=("n", "Rd", "I0"), + x0_func=_x0_func, + segments=self.rays, + ) + + @forward + def radial_model(self, i, R, n, Rd, I0): + return func.moffat(R, n[i], Rd[i], I0[i]) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 659222e9..4dfb2f50 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -1,11 +1,11 @@ from typing import Optional, Literal import numpy as np -from caskade import forward from torch.autograd.functional import jacobian import torch from torch import Tensor +from ...param import forward from ... import AP_config from ...image import Image, Window, Jacobian_Image from .. import func @@ -34,22 +34,16 @@ def sample_image(self, image: Image): sampling_mode = self.sampling_mode if sampling_mode == "midpoint": - i, j = func.pixel_center_meshgrid(image.shape, AP_config.ap_dtype, AP_config.ap_device) - x, y = image.pixel_to_plane(i, j) + x, y = image.coordinate_center_meshgrid() res = self.brightness(x, y) return func.pixel_center_integrator(res) elif sampling_mode == "simpsons": - i, j = func.pixel_simpsons_meshgrid( - image.shape, AP_config.ap_dtype, AP_config.ap_device - ) - x, y = image.pixel_to_plane(i, j) + x, y = image.coordinate_simpsons_meshgrid() res = self.brightness(x, y) return func.pixel_simpsons_integrator(res) elif sampling_mode.startswith("quad:"): order = int(self.sampling_mode.split(":")[1]) - i, j, w = func.pixel_quad_meshgrid( - image.shape, AP_config.ap_dtype, AP_config.ap_device, order=order - ) + i, j, w = image.pixel_quad_meshgrid(order=order) x, y = image.pixel_to_plane(i, j) res = self.brightness(x, y) return func.pixel_quad_integrator(res, w) @@ -57,13 +51,37 @@ def sample_image(self, image: Image): f"Unknown sampling mode {self.sampling_mode} for model {self.name}" ) - def build_params_array_identities(self): - identities = [] - for param in self.dynamic_params: - numel = max(1, np.prod(param.shape)) - for i in range(numel): - identities.append(f"{id(param)}_{i}") - return identities + @forward + def sample_integrate(self, sample, image: Image): + i, j = image.pixel_center_meshgrid() + kernel = func.curvature_kernel(AP_config.ap_dtype, AP_config.ap_device) + curvature = ( + torch.nn.functional.pad( + torch.nn.functional.conv2d( + sample.view(1, 1, *sample.shape), + kernel.view(1, 1, *kernel.shape), + padding="valid", + ), + (1, 1, 1, 1), + mode="replicate", + ) + .squeeze(0) + .squeeze(0) + .abs() + ) + total_est = torch.sum(sample) + threshold = total_est * self.integrate_tolerance + select = curvature > (total_est * self.integrate_tolerance) + sample[select] = func.recursive_quad_integrate( + i[select], + j[select], + lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), + threshold=threshold, + quad_order=self.integrate_quad_order, + gridding=self.integrate_gridding, + max_depth=self.integrate_max_depth, + ) + return sample def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor): return jacobian( diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index dc0e68d4..d9105a43 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -1,6 +1,6 @@ import torch -from caskade import forward +from ...param import forward from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import parametric_initialize, parametric_segment_initialize from ...utils.parametric_profiles import sersic_np @@ -14,10 +14,10 @@ def _x0_func(model, R, F): class SersicMixin: _model_type = "sersic" - parameter_specs = { - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, - "Ie": {"units": "flux/arcsec^2"}, + _parameter_specs = { + "n": {"units": "none", "valid": (0.36, 8), "uncertainty": 0.05, "shape": ()}, + "Re": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "Ie": {"units": "flux/arcsec^2", "shape": ()}, } @torch.no_grad() @@ -37,22 +37,21 @@ def radial_model(self, R, n, Re, Ie): class iSersicMixin: _model_type = "sersic" - parameter_specs = { - "n": {"units": "none", "limits": (0.36, 8), "uncertainty": 0.05}, - "Re": {"units": "arcsec", "limits": (0, None)}, + _parameter_specs = { + "n": {"units": "none", "valid": (0.36, 8), "uncertainty": 0.05}, + "Re": {"units": "arcsec", "valid": (0, None)}, "Ie": {"units": "flux/arcsec^2"}, } @torch.no_grad() @ignore_numpy_warnings - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) + def initialize(self, **kwargs): + super().initialize() parametric_segment_initialize( model=self, - target=target, - parameters=parameters, - prof_func=_wrap_sersic, + target=self.target[self.window], + prof_func=sersic_np, params=("n", "Re", "Ie"), x0_func=_x0_func, segments=self.rays, diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py new file mode 100644 index 00000000..06839a99 --- /dev/null +++ b/astrophot/models/mixins/transform.py @@ -0,0 +1,34 @@ +import numpy as np +from ...param import forward + + +def rotate(theta, x, y): + """ + Applies a rotation matrix to the X,Y coordinates + """ + s = theta.sin() + c = theta.cos() + return c * x - s * y, s * x + c * y + + +class InclinedMixin: + + _parameter_specs = { + "q": {"units": "b/a", "valid": (0, 1), "uncertainty": 0.03, "shape": ()}, + "PA": { + "units": "radians", + "valid": (0, np.pi), + "cyclic": True, + "uncertainty": 0.06, + "shape": (), + }, + } + + @forward + def transform_coordinates(self, x, y, PA, q): + """ + Transform coordinates based on the position angle and axis ratio. + """ + x, y = super().transform_coordinates(x, y) + x, y = rotate(-PA, x, y) + return x, y / q diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index f2df4b83..4e753c48 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -2,8 +2,8 @@ import numpy as np import torch -from caskade import forward +from ..param import forward from .core_model import Model from . import func from ..image import ( @@ -53,8 +53,8 @@ class Component_Model(SampleMixin, Model): """ # Specifications for the model parameters including units, value, uncertainty, limits, locked, and cyclic - _parameter_specs = Model._parameter_specs | { - "center": {"units": "arcsec", "uncertainty": [0.1, 0.1]}, + _parameter_specs = { + "center": {"units": "arcsec", "uncertainty": [0.1, 0.1], "shape": (2,)}, } # Scope for PSF convolution @@ -63,7 +63,7 @@ class Component_Model(SampleMixin, Model): psf_subpixel_shift = "lanczos:3" # bilinear, lanczos:2, lanczos:3, lanczos:5, none # Level to which each pixel should be evaluated - sampling_tolerance = 1e-2 + integrate_tolerance = 1e-2 # Integration scope for model integrate_mode = "threshold" # none, threshold @@ -75,27 +75,22 @@ class Component_Model(SampleMixin, Model): integrate_gridding = 5 # The initial quadrature level for sub pixel integration. Please always choose an odd number 3 or higher - integrate_quad_level = 3 + integrate_quad_order = 3 # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) softening = 1e-3 - # Parameters which are treated specially by the model object and should not be updated directly when initializing - special_kwargs = ["parameters", "filename", "model_type"] - track_attrs = [ + _options = ( "psf_mode", - "psf_convolve_mode", "psf_subpixel_shift", "sampling_mode", "sampling_tolerance", "integrate_mode", "integrate_max_depth", "integrate_gridding", - "integrate_quad_level", - "jacobian_chunksize", - "image_chunksize", + "integrate_quad_order", "softening", - ] + ) usable = False @property @@ -152,7 +147,6 @@ def initialize( target (Optional[Target_Image]): A target image object to use as a reference when setting parameter values """ - super().initialize() target_area = self.target[self.window] # Use center of window if a center hasn't been set yet @@ -161,18 +155,26 @@ def initialize( else: return + dat = np.copy(target_area.data.npvalue) + if target_area.has_mask: + mask = target_area.mask.detach().cpu().numpy() + dat[mask] = np.nanmedian(dat[~mask]) + COM = center_of_mass(target_area.data.npvalue) + if not np.all(np.isfinite(COM)): + return COM_center = target_area.pixel_to_plane( *torch.tensor(COM, dtype=AP_config.ap_dtype, device=AP_config.ap_device) ) - self.center.value = COM_center def fit_mask(self): return torch.zeros_like(self.target[self.window].mask, dtype=torch.bool) - # Fit loop functions - ###################################################################### + @forward + def transform_coordinates(self, x, y, center): + return x - center[0], y - center[1] + def shift_kernel(self, shift): if self.psf_subpixel_shift == "bilinear": return func.bilinear_kernel(shift[0], shift[1]) diff --git a/astrophot/models/moffat_model.py b/astrophot/models/moffat_model.py index 06961c8c..a3213fd3 100644 --- a/astrophot/models/moffat_model.py +++ b/astrophot/models/moffat_model.py @@ -1,26 +1,14 @@ -import torch -import numpy as np +from caskade import forward from .galaxy_model_object import Galaxy_Model from .psf_model_object import PSF_Model -from ._shared_methods import parametric_initialize, select_target -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..utils.parametric_profiles import moffat_np -from ..utils.conversions.functions import moffat_I0_to_flux, general_uncertainty_prop -from ..param import Param_Unlock, Param_SoftLimits +from ..utils.conversions.functions import moffat_I0_to_flux +from .mixins import MoffatMixin, InclinedMixin __all__ = ["Moffat_Galaxy", "Moffat_PSF"] -def _x0_func(model_params, R, F): - return 2.0, R[4], F[0] - - -def _wrap_moffat(R, n, rd, i0): - return moffat_np(R, n, rd, 10 ** (i0)) - - -class Moffat_Galaxy(Galaxy_Model): +class Moffat_Galaxy(MoffatMixin, Galaxy_Model): """basic galaxy model with a Moffat profile for the radial light profile. The functional form of the Moffat profile is defined as: @@ -38,57 +26,14 @@ class Moffat_Galaxy(Galaxy_Model): """ - model_type = f"moffat {Galaxy_Model.model_type}" - parameter_specs = { - "n": {"units": "none", "limits": (0.1, 10), "uncertainty": 0.05}, - "Rd": {"units": "arcsec", "limits": (0, None)}, - "I0": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = Galaxy_Model._parameter_order + ("n", "Rd", "I0") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_moffat, ("n", "Rd", "I0"), _x0_func) - - @default_internal - def total_flux(self, parameters=None): - return moffat_I0_to_flux( - 10 ** parameters["I0"].value, - parameters["n"].value, - parameters["Rd"].value, - parameters["q"].value, - ) - - @default_internal - def total_flux_uncertainty(self, parameters=None): - return general_uncertainty_prop( - ( - 10 ** parameters["I0"].value, - parameters["n"].value, - parameters["Rd"].value, - parameters["q"].value, - ), - ( - (10 ** parameters["I0"].value) - * parameters["I0"].uncertainty - * torch.log(10 * torch.ones_like(parameters["I0"].value)), - parameters["n"].uncertainty, - parameters["Rd"].uncertainty, - parameters["q"].uncertainty, - ), - moffat_I0_to_flux, - ) - - from ._shared_methods import moffat_radial_model as radial_model - - -class Moffat_PSF(PSF_Model): + @forward + def total_flux(self, n, Rd, I0, q): + return moffat_I0_to_flux(I0, n, Rd, q) + + +class Moffat_PSF(MoffatMixin, PSF_Model): """basic point source model with a Moffat profile for the radial light profile. The functional form of the Moffat profile is defined as: @@ -106,86 +51,20 @@ class Moffat_PSF(PSF_Model): """ - model_type = f"moffat {PSF_Model.model_type}" - parameter_specs = { - "n": {"units": "none", "limits": (0.1, 10), "uncertainty": 0.05}, - "Rd": {"units": "arcsec", "limits": (0, None)}, - "I0": {"units": "log10(flux/arcsec^2)", "value": 0.0, "locked": True}, - } - _parameter_order = PSF_Model._parameter_order + ("n", "Rd", "I0") usable = True model_integrated = False - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_moffat, ("n", "Rd", "I0"), _x0_func) - - from ._shared_methods import moffat_radial_model as radial_model - - @default_internal - def total_flux(self, parameters=None): - return moffat_I0_to_flux( - 10 ** parameters["I0"].value, - parameters["n"].value, - parameters["Rd"].value, - torch.ones_like(parameters["n"].value), - ) - - @default_internal - def total_flux_uncertainty(self, parameters=None): - return general_uncertainty_prop( - ( - 10 ** parameters["I0"].value, - parameters["n"].value, - parameters["Rd"].value, - torch.ones_like(parameters["n"].value), - ), - ( - (10 ** parameters["I0"].value) - * parameters["I0"].uncertainty - * torch.log(10 * torch.ones_like(parameters["I0"].value)), - parameters["n"].uncertainty, - parameters["Rd"].uncertainty, - torch.zeros_like(parameters["n"].value), - ), - moffat_I0_to_flux, - ) - - from ._shared_methods import radial_evaluate_model as evaluate_model - - -class Moffat2D_PSF(Moffat_PSF): - - model_type = f"moffat2d {PSF_Model.model_type}" - parameter_specs = { - "q": {"units": "b/a", "limits": (0, 1), "uncertainty": 0.03}, - "PA": { - "units": "radians", - "limits": (0, np.pi), - "cyclic": True, - "uncertainty": 0.06, - }, - } - _parameter_order = Moffat_PSF._parameter_order + ("q", "PA") - usable = True - model_integrated = False + @forward + def total_flux(self, n, Rd, I0): + return moffat_I0_to_flux(I0, n, Rd, 1.0) + - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - with Param_Unlock(parameters["q"]), Param_SoftLimits(parameters["q"]): - if parameters["q"].value is None: - parameters["q"].value = 0.9 +class Moffat2D_PSF(InclinedMixin, Moffat_PSF): - with Param_Unlock(parameters["PA"]), Param_SoftLimits(parameters["PA"]): - if parameters["PA"].value is None: - parameters["PA"].value = 0.1 - super().initialize(target=target, parameters=parameters) + _model_type = "2d" + usable = True + model_integrated = False - from ._shared_methods import inclined_transform_coordinates as transform_coordinates - from ._shared_methods import transformed_evaluate_model as evaluate_model + @forward + def total_flux(self, n, Rd, I0, q): + return moffat_I0_to_flux(I0, n, Rd, q) diff --git a/astrophot/models/relspline_model.py b/astrophot/models/relspline_model.py deleted file mode 100644 index a7eb5f05..00000000 --- a/astrophot/models/relspline_model.py +++ /dev/null @@ -1,78 +0,0 @@ -from .galaxy_model_object import Galaxy_Model -from .psf_model_object import PSF_Model -from ..utils.decorators import default_internal - -__all__ = [ - "RelSpline_Galaxy", - "RelSpline_PSF", -] - - -# First Order -###################################################################### -class RelSpline_Galaxy(Galaxy_Model): - """Basic galaxy model with a spline radial light profile. The - light profile is defined as a cubic spline interpolation of the - stored brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I0: Central brightness - dI(R): Tensor of brighntess values relative to central brightness, represented as the log of the brightness divided by pixelscale squared - - """ - - model_type = f"relspline {Galaxy_Model.model_type}" - parameter_specs = { - "I0": {"units": "log10(flux/arcsec^2)"}, - "dI(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = Galaxy_Model._parameter_order + ("I0", "dI(R)") - usable = True - extend_profile = True - - from ._shared_methods import relspline_initialize as initialize - from ._shared_methods import relspline_radial_model as radial_model - - -class RelSpline_PSF(PSF_Model): - """point source model with a spline radial light profile. The light - profile is defined as a cubic spline interpolation of the stored - brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I0: Central brightness - dI(R): Tensor of brighntess values relative to central brightness, represented as the log of the brightness divided by pixelscale squared - - """ - - model_type = f"relspline {PSF_Model.model_type}" - parameter_specs = { - "I0": {"units": "log10(flux/arcsec^2)", "value": 0.0, "locked": True}, - "dI(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = PSF_Model._parameter_order + ("I0", "dI(R)") - usable = True - extend_profile = True - model_integrated = False - - @default_internal - def transform_coordinates(self, X=None, Y=None, image=None, parameters=None): - return X, Y - - from ._shared_methods import relspline_initialize as initialize - from ._shared_methods import relspline_radial_model as radial_model - from ._shared_methods import radial_evaluate_model as evaluate_model diff --git a/astrophot/models/sersic_model.py b/astrophot/models/sersic_model.py index 8a1ea4d1..a0718f13 100644 --- a/astrophot/models/sersic_model.py +++ b/astrophot/models/sersic_model.py @@ -1,29 +1,29 @@ -from caskade import forward - +from ..param import forward from .galaxy_model_object import Galaxy_Model -from .warp_model import Warp_Galaxy -from .ray_model import Ray_Galaxy -from .wedge_model import Wedge_Galaxy -from .psf_model_object import PSF_Model -from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp -from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp + +# from .warp_model import Warp_Galaxy +# from .ray_model import Ray_Galaxy +# from .wedge_model import Wedge_Galaxy +# from .psf_model_object import PSF_Model +# from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp +# from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp from ..utils.conversions.functions import sersic_Ie_to_flux_torch from .mixins import SersicMixin, RadialMixin, iSersicMixin __all__ = [ "Sersic_Galaxy", - "Sersic_PSF", - "Sersic_Warp", - "Sersic_SuperEllipse", - "Sersic_FourierEllipse", - "Sersic_Ray", - "Sersic_Wedge", - "Sersic_SuperEllipse_Warp", - "Sersic_FourierEllipse_Warp", + # "Sersic_PSF", + # "Sersic_Warp", + # "Sersic_SuperEllipse", + # "Sersic_FourierEllipse", + # "Sersic_Ray", + # "Sersic_Wedge", + # "Sersic_SuperEllipse_Warp", + # "Sersic_FourierEllipse_Warp", ] -class Sersic_Galaxy(SersicMixin, Galaxy_Model): +class Sersic_Galaxy(SersicMixin, RadialMixin, Galaxy_Model): """basic galaxy model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -49,182 +49,186 @@ def total_flux(self, Ie, n, Re, q): return sersic_Ie_to_flux_torch(Ie, n, Re, q) -class Sersic_PSF(SersicMixin, RadialMixin, PSF_Model): - """basic point source model with a sersic profile for the radial light - profile. The functional form of the Sersic profile is defined as: +# class Sersic_PSF(SersicMixin, RadialMixin, PSF_Model): +# """basic point source model with a sersic profile for the radial light +# profile. The functional form of the Sersic profile is defined as: - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) +# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius, and n is the sersic index +# which controls the shape of the profile. - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# Parameters: +# n: Sersic index which controls the shape of the brightness profile +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - """ +# """ - usable = True - model_integrated = False +# usable = True +# model_integrated = False +# @forward +# def total_flux(self, Ie, n, Re): +# return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) -class Sersic_SuperEllipse(SersicMixin, SuperEllipse_Galaxy): - """super ellipse galaxy model with a sersic profile for the radial - light profile. The functional form of the Sersic profile is defined as: - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) +# class Sersic_SuperEllipse(SersicMixin, SuperEllipse_Galaxy): +# """super ellipse galaxy model with a sersic profile for the radial +# light profile. The functional form of the Sersic profile is defined as: - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. +# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius, and n is the sersic index +# which controls the shape of the profile. - """ +# Parameters: +# n: Sersic index which controls the shape of the brightness profile +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - usable = True +# """ +# usable = True -class Sersic_SuperEllipse_Warp(SersicMixin, SuperEllipse_Warp): - """super ellipse warp galaxy model with a sersic profile for the - radial light profile. The functional form of the Sersic profile is - defined as: - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) +# class Sersic_SuperEllipse_Warp(SersicMixin, SuperEllipse_Warp): +# """super ellipse warp galaxy model with a sersic profile for the +# radial light profile. The functional form of the Sersic profile is +# defined as: - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. +# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius, and n is the sersic index +# which controls the shape of the profile. - """ +# Parameters: +# n: Sersic index which controls the shape of the brightness profile +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - usable = True +# """ +# usable = True -class Sersic_FourierEllipse(SersicMixin, FourierEllipse_Galaxy): - """fourier mode perturbations to ellipse galaxy model with a sersic - profile for the radial light profile. The functional form of the - Sersic profile is defined as: - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) +# class Sersic_FourierEllipse(SersicMixin, FourierEllipse_Galaxy): +# """fourier mode perturbations to ellipse galaxy model with a sersic +# profile for the radial light profile. The functional form of the +# Sersic profile is defined as: - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. +# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius, and n is the sersic index +# which controls the shape of the profile. - """ +# Parameters: +# n: Sersic index which controls the shape of the brightness profile +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - usable = True +# """ +# usable = True -class Sersic_FourierEllipse_Warp(SersicMixin, FourierEllipse_Warp): - """fourier mode perturbations to ellipse galaxy model with a sersic - profile for the radial light profile. The functional form of the - Sersic profile is defined as: - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) +# class Sersic_FourierEllipse_Warp(SersicMixin, FourierEllipse_Warp): +# """fourier mode perturbations to ellipse galaxy model with a sersic +# profile for the radial light profile. The functional form of the +# Sersic profile is defined as: - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. +# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius, and n is the sersic index +# which controls the shape of the profile. - """ +# Parameters: +# n: Sersic index which controls the shape of the brightness profile +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - usable = True +# """ +# usable = True -class Sersic_Warp(SersicMixin, Warp_Galaxy): - """warped coordinate galaxy model with a sersic profile for the radial - light model. The functional form of the Sersic profile is defined - as: - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) +# class Sersic_Warp(SersicMixin, Warp_Galaxy): +# """warped coordinate galaxy model with a sersic profile for the radial +# light model. The functional form of the Sersic profile is defined +# as: - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. +# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius, and n is the sersic index +# which controls the shape of the profile. - """ +# Parameters: +# n: Sersic index which controls the shape of the brightness profile +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - usable = True +# """ +# usable = True -class Sersic_Ray(iSersicMixin, Ray_Galaxy): - """ray galaxy model with a sersic profile for the radial light - model. The functional form of the Sersic profile is defined as: - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) +# class Sersic_Ray(iSersicMixin, Ray_Galaxy): +# """ray galaxy model with a sersic profile for the radial light +# model. The functional form of the Sersic profile is defined as: - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. +# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius, and n is the sersic index +# which controls the shape of the profile. - """ +# Parameters: +# n: Sersic index which controls the shape of the brightness profile +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - usable = True +# """ +# usable = True -class Sersic_Wedge(iSersicMixin, Wedge_Galaxy): - """wedge galaxy model with a sersic profile for the radial light - model. The functional form of the Sersic profile is defined as: - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) +# class Sersic_Wedge(iSersicMixin, Wedge_Galaxy): +# """wedge galaxy model with a sersic profile for the radial light +# model. The functional form of the Sersic profile is defined as: - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. +# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius, and n is the sersic index +# which controls the shape of the profile. - """ +# Parameters: +# n: Sersic index which controls the shape of the brightness profile +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - usable = True +# """ + +# usable = True diff --git a/astrophot/param/__init__.py b/astrophot/param/__init__.py new file mode 100644 index 00000000..6363f5a0 --- /dev/null +++ b/astrophot/param/__init__.py @@ -0,0 +1,5 @@ +from caskade import forward +from .module import Module +from .param import Param + +__all__ = ["Module", "Param", "forward"] diff --git a/astrophot/param/module.py b/astrophot/param/module.py new file mode 100644 index 00000000..761f0b34 --- /dev/null +++ b/astrophot/param/module.py @@ -0,0 +1,12 @@ +from caskade import Module as CModule + + +class Module(CModule): + + def build_params_array_identities(self): + identities = [] + for param in self.dynamic_params: + numel = max(1, np.prod(param.shape)) + for i in range(numel): + identities.append(f"{id(param)}_{i}") + return identities diff --git a/astrophot/param/param.py b/astrophot/param/param.py new file mode 100644 index 00000000..e04ae1c7 --- /dev/null +++ b/astrophot/param/param.py @@ -0,0 +1,24 @@ +from caskade import Param as CParam +import torch + + +class Param(CParam): + """ + A class that extends the Caskade Param class to include additional functionality. + This class is used to define parameters for models in the AstroPhot package. + """ + + def __init__(self, *args, uncertainty=None, **kwargs): + super().__init__(*args, **kwargs) + self.uncertainty = uncertainty + + @property + def uncertainty(self): + return self._uncertainty + + @uncertainty.setter + def uncertainty(self, uncertainty): + if uncertainty is None: + self._uncertainty = None + else: + self._uncertainty = torch.as_tensor(uncertainty) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 5775c628..234f6b61 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -6,7 +6,7 @@ import matplotlib from scipy.stats import iqr -from ..models import Group_Model, PSF_Model +# from ..models import Group_Model, PSF_Model from ..image import Image_List, Window_List from .. import AP_config from ..utils.conversions.units import flux_to_sb @@ -44,13 +44,11 @@ def target_image(fig, ax, target, window=None, **kwargs): return fig, ax if window is None: window = target.window - if kwargs.get("flipx", False): - ax.invert_xaxis() target_area = target[window] - dat = np.copy(target_area.data.detach().cpu().numpy()) + dat = np.copy(target_area.data.npvalue) if target_area.has_mask: dat[target_area.mask.detach().cpu().numpy()] = np.nan - X, Y = target_area.get_coordinate_corner_meshgrid() + X, Y = target_area.pixel_to_plane(*target_area.pixel_corner_meshgrid()) X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() sky = np.nanmedian(dat) @@ -168,9 +166,7 @@ def model_image( showcbar=True, target_mask=False, cmap_levels=None, - flipx=False, magunits=True, - sample_full_image=False, **kwargs, ): """ @@ -192,7 +188,6 @@ def model_image( cmap_levels (int, optional): The number of discrete levels to convert the continuous color map to. If not `None`, the color map is converted to a ListedColormap with the specified number of levels. Defaults to `None`. - sample_full_image: If True, every model will be sampled on the full image window. If False (default) each model will only be sampled in its fitting window. **kwargs: Arbitrary keyword arguments. These are used to override the default imshow_kwargs. Returns: @@ -205,11 +200,7 @@ def model_image( """ if sample_image is None: - if sample_full_image: - sample_image = model.make_model_image() - sample_image = model(sample_image) - else: - sample_image = model() + sample_image = model() # Use model target if not given if target is None: @@ -221,34 +212,30 @@ def model_image( # Handle image lists if isinstance(sample_image, Image_List): - for i, images in enumerate(zip(sample_image, target, window)): + for i, (images, targets, windows) in enumerate(zip(sample_image, target, window)): model_image( fig, ax[i], model, - sample_image=images[0], - window=images[2], - target=images[1], + sample_image=images, + window=windows, + target=targets, showcbar=showcbar, target_mask=target_mask, cmap_levels=cmap_levels, - flipx=flipx, magunits=magunits, **kwargs, ) return fig, ax - if flipx: - ax.invert_xaxis() - # cut out the requested window sample_image = sample_image[window] # Evaluate the model image - X, Y = sample_image.get_coordinate_corner_meshgrid() + X, Y = sample_image.pixel_corner_meshgrid() X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() - sample_image = sample_image.data.detach().cpu().numpy() + sample_image = sample_image.data.npvalue # Default kwargs for image imshow_kwargs = { diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index a1c635b0..6d33c30a 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -5,7 +5,8 @@ from scipy.stats import binned_statistic, iqr from .. import AP_config -from ..models import Warp_Galaxy + +# from ..models import Warp_Galaxy from ..utils.conversions.units import flux_to_sb from .visuals import * from ..errors import InvalidModel @@ -15,7 +16,7 @@ "radial_median_profile", "ray_light_profile", "wedge_light_profile", - "warp_phase_profile", + # "warp_phase_profile", ] @@ -70,7 +71,7 @@ def radial_light_profile( def radial_median_profile( fig, ax, - model: "AstroPhot_Model", + model: "Model", count_limit: int = 10, return_profile: bool = False, rad_unit: str = "arcsec", @@ -235,29 +236,29 @@ def wedge_light_profile( return fig, ax -def warp_phase_profile(fig, ax, model, rad_unit="arcsec", doassert=True): - if doassert: - if not isinstance(model, Warp_Galaxy): - raise InvalidModel( - f"warp_phase_profile must be given a 'Warp_Galaxy' object. Not {type(model)}" - ) +# def warp_phase_profile(fig, ax, model, rad_unit="arcsec", doassert=True): +# if doassert: +# if not isinstance(model, Warp_Galaxy): +# raise InvalidModel( +# f"warp_phase_profile must be given a 'Warp_Galaxy' object. Not {type(model)}" +# ) - ax.plot( - model.profR, - model["q(R)"].value.detach().cpu().numpy(), - linewidth=2, - color=main_pallet["primary1"], - label=f"{model.name} axis ratio", - ) - ax.plot( - model.profR, - model["PA(R)"].detach().cpu().numpy() / np.pi, - linewidth=2, - color=main_pallet["secondary1"], - label=f"{model.name} position angle", - ) - ax.set_ylim([0, 1]) - ax.set_ylabel("q [b/a], PA [rad/$\\pi$]") - ax.set_xlabel(f"Radius [{rad_unit}]") +# ax.plot( +# model.profR, +# model["q(R)"].value.detach().cpu().numpy(), +# linewidth=2, +# color=main_pallet["primary1"], +# label=f"{model.name} axis ratio", +# ) +# ax.plot( +# model.profR, +# model["PA(R)"].detach().cpu().numpy() / np.pi, +# linewidth=2, +# color=main_pallet["secondary1"], +# label=f"{model.name} position angle", +# ) +# ax.set_ylim([0, 1]) +# ax.set_ylabel("q [b/a], PA [rad/$\\pi$]") +# ax.set_xlabel(f"Radius [{rad_unit}]") - return fig, ax +# return fig, ax diff --git a/astrophot/plots/shared_elements.py b/astrophot/plots/shared_elements.py deleted file mode 100644 index 9751f757..00000000 --- a/astrophot/plots/shared_elements.py +++ /dev/null @@ -1,111 +0,0 @@ -import numpy as np -import matplotlib.pyplot as plt -from astropy.visualization.mpl_normalize import ImageNormalize -from astropy.visualization import LogStretch, HistEqStretch - - -def LSBImage(dat, noise): - plt.figure(figsize=(6, 6)) - plt.imshow( - dat, - origin="lower", - cmap="Greys", - norm=ImageNormalize( - stretch=HistEqStretch(dat[dat <= 3 * noise]), - clip=False, - vmax=3 * noise, - vmin=np.min(dat), - ), - ) - my_cmap = copy(cm.Greys_r) - my_cmap.set_under("k", alpha=0) - - plt.imshow( - np.ma.masked_where(dat < 3 * noise, dat), - origin="lower", - cmap=my_cmap, - norm=ImageNormalize(stretch=LogStretch(), clip=False), - clim=[3 * noise, None], - interpolation="none", - ) - plt.xticks([]) - plt.yticks([]) - plt.subplots_adjust(left=0.03, right=0.97, top=0.97, bottom=0.05) - plt.xlim([0, dat.shape[1]]) - plt.ylim([0, dat.shape[0]]) - - -def _display_time(seconds): - intervals = ( - ("hours", 3600), # 60 * 60 - ("arcminutes", 60), - ("arcseconds", 1), - ) - result = [] - - for name, count in intervals: - value = seconds // count - if value: - seconds -= value * count - if value == 1: - name = name.rstrip("s") - result.append("{} {}".format(value, name)) - return ", ".join(result) - - -def AddScale(ax, img_width, loc="lower right"): - """ - ax: figure axis object - img_width: image width in arcseconds - loc: location to put the scale bar - """ - scale_width = int(img_width / 6) - - if scale_width > 60 and scale_width % 60 <= 15: - scale_width -= scale_width % 60 - if scale_width > 45 and scale_width % 60 >= 45: - scale_width += 60 - (scale_width % 60) - if 15 < scale_width % 60 < 45: - scale_width += 30 - (scale_width % 60) - - label = _display_time(scale_width) - - xloc = 0.05 if "left" in loc else 0.95 - yloc = 0.95 if "upper" in loc else 0.05 - - ax.text( - xloc - 0.5 * scale_width / img_width, - yloc + 0.005, - label, - horizontalalignment="center", - verticalalignment="bottom", - transform=ax.transAxes, - fontsize="x-small" if len(label) < 20 else "xx-small", - weight="bold", - color=autocolours["red1"], - ) - ax.plot( - [xloc - scale_width / img_width, xloc], - [yloc, yloc], - transform=ax.transAxes, - color=autocolours["red1"], - ) - - -def AddLogo(fig, loc=[0.8, 0.01, 0.844 / 5, 0.185 / 5], white=False): - im = plt.imread( - get_sample_data( - os.path.join( - os.environ["AUTOPROF"], - "_static/", - ("AP_logo_white.png" if white else "AP_logo.png"), - ) - ) - ) - newax = fig.add_axes(loc, zorder=1000) - if white: - newax.imshow(np.zeros(im.shape) + np.array([0, 0, 0, 1])) - else: - newax.imshow(np.ones(im.shape)) - newax.imshow(im) - newax.axis("off") diff --git a/astrophot/utils/integration.py b/astrophot/utils/integration.py index e69de29b..eb124cc5 100644 --- a/astrophot/utils/integration.py +++ b/astrophot/utils/integration.py @@ -0,0 +1,33 @@ +from functools import lru_cache + +from scipy.special import roots_legendre +import torch + + +@lru_cache(maxsize=32) +def quad_table(order, dtype, device): + """ + Generate a meshgrid for quadrature points using Legendre-Gauss quadrature. + + Parameters + ---------- + n : int + The number of quadrature points in each dimension. + dtype : torch.dtype + The desired data type of the tensor. + device : torch.device + The device on which to create the tensor. + + Returns + ------- + Tuple[torch.Tensor, torch.Tensor, torch.Tensor] + The generated meshgrid as a tuple of Tensors. + """ + abscissa, weights = roots_legendre(order) + + w = torch.tensor(weights, dtype=dtype, device=device) + a = torch.tensor(abscissa, dtype=dtype, device=device) / 2.0 + di, dj = torch.meshgrid(a, a, indexing="xy") + + w = torch.outer(w, w) / 4.0 + return di, dj, w diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index cd14a6fb..fa09c7f0 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -45,17 +45,15 @@ "metadata": {}, "outputs": [], "source": [ - "model1 = ap.models.AstroPhot_Model(\n", + "model1 = ap.models.Model(\n", " name=\"model1\", # every model must have a unique name\n", " model_type=\"sersic galaxy model\", # this specifies the kind of model\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"n\": 2,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " }, # here we set initial values for each parameter\n", + " center=[50, 50], # here we set initial values for each parameter\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=2,\n", + " Re=10,\n", + " Ie=1,\n", " target=ap.image.Target_Image(\n", " data=np.zeros((100, 100)), zeropoint=22.5, pixelscale=1.0\n", " ), # every model needs a target, more on this later\n", @@ -63,7 +61,7 @@ "model1.initialize() # before using the model it is good practice to call initialize so the model can get itself ready\n", "\n", "# We can print the model's current state\n", - "model1.parameters" + "print(model1)" ] }, { @@ -123,7 +121,7 @@ "outputs": [], "source": [ "# This model now has a target that it will attempt to match\n", - "model2 = ap.models.AstroPhot_Model(\n", + "model2 = ap.models.Model(\n", " name=\"model with target\",\n", " model_type=\"sersic galaxy model\", # feel free to swap out sersic with other profile types\n", " target=target, # now the model knows what its trying to match\n", From ec6f7cd412c42419ca079ff07ba2672e42349557 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 18 Jun 2025 13:35:13 -0400 Subject: [PATCH 022/191] working to get LM online --- astrophot/fit/__init__.py | 3 +- astrophot/fit/base.py | 11 +- astrophot/fit/func/__init__.py | 3 + astrophot/fit/func/lm.py | 2 +- astrophot/fit/lm.py | 291 +++++------------------- astrophot/image/image_object.py | 32 +-- astrophot/image/target_image.py | 8 +- astrophot/image/window.py | 4 + astrophot/models/_shared_methods.py | 8 +- astrophot/models/func/integration.py | 2 +- astrophot/models/galaxy_model_object.py | 7 +- astrophot/models/mixins/sample.py | 4 +- astrophot/models/model_object.py | 4 +- astrophot/param/__init__.py | 4 +- astrophot/param/module.py | 1 + astrophot/plots/image.py | 71 +++--- 16 files changed, 157 insertions(+), 298 deletions(-) create mode 100644 astrophot/fit/func/__init__.py diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index fe88b755..483487a0 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,5 +1,6 @@ # from .base import * -# from .lm import * +from .lm import * + # from .gradient import * # from .iterative import * # from .minifit import * diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index de916c77..4fe40882 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -8,6 +8,7 @@ from .. import AP_config from ..models import Model from ..image import Window +from ..param import ValidContext __all__ = ["BaseOptimizer"] @@ -60,12 +61,20 @@ def __init__( self.model = model self.verbose = kwargs.get("verbose", 0) + if initial_state is None: + with ValidContext(model): + self.current_state = model.build_params_array() + else: + self.current_state = torch.as_tensor( + initial_state, dtype=model.dtype, device=model.device + ) + if fit_window is None: self.fit_window = self.model.window else: self.fit_window = fit_window & self.model.window - self.max_iter = kwargs.get("max_iter", 100 * len(initial_state)) + self.max_iter = kwargs.get("max_iter", 100 * len(self.current_state)) self.iteration = 0 self.save_steps = kwargs.get("save_steps", None) diff --git a/astrophot/fit/func/__init__.py b/astrophot/fit/func/__init__.py new file mode 100644 index 00000000..00087be4 --- /dev/null +++ b/astrophot/fit/func/__init__.py @@ -0,0 +1,3 @@ +from .lm import lm_step + +__all__ = ["lm_step"] diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 7b78f4f2..c1a0c69d 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -18,7 +18,7 @@ def damp_hessian(hess, L): return hess * (I + D / (1 + L)) + L * I * (1 + torch.diag(hess)) -def step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): +def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): chi20 = chi2 M0 = model(x) diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index f900f52a..3069e1d1 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -1,13 +1,13 @@ # Levenberg-Marquardt algorithm from typing import Sequence -from functools import partial import torch -import numpy as np from .base import BaseOptimizer from .. import AP_config +from . import func from ..errors import OptimizeStop +from ..param import ValidContext __all__ = ("LM",) @@ -158,6 +158,10 @@ def __init__( initial_state: Sequence = None, max_iter: int = 100, relative_tolerance: float = 1e-5, + Lup=11.0, + Ldn=9.0, + L0=1.0, + max_step_iter: int = 10, ndf=None, **kwargs, ): @@ -169,38 +173,16 @@ def __init__( relative_tolerance=relative_tolerance, **kwargs, ) - # The forward model which computes the output image given input parameters - self.forward = partial(model, as_representation=True) - # Compute the jacobian in representation units (defined for -inf, inf) - self.jacobian = partial(model.jacobian, as_representation=True) - self.jacobian_natural = partial(model.jacobian, as_representation=False) + # Maximum number of iterations of the algorithm self.max_iter = max_iter # Maximum number of steps while searching for chi^2 improvement on a single jacobian evaluation - self.max_step_iter = kwargs.get("max_step_iter", 10) - # sets how cautious the optimizer is for changing curvature, should be number greater than 0, where smaller is more cautious - self.curvature_limit = kwargs.get("curvature_limit", 1.0) + self.max_step_iter = max_step_iter # These are the adjustment step sized for the damping parameter - self._Lup = kwargs.get("Lup", 11.0) - self._Ldn = kwargs.get("Ldn", 9.0) + self.Lup = Lup + self.Ldn = Ldn # This is the starting damping parameter, for easy problems with good initialization, this can be set lower - self.L = kwargs.get("L0", 1.0) - # Geodesic acceleration is helpful in some scenarios. By default it is turned off. Set 1 for full acceleration, 0 for no acceleration. - self.acceleration = kwargs.get("acceleration", 0.0) - # Initialize optimizer attributes - self.Y = self.model.target[self.fit_window].flatten("data") - - # 1 / (sigma^2) - kW = kwargs.get("W", None) - if kW is not None: - self.W = torch.as_tensor( - kW, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ).flatten() - elif model.target.has_variance: - self.W = self.model.target[self.fit_window].flatten("weight") - else: - self.W = torch.ones_like(self.Y) - + self.L = L0 # mask fit_mask = self.model.fit_mask() if isinstance(fit_mask, tuple): @@ -222,208 +204,39 @@ def __init__( if self.mask is not None and torch.sum(self.mask).item() == 0: raise OptimizeStop("No data to fit. All pixels are masked") + # Initialize optimizer attributes + self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] + + # 1 / (sigma^2) + kW = kwargs.get("W", None) + if kW is not None: + self.W = torch.as_tensor( + kW, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ).flatten()[self.mask] + elif model.target.has_variance: + self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] + else: + self.W = torch.ones_like(self.Y) + + # The forward model which computes the output image given input parameters + self.forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[self.mask] + # Compute the jacobian in representation units (defined for -inf, inf) + self.jacobian = lambda x: model.jacobian(window=self.fit_window, params=x).flatten("data")[ + self.mask + ] + # variable to store covariance matrix if it is ever computed self._covariance_matrix = None # Degrees of freedom if ndf is None: - if self.mask is None: - self.ndf = max(1.0, len(self.Y) - len(self.current_state)) - else: - self.ndf = max(1.0, torch.sum(self.mask).item() - len(self.current_state)) + self.ndf = max(1.0, len(self.Y) - len(self.current_state)) else: self.ndf = ndf - def Lup(self): - """ - Increases the damping parameter for more gradient-like steps. Used internally. - """ - self.L = min(1e9, self.L * self._Lup) - - def Ldn(self): - """ - Decreases the damping parameter for more Gauss-Newton like steps. Used internally. - """ - self.L = max(1e-9, self.L / self._Ldn) - - @torch.no_grad() - def step(self, chi2) -> torch.Tensor: - """Performs one step of the LM algorithm. Computes Jacobian, infers - hessian and gradient, solves for step vector and iterates on - damping parameter magnitude until a step with some improvement - in chi2 is found. Used internally. - - """ - Y0 = self.forward(parameters=self.current_state).flatten("data") - J = self.jacobian(parameters=self.current_state).flatten("data") - r = self._r(Y0, self.Y, self.W) - self.hess = self._hess(J, self.W) - self.grad = self._grad(J, self.W, Y0, self.Y) - init_chi2 = chi2 - nostep = True - best = (torch.zeros_like(self.current_state), init_chi2, self.L) - scarry_best = (None, init_chi2, self.L) - direction = "none" - iteration = 0 - d = 0.1 - for iteration in range(self.max_step_iter): - # In a scenario where LM is having a hard time proposing a good step, but the damping is really low, just jump up to normal damping levels - if iteration > self.max_step_iter / 2 and self.L < 1e-3: - self.L = 1.0 - - # compute LM update step - h = self._h(self.L, self.grad, self.hess) - - # Compute goedesic acceleration - Y1 = self.forward(parameters=self.current_state + d * h).flatten("data") - - rh = self._r(Y1, self.Y, self.W) - - rpp = self._rpp(J, d, rh - r, self.W, h) - - if self.L > 1e-4: - a = -self._h(self.L, rpp, self.hess) / 2 - else: - a = torch.zeros_like(h) - - # Evaluate new step - ha = h + a * self.acceleration - Y1 = self.forward(parameters=self.current_state + ha).flatten("data") - - # Compute and report chi^2 - chi2 = self._chi2(Y1.detach()).item() - if self.verbose > 1: - AP_config.ap_logger.info(f"sub step L: {self.L}, Chi^2/DoF: {chi2}") - - # Skip if chi^2 is nan - if not np.isfinite(chi2): - if self.verbose > 1: - AP_config.ap_logger.info("Skip due to non-finite values") - self.Lup() - if direction == "better": - break - direction = "worse" - continue - - # Keep track of chi^2 improvement even if it fails curvature test - if chi2 <= scarry_best[1]: - scarry_best = (ha, chi2, self.L) - - # Check for high curvature, in which case linear approximation is not valid. avoid this step - rho = torch.linalg.norm(a) / torch.linalg.norm(h) - if rho > self.curvature_limit: - if self.verbose > 1: - AP_config.ap_logger.info("Skip due to large curvature") - self.Lup() - if direction == "better": - break - direction = "worse" - continue - - # Check for Chi^2 improvement - if chi2 < best[1]: - if self.verbose > 1: - AP_config.ap_logger.info("new best chi^2") - best = (ha, chi2, self.L) - nostep = False - self.Ldn() - if self.L <= 1e-8 or direction == "worse": - break - direction = "better" - elif chi2 > best[1] and direction in ["none", "worse"]: - if self.verbose > 1: - AP_config.ap_logger.info("chi^2 is worse") - self.Lup() - if self.L == 1e9: - break - direction = "worse" - else: - break - - # If a step substantially improves the chi^2, stop searching for better step, simply exit the loop and accept the good step - if (best[1] - init_chi2) / init_chi2 < -0.1: - if self.verbose > 1: - AP_config.ap_logger.info("Large step taken, ending search for good step") - break - - if nostep: - if scarry_best[0] is not None: - if self.verbose > 1: - AP_config.ap_logger.warning( - "no low curvature step found, taking high curvature step" - ) - return scarry_best - raise OptimizeStop("Could not find step to improve chi^2") - - return best - - @staticmethod - @torch.no_grad() - def _h(L, grad, hess) -> torch.Tensor: - I = torch.eye(len(grad), dtype=grad.dtype, device=grad.device) - D = torch.ones_like(hess) - I - # Alternate damping scheme - # (hess + 1e-2 * L**2 * I) * (1 + L**2 * I) ** 2 / (1 + L**2), - h = torch.linalg.solve( - hess * (I + D / (1 + L)) + L * I * (1 + torch.diag(hess)), - grad, - ) - - return h - - @torch.no_grad() - def _chi2(self, Ypred) -> torch.Tensor: - if self.mask is None: - return torch.sum(self.W * (self.Y - Ypred) ** 2) / self.ndf - else: - return torch.sum((self.W * (self.Y - Ypred) ** 2)[self.mask]) / self.ndf - - @torch.no_grad() - def _r(self, Y, Ypred, W) -> torch.Tensor: - if self.mask is None: - return W * (Y - Ypred) - else: - return W[self.mask] * (Y[self.mask] - Ypred[self.mask]) - - @torch.no_grad() - def _hess(self, J, W) -> torch.Tensor: - if self.mask is None: - return J.T @ (W.view(len(W), -1) * J) - else: - return J[self.mask].T @ (W[self.mask].view(len(W[self.mask]), -1) * J[self.mask]) - - @torch.no_grad() - def _grad(self, J, W, Y, Ypred) -> torch.Tensor: - if self.mask is None: - return -J.T @ self._r(Y, Ypred, W) - else: - return -J[self.mask].T @ self._r(Y, Ypred, W) - - @torch.no_grad() - def _rpp(self, J, d, dr, W, h): - if self.mask is None: - return J.T @ ((2 / d) * ((dr / d - W * (J @ h)))) - else: - return J[self.mask].T @ ((2 / d) * ((dr / d - W[self.mask] * (J[self.mask] @ h)))) - - @torch.no_grad() - def update_hess_grad(self, natural=False) -> None: - """Updates the stored hessian matrix and gradient vector. This can be - used to compute the quantities in their natural parameter - representation. During normal optimization the hessian and - gradient are computed in a re-mapped parameter space where - parameters are defined form -inf to inf. - - """ - if natural: - J = self.jacobian_natural( - parameters=self.model.parameters.vector_transform_rep_to_val(self.current_state) - ).flatten("data") - else: - J = self.jacobian(parameters=self.current_state).flatten("data") - Ypred = self.forward(parameters=self.current_state).flatten("data") - self.hess = self._hess(J, self.W) - self.grad = self._grad(J, self.W, self.Y, Ypred) + def chi2_ndf(self): + with ValidContext(self.model): + return torch.sum(self.W * (self.Y - self.forward(self.current_state)) ** 2) / self.ndf @torch.no_grad() def fit(self) -> BaseOptimizer: @@ -442,31 +255,39 @@ def fit(self) -> BaseOptimizer: return self self._covariance_matrix = None - self.loss_history = [ - self._chi2(self.forward(parameters=self.current_state).flatten("data")).item() - ] + self.loss_history = [self.chi2_ndf().item()] self.L_history = [self.L] self.lambda_history = [self.current_state.detach().clone().cpu().numpy()] - for iteration in range(self.max_iter): + for _ in range(self.max_iter): if self.verbose > 0: AP_config.ap_logger.info(f"Chi^2/DoF: {self.loss_history[-1]}, L: {self.L}") try: - res = self.step(chi2=self.loss_history[-1]) + with ValidContext(self.model): + res = func.lm_step( + x=self.current_state, + data=self.Y, + model=self.forward, + weight=self.W, + jacobian=self.jacobian, + ndf=self.ndf, + chi2=self.chi2_ndf(), + L=self.L, + Lup=self.Lup, + Ldn=self.Ldn, + ) except OptimizeStop: if self.verbose > 0: AP_config.ap_logger.warning("Could not find step to improve Chi^2, stopping") self.message = self.message + "fail. Could not find step to improve Chi^2" break - self.L = res[2] - self.current_state = (self.current_state + res[0]).detach() + self.L = res["L"] + self.current_state = (self.current_state + res["h"]).detach() self.L_history.append(self.L) - self.loss_history.append(res[1]) + self.loss_history.append(res["chi2"]) self.lambda_history.append(self.current_state.detach().clone().cpu().numpy()) - self.Ldn() - if len(self.loss_history) >= 3: if (self.loss_history[-3] - self.loss_history[-1]) / self.loss_history[ -1 @@ -489,7 +310,9 @@ def fit(self) -> BaseOptimizer: AP_config.ap_logger.info( f"Final Chi^2/DoF: {self.loss_history[-1]}, L: {self.L_history[-1]}. Converged: {self.message}" ) - self.model.parameters.vector_set_representation(self.res()) + + with ValidContext(self.model): + self.model.fill_dynamic_values(self.current_state) return self diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 80460a5c..dac82132 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -212,25 +212,21 @@ def world_to_plane(self, ra, dec, crval, crtan): return func.world_to_plane_gnomonic(ra, dec, *crval, *crtan) @forward - def world_to_pixel(self, ra, dec=None): + def world_to_pixel(self, ra, dec): """A wrapper which applies :meth:`world_to_plane` then :meth:`plane_to_pixel`, see those methods for further information. """ - if dec is None: - ra, dec = ra[0], ra[1] return self.plane_to_pixel(*self.world_to_plane(ra, dec)) @forward - def pixel_to_world(self, i, j=None): + def pixel_to_world(self, i, j): """A wrapper which applies :meth:`pixel_to_plane` then :meth:`plane_to_world`, see those methods for further information. """ - if j is None: - i, j = i[0], i[1] return self.plane_to_world(*self.pixel_to_plane(i, j)) def pixel_center_meshgrid(self): @@ -356,6 +352,8 @@ def crop(self, pixels, **kwargs): return self.copy(data=data, crpix=crpix, **kwargs) def flatten(self, attribute: str = "data") -> np.ndarray: + if attribute in self.children: + return getattr(self, attribute).value.reshape(-1) return getattr(self, attribute).reshape(-1) def reduce(self, scale: int, **kwargs): @@ -426,16 +424,22 @@ def get_indices(self, other: Union[Window, "Image"]): max(0, min(other.j_high - shift[1], self.shape[1])), ) - origin_pix = torch.round(self.plane_to_pixel(other.pixel_to_plane(-0.5, -0.5)) + 0.5).int() + origin_pix = torch.tensor( + (-0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + origin_pix = self.plane_to_pixel(*other.pixel_to_plane(*origin_pix)) + origin_pix = torch.round(torch.stack(origin_pix) + 0.5).int() new_origin_pix = torch.maximum(torch.zeros_like(origin_pix), origin_pix) - end_pix = torch.round( - self.plane_to_pixel( - other.pixel_to_plane(other.data.shape[0] - 0.5, other.data.shape[1] - 0.5) - ) - + 0.5 - ).int() - new_end_pix = torch.minimum(self.data.shape, end_pix) + end_pix = torch.tensor( + (other.data.shape[0] - 0.5, other.data.shape[1] - 0.5), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + end_pix = self.plane_to_pixel(*other.pixel_to_plane(*end_pix)) + end_pix = torch.round(torch.stack(end_pix) + 0.5).int() + shape = torch.tensor(self.data.shape, dtype=torch.int32, device=AP_config.ap_device) + new_end_pix = torch.minimum(shape, end_pix) return slice(new_origin_pix[1], new_end_pix[1]), slice(new_origin_pix[0], new_end_pix[0]) def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index e8a46e36..b6d079cf 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -379,8 +379,8 @@ def jacobian_image( device=AP_config.ap_device, ) copy_kwargs = { - "pixelscale": self.pixelscale.value, - "crpix": self.crpix.value, + "pixelscale": self.pixelscale, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -399,8 +399,8 @@ def model_image(self, **kwargs): """ copy_kwargs = { "data": torch.zeros_like(self.data.value), - "pixelscale": self.pixelscale.value, - "crpix": self.crpix.value, + "pixelscale": self.pixelscale, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, diff --git a/astrophot/image/window.py b/astrophot/image/window.py index b26c9ac6..a5404b6b 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -33,6 +33,10 @@ def __init__( def identity(self): return self.image.identity + @property + def shape(self): + return (self.i_high - self.i_low, self.j_high - self.j_low) + def chunk(self, chunk_size: int): # number of pixels on each axis px = self.i_high - self.i_low diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 3a1ec9ef..e4335ba5 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -9,7 +9,7 @@ from .. import AP_config -def _sample_image(image, transform): +def _sample_image(image, transform, rad_bins=None): dat = image.data.npvalue.copy() # Fill masked pixels if image.has_mask: @@ -19,9 +19,9 @@ def _sample_image(image, transform): edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) dat -= np.median(edge) # Get the radius of each pixel relative to object center - x, y = transform(*image.coordinate_center_meshgrid()) + x, y = transform(*image.coordinate_center_meshgrid(), params=()) - R = torch.sqrt(x**2 + y**2).detach().cpu().numpy() + R = torch.sqrt(x**2 + y**2).detach().cpu().numpy().flatten() # Bin fluxes by radius if rad_bins is None: @@ -94,7 +94,7 @@ def optim(x, r, f, u): reses.append(minimize(optim, x0=x0, args=(R[N], I[N], S[N]), method="Nelder-Mead")) for param, x0x in zip(params, x0): if model[param].value is None: - model[param].value = x0x + model[param].dynamic_value = x0x if model[param].uncertainty is None: model[param].uncertainty = np.std( list(subres.x[params.index(param)] for subres in reses) diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index 3cf32eb8..f120da48 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -97,6 +97,6 @@ def recursive_quad_integrate( gridding=gridding, _current_depth=_current_depth + 1, max_depth=max_depth, - ).sum(dim=-1) + ).mean(dim=-1) return integral diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index 6dd792ab..bdff17bc 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -60,13 +60,12 @@ def initialize(self, **kwargs): icenter = target_area.plane_to_pixel(*self.center.value) i, j = target_area.pixel_center_meshgrid() i, j = (i - icenter[0]).detach().cpu().numpy(), (j - icenter[1]).detach().cpu().numpy() - mu20 = np.sum(target_dat * i**2) + mu20 = np.sum(target_dat * i**2) # fixme try median? mu02 = np.sum(target_dat * j**2) mu11 = np.sum(target_dat * i * j) M = np.array([[mu20, mu11], [mu11, mu02]]) if self.PA.value is None: - self.PA.value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi + self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi if self.q.value is None: - print(M) l = np.sort(np.linalg.eigvals(M)) - self.q.value = np.sqrt(l[1] / l[0]) + self.q.dynamic_value = max(0.1, np.sqrt(l[0] / l[1])) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 4dfb2f50..a30b40da 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -87,7 +87,7 @@ def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_p return jacobian( lambda x: self.sample( window=window, params=torch.cat((params_pre, x, params_post), dim=-1) - ).data, + ).data.value, params, strategy="forward-mode", vectorize=True, @@ -104,7 +104,7 @@ def jacobian( window = self.window if params is not None: - self.fill_dynamic_params(params) + self.fill_dynamic_values(params) if pass_jacobian is None: jac_img = self.target[window].jacobian_image( diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 4e753c48..862162f7 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -151,7 +151,7 @@ def initialize( # Use center of window if a center hasn't been set yet if self.center.value is None: - self.center.value = target_area.center + self.center.dynamic_value = target_area.center else: return @@ -166,7 +166,7 @@ def initialize( COM_center = target_area.pixel_to_plane( *torch.tensor(COM, dtype=AP_config.ap_dtype, device=AP_config.ap_device) ) - self.center.value = COM_center + self.center.dynamic_value = COM_center def fit_mask(self): return torch.zeros_like(self.target[self.window].mask, dtype=torch.bool) diff --git a/astrophot/param/__init__.py b/astrophot/param/__init__.py index 6363f5a0..1de02ba6 100644 --- a/astrophot/param/__init__.py +++ b/astrophot/param/__init__.py @@ -1,5 +1,5 @@ -from caskade import forward +from caskade import forward, ValidContext from .module import Module from .param import Param -__all__ = ["Module", "Param", "forward"] +__all__ = ["Module", "Param", "forward", "ValidContext"] diff --git a/astrophot/param/module.py b/astrophot/param/module.py index 761f0b34..7d3dacbd 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -1,3 +1,4 @@ +import numpy as np from caskade import Module as CModule diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 234f6b61..64a5f70a 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -1,3 +1,4 @@ +from typing import Literal import numpy as np import torch @@ -290,11 +291,9 @@ def residual_image( sample_image=None, showcbar=True, window=None, - center_residuals=False, clb_label=None, normalize_residuals=False, - flipx=False, - sample_full_image=False, + scaling: Literal["arctan", "clip", "none"] = "arctan", **kwargs, ): """ @@ -336,11 +335,7 @@ def residual_image( if target is None: target = model.target if sample_image is None: - if sample_full_image: - sample_image = model.make_model_image() - sample_image = model(sample_image) - else: - sample_image = model() + sample_image = model() if isinstance(window, Window_List) or isinstance(target, Image_List): for i_ax, win, tar, sam in zip(ax, window, target, sample_image): residual_image( @@ -351,37 +346,61 @@ def residual_image( sample_image=sam, window=win, showcbar=showcbar, - center_residuals=center_residuals, clb_label=clb_label, normalize_residuals=normalize_residuals, - flipx=flipx, **kwargs, ) return fig, ax - if flipx: - ax.invert_xaxis() - X, Y = sample_image[window].get_coordinate_corner_meshgrid() + sample_image = sample_image[window] + target = target[window] + X, Y = sample_image.coordinate_corner_meshgrid() X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() - residuals = (target[window] - sample_image[window]).data - if isinstance(normalize_residuals, bool) and normalize_residuals: - residuals = residuals / torch.sqrt(target[window].variance) + residuals = (target - sample_image).data.value + if normalize_residuals is True: + residuals = residuals / torch.sqrt(target.variance) elif isinstance(normalize_residuals, torch.Tensor): residuals = residuals / torch.sqrt(normalize_residuals) normalize_residuals = True + if target.has_mask: + residuals[target.mask] = np.nan residuals = residuals.detach().cpu().numpy() - if target.has_mask: - residuals[target[window].mask.detach().cpu().numpy()] = np.nan - if center_residuals: - residuals -= np.nanmedian(residuals) - residuals = np.arctan(residuals / (iqr(residuals[np.isfinite(residuals)], rng=[10, 90]) * 2)) - extreme = np.max(np.abs(residuals[np.isfinite(residuals)])) + if scaling == "clip": + if normalize_residuals is not True: + AP_config.logger.warning( + "Using clipping scaling without normalizing residuals. This may lead to confusing results." + ) + residuals = np.clip(residuals, -5, 5) + vmax = 5 + default_label = ( + f"(Target - {model.name}) / $\\sigma$" + if normalize_residuals + else f"(Target - {model.name})" + ) + elif scaling == "arctan": + residuals = np.arctan( + residuals / (iqr(residuals[np.isfinite(residuals)], rng=[10, 90]) * 2) + ) + vmax = np.max(np.abs(residuals[np.isfinite(residuals)])) + if normalize_residuals: + default_label = f"tan$^{{-1}}$((Target - {model.name}) / $\\sigma$)" + else: + default_label = f"tan$^{{-1}}$(Target - {model.name})" + elif scaling == "none": + vmax = np.max(np.abs(residuals[np.isfinite(residuals)])) + default_label = ( + f"(Target - {model.name}) / $\\sigma$" + if normalize_residuals + else f"(Target - {model.name})" + ) + else: + raise ValueError(f"Unknown scaling type {scaling}. Use 'clip', 'arctan', or 'none'.") imshow_kwargs = { "cmap": cmap_div, - "vmin": -extreme, - "vmax": extreme, + "vmin": -vmax, + "vmax": vmax, } imshow_kwargs.update(kwargs) im = ax.pcolormesh(X, Y, residuals, **imshow_kwargs) @@ -390,10 +409,6 @@ def residual_image( ax.set_ylabel("Tangent Plane Y [arcsec]") if showcbar: - if normalize_residuals: - default_label = f"tan$^{{-1}}$((Target - {model.name}) / $\\sigma$)" - else: - default_label = f"tan$^{{-1}}$(Target - {model.name})" clb = fig.colorbar(im, ax=ax, label=default_label if clb_label is None else clb_label) clb.ax.set_yticks([]) clb.ax.set_yticklabels([]) From 578a1757842204689b444a890ae37f6efe5de703 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 18 Jun 2025 23:05:44 -0400 Subject: [PATCH 023/191] Lm now online --- astrophot/fit/func/__init__.py | 4 +-- astrophot/fit/func/lm.py | 30 ++++++++++----------- astrophot/fit/lm.py | 16 +++++++---- astrophot/image/image_object.py | 35 +++++++++++-------------- astrophot/image/jacobian_image.py | 4 ++- astrophot/image/window.py | 4 +-- astrophot/models/func/integration.py | 7 +++-- astrophot/models/galaxy_model_object.py | 4 +-- astrophot/models/mixins/sample.py | 4 +-- astrophot/models/mixins/transform.py | 2 +- astrophot/models/model_object.py | 3 ++- astrophot/plots/profile.py | 26 +++++++++--------- 12 files changed, 73 insertions(+), 66 deletions(-) diff --git a/astrophot/fit/func/__init__.py b/astrophot/fit/func/__init__.py index 00087be4..e5f23230 100644 --- a/astrophot/fit/func/__init__.py +++ b/astrophot/fit/func/__init__.py @@ -1,3 +1,3 @@ -from .lm import lm_step +from .lm import lm_step, hessian, gradient -__all__ = ["lm_step"] +__all__ = ["lm_step", "hessian", "gradient"] diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index c1a0c69d..d76a21b7 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -5,11 +5,11 @@ def hessian(J, W): - return J.T @ (W * J) + return J.T @ (W.unsqueeze(1) * J) def gradient(J, W, R): - return -J.T @ (W * R) + return J.T @ (W * R).unsqueeze(1) def damp_hessian(hess, L): @@ -21,11 +21,11 @@ def damp_hessian(hess, L): def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): chi20 = chi2 - M0 = model(x) - J = jacobian(x) - R = data - M0 - grad = gradient(J, weight, R) - hess = hessian(J, weight) + M0 = model(x) # (M,) + J = jacobian(x) # (M, N) + R = data - M0 # (M,) + grad = gradient(J, weight, R) # (N, 1) + hess = hessian(J, weight) # (N, N) best = {"h": torch.zeros_like(x), "chi2": chi20, "L": L} scary = {"h": None, "chi2": chi20, "L": L} @@ -33,9 +33,9 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. nostep = True improving = None for _ in range(10): - hessD = damp_hessian(hess, L) - h = torch.linalg.solve(hessD, grad) - M1 = model(x + h) + hessD = damp_hessian(hess, L) # (N, N) + h = torch.linalg.solve(hessD, grad) # (N, 1) + M1 = model(x + h.squeeze(1)) # (M,) chi21 = torch.sum(weight * (data - M1) ** 2).item() / ndf @@ -48,10 +48,10 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. continue if chi21 < scary["chi2"]: - scary = {"h": h, "chi2": chi21, "L": L} + scary = {"h": h.squeeze(1), "chi2": chi21, "L": L} # actual chi2 improvement vs expected from linearization - rho = (chi20 - chi21) / torch.abs(h.T @ hessD @ h - 2 * grad @ h).item() + rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h + 2 * grad.T @ h).item() # Avoid highly non-linear regions if rho < 0.1 or rho > 10: @@ -62,13 +62,13 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. continue if chi21 < best["chi2"]: # new best - best = {"h": h, "chi2": chi21, "L": L} + best = {"h": h.squeeze(1), "chi2": chi21, "L": L} nostep = False L /= Ldn if L < 1e-8 or improving is False: break improving = True - elif improving is True: + elif improving is True: # were improving, now not improving break else: # not improving and bad chi2, damp more L *= Lup @@ -76,8 +76,8 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. break improving = False + # If we are improving chi2 by more than 10% then we can stop if (best["chi2"] - chi20) / chi20 < -0.1: - # If we are improving chi2 by more than 10% then we can stop break if nostep: diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 3069e1d1..77b20bad 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -200,7 +200,9 @@ def __init__( elif fit_mask is not None: self.mask = ~fit_mask else: - self.mask = None + self.mask = torch.ones_like( + self.model.target[self.fit_window].flatten("data"), dtype=torch.bool + ) if self.mask is not None and torch.sum(self.mask).item() == 0: raise OptimizeStop("No data to fit. All pixels are masked") @@ -258,6 +260,10 @@ def fit(self) -> BaseOptimizer: self.loss_history = [self.chi2_ndf().item()] self.L_history = [self.L] self.lambda_history = [self.current_state.detach().clone().cpu().numpy()] + if self.verbose > 0: + AP_config.ap_logger.info( + f"==Starting LM fit for '{self.model.name}' with {len(self.current_state)} dynamic parameters and {len(self.Y)} pixels==" + ) for _ in range(self.max_iter): if self.verbose > 0: @@ -271,7 +277,7 @@ def fit(self) -> BaseOptimizer: weight=self.W, jacobian=self.jacobian, ndf=self.ndf, - chi2=self.chi2_ndf(), + chi2=self.loss_history[-1], L=self.L, Lup=self.Lup, Ldn=self.Ldn, @@ -282,9 +288,9 @@ def fit(self) -> BaseOptimizer: self.message = self.message + "fail. Could not find step to improve Chi^2" break - self.L = res["L"] + self.L = res["L"] / self.Ldn self.current_state = (self.current_state + res["h"]).detach() - self.L_history.append(self.L) + self.L_history.append(res["L"]) self.loss_history.append(res["chi2"]) self.lambda_history.append(self.current_state.detach().clone().cpu().numpy()) @@ -334,7 +340,7 @@ def covariance_matrix(self) -> torch.Tensor: self._covariance_matrix = torch.linalg.inv(self.hess) except: AP_config.ap_logger.warning( - "WARNING: Hessian is singular, likely at least one model is non-physical. Will massage Hessian to continue but results should be inspected." + "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will massage Hessian to continue but results should be inspected." ) self.hess += torch.eye( len(self.grad), dtype=AP_config.ap_dtype, device=AP_config.ap_device diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index dac82132..d2d954ad 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -108,10 +108,7 @@ def __init__( self.data = Param("data", data, units="flux") self.crval = Param("crval", kwargs.get("crval", self.default_crval), units="deg") self.crtan = Param("crtan", kwargs.get("crtan", self.default_crtan), units="arcsec") - self.crpix = np.asarray( - kwargs.get("crpix", self.default_crpix), - dtype=int, - ) + self.crpix = Param("crpix", kwargs.get("crpix", self.default_crpix), units="pixel") self.pixelscale = pixelscale @@ -134,7 +131,7 @@ def zeropoint(self, value): @property def window(self): - return Window(window=((0, 0), self.data.shape), crpix=self.crpix, image=self) + return Window(window=((0, 0), self.data.shape), crpix=self.crpix.npvalue, image=self) @property def center(self): @@ -196,12 +193,12 @@ def pixelscale_inv(self): return self._pixelscale_inv @forward - def pixel_to_plane(self, i, j, crtan): - return func.pixel_to_plane_linear(i, j, *self.crpix, self.pixelscale, *crtan) + def pixel_to_plane(self, i, j, crpix, crtan): + return func.pixel_to_plane_linear(i, j, *crpix, self.pixelscale, *crtan) @forward - def plane_to_pixel(self, x, y, crtan): - return func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale_inv, *crtan) + def plane_to_pixel(self, x, y, crpix, crtan): + return func.plane_to_pixel_linear(x, y, *crpix, self.pixelscale_inv, *crtan) @forward def plane_to_world(self, x, y, crval, crtan): @@ -280,7 +277,7 @@ def copy(self, **kwargs): kwargs = { "data": torch.clone(self.data.value), "pixelscale": self.pixelscale, - "crpix": self.crpix, + "crpix": self.crpix.value, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -297,7 +294,7 @@ def blank_copy(self, **kwargs): kwargs = { "data": torch.zeros_like(self.data.value), "pixelscale": self.pixelscale, - "crpix": self.crpix, + "crpix": self.crpix.value, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -332,26 +329,26 @@ def crop(self, pixels, **kwargs): crop : self.data.shape[0] - crop, crop : self.data.shape[1] - crop, ] - crpix = self.crpix - crop + crpix = self.crpix.value - crop elif len(pixels) == 2: # different crop in each dimension data = self.data.value[ pixels[1] : self.data.shape[0] - pixels[1], pixels[0] : self.data.shape[1] - pixels[0], ] - crpix = self.crpix - pixels + crpix = self.crpix.value - pixels elif len(pixels) == 4: # different crop on all sides data = self.data.value[ pixels[2] : self.data.shape[0] - pixels[3], pixels[0] : self.data.shape[1] - pixels[1], ] - crpix = self.crpix - pixels[0::2] # fixme + crpix = self.crpix.value - pixels[0::2] # fixme else: raise ValueError( f"Invalid crop shape {pixels}, must be int, (int,), (int, int), or (int, int, int, int)!" ) return self.copy(data=data, crpix=crpix, **kwargs) - def flatten(self, attribute: str = "data") -> np.ndarray: + def flatten(self, attribute: str = "data") -> torch.Tensor: if attribute in self.children: return getattr(self, attribute).value.reshape(-1) return getattr(self, attribute).reshape(-1) @@ -385,7 +382,7 @@ def reduce(self, scale: int, **kwargs): .sum(axis=(1, 3)) ) pixelscale = self.pixelscale * scale - crpix = (self.crpix + 0.5) / scale - 0.5 + crpix = (self.crpix.value + 0.5) / scale - 0.5 return self.copy( data=data, pixelscale=pixelscale, @@ -415,7 +412,7 @@ def get_astropywcs(self, **kwargs): @torch.no_grad() def get_indices(self, other: Union[Window, "Image"]): if isinstance(other, Window): - shift = self.crpix - other.crpix + shift = np.round(self.crpix.npvalue - other.crpix).astype(int) return slice( min(max(0, other.i_low - shift[0]), self.shape[0]), max(0, min(other.i_high - shift[0], self.shape[0])), @@ -438,7 +435,7 @@ def get_indices(self, other: Union[Window, "Image"]): ) end_pix = self.plane_to_pixel(*other.pixel_to_plane(*end_pix)) end_pix = torch.round(torch.stack(end_pix) + 0.5).int() - shape = torch.tensor(self.data.shape, dtype=torch.int32, device=AP_config.ap_device) + shape = torch.tensor(self.data.shape[:2], dtype=torch.int32, device=AP_config.ap_device) new_end_pix = torch.minimum(shape, end_pix) return slice(new_origin_pix[1], new_end_pix[1]), slice(new_origin_pix[0], new_end_pix[0]) @@ -455,7 +452,7 @@ def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): indices = _indices new_img = self.copy( data=self.data.value[indices], - crpix=self.crpix - np.array((indices[0].start, indices[1].start)), + crpix=self.crpix.value - np.array((indices[0].start, indices[1].start)), **kwargs, ) return new_img diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 97051392..57be8e0c 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -32,6 +32,8 @@ def __init__( raise SpecificationConflict("Every parameter should be unique upon jacobian creation") def flatten(self, attribute: str = "data"): + if attribute in self.children: + return getattr(self, attribute).value.reshape((-1, len(self.parameters))) return getattr(self, attribute).reshape((-1, len(self.parameters))) def copy(self, **kwargs): @@ -64,7 +66,7 @@ def __iadd__(self, other: "Jacobian_Image"): self.data = data self.parameters.append(other_identity) other_loc = -1 - self.data[self_indices[0], self_indices[1], other_loc] += other.data[ + self.data.value[self_indices[0], self_indices[1], other_loc] += other.data.value[ other_indices[0], other_indices[1], i ] return self diff --git a/astrophot/image/window.py b/astrophot/image/window.py index a5404b6b..a436310d 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -11,7 +11,7 @@ class Window: def __init__( self, window: Union[Tuple[int, int, int, int], Tuple[Tuple[int, int], Tuple[int, int]]], - crpix: Tuple[int, int], + crpix: Tuple[float, float], image: "Image", ): if len(window) == 4: @@ -26,7 +26,7 @@ def __init__( raise InvalidWindow( "Window must be a tuple of 4 integers or 2 tuples of 2 integers each" ) - self.crpix = np.asarray(crpix, dtype=int) + self.crpix = np.asarray(crpix) self.image = image @property diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index f120da48..254d34a2 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -71,16 +71,15 @@ def recursive_quad_integrate( quad_order=3, gridding=5, _current_depth=0, - max_depth=2, + max_depth=1, ): - - scale = 1.0 if _current_depth == 0 else 1 / (_current_depth * gridding) + scale = 1 / (gridding**_current_depth) z, z0 = single_quad_integrate(i, j, brightness_ij, scale, quad_order) if _current_depth >= max_depth: return z - select = torch.abs(z - z0) > threshold + select = torch.abs(z - z0) > threshold / scale**2 integral = torch.zeros_like(z) integral[~select] = z[~select] diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index bdff17bc..a3e8a7f9 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -65,7 +65,7 @@ def initialize(self, **kwargs): mu11 = np.sum(target_dat * i * j) M = np.array([[mu20, mu11], [mu11, mu02]]) if self.PA.value is None: - self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi + self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi if self.q.value is None: l = np.sort(np.linalg.eigvals(M)) - self.q.dynamic_value = max(0.1, np.sqrt(l[0] / l[1])) + self.q.dynamic_value = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index a30b40da..889b0cc5 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -32,7 +32,6 @@ def sample_image(self, image: Image): sampling_mode = "midpoint" else: sampling_mode = self.sampling_mode - if sampling_mode == "midpoint": x, y = image.coordinate_center_meshgrid() res = self.brightness(x, y) @@ -71,7 +70,8 @@ def sample_integrate(self, sample, image: Image): ) total_est = torch.sum(sample) threshold = total_est * self.integrate_tolerance - select = curvature > (total_est * self.integrate_tolerance) + select = curvature > threshold + sample[select] = func.recursive_quad_integrate( i[select], j[select], diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 06839a99..6f0b6da4 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -30,5 +30,5 @@ def transform_coordinates(self, x, y, PA, q): Transform coordinates based on the position angle and axis ratio. """ x, y = super().transform_coordinates(x, y) - x, y = rotate(-PA, x, y) + x, y = rotate(-(PA + np.pi / 2), x, y) return x, y / q diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 862162f7..34460b64 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -63,7 +63,7 @@ class Component_Model(SampleMixin, Model): psf_subpixel_shift = "lanczos:3" # bilinear, lanczos:2, lanczos:3, lanczos:5, none # Level to which each pixel should be evaluated - integrate_tolerance = 1e-2 + integrate_tolerance = 1e-3 # Integration scope for model integrate_mode = "threshold" # none, threshold @@ -262,6 +262,7 @@ def sample( working_image = Model_Image(window=window) sample = self.sample_image(working_image) if self.integrate_mode == "threshold": + # print("integrating") sample = self.sample_integrate(sample, working_image) working_image.data = sample diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 6d33c30a..f77715f2 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -28,17 +28,19 @@ def radial_light_profile( extend_profile=1.0, R0=0.0, resolution=1000, - doassert=True, plot_kwargs={}, ): xx = torch.linspace( R0, - torch.max(model.window.shape / 2) * extend_profile, + max(model.window.shape) + * model.target.pixel_length.detach().cpu().numpy() + * extend_profile + / 2, int(resolution), dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) - flux = model.radial_model(xx).detach().cpu().numpy() + flux = model.radial_model(xx, params=()).detach().cpu().numpy() if model.target.zeropoint is not None: yy = flux_to_sb(flux, model.target.pixel_area.item(), model.target.zeropoint.item()) else: @@ -75,7 +77,6 @@ def radial_median_profile( count_limit: int = 10, return_profile: bool = False, rad_unit: str = "arcsec", - doassert: bool = True, plot_kwargs: dict = {}, ): """Plot an SB profile by taking flux median at each radius. @@ -97,8 +98,8 @@ def radial_median_profile( """ - Rlast_phys = torch.max(model.window.shape / 2).item() - Rlast_pix = Rlast_phys / model.target.pixel_length.item() + Rlast_pix = max(model.window.shape) / 2 + Rlast_phys = Rlast_pix * model.target.pixel_length.item() Rbins = [0.0] while Rbins[-1] < Rlast_pix: @@ -107,21 +108,22 @@ def radial_median_profile( with torch.no_grad(): image = model.target[model.window] - X, Y = image.get_coordinate_meshgrid() - model["center"].value[..., None, None] - X, Y = model.transform_coordinates(X, Y) - R = model.radius_metric(X, Y) + x, y = image.coordinate_center_meshgrid() + x, y = model.transform_coordinates(x, y, params=()) + R = (x**2 + y**2).sqrt() # (N,) R = R.detach().cpu().numpy() + dat = image.data.value.detach().cpu().numpy() count, bins, binnum = binned_statistic( R.ravel(), - image.data.detach().cpu().numpy().ravel(), + dat.ravel(), statistic="count", bins=Rbins, ) stat, bins, binnum = binned_statistic( R.ravel(), - image.data.detach().cpu().numpy().ravel(), + dat.ravel(), statistic="median", bins=Rbins, ) @@ -129,7 +131,7 @@ def radial_median_profile( scat, bins, binnum = binned_statistic( R.ravel(), - image.data.detach().cpu().numpy().ravel(), + dat.ravel(), statistic=partial(iqr, rng=(16, 84)), bins=Rbins, ) From 79df025a6a5553ee315a68f209e5c5d801e06f8f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 19 Jun 2025 14:35:04 -0400 Subject: [PATCH 024/191] window system online and fitting --- astrophot/fit/lm.py | 15 +- astrophot/image/func/image.py | 6 +- astrophot/image/func/wcs.py | 6 +- astrophot/image/image_object.py | 52 +++-- astrophot/image/model_image.py | 9 +- astrophot/image/target_image.py | 2 +- astrophot/models/__init__.py | 14 +- astrophot/models/_shared_methods.py | 10 +- astrophot/models/{core_model.py => base.py} | 73 +++--- astrophot/models/exponential_model.py | 237 ++++++++++---------- astrophot/models/func/__init__.py | 2 + astrophot/models/func/base.py | 4 + astrophot/models/group_model_object.py | 21 +- astrophot/models/mixins/exponential.py | 4 +- astrophot/models/mixins/sample.py | 2 + astrophot/models/model_object.py | 8 +- astrophot/models/point_source.py | 4 +- astrophot/models/psf_model_object.py | 2 +- astrophot/param/module.py | 46 +++- astrophot/plots/image.py | 62 +++-- astrophot/plots/profile.py | 8 +- astrophot/utils/integration.py | 2 +- docs/source/tutorials/GettingStarted.ipynb | 67 ++---- 23 files changed, 353 insertions(+), 303 deletions(-) rename astrophot/models/{core_model.py => base.py} (84%) create mode 100644 astrophot/models/func/base.py diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 77b20bad..9f568c71 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -335,17 +335,18 @@ def covariance_matrix(self) -> torch.Tensor: if self._covariance_matrix is not None: return self._covariance_matrix - self.update_hess_grad(natural=True) + J = self.jacobian(self.model.from_valid(self.current_state)) + hess = func.hessian(J, self.W) try: - self._covariance_matrix = torch.linalg.inv(self.hess) + self._covariance_matrix = torch.linalg.inv(hess) except: AP_config.ap_logger.warning( "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will massage Hessian to continue but results should be inspected." ) - self.hess += torch.eye( - len(self.grad), dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) * (torch.diag(self.hess) == 0) - self._covariance_matrix = torch.linalg.inv(self.hess) + hess += torch.eye(len(hess), dtype=AP_config.ap_dtype, device=AP_config.ap_device) * ( + torch.diag(hess) == 0 + ) + self._covariance_matrix = torch.linalg.inv(hess) return self._covariance_matrix @torch.no_grad() @@ -360,7 +361,7 @@ def update_uncertainty(self) -> None: cov = self.covariance_matrix if torch.all(torch.isfinite(cov)): try: - self.model.parameters.vector_set_uncertainty(torch.sqrt(torch.abs(torch.diag(cov)))) + self.model.fill_dynamic_value_uncertainties(torch.sqrt(torch.abs(torch.diag(cov)))) except RuntimeError as e: AP_config.ap_logger.warning(f"Unable to update uncertainty due to: {e}") else: diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index c67ddbcd..4ab1af99 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -6,19 +6,19 @@ def pixel_center_meshgrid(shape, dtype, device): i = torch.arange(shape[0], dtype=dtype, device=device) j = torch.arange(shape[1], dtype=dtype, device=device) - return torch.meshgrid(i, j, indexing="xy") + return torch.meshgrid(i, j, indexing="ij") def pixel_corner_meshgrid(shape, dtype, device): i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="xy") + return torch.meshgrid(i, j, indexing="ij") def pixel_simpsons_meshgrid(shape, dtype, device): i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="xy") + return torch.meshgrid(i, j, indexing="ij") def pixel_quad_meshgrid(shape, dtype, device, order=3): diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 143a8250..1b6a8def 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -112,7 +112,7 @@ def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): Tuple: [Tensor, Tensor] Tuple containing the x and y tangent plane coordinates in arcsec. """ - uv = torch.stack((i.reshape(-1) - i0, j.reshape(-1) - j0), dim=1) + uv = torch.stack((j.reshape(-1) - j0, i.reshape(-1) - i0), dim=1) xy = (CD @ uv.T).T return xy[:, 0].reshape(i.shape) + x0, xy[:, 1].reshape(j.shape) + y0 @@ -173,7 +173,7 @@ def pixel_to_plane_sip(i, j, i0, j0, CD, sip_powers=[], sip_coefs=[], x0=0.0, y0 Tuple: [Tensor, Tensor] Tuple containing the x and y tangent plane coordinates in arcsec. """ - uv = torch.stack((i - i0, j - j0), -1) + uv = torch.stack((j - j0, i - i0), -1) delta_p = torch.zeros_like(uv) for p in range(len(sip_powers)): delta_p += sip_coefs[p] * torch.prod(uv ** sip_powers[p], dim=-1).unsqueeze(-1) @@ -212,4 +212,4 @@ def plane_to_pixel_linear(x, y, i0, j0, iCD, x0=0.0, y0=0.0): xy = torch.stack((x.reshape(-1) - x0, y.reshape(-1) - y0), dim=1) uv = (iCD @ xy.T).T - return uv[:, 0].reshape(x.shape) + i0, uv[:, 1].reshape(y.shape) + j0 + return uv[:, 1].reshape(x.shape) + i0, uv[:, 0].reshape(y.shape) + j0 diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index d2d954ad..08940b07 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -275,7 +275,7 @@ def copy(self, **kwargs): """ kwargs = { - "data": torch.clone(self.data.value), + "data": torch.clone(self.data.value.detach()), "pixelscale": self.pixelscale, "crpix": self.crpix.value, "crval": self.crval.value, @@ -409,16 +409,37 @@ def get_astropywcs(self, **kwargs): wargs.update(kwargs) return AstropyWCS(wargs) + def corners(self): + pixel_lowleft = torch.tensor( + (-0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + pixel_lowright = torch.tensor( + (self.data.shape[0] - 0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + pixel_upleft = torch.tensor( + (-0.5, self.data.shape[1] - 0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + pixel_upright = torch.tensor( + (self.data.shape[0] - 0.5, self.data.shape[1] - 0.5), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + lowleft = self.pixel_to_plane(*pixel_lowleft) + lowright = self.pixel_to_plane(*pixel_lowright) + upleft = self.pixel_to_plane(*pixel_upleft) + upright = self.pixel_to_plane(*pixel_upright) + return (lowleft, lowright, upright, upleft) + @torch.no_grad() def get_indices(self, other: Union[Window, "Image"]): if isinstance(other, Window): shift = np.round(self.crpix.npvalue - other.crpix).astype(int) return slice( - min(max(0, other.i_low - shift[0]), self.shape[0]), - max(0, min(other.i_high - shift[0], self.shape[0])), + min(max(0, other.i_low + shift[0]), self.shape[0]), + max(0, min(other.i_high + shift[0], self.shape[0])), ), slice( - min(max(0, other.j_low - shift[1]), self.shape[1]), - max(0, min(other.j_high - shift[1], self.shape[1])), + min(max(0, other.j_low + shift[1]), self.shape[1]), + max(0, min(other.j_high + shift[1], self.shape[1])), ) origin_pix = torch.tensor( @@ -437,7 +458,7 @@ def get_indices(self, other: Union[Window, "Image"]): end_pix = torch.round(torch.stack(end_pix) + 0.5).int() shape = torch.tensor(self.data.shape[:2], dtype=torch.int32, device=AP_config.ap_device) new_end_pix = torch.minimum(shape, end_pix) - return slice(new_origin_pix[1], new_end_pix[1]), slice(new_origin_pix[0], new_end_pix[0]) + return slice(new_origin_pix[0], new_end_pix[0]), slice(new_origin_pix[1], new_end_pix[1]) def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): """Get a new image object which is a window of this image @@ -452,7 +473,12 @@ def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): indices = _indices new_img = self.copy( data=self.data.value[indices], - crpix=self.crpix.value - np.array((indices[0].start, indices[1].start)), + crpix=self.crpix.value + - torch.tensor( + (indices[0].start, indices[1].start), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ), **kwargs, ) return new_img @@ -460,35 +486,35 @@ def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): def __sub__(self, other): if isinstance(other, Image): new_img = self[other] - new_img.data._value -= other[self].data.value + new_img.data._value = new_img.data._value - other[self].data.value return new_img else: new_img = self.copy() - new_img.data._value -= other + new_img.data._value = new_img.data._value - other return new_img def __add__(self, other): if isinstance(other, Image): new_img = self[other] - new_img.data._value += other[self].data.value + new_img.data._value = new_img.data._value + other[self].data.value return new_img else: new_img = self.copy() - new_img.data._value += other + new_img.data._value = new_img.data._value + other return new_img def __iadd__(self, other): if isinstance(other, Image): self.data._value[self.get_indices(other)] += other.data.value[other.get_indices(self)] else: - self.data._value += other + self.data._value = self.data._value + other return self def __isub__(self, other): if isinstance(other, Image): self.data._value[self.get_indices(other)] -= other.data.value[other.get_indices(self)] else: - self.data._value -= other + self.data._value = self.data._value - other return self def __getitem__(self, *args): diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index f57f28ee..c2418487 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -1,3 +1,4 @@ +import numpy as np import torch from .. import AP_config @@ -20,9 +21,11 @@ class Model_Image(Image): def __init__(self, *args, window=None, upsample=1, pad=0, **kwargs): if window is not None: kwargs["pixelscale"] = window.image.pixelscale / upsample - kwargs["crpix"] = (window.crpix + 0.5) * upsample + pad - 0.5 - kwargs["crval"] = window.image.crval - kwargs["crtan"] = window.image.crtan + kwargs["crpix"] = ( + (window.crpix - np.array((window.i_low, window.j_low)) + 0.5) * upsample + pad - 0.5 + ) + kwargs["crval"] = window.image.crval.value + kwargs["crtan"] = window.image.crtan.value kwargs["data"] = torch.zeros( ( (window.i_high - window.i_low) * upsample + 2 * pad, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index b6d079cf..511d49eb 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -400,7 +400,7 @@ def model_image(self, **kwargs): copy_kwargs = { "data": torch.zeros_like(self.data.value), "pixelscale": self.pixelscale, - "crpix": self.crpix, + "crpix": self.crpix.value, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index f3ecbd3d..31b81a45 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -1,9 +1,10 @@ -from .core_model import * -from .model_object import * -from .galaxy_model_object import * -from .sersic_model import * +from .base import Model +from .model_object import Component_Model +from .galaxy_model_object import Galaxy_Model +from .sersic_model import Sersic_Galaxy +from .group_model_object import Group_Model +from .exponential_model import * -# from .group_model_object import * # from .ray_model import * # from .sky_model_object import * # from .flatsky_model import * @@ -16,7 +17,6 @@ # from .eigen_psf import * # from .superellipse_model import * # from .edgeon_model import * -# from .exponential_model import * # from .foureirellipse_model import * # from .wedge_model import * # from .warp_model import * @@ -26,3 +26,5 @@ # from .airy_psf import * # from .point_source import * # from .group_psf_model import * + +__all__ = ("Model", "Component_Model", "Galaxy_Model", "Sersic_Galaxy", "Group_Model") diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index e4335ba5..741a5e87 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -4,7 +4,7 @@ from scipy.optimize import minimize from ..utils.initialize import isophotes -from ..utils.decorators import ignore_numpy_warnings, default_internal +from ..utils.decorators import ignore_numpy_warnings from . import func from .. import AP_config @@ -37,10 +37,12 @@ def _sample_image(image, transform, rad_bins=None): R = (rad_bins[:-1] + rad_bins[1:]) / 2 # Ensure enough values are positive - I[~np.isfinite(I)] = np.median(I[np.isfinite(I)]) + N = np.isfinite(I) + I[~N] = np.interp(R[~N], R[N], I[N]) if np.sum(I > 0) <= 3: - I = I - np.min(I) - I[I <= 0] = np.min(I[I > 0]) + I = np.abs(I) + N = I > 0 + I[~N] = np.interp(R[~N], R[N], I[N]) # Ensure decreasing brightness with radius in outer regions for i in range(5, len(I)): if I[i] >= I[i - 1]: diff --git a/astrophot/models/core_model.py b/astrophot/models/base.py similarity index 84% rename from astrophot/models/core_model.py rename to astrophot/models/base.py index 0f387123..849cbbae 100644 --- a/astrophot/models/core_model.py +++ b/astrophot/models/base.py @@ -5,18 +5,13 @@ from ..param import Module, forward, Param from ..utils.decorators import classproperty -from ..image import Window, Target_Image_List +from ..image import Window, Image_List, Model_Image, Model_Image_List from ..errors import UnrecognizedModel, InvalidWindow +from . import func __all__ = ("Model",) -def all_subclasses(cls): - return set(cls.__subclasses__()).union( - [s for c in cls.__subclasses__() for s in all_subclasses(c)] - ) - - ###################################################################### class Model(Module): """Core class for all AstroPhot models and model like objects. This @@ -110,16 +105,16 @@ def __new__(cls, *, filename=None, model_type=None, **kwargs): return super().__new__(cls) - def __init__(self, *, name=None, target=None, window=None, **kwargs): + def __init__(self, *, name=None, target=None, window=None, mask=None, filename=None, **kwargs): super().__init__(name=name) self.target = target self.window = window - self.mask = kwargs.get("mask", None) + self.mask = mask # Set any user defined options for the model - for kwarg in kwargs: + for kwarg in list(kwargs.keys()): if kwarg in self.options: - setattr(self, kwarg, kwargs[kwarg]) + setattr(self, kwarg, kwargs.pop(kwarg)) # Create Param objects for this Module parameter_specs = self.build_parameter_specs(kwargs) @@ -127,12 +122,18 @@ def __init__(self, *, name=None, target=None, window=None, **kwargs): setattr(self, key, Param(key, **parameter_specs[key])) # If loading from a file, get model configuration then exit __init__ - if "filename" in kwargs: - self.load(kwargs["filename"], new_name=name) + if filename is not None: + self.load(filename, new_name=name) return + kwargs.pop("model_type", None) # model_type is set by __new__ + if len(kwargs) > 0: + raise TypeError( + f"Unrecognized keyword arguments for {self.__class__.__name__}: {', '.join(kwargs.keys())}" + ) + @classproperty - def model_type(cls): + def model_type(cls) -> str: collected = [] for subcls in cls.mro(): if subcls is object: @@ -143,7 +144,7 @@ def model_type(cls): return " ".join(collected) @classproperty - def options(cls): + def options(cls) -> set: options = set() for subcls in cls.mro(): if subcls is object: @@ -152,7 +153,7 @@ def options(cls): return options @classproperty - def parameter_specs(cls): + def parameter_specs(cls) -> dict: """Collects all parameter specifications from the class hierarchy.""" specs = {} for subcls in reversed(cls.mro()): @@ -161,16 +162,16 @@ def parameter_specs(cls): specs.update(getattr(subcls, "_parameter_specs", {})) return specs - def build_parameter_specs(self, kwargs): + def build_parameter_specs(self, kwargs) -> dict: parameter_specs = deepcopy(self.parameter_specs) - for p in kwargs: + for p in list(kwargs.keys()): if p not in parameter_specs: continue if isinstance(kwargs[p], dict): - parameter_specs[p].update(kwargs[p]) + parameter_specs[p].update(kwargs.pop(p)) else: - parameter_specs[p]["value"] = kwargs[p] + parameter_specs[p]["value"] = kwargs.pop(p) return parameter_specs @@ -178,7 +179,7 @@ def build_parameter_specs(self, kwargs): def gaussian_negative_log_likelihood( self, window: Optional[Window] = None, - ): + ) -> torch.Tensor: """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. """ @@ -190,7 +191,7 @@ def gaussian_negative_log_likelihood( weight = data.weight mask = data.mask data = data.data - if isinstance(data, Target_Image_List): + if isinstance(data, Image_List): nll = sum( torch.sum(((mo - da) ** 2 * wgt)[~ma]) / 2.0 for mo, da, wgt, ma in zip(model, data, weight, mask) @@ -204,7 +205,7 @@ def gaussian_negative_log_likelihood( def poisson_negative_log_likelihood( self, window: Optional[Window] = None, - ): + ) -> torch.Tensor: """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. """ @@ -215,7 +216,7 @@ def poisson_negative_log_likelihood( mask = data.mask data = data.data - if isinstance(data, Target_Image_List): + if isinstance(data, Image_List): nll = sum( torch.sum((mo - da * (mo + 1e-10).log() + torch.lgamma(da + 1))[~ma]) for mo, da, ma in zip(model, data, mask) @@ -226,12 +227,12 @@ def poisson_negative_log_likelihood( return nll @forward - def total_flux(self, window=None): + def total_flux(self, window=None) -> torch.Tensor: F = self(window=window) return torch.sum(F.data) @property - def window(self): + def window(self) -> Optional[Window]: """The window defines a region on the sky in which this model will be optimized and typically evaluated. Two models with non-overlapping windows are in effect independent of each @@ -260,15 +261,23 @@ def window(self, window): elif isinstance(window, Window): # If window object given, use that self._window = window - elif len(window) == 2 or len(window) == 4: + elif len(window) == 2: # If window given in pixels, use relative to target - self._window = Window(window, crpix=self.target.crpix, image=self.target) + self._window = Window( + (window[1], window[0]), crpix=self.target.crpix.value, image=self.target + ) + elif len(window) == 4: + self._window = Window( + (window[2], window[3], window[0], window[1]), + crpix=self.target.crpix.value, + image=self.target, + ) else: raise InvalidWindow(f"Unrecognized window format: {str(window)}") @classmethod - def List_Models(cls, usable=None): - MODELS = all_subclasses(cls) + def List_Models(cls, usable: Optional[bool] = None) -> set: + MODELS = func.all_subclasses(cls) if usable is not None: for model in list(MODELS): if model.usable is not usable: @@ -278,8 +287,8 @@ def List_Models(cls, usable=None): @forward def __call__( self, - window=None, + window: Optional[Window] = None, **kwargs, - ): + ) -> Union[Model_Image, Model_Image_List]: return self.sample(window=window, **kwargs) diff --git a/astrophot/models/exponential_model.py b/astrophot/models/exponential_model.py index f470cb97..3da822ab 100644 --- a/astrophot/models/exponential_model.py +++ b/astrophot/models/exponential_model.py @@ -1,20 +1,21 @@ from .galaxy_model_object import Galaxy_Model -from .warp_model import Warp_Galaxy -from .ray_model import Ray_Galaxy -from .psf_model_object import PSF_Model -from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp -from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp -from .wedge_model import Wedge_Galaxy -from .mixins import ExponentialMixin, iExponentialMixin + +# from .warp_model import Warp_Galaxy +# from .ray_model import Ray_Galaxy +# from .psf_model_object import PSF_Model +# from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp +# from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp +# from .wedge_model import Wedge_Galaxy +from .mixins import ExponentialMixin # , iExponentialMixin __all__ = [ "Exponential_Galaxy", - "Exponential_PSF", - "Exponential_SuperEllipse", - "Exponential_SuperEllipse_Warp", - "Exponential_Warp", - "Exponential_Ray", - "Exponential_Wedge", + # "Exponential_PSF", + # "Exponential_SuperEllipse", + # "Exponential_SuperEllipse_Warp", + # "Exponential_Warp", + # "Exponential_Ray", + # "Exponential_Wedge", ] @@ -38,162 +39,162 @@ class Exponential_Galaxy(ExponentialMixin, Galaxy_Model): usable = True -class Exponential_PSF(ExponentialMixin, PSF_Model): - """basic point source model with a exponential profile for the radial light - profile. +# class Exponential_PSF(ExponentialMixin, PSF_Model): +# """basic point source model with a exponential profile for the radial light +# profile. - I(R) = Ie * exp(-b1(R/Re - 1)) +# I(R) = Ie * exp(-b1(R/Re - 1)) - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. +# where I(R) is the brightness as a function of semi-major axis, Ie +# is the brightness at the half light radius, b1 is a constant not +# involved in the fit, R is the semi-major axis, and Re is the +# effective radius. - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. +# Parameters: +# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness +# Re: half light radius, represented in arcsec. This parameter cannot go below zero. - """ +# """ - usable = True - model_integrated = False +# usable = True +# model_integrated = False -class Exponential_SuperEllipse(ExponentialMixin, SuperEllipse_Galaxy): - """super ellipse galaxy model with a exponential profile for the radial - light profile. +# class Exponential_SuperEllipse(ExponentialMixin, SuperEllipse_Galaxy): +# """super ellipse galaxy model with a exponential profile for the radial +# light profile. - I(R) = Ie * exp(-b1(R/Re - 1)) +# I(R) = Ie * exp(-b1(R/Re - 1)) - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. +# where I(R) is the brightness as a function of semi-major axis, Ie +# is the brightness at the half light radius, b1 is a constant not +# involved in the fit, R is the semi-major axis, and Re is the +# effective radius. - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. +# Parameters: +# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness +# Re: half light radius, represented in arcsec. This parameter cannot go below zero. - """ +# """ - usable = True +# usable = True -class Exponential_SuperEllipse_Warp(ExponentialMixin, SuperEllipse_Warp): - """super ellipse warp galaxy model with a exponential profile for the - radial light profile. +# class Exponential_SuperEllipse_Warp(ExponentialMixin, SuperEllipse_Warp): +# """super ellipse warp galaxy model with a exponential profile for the +# radial light profile. - I(R) = Ie * exp(-b1(R/Re - 1)) +# I(R) = Ie * exp(-b1(R/Re - 1)) - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. +# where I(R) is the brightness as a function of semi-major axis, Ie +# is the brightness at the half light radius, b1 is a constant not +# involved in the fit, R is the semi-major axis, and Re is the +# effective radius. - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. +# Parameters: +# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness +# Re: half light radius, represented in arcsec. This parameter cannot go below zero. - """ +# """ - usable = True +# usable = True -class Exponential_FourierEllipse(ExponentialMixin, FourierEllipse_Galaxy): - """fourier mode perturbations to ellipse galaxy model with an - exponential profile for the radial light profile. +# class Exponential_FourierEllipse(ExponentialMixin, FourierEllipse_Galaxy): +# """fourier mode perturbations to ellipse galaxy model with an +# exponential profile for the radial light profile. - I(R) = Ie * exp(-b1(R/Re - 1)) +# I(R) = Ie * exp(-b1(R/Re - 1)) - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. +# where I(R) is the brightness as a function of semi-major axis, Ie +# is the brightness at the half light radius, b1 is a constant not +# involved in the fit, R is the semi-major axis, and Re is the +# effective radius. - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. +# Parameters: +# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness +# Re: half light radius, represented in arcsec. This parameter cannot go below zero. - """ +# """ - usable = True +# usable = True -class Exponential_FourierEllipse_Warp(ExponentialMixin, FourierEllipse_Warp): - """fourier mode perturbations to ellipse galaxy model with a exponential - profile for the radial light profile. +# class Exponential_FourierEllipse_Warp(ExponentialMixin, FourierEllipse_Warp): +# """fourier mode perturbations to ellipse galaxy model with a exponential +# profile for the radial light profile. - I(R) = Ie * exp(-b1(R/Re - 1)) +# I(R) = Ie * exp(-b1(R/Re - 1)) - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. +# where I(R) is the brightness as a function of semi-major axis, Ie +# is the brightness at the half light radius, b1 is a constant not +# involved in the fit, R is the semi-major axis, and Re is the +# effective radius. - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. +# Parameters: +# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness +# Re: half light radius, represented in arcsec. This parameter cannot go below zero. - """ +# """ - usable = True +# usable = True -class Exponential_Warp(ExponentialMixin, Warp_Galaxy): - """warped coordinate galaxy model with a exponential profile for the - radial light model. +# class Exponential_Warp(ExponentialMixin, Warp_Galaxy): +# """warped coordinate galaxy model with a exponential profile for the +# radial light model. - I(R) = Ie * exp(-b1(R/Re - 1)) +# I(R) = Ie * exp(-b1(R/Re - 1)) - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. +# where I(R) is the brightness as a function of semi-major axis, Ie +# is the brightness at the half light radius, b1 is a constant not +# involved in the fit, R is the semi-major axis, and Re is the +# effective radius. - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. +# Parameters: +# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness +# Re: half light radius, represented in arcsec. This parameter cannot go below zero. - """ +# """ - usable = True +# usable = True -class Exponential_Ray(iExponentialMixin, Ray_Galaxy): - """ray galaxy model with a sersic profile for the radial light - model. The functional form of the Sersic profile is defined as: +# class Exponential_Ray(iExponentialMixin, Ray_Galaxy): +# """ray galaxy model with a sersic profile for the radial light +# model. The functional form of the Sersic profile is defined as: - I(R) = Ie * exp(- bn((R/Re) - 1)) +# I(R) = Ie * exp(- bn((R/Re) - 1)) - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius. +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius. - Parameters: - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# Parameters: +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - """ +# """ - usable = True +# usable = True -class Exponential_Wedge(iExponentialMixin, Wedge_Galaxy): - """wedge galaxy model with a exponential profile for the radial light - model. The functional form of the Sersic profile is defined as: +# class Exponential_Wedge(iExponentialMixin, Wedge_Galaxy): +# """wedge galaxy model with a exponential profile for the radial light +# model. The functional form of the Sersic profile is defined as: - I(R) = Ie * exp(- bn((R/Re) - 1)) +# I(R) = Ie * exp(- bn((R/Re) - 1)) - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius. +# where I(R) is the brightness profile as a function of semi-major +# axis, R is the semi-major axis length, Ie is the brightness as the +# half light radius, bn is a function of n and is not involved in +# the fit, Re is the half light radius. - Parameters: - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius +# Parameters: +# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. +# Re: half light radius - """ +# """ - usable = True +# usable = True diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 795df64e..2c847bc5 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -1,3 +1,4 @@ +from .base import all_subclasses from .integration import ( quad_table, pixel_center_integrator, @@ -18,6 +19,7 @@ from .moffat import moffat __all__ = ( + "all_subclasses", "quad_table", "pixel_center_integrator", "pixel_corner_integrator", diff --git a/astrophot/models/func/base.py b/astrophot/models/func/base.py new file mode 100644 index 00000000..de9906ca --- /dev/null +++ b/astrophot/models/func/base.py @@ -0,0 +1,4 @@ +def all_subclasses(cls): + return set(cls.__subclasses__()).union( + [s for c in cls.__subclasses__() for s in all_subclasses(c)] + ) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 3ea7fa10..f15f0398 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -1,13 +1,15 @@ -from typing import Optional, Sequence +from typing import Optional, Sequence, Union import torch from caskade import forward -from .core_model import Model +from .base import Model from ..image import ( Image, Target_Image, Target_Image_List, + Model_Image, + Model_Image_List, Image_List, Window, Window_List, @@ -45,11 +47,9 @@ def __init__( models: Optional[Sequence[Model]] = None, **kwargs, ): - super().__init__(name=name, models=models, **kwargs) + super().__init__(name=name, **kwargs) self.models = models self.update_window() - if "filename" in kwargs: - self.load(kwargs["filename"], new_name=name) def update_window(self): """Makes a new window object which encloses all the windows of the @@ -138,7 +138,7 @@ def fit_mask(self) -> torch.Tensor: def sample( self, window: Optional[Window] = None, - ): + ) -> Union[Model_Image, Model_Image_List]: """Sample the group model on an image. Produces the flux values for each pixel associated with the models in this group. Each model is called individually and the results are added @@ -188,8 +188,7 @@ def jacobian( self, pass_jacobian: Optional[Jacobian_Image] = None, window: Optional[Window] = None, - **kwargs, - ): + ) -> Jacobian_Image: """Compute the jacobian for this model. Done by first constructing a full jacobian (Npixels * Nparameters) of zeros then call the jacobian method of each sub model and add it in to the total. @@ -203,7 +202,7 @@ def jacobian( if pass_jacobian is None: jac_img = self.target[window].jacobian_image( - parameters=self.parameters.vector_identities() + parameters=self.build_params_array_identities() ) else: jac_img = pass_jacobian @@ -220,14 +219,14 @@ def __iter__(self): return (mod for mod in self.models.values()) @property - def target(self): + def target(self) -> Optional[Union[Target_Image, Target_Image_List]]: try: return self._target except AttributeError: return None @target.setter - def target(self, tar): + def target(self, tar: Optional[Union[Target_Image, Target_Image_List]]): if not (tar is None or isinstance(tar, (Target_Image, Target_Image_List))): raise InvalidTarget("Group_Model target must be a Target_Image instance.") self._target = tar diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 78cfeeff..6d24b8e9 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -27,8 +27,8 @@ class ExponentialMixin: """ _model_type = "exponential" - parameter_specs = { - "Re": {"units": "arcsec", "limits": (0, None)}, + _parameter_specs = { + "Re": {"units": "arcsec", "valid": (0, None)}, "Ie": {"units": "flux/arcsec^2"}, } diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 889b0cc5..7e89bd53 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -20,6 +20,8 @@ class SampleMixin: jacobian_maxparams = 10 jacobian_maxpixels = 1000**2 + _options = ("sampling_mode", "jacobian_maxparams", "jacobian_maxpixels") + @forward def sample_image(self, image: Image): if self.sampling_mode == "auto": diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 34460b64..2217e941 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -4,7 +4,7 @@ import torch from ..param import forward -from .core_model import Model +from .base import Model from . import func from ..image import ( Model_Image, @@ -63,7 +63,7 @@ class Component_Model(SampleMixin, Model): psf_subpixel_shift = "lanczos:3" # bilinear, lanczos:2, lanczos:3, lanczos:5, none # Level to which each pixel should be evaluated - integrate_tolerance = 1e-3 + integrate_tolerance = 1e-3 # total flux fraction # Integration scope for model integrate_mode = "threshold" # none, threshold @@ -78,12 +78,11 @@ class Component_Model(SampleMixin, Model): integrate_quad_order = 3 # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) - softening = 1e-3 + softening = 1e-3 # arcsec _options = ( "psf_mode", "psf_subpixel_shift", - "sampling_mode", "sampling_tolerance", "integrate_mode", "integrate_max_depth", @@ -262,7 +261,6 @@ def sample( working_image = Model_Image(window=window) sample = self.sample_image(working_image) if self.integrate_mode == "threshold": - # print("integrating") sample = self.sample_integrate(sample, working_image) working_image.data = sample diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 6c8a3552..28131bd9 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -3,12 +3,10 @@ import torch import numpy as np -from ..param import Param_Unlock, Param_SoftLimits, Parameter_Node from .model_object import Component_Model -from .core_model import AstroPhot_Model +from .base import Model from ..utils.decorators import ignore_numpy_warnings, default_internal from ..image import PSF_Image, Window, Model_Image, Image -from ._shared_methods import select_target from ..errors import SpecificationConflict __all__ = ("Point_Source",) diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index a823678f..6c95c635 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -3,7 +3,7 @@ import torch from caskade import forward -from .core_model import Model +from .base import Model from ..image import ( Model_Image, Window, diff --git a/astrophot/param/module.py b/astrophot/param/module.py index 7d3dacbd..d97f0352 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -1,5 +1,11 @@ import numpy as np -from caskade import Module as CModule +from math import prod +from caskade import ( + Module as CModule, + ActiveStateError, + ParamConfigurationError, + FillDynamicParamsArrayError, +) class Module(CModule): @@ -11,3 +17,41 @@ def build_params_array_identities(self): for i in range(numel): identities.append(f"{id(param)}_{i}") return identities + + def build_params_array_names(self): + names = [] + for param in self.dynamic_params: + numel = max(1, np.prod(param.shape)) + if numel == 1: + names.append(param.name) + else: + for i in range(numel): + names.append(f"{param.name}_{i}") + return names + + def fill_dynamic_value_uncertainties(self, uncertainty): + if self.active: + raise ActiveStateError(f"Cannot fill dynamic values when Module {self.name} is active") + + dynamic_params = self.dynamic_params + + if uncertainty.shape[-1] == 0: + return # No parameters to fill + # check for batch dimension + pos = 0 + for param in dynamic_params: + if not isinstance(param.shape, tuple): + raise ParamConfigurationError( + f"Param {param.name} has no shape. dynamic parameters must have a shape to use Tensor input." + ) + # Handle scalar parameters + size = max(1, prod(param.shape)) + try: + val = uncertainty[..., pos : pos + size].view(param.shape) + param.uncertainty = val + except (RuntimeError, IndexError, ValueError, TypeError): + raise FillDynamicParamsArrayError(self.name, uncertainty, dynamic_params) + + pos += size + if pos != uncertainty.shape[-1]: + raise FillDynamicParamsArrayError(self.name, uncertainty, dynamic_params) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 64a5f70a..5755d2ea 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -7,7 +7,7 @@ import matplotlib from scipy.stats import iqr -# from ..models import Group_Model, PSF_Model +from ..models import Group_Model # , PSF_Model from ..image import Image_List, Window_List from .. import AP_config from ..utils.conversions.units import flux_to_sb @@ -61,16 +61,16 @@ def target_image(fig, ax, target, window=None, **kwargs): if kwargs.get("linear", False): im = ax.pcolormesh( - X, - Y, - dat, + X.T, + Y.T, + dat.T, cmap=cmap_grad, ) else: im = ax.pcolormesh( - X, - Y, - dat, + X.T, + Y.T, + dat.T, cmap="Greys", norm=ImageNormalize( stretch=HistEqStretch( @@ -83,9 +83,9 @@ def target_image(fig, ax, target, window=None, **kwargs): ) im = ax.pcolormesh( - X, - Y, - np.ma.masked_where(dat < (sky + 3 * noise), dat), + X.T, + Y.T, + np.ma.masked_where(dat < (sky + 3 * noise), dat).T, cmap=cmap_grad, norm=matplotlib.colors.LogNorm(), clim=[sky + 3 * noise, None], @@ -233,7 +233,7 @@ def model_image( sample_image = sample_image[window] # Evaluate the model image - X, Y = sample_image.pixel_corner_meshgrid() + X, Y = sample_image.coordinate_corner_meshgrid() X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() sample_image = sample_image.data.npvalue @@ -264,7 +264,7 @@ def model_image( sample_image[target.mask.detach().cpu().numpy()] = np.nan # Plot the image - im = ax.pcolormesh(X, Y, sample_image, **imshow_kwargs) + im = ax.pcolormesh(X.T, Y.T, sample_image.T, **imshow_kwargs) # Enforce equal spacing on x y ax.axis("equal") @@ -403,7 +403,7 @@ def residual_image( "vmax": vmax, } imshow_kwargs.update(kwargs) - im = ax.pcolormesh(X, Y, residuals, **imshow_kwargs) + im = ax.pcolormesh(X.T, Y.T, residuals.T, **imshow_kwargs) ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") ax.set_ylabel("Tangent Plane Y [arcsec]") @@ -416,9 +416,11 @@ def residual_image( def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): + if target is None: + target = model.target if isinstance(ax, np.ndarray): for i, axitem in enumerate(ax): - model_window(fig, axitem, model, target=model.target.image_list[i], **kwargs) + model_window(fig, axitem, model, target=target.images[i], **kwargs) return fig, ax if isinstance(model, Group_Model): @@ -459,31 +461,19 @@ def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): ) ) else: - if isinstance(model.window, Window_List): - use_window = model.window.window_list[model.target.index(target)] - else: - use_window = model.window - lowright = use_window.pixel_shape.clone().to(dtype=AP_config.ap_dtype) - lowright[1] = 0.0 - lowright = use_window.origin + use_window.pixel_to_plane_delta(lowright) - lowright = lowright.detach().cpu().numpy() - upleft = use_window.pixel_shape.clone().to(dtype=AP_config.ap_dtype) - upleft[0] = 0.0 - upleft = use_window.origin + use_window.pixel_to_plane_delta(upleft) - upleft = upleft.detach().cpu().numpy() - end = use_window.origin + use_window.end - end = end.detach().cpu().numpy() + use_window = model.window + corners = target[use_window].corners() x = [ - use_window.origin[0].detach().cpu().numpy(), - lowright[0], - end[0], - upleft[0], + corners[0][0].item(), + corners[1][0].item(), + corners[2][0].item(), + corners[3][0].item(), ] y = [ - use_window.origin[1].detach().cpu().numpy(), - lowright[1], - end[1], - upleft[1], + corners[0][1].item(), + corners[1][1].item(), + corners[2][1].item(), + corners[3][1].item(), ] ax.add_patch( Polygon( diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index f77715f2..577cf91c 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -42,7 +42,7 @@ def radial_light_profile( ) flux = model.radial_model(xx, params=()).detach().cpu().numpy() if model.target.zeropoint is not None: - yy = flux_to_sb(flux, model.target.pixel_area.item(), model.target.zeropoint.item()) + yy = flux_to_sb(flux, 1.0, model.target.zeropoint.item()) else: yy = np.log10(flux) @@ -102,15 +102,15 @@ def radial_median_profile( Rlast_phys = Rlast_pix * model.target.pixel_length.item() Rbins = [0.0] - while Rbins[-1] < Rlast_pix: - Rbins.append(Rbins[-1] + max(2, Rbins[-1] * 0.1)) + while Rbins[-1] < Rlast_phys: + Rbins.append(Rbins[-1] + max(2 * model.target.pixel_length.item(), Rbins[-1] * 0.1)) Rbins = np.array(Rbins) with torch.no_grad(): image = model.target[model.window] x, y = image.coordinate_center_meshgrid() x, y = model.transform_coordinates(x, y, params=()) - R = (x**2 + y**2).sqrt() # (N,) + R = (x**2 + y**2).sqrt() R = R.detach().cpu().numpy() dat = image.data.value.detach().cpu().numpy() diff --git a/astrophot/utils/integration.py b/astrophot/utils/integration.py index eb124cc5..99517f7d 100644 --- a/astrophot/utils/integration.py +++ b/astrophot/utils/integration.py @@ -27,7 +27,7 @@ def quad_table(order, dtype, device): w = torch.tensor(weights, dtype=dtype, device=device) a = torch.tensor(abscissa, dtype=dtype, device=device) / 2.0 - di, dj = torch.meshgrid(a, a, indexing="xy") + di, dj = torch.meshgrid(a, a, indexing="ij") w = torch.outer(w, w) / 4.0 return di, dj, w diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index fa09c7f0..845e27a1 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -225,8 +225,8 @@ "# can still see how the covariance of the parameters plays out in a given fit.\n", "fig, ax = ap.plots.covariance_matrix(\n", " result.covariance_matrix.detach().cpu().numpy(),\n", - " model2.parameters.vector_values().detach().cpu().numpy(),\n", - " model2.parameters.vector_names(),\n", + " model2.build_params_array().detach().cpu().numpy(),\n", + " model2.build_params_array_names(),\n", ")\n", "plt.show()" ] @@ -247,13 +247,10 @@ "outputs": [], "source": [ "# note, we don't provide a name here. A unique name will automatically be generated using the model type\n", - "model3 = ap.models.AstroPhot_Model(\n", + "model3 = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " window=[\n", - " [480, 595],\n", - " [555, 665],\n", - " ], # this is a region in pixel coordinates ((xmin,xmax),(ymin,ymax))\n", + " window=[480, 595, 555, 665], # this is a region in pixel coordinates ((xmin,xmax),(ymin,ymax))\n", ")\n", "\n", "print(f\"automatically generated name: '{model3.name}'\")\n", @@ -272,9 +269,7 @@ "outputs": [], "source": [ "model3.initialize()\n", - "\n", - "result = ap.fit.LM(model3, verbose=1).fit()\n", - "print(result.message)" + "result = ap.fit.LM(model3, verbose=1).fit()" ] }, { @@ -309,14 +304,12 @@ "source": [ "# here we make a sersic model that can only have q and n in a narrow range\n", "# Also, we give PA and initial value and lock that so it does not change during fitting\n", - "constrained_param_model = ap.models.AstroPhot_Model(\n", + "constrained_param_model = ap.models.Model(\n", " name=\"constrained parameters\",\n", " model_type=\"sersic galaxy model\",\n", - " parameters={\n", - " \"q\": {\"limits\": [0.4, 0.6]},\n", - " \"n\": {\"limits\": [2, 3]},\n", - " \"PA\": {\"value\": 60 * np.pi / 180, \"locked\": True},\n", - " },\n", + " q={\"valid\": (0.4, 0.6)},\n", + " n={\"valid\": (2, 3)},\n", + " PA={\"value\": 60 * np.pi / 180},\n", ")" ] }, @@ -334,56 +327,32 @@ "outputs": [], "source": [ "# model 1 is a sersic model\n", - "model_1 = ap.models.AstroPhot_Model(\n", - " model_type=\"sersic galaxy model\", parameters={\"center\": [50, 50], \"PA\": np.pi / 4}\n", - ")\n", + "model_1 = ap.models.Model(model_type=\"sersic galaxy model\", center=[50, 50], PA=np.pi / 4)\n", "# model 2 is an exponential model\n", - "model_2 = ap.models.AstroPhot_Model(\n", - " model_type=\"exponential galaxy model\",\n", - ")\n", + "model_2 = ap.models.Model(model_type=\"exponential galaxy model\")\n", "\n", "# Here we add the constraint for \"PA\" to be the same for each model.\n", "# In doing so we provide the model and parameter name which should\n", "# be connected.\n", - "model_2[\"PA\"].value = model_1[\"PA\"]\n", + "model_2.PA = model_1.PA\n", "\n", "# Here we can see how the two models now both can modify this parameter\n", "print(\n", " \"initial values: model_1 PA\",\n", - " model_1[\"PA\"].value.item(),\n", + " model_1.PA.value.item(),\n", " \"model_2 PA\",\n", - " model_2[\"PA\"].value.item(),\n", + " model_2.PA.value.item(),\n", ")\n", "# Now we modify the PA for model_1\n", - "model_1[\"PA\"].value = np.pi / 3\n", + "model_1.PA.value = np.pi / 3\n", "print(\n", " \"change model_1: model_1 PA\",\n", - " model_1[\"PA\"].value.item(),\n", + " model_1.PA.value.item(),\n", " \"model_2 PA\",\n", - " model_2[\"PA\"].value.item(),\n", - ")\n", - "# Similarly we modify the PA for model_2\n", - "model_2[\"PA\"].value = np.pi / 2\n", - "print(\n", - " \"change model_2: model_1 PA\",\n", - " model_1[\"PA\"].value.item(),\n", - " \"model_2 PA\",\n", - " model_2[\"PA\"].value.item(),\n", + " model_2.PA.value.item(),\n", ")" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Keep in mind that both models have full control over the parameter, it is listed in both of\n", - "# their \"parameter_order\" tuples.\n", - "print(\"model_1 parameters: \", model_1.parameter_order)\n", - "print(\"model_2 parameters: \", model_2.parameter_order)" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -416,7 +385,7 @@ "# load a model from a file\n", "\n", "# note that the target still must be specified, only the parameters are saved\n", - "model4 = ap.models.AstroPhot_Model(name=\"new name\", filename=\"AstroPhot.yaml\", target=target)\n", + "model4 = ap.models.Model(name=\"new name\", filename=\"AstroPhot.yaml\", target=target)\n", "print(\n", " model4\n", ") # can see that it has been constructed with all the same parameters as the saved model2." From 102c9e12fd313eb3ad0d2c486c107f88158ec5ad Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 19 Jun 2025 21:12:16 -0400 Subject: [PATCH 025/191] first tutorial online --- astrophot/fit/lm.py | 4 +- astrophot/image/image_object.py | 105 ++++++++++---- astrophot/image/target_image.py | 156 +++++++++++---------- astrophot/image/window.py | 31 ++-- astrophot/models/base.py | 20 +-- astrophot/models/galaxy_model_object.py | 6 +- astrophot/models/mixins/sample.py | 5 +- astrophot/models/model_object.py | 16 ++- astrophot/param/param.py | 1 + astrophot/plots/diagnostic.py | 12 +- astrophot/plots/image.py | 2 +- astrophot/plots/visuals.py | 14 +- docs/source/tutorials/GettingStarted.ipynb | 101 ++++--------- 13 files changed, 246 insertions(+), 227 deletions(-) diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 9f568c71..d6e7f128 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -267,7 +267,7 @@ def fit(self) -> BaseOptimizer: for _ in range(self.max_iter): if self.verbose > 0: - AP_config.ap_logger.info(f"Chi^2/DoF: {self.loss_history[-1]}, L: {self.L}") + AP_config.ap_logger.info(f"Chi^2/DoF: {self.loss_history[-1]:.4g}, L: {self.L:.3g}") try: with ValidContext(self.model): res = func.lm_step( @@ -314,7 +314,7 @@ def fit(self) -> BaseOptimizer: if self.verbose > 0: AP_config.ap_logger.info( - f"Final Chi^2/DoF: {self.loss_history[-1]}, L: {self.L_history[-1]}. Converged: {self.message}" + f"Final Chi^2/DoF: {self.loss_history[-1]:.4g}, L: {self.L_history[-1]:.3g}. Converged: {self.message}" ) with ValidContext(self.model): diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 08940b07..1bf987db 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -3,12 +3,13 @@ import torch import numpy as np from astropy.wcs import WCS as AstropyWCS +from astropy.io import fits from ..param import Module, Param, forward from .. import AP_config from ..utils.conversions.units import deg_to_arcsec from .window import Window -from ..errors import SpecificationConflict, InvalidWindow, InvalidImage +from ..errors import SpecificationConflict, InvalidImage from . import func __all__ = ["Image", "Image_List"] @@ -41,13 +42,13 @@ def __init__( data: Optional[torch.Tensor] = None, pixelscale: Optional[Union[float, torch.Tensor]] = None, zeropoint: Optional[Union[float, torch.Tensor]] = None, + crpix: Union[torch.Tensor, tuple] = (0, 0), + crtan: Union[torch.Tensor, tuple] = (0.0, 0.0), + crval: Union[torch.Tensor, tuple] = (0.0, 0.0), wcs: Optional[AstropyWCS] = None, filename: Optional[str] = None, identity: str = None, - state: Optional[dict] = None, - fits_state: Optional[dict] = None, name: Optional[str] = None, - **kwargs: Any, ) -> None: """Initialize an instance of the APImage class. @@ -66,12 +67,11 @@ def __init__( """ super().__init__(name=name) - if state is not None: - self.set_state(state) - return - if fits_state is not None: - self.set_fits_state(fits_state) - return + self.data = Param("data", units="flux") + self.crval = Param("crval", units="deg") + self.crtan = Param("crtan", units="arcsec") + self.crpix = Param("crpix", units="pixel") + if filename is not None: self.load(filename) return @@ -91,12 +91,8 @@ def __init__( "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." ) - if "crpix" in kwargs or "crval" in kwargs: - AP_config.ap_logger.warning( - "WCS crpix/crval set with supplied WCS, ignoring user supplied crpix/crval!" - ) - kwargs["crval"] = wcs.wcs.crval - kwargs["crpix"] = wcs.wcs.crpix + crval = wcs.wcs.crval + crpix = wcs.wcs.crpix if pixelscale is not None: AP_config.ap_logger.warning( @@ -105,10 +101,10 @@ def __init__( pixelscale = deg_to_arcsec * wcs.pixel_scale_matrix # set the data - self.data = Param("data", data, units="flux") - self.crval = Param("crval", kwargs.get("crval", self.default_crval), units="deg") - self.crtan = Param("crtan", kwargs.get("crtan", self.default_crtan), units="arcsec") - self.crpix = Param("crpix", kwargs.get("crpix", self.default_crpix), units="pixel") + self.data = data + self.crval = crval + self.crtan = crtan + self.crpix = crpix self.pixelscale = pixelscale @@ -390,24 +386,71 @@ def reduce(self, scale: int, **kwargs): **kwargs, ) - def get_astropywcs(self, **kwargs): - wargs = { - "NAXIS": 2, - "NAXIS1": self.pixel_shape[0].item(), - "NAXIS2": self.pixel_shape[1].item(), + def fits_info(self): + return { "CTYPE1": "RA---TAN", "CTYPE2": "DEC--TAN", - "CRVAL1": self.pixel_to_world(self.reference_imageij)[0].item(), - "CRVAL2": self.pixel_to_world(self.reference_imageij)[1].item(), - "CRPIX1": self.reference_imageij[0].item(), - "CRPIX2": self.reference_imageij[1].item(), + "CRVAL1": self.crval.value[0].item(), + "CRVAL2": self.crval.value[1].item(), + "CRPIX1": self.crpix.value[0].item(), + "CRPIX2": self.crpix.value[1].item(), + "CRTAN1": self.crtan.value[0].item(), + "CRTAN2": self.crtan.value[1].item(), "CD1_1": self.pixelscale[0][0].item(), "CD1_2": self.pixelscale[0][1].item(), "CD2_1": self.pixelscale[1][0].item(), "CD2_2": self.pixelscale[1][1].item(), + "MAGZP": self.zeropoint.item() if self.zeropoint is not None else -999, + "IDNTY": self.identity, + } + + def fits_images(self): + return [ + fits.PrimaryHDU(self.data.value.cpu().numpy(), header=fits.Header(self.fits_info())) + ] + + def get_astropywcs(self, **kwargs): + kwargs = { + "NAXIS": 2, + "NAXIS1": self.shape[0].item(), + "NAXIS2": self.shape[1].item(), + **self.fits_info(), + **kwargs, } - wargs.update(kwargs) - return AstropyWCS(wargs) + return AstropyWCS(kwargs) + + def save(self, filename: str): + hdulist = fits.HDUList(self.fits_images()) + hdulist.writeto(filename, overwrite=True) + + def load(self, filename: str): + """Load an image from a FITS file. This will load the primary HDU + and set the data, pixelscale, crpix, crval, and crtan attributes + accordingly. If the WCS is not tangent plane, it will warn the user. + + """ + hdulist = fits.open(filename) + self.data = torch.as_tensor( + np.array(hdulist[0].data, dtype=np.float64), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + self.pixelscale = ( + (hdulist[0].header["CD1_1"], hdulist[0].header["CD1_2"]), + (hdulist[0].header["CD2_1"], hdulist[0].header["CD2_2"]), + ) + self.crpix = (hdulist[0].header["CRPIX1"], hdulist[0].header["CRPIX2"]) + self.crval = (hdulist[0].header["CRVAL1"], hdulist[0].header["CRVAL2"]) + if "CRTAN1" in hdulist[0].header and "CRTAN2" in hdulist[0].header: + self.crtan = (hdulist[0].header["CRTAN1"], hdulist[0].header["CRTAN2"]) + else: + self.crtan = (0.0, 0.0) + if "MAGZP" in hdulist[0].header and hdulist[0].header["MAGZP"] > -998: + self.zeropoint = hdulist[0].header["MAGZP"] + else: + self.zeropoint = None + self.identity = hdulist[0].header.get("IDNTY", str(id(self))) + return hdulist def corners(self): pixel_lowleft = torch.tensor( diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 511d49eb..7b212bd7 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -1,6 +1,8 @@ from typing import List, Optional +import numpy as np import torch +from astropy.io import fits from .image_object import Image, Image_List from .jacobian_image import Jacobian_Image, Jacobian_Image_List @@ -80,21 +82,21 @@ class Target_Image(Image): image_count = 0 - def __init__(self, *args, mask=None, variance=None, psf=None, **kwargs): + def __init__(self, *args, mask=None, variance=None, psf=None, weight=None, **kwargs): super().__init__(*args, **kwargs) if not self.has_mask: - self.set_mask(mask) - if not self.has_weight and "weight" in kwargs: - self.set_weight(kwargs.get("weight", None)) + self.mask = mask + if not self.has_weight and variance is None: + self.weight = weight elif not self.has_variance: - self.set_variance(variance) + self.variance = variance if not self.has_psf: self.set_psf(psf) # Set nan pixels to be masked automatically if torch.any(torch.isnan(self.data.value)).item(): - self.set_mask(torch.logical_or(self.mask, torch.isnan(self.data.value))) + self.mask = self.mask | torch.isnan(self.data.value) @property def standard_deviation(self): @@ -132,7 +134,13 @@ def variance(self): @variance.setter def variance(self, variance): - self.set_variance(variance) + if variance is None: + self._weight = None + return + if isinstance(variance, str) and variance == "auto": + self.weight = "auto" + return + self.weight = 1 / variance @property def has_variance(self): @@ -184,7 +192,16 @@ def weight(self): @weight.setter def weight(self, weight): - self.set_weight(weight) + if weight is None: + self._weight = None + return + if isinstance(weight, str) and weight == "auto": + weight = 1 / auto_variance(self.data.value, self.mask) + if weight.shape != self.data.shape: + raise SpecificationConflict( + f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" + ) + self._weight = torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device) @property def has_weight(self): @@ -220,7 +237,14 @@ def mask(self): @mask.setter def mask(self, mask): - self.set_mask(mask) + if mask is None: + self._mask = None + return + if mask.shape != self.data.shape: + raise SpecificationConflict( + f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" + ) + self._mask = torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device) @property def has_mask(self): @@ -232,34 +256,6 @@ def has_mask(self): except AttributeError: return False - def set_variance(self, variance): - """ - Provide a variance tensor for the image. Variance is equal to :math:`\\sigma^2`. This should have the same shape as the data. - """ - if variance is None: - self._weight = None - return - if isinstance(variance, str) and variance == "auto": - self.set_weight("auto") - return - self.set_weight(1 / variance) - - def set_weight(self, weight): - """Provide a weight tensor for the image. Weight is equal to :math:`\\frac{1}{\\sigma^2}`. This should have the same - shape as the data. - - """ - if weight is None: - self._weight = None - return - if isinstance(weight, str) and weight == "auto": - weight = 1 / auto_variance(self.data.value, self.mask) - if weight.shape != self.data.shape: - raise SpecificationConflict( - f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" - ) - self._weight = torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - @property def has_psf(self): """Returns True when the target image object has a PSF model.""" @@ -301,19 +297,6 @@ def set_psf(self, psf): pixelscale=self.pixelscale, ) - def set_mask(self, mask): - """ - Set the boolean mask which will indicate which pixels to ignore. A mask value of True means the pixel will be ignored. - """ - if mask is None: - self._mask = None - return - if mask.shape != self.data.shape: - raise SpecificationConflict( - f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" - ) - self._mask = torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device) - def to(self, dtype=None, device=None): """Converts the stored `Target_Image` data, variance, psf, etc to a given data type and device. @@ -360,6 +343,44 @@ def get_window(self, other, **kwargs): **kwargs, ) + def fits_images(self): + images = super().fits_images() + if self.has_variance: + images.append(fits.ImageHDU(self.weight.cpu().numpy(), name="WEIGHT")) + if self.has_mask: + images.append(fits.ImageHDU(self.mask.cpu().numpy(), name="MASK")) + if self.has_psf: + if isinstance(self.psf, PSF_Image): + images.append( + fits.ImageHDU( + self.psf.data.npvalue, name="PSF", header=fits.Header(self.psf.fits_info()) + ) + ) + else: + AP_config.ap_logger.warning("Unable to save PSF to FITS, not a PSF_Image.") + return images + + def load(self, filename: str): + """Load the image from a FITS file. This will load the data, WCS, and + any ancillary data such as variance, mask, and PSF. + + """ + hdulist = super().load(filename) + if "WEIGHT" in hdulist: + self.weight = np.array(hdulist["WEIGHT"].data, dtype=np.float64) + if "MASK" in hdulist: + self.mask = np.array(hdulist["MASK"].data, dtype=bool) + if "PSF" in hdulist: + self.set_psf( + PSF_Image( + data=np.array(hdulist["PSF"].data, dtype=np.float64), + pixelscale=( + (hdulist["PSF"].header["CD1_1"], hdulist["PSF"].header["CD1_2"]), + (hdulist["PSF"].header["CD2_1"], hdulist["PSF"].header["CD2_2"]), + ), + ) + ) + def jacobian_image( self, parameters: Optional[List[str]] = None, @@ -378,26 +399,22 @@ def jacobian_image( dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) - copy_kwargs = { + kwargs = { "pixelscale": self.pixelscale, - "crpix": self.crpix, + "crpix": self.crpix.value, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, "identity": self.identity, + **kwargs, } - copy_kwargs.update(kwargs) - return Jacobian_Image( - parameters=parameters, - data=data, - **copy_kwargs, - ) + return Jacobian_Image(parameters=parameters, data=data, **kwargs) def model_image(self, **kwargs): """ Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ - copy_kwargs = { + kwargs = { "data": torch.zeros_like(self.data.value), "pixelscale": self.pixelscale, "crpix": self.crpix.value, @@ -405,11 +422,9 @@ def model_image(self, **kwargs): "crtan": self.crtan.value, "zeropoint": self.zeropoint, "identity": self.identity, + **kwargs, } - copy_kwargs.update(kwargs) - return Model_Image( - **copy_kwargs, - ) + return Model_Image(**kwargs) def reduce(self, scale, **kwargs): """Returns a new `Target_Image` object with a reduced resolution @@ -461,7 +476,7 @@ def variance(self): @variance.setter def variance(self, variance): for image, var in zip(self.images, variance): - image.set_variance(var) + image.variance = var @property def has_variance(self): @@ -474,7 +489,7 @@ def weight(self): @weight.setter def weight(self, weight): for image, wgt in zip(self.images, weight): - image.set_weight(wgt) + image.weight = wgt @property def has_weight(self): @@ -549,7 +564,7 @@ def mask(self): @mask.setter def mask(self, mask): for image, M in zip(self.images, mask): - image.set_mask(M) + image.mask = M @property def has_mask(self): @@ -575,12 +590,3 @@ def psf_border(self): @property def psf_border_int(self): return tuple(image.psf_border_int for image in self.images) - - def set_variance(self, variance, img): - self.images[img].set_variance(variance) - - def set_psf(self, psf, img): - self.images[img].set_psf(psf) - - def set_mask(self, mask, img): - self.images[img].set_mask(mask) diff --git a/astrophot/image/window.py b/astrophot/image/window.py index a436310d..3f6c6d04 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -14,18 +14,7 @@ def __init__( crpix: Tuple[float, float], image: "Image", ): - if len(window) == 4: - self.i_low = window[0] - self.i_high = window[1] - self.j_low = window[2] - self.j_high = window[3] - elif len(window) == 2: - self.i_low, self.j_low = window[0] - self.i_high, self.j_high = window[1] - else: - raise InvalidWindow( - "Window must be a tuple of 4 integers or 2 tuples of 2 integers each" - ) + self.extent = window self.crpix = np.asarray(crpix) self.image = image @@ -37,6 +26,24 @@ def identity(self): def shape(self): return (self.i_high - self.i_low, self.j_high - self.j_low) + @property + def extent(self): + return (self.i_low, self.i_high, self.j_low, self.j_high) + + @extent.setter + def extent( + self, value: Union[Tuple[int, int, int, int], Tuple[Tuple[int, int], Tuple[int, int]]] + ): + if len(value) == 4: + self.i_low, self.i_high, self.j_low, self.j_high = value + elif len(value) == 2: + self.i_low, self.j_low = value[0] + self.i_high, self.j_high = value[1] + else: + raise ValueError( + "Extent must be formatted as (i_low, i_high, j_low, j_high) or ((i_low, j_low), (i_high, j_high))" + ) + def chunk(self, chunk_size: int): # number of pixels on each axis px = self.i_high - self.i_low diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 849cbbae..d26c3650 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -121,10 +121,8 @@ def __init__(self, *, name=None, target=None, window=None, mask=None, filename=N for key in parameter_specs: setattr(self, key, Param(key, **parameter_specs[key])) - # If loading from a file, get model configuration then exit __init__ - if filename is not None: - self.load(filename, new_name=name) - return + self.saveattrs.update(self.options) + self.saveattrs.add("window.extent") kwargs.pop("model_type", None) # model_type is set by __new__ if len(kwargs) > 0: @@ -276,13 +274,15 @@ def window(self, window): raise InvalidWindow(f"Unrecognized window format: {str(window)}") @classmethod - def List_Models(cls, usable: Optional[bool] = None) -> set: + def List_Models(cls, usable: Optional[bool] = None, types: bool = False) -> set: MODELS = func.all_subclasses(cls) - if usable is not None: - for model in list(MODELS): - if model.usable is not usable: - MODELS.remove(model) - return MODELS + result = set() + for model in MODELS: + if types: + result.add(model.model_type) + elif model.usable is usable or usable is None: + result.add(model) + return result @forward def __call__( diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index a3e8a7f9..283544c0 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -60,9 +60,9 @@ def initialize(self, **kwargs): icenter = target_area.plane_to_pixel(*self.center.value) i, j = target_area.pixel_center_meshgrid() i, j = (i - icenter[0]).detach().cpu().numpy(), (j - icenter[1]).detach().cpu().numpy() - mu20 = np.sum(target_dat * i**2) # fixme try median? - mu02 = np.sum(target_dat * j**2) - mu11 = np.sum(target_dat * i * j) + mu20 = np.median(target_dat * i**2) # fixme try median? + mu02 = np.median(target_dat * j**2) + mu11 = np.median(target_dat * i * j) M = np.array([[mu20, mu11], [mu11, mu02]]) if self.PA.value is None: self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 7e89bd53..160d15fa 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -128,6 +128,7 @@ def jacobian( params = self.build_params_array() identities = self.build_params_array_identities() + target = self.target[window] if len(params) > self.jacobian_maxparams: # handle large number of parameters chunksize = len(params) // self.jacobian_maxparams + 1 for i in range(chunksize, len(params), chunksize): @@ -135,13 +136,13 @@ def jacobian( params_post = params[i + chunksize :] params_chunk = params[i : i + chunksize] jac_chunk = self._jacobian(window, params_pre, params_chunk, params_post) - jac_img += self.target[window].jacobian_image( + jac_img += target.jacobian_image( parameters=identities[i : i + chunksize], data=jac_chunk, ) else: jac = self._jacobian(window, params[:0], params, params[0:0]) - jac_img += self.target[window].jacobian_image(parameters=identities, data=jac) + jac_img += target.jacobian_image(parameters=identities, data=jac) return jac_img diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 2217e941..e4545deb 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -83,7 +83,6 @@ class Component_Model(SampleMixin, Model): _options = ( "psf_mode", "psf_subpixel_shift", - "sampling_tolerance", "integrate_mode", "integrate_max_depth", "integrate_gridding", @@ -114,7 +113,7 @@ def psf(self, val): AP_config.ap_logger.warning( "Setting PSF with pixel matrix, assuming target pixelscale is the same as " "PSF pixelscale. To remove this warning, set PSFs as an ap.image.PSF_Image " - "or ap.models.AstroPhot_Model object instead." + "or ap.models.Model object instead." ) @property @@ -271,3 +270,16 @@ def sample( working_image.data = working_image.data * (~self.mask) return working_image + + def get_state(self): + """Get the state of the model, including parameters and PSF.""" + state = super().get_state() + if self._psf is not None: + state["psf"] = self.psf.get_state() + return state + + def set_state(self, state): + """Set the state of the model, including parameters and PSF.""" + super().set_state(state) + if "psf" in state: + self.psf = PSF_Image(state=state["psf"]) diff --git a/astrophot/param/param.py b/astrophot/param/param.py index e04ae1c7..b09efb9c 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -11,6 +11,7 @@ class Param(CParam): def __init__(self, *args, uncertainty=None, **kwargs): super().__init__(*args, **kwargs) self.uncertainty = uncertainty + self.saveattrs.add("uncertainty") @property def uncertainty(self): diff --git a/astrophot/plots/diagnostic.py b/astrophot/plots/diagnostic.py index a9c161ba..75a9e4e4 100644 --- a/astrophot/plots/diagnostic.py +++ b/astrophot/plots/diagnostic.py @@ -3,6 +3,7 @@ from matplotlib.patches import Ellipse from matplotlib import pyplot as plt from scipy.stats import norm +from .visuals import main_pallet __all__ = ("covariance_matrix",) @@ -13,7 +14,7 @@ def covariance_matrix( labels=None, figsize=(10, 10), reference_values=None, - ellipse_colors="g", + ellipse_colors=main_pallet["primary1"], showticks=True, **kwargs, ): @@ -32,13 +33,13 @@ def covariance_matrix( 100, ) y = norm.pdf(x, mean[i], np.sqrt(covariance_matrix[i, i])) - ax.plot(x, y, color="g") + ax.plot(x, y, color=ellipse_colors, lw=1.5) ax.set_xlim( mean[i] - 3 * np.sqrt(covariance_matrix[i, i]), mean[i] + 3 * np.sqrt(covariance_matrix[i, i]), ) if reference_values is not None: - ax.axvline(reference_values[i], color="red", linestyle="-", lw=1) + ax.axvline(reference_values[i], color=main_pallet["pop"], linestyle="-", lw=1) elif j < i: cov = covariance_matrix[np.ix_([j, i], [j, i])] lambda_, v = np.linalg.eig(cov) @@ -52,6 +53,7 @@ def covariance_matrix( angle=angle, edgecolor=ellipse_colors, facecolor="none", + lw=1.5, ) ax.add_artist(ellipse) @@ -67,8 +69,8 @@ def covariance_matrix( ) if reference_values is not None: - ax.axvline(reference_values[j], color="red", linestyle="-", lw=1) - ax.axhline(reference_values[i], color="red", linestyle="-", lw=1) + ax.axvline(reference_values[j], color=main_pallet["pop"], linestyle="-", lw=1) + ax.axhline(reference_values[i], color=main_pallet["pop"], linestyle="-", lw=1) if j > i: ax.axis("off") diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 5755d2ea..a7482beb 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -71,7 +71,7 @@ def target_image(fig, ax, target, window=None, **kwargs): X.T, Y.T, dat.T, - cmap="Greys", + cmap="gray_r", norm=ImageNormalize( stretch=HistEqStretch( dat[np.logical_and(dat <= (sky + 3 * noise), np.isfinite(dat))] diff --git a/astrophot/plots/visuals.py b/astrophot/plots/visuals.py index e77a2587..39f9c836 100644 --- a/astrophot/plots/visuals.py +++ b/astrophot/plots/visuals.py @@ -3,13 +3,13 @@ __all__ = ["main_pallet", "cmap_grad", "cmap_div"] main_pallet = { - "primary1": "tab:green", - "primary2": "limegreen", - "primary3": "lime", - "secondary1": "tab:blue", - "secondary2": "blue", - "pop": "tab:orange", + "primary1": "tab:blue", + "primary2": "tab:orange", + "primary3": "tab:red", + "secondary1": "tab:green", + "secondary2": "tab:purple", + "pop": "tab:pink", } cmap_grad = get_cmap("inferno") -cmap_div = get_cmap("seismic") +cmap_div = get_cmap("RdBu_r") diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 845e27a1..43241fa5 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -18,14 +18,12 @@ "%load_ext autoreload\n", "%autoreload 2\n", "\n", - "import os\n", "import astrophot as ap\n", "import numpy as np\n", "import torch\n", "from astropy.io import fits\n", "from astropy.wcs import WCS\n", "import matplotlib.pyplot as plt\n", - "from time import time\n", "\n", "%matplotlib inline" ] @@ -368,12 +366,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Save the model to a file\n", + "# Save the model state to a file\n", "\n", - "model2.save() # will default to save as AstroPhot.yaml\n", - "\n", - "with open(\"AstroPhot.yaml\", \"r\") as f:\n", - " print(f.read()) # show what the saved file looks like" + "model2.save_state(\"current_spot.hdf5\", appendable=True) # save as it is\n", + "model2.q = 0.1 # do some updates to the model\n", + "model2.PA = 0.1\n", + "model2.n = 0.9\n", + "model2.Re = 0.1\n", + "model2.append_state(\"current_spot.hdf5\") # save the updated model state as often as you like" ] }, { @@ -382,13 +382,10 @@ "metadata": {}, "outputs": [], "source": [ - "# load a model from a file\n", + "# load a model state from a file\n", "\n", - "# note that the target still must be specified, only the parameters are saved\n", - "model4 = ap.models.Model(name=\"new name\", filename=\"AstroPhot.yaml\", target=target)\n", - "print(\n", - " model4\n", - ") # can see that it has been constructed with all the same parameters as the saved model2." + "model2.load_state(\"current_spot.hdf5\", index=0) # load the first state from the file\n", + "print(model2) # see that the values are back to where they started" ] }, { @@ -437,6 +434,7 @@ "\n", "target.save(\"target.fits\")\n", "\n", + "# Note that it is often also possible to load from regular FITS files\n", "new_target = ap.image.Target_Image(filename=\"target.fits\")\n", "\n", "fig, ax = plt.subplots(figsize=(8, 8))\n", @@ -444,35 +442,6 @@ "plt.show()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Give the model new parameter values manually\n", - "\n", - "print(\n", - " \"parameter input order: \", model4.parameter_order\n", - ") # use this to see what order you have to give the parameters as input\n", - "\n", - "# plot the old model\n", - "fig9, ax9 = plt.subplots(1, 2, figsize=(16, 6))\n", - "ap.plots.model_image(fig9, ax9[0], model4)\n", - "T = ax9[0].set_title(\"parameters as loaded\")\n", - "\n", - "# update and plot the new parameters\n", - "new_parameters = torch.tensor(\n", - " [75, 110, 0.4, 20 * np.pi / 180, 3, 25, 0.12]\n", - ") # note that the center parameter needs two values as input\n", - "model4.initialize() # initialize must be called before optimization, or any other activity in which parameters are updated\n", - "model4.parameters.vector_set_values(\n", - " new_parameters\n", - ") # full_sample will update the parameters, then run sample and return the model image\n", - "ap.plots.model_image(fig9, ax9[1], model4)\n", - "T = ax9[1].set_title(\"new parameter values\")" - ] - }, { "cell_type": "code", "execution_count": null, @@ -483,9 +452,7 @@ "\n", "fig2, ax2 = plt.subplots(figsize=(8, 8))\n", "\n", - "pixels = (\n", - " model4().data.detach().cpu().numpy()\n", - ") # model4.model_image.data is the pytorch stored model image pixel values. Calling detach().cpu().numpy() is needed to get the data out of pytorch and in a usable form\n", + "pixels = model2().data.npvalue\n", "\n", "im = plt.imshow(\n", " np.log10(pixels), # take log10 for better dynamic range\n", @@ -525,31 +492,11 @@ ")\n", "\n", "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", - "ap.plots.target_image(\n", - " fig3, ax3, target, flipx=True\n", - ") # note we flip the x-axis since RA coordinates are backwards\n", + "ax3.invert_xaxis() # note we flip the x-axis since RA coordinates are backwards\n", + "ap.plots.target_image(fig3, ax3, target)\n", "plt.show()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Models can be constructed by providing model_type, or by creating the desired class directly\n", - "\n", - "# notice this is no longer \"AstroPhot_Model\"\n", - "model1_v2 = ap.models.Sersic_Galaxy(\n", - " parameters={\"center\": [50, 50], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"n\": 2, \"Re\": 10, \"Ie\": 1},\n", - " target=ap.image.Target_Image(data=np.zeros((100, 100)), pixelscale=1),\n", - " psf_mode=\"full\", # only change is the psf_mode\n", - ")\n", - "\n", - "# This will be the same as model1, except note that the \"psf_mode\" keyword is now tracked since it isn't a default value\n", - "print(model1_v2)" - ] - }, { "cell_type": "code", "execution_count": null, @@ -558,14 +505,12 @@ "source": [ "# List all the available model names\n", "\n", - "# AstroPhot keeps track of all the subclasses of the AstroPhot_Model object, this list will\n", + "# AstroPhot keeps track of all the subclasses of the AstroPhot Model object, this list will\n", "# include all models even ones added by the user\n", - "print(\n", - " ap.models.AstroPhot_Model.List_Model_Names(usable=True)\n", - ") # set usable = None for all models, or usable = False for only base classes\n", + "print(ap.models.Model.List_Models(usable=True, types=True))\n", "print(\"---------------------------\")\n", "# It is also possible to get all sub models of a specific Type\n", - "print(\"only warp models: \", ap.models.Warp_Galaxy.List_Model_Names())" + "print(\"only galaxy models: \", ap.models.Galaxy_Model.List_Models(types=True))" ] }, { @@ -618,14 +563,16 @@ "ap.AP_config.ap_dtype = torch.float32\n", "\n", "# Now new AstroPhot objects will be made with single bit precision\n", - "W1 = ap.image.Window(origin=[0, 0], pixel_shape=[1, 1], pixelscale=1)\n", - "print(\"now a single:\", W1.origin.dtype)\n", + "T1 = ap.image.Target_Image(data=np.zeros((100, 100)), pixelscale=1.0)\n", + "T1.to()\n", + "print(\"now a single:\", T1.data.value.dtype)\n", "\n", "# Here we switch back to double precision\n", "ap.AP_config.ap_dtype = torch.float64\n", - "W2 = ap.image.Window(origin=[0, 0], pixel_shape=[1, 1], pixelscale=1)\n", - "print(\"back to double:\", W2.origin.dtype)\n", - "print(\"old window is still single:\", W1.origin.dtype)" + "T2 = ap.image.Target_Image(data=np.zeros((100, 100)), pixelscale=1.0)\n", + "T2.to()\n", + "print(\"back to double:\", T2.data.value.dtype)\n", + "print(\"old image is still single!:\", T1.data.value.dtype)" ] }, { From 42297645a80752b74d81e47de07019a38f9ed3f7 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 20 Jun 2025 17:01:22 -0400 Subject: [PATCH 026/191] PSF models getting online --- astrophot/image/__init__.py | 30 ++-- astrophot/image/image_object.py | 149 +++++------------- astrophot/image/jacobian_image.py | 16 +- astrophot/image/model_image.py | 114 +++++++++----- astrophot/image/psf_image.py | 61 ++++---- astrophot/image/target_image.py | 100 +++++++------ astrophot/image/window.py | 28 +++- astrophot/models/__init__.py | 26 +++- astrophot/models/base.py | 23 +-- astrophot/models/exponential_model.py | 38 ++--- astrophot/models/galaxy_model_object.py | 46 +----- astrophot/models/group_model_object.py | 50 +++---- astrophot/models/mixins/sample.py | 100 +++++++++---- astrophot/models/mixins/transform.py | 48 ++++++ astrophot/models/model_object.py | 110 ++++---------- astrophot/models/point_source.py | 166 +++++++-------------- astrophot/models/psf_model_object.py | 67 ++------- astrophot/models/sersic_model.py | 48 +++--- astrophot/plots/visuals.py | 8 +- astrophot/utils/initialize/__init__.py | 3 +- astrophot/utils/initialize/center.py | 26 ++++ docs/source/tutorials/BasicPSFModels.ipynb | 2 +- docs/source/tutorials/GettingStarted.ipynb | 2 +- 23 files changed, 595 insertions(+), 666 deletions(-) diff --git a/astrophot/image/__init__.py b/astrophot/image/__init__.py index 61c19c45..730b026e 100644 --- a/astrophot/image/__init__.py +++ b/astrophot/image/__init__.py @@ -1,21 +1,21 @@ -from .image_object import Image, Image_List -from .target_image import Target_Image, Target_Image_List -from .jacobian_image import Jacobian_Image, Jacobian_Image_List -from .psf_image import PSF_Image -from .model_image import Model_Image, Model_Image_List -from .window import Window, Window_List +from .image_object import Image, ImageList +from .target_image import TargetImage, TargetImageList +from .jacobian_image import JacobianImage, JacobianImageList +from .psf_image import PSFImage +from .model_image import ModelImage, ModelImageList +from .window import Window, WindowList __all__ = ( "Image", - "Image_List", - "Target_Image", - "Target_Image_List", - "Jacobian_Image", - "Jacobian_Image_List", - "PSF_Image", - "Model_Image", - "Model_Image_List", + "ImageList", + "TargetImage", + "TargetImageList", + "JacobianImage", + "JacobianImageList", + "PSFImage", + "ModelImage", + "ModelImageList", "Window", - "Window_List", + "WindowList", ) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 1bf987db..cdc12ca7 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -12,7 +12,7 @@ from ..errors import SpecificationConflict, InvalidImage from . import func -__all__ = ["Image", "Image_List"] +__all__ = ["Image", "ImageList"] class Image(Module): @@ -127,12 +127,12 @@ def zeropoint(self, value): @property def window(self): - return Window(window=((0, 0), self.data.shape), crpix=self.crpix.npvalue, image=self) + return Window(window=((0, 0), self.data.shape[:2]), image=self) @property def center(self): shape = torch.as_tensor( - self.data.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device + self.data.shape[:2], dtype=AP_config.ap_dtype, device=AP_config.ap_device ) return self.pixel_to_plane(*((shape - 1) / 2)) @@ -309,83 +309,11 @@ def to(self, dtype=None, device=None): self.zeropoint = self.zeropoint.to(dtype=dtype, device=device) return self - def crop(self, pixels, **kwargs): - """Crop the image by the number of pixels given. This will crop - the image in all four directions by the number of pixels given. - - given data shape (N, M) the new shape will be: - - crop - int: crop the same number of pixels on all sides. new shape (N - 2*crop, M - 2*crop) - crop - (int, int): crop each dimension by the number of pixels given. new shape (N - 2*crop[1], M - 2*crop[0]) - crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) - """ - if isinstance(pixels, int) or len(pixels) == 1: # same crop in all dimension - crop = pixels if isinstance(pixels, int) else pixels[0] - data = self.data.value[ - crop : self.data.shape[0] - crop, - crop : self.data.shape[1] - crop, - ] - crpix = self.crpix.value - crop - elif len(pixels) == 2: # different crop in each dimension - data = self.data.value[ - pixels[1] : self.data.shape[0] - pixels[1], - pixels[0] : self.data.shape[1] - pixels[0], - ] - crpix = self.crpix.value - pixels - elif len(pixels) == 4: # different crop on all sides - data = self.data.value[ - pixels[2] : self.data.shape[0] - pixels[3], - pixels[0] : self.data.shape[1] - pixels[1], - ] - crpix = self.crpix.value - pixels[0::2] # fixme - else: - raise ValueError( - f"Invalid crop shape {pixels}, must be int, (int,), (int, int), or (int, int, int, int)!" - ) - return self.copy(data=data, crpix=crpix, **kwargs) - def flatten(self, attribute: str = "data") -> torch.Tensor: if attribute in self.children: return getattr(self, attribute).value.reshape(-1) return getattr(self, attribute).reshape(-1) - def reduce(self, scale: int, **kwargs): - """This operation will downsample an image by the factor given. If - scale = 2 then 2x2 blocks of pixels will be summed together to - form individual larger pixels. A new image object will be - returned with the appropriate pixelscale and data tensor. Note - that the window does not change in this operation since the - pixels are condensed, but the pixel size is increased - correspondingly. - - Parameters: - scale: factor by which to condense the image pixels. Each scale X scale region will be summed [int] - - """ - if not isinstance(scale, int) and not ( - isinstance(scale, torch.Tensor) and scale.dtype is torch.int32 - ): - raise SpecificationConflict(f"Reduce scale must be an integer! not {type(scale)}") - if scale == 1: - return self - - MS = self.data.shape[0] // scale - NS = self.data.shape[1] // scale - - data = ( - self.data.value[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .sum(axis=(1, 3)) - ) - pixelscale = self.pixelscale * scale - crpix = (self.crpix.value + 0.5) / scale - 0.5 - return self.copy( - data=data, - pixelscale=pixelscale, - crpix=crpix, - **kwargs, - ) - def fits_info(self): return { "CTYPE1": "RA---TAN", @@ -474,34 +402,35 @@ def corners(self): return (lowleft, lowright, upright, upleft) @torch.no_grad() - def get_indices(self, other: Union[Window, "Image"]): - if isinstance(other, Window): - shift = np.round(self.crpix.npvalue - other.crpix).astype(int) - return slice( - min(max(0, other.i_low + shift[0]), self.shape[0]), - max(0, min(other.i_high + shift[0], self.shape[0])), - ), slice( - min(max(0, other.j_low + shift[1]), self.shape[1]), - max(0, min(other.j_high + shift[1], self.shape[1])), - ) - - origin_pix = torch.tensor( - (-0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device + def get_indices(self, other: Window): + if other.image == self: + return slice(other.i_low, other.i_high), slice(other.j_low, other.j_high) + shift = np.round(self.crpix.npvalue - other.crpix.npvalue).astype(int) + return slice( + min(max(0, other.i_low + shift[0]), self.shape[0]), + max(0, min(other.i_high + shift[0], self.shape[0])), + ), slice( + min(max(0, other.j_low + shift[1]), self.shape[1]), + max(0, min(other.j_high + shift[1], self.shape[1])), ) - origin_pix = self.plane_to_pixel(*other.pixel_to_plane(*origin_pix)) - origin_pix = torch.round(torch.stack(origin_pix) + 0.5).int() - new_origin_pix = torch.maximum(torch.zeros_like(origin_pix), origin_pix) - end_pix = torch.tensor( - (other.data.shape[0] - 0.5, other.data.shape[1] - 0.5), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - end_pix = self.plane_to_pixel(*other.pixel_to_plane(*end_pix)) - end_pix = torch.round(torch.stack(end_pix) + 0.5).int() - shape = torch.tensor(self.data.shape[:2], dtype=torch.int32, device=AP_config.ap_device) - new_end_pix = torch.minimum(shape, end_pix) - return slice(new_origin_pix[0], new_end_pix[0]), slice(new_origin_pix[1], new_end_pix[1]) + # origin_pix = torch.tensor( + # (-0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device + # ) + # origin_pix = self.plane_to_pixel(*other.pixel_to_plane(*origin_pix)) + # origin_pix = torch.round(torch.stack(origin_pix) + 0.5).int() + # new_origin_pix = torch.maximum(torch.zeros_like(origin_pix), origin_pix) + + # end_pix = torch.tensor( + # (other.data.shape[0] - 0.5, other.data.shape[1] - 0.5), + # dtype=AP_config.ap_dtype, + # device=AP_config.ap_device, + # ) + # end_pix = self.plane_to_pixel(*other.pixel_to_plane(*end_pix)) + # end_pix = torch.round(torch.stack(end_pix) + 0.5).int() + # shape = torch.tensor(self.data.shape[:2], dtype=torch.int32, device=AP_config.ap_device) + # new_end_pix = torch.minimum(shape, end_pix) + # return slice(new_origin_pix[0], new_end_pix[0]), slice(new_origin_pix[1], new_end_pix[1]) def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): """Get a new image object which is a window of this image @@ -511,7 +440,7 @@ def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): """ if _indices is None: - indices = self.get_indices(other) + indices = self.get_indices(other if isinstance(other, Window) else other.window) else: indices = _indices new_img = self.copy( @@ -566,7 +495,7 @@ def __getitem__(self, *args): return super().__getitem__(*args) -class Image_List(Module): +class ImageList(Module): def __init__(self, images): self.images = list(images) if not all(isinstance(image, Image) for image in self.images): @@ -601,7 +530,7 @@ def blank_copy(self): tuple(image.blank_copy() for image in self.images), ) - def get_window(self, other: "Image_List"): + def get_window(self, other: "ImageList"): return self.__class__( tuple(image[win] for image, win in zip(self.images, other.images)), ) @@ -613,7 +542,7 @@ def index(self, other: Image): else: raise ValueError("Could not find identity match between image list and input image") - def match_indices(self, other: "Image_List"): + def match_indices(self, other: "ImageList"): """Match the indices of the images in this list with those in another Image_List.""" indices = [] for other_image in other.images: @@ -639,7 +568,7 @@ def flatten(self, attribute="data"): return torch.cat(tuple(image.flatten(attribute) for image in self.images)) def __sub__(self, other): - if isinstance(other, Image_List): + if isinstance(other, ImageList): new_list = [] for other_image in other.images: i = self.index(other_image) @@ -650,7 +579,7 @@ def __sub__(self, other): raise ValueError("Subtraction of Image_List only works with another Image_List object!") def __add__(self, other): - if isinstance(other, Image_List): + if isinstance(other, ImageList): new_list = [] for other_image in other.images: i = self.index(other_image) @@ -661,7 +590,7 @@ def __add__(self, other): raise ValueError("Addition of Image_List only works with another Image_List object!") def __isub__(self, other): - if isinstance(other, Image_List): + if isinstance(other, ImageList): for other_image in other.images: i = self.index(other_image) self.images[i] -= other_image @@ -673,7 +602,7 @@ def __isub__(self, other): return self def __iadd__(self, other): - if isinstance(other, Image_List): + if isinstance(other, ImageList): for other_image in other.images: i = self.index(other_image) self.images[i] += other_image @@ -685,7 +614,7 @@ def __iadd__(self, other): return self def __getitem__(self, *args): - if len(args) == 1 and isinstance(args[0], Image_List): + if len(args) == 1 and isinstance(args[0], ImageList): new_list = [] for other_image in args[0].images: i = self.index(other_image) diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 57be8e0c..7806b1fe 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -2,15 +2,15 @@ import torch -from .image_object import Image, Image_List +from .image_object import Image, ImageList from .. import AP_config from ..errors import SpecificationConflict, InvalidImage -__all__ = ["Jacobian_Image", "Jacobian_Image_List"] +__all__ = ["JacobianImage", "JacobianImageList"] ###################################################################### -class Jacobian_Image(Image): +class JacobianImage(Image): """Jacobian of a model evaluated in an image. Image object which represents the evaluation of a jacobian on an @@ -39,8 +39,8 @@ def flatten(self, attribute: str = "data"): def copy(self, **kwargs): return super().copy(parameters=self.parameters, **kwargs) - def __iadd__(self, other: "Jacobian_Image"): - if not isinstance(other, Jacobian_Image): + def __iadd__(self, other: "JacobianImage"): + if not isinstance(other, JacobianImage): raise InvalidImage("Jacobian images can only add with each other, not: type(other)") # exclude null jacobian images @@ -49,8 +49,8 @@ def __iadd__(self, other: "Jacobian_Image"): if self.data.value is None: return other - self_indices = self.get_indices(other) - other_indices = other.get_indices(self) + self_indices = self.get_indices(other.window) + other_indices = other.get_indices(self.window) for i, other_identity in enumerate(other.parameters): if other_identity in self.parameters: other_loc = self.parameters.index(other_identity) @@ -73,7 +73,7 @@ def __iadd__(self, other: "Jacobian_Image"): ###################################################################### -class Jacobian_Image_List(Image_List, Jacobian_Image): +class JacobianImageList(ImageList, JacobianImage): """For joint modelling, represents Jacobians evaluated on a list of images. diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index c2418487..a389f8eb 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -2,14 +2,14 @@ import torch from .. import AP_config -from .image_object import Image, Image_List +from .image_object import Image, ImageList from ..errors import InvalidImage -__all__ = ["Model_Image", "Model_Image_List"] +__all__ = ["ModelImage", "ModelImageList"] ###################################################################### -class Model_Image(Image): +class ModelImage(Image): """Image object which represents the sampling of a model at the given coordinates of the image. Extra arithmetic operations are available which can update model values in the image. The whole @@ -22,7 +22,9 @@ def __init__(self, *args, window=None, upsample=1, pad=0, **kwargs): if window is not None: kwargs["pixelscale"] = window.image.pixelscale / upsample kwargs["crpix"] = ( - (window.crpix - np.array((window.i_low, window.j_low)) + 0.5) * upsample + pad - 0.5 + (window.crpix.npvalue - np.array((window.i_low, window.j_low)) + 0.5) * upsample + + pad + - 0.5 ) kwargs["crval"] = window.image.crval.value kwargs["crtan"] = window.image.crtan.value @@ -40,36 +42,84 @@ def __init__(self, *args, window=None, upsample=1, pad=0, **kwargs): def clear_image(self): self.data._value = torch.zeros_like(self.data.value) - def shift_crtan(self, shift): - # self.data = shift_Lanczos_torch( - # self.data, - # pix_shift[0], - # pix_shift[1], - # min(min(self.data.shape), 10), - # dtype=AP_config.ap_dtype, - # device=AP_config.ap_device, - # img_prepadded=is_prepadded, - # ) - self.crtan._value += shift - - def replace(self, other): - if isinstance(other, Image): - self_indices = self.get_indices(other) - other_indices = other.get_indices(self) - sub_self = self.data._value[self_indices] - sub_other = other.data._value[other_indices] - if sub_self.numel() == 0 or sub_other.numel() == 0: - return - self.data._value[self_indices] = sub_other + def crop(self, pixels, **kwargs): + """Crop the image by the number of pixels given. This will crop + the image in all four directions by the number of pixels given. + + given data shape (N, M) the new shape will be: + + crop - int: crop the same number of pixels on all sides. new shape (N - 2*crop, M - 2*crop) + crop - (int, int): crop each dimension by the number of pixels given. new shape (N - 2*crop[1], M - 2*crop[0]) + crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) + """ + if isinstance(pixels, int) or len(pixels) == 1: # same crop in all dimension + crop = pixels if isinstance(pixels, int) else pixels[0] + data = self.data.value[ + crop : self.data.shape[0] - crop, + crop : self.data.shape[1] - crop, + ] + crpix = self.crpix.value - crop + elif len(pixels) == 2: # different crop in each dimension + data = self.data.value[ + pixels[1] : self.data.shape[0] - pixels[1], + pixels[0] : self.data.shape[1] - pixels[0], + ] + crpix = self.crpix.value - pixels + elif len(pixels) == 4: # different crop on all sides + data = self.data.value[ + pixels[2] : self.data.shape[0] - pixels[3], + pixels[0] : self.data.shape[1] - pixels[1], + ] + crpix = self.crpix.value - pixels[0::2] # fixme else: - raise TypeError(f"Model_Image can only replace with Image objects, not {type(other)}") + raise ValueError( + f"Invalid crop shape {pixels}, must be int, (int,), (int, int), or (int, int, int, int)!" + ) + return self.copy(data=data, crpix=crpix, **kwargs) + + def reduce(self, scale: int, **kwargs): + """This operation will downsample an image by the factor given. If + scale = 2 then 2x2 blocks of pixels will be summed together to + form individual larger pixels. A new image object will be + returned with the appropriate pixelscale and data tensor. Note + that the window does not change in this operation since the + pixels are condensed, but the pixel size is increased + correspondingly. + + Parameters: + scale: factor by which to condense the image pixels. Each scale X scale region will be summed [int] + + """ + if not isinstance(scale, int) and not ( + isinstance(scale, torch.Tensor) and scale.dtype is torch.int32 + ): + raise SpecificationConflict(f"Reduce scale must be an integer! not {type(scale)}") + if scale == 1: + return self + + MS = self.data.shape[0] // scale + NS = self.data.shape[1] // scale + + data = ( + self.data.value[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .sum(axis=(1, 3)) + ) + pixelscale = self.pixelscale * scale + crpix = (self.crpix.value + 0.5) / scale - 0.5 + return self.copy( + data=data, + pixelscale=pixelscale, + crpix=crpix, + **kwargs, + ) ###################################################################### -class Model_Image_List(Image_List): +class ModelImageList(ImageList): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not all(isinstance(image, Model_Image) for image in self.images): + if not all(isinstance(image, ModelImage) for image in self.images): raise InvalidImage( f"Model_Image_List can only hold Model_Image objects, not {tuple(type(image) for image in self.images)}" ) @@ -77,11 +127,3 @@ def __init__(self, *args, **kwargs): def clear_image(self): for image in self.images: image.clear_image() - - def replace(self, other, data=None): - if data is None: - for image, oth in zip(self.images, other): - image.replace(oth) - else: - for image, oth, dat in zip(self.images, other, data): - image.replace(oth, dat) diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index c5a14185..96990663 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -4,14 +4,14 @@ import numpy as np from .image_object import Image -from .model_image import Model_Image -from .jacobian_image import Jacobian_Image +from .model_image import ModelImage +from .jacobian_image import JacobianImage from .. import AP_config -__all__ = ["PSF_Image"] +__all__ = ["PSFImage"] -class PSF_Image(Image): +class PSFImage(Image): """Image object which represents a model of PSF (Point Spread Function). PSF_Image inherits from the base Image class and represents the model of a point spread function. @@ -36,7 +36,7 @@ class PSF_Image(Image): def __init__(self, *args, **kwargs): kwargs.update({"crval": (0, 0), "crpix": (0, 0), "crtan": (0, 0)}) super().__init__(*args, **kwargs) - self.crpix = np.flip(np.array(self.data.shape, dtype=float) - 1.0) / 2 + self.crpix = (np.array(self.data.shape, dtype=float) - 1.0) / 2 def normalize(self): """Normalizes the PSF image to have a sum of 1.""" @@ -55,20 +55,11 @@ def psf_border_int(self): torch.Tensor: The border size of the PSF image in integer format. """ - return torch.ceil( - ( - 1 - + torch.flip( - torch.tensor( - self.data.shape, - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ), - (0,), - ) - ) - / 2 - ).int() + return torch.tensor( + self.data.shape, + dtype=torch.int32, + device=AP_config.ap_device, + ) def jacobian_image( self, @@ -88,21 +79,29 @@ def jacobian_image( dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) - return Jacobian_Image( - parameters=parameters, - target_identity=self.identity, - data=data, - header=self.header, + kwargs = { + "pixelscale": self.pixelscale, + "crpix": self.crpix.value, + "crval": self.crval.value, + "crtan": self.crtan.value, + "zeropoint": self.zeropoint, + "identity": self.identity, **kwargs, - ) + } + return JacobianImage(parameters=parameters, data=data, **kwargs) - def model_image(self, data: Optional[torch.Tensor] = None, **kwargs): + def model_image(self, **kwargs): """ Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ - return Model_Image( - data=torch.zeros_like(self.data.value) if data is None else data, - header=self.header, - target_identity=self.identity, + kwargs = { + "data": torch.zeros_like(self.data.value), + "pixelscale": self.pixelscale, + "crpix": self.crpix.value, + "crval": self.crval.value, + "crtan": self.crtan.value, + "zeropoint": self.zeropoint, + "identity": self.identity, **kwargs, - ) + } + return ModelImage(**kwargs) diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 7b212bd7..b8c21a92 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -1,21 +1,22 @@ -from typing import List, Optional +from typing import List, Optional, Union import numpy as np import torch from astropy.io import fits -from .image_object import Image, Image_List -from .jacobian_image import Jacobian_Image, Jacobian_Image_List -from .model_image import Model_Image, Model_Image_List -from .psf_image import PSF_Image +from .image_object import Image, ImageList +from .window import Window +from .jacobian_image import JacobianImage, JacobianImageList +from .model_image import ModelImage, ModelImageList +from .psf_image import PSFImage from .. import AP_config from ..utils.initialize import auto_variance from ..errors import SpecificationConflict, InvalidImage -__all__ = ["Target_Image", "Target_Image_List"] +__all__ = ["TargetImage", "TargetImageList"] -class Target_Image(Image): +class TargetImage(Image): """Image object which represents the data to be fit by a model. It can include a variance image, mask, and PSF as anciliary data which describes the target image. @@ -92,7 +93,7 @@ def __init__(self, *args, mask=None, variance=None, psf=None, weight=None, **kwa elif not self.has_variance: self.variance = variance if not self.has_psf: - self.set_psf(psf) + self.psf = psf # Set nan pixels to be masked automatically if torch.any(torch.isnan(self.data.value)).item(): @@ -260,11 +261,28 @@ def has_mask(self): def has_psf(self): """Returns True when the target image object has a PSF model.""" try: - return self.psf is not None + return self._psf is not None except AttributeError: return False - def set_psf(self, psf): + @property + def psf(self): + """The PSF for the `Target_Image`. This is used to convolve the + model with the PSF before evaluating the likelihood. The PSF + should be a `PSF_Image` object or an `AstroPhot` PSF_Model. + + If no PSF is provided, then the image will not be convolved + with a PSF and the model will be evaluated directly on the + image pixels. + + """ + try: + return self._psf + except AttributeError: + return None + + @psf.setter + def psf(self, psf): """Provide a psf for the `Target_Image`. This is stored and passed to models which need to be convolved. @@ -274,25 +292,25 @@ def set_psf(self, psf): the psf may have a pixelscale of 1, 1/2, 1/3, 1/4 and so on. """ - if hasattr(self, "psf"): - del self.psf # remove old psf if it exists + if hasattr(self, "_psf"): + del self._psf # remove old psf if it exists from ..models import Model if psf is None: - self.psf = None - elif isinstance(psf, PSF_Image): - self.psf = psf + self._psf = None + elif isinstance(psf, PSFImage): + self._psf = psf elif isinstance(psf, Model): - self.psf = PSF_Image( - data=lambda p: p.psf_model(), + self._psf = PSFImage( + data=lambda p: p.psf_model().data.value, pixelscale=psf.target.pixelscale, ) - self.psf.link("psf_model", psf) + self._psf.link("psf_model", psf) else: AP_config.ap_logger.warning( - "PSF provided is not a PSF_Image or AstroPhot_Model, assuming its pixelscale is the same as this Target_Image." + "PSF provided is not a PSF_Image or AstroPhot PSF_Model, assuming its pixelscale is the same as this Target_Image." ) - self.psf = PSF_Image( + self._psf = PSFImage( data=psf, pixelscale=self.pixelscale, ) @@ -331,9 +349,9 @@ def blank_copy(self, **kwargs): kwargs = {"mask": self._mask, "psf": self.psf, "weight": self._weight, **kwargs} return super().blank_copy(**kwargs) - def get_window(self, other, **kwargs): + def get_window(self, other: Union[Image, Window], **kwargs): """Get a sub-region of the image as defined by an other image on the sky.""" - indices = self.get_indices(other) + indices = self.get_indices(other if isinstance(other, Window) else other.window) return super().get_window( other, weight=self._weight[indices] if self.has_weight else None, @@ -350,7 +368,7 @@ def fits_images(self): if self.has_mask: images.append(fits.ImageHDU(self.mask.cpu().numpy(), name="MASK")) if self.has_psf: - if isinstance(self.psf, PSF_Image): + if isinstance(self.psf, PSFImage): images.append( fits.ImageHDU( self.psf.data.npvalue, name="PSF", header=fits.Header(self.psf.fits_info()) @@ -371,14 +389,12 @@ def load(self, filename: str): if "MASK" in hdulist: self.mask = np.array(hdulist["MASK"].data, dtype=bool) if "PSF" in hdulist: - self.set_psf( - PSF_Image( - data=np.array(hdulist["PSF"].data, dtype=np.float64), - pixelscale=( - (hdulist["PSF"].header["CD1_1"], hdulist["PSF"].header["CD1_2"]), - (hdulist["PSF"].header["CD2_1"], hdulist["PSF"].header["CD2_2"]), - ), - ) + self.psf = PSFImage( + data=np.array(hdulist["PSF"].data, dtype=np.float64), + pixelscale=( + (hdulist["PSF"].header["CD1_1"], hdulist["PSF"].header["CD1_2"]), + (hdulist["PSF"].header["CD2_1"], hdulist["PSF"].header["CD2_2"]), + ), ) def jacobian_image( @@ -408,7 +424,7 @@ def jacobian_image( "identity": self.identity, **kwargs, } - return Jacobian_Image(parameters=parameters, data=data, **kwargs) + return JacobianImage(parameters=parameters, data=data, **kwargs) def model_image(self, **kwargs): """ @@ -424,7 +440,7 @@ def model_image(self, **kwargs): "identity": self.identity, **kwargs, } - return Model_Image(**kwargs) + return ModelImage(**kwargs) def reduce(self, scale, **kwargs): """Returns a new `Target_Image` object with a reduced resolution @@ -461,10 +477,10 @@ def reduce(self, scale, **kwargs): ) -class Target_Image_List(Image_List): +class TargetImageList(ImageList): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not all(isinstance(image, Target_Image) for image in self.images): + if not all(isinstance(image, TargetImage) for image in self.images): raise InvalidImage( f"Target_Image_List can only hold Target_Image objects, not {tuple(type(image) for image in self.images)}" ) @@ -498,16 +514,16 @@ def has_weight(self): def jacobian_image(self, parameters: List[str], data: Optional[List[torch.Tensor]] = None): if data is None: data = [None] * len(self.images) - return Jacobian_Image_List( + return JacobianImageList( list(image.jacobian_image(parameters, dat) for image, dat in zip(self.images, data)) ) def model_image(self): - return Model_Image_List(list(image.model_image() for image in self.images)) + return ModelImageList(list(image.model_image() for image in self.images)) def match_indices(self, other): indices = [] - if isinstance(other, Target_Image_List): + if isinstance(other, TargetImageList): for other_image in other.images: for isi, self_image in enumerate(self.images): if other_image.identity == self_image.identity: @@ -515,7 +531,7 @@ def match_indices(self, other): break else: indices.append(None) - elif isinstance(other, Target_Image): + elif isinstance(other, TargetImage): for isi, self_image in enumerate(self.images): if other.identity == self_image.identity: indices = isi @@ -525,7 +541,7 @@ def match_indices(self, other): return indices def __isub__(self, other): - if isinstance(other, Image_List): + if isinstance(other, ImageList): for other_image in other.images: for self_image in self.images: if other_image.identity == self_image.identity: @@ -542,7 +558,7 @@ def __isub__(self, other): return self def __iadd__(self, other): - if isinstance(other, Image_List): + if isinstance(other, ImageList): for other_image in other.images: for self_image in self.images: if other_image.identity == self_image.identity: @@ -577,7 +593,7 @@ def psf(self): @psf.setter def psf(self, psf): for image, P in zip(self.images, psf): - image.set_psf(P) + image.psf = P @property def has_psf(self): diff --git a/astrophot/image/window.py b/astrophot/image/window.py index 3f6c6d04..ce206d99 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -11,17 +11,19 @@ class Window: def __init__( self, window: Union[Tuple[int, int, int, int], Tuple[Tuple[int, int], Tuple[int, int]]], - crpix: Tuple[float, float], image: "Image", ): self.extent = window - self.crpix = np.asarray(crpix) self.image = image @property def identity(self): return self.image.identity + @property + def crpix(self): + return self.image.crpix + @property def shape(self): return (self.i_high - self.i_low, self.j_high - self.j_low) @@ -62,7 +64,7 @@ def chunk(self, chunk_size: int): for j in range(self.j_low, self.j_high, stepy): i_high = min(i + stepx, self.i_high) j_high = min(j + stepy, self.j_high) - windows.append(Window((i, i_high, j, j_high), self.crpix, self.image)) + windows.append(Window((i, i_high, j, j_high), self.image)) return windows def pad(self, pad: int): @@ -74,15 +76,23 @@ def pad(self, pad: int): def __or__(self, other: "Window"): if not isinstance(other, Window): raise TypeError(f"Cannot combine Window with {type(other)}") + if self.image != other.image: + raise InvalidWindow( + f"Cannot combine Windows from different images: {self.image.identity} and {other.image.identity}" + ) new_i_low = min(self.i_low, other.i_low) new_i_high = max(self.i_high, other.i_high) new_j_low = min(self.j_low, other.j_low) new_j_high = max(self.j_high, other.j_high) - return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.crpix) + return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.image) def __ior__(self, other: "Window"): if not isinstance(other, Window): raise TypeError(f"Cannot combine Window with {type(other)}") + if self.image != other.image: + raise InvalidWindow( + f"Cannot combine Windows from different images: {self.image.identity} and {other.image.identity}" + ) self.i_low = min(self.i_low, other.i_low) self.i_high = max(self.i_high, other.i_high) self.j_low = min(self.j_low, other.j_low) @@ -92,21 +102,25 @@ def __ior__(self, other: "Window"): def __and__(self, other: "Window"): if not isinstance(other, Window): raise TypeError(f"Cannot intersect Window with {type(other)}") + if self.image != other.image: + raise InvalidWindow( + f"Cannot combine Windows from different images: {self.image.identity} and {other.image.identity}" + ) if ( self.i_high <= other.i_low or self.i_low >= other.i_high or self.j_high <= other.j_low or self.j_low >= other.j_high ): - return Window(0, 0, 0, 0, self.crpix) + return Window((0, 0, 0, 0), self.image) new_i_low = max(self.i_low, other.i_low) new_i_high = min(self.i_high, other.i_high) new_j_low = max(self.j_low, other.j_low) new_j_high = min(self.j_high, other.j_high) - return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.crpix) + return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.image) -class Window_List: +class WindowList: def __init__(self, windows: list[Window]): if not all(isinstance(window, Window) for window in windows): raise InvalidWindow( diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 31b81a45..f0c4f6f8 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -1,9 +1,11 @@ from .base import Model -from .model_object import Component_Model -from .galaxy_model_object import Galaxy_Model -from .sersic_model import Sersic_Galaxy -from .group_model_object import Group_Model -from .exponential_model import * +from .model_object import ComponentModel +from .galaxy_model_object import GalaxyModel +from .sersic_model import SersicGalaxy, SersicPSF +from .group_model_object import GroupModel +from .exponential_model import ExponentialGalaxy +from .point_source import PointSource +from .psf_model_object import PSFModel # from .ray_model import * # from .sky_model_object import * @@ -12,7 +14,6 @@ # from .gaussian_model import * # from .multi_gaussian_expansion_model import * # from .spline_model import * -# from .psf_model_object import * # from .pixelated_psf_model import * # from .eigen_psf import * # from .superellipse_model import * @@ -24,7 +25,16 @@ # from .nuker_model import * # from .zernike_model import * # from .airy_psf import * -# from .point_source import * # from .group_psf_model import * -__all__ = ("Model", "Component_Model", "Galaxy_Model", "Sersic_Galaxy", "Group_Model") +__all__ = ( + "Model", + "ComponentModel", + "GalaxyModel", + "SersicGalaxy", + "SersicPSF", + "GroupModel", + "ExponentialGalaxy", + "PointSource", + "PSFModel", +) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index d26c3650..abfcadf2 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -5,7 +5,7 @@ from ..param import Module, forward, Param from ..utils.decorators import classproperty -from ..image import Window, Image_List, Model_Image, Model_Image_List +from ..image import Window, ImageList, ModelImage, ModelImageList from ..errors import UnrecognizedModel, InvalidWindow from . import func @@ -147,7 +147,7 @@ def options(cls) -> set: for subcls in cls.mro(): if subcls is object: continue - options.update(getattr(subcls, "_options", [])) + options.update(subcls.__dict__.get("_options", [])) return options @classproperty @@ -189,7 +189,7 @@ def gaussian_negative_log_likelihood( weight = data.weight mask = data.mask data = data.data - if isinstance(data, Image_List): + if isinstance(data, ImageList): nll = sum( torch.sum(((mo - da) ** 2 * wgt)[~ma]) / 2.0 for mo, da, wgt, ma in zip(model, data, weight, mask) @@ -214,7 +214,7 @@ def poisson_negative_log_likelihood( mask = data.mask data = data.data - if isinstance(data, Image_List): + if isinstance(data, ImageList): nll = sum( torch.sum((mo - da * (mo + 1e-10).log() + torch.lgamma(da + 1))[~ma]) for mo, da, ma in zip(model, data, mask) @@ -254,22 +254,13 @@ def window(self) -> Optional[Window]: @window.setter def window(self, window): if window is None: - # If no window given, set to none self._window = None elif isinstance(window, Window): - # If window object given, use that self._window = window elif len(window) == 2: - # If window given in pixels, use relative to target - self._window = Window( - (window[1], window[0]), crpix=self.target.crpix.value, image=self.target - ) + self._window = Window((window[1], window[0]), image=self.target) elif len(window) == 4: - self._window = Window( - (window[2], window[3], window[0], window[1]), - crpix=self.target.crpix.value, - image=self.target, - ) + self._window = Window((window[2], window[3], window[0], window[1]), image=self.target) else: raise InvalidWindow(f"Unrecognized window format: {str(window)}") @@ -289,6 +280,6 @@ def __call__( self, window: Optional[Window] = None, **kwargs, - ) -> Union[Model_Image, Model_Image_List]: + ) -> Union[ModelImage, ModelImageList]: return self.sample(window=window, **kwargs) diff --git a/astrophot/models/exponential_model.py b/astrophot/models/exponential_model.py index 3da822ab..f978b113 100644 --- a/astrophot/models/exponential_model.py +++ b/astrophot/models/exponential_model.py @@ -1,16 +1,17 @@ -from .galaxy_model_object import Galaxy_Model +from .galaxy_model_object import GalaxyModel # from .warp_model import Warp_Galaxy # from .ray_model import Ray_Galaxy -# from .psf_model_object import PSF_Model +from .psf_model_object import PSFModel + # from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp # from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp # from .wedge_model import Wedge_Galaxy from .mixins import ExponentialMixin # , iExponentialMixin __all__ = [ - "Exponential_Galaxy", - # "Exponential_PSF", + "ExponentialGalaxy", + "ExponentialPSF", # "Exponential_SuperEllipse", # "Exponential_SuperEllipse_Warp", # "Exponential_Warp", @@ -19,7 +20,7 @@ ] -class Exponential_Galaxy(ExponentialMixin, Galaxy_Model): +class ExponentialGalaxy(ExponentialMixin, GalaxyModel): """basic galaxy model with a exponential profile for the radial light profile. The light profile is defined as: @@ -39,25 +40,24 @@ class Exponential_Galaxy(ExponentialMixin, Galaxy_Model): usable = True -# class Exponential_PSF(ExponentialMixin, PSF_Model): -# """basic point source model with a exponential profile for the radial light -# profile. +class ExponentialPSF(ExponentialMixin, PSFModel): + """basic point source model with a exponential profile for the radial light + profile. -# I(R) = Ie * exp(-b1(R/Re - 1)) + I(R) = Ie * exp(-b1(R/Re - 1)) -# where I(R) is the brightness as a function of semi-major axis, Ie -# is the brightness at the half light radius, b1 is a constant not -# involved in the fit, R is the semi-major axis, and Re is the -# effective radius. + where I(R) is the brightness as a function of semi-major axis, Ie + is the brightness at the half light radius, b1 is a constant not + involved in the fit, R is the semi-major axis, and Re is the + effective radius. -# Parameters: -# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness -# Re: half light radius, represented in arcsec. This parameter cannot go below zero. + Parameters: + Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness + Re: half light radius, represented in arcsec. This parameter cannot go below zero. -# """ + """ -# usable = True -# model_integrated = False + usable = True # class Exponential_SuperEllipse(ExponentialMixin, SuperEllipse_Galaxy): diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index 283544c0..fb07831b 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -1,16 +1,11 @@ -import torch -import numpy as np - -from . import func -from ..utils.decorators import ignore_numpy_warnings -from .model_object import Component_Model +from .model_object import ComponentModel from .mixins import InclinedMixin -__all__ = ["Galaxy_Model"] +__all__ = ["GalaxyModel"] -class Galaxy_Model(InclinedMixin, Component_Model): +class GalaxyModel(InclinedMixin, ComponentModel): """General galaxy model to be subclassed for any specific representation. Defines a galaxy as an object with a position angle and axis ratio, or effectively a tilted disk. Most @@ -34,38 +29,3 @@ class Galaxy_Model(InclinedMixin, Component_Model): _model_type = "galaxy" usable = False - - @torch.no_grad() - @ignore_numpy_warnings - def initialize(self, **kwargs): - super().initialize() - - if not (self.PA.value is None or self.q.value is None): - return - target_area = self.target[self.window] - target_dat = target_area.data.npvalue - if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() - target_dat[mask] = np.median(target_dat[~mask]) - edge = np.concatenate( - ( - target_dat[:, 0], - target_dat[:, -1], - target_dat[0, :], - target_dat[-1, :], - ) - ) - edge_average = np.nanmedian(edge) - target_dat -= edge_average - icenter = target_area.plane_to_pixel(*self.center.value) - i, j = target_area.pixel_center_meshgrid() - i, j = (i - icenter[0]).detach().cpu().numpy(), (j - icenter[1]).detach().cpu().numpy() - mu20 = np.median(target_dat * i**2) # fixme try median? - mu02 = np.median(target_dat * j**2) - mu11 = np.median(target_dat * i * j) - M = np.array([[mu20, mu11], [mu11, mu02]]) - if self.PA.value is None: - self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi - if self.q.value is None: - l = np.sort(np.linalg.eigvals(M)) - self.q.dynamic_value = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index f15f0398..9f1d0d39 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -6,22 +6,22 @@ from .base import Model from ..image import ( Image, - Target_Image, - Target_Image_List, - Model_Image, - Model_Image_List, - Image_List, + TargetImage, + TargetImageList, + ModelImage, + ModelImageList, + ImageList, Window, - Window_List, - Jacobian_Image, + WindowList, + JacobianImage, ) from ..utils.decorators import ignore_numpy_warnings from ..errors import InvalidTarget -__all__ = ["Group_Model"] +__all__ = ["GroupModel"] -class Group_Model(Model): +class GroupModel(Model): """Model object which represents a list of other models. For each general AstroPhot model method, this calls all the appropriate models from its list and combines their output into a single @@ -56,17 +56,17 @@ def update_window(self): sub models in this group model object. """ - if isinstance(self.target, Image_List): # Window_List if target is a Target_Image_List + if isinstance(self.target, ImageList): # Window_List if target is a Target_Image_List new_window = [None] * len(self.target.images) for model in self.models.values(): - if isinstance(model.target, Image_List): + if isinstance(model.target, ImageList): for target, window in zip(model.target, model.window): index = self.target.index(target) if new_window[index] is None: new_window[index] = window.copy() else: new_window[index] |= window - elif isinstance(model.target, Target_Image): + elif isinstance(model.target, TargetImage): index = self.target.index(model.target) if new_window[index] is None: new_window[index] = model.window.copy() @@ -76,7 +76,7 @@ def update_window(self): raise NotImplementedError( f"Group_Model cannot construct a window for itself using {type(model.target)} object. Must be a Target_Image" ) - new_window = Window_List(new_window) + new_window = WindowList(new_window) else: new_window = None for model in self.models.values(): @@ -109,12 +109,12 @@ def fit_mask(self) -> torch.Tensor: """ subtarget = self.target[self.window] - if isinstance(self.target, Image_List): + if isinstance(self.target, ImageList): mask = tuple(torch.ones_like(submask) for submask in subtarget.mask) for model in self.models.values(): model_subtarget = model.target[model.window] model_fit_mask = model.fit_mask() - if isinstance(model.target, Image_List): + if isinstance(model.target, ImageList): for target, submask in zip(model_subtarget, model_fit_mask): index = subtarget.index(target) group_indices = subtarget.images[index].get_indices(target) @@ -138,7 +138,7 @@ def fit_mask(self) -> torch.Tensor: def sample( self, window: Optional[Window] = None, - ) -> Union[Model_Image, Model_Image_List]: + ) -> Union[ModelImage, ModelImageList]: """Sample the group model on an image. Produces the flux values for each pixel associated with the models in this group. Each model is called individually and the results are added @@ -156,17 +156,17 @@ def sample( for model in self.models.values(): if window is None: use_window = model.window - elif isinstance(image, Image_List) and isinstance(model.target, Image_List): + elif isinstance(image, ImageList) and isinstance(model.target, ImageList): indices = image.match_indices(model.target) if len(indices) == 0: continue - use_window = Window_List(window_list=list(image.images[i].window for i in indices)) - elif isinstance(image, Image_List) and isinstance(model.target, Image): + use_window = WindowList(window_list=list(image.images[i].window for i in indices)) + elif isinstance(image, ImageList) and isinstance(model.target, Image): try: image.index(model.target) except ValueError: continue - elif isinstance(image, Image) and isinstance(model.target, Image_List): + elif isinstance(image, Image) and isinstance(model.target, ImageList): try: model.target.index(image) except ValueError: @@ -186,9 +186,9 @@ def sample( @torch.no_grad() def jacobian( self, - pass_jacobian: Optional[Jacobian_Image] = None, + pass_jacobian: Optional[JacobianImage] = None, window: Optional[Window] = None, - ) -> Jacobian_Image: + ) -> JacobianImage: """Compute the jacobian for this model. Done by first constructing a full jacobian (Npixels * Nparameters) of zeros then call the jacobian method of each sub model and add it in to the total. @@ -219,14 +219,14 @@ def __iter__(self): return (mod for mod in self.models.values()) @property - def target(self) -> Optional[Union[Target_Image, Target_Image_List]]: + def target(self) -> Optional[Union[TargetImage, TargetImageList]]: try: return self._target except AttributeError: return None @target.setter - def target(self, tar: Optional[Union[Target_Image, Target_Image_List]]): - if not (tar is None or isinstance(tar, (Target_Image, Target_Image_List))): + def target(self, tar: Optional[Union[TargetImage, TargetImageList]]): + if not (tar is None or isinstance(tar, (TargetImage, TargetImageList))): raise InvalidTarget("Group_Model target must be a Target_Image instance.") self._target = tar diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 160d15fa..ac0dcda2 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -19,41 +19,43 @@ class SampleMixin: # Maximum size of parameter list before jacobian will be broken into smaller chunks, this is helpful for limiting the memory requirements to build a model, lower jacobian_chunksize is slower but uses less memory jacobian_maxparams = 10 jacobian_maxpixels = 1000**2 - - _options = ("sampling_mode", "jacobian_maxparams", "jacobian_maxpixels") - - @forward - def sample_image(self, image: Image): - if self.sampling_mode == "auto": - N = np.prod(image.data.shape) - if N <= 100: - sampling_mode = "quad:5" - elif N <= 10000: - sampling_mode = "simpsons" - else: - sampling_mode = "midpoint" + integrate_mode = "threshold" # none, threshold + integrate_tolerance = 1e-3 # total flux fraction + integrate_max_depth = 3 + integrate_gridding = 5 + integrate_quad_order = 3 + + _options = ( + "sampling_mode", + "jacobian_maxparams", + "jacobian_maxpixels", + "psf_subpixel_shift", + "integrate_mode", + "integrate_tolerance", + "integrate_max_depth", + "integrate_gridding", + "integrate_quad_order", + ) + + def shift_kernel(self, shift): + if self.psf_subpixel_shift == "bilinear": + return func.bilinear_kernel(shift[0], shift[1]) + elif self.psf_subpixel_shift.startswith("lanczos:"): + order = int(self.psf_subpixel_shift.split(":")[1]) + return func.lanczos_kernel(shift[0], shift[1], order) + elif self.psf_subpixel_shift == "none": + return torch.tensor( + [[0, 0, 0], [0, 1, 0], [0, 0, 0]], + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) else: - sampling_mode = self.sampling_mode - if sampling_mode == "midpoint": - x, y = image.coordinate_center_meshgrid() - res = self.brightness(x, y) - return func.pixel_center_integrator(res) - elif sampling_mode == "simpsons": - x, y = image.coordinate_simpsons_meshgrid() - res = self.brightness(x, y) - return func.pixel_simpsons_integrator(res) - elif sampling_mode.startswith("quad:"): - order = int(self.sampling_mode.split(":")[1]) - i, j, w = image.pixel_quad_meshgrid(order=order) - x, y = image.pixel_to_plane(i, j) - res = self.brightness(x, y) - return func.pixel_quad_integrator(res, w) - raise SpecificationConflict( - f"Unknown sampling mode {self.sampling_mode} for model {self.name}" - ) + raise SpecificationConflict( + f"Unknown PSF subpixel shift mode {self.psf_subpixel_shift} for model {self.name}" + ) @forward - def sample_integrate(self, sample, image: Image): + def _sample_integrate(self, sample, image: Image): i, j = image.pixel_center_meshgrid() kernel = func.curvature_kernel(AP_config.ap_dtype, AP_config.ap_device) curvature = ( @@ -85,6 +87,40 @@ def sample_integrate(self, sample, image: Image): ) return sample + @forward + def sample_image(self, image: Image): + if self.sampling_mode == "auto": + N = np.prod(image.data.shape) + if N <= 100: + sampling_mode = "quad:5" + elif N <= 10000: + sampling_mode = "simpsons" + else: + sampling_mode = "midpoint" + else: + sampling_mode = self.sampling_mode + if sampling_mode == "midpoint": + x, y = image.coordinate_center_meshgrid() + res = self.brightness(x, y) + sample = func.pixel_center_integrator(res) + elif sampling_mode == "simpsons": + x, y = image.coordinate_simpsons_meshgrid() + res = self.brightness(x, y) + sample = func.pixel_simpsons_integrator(res) + elif sampling_mode.startswith("quad:"): + order = int(self.sampling_mode.split(":")[1]) + i, j, w = image.pixel_quad_meshgrid(order=order) + x, y = image.pixel_to_plane(i, j) + res = self.brightness(x, y) + sample = func.pixel_quad_integrator(res, w) + else: + raise SpecificationConflict( + f"Unknown sampling mode {self.sampling_mode} for model {self.name}" + ) + if self.integrate_mode == "threshold": + sample = self._sample_integrate(sample, image) + return sample + def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor): return jacobian( lambda x: self.sample( diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 6f0b6da4..c5f70c76 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -1,4 +1,7 @@ import numpy as np +import torch + +from ...utils.decorators import ignore_numpy_warnings from ...param import forward @@ -24,6 +27,51 @@ class InclinedMixin: }, } + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if not (self.PA.value is None or self.q.value is None): + return + target_area = self.target[self.window] + target_dat = target_area.data.npvalue + if target_area.has_mask: + mask = target_area.mask.detach().cpu().numpy() + target_dat[mask] = np.median(target_dat[~mask]) + edge = np.concatenate( + ( + target_dat[:, 0], + target_dat[:, -1], + target_dat[0, :], + target_dat[-1, :], + ) + ) + edge_average = np.nanmedian(edge) + target_dat -= edge_average + x, y = target_area.coordinate_center_meshgrid() + x = (x - self.center.value[0]).detach().cpu().numpy() + y = (y - self.center.value[1]).detach().cpu().numpy() + mu20 = np.median(target_dat * np.abs(x)) + mu02 = np.median(target_dat * np.abs(y)) + mu11 = np.median(target_dat * x * y / np.sqrt(np.abs(x * y))) + # mu20 = np.median(target_dat * x**2) + # mu02 = np.median(target_dat * y**2) + # mu11 = np.median(target_dat * x * y) + M = np.array([[mu20, mu11], [mu11, mu02]]) + if self.PA.value is None: + if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): + self.PA.dynamic_value = np.pi / 2 + else: + self.PA.dynamic_value = ( + 0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2 + ) % np.pi + if self.q.value is None: + l = np.sort(np.linalg.eigvals(M)) + if np.any(np.iscomplex(l)) or np.any(~np.isfinite(l)): + l = (0.7, 1.0) + self.q.dynamic_value = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) + @forward def transform_coordinates(self, x, y, PA, q): """ diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index e4545deb..9b896e16 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -7,21 +7,21 @@ from .base import Model from . import func from ..image import ( - Model_Image, - Target_Image, + ModelImage, + TargetImage, Window, - PSF_Image, + PSFImage, ) -from ..utils.initialize import center_of_mass +from ..utils.initialize import recursive_center_of_mass from ..utils.decorators import ignore_numpy_warnings from .. import AP_config from ..errors import SpecificationConflict, InvalidTarget from .mixins import SampleMixin -__all__ = ["Component_Model"] +__all__ = ["ComponentModel"] -class Component_Model(SampleMixin, Model): +class ComponentModel(SampleMixin, Model): """Component_Model(name, target, window, locked, **kwargs) Component_Model is a base class for models that represent single @@ -59,34 +59,14 @@ class Component_Model(SampleMixin, Model): # Scope for PSF convolution psf_mode = "none" # none, full - # Method to use when performing subpixel shifts. bilinear set by default for stability around pixel edges, though lanczos:3 is also fairly stable, and all are stable when away from pixel edges + # Method to use when performing subpixel shifts. psf_subpixel_shift = "lanczos:3" # bilinear, lanczos:2, lanczos:3, lanczos:5, none - - # Level to which each pixel should be evaluated - integrate_tolerance = 1e-3 # total flux fraction - - # Integration scope for model - integrate_mode = "threshold" # none, threshold - - # Maximum recursion depth when performing sub pixel integration - integrate_max_depth = 3 - - # Amount by which to subdivide pixels when doing recursive pixel integration - integrate_gridding = 5 - - # The initial quadrature level for sub pixel integration. Please always choose an odd number 3 or higher - integrate_quad_order = 3 - # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) softening = 1e-3 # arcsec _options = ( "psf_mode", "psf_subpixel_shift", - "integrate_mode", - "integrate_max_depth", - "integrate_gridding", - "integrate_quad_order", "softening", ) usable = False @@ -94,26 +74,26 @@ class Component_Model(SampleMixin, Model): @property def psf(self): if self._psf is None: - try: - return self.target.psf - except AttributeError: - return None + return self.target.psf return self._psf @psf.setter def psf(self, val): if val is None: self._psf = None - elif isinstance(val, PSF_Image): + elif isinstance(val, PSFImage): self._psf = val elif isinstance(val, Model): - self.set_aux_psf(val) + self._psf = PSFImage( + data=lambda p: p.psf_model().data.value, pixelscale=val.target.pixelscale + ) + self._psf.link("psf_model", val) else: - self._psf = PSF_Image(data=val, pixelscale=self.target.pixelscale) + self._psf = PSFImage(data=val, pixelscale=self.target.pixelscale) AP_config.ap_logger.warning( - "Setting PSF with pixel matrix, assuming target pixelscale is the same as " + "Setting PSF with pixel image, assuming target pixelscale is the same as " "PSF pixelscale. To remove this warning, set PSFs as an ap.image.PSF_Image " - "or ap.models.Model object instead." + "or ap.models.PSF_Model object instead." ) @property @@ -125,7 +105,7 @@ def target(self, tar): if tar is None: self._target = None return - elif not isinstance(tar, Target_Image): + elif not isinstance(tar, TargetImage): raise InvalidTarget("AstroPhot Model target must be a Target_Image instance.") self._target = tar @@ -133,9 +113,7 @@ def target(self, tar): ###################################################################### @torch.no_grad() @ignore_numpy_warnings - def initialize( - self, - ): + def initialize(self): """Determine initial values for the center coordinates. This is done with a local center of mass search which iterates by finding the center of light in a window, then iteratively updates @@ -158,7 +136,7 @@ def initialize( mask = target_area.mask.detach().cpu().numpy() dat[mask] = np.nanmedian(dat[~mask]) - COM = center_of_mass(target_area.data.npvalue) + COM = recursive_center_of_mass(target_area.data.npvalue) if not np.all(np.isfinite(COM)): return COM_center = target_area.pixel_to_plane( @@ -173,23 +151,6 @@ def fit_mask(self): def transform_coordinates(self, x, y, center): return x - center[0], y - center[1] - def shift_kernel(self, shift): - if self.psf_subpixel_shift == "bilinear": - return func.bilinear_kernel(shift[0], shift[1]) - elif self.psf_subpixel_shift.startswith("lanczos:"): - order = int(self.psf_subpixel_shift.split(":")[1]) - return func.lanczos_kernel(shift[0], shift[1], order) - elif self.psf_subpixel_shift == "none": - return torch.tensor( - [[0, 0, 0], [0, 1, 0], [0, 0, 0]], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - else: - raise SpecificationConflict( - f"Unknown PSF subpixel shift mode {self.psf_subpixel_shift} for model {self.name}" - ) - @forward def sample( self, @@ -229,17 +190,16 @@ def sample( raise NotImplementedError("PSF convolution in sub-window not available yet") if "full" in self.psf_mode: - psf = self.psf.image.value - psf_upscale = torch.round(self.target.pixel_length / psf.pixel_length).int() - psf_pad = np.max(psf.shape) // 2 + psf_upscale = torch.round(self.target.pixel_length / self.psf.pixel_length).int() + psf_pad = np.max(self.psf.shape) // 2 - working_image = Model_Image(window=window, upsample=psf_upscale, pad=psf_pad) + working_image = ModelImage(window=window, upsample=psf_upscale, pad=psf_pad) # Sub pixel shift to align the model with the center of a pixel if self.psf_subpixel_shift != "none": - pixel_center = working_image.plane_to_pixel(center) + pixel_center = working_image.plane_to_pixel(*center) pixel_shift = pixel_center - torch.round(pixel_center) - center_shift = center - working_image.pixel_to_plane(torch.round(pixel_center)) + center_shift = center - working_image.pixel_to_plane(*torch.round(pixel_center)) working_image.crtan = working_image.crtan.value + center_shift else: pixel_shift = torch.zeros_like(center) @@ -247,20 +207,15 @@ def sample( sample = self.sample_image(working_image) - if self.integrate_mode == "threshold": - sample = self.sample_integrate(sample, working_image) - shift_kernel = self.shift_kernel(pixel_shift) - working_image.data = func.convolve_and_shift(sample, shift_kernel, psf) + working_image.data = func.convolve_and_shift(sample, shift_kernel, self.psf.data.value) working_image.crtan = working_image.crtan.value - center_shift working_image = working_image.crop(psf_pad).reduce(psf_upscale) else: - working_image = Model_Image(window=window) + working_image = ModelImage(window=window) sample = self.sample_image(working_image) - if self.integrate_mode == "threshold": - sample = self.sample_integrate(sample, working_image) working_image.data = sample # Units from flux/arcsec^2 to flux @@ -270,16 +225,3 @@ def sample( working_image.data = working_image.data * (~self.mask) return working_image - - def get_state(self): - """Get the state of the model, including parameters and PSF.""" - state = super().get_state() - if self._psf is not None: - state["psf"] = self.psf.get_state() - return state - - def set_state(self, state): - """Set the state of the model, including parameters and PSF.""" - super().set_state(state) - if "psf" in state: - self.psf = PSF_Image(state=state["psf"]) diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 28131bd9..7139188f 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -3,16 +3,19 @@ import torch import numpy as np -from .model_object import Component_Model +from .model_object import ComponentModel from .base import Model -from ..utils.decorators import ignore_numpy_warnings, default_internal +from ..utils.decorators import ignore_numpy_warnings from ..image import PSF_Image, Window, Model_Image, Image from ..errors import SpecificationConflict +from ..param import forward +from . import func +from .. import AP_config -__all__ = ("Point_Source",) +__all__ = ("PointSource",) -class Point_Source(Component_Model): +class PointSource(ComponentModel): """Describes a point source in the image, this is a delta function at some position in the sky. This is typically used to describe stars, supernovae, very small galaxies, quasars, asteroids or any @@ -21,11 +24,10 @@ class Point_Source(Component_Model): """ - model_type = f"point {Component_Model.model_type}" - parameter_specs = { - "flux": {"units": "log10(flux)"}, + _model_type = "point" + _parameter_specs = { + "flux": {"units": "flux", "shape": ()}, } - _parameter_order = Component_Model._parameter_order + ("flux",) usable = True def __init__(self, *args, **kwargs): @@ -33,34 +35,21 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.psf is None: - raise ValueError("Point_Source needs psf information") + raise SpecificationConflict("Point_Source needs psf information") @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) + def initialize(self): + super().initialize() - if parameters["flux"].value is not None: + if self.flux.value is not None: return - target_area = target[self.window] - target_dat = target_area.data.detach().cpu().numpy() - with Param_Unlock(parameters["flux"]), Param_SoftLimits(parameters["flux"]): - icenter = target_area.plane_to_pixel(parameters["center"].value) - edge = np.concatenate( - ( - target_dat[:, 0], - target_dat[:, -1], - target_dat[0, :], - target_dat[-1, :], - ) - ) - edge_average = np.median(edge) - parameters["flux"].value = np.log10(np.abs(np.sum(target_dat - edge_average))) - parameters["flux"].uncertainty = torch.std(target_area.data) / ( - np.log(10) * 10 ** parameters["flux"].value - ) + target_area = self.target[self.window] + dat = target_area.data.npvalue + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) + edge_average = np.median(edge) + self.flux.dynamic_value = np.abs(np.sum(dat - edge_average)) + self.flux.uncertainty = torch.std(dat) / np.sqrt(np.prod(dat.shape)) # Psf convolution should be on by default since this is a delta function @property @@ -71,12 +60,8 @@ def psf_mode(self): def psf_mode(self, value): pass - def sample( - self, - image: Optional[Image] = None, - window: Optional[Window] = None, - parameters: Optional[Parameter_Node] = None, - ): + @forward + def sample(self, window: Optional[Window] = None, center=None, flux=None): """Evaluate the model on the space covered by an image object. This function properly calls integration methods and PSF convolution. This should not be overloaded except in special @@ -102,87 +87,48 @@ def sample( Image: The image with the computed model values. """ - # Image on which to evaluate model - if image is None: - image = self.make_model_image(window=window) - # Window within which to evaluate model if window is None: - working_window = image.window.copy() - else: - working_window = window.copy() - - # Parameters with which to evaluate the model - if parameters is None: - parameters = self.parameters - - # Sample the PSF pixels - if isinstance(self.psf, AstroPhot_Model): - # Adjust for supersampled PSF - psf_upscale = torch.round( - self.psf.target.pixel_length / working_window.pixel_length - ).int() - working_window = working_window.rescale_pixel(psf_upscale) - working_window.shift(-parameters["center"].value) - - # Make the image object to which the samples will be tracked - working_image = Model_Image(window=working_window) - - # Fill the image using the PSF model - psf = self.psf( - image=working_image, - parameters=parameters[self.psf.name], - ) - - # Scale for point source flux - working_image.data *= 10 ** parameters["flux"].value - - # Return to original coordinates - working_image.header.shift(parameters["center"].value) - - elif isinstance(self.psf, PSF_Image): - psf = self.psf.copy() + window = self.window - # Adjust for supersampled PSF - psf_upscale = torch.round(psf.pixel_length / working_window.pixel_length).int() - working_window = working_window.rescale_pixel(psf_upscale) + # Adjust for supersampled PSF + psf_upscale = torch.round(self.target.pixel_length / self.psf.pixel_length).int() - # Make the image object to which the samples will be tracked - working_image = Model_Image(window=working_window) + # Make the image object to which the samples will be tracked + working_image = Model_Image(window=window, upsample=psf_upscale) - # Compute the center offset - pixel_center = working_image.plane_to_pixel(parameters["center"].value) - center_shift = pixel_center - torch.round(pixel_center) - # working_image.header.pixel_shift(center_shift) - psf.window.shift(working_image.pixel_to_plane(torch.round(pixel_center))) - psf.data = self._shift_psf( - psf=psf.data, - shift=center_shift, - shift_method=self.psf_subpixel_shift, - keep_pad=False, - ) - psf.data /= torch.sum(psf.data) - - # Scale for psf flux - psf.data *= 10 ** parameters["flux"].value - - # Fill pixels with the PSF image - working_image += psf - - # Shift image back to align with original pixel grid - # working_image.header.pixel_shift(-center_shift) + # Compute the center offset + pixel_center = working_image.plane_to_pixel(*center) + pixel_shift = pixel_center - torch.round(pixel_center) + shift_kernel = self.shift_kernel(pixel_shift) - else: - raise SpecificationConflict( - f"Point_Source must have a psf that is either an AstroPhot_Model or a PSF_Image. not {type(self.psf)}" + psf = ( + torch.nn.functional.conv2d( + self.psf.data.value.view(1, 1, *self.psf.data.shape), + shift_kernel.view(1, 1, *shift_kernel.shape), + padding="valid", # fixme add note about valid padding ) + .squeeze(0) + .squeeze(0) + ) + psf = flux * psf + + # Fill pixels with the PSF image + pixel_center = torch.round(pixel_center).int() + psf_window = Window( + ( + pixel_center[0] - psf.shape[0] // 2, + pixel_center[1] - psf.shape[1] // 2, + pixel_center[0] + psf.shape[0] // 2 + 1, + pixel_center[1] + psf.shape[1] // 2 + 1, + ), + image=working_image, + ) + working_image[psf_window] += psf[psf_window.get_indices(working_image.window)] + working_image = working_image.reduce(psf_upscale) # Return to image pixelscale - working_image = working_image.reduce(psf_upscale) if self.mask is not None: - working_image.data = working_image.data * torch.logical_not(self.mask) - - # Add the sampled/integrated/convolved pixels to the requested image - image += working_image + working_image.data = working_image.data.value * (~self.mask) - return image + return working_image diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 6c95c635..5ef82bdb 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -1,22 +1,16 @@ -from typing import Optional - import torch from caskade import forward from .base import Model -from ..image import ( - Model_Image, - Window, - PSF_Image, -) +from ..image import Model_Image, PSF_Image from ..errors import InvalidTarget from .mixins import SampleMixin -__all__ = ["PSF_Model"] +__all__ = ["PSFModel"] -class PSF_Model(SampleMixin, Model): +class PSFModel(SampleMixin, Model): """Prototype point source (typically a star) model, to be subclassed by other point source models which define specific behavior. @@ -30,7 +24,7 @@ class PSF_Model(SampleMixin, Model): """ # Specifications for the model parameters including units, value, uncertainty, limits, locked, and cyclic - parameter_specs = { + _parameter_specs = { "center": { "units": "arcsec", "value": (0.0, 0.0), @@ -43,44 +37,20 @@ class PSF_Model(SampleMixin, Model): # The sampled PSF will be normalized to a total flux of 1 within the window normalize_psf = True - # Level to which each pixel should be evaluated - sampling_tolerance = 1e-3 - - # Integration scope for model - integrate_mode = "threshold" # none, threshold, full* - - # Maximum recursion depth when performing sub pixel integration - integrate_max_depth = 3 - - # Amount by which to subdivide pixels when doing recursive pixel integration - integrate_gridding = 5 - - # The initial quadrature level for sub pixel integration. Please always choose an odd number 3 or higher - integrate_quad_level = 3 - # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) - softening = 1e-3 + softening = 1e-3 # arcsec # Parameters which are treated specially by the model object and should not be updated directly when initializing - special_kwargs = ["parameters", "filename", "model_type"] - track_attrs = [ - "sampling_mode", - "sampling_tolerance", - "integrate_mode", - "integrate_max_depth", - "integrate_gridding", - "integrate_quad_level", - "jacobian_chunksize", - "softening", - ] + _options = ("softening", "normalize_psf") + + @forward + def transform_coordinates(self, x, y, center): + return x - center[0], y - center[1] # Fit loop functions ###################################################################### @forward - def sample( - self, - window: Optional[Window] = None, - ): + def sample(self): """Evaluate the model on the space covered by an image object. This function properly calls integration methods. This should not be overloaded except in special cases. @@ -105,23 +75,16 @@ def sample( Image: The image with the computed model values. """ - # Image on which to evaluate model - if window is None: - window = self.window - # Create an image to store pixel samples - working_image = Model_Image(window=window) - sample = self.sample_image(working_image) - if self.integrate_mode == "threshold": - sample = self.sample_integrate(sample, working_image) - working_image.data = sample + working_image = Model_Image(window=self.window) + working_image.data = self.sample_image(working_image) # normalize to total flux 1 if self.normalize_psf: - working_image.data /= torch.sum(working_image.data.value) + working_image.data = working_image.data.value / torch.sum(working_image.data.value) if self.mask is not None: - working_image.data = working_image.data.value * torch.logical_not(self.mask) + working_image.data = working_image.data.value * (~self.mask) return working_image diff --git a/astrophot/models/sersic_model.py b/astrophot/models/sersic_model.py index a0718f13..9433f24b 100644 --- a/astrophot/models/sersic_model.py +++ b/astrophot/models/sersic_model.py @@ -1,18 +1,19 @@ from ..param import forward -from .galaxy_model_object import Galaxy_Model +from .galaxy_model_object import GalaxyModel # from .warp_model import Warp_Galaxy # from .ray_model import Ray_Galaxy # from .wedge_model import Wedge_Galaxy -# from .psf_model_object import PSF_Model +from .psf_model_object import PSFModel + # from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp # from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp from ..utils.conversions.functions import sersic_Ie_to_flux_torch from .mixins import SersicMixin, RadialMixin, iSersicMixin __all__ = [ - "Sersic_Galaxy", - # "Sersic_PSF", + "SersicGalaxy", + "SersicPSF", # "Sersic_Warp", # "Sersic_SuperEllipse", # "Sersic_FourierEllipse", @@ -23,7 +24,7 @@ ] -class Sersic_Galaxy(SersicMixin, RadialMixin, Galaxy_Model): +class SersicGalaxy(SersicMixin, RadialMixin, GalaxyModel): """basic galaxy model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -49,31 +50,30 @@ def total_flux(self, Ie, n, Re, q): return sersic_Ie_to_flux_torch(Ie, n, Re, q) -# class Sersic_PSF(SersicMixin, RadialMixin, PSF_Model): -# """basic point source model with a sersic profile for the radial light -# profile. The functional form of the Sersic profile is defined as: +class SersicPSF(SersicMixin, RadialMixin, PSFModel): + """basic point source model with a sersic profile for the radial light + profile. The functional form of the Sersic profile is defined as: -# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) + I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius, and n is the sersic index -# which controls the shape of the profile. + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius, and n is the sersic index + which controls the shape of the profile. -# Parameters: -# n: Sersic index which controls the shape of the brightness profile -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius + Parameters: + n: Sersic index which controls the shape of the brightness profile + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius -# """ + """ -# usable = True -# model_integrated = False + usable = True -# @forward -# def total_flux(self, Ie, n, Re): -# return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) + @forward + def total_flux(self, Ie, n, Re): + return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) # class Sersic_SuperEllipse(SersicMixin, SuperEllipse_Galaxy): diff --git a/astrophot/plots/visuals.py b/astrophot/plots/visuals.py index 39f9c836..5c8e10fb 100644 --- a/astrophot/plots/visuals.py +++ b/astrophot/plots/visuals.py @@ -1,5 +1,8 @@ from matplotlib.pyplot import get_cmap +# from matplotlib.colors import ListedColormap +# import numpy as np + __all__ = ["main_pallet", "cmap_grad", "cmap_div"] main_pallet = { @@ -12,4 +15,7 @@ } cmap_grad = get_cmap("inferno") -cmap_div = get_cmap("RdBu_r") +cmap_div = get_cmap("twilight") # RdBu_r +# print(__file__) +# colors = np.load(f"{__file__[:-10]}/managua_cmap.npy") +# cmap_div = ListedColormap(list(reversed(colors)), name="mangua") diff --git a/astrophot/utils/initialize/__init__.py b/astrophot/utils/initialize/__init__.py index d634daa1..5224110e 100644 --- a/astrophot/utils/initialize/__init__.py +++ b/astrophot/utils/initialize/__init__.py @@ -1,12 +1,13 @@ from .segmentation_map import * from .initialize import isophotes -from .center import center_of_mass, GaussianDensity_Peak, Lanczos_peak +from .center import center_of_mass, recursive_center_of_mass, GaussianDensity_Peak, Lanczos_peak from .construct_psf import gaussian_psf, moffat_psf, construct_psf from .variance import auto_variance __all__ = ( "isophotes", "center_of_mass", + "recursive_center_of_mass", "GaussianDensity_Peak", "Lanczos_peak", "gaussian_psf", diff --git a/astrophot/utils/initialize/center.py b/astrophot/utils/initialize/center.py index fc2f1c32..c4294192 100644 --- a/astrophot/utils/initialize/center.py +++ b/astrophot/utils/initialize/center.py @@ -11,6 +11,32 @@ def center_of_mass(image): return center +def recursive_center_of_mass(image, max_iter=10, tol=1e-1): + + center = center_of_mass(image) + for i in range(max_iter): + width = (image.shape[0] / (3 + i), image.shape[1] / (3 + i)) + ranges = ( + slice( + max(0, int(center[0] - width[0])), min(image.shape[0], int(center[0] + width[0])) + ), + slice( + max(0, int(center[1] - width[1])), min(image.shape[1], int(center[1] + width[1])) + ), + ) + subimage = image[ranges] + if subimage.size < 9: + return center + new_center = center_of_mass(subimage) + new_center += np.array((ranges[0].start, ranges[1].start)) + + if np.linalg.norm(new_center - center) < tol: + return new_center + + center = new_center + return center + + def GaussianDensity_Peak(center, image, window=10, std=0.5): init_center = center window += window % 2 diff --git a/docs/source/tutorials/BasicPSFModels.ipynb b/docs/source/tutorials/BasicPSFModels.ipynb index d11cac0e..41673796 100644 --- a/docs/source/tutorials/BasicPSFModels.ipynb +++ b/docs/source/tutorials/BasicPSFModels.ipynb @@ -90,7 +90,7 @@ "metadata": {}, "outputs": [], "source": [ - "pointsource = ap.models.AstroPhot_Model(\n", + "pointsource = ap.models.Model(\n", " model_type=\"point model\",\n", " target=target,\n", " parameters={\"center\": [75, 75], \"flux\": 1},\n", diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 43241fa5..a181c149 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -404,7 +404,7 @@ "ax.imshow(\n", " np.log10(saved_image_hdu[0].data),\n", " origin=\"lower\",\n", - " cmap=\"plasma\",\n", + " cmap=\"viridis\",\n", ")\n", "plt.show()" ] From 2f6734ab7a1b3d3d720066823d483021904775d5 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 20 Jun 2025 21:02:43 -0400 Subject: [PATCH 027/191] adding models back in --- astrophot/image/image_object.py | 14 +- astrophot/image/model_image.py | 4 +- astrophot/models/__init__.py | 38 +- astrophot/models/_shared_methods.py | 6 +- astrophot/models/airy_psf.py | 79 +-- astrophot/models/base.py | 6 + astrophot/models/edgeon_model.py | 222 +++----- astrophot/models/eigen_psf.py | 128 ++--- astrophot/models/flatsky_model.py | 58 +- astrophot/models/foureirellipse_model.py | 346 ++++++------ astrophot/models/func/__init__.py | 4 + astrophot/models/func/convolution.py | 10 +- astrophot/models/func/gaussian.py | 4 +- astrophot/models/func/transform.py | 7 + astrophot/models/gaussian_model.py | 517 ++++++++---------- astrophot/models/group_model_object.py | 4 +- astrophot/models/group_psf_model.py | 10 +- astrophot/models/mixins/__init__.py | 2 + astrophot/models/mixins/brightness.py | 2 +- astrophot/models/mixins/gaussian.py | 33 ++ astrophot/models/mixins/moffat.py | 4 +- astrophot/models/mixins/sample.py | 4 +- astrophot/models/mixins/sersic.py | 4 +- astrophot/models/mixins/transform.py | 12 +- astrophot/models/model_object.py | 14 +- astrophot/models/moffat_model.py | 14 +- .../models/multi_gaussian_expansion_model.py | 213 +++----- astrophot/models/point_source.py | 16 +- astrophot/models/psf_model_object.py | 6 +- astrophot/models/sky_model_object.py | 19 +- astrophot/plots/image.py | 68 +-- astrophot/utils/decorators.py | 64 --- docs/source/tutorials/BasicPSFModels.ipynb | 41 +- docs/source/tutorials/GettingStarted.ipynb | 14 +- 34 files changed, 871 insertions(+), 1116 deletions(-) create mode 100644 astrophot/models/func/transform.py create mode 100644 astrophot/models/mixins/gaussian.py diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index cdc12ca7..6584ba55 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -9,7 +9,7 @@ from .. import AP_config from ..utils.conversions.units import deg_to_arcsec from .window import Window -from ..errors import SpecificationConflict, InvalidImage +from ..errors import InvalidImage from . import func __all__ = ["Image", "ImageList"] @@ -404,7 +404,9 @@ def corners(self): @torch.no_grad() def get_indices(self, other: Window): if other.image == self: - return slice(other.i_low, other.i_high), slice(other.j_low, other.j_high) + return slice(max(0, other.i_low), min(self.shape[0], other.i_high)), slice( + max(0, other.j_low), min(self.shape[1], other.j_high) + ) shift = np.round(self.crpix.npvalue - other.crpix.npvalue).astype(int) return slice( min(max(0, other.i_low + shift[0]), self.shape[0]), @@ -414,6 +416,14 @@ def get_indices(self, other: Window): max(0, min(other.j_high + shift[1], self.shape[1])), ) + @torch.no_grad() + def get_other_indices(self, other: Window): + if other.image == self: + shape = other.shape + return slice(max(0, -other.i_low), min(self.shape[0] - other.i_low, shape[0])), slice( + max(0, -other.j_low), min(self.shape[1] - other.j_low, shape[1]) + ) + raise ValueError() # origin_pix = torch.tensor( # (-0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device # ) diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index a389f8eb..d07e3b25 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -52,7 +52,7 @@ def crop(self, pixels, **kwargs): crop - (int, int): crop each dimension by the number of pixels given. new shape (N - 2*crop[1], M - 2*crop[0]) crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) """ - if isinstance(pixels, int) or len(pixels) == 1: # same crop in all dimension + if len(pixels) == 1: # same crop in all dimension crop = pixels if isinstance(pixels, int) else pixels[0] data = self.data.value[ crop : self.data.shape[0] - crop, @@ -73,7 +73,7 @@ def crop(self, pixels, **kwargs): crpix = self.crpix.value - pixels[0::2] # fixme else: raise ValueError( - f"Invalid crop shape {pixels}, must be int, (int,), (int, int), or (int, int, int, int)!" + f"Invalid crop shape {pixels}, must be (int,), (int, int), or (int, int, int, int)!" ) return self.copy(data=data, crpix=crpix, **kwargs) diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index f0c4f6f8..738851f9 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -3,29 +3,29 @@ from .galaxy_model_object import GalaxyModel from .sersic_model import SersicGalaxy, SersicPSF from .group_model_object import GroupModel -from .exponential_model import ExponentialGalaxy +from .exponential_model import ExponentialGalaxy, ExponentialPSF from .point_source import PointSource from .psf_model_object import PSFModel +from .group_psf_model import PSFGroupModel +from .gaussian_model import GaussianGalaxy, GaussianPSF +from .edgeon_model import EdgeonModel, EdgeonSech, EdgeonIsothermal +from .eigen_psf import EigenPSF +from .multi_gaussian_expansion_model import MultiGaussianExpansion +from .sky_model_object import SkyModel +from .flatsky_model import FlatSky +from .foureirellipse_model import FourierEllipseGalaxy +from .airy_psf import AiryPSF +from .moffat_model import MoffatGalaxy, MoffatPSF, Moffat2DPSF # from .ray_model import * -# from .sky_model_object import * -# from .flatsky_model import * # from .planesky_model import * -# from .gaussian_model import * -# from .multi_gaussian_expansion_model import * # from .spline_model import * # from .pixelated_psf_model import * -# from .eigen_psf import * # from .superellipse_model import * -# from .edgeon_model import * -# from .foureirellipse_model import * # from .wedge_model import * # from .warp_model import * -# from .moffat_model import * # from .nuker_model import * # from .zernike_model import * -# from .airy_psf import * -# from .group_psf_model import * __all__ = ( "Model", @@ -35,6 +35,22 @@ "SersicPSF", "GroupModel", "ExponentialGalaxy", + "ExponentialPSF", "PointSource", "PSFModel", + "PSFGroupModel", + "GaussianGalaxy", + "GaussianPSF", + "EdgeonModel", + "EdgeonSech", + "EdgeonIsothermal", + "EigenPSF", + "MultiGaussianExpansion", + "SkyModel", + "FlatSky", + "FourierEllipseGalaxy", + "AiryPSF", + "MoffatGalaxy", + "MoffatPSF", + "Moffat2DPSF", ) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 741a5e87..fb288fea 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -9,7 +9,7 @@ from .. import AP_config -def _sample_image(image, transform, rad_bins=None): +def _sample_image(image, transform, radius, rad_bins=None): dat = image.data.npvalue.copy() # Fill masked pixels if image.has_mask: @@ -21,7 +21,7 @@ def _sample_image(image, transform, rad_bins=None): # Get the radius of each pixel relative to object center x, y = transform(*image.coordinate_center_meshgrid(), params=()) - R = torch.sqrt(x**2 + y**2).detach().cpu().numpy().flatten() + R = radius(x, y).detach().cpu().numpy().flatten() # Bin fluxes by radius if rad_bins is None: @@ -70,7 +70,7 @@ def parametric_initialize(model, target, prof_func, params, x0_func): return # Get the sub-image area corresponding to the model image - R, I, S = _sample_image(target, model.transform_coordinates) + R, I, S = _sample_image(target, model.transform_coordinates, model.radial_metric) x0 = list(x0_func(model, R, I)) for i, param in enumerate(params): diff --git a/astrophot/models/airy_psf.py b/astrophot/models/airy_psf.py index 81bed4ed..f0a7e178 100644 --- a/astrophot/models/airy_psf.py +++ b/astrophot/models/airy_psf.py @@ -1,14 +1,13 @@ import torch -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ._shared_methods import select_target -from .psf_model_object import PSF_Model -from ..param import Param_Unlock, Param_SoftLimits +from ..utils.decorators import ignore_numpy_warnings +from .psf_model_object import PSFModel +from .mixins import RadialMixin -__all__ = ("Airy_PSF",) +__all__ = ("AiryPSF",) -class Airy_PSF(PSF_Model): +class AiryPSF(RadialMixin, PSFModel): """The Airy disk is an analytic description of the diffraction pattern for a circular aperture. @@ -37,55 +36,33 @@ class Airy_PSF(PSF_Model): """ - model_type = f"airy {PSF_Model.model_type}" - parameter_specs = { - "I0": {"units": "log10(flux/arcsec^2)", "value": 0.0, "locked": True}, - "aRL": {"units": "a/(R lambda)"}, + _model_type = "airy" + _parameter_specs = { + "I0": {"units": "flux/arcsec^2", "value": 1.0, "shape": ()}, + "aRL": {"units": "a/(R lambda)", "shape": ()}, } - _parameter_order = PSF_Model._parameter_order + ("I0", "aRL") usable = True - model_integrated = False @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) + def initialize(self): + super().initialize() - if (parameters["I0"].value is not None) and (parameters["aRL"].value is not None): + if (self.I0.value is not None) and (self.aRL.value is not None): return - target_area = target[self.window] - icenter = target_area.plane_to_pixel(parameters["center"].value) - - if parameters["I0"].value is None: - with Param_Unlock(parameters["I0"]), Param_SoftLimits(parameters["I0"]): - parameters["I0"].value = torch.log10( - torch.mean( - target_area.data[ - int(icenter[0]) - 2 : int(icenter[0]) + 2, - int(icenter[1]) - 2 : int(icenter[1]) + 2, - ] - ) - / target.pixel_area.item() - ) - parameters["I0"].uncertainty = torch.std( - target_area.data[ - int(icenter[0]) - 2 : int(icenter[0]) + 2, - int(icenter[1]) - 2 : int(icenter[1]) + 2, - ] - ) / (torch.abs(parameters["I0"].value) * target.pixel_area) - if parameters["aRL"].value is None: - with Param_Unlock(parameters["aRL"]), Param_SoftLimits(parameters["aRL"]): - parameters["aRL"].value = (5.0 / 8.0) * 2 * target.pixel_length - parameters["aRL"].uncertainty = parameters["aRL"].value * self.default_uncertainty - - @default_internal - def radial_model(self, R, image=None, parameters=None): - x = 2 * torch.pi * parameters["aRL"].value * R - - return (image.pixel_area * 10 ** parameters["I0"].value) * ( - 2 * torch.special.bessel_j1(x) / x - ) ** 2 - - from ._shared_methods import radial_evaluate_model as evaluate_model + icenter = self.target.plane_to_pixel(*self.center.value) + + if self.I0.value is None: + mid_chunk = self.target.data.value[ + int(icenter[0]) - 2 : int(icenter[0]) + 2, + int(icenter[1]) - 2 : int(icenter[1]) + 2, + ] + self.I0.dynamic_value = torch.mean(mid_chunk) / self.target.pixel_area + self.I0.uncertainty = torch.std(mid_chunk) / self.target.pixel_area + if self.aRL.value is None: + self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixel_length + self.aRL.uncertainty = self.aRL.value * self.default_uncertainty + + def radial_model(self, R, I0, aRL): + x = 2 * torch.pi * aRL * R + return I0 * (2 * torch.special.bessel_j1(x) / x) ** 2 diff --git a/astrophot/models/base.py b/astrophot/models/base.py index abfcadf2..69930bc2 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -275,6 +275,12 @@ def List_Models(cls, usable: Optional[bool] = None, types: bool = False) -> set: result.add(model) return result + def radius_metric(self, x, y): + return (x**2 + y**2).sqrt() + + def angular_metric(self, x, y): + return torch.atan2(y, x) + @forward def __call__( self, diff --git a/astrophot/models/edgeon_model.py b/astrophot/models/edgeon_model.py index 83f6be84..b1eae026 100644 --- a/astrophot/models/edgeon_model.py +++ b/astrophot/models/edgeon_model.py @@ -1,24 +1,14 @@ -from typing import Optional - -from scipy.stats import iqr import torch import numpy as np -from .model_object import Component_Model -from ._shared_methods import select_target -from ..utils.initialize import isophotes -from ..utils.angle_operations import Angle_Average -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..param import Param_Unlock, Param_SoftLimits, Parameter_Node -from ..image import Image -from ..utils.conversions.coordinates import ( - Rotate_Cartesian, -) +from .model_object import ComponentModel +from ..utils.decorators import ignore_numpy_warnings +from . import func -__all__ = ["Edgeon_Model"] +__all__ = ["EdgeonModel", "EdgeonSech", "EdgeonIsothermal"] -class Edgeon_Model(Component_Model): +class EdgeonModel(ComponentModel): """General Edge-On galaxy model to be subclassed for any specific representation such as radial light profile or the structure of the galaxy on the sky. Defines an edgeon galaxy as an object with @@ -26,166 +16,108 @@ class Edgeon_Model(Component_Model): """ - model_type = f"edgeon {Component_Model.model_type}" - parameter_specs = { + _model_type = "edgeon" + _parameter_specs = { "PA": { - "units": "rad", + "units": "radians", "limits": (0, np.pi), "cyclic": True, "uncertainty": 0.06, }, } - _parameter_order = Component_Model._parameter_order + ("PA",) usable = False @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters: Optional[Parameter_Node] = None, **kwargs): - super().initialize(target=target, parameters=parameters) - if parameters["PA"].value is not None: + def initialize(self): + super().initialize() + if self.PA.value is not None: return - target_area = target[self.window] - edge = np.concatenate( - ( - target_area.data[:, 0].detach().cpu().numpy(), - target_area.data[:, -1].detach().cpu().numpy(), - target_area.data[0, :].detach().cpu().numpy(), - target_area.data[-1, :].detach().cpu().numpy(), - ) - ) + target_area = self.target[self.window] + dat = target_area.data.npvalue + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) - edge_scatter = iqr(edge, rng=(16, 84)) / 2 - icenter = target_area.plane_to_pixel(parameters["center"].value) - - iso_info = isophotes( - target_area.data.detach().cpu().numpy() - edge_average, - (icenter[1].detach().cpu().item(), icenter[0].detach().cpu().item()), - threshold=3 * edge_scatter, - pa=0.0, - q=1.0, - n_isophotes=15, - ) - with Param_Unlock(parameters["PA"]), Param_SoftLimits(parameters["PA"]): - parameters["PA"].value = ( - -( - ( - Angle_Average( - list(iso["phase2"] for iso in iso_info[-int(len(iso_info) / 3) :]) - ) - / 2 - ) - + target.north - ) - ) % np.pi - parameters["PA"].uncertainty = parameters["PA"].value * self.default_uncertainty - - @default_internal - def transform_coordinates(self, X, Y, image=None, parameters=None): - return Rotate_Cartesian(-(parameters["PA"].value - image.north), X, Y) - - @default_internal - def evaluate_model( - self, - X=None, - Y=None, - image: Image = None, - parameters: Parameter_Node = None, - **kwargs, - ): - if X is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - XX, YY = self.transform_coordinates(X, Y, image=image, parameters=parameters) - - return self.brightness_model( - torch.abs(XX), torch.abs(YY), image=image, parameters=parameters - ) - - -class Edgeon_Sech(Edgeon_Model): + dat = dat - edge_average + + x, y = target_area.coordinate_center_meshgrid() + x = (x - self.center.value[0]).detach().cpu().numpy() + y = (y - self.center.value[1]).detach().cpu().numpy() + mu20 = np.median(dat * np.abs(x)) + mu02 = np.median(dat * np.abs(y)) + mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y))) + M = np.array([[mu20, mu11], [mu11, mu02]]) + if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): + self.PA.dynamic_value = np.pi / 2 + else: + self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi + self.PA.uncertainty = self.PA.value * self.default_uncertainty + + def transform_coordinates(self, x, y, PA): + x, y = super().transform_coordinates(x, y) + return func.rotate(PA - np.pi / 2, x, y) + + +class EdgeonSech(EdgeonModel): """An edgeon profile where the vertical distribution is a sech^2 profile, subclasses define the radial profile. """ - model_type = f"sech2 {Edgeon_Model.model_type}" - parameter_specs = { - "I0": {"units": "log10(flux/arcsec^2)"}, - "hs": {"units": "arcsec", "limits": (0, None)}, + _model_type = "sech2" + _parameter_specs = { + "I0": {"units": "flux/arcsec^2"}, + "hs": {"units": "arcsec", "valid": (0, None)}, } - _parameter_order = Edgeon_Model._parameter_order + ("I0", "hs") usable = False @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters: Optional[Parameter_Node] = None, **kwargs): - super().initialize(target=target, parameters=parameters) - if (parameters["I0"].value is not None) and (parameters["hs"].value is not None): + def initialize(self): + super().initialize() + if (self.I0.value is not None) and (self.hs.value is not None): return - target_area = target[self.window] - icenter = target_area.plane_to_pixel(parameters["center"].value) - - if parameters["I0"].value is None: - with Param_Unlock(parameters["I0"]), Param_SoftLimits(parameters["I0"]): - parameters["I0"].value = torch.log10( - torch.mean( - target_area.data[ - int(icenter[0]) - 2 : int(icenter[0]) + 2, - int(icenter[1]) - 2 : int(icenter[1]) + 2, - ] - ) - / target.pixel_area.item() - ) - parameters["I0"].uncertainty = torch.std( - target_area.data[ - int(icenter[0]) - 2 : int(icenter[0]) + 2, - int(icenter[1]) - 2 : int(icenter[1]) + 2, - ] - ) / (torch.abs(parameters["I0"].value) * target.pixel_area) - if parameters["hs"].value is None: - with Param_Unlock(parameters["hs"]), Param_SoftLimits(parameters["hs"]): - parameters["hs"].value = torch.max(self.window.shape) * 0.1 - parameters["hs"].uncertainty = parameters["hs"].value / 2 - - @default_internal - def brightness_model(self, X, Y, image=None, parameters=None): - return ( - (image.pixel_area * 10 ** parameters["I0"].value) - * self.radial_model(X, image=image, parameters=parameters) - / (torch.cosh((Y + self.softening) / parameters["hs"].value) ** 2) - ) - - -class Edgeon_Isothermal(Edgeon_Sech): + target_area = self.target[self.window] + icenter = target_area.plane_to_pixel(*self.center.value) + + if self.I0.value is None: + chunk = target_area.data.value[ + int(icenter[0]) - 2 : int(icenter[0]) + 2, + int(icenter[1]) - 2 : int(icenter[1]) + 2, + ] + self.I0.dynamic_value = torch.mean(chunk) / self.target.pixel_area + self.I0.uncertainty = torch.std(chunk) / self.target.pixel_area + if self.hs.value is None: + self.hs.value = torch.max(self.window.shape) * target_area.pixel_length * 0.1 + self.hs.uncertainty = self.hs.value / 2 + + def brightness(self, x, y, I0, hs): + x, y = self.transform_coordinates(x, y) + return I0 * self.radial_model(x) / (torch.cosh((y + self.softening) / hs) ** 2) + + +class EdgeonIsothermal(EdgeonSech): """A self-gravitating locally-isothermal edgeon disk. This comes from van der Kruit & Searle 1981. """ - model_type = f"isothermal {Edgeon_Sech.model_type}" - parameter_specs = { - "rs": {"units": "arcsec", "limits": (0, None)}, - } - _parameter_order = Edgeon_Sech._parameter_order + ("rs",) + _model_type = "isothermal" + _parameter_specs = {"rs": {"units": "arcsec", "valid": (0, None)}} usable = True @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters: Optional[Parameter_Node] = None, **kwargs): - super().initialize(target=target, parameters=parameters) - if parameters["rs"].value is not None: + def initialize(self): + super().initialize() + if self.rs.value is not None: return - with Param_Unlock(parameters["rs"]), Param_SoftLimits(parameters["rs"]): - parameters["rs"].value = torch.max(self.window.shape) * 0.4 - parameters["rs"].uncertainty = parameters["rs"].value / 2 - - @default_internal - def radial_model(self, R, image=None, parameters=None): - Rscaled = torch.abs((R + self.softening) / parameters["rs"].value) - return Rscaled * torch.exp(-Rscaled) * torch.special.scaled_modified_bessel_k1(Rscaled) + self.rs.value = torch.max(self.window.shape) * self.target.pixel_length * 0.4 + self.rs.uncertainty = self.rs.value / 2 + + def radial_model(self, R, rs): + Rscaled = torch.abs(R / rs) + return ( + Rscaled + * torch.exp(-Rscaled) + * torch.special.scaled_modified_bessel_k1(Rscaled + self.softening / rs) + ) diff --git a/astrophot/models/eigen_psf.py b/astrophot/models/eigen_psf.py index 64d09ca0..2df43bf8 100644 --- a/astrophot/models/eigen_psf.py +++ b/astrophot/models/eigen_psf.py @@ -1,18 +1,17 @@ import torch import numpy as np -from .psf_model_object import PSF_Model -from ..image import PSF_Image -from ..utils.decorators import ignore_numpy_warnings, default_internal +from .psf_model_object import PSFModel +from ..image import PSFImage +from ..utils.decorators import ignore_numpy_warnings from ..utils.interpolate import interp2d -from ._shared_methods import select_target -from ..param import Param_Unlock, Param_SoftLimits from .. import AP_config +from ..errors import SpecificationConflict -__all__ = ["Eigen_PSF"] +__all__ = ["EigenPSF"] -class Eigen_PSF(PSF_Model): +class EigenPSF(PSFModel): """point source model which uses multiple images as a basis for the PSF as its representation for point sources. Using bilinear interpolation it will shift the PSF within a pixel to accurately @@ -39,107 +38,48 @@ class Eigen_PSF(PSF_Model): """ - model_type = f"eigen {PSF_Model.model_type}" - parameter_specs = { - "flux": {"units": "log10(flux/arcsec^2)", "value": 0.0, "locked": True}, + _model_type = "eigen" + _parameter_specs = { + "flux": {"units": "flux/arcsec^2", "value": 1.0}, "weights": {"units": "unitless"}, } - _parameter_order = PSF_Model._parameter_order + ("flux", "weights") usable = True - model_integrated = True - def __init__(self, *args, **kwargs): + def __init__(self, *args, eigen_basis=None, **kwargs): super().__init__(*args, **kwargs) - if "eigen_basis" not in kwargs: - AP_config.ap_logger.warning( - "Eigen basis not supplied! Assuming psf as single basis element. Please provide Eigen basis or just use an empirical PSF image." + if eigen_basis is None: + raise SpecificationConflict( + "EigenPSF model requires 'eigen_basis' argument to be provided." ) - self.eigen_basis = torch.clone(self.target.data).unsqueeze(0) - self.parameters["weights"].locked = True - else: - self.eigen_basis = torch.as_tensor( - kwargs["eigen_basis"], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - if kwargs.get("normalize_eigen_basis", True): - self.eigen_basis = self.eigen_basis / torch.sum( - self.eigen_basis, axis=(1, 2) - ).unsqueeze(1).unsqueeze(2) - self.eigen_pixelscale = torch.as_tensor( - kwargs.get( - "eigen_pixelscale", - 1.0 if self.target is None else self.target.pixelscale, - ), + self.eigen_basis = torch.as_tensor( + kwargs["eigen_basis"], dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - target_area = target[self.window] - with Param_Unlock(parameters["flux"]), Param_SoftLimits(parameters["flux"]): - if parameters["flux"].value is None: - parameters["flux"].value = torch.log10( - torch.abs(torch.sum(target_area.data)) / target.pixel_area - ) - if parameters["flux"].uncertainty is None: - parameters["flux"].uncertainty = ( - torch.abs(parameters["flux"].value) * self.default_uncertainty - ) - with ( - Param_Unlock(parameters["weights"]), - Param_SoftLimits(parameters["weights"]), - ): - if parameters["weights"].value is None: - W = np.zeros(len(self.eigen_basis)) - W[0] = 1.0 - parameters["weights"].value = W - if parameters["weights"].uncertainty is None: - parameters["weights"].uncertainty = ( - torch.ones_like(parameters["weights"].value) * self.default_uncertainty - ) - - @default_internal - def evaluate_model(self, X=None, Y=None, image=None, parameters=None, **kwargs): - if X is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - - psf_model = PSF_Image( - data=torch.clamp( - torch.sum( - self.eigen_basis.detach() - * (parameters["weights"].value / torch.linalg.norm(parameters["weights"].value)) - .unsqueeze(1) - .unsqueeze(2), - axis=0, - ), - min=0.0, - ), - pixelscale=self.eigen_pixelscale.detach(), - ) + def initialize(self): + super().initialize() + target_area = self.target[self.window] + if self.flux.value is None: + self.flux.dynamic_value = ( + torch.abs(torch.sum(target_area.data)) / target_area.pixel_area + ) + self.flux.uncertainty = self.flux.value * self.default_uncertainty + if self.weights.value is None: + self.weights.dynamic_value = 1 / np.arange(len(self.eigen_basis)) + self.weights.uncertainty = self.weights.value * self.default_uncertainty - # Convert coordinates into pixel locations in the psf image - pX, pY = psf_model.plane_to_pixel(X, Y) + def brightness(self, x, y, flux, weights): + x, y = self.transform_coordinates(x, y) - # Select only the pixels where the PSF image is defined - select = torch.logical_and( - torch.logical_and(pX > -0.5, pX < psf_model.data.shape[1] - 0.5), - torch.logical_and(pY > -0.5, pY < psf_model.data.shape[0] - 0.5), + psf = torch.sum( + self.eigen_basis * (weights / torch.linalg.norm(weights)).unsqueeze(1).unsqueeze(2), + axis=0, ) - # Zero everywhere outside the psf - result = torch.zeros_like(X) - - # Use bilinear interpolation of the PSF at the requested coordinates - result[select] = interp2d(psf_model.data, pX[select], pY[select]) - - # Ensure positive values - result = torch.clamp(result, min=0.0) + pX, pY = self.target.plane_to_pixel(x, y) + result = interp2d(psf, pX, pY) - return result * (image.pixel_area * 10 ** parameters["flux"].value) + return result * flux diff --git a/astrophot/models/flatsky_model.py b/astrophot/models/flatsky_model.py index e9ee06bc..9485d869 100644 --- a/astrophot/models/flatsky_model.py +++ b/astrophot/models/flatsky_model.py @@ -2,54 +2,40 @@ from scipy.stats import iqr import torch -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..param import Param_Unlock, Param_SoftLimits -from .sky_model_object import Sky_Model -from ._shared_methods import select_target +from ..utils.decorators import ignore_numpy_warnings +from .sky_model_object import SkyModel -__all__ = ["Flat_Sky"] +__all__ = ["FlatSky"] -class Flat_Sky(Sky_Model): +class FlatSky(SkyModel): """Model for the sky background in which all values across the image are the same. Parameters: - sky: brightness for the sky, represented as the log of the brightness over pixel scale squared, this is proportional to a surface brightness + I: brightness for the sky, represented as the log of the brightness over pixel scale squared, this is proportional to a surface brightness """ - model_type = f"flat {Sky_Model.model_type}" - parameter_specs = { - "F": {"units": "log10(flux/arcsec^2)"}, + _model_type = "flat" + _parameter_specs = { + "I": {"units": "flux/arcsec^2"}, } - _parameter_order = Sky_Model._parameter_order + ("F",) usable = True @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - with Param_Unlock(parameters["F"]), Param_SoftLimits(parameters["F"]): - if parameters["F"].value is None: - parameters["F"].value = torch.log10( - torch.abs(torch.median(target[self.window].data)) / target.pixel_area - ) - if parameters["F"].uncertainty is None: - parameters["F"].uncertainty = ( - ( - iqr( - target[self.window].data.detach().cpu().numpy(), - rng=(31.731 / 2, 100 - 31.731 / 2), - ) - / (2.0 * target.pixel_area.item()) - ) - / np.sqrt(np.prod(self.window.shape.detach().cpu().numpy())) - ) / (10 ** parameters["F"].value.item() * np.log(10)) - - def evaluate_model(self, X=None, Y=None, image=None, parameters=None, **kwargs): - ref = image.data if X is None else X - return torch.ones_like(ref) * (image.pixel_area * 10 ** parameters["F"].value) + def initialize(self): + super().initialize() + + if self.I.value is not None: + return + + dat = self.target[self.window].data.npvalue + self.I.value = np.median(dat) / self.target.pixel_area.item() + self.I.uncertainty = ( + iqr(dat, rng=(16, 84)) / (2.0 * self.target.pixel_area.item()) + ) / np.sqrt(np.prod(self.window.shape)) + + def brightness(self, x, y, I): + return torch.ones_like(x) * I diff --git a/astrophot/models/foureirellipse_model.py b/astrophot/models/foureirellipse_model.py index 3cdcf417..5bf49d7f 100644 --- a/astrophot/models/foureirellipse_model.py +++ b/astrophot/models/foureirellipse_model.py @@ -1,17 +1,20 @@ import torch import numpy as np -from ..utils.decorators import ignore_numpy_warnings, default_internal -from .galaxy_model_object import Galaxy_Model -from .warp_model import Warp_Galaxy -from ._shared_methods import select_target -from ..param import Param_Unlock, Param_SoftLimits +from ..utils.decorators import ignore_numpy_warnings +from .galaxy_model_object import GalaxyModel +from ..param import forward + +# from .warp_model import Warp_Galaxy from .. import AP_config -__all__ = ["FourierEllipse_Galaxy", "FourierEllipse_Warp"] +__all__ = [ + "FourierEllipseGalaxy", + # "FourierEllipse_Warp" +] -class FourierEllipse_Galaxy(Galaxy_Model): +class FourierEllipseGalaxy(GalaxyModel): """Expanded galaxy model which includes a Fourier transformation in its radius metric. This allows for the expression of arbitrarily complex isophotes instead of pure ellipses. This is a common @@ -52,191 +55,170 @@ class FourierEllipse_Galaxy(Galaxy_Model): """ - model_type = f"fourier {Galaxy_Model.model_type}" - parameter_specs = { + _model_type = "fourier" + _parameter_specs = { "am": {"units": "none"}, - "phim": {"units": "radians", "limits": (0, 2 * np.pi), "cyclic": True}, + "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True}, } - _parameter_order = Galaxy_Model._parameter_order + ("am", "phim") - modes = (1, 3, 4) - track_attrs = Galaxy_Model.track_attrs + ["modes"] usable = False + _options = ("modes",) - def __init__(self, *args, **kwargs): + def __init__(self, *args, modes=(3, 4), **kwargs): super().__init__(*args, **kwargs) - self.modes = torch.tensor( - kwargs.get("modes", FourierEllipse_Galaxy.modes), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - @default_internal - def angular_metric(self, X, Y, image=None, parameters=None): - return torch.atan2(Y, X) - - @default_internal - def radius_metric(self, X, Y, image=None, parameters=None): - R = super().radius_metric(X, Y, image, parameters) - theta = self.angular_metric(X, Y, image, parameters) - return R * torch.exp( - torch.sum( - parameters["am"].value.view(len(self.modes), -1) - * torch.cos( - self.modes.view(len(self.modes), -1) * theta.view(-1) - + parameters["phim"].value.view(len(self.modes), -1) - ), - 0, - ).view(theta.shape) - ) - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - with Param_Unlock(parameters["am"]), Param_SoftLimits(parameters["am"]): - if parameters["am"].value is None: - parameters["am"].value = torch.zeros( - len(self.modes), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - if parameters["am"].uncertainty is None: - parameters["am"].uncertainty = torch.tensor( - self.default_uncertainty * np.ones(len(self.modes)), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - with Param_Unlock(parameters["phim"]), Param_SoftLimits(parameters["phim"]): - if parameters["phim"].value is None: - parameters["phim"].value = torch.zeros( - len(self.modes), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - if parameters["phim"].uncertainty is None: - parameters["phim"].uncertainty = ( - torch.tensor( # Uncertainty assumed to be 5 degrees if not provided - (5 * np.pi / 180) * np.ones(len(self.modes)), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - ) - - -class FourierEllipse_Warp(Warp_Galaxy): - """Expanded warp galaxy model which includes a Fourier transformation - in its radius metric. This allows for the expression of - arbitrarily complex isophotes instead of pure ellipses. This is a - common extension of the standard elliptical representation. The - form of the Fourier perturbations is: - - R' = R * exp(sum_m(a_m * cos(m * theta + phi_m))) - - where R' is the new radius value, R is the original ellipse - radius, a_m is the amplitude of the m'th Fourier mode, m is the - index of the Fourier mode, theta is the angle around the ellipse, - and phi_m is the phase of the m'th fourier mode. This - representation is somewhat different from other Fourier mode - implementations where instead of an expoenntial it is just 1 + - sum_m(...), we opt for this formulation as it is more numerically - stable. It cannot ever produce negative radii, but to first order - the two representation are the same as can be seen by a Taylor - expansion of exp(x) = 1 + x + O(x^2). - - One can create extremely complex shapes using different Fourier - modes, however usually it is only low order modes that are of - interest. For intuition, the first Fourier mode is roughly - equivalent to a lopsided galaxy, one side will be compressed and - the opposite side will be expanded. The second mode is almost - never used as it is nearly degenerate with ellipticity. The third - mode is an alternate kind of lopsidedness for a galaxy which makes - it somewhat triangular, meaning that it is wider on one side than - the other. The fourth mode is similar to a boxyness/diskyness - parameter which tends to make more pronounced peanut shapes since - it is more rounded than a superellipse representation. Modes - higher than 4 are only useful in very specialized situations. In - general one should consider carefully why the Fourier modes are - being used for the science case at hand. - - Parameters: - am: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. - phi_m: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) - - """ - - model_type = f"fourier {Warp_Galaxy.model_type}" - parameter_specs = { - "am": {"units": "none"}, - "phim": {"units": "radians", "limits": (0, 2 * np.pi), "cyclic": True}, - } - _parameter_order = Warp_Galaxy._parameter_order + ("am", "phim") - modes = (1, 3, 4) - track_attrs = Galaxy_Model.track_attrs + ["modes"] - usable = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.modes = torch.tensor( - kwargs.get("modes", FourierEllipse_Warp.modes), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - @default_internal - def angular_metric(self, X, Y, image=None, parameters=None): - return torch.atan2(Y, X) + self.modes = torch.tensor(modes, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - @default_internal - def radius_metric(self, X, Y, image=None, parameters=None): - R = super().radius_metric(X, Y, image, parameters) - theta = self.angular_metric(X, Y, image, parameters) + @forward + def radius_metric(self, x, y, am, phim): + R = super().radius_metric(x, y) + theta = self.angular_metric(x, y) return R * torch.exp( torch.sum( - parameters["am"].value.view(len(self.modes), -1) - * torch.cos( - self.modes.view(len(self.modes), -1) * theta.view(-1) - + parameters["phim"].value.view(len(self.modes), -1) - ), + am.unsqueeze(-1) + * torch.cos(self.modes.unsqueeze(-1) * theta.flatten() + phim.unsqueeze(-1)), 0, - ).view(theta.shape) + ).reshape(x.shape) ) @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - with Param_Unlock(parameters["am"]), Param_SoftLimits(parameters["am"]): - if parameters["am"].value is None: - parameters["am"].value = torch.zeros( - len(self.modes), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - if parameters["am"].uncertainty is None: - parameters["am"].uncertainty = torch.tensor( - self.default_uncertainty * np.ones(len(self.modes)), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - with Param_Unlock(parameters["phim"]), Param_SoftLimits(parameters["phim"]): - if parameters["phim"].value is None: - parameters["phim"].value = torch.zeros( - len(self.modes), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - if parameters["phim"].uncertainty is None: - parameters["phim"].uncertainty = torch.tensor( - (5 * np.pi / 180) - * np.ones( - len(self.modes) - ), # Uncertainty assumed to be 5 degrees if not provided - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) + def initialize(self): + super().initialize() + + if self.am.value is None: + self.am.dynamic_value = torch.zeros( + len(self.modes), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + self.am.uncertainty = torch.tensor( + self.default_uncertainty * np.ones(len(self.modes)), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + if self.phim.value is None: + self.phim.value = torch.zeros( + len(self.modes), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + self.phim.uncertainty = torch.tensor( + (10 * np.pi / 180) * np.ones(len(self.modes)), + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + + +# class FourierEllipse_Warp(Warp_Galaxy): +# """Expanded warp galaxy model which includes a Fourier transformation +# in its radius metric. This allows for the expression of +# arbitrarily complex isophotes instead of pure ellipses. This is a +# common extension of the standard elliptical representation. The +# form of the Fourier perturbations is: + +# R' = R * exp(sum_m(a_m * cos(m * theta + phi_m))) + +# where R' is the new radius value, R is the original ellipse +# radius, a_m is the amplitude of the m'th Fourier mode, m is the +# index of the Fourier mode, theta is the angle around the ellipse, +# and phi_m is the phase of the m'th fourier mode. This +# representation is somewhat different from other Fourier mode +# implementations where instead of an expoenntial it is just 1 + +# sum_m(...), we opt for this formulation as it is more numerically +# stable. It cannot ever produce negative radii, but to first order +# the two representation are the same as can be seen by a Taylor +# expansion of exp(x) = 1 + x + O(x^2). + +# One can create extremely complex shapes using different Fourier +# modes, however usually it is only low order modes that are of +# interest. For intuition, the first Fourier mode is roughly +# equivalent to a lopsided galaxy, one side will be compressed and +# the opposite side will be expanded. The second mode is almost +# never used as it is nearly degenerate with ellipticity. The third +# mode is an alternate kind of lopsidedness for a galaxy which makes +# it somewhat triangular, meaning that it is wider on one side than +# the other. The fourth mode is similar to a boxyness/diskyness +# parameter which tends to make more pronounced peanut shapes since +# it is more rounded than a superellipse representation. Modes +# higher than 4 are only useful in very specialized situations. In +# general one should consider carefully why the Fourier modes are +# being used for the science case at hand. + +# Parameters: +# am: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. +# phi_m: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) + +# """ + +# model_type = f"fourier {Warp_Galaxy.model_type}" +# parameter_specs = { +# "am": {"units": "none"}, +# "phim": {"units": "radians", "limits": (0, 2 * np.pi), "cyclic": True}, +# } +# _parameter_order = Warp_Galaxy._parameter_order + ("am", "phim") +# modes = (1, 3, 4) +# track_attrs = Galaxy_Model.track_attrs + ["modes"] +# usable = False + +# def __init__(self, *args, **kwargs): +# super().__init__(*args, **kwargs) +# self.modes = torch.tensor( +# kwargs.get("modes", FourierEllipse_Warp.modes), +# dtype=AP_config.ap_dtype, +# device=AP_config.ap_device, +# ) + +# @default_internal +# def angular_metric(self, X, Y, image=None, parameters=None): +# return torch.atan2(Y, X) + +# @default_internal +# def radius_metric(self, X, Y, image=None, parameters=None): +# R = super().radius_metric(X, Y, image, parameters) +# theta = self.angular_metric(X, Y, image, parameters) +# return R * torch.exp( +# torch.sum( +# parameters["am"].value.view(len(self.modes), -1) +# * torch.cos( +# self.modes.view(len(self.modes), -1) * theta.view(-1) +# + parameters["phim"].value.view(len(self.modes), -1) +# ), +# 0, +# ).view(theta.shape) +# ) + +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def initialize(self, target=None, parameters=None, **kwargs): +# super().initialize(target=target, parameters=parameters) + +# with Param_Unlock(parameters["am"]), Param_SoftLimits(parameters["am"]): +# if parameters["am"].value is None: +# parameters["am"].value = torch.zeros( +# len(self.modes), +# dtype=AP_config.ap_dtype, +# device=AP_config.ap_device, +# ) +# if parameters["am"].uncertainty is None: +# parameters["am"].uncertainty = torch.tensor( +# self.default_uncertainty * np.ones(len(self.modes)), +# dtype=AP_config.ap_dtype, +# device=AP_config.ap_device, +# ) +# with Param_Unlock(parameters["phim"]), Param_SoftLimits(parameters["phim"]): +# if parameters["phim"].value is None: +# parameters["phim"].value = torch.zeros( +# len(self.modes), +# dtype=AP_config.ap_dtype, +# device=AP_config.ap_device, +# ) +# if parameters["phim"].uncertainty is None: +# parameters["phim"].uncertainty = torch.tensor( +# (5 * np.pi / 180) +# * np.ones( +# len(self.modes) +# ), # Uncertainty assumed to be 5 degrees if not provided +# dtype=AP_config.ap_dtype, +# device=AP_config.ap_device, +# ) diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 2c847bc5..bb3e8a7d 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -17,6 +17,8 @@ ) from .sersic import sersic, sersic_n_to_b from .moffat import moffat +from .gaussian import gaussian +from .transform import rotate __all__ = ( "all_subclasses", @@ -32,7 +34,9 @@ "sersic", "sersic_n_to_b", "moffat", + "gaussian", "single_quad_integrate", "recursive_quad_integrate", "upsample", + "rotate", ) diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index 0e127c68..5a4a0f9b 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -37,7 +37,15 @@ def convolve_and_shift(image, shift_kernel, psf): shift_fft = torch.fft.rfft2(shift_kernel, s=image.shape) convolved_fft = image_fft * psf_fft * shift_fft - return torch.fft.irfft2(convolved_fft, s=image.shape) + convolved = torch.fft.irfft2(convolved_fft, s=image.shape) + return torch.roll( + convolved, + shifts=( + -psf.shape[0] // 2 - shift_kernel.shape[0] // 2, + -psf.shape[1] // 2 - shift_kernel.shape[1] // 2, + ), + dims=(0, 1), + ) @lru_cache(maxsize=32) diff --git a/astrophot/models/func/gaussian.py b/astrophot/models/func/gaussian.py index 073c73a0..382dded1 100644 --- a/astrophot/models/func/gaussian.py +++ b/astrophot/models/func/gaussian.py @@ -2,7 +2,7 @@ import numpy as np -def gaussian(R, sigma, I0): +def gaussian(R, sigma, flux): """Gaussian 1d profile function, specifically designed for pytorch operations. @@ -11,4 +11,4 @@ def gaussian(R, sigma, I0): sigma: standard deviation of the gaussian in the same units as R I0: central surface density """ - return (I0 / torch.sqrt(2 * np.pi * sigma**2)) * torch.exp(-0.5 * torch.pow(R / sigma, 2)) + return (flux / (torch.sqrt(2 * np.pi) * sigma)) * torch.exp(-0.5 * torch.pow(R / sigma, 2)) diff --git a/astrophot/models/func/transform.py b/astrophot/models/func/transform.py new file mode 100644 index 00000000..58ab12f1 --- /dev/null +++ b/astrophot/models/func/transform.py @@ -0,0 +1,7 @@ +def rotate(theta, x, y): + """ + Applies a rotation matrix to the X,Y coordinates + """ + s = theta.sin() + c = theta.cos() + return c * x - s * y, s * x + c * y diff --git a/astrophot/models/gaussian_model.py b/astrophot/models/gaussian_model.py index 8213dc8a..dfa2a85d 100644 --- a/astrophot/models/gaussian_model.py +++ b/astrophot/models/gaussian_model.py @@ -1,40 +1,25 @@ -import torch - -from .galaxy_model_object import Galaxy_Model -from .warp_model import Warp_Galaxy -from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp -from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp -from .ray_model import Ray_Galaxy -from .wedge_model import Wedge_Galaxy -from .psf_model_object import PSF_Model -from ._shared_methods import ( - parametric_initialize, - parametric_segment_initialize, - select_target, -) -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..utils.parametric_profiles import gaussian_np +from .galaxy_model_object import GalaxyModel + +# from .warp_model import Warp_Galaxy +# from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp +# from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp +# from .ray_model import Ray_Galaxy +# from .wedge_model import Wedge_Galaxy +from .psf_model_object import PSFModel +from .mixins import GaussianMixin, RadialMixin __all__ = [ - "Gaussian_Galaxy", - "Gaussian_SuperEllipse", - "Gaussian_SuperEllipse_Warp", - "Gaussian_FourierEllipse", - "Gaussian_FourierEllipse_Warp", - "Gaussian_Warp", - "Gaussian_PSF", + "GaussianGalaxy", + "GaussianPSF", + # "Gaussian_SuperEllipse", + # "Gaussian_SuperEllipse_Warp", + # "Gaussian_FourierEllipse", + # "Gaussian_FourierEllipse_Warp", + # "Gaussian_Warp", ] -def _x0_func(model_params, R, F): - return R[4], F[0] - - -def _wrap_gauss(R, sig, flu): - return gaussian_np(R, sig, 10**flu) - - -class Gaussian_Galaxy(Galaxy_Model): +class GaussianGalaxy(GaussianMixin, RadialMixin, GalaxyModel): """Basic galaxy model with Gaussian as the radial light profile. The gaussian radial profile is defined as: @@ -50,29 +35,12 @@ class Gaussian_Galaxy(Galaxy_Model): """ - model_type = f"gaussian {Galaxy_Model.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, - } - _parameter_order = Galaxy_Model._parameter_order + ("sigma", "flux") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - from ._shared_methods import gaussian_radial_model as radial_model - - -class Gaussian_SuperEllipse(SuperEllipse_Galaxy): - """Super ellipse galaxy model with Gaussian as the radial light - profile.The gaussian radial profile is defined as: +class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): + """Basic point source model with a Gaussian as the radial light profile. The + gaussian radial profile is defined as: I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) @@ -86,293 +54,274 @@ class Gaussian_SuperEllipse(SuperEllipse_Galaxy): """ - model_type = f"gaussian {SuperEllipse_Galaxy.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, - } - _parameter_order = SuperEllipse_Galaxy._parameter_order + ("sigma", "flux") usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) +# class Gaussian_SuperEllipse(SuperEllipse_Galaxy): +# """Super ellipse galaxy model with Gaussian as the radial light +# profile.The gaussian radial profile is defined as: - from ._shared_methods import gaussian_radial_model as radial_model +# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# where I(R) is the prightness as a function of semi-major axis +# length, F is the total flux in the model, R is the semi-major +# axis, and S is the standard deviation. -class Gaussian_SuperEllipse_Warp(SuperEllipse_Warp): - """super ellipse warp galaxy model with a gaussian profile for the - radial light profile. The gaussian radial profile is defined as: +# Parameters: +# sigma: standard deviation of the gaussian profile, must be a positive value +# flux: the total flux in the gaussian model, represented as the log of the total - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# """ - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. +# model_type = f"gaussian {SuperEllipse_Galaxy.model_type}" +# parameter_specs = { +# "sigma": {"units": "arcsec", "limits": (0, None)}, +# "flux": {"units": "log10(flux)"}, +# } +# _parameter_order = SuperEllipse_Galaxy._parameter_order + ("sigma", "flux") +# usable = True - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def initialize(self, target=None, parameters=None, **kwargs): +# super().initialize(target=target, parameters=parameters) - """ +# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - model_type = f"gaussian {SuperEllipse_Warp.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, - } - _parameter_order = SuperEllipse_Warp._parameter_order + ("sigma", "flux") - usable = True +# from ._shared_methods import gaussian_radial_model as radial_model - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) +# class Gaussian_SuperEllipse_Warp(SuperEllipse_Warp): +# """super ellipse warp galaxy model with a gaussian profile for the +# radial light profile. The gaussian radial profile is defined as: - from ._shared_methods import gaussian_radial_model as radial_model +# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# where I(R) is the prightness as a function of semi-major axis +# length, F is the total flux in the model, R is the semi-major +# axis, and S is the standard deviation. -class Gaussian_FourierEllipse(FourierEllipse_Galaxy): - """fourier mode perturbations to ellipse galaxy model with a gaussian - profile for the radial light profile. The gaussian radial profile - is defined as: +# Parameters: +# sigma: standard deviation of the gaussian profile, must be a positive value +# flux: the total flux in the gaussian model, represented as the log of the total - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# """ - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. +# model_type = f"gaussian {SuperEllipse_Warp.model_type}" +# parameter_specs = { +# "sigma": {"units": "arcsec", "limits": (0, None)}, +# "flux": {"units": "log10(flux)"}, +# } +# _parameter_order = SuperEllipse_Warp._parameter_order + ("sigma", "flux") +# usable = True - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def initialize(self, target=None, parameters=None, **kwargs): +# super().initialize(target=target, parameters=parameters) - """ +# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - model_type = f"gaussian {FourierEllipse_Galaxy.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, - } - _parameter_order = FourierEllipse_Galaxy._parameter_order + ("sigma", "flux") - usable = True +# from ._shared_methods import gaussian_radial_model as radial_model - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) +# class Gaussian_FourierEllipse(FourierEllipse_Galaxy): +# """fourier mode perturbations to ellipse galaxy model with a gaussian +# profile for the radial light profile. The gaussian radial profile +# is defined as: - from ._shared_methods import gaussian_radial_model as radial_model +# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# where I(R) is the prightness as a function of semi-major axis +# length, F is the total flux in the model, R is the semi-major +# axis, and S is the standard deviation. -class Gaussian_FourierEllipse_Warp(FourierEllipse_Warp): - """fourier mode perturbations to ellipse galaxy model with a gaussian - profile for the radial light profile. The gaussian radial profile - is defined as: +# Parameters: +# sigma: standard deviation of the gaussian profile, must be a positive value +# flux: the total flux in the gaussian model, represented as the log of the total - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# """ - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. +# model_type = f"gaussian {FourierEllipse_Galaxy.model_type}" +# parameter_specs = { +# "sigma": {"units": "arcsec", "limits": (0, None)}, +# "flux": {"units": "log10(flux)"}, +# } +# _parameter_order = FourierEllipse_Galaxy._parameter_order + ("sigma", "flux") +# usable = True - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def initialize(self, target=None, parameters=None, **kwargs): +# super().initialize(target=target, parameters=parameters) - """ +# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - model_type = f"gaussian {FourierEllipse_Warp.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, - } - _parameter_order = FourierEllipse_Warp._parameter_order + ("sigma", "flux") - usable = True +# from ._shared_methods import gaussian_radial_model as radial_model - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) +# class Gaussian_FourierEllipse_Warp(FourierEllipse_Warp): +# """fourier mode perturbations to ellipse galaxy model with a gaussian +# profile for the radial light profile. The gaussian radial profile +# is defined as: - from ._shared_methods import gaussian_radial_model as radial_model +# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# where I(R) is the prightness as a function of semi-major axis +# length, F is the total flux in the model, R is the semi-major +# axis, and S is the standard deviation. -class Gaussian_Warp(Warp_Galaxy): - """Coordinate warped galaxy model with Gaussian as the radial light - profile. The gaussian radial profile is defined as: +# Parameters: +# sigma: standard deviation of the gaussian profile, must be a positive value +# flux: the total flux in the gaussian model, represented as the log of the total - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# """ - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. +# model_type = f"gaussian {FourierEllipse_Warp.model_type}" +# parameter_specs = { +# "sigma": {"units": "arcsec", "limits": (0, None)}, +# "flux": {"units": "log10(flux)"}, +# } +# _parameter_order = FourierEllipse_Warp._parameter_order + ("sigma", "flux") +# usable = True - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def initialize(self, target=None, parameters=None, **kwargs): +# super().initialize(target=target, parameters=parameters) - """ +# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - model_type = f"gaussian {Warp_Galaxy.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, - } - _parameter_order = Warp_Galaxy._parameter_order + ("sigma", "flux") - usable = True - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - - from ._shared_methods import gaussian_radial_model as radial_model - - -class Gaussian_PSF(PSF_Model): - """Basic point source model with a Gaussian as the radial light profile. The - gaussian radial profile is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - - model_type = f"gaussian {PSF_Model.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)", "value": 0.0, "locked": True}, - } - _parameter_order = PSF_Model._parameter_order + ("sigma", "flux") - usable = True - model_integrated = False +# from ._shared_methods import gaussian_radial_model as radial_model - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) +# class Gaussian_Warp(Warp_Galaxy): +# """Coordinate warped galaxy model with Gaussian as the radial light +# profile. The gaussian radial profile is defined as: - from ._shared_methods import gaussian_radial_model as radial_model - from ._shared_methods import radial_evaluate_model as evaluate_model +# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# where I(R) is the prightness as a function of semi-major axis +# length, F is the total flux in the model, R is the semi-major +# axis, and S is the standard deviation. -class Gaussian_Ray(Ray_Galaxy): - """ray galaxy model with a gaussian profile for the radial light - model. The gaussian radial profile is defined as: +# Parameters: +# sigma: standard deviation of the gaussian profile, must be a positive value +# flux: the total flux in the gaussian model, represented as the log of the total - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ +# """ - model_type = f"gaussian {Ray_Galaxy.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, - } - _parameter_order = Ray_Galaxy._parameter_order + ("sigma", "flux") - usable = True +# model_type = f"gaussian {Warp_Galaxy.model_type}" +# parameter_specs = { +# "sigma": {"units": "arcsec", "limits": (0, None)}, +# "flux": {"units": "log10(flux)"}, +# } +# _parameter_order = Warp_Galaxy._parameter_order + ("sigma", "flux") +# usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def initialize(self, target=None, parameters=None, **kwargs): +# super().initialize(target=target, parameters=parameters) - parametric_segment_initialize( - model=self, - parameters=parameters, - target=target, - prof_func=_wrap_gauss, - params=("sigma", "flux"), - x0_func=_x0_func, - segments=self.rays, - ) +# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) + +# from ._shared_methods import gaussian_radial_model as radial_model - from ._shared_methods import gaussian_iradial_model as iradial_model +# class Gaussian_Ray(Ray_Galaxy): +# """ray galaxy model with a gaussian profile for the radial light +# model. The gaussian radial profile is defined as: -class Gaussian_Wedge(Wedge_Galaxy): - """wedge galaxy model with a gaussian profile for the radial light - model. The gaussian radial profile is defined as: +# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) + +# where I(R) is the prightness as a function of semi-major axis +# length, F is the total flux in the model, R is the semi-major +# axis, and S is the standard deviation. + +# Parameters: +# sigma: standard deviation of the gaussian profile, must be a positive value +# flux: the total flux in the gaussian model, represented as the log of the total - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) +# """ - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. +# model_type = f"gaussian {Ray_Galaxy.model_type}" +# parameter_specs = { +# "sigma": {"units": "arcsec", "limits": (0, None)}, +# "flux": {"units": "log10(flux)"}, +# } +# _parameter_order = Ray_Galaxy._parameter_order + ("sigma", "flux") +# usable = True - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def initialize(self, target=None, parameters=None, **kwargs): +# super().initialize(target=target, parameters=parameters) + +# parametric_segment_initialize( +# model=self, +# parameters=parameters, +# target=target, +# prof_func=_wrap_gauss, +# params=("sigma", "flux"), +# x0_func=_x0_func, +# segments=self.rays, +# ) + +# from ._shared_methods import gaussian_iradial_model as iradial_model + + +# class Gaussian_Wedge(Wedge_Galaxy): +# """wedge galaxy model with a gaussian profile for the radial light +# model. The gaussian radial profile is defined as: + +# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) + +# where I(R) is the prightness as a function of semi-major axis +# length, F is the total flux in the model, R is the semi-major +# axis, and S is the standard deviation. + +# Parameters: +# sigma: standard deviation of the gaussian profile, must be a positive value +# flux: the total flux in the gaussian model, represented as the log of the total - """ - - model_type = f"gaussian {Wedge_Galaxy.model_type}" - parameter_specs = { - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, - } - _parameter_order = Wedge_Galaxy._parameter_order + ("sigma", "flux") - usable = True +# """ - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_segment_initialize( - self, - parameters, - target, - _wrap_gauss, - ("sigma", "flux"), - _x0_func, - self.wedges, - ) - - from ._shared_methods import gaussian_iradial_model as iradial_model +# model_type = f"gaussian {Wedge_Galaxy.model_type}" +# parameter_specs = { +# "sigma": {"units": "arcsec", "limits": (0, None)}, +# "flux": {"units": "log10(flux)"}, +# } +# _parameter_order = Wedge_Galaxy._parameter_order + ("sigma", "flux") +# usable = True + +# @torch.no_grad() +# @ignore_numpy_warnings +# @select_target +# @default_internal +# def initialize(self, target=None, parameters=None, **kwargs): +# super().initialize(target=target, parameters=parameters) + +# parametric_segment_initialize( +# self, +# parameters, +# target, +# _wrap_gauss, +# ("sigma", "flux"), +# _x0_func, +# self.wedges, +# ) + +# from ._shared_methods import gaussian_iradial_model as iradial_model diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 9f1d0d39..b9dcbb7b 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -56,7 +56,7 @@ def update_window(self): sub models in this group model object. """ - if isinstance(self.target, ImageList): # Window_List if target is a Target_Image_List + if isinstance(self.target, ImageList): # WindowList if target is a TargetImageList new_window = [None] * len(self.target.images) for model in self.models.values(): if isinstance(model.target, ImageList): @@ -88,7 +88,7 @@ def update_window(self): @torch.no_grad() @ignore_numpy_warnings - def initialize(self, **kwargs): + def initialize(self): """ Initialize each model in this group. Does this by iteratively initializing a model then subtracting it from a copy of the target. diff --git a/astrophot/models/group_psf_model.py b/astrophot/models/group_psf_model.py index 0383d538..023501bb 100644 --- a/astrophot/models/group_psf_model.py +++ b/astrophot/models/group_psf_model.py @@ -1,11 +1,11 @@ -from .group_model_object import Group_Model -from ..image import PSF_Image +from .group_model_object import GroupModel +from ..image import PSFImage from ..errors import InvalidTarget -__all__ = ["PSF_Group_Model"] +__all__ = ["PSFGroupModel"] -class PSF_Group_Model(Group_Model): +class PSFGroupModel(GroupModel): _model_type = "psf" usable = True @@ -19,6 +19,6 @@ def target(self): @target.setter def target(self, target): - if not (target is None or isinstance(target, PSF_Image)): + if not (target is None or isinstance(target, PSFImage)): raise InvalidTarget("Group_Model target must be a PSF_Image instance.") self._target = target diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index cc37ab4f..2a46e321 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -3,6 +3,7 @@ from .transform import InclinedMixin from .exponential import ExponentialMixin, iExponentialMixin from .moffat import MoffatMixin +from .gaussian import GaussianMixin from .sample import SampleMixin __all__ = ( @@ -13,5 +14,6 @@ "ExponentialMixin", "iExponentialMixin", "MoffatMixin", + "GaussianMixin", "SampleMixin", ) diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index 11b861c1..1c62b42c 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -9,4 +9,4 @@ def brightness(self, x, y): Calculate the brightness at a given point (x, y) based on radial distance from the center. """ x, y = self.transform_coordinates(x, y) - return self.radial_model((x**2 + y**2).sqrt()) + return self.radial_model(self.radius_metric(x, y)) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py new file mode 100644 index 00000000..4718c9c2 --- /dev/null +++ b/astrophot/models/mixins/gaussian.py @@ -0,0 +1,33 @@ +import torch + +from ...param import forward +from ...utils.decorators import ignore_numpy_warnings +from .._shared_methods import parametric_initialize, parametric_segment_initialize +from ...utils.parametric_profiles import gaussian_np +from .. import func + + +def _x0_func(model_params, R, F): + return R[4], F[0] + + +class GaussianMixin: + + _model_type = "gaussian" + _parameter_specs = { + "sigma": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "flux": {"units": "flux", "shape": ()}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, self.target[self.window], gaussian_np, ("sigma", "flux"), _x0_func + ) + + @forward + def radial_model(self, R, sigma, flux): + return func.gaussian(R, sigma, flux) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 214eecb2..55a997e6 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -22,7 +22,7 @@ class MoffatMixin: @torch.no_grad() @ignore_numpy_warnings - def initialize(self, **kwargs): + def initialize(self): super().initialize() parametric_initialize( @@ -45,7 +45,7 @@ class iMoffatMixin: @torch.no_grad() @ignore_numpy_warnings - def initialize(self, **kwargs): + def initialize(self): super().initialize() parametric_segment_initialize( diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index ac0dcda2..2a7bcd76 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -7,7 +7,7 @@ from ...param import forward from ... import AP_config -from ...image import Image, Window, Jacobian_Image +from ...image import Image, Window, JacobianImage from .. import func from ...errors import SpecificationConflict @@ -135,7 +135,7 @@ def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_p def jacobian( self, window: Optional[Window] = None, - pass_jacobian: Optional[Jacobian_Image] = None, + pass_jacobian: Optional[JacobianImage] = None, params: Optional[Tensor] = None, ): if window is None: diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index d9105a43..d28c1e47 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -22,7 +22,7 @@ class SersicMixin: @torch.no_grad() @ignore_numpy_warnings - def initialize(self, **kwargs): + def initialize(self): super().initialize() parametric_initialize( @@ -45,7 +45,7 @@ class iSersicMixin: @torch.no_grad() @ignore_numpy_warnings - def initialize(self, **kwargs): + def initialize(self): super().initialize() parametric_segment_initialize( diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index c5f70c76..f092ba1e 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -3,15 +3,7 @@ from ...utils.decorators import ignore_numpy_warnings from ...param import forward - - -def rotate(theta, x, y): - """ - Applies a rotation matrix to the X,Y coordinates - """ - s = theta.sin() - c = theta.cos() - return c * x - s * y, s * x + c * y +from .. import func class InclinedMixin: @@ -78,5 +70,5 @@ def transform_coordinates(self, x, y, PA, q): Transform coordinates based on the position angle and axis ratio. """ x, y = super().transform_coordinates(x, y) - x, y = rotate(-(PA + np.pi / 2), x, y) + x, y = func.rotate(-(PA + np.pi / 2), x, y) return x, y / q diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 9b896e16..b0412090 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -71,6 +71,10 @@ class ComponentModel(SampleMixin, Model): ) usable = False + def __init__(self, *args, psf=None, **kwargs): + super().__init__(*args, **kwargs) + self.psf = psf + @property def psf(self): if self._psf is None: @@ -190,16 +194,18 @@ def sample( raise NotImplementedError("PSF convolution in sub-window not available yet") if "full" in self.psf_mode: - psf_upscale = torch.round(self.target.pixel_length / self.psf.pixel_length).int() + psf_upscale = torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() psf_pad = np.max(self.psf.shape) // 2 working_image = ModelImage(window=window, upsample=psf_upscale, pad=psf_pad) # Sub pixel shift to align the model with the center of a pixel if self.psf_subpixel_shift != "none": - pixel_center = working_image.plane_to_pixel(*center) + pixel_center = torch.stack(working_image.plane_to_pixel(*center)) pixel_shift = pixel_center - torch.round(pixel_center) - center_shift = center - working_image.pixel_to_plane(*torch.round(pixel_center)) + center_shift = center - torch.stack( + working_image.pixel_to_plane(*torch.round(pixel_center)) + ) working_image.crtan = working_image.crtan.value + center_shift else: pixel_shift = torch.zeros_like(center) @@ -211,7 +217,7 @@ def sample( working_image.data = func.convolve_and_shift(sample, shift_kernel, self.psf.data.value) working_image.crtan = working_image.crtan.value - center_shift - working_image = working_image.crop(psf_pad).reduce(psf_upscale) + working_image = working_image.crop([psf_pad]).reduce(psf_upscale) else: working_image = ModelImage(window=window) diff --git a/astrophot/models/moffat_model.py b/astrophot/models/moffat_model.py index a3213fd3..2123a0f2 100644 --- a/astrophot/models/moffat_model.py +++ b/astrophot/models/moffat_model.py @@ -1,14 +1,14 @@ from caskade import forward -from .galaxy_model_object import Galaxy_Model -from .psf_model_object import PSF_Model +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel from ..utils.conversions.functions import moffat_I0_to_flux from .mixins import MoffatMixin, InclinedMixin -__all__ = ["Moffat_Galaxy", "Moffat_PSF"] +__all__ = ["MoffatGalaxy", "MoffatPSF"] -class Moffat_Galaxy(MoffatMixin, Galaxy_Model): +class MoffatGalaxy(MoffatMixin, GalaxyModel): """basic galaxy model with a Moffat profile for the radial light profile. The functional form of the Moffat profile is defined as: @@ -33,7 +33,7 @@ def total_flux(self, n, Rd, I0, q): return moffat_I0_to_flux(I0, n, Rd, q) -class Moffat_PSF(MoffatMixin, PSF_Model): +class MoffatPSF(MoffatMixin, PSFModel): """basic point source model with a Moffat profile for the radial light profile. The functional form of the Moffat profile is defined as: @@ -52,18 +52,16 @@ class Moffat_PSF(MoffatMixin, PSF_Model): """ usable = True - model_integrated = False @forward def total_flux(self, n, Rd, I0): return moffat_I0_to_flux(I0, n, Rd, 1.0) -class Moffat2D_PSF(InclinedMixin, Moffat_PSF): +class Moffat2DPSF(InclinedMixin, MoffatPSF): _model_type = "2d" usable = True - model_integrated = False @forward def total_flux(self, n, Rd, I0, q): diff --git a/astrophot/models/multi_gaussian_expansion_model.py b/astrophot/models/multi_gaussian_expansion_model.py index dd71726b..0c3efbbe 100644 --- a/astrophot/models/multi_gaussian_expansion_model.py +++ b/astrophot/models/multi_gaussian_expansion_model.py @@ -1,24 +1,15 @@ import torch import numpy as np -from scipy.stats import iqr -from .psf_model_object import PSF_Model -from .model_object import Component_Model -from ._shared_methods import ( - select_target, -) -from ..utils.initialize import isophotes -from ..utils.angle_operations import Angle_COM_PA -from ..utils.conversions.coordinates import ( - Rotate_Cartesian, -) -from ..param import Param_Unlock, Param_SoftLimits, Parameter_Node -from ..utils.decorators import ignore_numpy_warnings, default_internal +from .model_object import ComponentModel +from ..utils.decorators import ignore_numpy_warnings +from . import func +from ..param import forward -__all__ = ["Multi_Gaussian_Expansion"] +__all__ = ["MultiGaussianExpansion"] -class Multi_Gaussian_Expansion(Component_Model): +class MultiGaussianExpansion(ComponentModel): """Model that represents a galaxy as a sum of multiple Gaussian profiles. The model is defined as: @@ -33,58 +24,57 @@ class Multi_Gaussian_Expansion(Component_Model): flux: amplitude of each Gaussian """ - model_type = f"mge {Component_Model.model_type}" - parameter_specs = { - "q": {"units": "b/a", "limits": (0, 1)}, - "PA": {"units": "radians", "limits": (0, np.pi), "cyclic": True}, - "sigma": {"units": "arcsec", "limits": (0, None)}, - "flux": {"units": "log10(flux)"}, + _model_type = "mge" + _parameter_specs = { + "q": {"units": "b/a", "valid": (0, 1)}, + "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True}, + "sigma": {"units": "arcsec", "valid": (0, None)}, + "flux": {"units": "flux"}, } - _parameter_order = Component_Model._parameter_order + ("q", "PA", "sigma", "flux") usable = True - def __init__(self, *args, **kwargs): + def __init__(self, *args, n_components=None, **kwargs): super().__init__(*args, **kwargs) - - # determine the number of components - for key in ("q", "sigma", "flux"): - if self[key].value is not None: - self.n_components = self[key].value.shape[0] - break + if n_components is None: + for key in ("q", "sigma", "flux"): + if self[key].value is not None: + self.n_components = self[key].value.shape[0] + else: + raise ValueError( + f"n_components must be specified when initial values is not defined." + ) else: - self.n_components = kwargs.get("n_components", 3) + self.n_components = int(n_components) @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) + def initialize(self): + super().initialize() - target_area = target[self.window] - target_dat = target_area.data.detach().cpu().numpy() + target_area = self.target[self.window] + dat = target_area.data.npvalue if target_area.has_mask: mask = target_area.mask.detach().cpu().numpy() - target_dat[mask] = np.median(target_dat[np.logical_not(mask)]) - if parameters["sigma"].value is None: - with Param_Unlock(parameters["sigma"]), Param_SoftLimits(parameters["sigma"]): - parameters["sigma"].value = np.logspace( - np.log10(target_area.pixel_length.item() * 3), - max(target_area.shape.detach().cpu().numpy()) * 0.7, - self.n_components, - ) - parameters["sigma"].uncertainty = ( - self.default_uncertainty * parameters["sigma"].value - ) - if parameters["flux"].value is None: - with Param_Unlock(parameters["flux"]), Param_SoftLimits(parameters["flux"]): - parameters["flux"].value = np.log10( - np.sum(target_dat[~mask]) / self.n_components - ) * np.ones(self.n_components) - parameters["flux"].uncertainty = 0.1 * parameters["flux"].value - - if not (parameters["PA"].value is None or parameters["q"].value is None): + dat[mask] = np.median(dat[~mask]) + + if self.sigma.value is None: + self.sigma.dynamic_value = np.logspace( + np.log10(target_area.pixel_length.item() * 3), + max(target_area.shape) * target_area.pixel_length.item() * 0.7, + self.n_components, + ) + self.sigma.uncertainty = self.default_uncertainty * self.sigma.value + if self.flux.value is None: + self.flux.dynamic_value = (np.sum(dat) / self.n_components) * np.ones(self.n_components) + self.flux.uncertainty = self.default_uncertainty * self.flux.value + + if not (self.PA.value is None or self.q.value is None): return + target_area = self.target[self.window] + target_dat = target_area.data.npvalue + if target_area.has_mask: + mask = target_area.mask.detach().cpu().numpy() + target_dat[mask] = np.median(target_dat[~mask]) edge = np.concatenate( ( target_dat[:, 0], @@ -94,78 +84,55 @@ def initialize(self, target=None, parameters=None, **kwargs): ) ) edge_average = np.nanmedian(edge) - edge_scatter = iqr(edge[np.isfinite(edge)], rng=(16, 84)) / 2 - icenter = target_area.plane_to_pixel(parameters["center"].value) - - if parameters["PA"].value is None: - weights = target_dat - edge_average - Coords = target_area.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - X, Y = X.detach().cpu().numpy(), Y.detach().cpu().numpy() - if target_area.has_mask: - seg = np.logical_not(target_area.mask.detach().cpu().numpy()) - PA = Angle_COM_PA(weights[seg], X[seg], Y[seg]) + target_dat -= edge_average + x, y = target_area.coordinate_center_meshgrid() + x = (x - self.center.value[0]).detach().cpu().numpy() + y = (y - self.center.value[1]).detach().cpu().numpy() + mu20 = np.median(target_dat * np.abs(x)) + mu02 = np.median(target_dat * np.abs(y)) + mu11 = np.median(target_dat * x * y / np.sqrt(np.abs(x * y))) + # mu20 = np.median(target_dat * x**2) + # mu02 = np.median(target_dat * y**2) + # mu11 = np.median(target_dat * x * y) + M = np.array([[mu20, mu11], [mu11, mu02]]) + ones = np.ones(self.n_components) + if self.PA.value is None: + if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): + self.PA.dynamic_value = ones * np.pi / 2 else: - PA = Angle_COM_PA(weights, X, Y) - - with Param_Unlock(parameters["PA"]), Param_SoftLimits(parameters["PA"]): - parameters["PA"].value = ((PA + target_area.north) % np.pi) * np.ones( - self.n_components + self.PA.dynamic_value = ( + ones * (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi ) - if parameters["PA"].uncertainty is None: - parameters["PA"].uncertainty = (5 * np.pi / 180) * torch.ones_like( - parameters["PA"].value - ) # default uncertainty of 5 degrees is assumed - if parameters["q"].value is None: - q_samples = np.linspace(0.2, 0.9, 15) - try: - pa = parameters["PA"].value.item() - except: - pa = parameters["PA"].value[0].item() - iso_info = isophotes( - target_area.data.detach().cpu().numpy() - edge_average, - (icenter[1].detach().cpu().item(), icenter[0].detach().cpu().item()), - threshold=3 * edge_scatter, - pa=(pa - target.north), - q=q_samples, - ) - with Param_Unlock(parameters["q"]), Param_SoftLimits(parameters["q"]): - parameters["q"].value = q_samples[ - np.argmin(list(iso["amplitude2"] for iso in iso_info)) - ] * torch.ones(self.n_components) - if parameters["q"].uncertainty is None: - parameters["q"].uncertainty = parameters["q"].value * self.default_uncertainty - - @default_internal - def total_flux(self, parameters=None): - return torch.sum(10 ** parameters["flux"].value) - - @default_internal - def evaluate_model(self, X=None, Y=None, image=None, parameters=None, **kwargs): - if X is None or Y is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - - if parameters["PA"].value.numel() == 1: - X, Y = Rotate_Cartesian(-(parameters["PA"].value - image.north), X, Y) - X = X.repeat(parameters["q"].value.shape[0], *[1] * X.ndim) - Y = torch.vmap(lambda q: Y / q)(parameters["q"].value) + if self.q.value is None: + l = np.sort(np.linalg.eigvals(M)) + if np.any(np.iscomplex(l)) or np.any(~np.isfinite(l)): + l = (0.7, 1.0) + self.q.dynamic_value = ones * np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) + + @forward + def total_flux(self, flux): + return torch.sum(flux) + + @forward + def transform_coordinates(self, x, y, q, PA): + x, y = super().transform_coordinates(x, y) + if PA.numel() == 1: + x, y = func.rotate(-(PA + np.pi / 2), x, y) + x = x.repeat(q.shape[0], *[1] * x.ndim) + y = y.repeat(q.shape[0], *[1] * y.ndim) else: - X, Y = torch.vmap(lambda pa: Rotate_Cartesian(-(pa - image.north), X, Y))( - parameters["PA"].value - ) - Y = torch.vmap(lambda q, y: y / q)(parameters["q"].value, Y) - - R = self.radius_metric(X, Y, image, parameters) + x, y = torch.vmap(lambda pa: func.rotate(-(pa + np.pi / 2), x, y))(PA) + y = torch.vmap(lambda q, y: y / q)(q, y) + return x, y + + @forward + def brightness(self, x, y, flux, sigma, q): + x, y = self.transform_coordinates(x, y) + R = self.radius_metric(x, y) return torch.sum( torch.vmap( - lambda A, R, sigma, q: (A / (2 * np.pi * q * sigma**2)) - * torch.exp(-0.5 * (R / sigma) ** 2) - )( - image.pixel_area * 10 ** parameters["flux"].value, - R, - parameters["sigma"].value, - parameters["q"].value, - ), + lambda A, r, sig, _q: (A / torch.sqrt(2 * np.pi * _q * sig**2)) + * torch.exp(-0.5 * (r / sig) ** 2) + )(flux, R, sigma, q), dim=0, ) diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 7139188f..1be26bb7 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -4,13 +4,10 @@ import numpy as np from .model_object import ComponentModel -from .base import Model from ..utils.decorators import ignore_numpy_warnings -from ..image import PSF_Image, Window, Model_Image, Image +from ..image import Window, ModelImage from ..errors import SpecificationConflict from ..param import forward -from . import func -from .. import AP_config __all__ = ("PointSource",) @@ -92,16 +89,15 @@ def sample(self, window: Optional[Window] = None, center=None, flux=None): window = self.window # Adjust for supersampled PSF - psf_upscale = torch.round(self.target.pixel_length / self.psf.pixel_length).int() + psf_upscale = torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() # Make the image object to which the samples will be tracked - working_image = Model_Image(window=window, upsample=psf_upscale) + working_image = ModelImage(window=window, upsample=psf_upscale) # Compute the center offset - pixel_center = working_image.plane_to_pixel(*center) + pixel_center = torch.stack(working_image.plane_to_pixel(*center)) pixel_shift = pixel_center - torch.round(pixel_center) shift_kernel = self.shift_kernel(pixel_shift) - psf = ( torch.nn.functional.conv2d( self.psf.data.value.view(1, 1, *self.psf.data.shape), @@ -118,13 +114,13 @@ def sample(self, window: Optional[Window] = None, center=None, flux=None): psf_window = Window( ( pixel_center[0] - psf.shape[0] // 2, - pixel_center[1] - psf.shape[1] // 2, pixel_center[0] + psf.shape[0] // 2 + 1, + pixel_center[1] - psf.shape[1] // 2, pixel_center[1] + psf.shape[1] // 2 + 1, ), image=working_image, ) - working_image[psf_window] += psf[psf_window.get_indices(working_image.window)] + working_image[psf_window].data._value += psf[working_image.get_other_indices(psf_window)] working_image = working_image.reduce(psf_upscale) # Return to image pixelscale diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 5ef82bdb..9f892325 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -2,7 +2,7 @@ from caskade import forward from .base import Model -from ..image import Model_Image, PSF_Image +from ..image import ModelImage, PSFImage from ..errors import InvalidTarget from .mixins import SampleMixin @@ -76,7 +76,7 @@ def sample(self): """ # Create an image to store pixel samples - working_image = Model_Image(window=self.window) + working_image = ModelImage(window=self.window) working_image.data = self.sample_image(working_image) # normalize to total flux 1 @@ -99,6 +99,6 @@ def target(self): def target(self, target): if target is None: self._target = None - elif not isinstance(target, PSF_Image): + elif not isinstance(target, PSFImage): raise InvalidTarget(f"Target for PSF_Model must be a PSF_Image, not {type(target)}") self._target = target diff --git a/astrophot/models/sky_model_object.py b/astrophot/models/sky_model_object.py index a0c345c3..a7117f36 100644 --- a/astrophot/models/sky_model_object.py +++ b/astrophot/models/sky_model_object.py @@ -1,9 +1,9 @@ -from .model_object import Component_Model +from .model_object import ComponentModel -__all__ = ["Sky_Model"] +__all__ = ["SkyModel"] -class Sky_Model(Component_Model): +class SkyModel(ComponentModel): """prototype class for any sky background model. This simply imposes that the center is a locked parameter, not involved in the fit. Also, a sky model object has no psf mode or integration mode @@ -12,12 +12,17 @@ class Sky_Model(Component_Model): """ - model_type = f"sky {Component_Model.model_type}" - parameter_specs = { - "center": {"units": "arcsec", "locked": True, "uncertainty": 0.0}, - } + _model_type = "sky" usable = False + def initialize(self): + """Initialize the sky model, this is called after the model is + created and before it is used. This is where we can set the + center to be a locked parameter. + """ + super().initialize() + self.center.to_static() + @property def psf_mode(self): return "none" diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index a7482beb..6db1abfd 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -7,8 +7,8 @@ import matplotlib from scipy.stats import iqr -from ..models import Group_Model # , PSF_Model -from ..image import Image_List, Window_List +from ..models import GroupModel, PSFModel +from ..image import ImageList, WindowList from .. import AP_config from ..utils.conversions.units import flux_to_sb from .visuals import * @@ -39,7 +39,7 @@ def target_image(fig, ax, target, window=None, **kwargs): """ # recursive call for target image list - if isinstance(target, Image_List): + if isinstance(target, ImageList): for i in range(len(target.image_list)): target_image(fig, ax[i], target.image_list[i], window=window, **kwargs) return fig, ax @@ -103,50 +103,38 @@ def psf_image( fig, ax, psf, - window=None, cmap_levels=None, - flipx=False, **kwargs, ): - if isinstance(psf, PSF_Model): + if isinstance(psf, PSFModel): psf = psf() # recursive call for target image list - if isinstance(psf, Image_List): - for i in range(len(psf.image_list)): - psf_image(fig, ax[i], psf.image_list[i], window=window, **kwargs) + if isinstance(psf, ImageList): + for i in range(len(psf.images)): + psf_image(fig, ax[i], psf.images[i], **kwargs) return fig, ax - if window is None: - window = psf.window - if flipx: - ax.invert_xaxis() - - # cut out the requested window - psf = psf[window] - # Evaluate the model image - X, Y = psf.get_coordinate_corner_meshgrid() - X = X.detach().cpu().numpy() - Y = Y.detach().cpu().numpy() - psf = psf.data.detach().cpu().numpy() + x, y = psf.coordinate_corner_meshgrid() + x = x.detach().cpu().numpy() + y = y.detach().cpu().numpy() + psf = psf.data.value.detach().cpu().numpy() # Default kwargs for image - imshow_kwargs = { + kwargs = { "cmap": cmap_grad, "norm": matplotlib.colors.LogNorm(), # "norm": ImageNormalize(stretch=LogStretch(), clip=False), + **kwargs, } - # Update with user provided kwargs - imshow_kwargs.update(kwargs) - # if requested, convert the continuous colourmap into discrete levels if cmap_levels is not None: - imshow_kwargs["cmap"] = matplotlib.colors.ListedColormap( - list(imshow_kwargs["cmap"](c) for c in np.linspace(0.0, 1.0, cmap_levels)) + kwargs["cmap"] = matplotlib.colors.ListedColormap( + list(kwargs["cmap"](c) for c in np.linspace(0.0, 1.0, cmap_levels)) ) # Plot the image - im = ax.pcolormesh(X, Y, psf, **imshow_kwargs) + ax.pcolormesh(x.T, y.T, psf.T, **kwargs) # Enforce equal spacing on x y ax.axis("equal") @@ -212,7 +200,7 @@ def model_image( window = model.window # Handle image lists - if isinstance(sample_image, Image_List): + if isinstance(sample_image, ImageList): for i, (images, targets, windows) in enumerate(zip(sample_image, target, window)): model_image( fig, @@ -239,32 +227,30 @@ def model_image( sample_image = sample_image.data.npvalue # Default kwargs for image - imshow_kwargs = { + kwargs = { "cmap": cmap_grad, "norm": matplotlib.colors.LogNorm(), # "norm": ImageNormalize(stretch=LogStretch(), clip=False), + **kwargs, } - # Update with user provided kwargs - imshow_kwargs.update(kwargs) - # if requested, convert the continuous colourmap into discrete levels if cmap_levels is not None: - imshow_kwargs["cmap"] = matplotlib.colors.ListedColormap( - list(imshow_kwargs["cmap"](c) for c in np.linspace(0.0, 1.0, cmap_levels)) + kwargs["cmap"] = matplotlib.colors.ListedColormap( + list(kwargs["cmap"](c) for c in np.linspace(0.0, 1.0, cmap_levels)) ) # If zeropoint is available, convert to surface brightness units if target.zeropoint is not None and magunits: sample_image = flux_to_sb(sample_image, target.pixel_area.item(), target.zeropoint.item()) - del imshow_kwargs["norm"] - imshow_kwargs["cmap"] = imshow_kwargs["cmap"].reversed() + del kwargs["norm"] + kwargs["cmap"] = kwargs["cmap"].reversed() # Apply the mask if available if target_mask and target.has_mask: sample_image[target.mask.detach().cpu().numpy()] = np.nan # Plot the image - im = ax.pcolormesh(X.T, Y.T, sample_image.T, **imshow_kwargs) + im = ax.pcolormesh(X.T, Y.T, sample_image.T, **kwargs) # Enforce equal spacing on x y ax.axis("equal") @@ -336,7 +322,7 @@ def residual_image( target = model.target if sample_image is None: sample_image = model() - if isinstance(window, Window_List) or isinstance(target, Image_List): + if isinstance(window, WindowList) or isinstance(target, ImageList): for i_ax, win, tar, sam in zip(ax, window, target, sample_image): residual_image( fig, @@ -423,9 +409,9 @@ def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): model_window(fig, axitem, model, target=target.images[i], **kwargs) return fig, ax - if isinstance(model, Group_Model): + if isinstance(model, GroupModel): for m in model.models.values(): - if isinstance(m.window, Window_List): + if isinstance(m.window, WindowList): use_window = m.window.window_list[m.target.index(target)] else: use_window = m.window diff --git a/astrophot/utils/decorators.py b/astrophot/utils/decorators.py index 98fb7521..238c2f20 100644 --- a/astrophot/utils/decorators.py +++ b/astrophot/utils/decorators.py @@ -1,16 +1,8 @@ from functools import wraps -import inspect import warnings import numpy as np -from ..image import ( - Image_List, - Model_Image_List, - Target_Image_List, - Window_List, -) - class classproperty: def __init__(self, fget): @@ -40,59 +32,3 @@ def wrapped(*args, **kwargs): return result return wrapped - - -def default_internal(func): - """This decorator inspects the input parameters for a function which - expects to receive `image` and `parameters` arguments. If either - of these are not given, then the model can use its default values - for the parameters assuming the `image` is the internal `target` - object and the `parameters` are the internally stored parameters. - - """ - sig = inspect.signature(func) - handles = sig.parameters.keys() - - @wraps(func) - def wrapper(self, *args, **kwargs): - bound = sig.bind(self, *args, **kwargs) - bound.apply_defaults() - - if "window" in handles: - window = bound.arguments.get("window") - if window is None: - bound.arguments["window"] = self.window - - if "image" in handles: - image = bound.arguments.get("image") - if image is None: - bound.arguments["image"] = self.target - elif isinstance(image, Model_Image_List) and not isinstance(self.target, Image_List): - for i, sub_image in enumerate(image): - if sub_image.target_identity == self.target.identity: - bound.arguments["image"] = sub_image - if "window" in bound.arguments and isinstance( - bound.arguments["window"], Window_List - ): - bound.arguments["window"] = bound.arguments["window"].window_list[i] - break - else: - raise RuntimeError(f"{self.name} could not find matching image to sample with") - - if "target" in handles: - target = bound.arguments.get("target") - if target is None: - bound.arguments["target"] = self.target - elif isinstance(target, Target_Image_List) and not isinstance(self.target, Image_List): - for sub_target in target: - if sub_target.identity == self.target.identity: - bound.arguments["target"] = sub_target - break - else: - raise RuntimeError( - f"{self.name} could not find matching target to initialize with" - ) - - return func(*bound.args, **bound.kwargs) - - return wrapper diff --git a/docs/source/tutorials/BasicPSFModels.ipynb b/docs/source/tutorials/BasicPSFModels.ipynb index 41673796..59090019 100644 --- a/docs/source/tutorials/BasicPSFModels.ipynb +++ b/docs/source/tutorials/BasicPSFModels.ipynb @@ -56,7 +56,7 @@ "psf += np.random.normal(scale=psf / 4)\n", "psf[psf < 0] = ap.utils.initialize.gaussian_psf(2.0, 101, 0.5)[psf < 0]\n", "\n", - "psf_target = ap.image.PSF_Image(\n", + "psf_target = ap.image.PSFImage(\n", " data=psf,\n", " pixelscale=0.5,\n", ")\n", @@ -70,7 +70,7 @@ "plt.show()\n", "\n", "# Dummy target for sampling purposes\n", - "target = ap.image.Target_Image(data=np.zeros((300, 300)), pixelscale=0.5, psf=psf_target)" + "target = ap.image.TargetImage(data=np.zeros((300, 300)), pixelscale=0.5, psf=psf_target)" ] }, { @@ -93,14 +93,16 @@ "pointsource = ap.models.Model(\n", " model_type=\"point model\",\n", " target=target,\n", - " parameters={\"center\": [75, 75], \"flux\": 1},\n", + " center=[75, 75],\n", + " flux=1,\n", " psf=psf_target,\n", ")\n", "pointsource.initialize()\n", + "pointsource.to()\n", "# With a convolved sersic the center is much more smoothed out\n", "fig, ax = plt.subplots(figsize=(6, 6))\n", - "ap.plots.model_image(fig, ax, pointsource)\n", - "ax.set_title(\"Point source, convolved with empirical PSF\")\n", + "ap.plots.model_image(fig, ax, pointsource, showcbar=False)\n", + "ax.set_title(\"Point source, with empirical PSF\")\n", "plt.show()" ] }, @@ -121,17 +123,27 @@ "metadata": {}, "outputs": [], "source": [ - "model_nopsf = ap.models.AstroPhot_Model(\n", + "model_nopsf = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " parameters={\"center\": [75, 75], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"n\": 3, \"Re\": 10, \"Ie\": 1},\n", + " center=[75, 75],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=3,\n", + " Re=10,\n", + " Ie=1,\n", " psf_mode=\"none\", # no PSF convolution will be done\n", ")\n", "model_nopsf.initialize()\n", - "model_psf = ap.models.AstroPhot_Model(\n", + "model_psf = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " parameters={\"center\": [75, 75], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"n\": 3, \"Re\": 10, \"Ie\": 1},\n", + " center=[75, 75],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=3,\n", + " Re=10,\n", + " Ie=1,\n", " psf_mode=\"full\", # now the full window will be PSF convolved using the PSF from the target\n", ")\n", "model_psf.initialize()\n", @@ -139,15 +151,20 @@ "psf = psf.copy()\n", "psf[49:51] += 4 * np.mean(psf)\n", "psf[:, 49:51] += 4 * np.mean(psf)\n", - "psf_target_2 = ap.image.PSF_Image(\n", + "psf_target_2 = ap.image.PSFImage(\n", " data=psf,\n", " pixelscale=0.5,\n", ")\n", "psf_target_2.normalize()\n", - "model_selfpsf = ap.models.AstroPhot_Model(\n", + "model_selfpsf = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " parameters={\"center\": [75, 75], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"n\": 3, \"Re\": 10, \"Ie\": 1},\n", + " center=[75, 75],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=3,\n", + " Re=10,\n", + " Ie=1,\n", " psf_mode=\"full\",\n", " psf=psf_target_2, # Now this model has its own PSF, instead of using the target psf\n", ")\n", diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index a181c149..9a24cce4 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -52,7 +52,7 @@ " n=2,\n", " Re=10,\n", " Ie=1,\n", - " target=ap.image.Target_Image(\n", + " target=ap.image.TargetImage(\n", " data=np.zeros((100, 100)), zeropoint=22.5, pixelscale=1.0\n", " ), # every model needs a target, more on this later\n", ")\n", @@ -99,7 +99,7 @@ "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", "# Create a target object with specified pixelscale and zeropoint\n", - "target = ap.image.Target_Image(\n", + "target = ap.image.TargetImage(\n", " data=target_data,\n", " pixelscale=0.262, # Every target image needs to know it's pixelscale in arcsec/pixel\n", " zeropoint=22.5, # optionally, you can give a zeropoint to tell AstroPhot what the pixel flux units are\n", @@ -435,7 +435,7 @@ "target.save(\"target.fits\")\n", "\n", "# Note that it is often also possible to load from regular FITS files\n", - "new_target = ap.image.Target_Image(filename=\"target.fits\")\n", + "new_target = ap.image.TargetImage(filename=\"target.fits\")\n", "\n", "fig, ax = plt.subplots(figsize=(8, 8))\n", "ap.plots.target_image(fig, ax, new_target)\n", @@ -485,7 +485,7 @@ "wcs = WCS(hdu[0].header)\n", "\n", "# Create a target object with WCS which will specify the pixelscale and origin for us!\n", - "target = ap.image.Target_Image(\n", + "target = ap.image.TargetImage(\n", " data=target_data,\n", " zeropoint=22.5,\n", " wcs=wcs,\n", @@ -510,7 +510,7 @@ "print(ap.models.Model.List_Models(usable=True, types=True))\n", "print(\"---------------------------\")\n", "# It is also possible to get all sub models of a specific Type\n", - "print(\"only galaxy models: \", ap.models.Galaxy_Model.List_Models(types=True))" + "print(\"only galaxy models: \", ap.models.GalaxyModel.List_Models(types=True))" ] }, { @@ -563,13 +563,13 @@ "ap.AP_config.ap_dtype = torch.float32\n", "\n", "# Now new AstroPhot objects will be made with single bit precision\n", - "T1 = ap.image.Target_Image(data=np.zeros((100, 100)), pixelscale=1.0)\n", + "T1 = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1.0)\n", "T1.to()\n", "print(\"now a single:\", T1.data.value.dtype)\n", "\n", "# Here we switch back to double precision\n", "ap.AP_config.ap_dtype = torch.float64\n", - "T2 = ap.image.Target_Image(data=np.zeros((100, 100)), pixelscale=1.0)\n", + "T2 = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1.0)\n", "T2.to()\n", "print(\"back to double:\", T2.data.value.dtype)\n", "print(\"old image is still single!:\", T1.data.value.dtype)" From de6e6ccf8f809eadc3fa2a59bd85d87e316e8ff0 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 23 Jun 2025 11:59:23 -0400 Subject: [PATCH 028/191] getting all models back online --- astrophot/models/__init__.py | 173 ++++++++-- astrophot/models/_shared_methods.py | 38 +-- astrophot/models/eigen_psf.py | 1 - astrophot/models/exponential_model.py | 197 +++++------ astrophot/models/func/__init__.py | 6 + astrophot/models/func/exponential.py | 16 + astrophot/models/gaussian_model.py | 316 ++++-------------- astrophot/models/mixins/__init__.py | 11 +- astrophot/models/mixins/exponential.py | 4 +- astrophot/models/mixins/gaussian.py | 27 ++ astrophot/models/mixins/moffat.py | 2 +- astrophot/models/mixins/nuker.py | 70 ++++ astrophot/models/mixins/sersic.py | 4 +- astrophot/models/mixins/spline.py | 98 ++++++ astrophot/models/moffat_model.py | 33 +- astrophot/models/nuker_model.py | 412 ++---------------------- astrophot/models/pixelated_psf_model.py | 64 ++-- astrophot/models/planesky_model.py | 75 ++--- astrophot/models/ray_model.py | 104 +++--- astrophot/models/sersic_model.py | 222 +++++-------- astrophot/models/spline_model.py | 207 ++---------- astrophot/models/superellipse_model.py | 87 +++-- astrophot/models/warp_model.py | 112 ++----- astrophot/models/wedge_model.py | 80 ++--- astrophot/models/zernike_model.py | 69 ++-- astrophot/utils/interpolate.py | 7 + 26 files changed, 916 insertions(+), 1519 deletions(-) create mode 100644 astrophot/models/func/exponential.py create mode 100644 astrophot/models/mixins/nuker.py create mode 100644 astrophot/models/mixins/spline.py diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 738851f9..46502655 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -1,56 +1,161 @@ +# Base model object from .base import Model + +# Primary model types from .model_object import ComponentModel -from .galaxy_model_object import GalaxyModel -from .sersic_model import SersicGalaxy, SersicPSF -from .group_model_object import GroupModel -from .exponential_model import ExponentialGalaxy, ExponentialPSF -from .point_source import PointSource from .psf_model_object import PSFModel +from .group_model_object import GroupModel from .group_psf_model import PSFGroupModel -from .gaussian_model import GaussianGalaxy, GaussianPSF -from .edgeon_model import EdgeonModel, EdgeonSech, EdgeonIsothermal -from .eigen_psf import EigenPSF -from .multi_gaussian_expansion_model import MultiGaussianExpansion + +# Component model main types +from .galaxy_model_object import GalaxyModel from .sky_model_object import SkyModel -from .flatsky_model import FlatSky +from .point_source import PointSource + +# Subtypes of GalaxyModel from .foureirellipse_model import FourierEllipseGalaxy +from .ray_model import RayGalaxy +from .superellipse_model import SuperEllipseGalaxy +from .wedge_model import WedgeGalaxy +from .warp_model import WarpGalaxy + +# subtypes of PSFModel +from .eigen_psf import EigenPSF from .airy_psf import AiryPSF -from .moffat_model import MoffatGalaxy, MoffatPSF, Moffat2DPSF - -# from .ray_model import * -# from .planesky_model import * -# from .spline_model import * -# from .pixelated_psf_model import * -# from .superellipse_model import * -# from .wedge_model import * -# from .warp_model import * -# from .nuker_model import * -# from .zernike_model import * +from .zernike_model import ZernikePSF +from .pixelated_psf_model import PixelatedPSF + +# Subtypes of SkyModel +from .flatsky_model import FlatSky +from .planesky_model import PlaneSky + +# Special galaxy types +from .edgeon_model import EdgeonModel, EdgeonSech, EdgeonIsothermal +from .multi_gaussian_expansion_model import MultiGaussianExpansion + +# Standard models based on a core radial profile +from .sersic_model import ( + SersicGalaxy, + SersicPSF, + SersicFourierEllipse, + SersicSuperEllipse, + SersicWarp, + SersicRay, + SersicWedge, +) +from .exponential_model import ( + ExponentialGalaxy, + ExponentialPSF, + ExponentialSuperEllipse, + ExponentialFourierEllipse, + ExponentialWarp, + ExponentialRay, + ExponentialWedge, +) +from .gaussian_model import ( + GaussianGalaxy, + GaussianPSF, + GaussianSuperEllipse, + GaussianFourierEllipse, + GaussianWarp, + GaussianRay, + GaussianWedge, +) +from .moffat_model import ( + MoffatGalaxy, + MoffatPSF, + Moffat2DPSF, + MoffatFourierEllipseGalaxy, + MoffatRayGalaxy, + MoffatWedgeGalaxy, + MoffatWarpGalaxy, + MoffatSuperEllipseGalaxy, +) +from .nuker_model import ( + NukerGalaxy, + NukerPSF, + NukerFourierEllipse, + NukerSuperEllipse, + NukerWarp, + NukerRay, + NukerWedge, +) +from .spline_model import ( + SplineGalaxy, + SplinePSF, + SplineFourierEllipse, + SplineSuperEllipse, + SplineWarp, + SplineRay, + SplineWedge, +) + __all__ = ( "Model", "ComponentModel", - "GalaxyModel", - "SersicGalaxy", - "SersicPSF", - "GroupModel", - "ExponentialGalaxy", - "ExponentialPSF", - "PointSource", "PSFModel", + "GroupModel", "PSFGroupModel", - "GaussianGalaxy", - "GaussianPSF", + "GalaxyModel", + "SkyModel", + "PointSource", + "RayGalaxy", + "SuperEllipseGalaxy", + "WedgeGalaxy", + "WarpGalaxy", + "EigenPSF", + "AiryPSF", + "ZernikePSF", + "PixelatedPSF", + "FlatSky", + "PlaneSky", "EdgeonModel", "EdgeonSech", "EdgeonIsothermal", - "EigenPSF", "MultiGaussianExpansion", - "SkyModel", - "FlatSky", "FourierEllipseGalaxy", - "AiryPSF", + "SersicGalaxy", + "SersicPSF", + "SersicFourierEllipse", + "SersicSuperEllipse", + "SersicWarp", + "SersicRay", + "SersicWedge", + "ExponentialGalaxy", + "ExponentialPSF", + "ExponentialSuperEllipse", + "ExponentialFourierEllipse", + "ExponentialWarp", + "ExponentialRay", + "ExponentialWedge", + "GaussianGalaxy", + "GaussianPSF", + "GaussianSuperEllipse", + "GaussianFourierEllipse", + "GaussianWarp", + "GaussianRay", + "GaussianWedge", "MoffatGalaxy", "MoffatPSF", "Moffat2DPSF", + "MoffatFourierEllipseGalaxy", + "MoffatRayGalaxy", + "MoffatWedgeGalaxy", + "MoffatWarpGalaxy", + "MoffatSuperEllipseGalaxy", + "NukerGalaxy", + "NukerPSF", + "NukerFourierEllipse", + "NukerSuperEllipse", + "NukerWarp", + "NukerRay", + "NukerWedge", + "SplineGalaxy", + "SplinePSF", + "SplineFourierEllipse", + "SplineWarp", + "SplineSuperEllipse", + "SplineRay", + "SplineWedge", ) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index fb288fea..4249755c 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -207,42 +207,8 @@ def parametric_segment_initialize( model[param].uncertainty = unc[param] -# # Spline -# ###################################################################### -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def spline_initialize(self, target=None, parameters=None, **kwargs): -# super(self.__class__, self).initialize(target=target, parameters=parameters) - -# if parameters["I(R)"].value is not None and parameters["I(R)"].prof is not None: -# return - -# # Create the I(R) profile radii if needed -# if parameters["I(R)"].prof is None: -# new_prof = [0, 2 * target.pixel_length] -# while new_prof[-1] < torch.max(self.window.shape / 2): -# new_prof.append(new_prof[-1] + torch.max(2 * target.pixel_length, new_prof[-1] * 0.2)) -# new_prof.pop() -# new_prof.pop() -# new_prof.append(torch.sqrt(torch.sum((self.window.shape / 2) ** 2))) -# parameters["I(R)"].prof = new_prof - -# profR = parameters["I(R)"].prof.detach().cpu().numpy() -# target_area = target[self.window] -# R, I, S = _sample_image( -# target_area, -# self.transform_coordinates, -# self.radius_metric, -# parameters, -# rad_bins=[profR[0]] + list((profR[:-1] + profR[1:]) / 2) + [profR[-1] * 100], -# ) -# with Param_Unlock(parameters["I(R)"]), Param_SoftLimits(parameters["I(R)"]): -# parameters["I(R)"].value = I -# parameters["I(R)"].uncertainty = S - - +# Spline +###################################################################### # @torch.no_grad() # @ignore_numpy_warnings # @select_target diff --git a/astrophot/models/eigen_psf.py b/astrophot/models/eigen_psf.py index 2df43bf8..c705bf2c 100644 --- a/astrophot/models/eigen_psf.py +++ b/astrophot/models/eigen_psf.py @@ -2,7 +2,6 @@ import numpy as np from .psf_model_object import PSFModel -from ..image import PSFImage from ..utils.decorators import ignore_numpy_warnings from ..utils.interpolate import interp2d from .. import AP_config diff --git a/astrophot/models/exponential_model.py b/astrophot/models/exponential_model.py index f978b113..eb869098 100644 --- a/astrophot/models/exponential_model.py +++ b/astrophot/models/exponential_model.py @@ -1,26 +1,25 @@ from .galaxy_model_object import GalaxyModel -# from .warp_model import Warp_Galaxy -# from .ray_model import Ray_Galaxy +from .warp_model import WarpGalaxy +from .ray_model import RayGalaxy from .psf_model_object import PSFModel - -# from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp -# from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp -# from .wedge_model import Wedge_Galaxy -from .mixins import ExponentialMixin # , iExponentialMixin +from .superellipse_model import SuperEllipseGalaxy # , SuperEllipse_Warp +from .foureirellipse_model import FourierEllipseGalaxy # , FourierEllipse_Warp +from .wedge_model import WedgeGalaxy +from .mixins import ExponentialMixin, iExponentialMixin, RadialMixin __all__ = [ "ExponentialGalaxy", "ExponentialPSF", - # "Exponential_SuperEllipse", - # "Exponential_SuperEllipse_Warp", - # "Exponential_Warp", - # "Exponential_Ray", - # "Exponential_Wedge", + "ExponentialSuperEllipse", + "ExponentialFourierEllipse", + "ExponentialWarp", + "ExponentialRay", + "ExponentialWedge", ] -class ExponentialGalaxy(ExponentialMixin, GalaxyModel): +class ExponentialGalaxy(ExponentialMixin, RadialMixin, GalaxyModel): """basic galaxy model with a exponential profile for the radial light profile. The light profile is defined as: @@ -40,7 +39,7 @@ class ExponentialGalaxy(ExponentialMixin, GalaxyModel): usable = True -class ExponentialPSF(ExponentialMixin, PSFModel): +class ExponentialPSF(ExponentialMixin, RadialMixin, PSFModel): """basic point source model with a exponential profile for the radial light profile. @@ -60,141 +59,101 @@ class ExponentialPSF(ExponentialMixin, PSFModel): usable = True -# class Exponential_SuperEllipse(ExponentialMixin, SuperEllipse_Galaxy): -# """super ellipse galaxy model with a exponential profile for the radial -# light profile. - -# I(R) = Ie * exp(-b1(R/Re - 1)) - -# where I(R) is the brightness as a function of semi-major axis, Ie -# is the brightness at the half light radius, b1 is a constant not -# involved in the fit, R is the semi-major axis, and Re is the -# effective radius. - -# Parameters: -# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness -# Re: half light radius, represented in arcsec. This parameter cannot go below zero. - -# """ - -# usable = True - - -# class Exponential_SuperEllipse_Warp(ExponentialMixin, SuperEllipse_Warp): -# """super ellipse warp galaxy model with a exponential profile for the -# radial light profile. - -# I(R) = Ie * exp(-b1(R/Re - 1)) - -# where I(R) is the brightness as a function of semi-major axis, Ie -# is the brightness at the half light radius, b1 is a constant not -# involved in the fit, R is the semi-major axis, and Re is the -# effective radius. - -# Parameters: -# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness -# Re: half light radius, represented in arcsec. This parameter cannot go below zero. - -# """ - -# usable = True +class ExponentialSuperEllipse(ExponentialMixin, RadialMixin, SuperEllipseGalaxy): + """super ellipse galaxy model with a exponential profile for the radial + light profile. + I(R) = Ie * exp(-b1(R/Re - 1)) -# class Exponential_FourierEllipse(ExponentialMixin, FourierEllipse_Galaxy): -# """fourier mode perturbations to ellipse galaxy model with an -# exponential profile for the radial light profile. - -# I(R) = Ie * exp(-b1(R/Re - 1)) - -# where I(R) is the brightness as a function of semi-major axis, Ie -# is the brightness at the half light radius, b1 is a constant not -# involved in the fit, R is the semi-major axis, and Re is the -# effective radius. + where I(R) is the brightness as a function of semi-major axis, Ie + is the brightness at the half light radius, b1 is a constant not + involved in the fit, R is the semi-major axis, and Re is the + effective radius. -# Parameters: -# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness -# Re: half light radius, represented in arcsec. This parameter cannot go below zero. + Parameters: + Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness + Re: half light radius, represented in arcsec. This parameter cannot go below zero. -# """ + """ -# usable = True + usable = True -# class Exponential_FourierEllipse_Warp(ExponentialMixin, FourierEllipse_Warp): -# """fourier mode perturbations to ellipse galaxy model with a exponential -# profile for the radial light profile. +class ExponentialFourierEllipse(ExponentialMixin, RadialMixin, FourierEllipseGalaxy): + """fourier mode perturbations to ellipse galaxy model with an + exponential profile for the radial light profile. -# I(R) = Ie * exp(-b1(R/Re - 1)) + I(R) = Ie * exp(-b1(R/Re - 1)) -# where I(R) is the brightness as a function of semi-major axis, Ie -# is the brightness at the half light radius, b1 is a constant not -# involved in the fit, R is the semi-major axis, and Re is the -# effective radius. + where I(R) is the brightness as a function of semi-major axis, Ie + is the brightness at the half light radius, b1 is a constant not + involved in the fit, R is the semi-major axis, and Re is the + effective radius. -# Parameters: -# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness -# Re: half light radius, represented in arcsec. This parameter cannot go below zero. + Parameters: + Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness + Re: half light radius, represented in arcsec. This parameter cannot go below zero. -# """ + """ -# usable = True + usable = True -# class Exponential_Warp(ExponentialMixin, Warp_Galaxy): -# """warped coordinate galaxy model with a exponential profile for the -# radial light model. +class ExponentialWarp(ExponentialMixin, RadialMixin, WarpGalaxy): + """warped coordinate galaxy model with a exponential profile for the + radial light model. -# I(R) = Ie * exp(-b1(R/Re - 1)) + I(R) = Ie * exp(-b1(R/Re - 1)) -# where I(R) is the brightness as a function of semi-major axis, Ie -# is the brightness at the half light radius, b1 is a constant not -# involved in the fit, R is the semi-major axis, and Re is the -# effective radius. + where I(R) is the brightness as a function of semi-major axis, Ie + is the brightness at the half light radius, b1 is a constant not + involved in the fit, R is the semi-major axis, and Re is the + effective radius. -# Parameters: -# Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness -# Re: half light radius, represented in arcsec. This parameter cannot go below zero. + Parameters: + Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness + Re: half light radius, represented in arcsec. This parameter cannot go below zero. -# """ + """ -# usable = True + usable = True -# class Exponential_Ray(iExponentialMixin, Ray_Galaxy): -# """ray galaxy model with a sersic profile for the radial light -# model. The functional form of the Sersic profile is defined as: +class ExponentialRay(iExponentialMixin, RayGalaxy): + """ray galaxy model with a sersic profile for the radial light + model. The functional form of the Sersic profile is defined as: -# I(R) = Ie * exp(- bn((R/Re) - 1)) + I(R) = Ie * exp(- bn((R/Re) - 1)) -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius. + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius. -# Parameters: -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius + Parameters: + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius -# """ + """ -# usable = True + usable = True -# class Exponential_Wedge(iExponentialMixin, Wedge_Galaxy): -# """wedge galaxy model with a exponential profile for the radial light -# model. The functional form of the Sersic profile is defined as: +class ExponentialWedge(iExponentialMixin, WedgeGalaxy): + """wedge galaxy model with a exponential profile for the radial light + model. The functional form of the Sersic profile is defined as: -# I(R) = Ie * exp(- bn((R/Re) - 1)) + I(R) = Ie * exp(- bn((R/Re) - 1)) -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius. + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius. -# Parameters: -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius + Parameters: + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius -# """ + """ -# usable = True + usable = True diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index bb3e8a7d..9992414c 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -18,6 +18,9 @@ from .sersic import sersic, sersic_n_to_b from .moffat import moffat from .gaussian import gaussian +from .exponential import exponential +from .nuker import nuker +from .spline import spline from .transform import rotate __all__ = ( @@ -35,6 +38,9 @@ "sersic_n_to_b", "moffat", "gaussian", + "exponential", + "nuker", + "spline", "single_quad_integrate", "recursive_quad_integrate", "upsample", diff --git a/astrophot/models/func/exponential.py b/astrophot/models/func/exponential.py new file mode 100644 index 00000000..ff7e1469 --- /dev/null +++ b/astrophot/models/func/exponential.py @@ -0,0 +1,16 @@ +import torch +from .sersic import sersic_n_to_b + +b = sersic_n_to_b(1.0) + + +def exponential(R, Re, Ie): + """Exponential 1d profile function, specifically designed for pytorch + operations. + + Parameters: + R: Radii tensor at which to evaluate the sersic function + Re: Effective radius in the same units as R + Ie: Effective surface density + """ + return Ie * torch.exp(-b * ((R / Re) - 1.0)) diff --git a/astrophot/models/gaussian_model.py b/astrophot/models/gaussian_model.py index dfa2a85d..b9d3b059 100644 --- a/astrophot/models/gaussian_model.py +++ b/astrophot/models/gaussian_model.py @@ -1,21 +1,21 @@ from .galaxy_model_object import GalaxyModel -# from .warp_model import Warp_Galaxy -# from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp -# from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp -# from .ray_model import Ray_Galaxy -# from .wedge_model import Wedge_Galaxy +from .warp_model import WarpGalaxy +from .superellipse_model import SuperEllipseGalaxy +from .foureirellipse_model import FourierEllipseGalaxy +from .ray_model import RayGalaxy +from .wedge_model import WedgeGalaxy from .psf_model_object import PSFModel from .mixins import GaussianMixin, RadialMixin __all__ = [ "GaussianGalaxy", "GaussianPSF", - # "Gaussian_SuperEllipse", - # "Gaussian_SuperEllipse_Warp", - # "Gaussian_FourierEllipse", - # "Gaussian_FourierEllipse_Warp", - # "Gaussian_Warp", + "GaussianSuperEllipse", + "GaussianFourierEllipse", + "GaussianWarp", + "GaussianRay", + "GaussianWedge", ] @@ -57,271 +57,97 @@ class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): usable = True -# class Gaussian_SuperEllipse(SuperEllipse_Galaxy): -# """Super ellipse galaxy model with Gaussian as the radial light -# profile.The gaussian radial profile is defined as: +class GaussianSuperEllipse(GaussianMixin, RadialMixin, SuperEllipseGalaxy): + """Super ellipse galaxy model with Gaussian as the radial light + profile.The gaussian radial profile is defined as: -# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - -# where I(R) is the prightness as a function of semi-major axis -# length, F is the total flux in the model, R is the semi-major -# axis, and S is the standard deviation. - -# Parameters: -# sigma: standard deviation of the gaussian profile, must be a positive value -# flux: the total flux in the gaussian model, represented as the log of the total - -# """ - -# model_type = f"gaussian {SuperEllipse_Galaxy.model_type}" -# parameter_specs = { -# "sigma": {"units": "arcsec", "limits": (0, None)}, -# "flux": {"units": "log10(flux)"}, -# } -# _parameter_order = SuperEllipse_Galaxy._parameter_order + ("sigma", "flux") -# usable = True - -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def initialize(self, target=None, parameters=None, **kwargs): -# super().initialize(target=target, parameters=parameters) - -# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - -# from ._shared_methods import gaussian_radial_model as radial_model - - -# class Gaussian_SuperEllipse_Warp(SuperEllipse_Warp): -# """super ellipse warp galaxy model with a gaussian profile for the -# radial light profile. The gaussian radial profile is defined as: - -# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - -# where I(R) is the prightness as a function of semi-major axis -# length, F is the total flux in the model, R is the semi-major -# axis, and S is the standard deviation. - -# Parameters: -# sigma: standard deviation of the gaussian profile, must be a positive value -# flux: the total flux in the gaussian model, represented as the log of the total - -# """ - -# model_type = f"gaussian {SuperEllipse_Warp.model_type}" -# parameter_specs = { -# "sigma": {"units": "arcsec", "limits": (0, None)}, -# "flux": {"units": "log10(flux)"}, -# } -# _parameter_order = SuperEllipse_Warp._parameter_order + ("sigma", "flux") -# usable = True - -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def initialize(self, target=None, parameters=None, **kwargs): -# super().initialize(target=target, parameters=parameters) - -# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - -# from ._shared_methods import gaussian_radial_model as radial_model - - -# class Gaussian_FourierEllipse(FourierEllipse_Galaxy): -# """fourier mode perturbations to ellipse galaxy model with a gaussian -# profile for the radial light profile. The gaussian radial profile -# is defined as: - -# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - -# where I(R) is the prightness as a function of semi-major axis -# length, F is the total flux in the model, R is the semi-major -# axis, and S is the standard deviation. - -# Parameters: -# sigma: standard deviation of the gaussian profile, must be a positive value -# flux: the total flux in the gaussian model, represented as the log of the total + I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) -# """ + where I(R) is the prightness as a function of semi-major axis + length, F is the total flux in the model, R is the semi-major + axis, and S is the standard deviation. -# model_type = f"gaussian {FourierEllipse_Galaxy.model_type}" -# parameter_specs = { -# "sigma": {"units": "arcsec", "limits": (0, None)}, -# "flux": {"units": "log10(flux)"}, -# } -# _parameter_order = FourierEllipse_Galaxy._parameter_order + ("sigma", "flux") -# usable = True + Parameters: + sigma: standard deviation of the gaussian profile, must be a positive value + flux: the total flux in the gaussian model, represented as the log of the total -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def initialize(self, target=None, parameters=None, **kwargs): -# super().initialize(target=target, parameters=parameters) + """ -# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) + usable = True -# from ._shared_methods import gaussian_radial_model as radial_model +class GaussianFourierEllipse(GaussianMixin, RadialMixin, FourierEllipseGalaxy): + """fourier mode perturbations to ellipse galaxy model with a gaussian + profile for the radial light profile. The gaussian radial profile + is defined as: -# class Gaussian_FourierEllipse_Warp(FourierEllipse_Warp): -# """fourier mode perturbations to ellipse galaxy model with a gaussian -# profile for the radial light profile. The gaussian radial profile -# is defined as: + I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) -# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) + where I(R) is the prightness as a function of semi-major axis + length, F is the total flux in the model, R is the semi-major + axis, and S is the standard deviation. -# where I(R) is the prightness as a function of semi-major axis -# length, F is the total flux in the model, R is the semi-major -# axis, and S is the standard deviation. + Parameters: + sigma: standard deviation of the gaussian profile, must be a positive value + flux: the total flux in the gaussian model, represented as the log of the total -# Parameters: -# sigma: standard deviation of the gaussian profile, must be a positive value -# flux: the total flux in the gaussian model, represented as the log of the total + """ -# """ + usable = True -# model_type = f"gaussian {FourierEllipse_Warp.model_type}" -# parameter_specs = { -# "sigma": {"units": "arcsec", "limits": (0, None)}, -# "flux": {"units": "log10(flux)"}, -# } -# _parameter_order = FourierEllipse_Warp._parameter_order + ("sigma", "flux") -# usable = True -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def initialize(self, target=None, parameters=None, **kwargs): -# super().initialize(target=target, parameters=parameters) +class GaussianWarp(GaussianMixin, RadialMixin, WarpGalaxy): + """Coordinate warped galaxy model with Gaussian as the radial light + profile. The gaussian radial profile is defined as: -# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) + I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) -# from ._shared_methods import gaussian_radial_model as radial_model + where I(R) is the prightness as a function of semi-major axis + length, F is the total flux in the model, R is the semi-major + axis, and S is the standard deviation. + Parameters: + sigma: standard deviation of the gaussian profile, must be a positive value + flux: the total flux in the gaussian model, represented as the log of the total -# class Gaussian_Warp(Warp_Galaxy): -# """Coordinate warped galaxy model with Gaussian as the radial light -# profile. The gaussian radial profile is defined as: + """ -# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) + usable = True -# where I(R) is the prightness as a function of semi-major axis -# length, F is the total flux in the model, R is the semi-major -# axis, and S is the standard deviation. -# Parameters: -# sigma: standard deviation of the gaussian profile, must be a positive value -# flux: the total flux in the gaussian model, represented as the log of the total +class GaussianRay(iGaussianMixin, RayGalaxy): + """ray galaxy model with a gaussian profile for the radial light + model. The gaussian radial profile is defined as: -# """ + I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) -# model_type = f"gaussian {Warp_Galaxy.model_type}" -# parameter_specs = { -# "sigma": {"units": "arcsec", "limits": (0, None)}, -# "flux": {"units": "log10(flux)"}, -# } -# _parameter_order = Warp_Galaxy._parameter_order + ("sigma", "flux") -# usable = True + where I(R) is the prightness as a function of semi-major axis + length, F is the total flux in the model, R is the semi-major + axis, and S is the standard deviation. -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def initialize(self, target=None, parameters=None, **kwargs): -# super().initialize(target=target, parameters=parameters) + Parameters: + sigma: standard deviation of the gaussian profile, must be a positive value + flux: the total flux in the gaussian model, represented as the log of the total -# parametric_initialize(self, parameters, target, _wrap_gauss, ("sigma", "flux"), _x0_func) - -# from ._shared_methods import gaussian_radial_model as radial_model + """ + usable = True -# class Gaussian_Ray(Ray_Galaxy): -# """ray galaxy model with a gaussian profile for the radial light -# model. The gaussian radial profile is defined as: -# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - -# where I(R) is the prightness as a function of semi-major axis -# length, F is the total flux in the model, R is the semi-major -# axis, and S is the standard deviation. - -# Parameters: -# sigma: standard deviation of the gaussian profile, must be a positive value -# flux: the total flux in the gaussian model, represented as the log of the total +class GaussianWedge(iGaussianMixin, WedgeGalaxy): + """wedge galaxy model with a gaussian profile for the radial light + model. The gaussian radial profile is defined as: -# """ + I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) -# model_type = f"gaussian {Ray_Galaxy.model_type}" -# parameter_specs = { -# "sigma": {"units": "arcsec", "limits": (0, None)}, -# "flux": {"units": "log10(flux)"}, -# } -# _parameter_order = Ray_Galaxy._parameter_order + ("sigma", "flux") -# usable = True + where I(R) is the prightness as a function of semi-major axis + length, F is the total flux in the model, R is the semi-major + axis, and S is the standard deviation. -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def initialize(self, target=None, parameters=None, **kwargs): -# super().initialize(target=target, parameters=parameters) - -# parametric_segment_initialize( -# model=self, -# parameters=parameters, -# target=target, -# prof_func=_wrap_gauss, -# params=("sigma", "flux"), -# x0_func=_x0_func, -# segments=self.rays, -# ) - -# from ._shared_methods import gaussian_iradial_model as iradial_model - - -# class Gaussian_Wedge(Wedge_Galaxy): -# """wedge galaxy model with a gaussian profile for the radial light -# model. The gaussian radial profile is defined as: - -# I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - -# where I(R) is the prightness as a function of semi-major axis -# length, F is the total flux in the model, R is the semi-major -# axis, and S is the standard deviation. - -# Parameters: -# sigma: standard deviation of the gaussian profile, must be a positive value -# flux: the total flux in the gaussian model, represented as the log of the total + Parameters: + sigma: standard deviation of the gaussian profile, must be a positive value + flux: the total flux in the gaussian model, represented as the log of the total -# """ + """ -# model_type = f"gaussian {Wedge_Galaxy.model_type}" -# parameter_specs = { -# "sigma": {"units": "arcsec", "limits": (0, None)}, -# "flux": {"units": "log10(flux)"}, -# } -# _parameter_order = Wedge_Galaxy._parameter_order + ("sigma", "flux") -# usable = True - -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def initialize(self, target=None, parameters=None, **kwargs): -# super().initialize(target=target, parameters=parameters) - -# parametric_segment_initialize( -# self, -# parameters, -# target, -# _wrap_gauss, -# ("sigma", "flux"), -# _x0_func, -# self.wedges, -# ) - -# from ._shared_methods import gaussian_iradial_model as iradial_model + usable = True diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index 2a46e321..b242e35a 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -2,8 +2,10 @@ from .brightness import RadialMixin from .transform import InclinedMixin from .exponential import ExponentialMixin, iExponentialMixin -from .moffat import MoffatMixin -from .gaussian import GaussianMixin +from .moffat import MoffatMixin, iMoffatMixin +from .gaussian import GaussianMixin, iGaussianMixin +from .nuker import NukerMixin, iNukerMixin +from .spline import SplineMixin from .sample import SampleMixin __all__ = ( @@ -14,6 +16,11 @@ "ExponentialMixin", "iExponentialMixin", "MoffatMixin", + "iMoffatMixin", "GaussianMixin", + "iGaussianMixin", + "NukerMixin", + "iNukerMixin", + "SplineMixin", "SampleMixin", ) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 6d24b8e9..9505a94d 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -76,10 +76,10 @@ def initialize(self, target=None, parameters=None, **kwargs): model=self, target=target, parameters=parameters, - prof_func=func.exponential, + prof_func=exponential_np, params=("Re", "Ie"), x0_func=_x0_func, - segments=self.rays, + segments=self.segments, ) @forward diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 4718c9c2..9a12213a 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -31,3 +31,30 @@ def initialize(self): @forward def radial_model(self, R, sigma, flux): return func.gaussian(R, sigma, flux) + + +class iGaussianMixin: + + _model_type = "gaussian" + _parameter_specs = { + "sigma": {"units": "arcsec", "valid": (0, None)}, + "flux": {"units": "flux"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_segment_initialize( + model=self, + target=self.target[self.window], + prof_func=gaussian_np, + params=("sigma", "flux"), + x0_func=_x0_func, + segments=self.segments, + ) + + @forward + def iradial_model(self, i, R, sigma, flux): + return func.gaussian(R, sigma[i], flux[i]) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 55a997e6..6ca6a9e3 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -54,7 +54,7 @@ def initialize(self): prof_func=moffat_np, params=("n", "Rd", "I0"), x0_func=_x0_func, - segments=self.rays, + segments=self.segments, ) @forward diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py new file mode 100644 index 00000000..8c2db66d --- /dev/null +++ b/astrophot/models/mixins/nuker.py @@ -0,0 +1,70 @@ +import torch + +from ...param import forward +from ...utils.decorators import ignore_numpy_warnings +from .._shared_methods import parametric_initialize, parametric_segment_initialize +from ...utils.parametric_profiles import nuker_np +from .. import func + + +def _x0_func(model_params, R, F): + return R[4], F[4], 1.0, 2.0, 0.5 + + +class NukerMixin: + + _model_type = "nuker" + _parameter_specs = { + "Rb": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "Ib": {"units": "flux/arcsec^2", "shape": ()}, + "alpha": {"units": "none", "valid": (0, None), "shape": ()}, + "beta": {"units": "none", "valid": (0, None), "shape": ()}, + "gamma": {"units": "none", "shape": ()}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + nuker_np, + ("Rb", "Ib", "alpha", "beta", "gamma"), + _x0_func, + ) + + @forward + def radial_model(self, R, Rb, Ib, alpha, beta, gamma): + return func.nuker(R, Rb, Ib, alpha, beta, gamma) + + +class iNukerMixin: + + _model_type = "nuker" + _parameter_specs = { + "Rb": {"units": "arcsec", "valid": (0, None)}, + "Ib": {"units": "flux/arcsec^2"}, + "alpha": {"units": "none", "valid": (0, None)}, + "beta": {"units": "none", "valid": (0, None)}, + "gamma": {"units": "none"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_segment_initialize( + model=self, + target=self.target[self.window], + prof_func=nuker_np, + params=("Rb", "Ib", "alpha", "beta", "gamma"), + x0_func=_x0_func, + segments=self.segments, + ) + + @forward + def iradial_model(self, i, R, Rb, Ib, alpha, beta, gamma): + return func.nuker(R, Rb[i], Ib[i], alpha[i], beta[i], gamma[i]) diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index d28c1e47..f4732b2d 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -54,9 +54,9 @@ def initialize(self): prof_func=sersic_np, params=("n", "Re", "Ie"), x0_func=_x0_func, - segments=self.rays, + segments=self.segments, ) @forward - def radial_model(self, i, R, n, Re, Ie): + def iradial_model(self, i, R, n, Re, Ie): return func.sersic(R, n[i], Re[i], Ie[i]) diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py new file mode 100644 index 00000000..019febeb --- /dev/null +++ b/astrophot/models/mixins/spline.py @@ -0,0 +1,98 @@ +import torch + +from ...param import forward +from ...utils.decorators import ignore_numpy_warnings +from .._shared_methods import _sample_image +from .. import func + + +class SplineMixin: + + _model_type = "spline" + parameter_specs = { + "I_R": {"units": "flux/arcsec^2"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if self.I_R.value is not None: + return + + target_area = self.target[self.window] + # Create the I_R profile radii if needed + if self.I_R.prof is None: + prof = [0, 2 * target_area.pixel_length] + while prof[-1] < (max(self.window.shape) * target_area.pixel_length / 2): + prof.append(prof[-1] + torch.max(2 * target_area.pixel_length, prof[-1] * 0.2)) + prof.pop() + prof.append( + torch.sqrt( + torch.sum((self.window.shape[0] / 2) ** 2 + (self.window.shape[1] / 2) ** 2) + * target_area.pixel_length**2 + ) + ) + self.I_R.prof = prof + else: + prof = self.I_R.prof + + R, I, S = _sample_image( + target_area, + self.transform_coordinates, + self.radius_metric, + rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], + ) + self.I_R.dynamic_value = I + self.I_R.uncertainty = S + + @forward + def radial_model(self, R, I_R): + return func.spline(R, self.I_R.prof, I_R) + + +class iSplineMixin: + + _model_type = "spline" + parameter_specs = { + "I_R": {"units": "flux/arcsec^2"}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if self.I_R.value is not None: + return + + target_area = self.target[self.window] + # Create the I_R profile radii if needed + if self.I_R.prof is None: + prof = [0, 2 * target_area.pixel_length] + while prof[-1] < (max(self.window.shape) * target_area.pixel_length / 2): + prof.append(prof[-1] + torch.max(2 * target_area.pixel_length, prof[-1] * 0.2)) + prof.pop() + prof.append( + torch.sqrt( + torch.sum((self.window.shape[0] / 2) ** 2 + (self.window.shape[1] / 2) ** 2) + * target_area.pixel_length**2 + ) + ) + self.I_R.prof = [prof] * self.segments + else: + prof = self.I_R.prof + + R, I, S = _sample_image( + target_area, + self.transform_coordinates, + self.radius_metric, + rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], + ) + self.I_R.dynamic_value = I + self.I_R.uncertainty = S + + @forward + def iradial_model(self, i, R, I_R): + return func.spline(R, self.I_R.prof[i], I_R[i]) diff --git a/astrophot/models/moffat_model.py b/astrophot/models/moffat_model.py index 2123a0f2..d42e1969 100644 --- a/astrophot/models/moffat_model.py +++ b/astrophot/models/moffat_model.py @@ -2,13 +2,18 @@ from .galaxy_model_object import GalaxyModel from .psf_model_object import PSFModel +from .warp_model import WarpGalaxy +from .ray_model import RayGalaxy +from .wedge_model import WedgeGalaxy +from .superellipse_model import SuperEllipseGalaxy +from .foureirellipse_model import FourierEllipseGalaxy from ..utils.conversions.functions import moffat_I0_to_flux -from .mixins import MoffatMixin, InclinedMixin +from .mixins import MoffatMixin, InclinedMixin, RadialMixin -__all__ = ["MoffatGalaxy", "MoffatPSF"] +__all__ = ("MoffatGalaxy", "MoffatPSF") -class MoffatGalaxy(MoffatMixin, GalaxyModel): +class MoffatGalaxy(MoffatMixin, RadialMixin, GalaxyModel): """basic galaxy model with a Moffat profile for the radial light profile. The functional form of the Moffat profile is defined as: @@ -33,7 +38,7 @@ def total_flux(self, n, Rd, I0, q): return moffat_I0_to_flux(I0, n, Rd, q) -class MoffatPSF(MoffatMixin, PSFModel): +class MoffatPSF(MoffatMixin, RadialMixin, PSFModel): """basic point source model with a Moffat profile for the radial light profile. The functional form of the Moffat profile is defined as: @@ -66,3 +71,23 @@ class Moffat2DPSF(InclinedMixin, MoffatPSF): @forward def total_flux(self, n, Rd, I0, q): return moffat_I0_to_flux(I0, n, Rd, q) + + +class MoffatSuperEllipseGalaxy(MoffatMixin, RadialMixin, SuperEllipseGalaxy): + usable = True + + +class MoffatFourierEllipseGalaxy(MoffatMixin, RadialMixin, FourierEllipseGalaxy): + usable = True + + +class MoffatWarpGalaxy(MoffatMixin, RadialMixin, WarpGalaxy): + usable = True + + +class MoffatWedgeGalaxy(MoffatMixin, WedgeGalaxy): + usable = True + + +class MoffatRayGalaxy(MoffatMixin, RayGalaxy): + usable = True diff --git a/astrophot/models/nuker_model.py b/astrophot/models/nuker_model.py index 8911ca99..e3c58bcb 100644 --- a/astrophot/models/nuker_model.py +++ b/astrophot/models/nuker_model.py @@ -1,41 +1,23 @@ -import torch - -from .galaxy_model_object import Galaxy_Model -from .psf_model_object import PSF_Model -from .warp_model import Warp_Galaxy -from .ray_model import Ray_Galaxy -from .wedge_model import Wedge_Galaxy -from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp -from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp -from ._shared_methods import ( - parametric_initialize, - parametric_segment_initialize, - select_target, -) -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..utils.parametric_profiles import nuker_np +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel +from .warp_model import WarpGalaxy +from .ray_model import RayGalaxy +from .wedge_model import WedgeGalaxy +from .superellipse_model import SuperEllipseGalaxy +from .foureirellipse_model import FourierEllipseGalaxy +from .mixins import NukerMixin, RadialMixin __all__ = [ - "Nuker_Galaxy", - "Nuker_PSF", - "Nuker_SuperEllipse", - "Nuker_SuperEllipse_Warp", - "Nuker_FourierEllipse", - "Nuker_FourierEllipse_Warp", - "Nuker_Warp", - "Nuker_Ray", + "NukerGalaxy", + "NukerPSF", + "NukerSuperEllipse", + "NukerFourierEllipse", + "NukerWarp", + "NukerRay", ] -def _x0_func(model_params, R, F): - return R[4], F[4], 1.0, 2.0, 0.5 - - -def _wrap_nuker(R, rb, ib, a, b, g): - return nuker_np(R, rb, 10 ** (ib), a, b, g) - - -class Nuker_Galaxy(Galaxy_Model): +class NukerGalaxy(NukerMixin, RadialMixin, GalaxyModel): """basic galaxy model with a Nuker profile for the radial light profile. The functional form of the Nuker profile is defined as: @@ -56,43 +38,10 @@ class Nuker_Galaxy(Galaxy_Model): """ - model_type = f"nuker {Galaxy_Model.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)"}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = Galaxy_Model._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize( - self, - parameters, - target, - _wrap_nuker, - ("Rb", "Ib", "alpha", "beta", "gamma"), - _x0_func, - ) - from ._shared_methods import nuker_radial_model as radial_model - - -class Nuker_PSF(PSF_Model): +class NukerPSF(NukerMixin, RadialMixin, PSFModel): """basic point source model with a Nuker profile for the radial light profile. The functional form of the Nuker profile is defined as: @@ -113,45 +62,10 @@ class Nuker_PSF(PSF_Model): """ - model_type = f"nuker {PSF_Model.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)", "value": 0.0, "locked": True}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = PSF_Model._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) usable = True - model_integrated = False - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize( - self, - parameters, - target, - _wrap_nuker, - ("Rb", "Ib", "alpha", "beta", "gamma"), - _x0_func, - ) - - from ._shared_methods import nuker_radial_model as radial_model - from ._shared_methods import radial_evaluate_model as evaluate_model -class Nuker_SuperEllipse(SuperEllipse_Galaxy): +class NukerSuperEllipse(NukerMixin, RadialMixin, SuperEllipseGalaxy): """super ellipse galaxy model with a Nuker profile for the radial light profile. The functional form of the Nuker profile is defined as: @@ -172,102 +86,10 @@ class Nuker_SuperEllipse(SuperEllipse_Galaxy): """ - model_type = f"nuker {SuperEllipse_Galaxy.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)"}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = SuperEllipse_Galaxy._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) - usable = True - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize( - self, - parameters, - target, - _wrap_nuker, - ("Rb", "Ib", "alpha", "beta", "gamma"), - _x0_func, - ) - - from ._shared_methods import nuker_radial_model as radial_model - - -class Nuker_SuperEllipse_Warp(SuperEllipse_Warp): - """super ellipse warp galaxy model with a Nuker profile for the - radial light profile. The functional form of the Nuker profile is - defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - - """ - - model_type = f"nuker {SuperEllipse_Warp.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)"}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = SuperEllipse_Warp._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize( - self, - parameters, - target, - _wrap_nuker, - ("Rb", "Ib", "alpha", "beta", "gamma"), - _x0_func, - ) - - from ._shared_methods import nuker_radial_model as radial_model - -class Nuker_FourierEllipse(FourierEllipse_Galaxy): +class NukerFourierEllipse(NukerMixin, RadialMixin, FourierEllipseGalaxy): """fourier mode perturbations to ellipse galaxy model with a Nuker profile for the radial light profile. The functional form of the Nuker profile is defined as: @@ -289,101 +111,10 @@ class Nuker_FourierEllipse(FourierEllipse_Galaxy): """ - model_type = f"nuker {FourierEllipse_Galaxy.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)"}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = FourierEllipse_Galaxy._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_initialize( - self, - parameters, - target, - _wrap_nuker, - ("Rb", "Ib", "alpha", "beta", "gamma"), - _x0_func, - ) - - from ._shared_methods import nuker_radial_model as radial_model - - -class Nuker_FourierEllipse_Warp(FourierEllipse_Warp): - """fourier mode perturbations to ellipse galaxy model with a Nuker - profile for the radial light profile. The functional form of the - Nuker profile is defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - - model_type = f"nuker {FourierEllipse_Warp.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)"}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = FourierEllipse_Warp._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) - usable = True - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize( - self, - parameters, - target, - _wrap_nuker, - ("Rb", "Ib", "alpha", "beta", "gamma"), - _x0_func, - ) - - from ._shared_methods import nuker_radial_model as radial_model - - -class Nuker_Warp(Warp_Galaxy): +class NukerWarp(NukerMixin, RadialMixin, WarpGalaxy): """warped coordinate galaxy model with a Nuker profile for the radial light model. The functional form of the Nuker profile is defined as: @@ -405,43 +136,10 @@ class Nuker_Warp(Warp_Galaxy): """ - model_type = f"nuker {Warp_Galaxy.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)"}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = Warp_Galaxy._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_initialize( - self, - parameters, - target, - _wrap_nuker, - ("Rb", "Ib", "alpha", "beta", "gamma"), - _x0_func, - ) - - from ._shared_methods import nuker_radial_model as radial_model - -class Nuker_Ray(Ray_Galaxy): +class NukerRay(iNukerMixin, RayGalaxy): """ray galaxy model with a nuker profile for the radial light model. The functional form of the Sersic profile is defined as: @@ -462,44 +160,10 @@ class Nuker_Ray(Ray_Galaxy): """ - model_type = f"nuker {Ray_Galaxy.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)"}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = Ray_Galaxy._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) usable = True - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - parametric_segment_initialize( - model=self, - parameters=parameters, - target=target, - prof_func=_wrap_nuker, - params=("Rb", "Ib", "alpha", "beta", "gamma"), - x0_func=_x0_func, - segments=self.rays, - ) - - from ._shared_methods import nuker_iradial_model as iradial_model - - -class Nuker_Wedge(Wedge_Galaxy): +class NukerWedge(iNukerMixin, WedgeGalaxy): """wedge galaxy model with a nuker profile for the radial light model. The functional form of the Sersic profile is defined as: @@ -520,38 +184,4 @@ class Nuker_Wedge(Wedge_Galaxy): """ - model_type = f"nuker {Wedge_Galaxy.model_type}" - parameter_specs = { - "Rb": {"units": "arcsec", "limits": (0, None)}, - "Ib": {"units": "log10(flux/arcsec^2)"}, - "alpha": {"units": "none", "limits": (0, None)}, - "beta": {"units": "none", "limits": (0, None)}, - "gamma": {"units": "none"}, - } - _parameter_order = Wedge_Galaxy._parameter_order + ( - "Rb", - "Ib", - "alpha", - "beta", - "gamma", - ) usable = True - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - parametric_segment_initialize( - model=self, - parameters=parameters, - target=target, - prof_func=_wrap_nuker, - params=("Rb", "Ib", "alpha", "beta", "gamma"), - x0_func=_x0_func, - segments=self.wedges, - ) - - from ._shared_methods import nuker_iradial_model as iradial_model diff --git a/astrophot/models/pixelated_psf_model.py b/astrophot/models/pixelated_psf_model.py index 5169a3b0..c250fcdd 100644 --- a/astrophot/models/pixelated_psf_model.py +++ b/astrophot/models/pixelated_psf_model.py @@ -1,15 +1,14 @@ import torch -from .psf_model_object import PSF_Model -from ..utils.decorators import ignore_numpy_warnings, default_internal +from .psf_model_object import PSFModel +from ..utils.decorators import ignore_numpy_warnings from ..utils.interpolate import interp2d -from ._shared_methods import select_target -from ..param import Param_Unlock, Param_SoftLimits +from caskade import OverrideParam -__all__ = ["Pixelated_PSF"] +__all__ = ["PixelatedPSF"] -class Pixelated_PSF(PSF_Model): +class PixelatedPSF(PSFModel): """point source model which uses an image of the PSF as its representation for point sources. Using bilinear interpolation it will shift the PSF within a pixel to accurately represent the @@ -37,50 +36,25 @@ class Pixelated_PSF(PSF_Model): """ - model_type = f"pixelated {PSF_Model.model_type}" - parameter_specs = { - "pixels": {"units": "log10(flux/arcsec^2)"}, + _model_type = "pixelated" + _parameter_specs = { + "pixels": {"units": "flux"}, } - _parameter_order = PSF_Model._parameter_order + ("pixels",) usable = True - model_integrated = True @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - target_area = target[self.window] - with Param_Unlock(parameters["pixels"]), Param_SoftLimits(parameters["pixels"]): - if parameters["pixels"].value is None: - dat = torch.abs(target_area.data) - dat[dat == 0] = torch.median(dat) * 1e-7 - parameters["pixels"].value = torch.log10(dat / target.pixel_area) - if parameters["pixels"].uncertainty is None: - parameters["pixels"].uncertainty = ( - torch.abs(parameters["pixels"].value) * self.default_uncertainty - ) + def initialize(self): + super().initialize() + if self.pixels.value is None: + target_area = self.target[self.window] + self.pixels.dynamic_value = target_area.data.value + self.pixels.uncertainty = torch.abs(self.pixels.value) * self.default_uncertainty - @default_internal - def evaluate_model(self, X=None, Y=None, image=None, parameters=None, **kwargs): - if X is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] + def brightness(self, x, y, pixels, center): + with OverrideParam(self.target.crtan, center): + pX, pY = self.target.plane_to_pixel(x, y) - # Convert coordinates into pixel locations in the psf image - pX, pY = self.target.plane_to_pixel(X, Y) + result = interp2d(pixels, pX, pY) - # Select only the pixels where the PSF image is defined - select = torch.logical_and( - torch.logical_and(pX > -0.5, pX < parameters["pixels"].shape[1] - 0.5), - torch.logical_and(pY > -0.5, pY < parameters["pixels"].shape[0] - 0.5), - ) - - # Zero everywhere outside the psf - result = torch.zeros_like(X) - - # Use bilinear interpolation of the PSF at the requested coordinates - result[select] = interp2d(parameters["pixels"].value, pX[select], pY[select]) - - return image.pixel_area * 10**result + return result diff --git a/astrophot/models/planesky_model.py b/astrophot/models/planesky_model.py index 31b0ace7..1eb5ea50 100644 --- a/astrophot/models/planesky_model.py +++ b/astrophot/models/planesky_model.py @@ -2,15 +2,13 @@ from scipy.stats import iqr import torch -from .sky_model_object import Sky_Model -from ._shared_methods import select_target -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..param import Param_Unlock, Param_SoftLimits +from .sky_model_object import SkyModel +from ..utils.decorators import ignore_numpy_warnings -__all__ = ["Plane_Sky"] +__all__ = ["PlaneSky"] -class Plane_Sky(Sky_Model): +class PlaneSky(SkyModel): """Sky background model using a tilted plane for the sky flux. The brightness for each pixel is defined as: I(X, Y) = S + X*dx + Y*dy @@ -25,50 +23,35 @@ class Plane_Sky(Sky_Model): """ - model_type = f"plane {Sky_Model.model_type}" - parameter_specs = { - "F": {"units": "flux/arcsec^2"}, + _model_type = "plane" + _parameter_specs = { + "I0": {"units": "flux/arcsec^2"}, "delta": {"units": "flux/arcsec"}, } - _parameter_order = Sky_Model._parameter_order + ("F", "delta") usable = True @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - with Param_Unlock(parameters["F"]), Param_SoftLimits(parameters["F"]): - if parameters["F"].value is None: - parameters["F"].value = ( - np.median(target[self.window].data.detach().cpu().numpy()) - / target.pixel_area.item() + def initialize(self): + super().initialize() + + if self.I0.value is None: + self.I0.dynamic_value = ( + np.median(self.target[self.window].data.npvalue) / self.target.pixel_area.item() + ) + self.I0.uncertainty = ( + iqr( + self.target[self.window].data.npvalue, + rng=(16, 84), ) - if parameters["F"].uncertainty is None: - parameters["F"].uncertainty = ( - iqr( - target[self.window].data.detach().cpu().numpy(), - rng=(31.731 / 2, 100 - 31.731 / 2), - ) - / (2.0) - ) / np.sqrt(np.prod(self.window.shape.detach().cpu().numpy())) - with Param_Unlock(parameters["delta"]), Param_SoftLimits(parameters["delta"]): - if parameters["delta"].value is None: - parameters["delta"].value = [0.0, 0.0] - parameters["delta"].uncertainty = [ - self.default_uncertainty, - self.default_uncertainty, - ] - - @default_internal - def evaluate_model(self, X=None, Y=None, image=None, parameters=None, **kwargs): - if X is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - return ( - image.pixel_area * parameters["F"].value - + X * parameters["delta"].value[0] - + Y * parameters["delta"].value[1] - ) + / 2.0 + ) / np.sqrt(np.prod(self.window.shape.detach().cpu().numpy())) + if self.delta.value is None: + self.delta.dynamic_value = [0.0, 0.0] + self.delta.uncertainty = [ + self.default_uncertainty, + self.default_uncertainty, + ] + + def brightness(self, x, y, I0, delta): + return I0 + x * delta[0] + y * delta[1] diff --git a/astrophot/models/ray_model.py b/astrophot/models/ray_model.py index 965e9ae9..2ab48769 100644 --- a/astrophot/models/ray_model.py +++ b/astrophot/models/ray_model.py @@ -1,13 +1,12 @@ import numpy as np import torch -from .galaxy_model_object import Galaxy_Model -from ..utils.decorators import default_internal +from .galaxy_model_object import GalaxyModel -__all__ = ["Ray_Galaxy"] +__all__ = ["RayGalaxy"] -class Ray_Galaxy(Galaxy_Model): +class RayGalaxy(GalaxyModel): """Variant of a galaxy model which defines multiple radial models seprarately along some number of rays projected from the galaxy center. These rays smoothly transition from one to another along @@ -29,77 +28,62 @@ class Ray_Galaxy(Galaxy_Model): """ - model_type = f"ray {Galaxy_Model.model_type}" - special_kwargs = Galaxy_Model.special_kwargs + ["rays"] - rays = 2 - track_attrs = Galaxy_Model.track_attrs + ["rays"] + _model_type = "segments" usable = False + _options = ("symmetric_rays", "rays") - def __init__(self, *args, **kwargs): - self.symmetric_rays = True + def __init__(self, *args, symmetric_rays=True, segments=2, **kwargs): super().__init__(*args, **kwargs) - self.rays = kwargs.get("rays", Ray_Galaxy.rays) + self.symmetric_rays = symmetric_rays + self.segments = segments - @default_internal - def polar_model(self, R, T, image=None, parameters=None): + def polar_model(self, R, T): model = torch.zeros_like(R) - if self.rays % 2 == 0 and self.symmetric_rays: - for r in range(self.rays): - angles = (T - (r * np.pi / self.rays)) % np.pi + if self.segments % 2 == 0 and self.symmetric_rays: + for r in range(self.segments): + angles = (T - (r * np.pi / self.segments)) % np.pi indices = torch.logical_or( - angles < (np.pi / self.rays), - angles >= (np.pi * (1 - 1 / self.rays)), + angles < (np.pi / self.segments), + angles >= (np.pi * (1 - 1 / self.segments)), ) - weight = (torch.cos(angles[indices] * self.rays) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices], image) - elif self.rays % 2 == 1 and self.symmetric_rays: - for r in range(self.rays): - angles = (T - (r * np.pi / self.rays)) % (2 * np.pi) + weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 + model[indices] += weight * self.iradial_model(r, R[indices]) + elif self.segments % 2 == 1 and self.symmetric_rays: + for r in range(self.segments): + angles = (T - (r * np.pi / self.segments)) % (2 * np.pi) indices = torch.logical_or( - angles < (np.pi / self.rays), - angles >= (np.pi * (2 - 1 / self.rays)), + angles < (np.pi / self.segments), + angles >= (np.pi * (2 - 1 / self.segments)), ) - weight = (torch.cos(angles[indices] * self.rays) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices], image) - angles = (T - (np.pi + r * np.pi / self.rays)) % (2 * np.pi) + weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 + model[indices] += weight * self.iradial_model(r, R[indices]) + angles = (T - (np.pi + r * np.pi / self.segments)) % (2 * np.pi) indices = torch.logical_or( - angles < (np.pi / self.rays), - angles >= (np.pi * (2 - 1 / self.rays)), + angles < (np.pi / self.segments), + angles >= (np.pi * (2 - 1 / self.segments)), ) - weight = (torch.cos(angles[indices] * self.rays) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices], image) - elif self.rays % 2 == 0 and not self.symmetric_rays: - for r in range(self.rays): - angles = (T - (r * 2 * np.pi / self.rays)) % (2 * np.pi) + weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 + model[indices] += weight * self.iradial_model(r, R[indices]) + elif self.segments % 2 == 0 and not self.symmetric_rays: + for r in range(self.segments): + angles = (T - (r * 2 * np.pi / self.segments)) % (2 * np.pi) indices = torch.logical_or( - angles < (2 * np.pi / self.rays), - angles >= (2 * np.pi * (1 - 1 / self.rays)), + angles < (2 * np.pi / self.segments), + angles >= (2 * np.pi * (1 - 1 / self.segments)), ) - weight = (torch.cos(angles[indices] * self.rays) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices], image) + weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 + model[indices] += weight * self.iradial_model(r, R[indices]) else: - for r in range(self.rays): - angles = (T - (r * 2 * np.pi / self.rays)) % (2 * np.pi) + for r in range(self.segments): + angles = (T - (r * 2 * np.pi / self.segments)) % (2 * np.pi) indices = torch.logical_or( - angles < (2 * np.pi / self.rays), - angles >= (np.pi * (2 - 1 / self.rays)), + angles < (2 * np.pi / self.segments), + angles >= (np.pi * (2 - 1 / self.segments)), ) - weight = (torch.cos(angles[indices] * self.rays) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices], image) + weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 + model[indices] += weight * self.iradial_model(r, R[indices]) return model - def evaluate_model(self, X=None, Y=None, image=None, parameters=None, **kwargs): - if X is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - XX, YY = self.transform_coordinates(X, Y, image, parameters) - - return self.polar_model( - self.radius_metric(XX, YY, image=image, parameters=parameters), - self.angular_metric(XX, YY, image=image, parameters=parameters), - image=image, - parameters=parameters, - ) - - -# class SingleRay_Galaxy(Galaxy_Model): + def brightness(self, x, y): + x, y = self.transform_coordinates(x, y) + return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) diff --git a/astrophot/models/sersic_model.py b/astrophot/models/sersic_model.py index 9433f24b..e022b6b4 100644 --- a/astrophot/models/sersic_model.py +++ b/astrophot/models/sersic_model.py @@ -1,26 +1,24 @@ from ..param import forward from .galaxy_model_object import GalaxyModel -# from .warp_model import Warp_Galaxy -# from .ray_model import Ray_Galaxy -# from .wedge_model import Wedge_Galaxy +from .warp_model import WarpGalaxy +from .ray_model import RayGalaxy +from .wedge_model import WedgeGalaxy from .psf_model_object import PSFModel -# from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp -# from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp +from .superellipse_model import SuperEllipseGalaxy # , SuperEllipse_Warp +from .foureirellipse_model import FourierEllipseGalaxy # , FourierEllipse_Warp from ..utils.conversions.functions import sersic_Ie_to_flux_torch from .mixins import SersicMixin, RadialMixin, iSersicMixin __all__ = [ "SersicGalaxy", "SersicPSF", - # "Sersic_Warp", - # "Sersic_SuperEllipse", - # "Sersic_FourierEllipse", - # "Sersic_Ray", - # "Sersic_Wedge", - # "Sersic_SuperEllipse_Warp", - # "Sersic_FourierEllipse_Warp", + "Sersic_Warp", + "Sersic_SuperEllipse", + "Sersic_FourierEllipse", + "Sersic_Ray", + "Sersic_Wedge", ] @@ -76,159 +74,113 @@ def total_flux(self, Ie, n, Re): return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) -# class Sersic_SuperEllipse(SersicMixin, SuperEllipse_Galaxy): -# """super ellipse galaxy model with a sersic profile for the radial -# light profile. The functional form of the Sersic profile is defined as: +class SersicSuperEllipse(SersicMixin, RadialMixin, SuperEllipseGalaxy): + """super ellipse galaxy model with a sersic profile for the radial + light profile. The functional form of the Sersic profile is defined as: -# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius, and n is the sersic index -# which controls the shape of the profile. - -# Parameters: -# n: Sersic index which controls the shape of the brightness profile -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius - -# """ - -# usable = True - - -# class Sersic_SuperEllipse_Warp(SersicMixin, SuperEllipse_Warp): -# """super ellipse warp galaxy model with a sersic profile for the -# radial light profile. The functional form of the Sersic profile is -# defined as: - -# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius, and n is the sersic index -# which controls the shape of the profile. - -# Parameters: -# n: Sersic index which controls the shape of the brightness profile -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius - -# """ - -# usable = True - - -# class Sersic_FourierEllipse(SersicMixin, FourierEllipse_Galaxy): -# """fourier mode perturbations to ellipse galaxy model with a sersic -# profile for the radial light profile. The functional form of the -# Sersic profile is defined as: - -# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) + I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius, and n is the sersic index -# which controls the shape of the profile. + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius, and n is the sersic index + which controls the shape of the profile. -# Parameters: -# n: Sersic index which controls the shape of the brightness profile -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius + Parameters: + n: Sersic index which controls the shape of the brightness profile + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius -# """ + """ -# usable = True + usable = True -# class Sersic_FourierEllipse_Warp(SersicMixin, FourierEllipse_Warp): -# """fourier mode perturbations to ellipse galaxy model with a sersic -# profile for the radial light profile. The functional form of the -# Sersic profile is defined as: +class SersicFourierEllipse(SersicMixin, RadialMixin, FourierEllipseGalaxy): + """fourier mode perturbations to ellipse galaxy model with a sersic + profile for the radial light profile. The functional form of the + Sersic profile is defined as: -# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) + I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius, and n is the sersic index -# which controls the shape of the profile. + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius, and n is the sersic index + which controls the shape of the profile. -# Parameters: -# n: Sersic index which controls the shape of the brightness profile -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius + Parameters: + n: Sersic index which controls the shape of the brightness profile + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius -# """ + """ -# usable = True + usable = True -# class Sersic_Warp(SersicMixin, Warp_Galaxy): -# """warped coordinate galaxy model with a sersic profile for the radial -# light model. The functional form of the Sersic profile is defined -# as: +class SersicWarp(SersicMixin, RadialMixin, WarpGalaxy): + """warped coordinate galaxy model with a sersic profile for the radial + light model. The functional form of the Sersic profile is defined + as: -# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) + I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius, and n is the sersic index -# which controls the shape of the profile. + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius, and n is the sersic index + which controls the shape of the profile. -# Parameters: -# n: Sersic index which controls the shape of the brightness profile -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius + Parameters: + n: Sersic index which controls the shape of the brightness profile + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius -# """ + """ -# usable = True + usable = True -# class Sersic_Ray(iSersicMixin, Ray_Galaxy): -# """ray galaxy model with a sersic profile for the radial light -# model. The functional form of the Sersic profile is defined as: +class SersicRay(iSersicMixin, RayGalaxy): + """ray galaxy model with a sersic profile for the radial light + model. The functional form of the Sersic profile is defined as: -# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) + I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius, and n is the sersic index -# which controls the shape of the profile. + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius, and n is the sersic index + which controls the shape of the profile. -# Parameters: -# n: Sersic index which controls the shape of the brightness profile -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius + Parameters: + n: Sersic index which controls the shape of the brightness profile + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius -# """ + """ -# usable = True + usable = True -# class Sersic_Wedge(iSersicMixin, Wedge_Galaxy): -# """wedge galaxy model with a sersic profile for the radial light -# model. The functional form of the Sersic profile is defined as: +class SersicWedge(iSersicMixin, WedgeGalaxy): + """wedge galaxy model with a sersic profile for the radial light + model. The functional form of the Sersic profile is defined as: -# I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) + I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) -# where I(R) is the brightness profile as a function of semi-major -# axis, R is the semi-major axis length, Ie is the brightness as the -# half light radius, bn is a function of n and is not involved in -# the fit, Re is the half light radius, and n is the sersic index -# which controls the shape of the profile. + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius, and n is the sersic index + which controls the shape of the profile. -# Parameters: -# n: Sersic index which controls the shape of the brightness profile -# Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. -# Re: half light radius + Parameters: + n: Sersic index which controls the shape of the brightness profile + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius -# """ + """ -# usable = True + usable = True diff --git a/astrophot/models/spline_model.py b/astrophot/models/spline_model.py index 76711326..2845e89e 100644 --- a/astrophot/models/spline_model.py +++ b/astrophot/models/spline_model.py @@ -1,30 +1,28 @@ -import torch - -from .galaxy_model_object import Galaxy_Model -from .warp_model import Warp_Galaxy -from .superellipse_model import SuperEllipse_Galaxy, SuperEllipse_Warp -from .foureirellipse_model import FourierEllipse_Galaxy, FourierEllipse_Warp -from .psf_model_object import PSF_Model -from .ray_model import Ray_Galaxy -from .wedge_model import Wedge_Galaxy -from ._shared_methods import spline_segment_initialize, select_target -from ..utils.decorators import ignore_numpy_warnings, default_internal +from .galaxy_model_object import GalaxyModel + +from .warp_model import WarpGalaxy +from .superellipse_model import SuperEllipseGalaxy # , SuperEllipse_Warp +from .foureirellipse_model import FourierEllipseGalaxy # , FourierEllipse_Warp +from .psf_model_object import PSFModel + +from .ray_model import RayGalaxy +from .wedge_model import WedgeGalaxy +from .mixins import SplineMixin, RadialMixin __all__ = [ - "Spline_Galaxy", - "Spline_PSF", - "Spline_Warp", - "Spline_SuperEllipse", - "Spline_FourierEllipse", - "Spline_Ray", - "Spline_SuperEllipse_Warp", - "Spline_FourierEllipse_Warp", + "SplineGalaxy", + "SplinePSF", + "SplineWarp", + "SplineSuperEllipse", + "SplineFourierEllipse", + "SplineRay", + "SplineWedge", ] # First Order ###################################################################### -class Spline_Galaxy(Galaxy_Model): +class SplineGalaxy(SplineMixin, RadialMixin, GalaxyModel): """Basic galaxy model with a spline radial light profile. The light profile is defined as a cubic spline interpolation of the stored brightness values: @@ -41,19 +39,10 @@ class Spline_Galaxy(Galaxy_Model): """ - model_type = f"spline {Galaxy_Model.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = Galaxy_Model._parameter_order + ("I(R)",) usable = True - extend_profile = True - from ._shared_methods import spline_initialize as initialize - from ._shared_methods import spline_radial_model as radial_model - -class Spline_PSF(PSF_Model): +class SplinePSF(SplineMixin, RadialMixin, PSFModel): """star model with a spline radial light profile. The light profile is defined as a cubic spline interpolation of the stored brightness values: @@ -70,25 +59,10 @@ class Spline_PSF(PSF_Model): """ - model_type = f"spline {PSF_Model.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = PSF_Model._parameter_order + ("I(R)",) usable = True - extend_profile = True - model_integrated = False - - @default_internal - def transform_coordinates(self, X=None, Y=None, image=None, parameters=None): - return X, Y - from ._shared_methods import spline_initialize as initialize - from ._shared_methods import spline_radial_model as radial_model - from ._shared_methods import radial_evaluate_model as evaluate_model - -class Spline_Warp(Warp_Galaxy): +class SplineWarp(SplineMixin, RadialMixin, WarpGalaxy): """warped coordinate galaxy model with a spline light profile. The light profile is defined as a cubic spline interpolation of the stored brightness values: @@ -105,21 +79,10 @@ class Spline_Warp(Warp_Galaxy): """ - model_type = f"spline {Warp_Galaxy.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = Warp_Galaxy._parameter_order + ("I(R)",) usable = True - extend_profile = True - - from ._shared_methods import spline_initialize as initialize - from ._shared_methods import spline_radial_model as radial_model -# Second Order -###################################################################### -class Spline_SuperEllipse(SuperEllipse_Galaxy): +class SplineSuperEllipse(SplineMixin, RadialMixin, SuperEllipseGalaxy): """The light profile is defined as a cubic spline interpolation of the stored brightness values: @@ -135,19 +98,10 @@ class Spline_SuperEllipse(SuperEllipse_Galaxy): """ - model_type = f"spline {SuperEllipse_Galaxy.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = SuperEllipse_Galaxy._parameter_order + ("I(R)",) usable = True - extend_profile = True - from ._shared_methods import spline_initialize as initialize - from ._shared_methods import spline_radial_model as radial_model - -class Spline_FourierEllipse(FourierEllipse_Galaxy): +class SplineFourierEllipse(SplineMixin, RadialMixin, FourierEllipseGalaxy): """The light profile is defined as a cubic spline interpolation of the stored brightness values: @@ -163,19 +117,10 @@ class Spline_FourierEllipse(FourierEllipse_Galaxy): """ - model_type = f"spline {FourierEllipse_Galaxy.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = FourierEllipse_Galaxy._parameter_order + ("I(R)",) usable = True - extend_profile = True - - from ._shared_methods import spline_initialize as initialize - from ._shared_methods import spline_radial_model as radial_model -class Spline_Ray(Ray_Galaxy): +class SplineRay(iSplineMixin, RayGalaxy): """ray galaxy model with a spline light profile. The light profile is defined as a cubic spline interpolation of the stored brightness values: @@ -192,33 +137,10 @@ class Spline_Ray(Ray_Galaxy): """ - model_type = f"spline {Ray_Galaxy.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = Ray_Galaxy._parameter_order + ("I(R)",) usable = True - extend_profile = True - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - spline_segment_initialize( - self, - target=target, - parameters=parameters, - segments=self.rays, - symmetric=self.symmetric_rays, - ) - from ._shared_methods import spline_iradial_model as iradial_model - -class Spline_Wedge(Wedge_Galaxy): +class SplineWedge(iSplineMixin, WedgeGalaxy): """wedge galaxy model with a spline light profile. The light profile is defined as a cubic spline interpolation of the stored brightness values: @@ -235,85 +157,4 @@ class Spline_Wedge(Wedge_Galaxy): """ - model_type = f"spline {Wedge_Galaxy.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = Wedge_Galaxy._parameter_order + ("I(R)",) - usable = True - extend_profile = True - - @torch.no_grad() - @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) - - spline_segment_initialize( - self, - target=target, - parameters=parameters, - segments=self.wedges, - symmetric=self.symmetric_wedges, - ) - - from ._shared_methods import spline_iradial_model as iradial_model - - -# Third Order -###################################################################### -class Spline_SuperEllipse_Warp(SuperEllipse_Warp): - """The light profile is defined as a cubic spline interpolation of the - stored brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared - - """ - - model_type = f"spline {SuperEllipse_Warp.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = SuperEllipse_Warp._parameter_order + ("I(R)",) usable = True - extend_profile = True - - from ._shared_methods import spline_initialize as initialize - from ._shared_methods import spline_radial_model as radial_model - - -class Spline_FourierEllipse_Warp(FourierEllipse_Warp): - """The light profile is defined as a cubic spline interpolation of the - stored brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared - - """ - - model_type = f"spline {FourierEllipse_Warp.model_type}" - parameter_specs = { - "I(R)": {"units": "log10(flux/arcsec^2)"}, - } - _parameter_order = FourierEllipse_Warp._parameter_order + ("I(R)",) - usable = True - extend_profile = True - - from ._shared_methods import spline_initialize as initialize - from ._shared_methods import spline_radial_model as radial_model diff --git a/astrophot/models/superellipse_model.py b/astrophot/models/superellipse_model.py index 64e3b6d3..2b5ebf07 100644 --- a/astrophot/models/superellipse_model.py +++ b/astrophot/models/superellipse_model.py @@ -1,13 +1,16 @@ import torch -from .galaxy_model_object import Galaxy_Model -from .warp_model import Warp_Galaxy -from ..utils.decorators import default_internal +from .galaxy_model_object import GalaxyModel -__all__ = ["SuperEllipse_Galaxy", "SuperEllipse_Warp"] +# from .warp_model import Warp_Galaxy +__all__ = [ + "SuperEllipseGalaxy", + # "SuperEllipse_Warp" +] -class SuperEllipse_Galaxy(Galaxy_Model): + +class SuperEllipseGalaxy(GalaxyModel): """Expanded galaxy model which includes a superellipse transformation in its radius metric. This allows for the expression of "boxy" and "disky" isophotes instead of pure ellipses. This is a common @@ -23,58 +26,52 @@ class SuperEllipse_Galaxy(Galaxy_Model): > 2 transforms an ellipse to be more boxy. Parameters: - C0: superellipse distance metric parameter where C0 = C-2 so that a value of zero is now a standard ellipse. + C: superellipse distance metric parameter. """ - model_type = f"superellipse {Galaxy_Model.model_type}" - parameter_specs = { - "C0": {"units": "C-2", "value": 0.0, "uncertainty": 1e-2, "limits": (-2, None)}, + _model_type = "superellipse" + _parameter_specs = { + "C": {"units": "none", "value": 2.0, "uncertainty": 1e-2, "valid": (0, None)}, } - _parameter_order = Galaxy_Model._parameter_order + ("C0",) usable = False - @default_internal - def radius_metric(self, X, Y, image=None, parameters=None): - return torch.pow( - torch.pow(torch.abs(X), parameters["C0"].value + 2.0) - + torch.pow(torch.abs(Y), parameters["C0"].value + 2.0), - 1.0 / (parameters["C0"].value + 2.0), - ) + def radius_metric(self, x, y, C): + return torch.pow(x.abs().pow(C) + y.abs().pow(C), 1.0 / C) -class SuperEllipse_Warp(Warp_Galaxy): - """Expanded warp model which includes a superellipse transformation - in its radius metric. This allows for the expression of "boxy" and - "disky" isophotes instead of pure ellipses. This is a common - extension of the standard elliptical representation, especially - for early-type galaxies. The functional form for this is: +# class SuperEllipse_Warp(Warp_Galaxy): +# """Expanded warp model which includes a superellipse transformation +# in its radius metric. This allows for the expression of "boxy" and +# "disky" isophotes instead of pure ellipses. This is a common +# extension of the standard elliptical representation, especially +# for early-type galaxies. The functional form for this is: - R = (|X|^C + |Y|^C)^(1/C) +# R = (|X|^C + |Y|^C)^(1/C) - where R is the new distance metric, X Y are the coordinates, and C - is the coefficient for the superellipse. C can take on any value - greater than zero where C = 2 is the standard distance metric, 0 < - C < 2 creates disky or pointed perturbations to an ellipse, and C - > 2 transforms an ellipse to be more boxy. +# where R is the new distance metric, X Y are the coordinates, and C +# is the coefficient for the superellipse. C can take on any value +# greater than zero where C = 2 is the standard distance metric, 0 < +# C < 2 creates disky or pointed perturbations to an ellipse, and C +# > 2 transforms an ellipse to be more boxy. - Parameters: - C0: superellipse distance metric parameter where C0 = C-2 so that a value of zero is now a standard ellipse. +# Parameters: +# C0: superellipse distance metric parameter where C0 = C-2 so that a value of zero is now a standard ellipse. - """ +# """ - model_type = f"superellipse {Warp_Galaxy.model_type}" - parameter_specs = { - "C0": {"units": "C-2", "value": 0.0, "uncertainty": 1e-2, "limits": (-2, None)}, - } - _parameter_order = Warp_Galaxy._parameter_order + ("C0",) - usable = False +# model_type = f"superellipse {Warp_Galaxy.model_type}" +# parameter_specs = { +# "C0": {"units": "C-2", "value": 0.0, "uncertainty": 1e-2, "limits": (-2, None)}, +# } +# _parameter_order = Warp_Galaxy._parameter_order + ("C0",) +# usable = False - @default_internal - def radius_metric(self, X, Y, image=None, parameters=None): - return torch.pow( - torch.pow(torch.abs(X), parameters["C0"].value + 2.0) - + torch.pow(torch.abs(Y), parameters["C0"].value + 2.0), - 1.0 / (parameters["C0"].value + 2.0), - ) # epsilon added for numerical stability of gradient +# @default_internal +# def radius_metric(self, X, Y, image=None, parameters=None): +# return torch.pow( +# torch.pow(torch.abs(X), parameters["C0"].value + 2.0) +# + torch.pow(torch.abs(Y), parameters["C0"].value + 2.0), +# 1.0 / (parameters["C0"].value + 2.0), +# ) # epsilon added for numerical stability of gradient diff --git a/astrophot/models/warp_model.py b/astrophot/models/warp_model.py index 664dc561..43c9d145 100644 --- a/astrophot/models/warp_model.py +++ b/astrophot/models/warp_model.py @@ -1,17 +1,16 @@ import numpy as np import torch -from .galaxy_model_object import Galaxy_Model -from ..utils.interpolate import cubic_spline_torch -from ..utils.conversions.coordinates import Rotate_Cartesian -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ..param import Param_Unlock, Param_SoftLimits -from ._shared_methods import select_target +from .galaxy_model_object import GalaxyModel +from ..utils.interpolate import default_prof +from ..utils.decorators import ignore_numpy_warnings +from . import func +from ..param import forward -__all__ = ["Warp_Galaxy"] +__all__ = ["WarpGalaxy"] -class Warp_Galaxy(Galaxy_Model): +class WarpGalaxy(GalaxyModel): """Galaxy model which includes radially varrying PA and q profiles. This works by warping the coordinates using the same transform for a global PA/q except applied to each pixel @@ -37,84 +36,39 @@ class Warp_Galaxy(Galaxy_Model): """ - model_type = f"warp {Galaxy_Model.model_type}" - parameter_specs = { - "q(R)": {"units": "b/a", "limits": (0.05, 1), "uncertainty": 0.04}, - "PA(R)": { - "units": "rad", - "limits": (0, np.pi), + _model_type = "warp" + _parameter_specs = { + "q_R": {"units": "b/a", "valid": (0.0, 1), "uncertainty": 0.04}, + "PA_R": { + "units": "radians", + "valid": (0, np.pi), "cyclic": True, "uncertainty": 0.08, }, } - _parameter_order = Galaxy_Model._parameter_order + ("q(R)", "PA(R)") usable = False @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) + def initialize(self): + super().initialize() - # create the PA(R) and q(R) profile radii if needed - for prof_param in ["PA(R)", "q(R)"]: - if parameters[prof_param].prof is None: - if parameters[prof_param].value is None: # from scratch - new_prof = [0, 2 * target.pixel_length] - while new_prof[-1] < torch.min(self.window.shape / 2): - new_prof.append( - new_prof[-1] + torch.max(2 * target.pixel_length, new_prof[-1] * 0.2) - ) - new_prof.pop() - new_prof.pop() - new_prof.append(torch.sqrt(torch.sum((self.window.shape / 2) ** 2))) - parameters[prof_param].prof = new_prof - else: # matching length of a provided profile - # create logarithmically spaced profile radii - new_prof = [0] + list( - np.logspace( - np.log10(2 * target.pixel_length), - np.log10(torch.max(self.window.shape / 2).item()), - len(parameters[prof_param].value) - 1, - ) - ) - # ensure no step is smaller than a pixelscale - for i in range(1, len(new_prof)): - if new_prof[i] - new_prof[i - 1] < target.pixel_length.item(): - new_prof[i] = new_prof[i - 1] + target.pixel_length.item() - parameters[prof_param].prof = new_prof + if self.PA_R.value is None: + if self.PA_R.prof is None: + self.PA_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 + self.PA_R.uncertainty = (10 * np.pi / 180) * torch.ones_like(self.PA_R.value) + if self.q_R.value is None: + if self.q_R.prof is None: + self.q_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 + self.q_R.uncertainty = self.default_uncertainty * self.q_R.value - if not (parameters["PA(R)"].value is None or parameters["q(R)"].value is None): - return - - with Param_Unlock(parameters["PA(R)"]), Param_SoftLimits(parameters["PA(R)"]): - if parameters["PA(R)"].value is None: - parameters["PA(R)"].value = np.zeros(len(parameters["PA(R)"].prof)) + target.north - if parameters["PA(R)"].uncertainty is None: - parameters["PA(R)"].uncertainty = (5 * np.pi / 180) * torch.ones_like( - parameters["PA(R)"].value - ) - if parameters["q(R)"].value is None: - # If no initial value is provided for q(R) a heuristic initial value is assumed. - # The most neutral initial position would be 1, but the boundaries of q are (0,1) non-inclusive - # so that is not allowed. A value like 0.999 may get stuck since it is near the very edge of - # the (0,1) range. So 0.9 is chosen to be mostly passive, but still some signal for the optimizer. - parameters["q(R)"].value = np.ones(len(parameters["q(R)"].prof)) * 0.9 - if parameters["q(R)"].uncertainty is None: - parameters["q(R)"].uncertainty = self.default_uncertainty * parameters["q(R)"].value - - @default_internal - def transform_coordinates(self, X, Y, image=None, parameters=None): - X, Y = super().transform_coordinates(X, Y, image, parameters) - R = self.radius_metric(X, Y, image, parameters) - PA = cubic_spline_torch( - parameters["PA(R)"].prof, - -(parameters["PA(R)"].value - image.north), - R.view(-1), - ).view(*R.shape) - q = cubic_spline_torch(parameters["q(R)"].prof, parameters["q(R)"].value, R.view(-1)).view( - *R.shape - ) - X, Y = Rotate_Cartesian(PA, X, Y) - return X, Y / q + @forward + def transform_coordinates(self, x, y, q_R, PA_R): + x, y = super().transform_coordinates(x, y) + R = self.radius_metric(x, y) + PA = func.spline(R, self.PA_R.prof, PA_R) + q = func.spline(R, self.q_R.prof, q_R) + x, y = func.rotate(PA, x, y) + return x, y / q diff --git a/astrophot/models/wedge_model.py b/astrophot/models/wedge_model.py index 31ee5b74..3cbbe5b7 100644 --- a/astrophot/models/wedge_model.py +++ b/astrophot/models/wedge_model.py @@ -1,13 +1,12 @@ import numpy as np import torch -from .galaxy_model_object import Galaxy_Model -from ..utils.decorators import default_internal +from .galaxy_model_object import GalaxyModel -__all__ = ["Wedge_Galaxy"] +__all__ = ["WedgeGalaxy"] -class Wedge_Galaxy(Galaxy_Model): +class WedgeGalaxy(GalaxyModel): """Variant of the ray model where no smooth transition is performed between regions as a function of theta, instead there is a sharp trnasition boundary. This may be desirable as it cleanly @@ -23,62 +22,49 @@ class Wedge_Galaxy(Galaxy_Model): """ - model_type = f"wedge {Galaxy_Model.model_type}" - special_kwargs = Galaxy_Model.special_kwargs + ["wedges"] - wedges = 2 - track_attrs = Galaxy_Model.track_attrs + ["wedges"] + _model_type = "segments" usable = False + _options = ("segmentss", "symmetric_wedges") - def __init__(self, *args, **kwargs): - self.symmetric_wedges = True + def __init__(self, *args, symmetric_wedges=True, segments=2, **kwargs): super().__init__(*args, **kwargs) - self.wedges = kwargs.get("wedges", 2) + self.symmetric_wedges = symmetric_wedges + self.segments = segments - @default_internal - def polar_model(self, R, T, image=None, parameters=None): + def polar_model(self, R, T): model = torch.zeros_like(R) - if self.wedges % 2 == 0 and self.symmetric_wedges: - for w in range(self.wedges): - angles = (T - (w * np.pi / self.wedges)) % np.pi + if self.segments % 2 == 0 and self.symmetric_wedges: + for w in range(self.segments): + angles = (T - (w * np.pi / self.segments)) % np.pi indices = torch.logical_or( - angles < (np.pi / (2 * self.wedges)), - angles >= (np.pi * (1 - 1 / (2 * self.wedges))), + angles < (np.pi / (2 * self.segments)), + angles >= (np.pi * (1 - 1 / (2 * self.segments))), ) - model[indices] += self.iradial_model(w, R[indices], image, parameters) - elif self.wedges % 2 == 1 and self.symmetric_wedges: - for w in range(self.wedges): - angles = (T - (w * np.pi / self.wedges)) % (2 * np.pi) + model[indices] += self.iradial_model(w, R[indices]) + elif self.segments % 2 == 1 and self.symmetric_wedges: + for w in range(self.segments): + angles = (T - (w * np.pi / self.segments)) % (2 * np.pi) indices = torch.logical_or( - angles < (np.pi / (2 * self.wedges)), - angles >= (np.pi * (2 - 1 / (2 * self.wedges))), + angles < (np.pi / (2 * self.segments)), + angles >= (np.pi * (2 - 1 / (2 * self.segments))), ) - model[indices] += self.iradial_model(w, R[indices], image, parameters) - angles = (T - (np.pi + w * np.pi / self.wedges)) % (2 * np.pi) + model[indices] += self.iradial_model(w, R[indices]) + angles = (T - (np.pi + w * np.pi / self.segments)) % (2 * np.pi) indices = torch.logical_or( - angles < (np.pi / (2 * self.wedges)), - angles >= (np.pi * (2 - 1 / (2 * self.wedges))), + angles < (np.pi / (2 * self.segments)), + angles >= (np.pi * (2 - 1 / (2 * self.segments))), ) - model[indices] += self.iradial_model(w, R[indices], image, parameters) + model[indices] += self.iradial_model(w, R[indices]) else: - for w in range(self.wedges): - angles = (T - (w * 2 * np.pi / self.wedges)) % (2 * np.pi) + for w in range(self.segments): + angles = (T - (w * 2 * np.pi / self.segments)) % (2 * np.pi) indices = torch.logical_or( - angles < (np.pi / self.wedges), - angles >= (np.pi * (2 - 1 / self.wedges)), + angles < (np.pi / self.segments), + angles >= (np.pi * (2 - 1 / self.segments)), ) - model[indices] += self.iradial_model(w, R[indices], image, parameters) + model[indices] += self.iradial_model(w, R[indices]) return model - @default_internal - def evaluate_model(self, X=None, Y=None, image=None, parameters=None, **kwargs): - if X is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] - XX, YY = self.transform_coordinates(X, Y, image, parameters) - - return self.polar_model( - self.radius_metric(XX, YY, image=image, parameters=parameters), - self.angular_metric(XX, YY, image=image, parameters=parameters), - image=image, - parameters=parameters, - ) + def brightness(self, x, y): + x, y = self.transform_coordinates(x, y) + return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) diff --git a/astrophot/models/zernike_model.py b/astrophot/models/zernike_model.py index 73b2ebb8..97a4d161 100644 --- a/astrophot/models/zernike_model.py +++ b/astrophot/models/zernike_model.py @@ -3,27 +3,22 @@ import torch from scipy.special import binom -from ..utils.decorators import ignore_numpy_warnings, default_internal -from ._shared_methods import select_target -from .psf_model_object import PSF_Model -from ..param import Param_Unlock, Param_SoftLimits +from ..utils.decorators import ignore_numpy_warnings +from .psf_model_object import PSFModel from ..errors import SpecificationConflict +from ..param import forward -__all__ = ("Zernike_PSF",) +__all__ = ("ZernikePSF",) -class Zernike_PSF(PSF_Model): +class ZernikePSF(PSFModel): - model_type = f"zernike {PSF_Model.model_type}" - parameter_specs = { - "Anm": {"units": "flux/arcsec^2"}, - } - _parameter_order = PSF_Model._parameter_order + ("Anm",) + _model_type = "zernike" + _parameter_specs = {"Anm": {"units": "flux/arcsec^2"}} usable = True - model_integrated = False - def __init__(self, *, name=None, order_n=2, r_scale=None, **kwargs): - super().__init__(name=name, **kwargs) + def __init__(self, *args, order_n=2, r_scale=None, **kwargs): + super().__init__(*args, **kwargs) self.order_n = int(order_n) self.r_scale = r_scale @@ -31,10 +26,8 @@ def __init__(self, *, name=None, order_n=2, r_scale=None, **kwargs): @torch.no_grad() @ignore_numpy_warnings - @select_target - @default_internal - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) + def initialize(self): + super().initialize() # List the coefficients to use self.nm_list = self.iter_nm(self.order_n) @@ -43,25 +36,20 @@ def initialize(self, target=None, parameters=None, **kwargs): self.r_scale = torch.max(self.window.shape) / 2 # Check if user has already set the coefficients - if parameters["Anm"].value is not None: - if len(self.nm_list) != len(parameters["Anm"].value): + if self.Anm.value is not None: + if len(self.nm_list) != len(self.Anm.value): raise SpecificationConflict( - f"nm_list length ({len(self.nm_list)}) must match coefficients ({len(parameters['Anm'].value)})" + f"nm_list length ({len(self.nm_list)}) must match coefficients ({len(self.Anm.value)})" ) return # Set the default coefficients to zeros - with Param_Unlock(parameters["Anm"]), Param_SoftLimits(parameters["Anm"]): - parameters["Anm"].value = torch.zeros(len(self.nm_list)) - if parameters["Anm"].uncertainty is None: - parameters["Anm"].uncertainty = self.default_uncertainty * torch.ones_like( - parameters["Anm"].value - ) - # Set the zero order zernike polynomial to the average in the image - if self.nm_list[0] == (0, 0): - parameters["Anm"].value[0] = ( - torch.median(target[self.window].data) / target.pixel_area - ) + self.Anm.dynamic_value = torch.zeros(len(self.nm_list)) + self.Anm.uncertainty = self.default_uncertainty * torch.ones_like(self.Anm.value) + if self.nm_list[0] == (0, 0): + self.Anm.value[0] = ( + torch.median(self.target[self.window].data.value) / self.target.pixel_area + ) def iter_nm(self, n): nm = [] @@ -114,23 +102,20 @@ def Z_n_m(self, rho, phi, n, m, efficient=True): Z += c * R * T return Z - @default_internal - def evaluate_model(self, X=None, Y=None, image=None, parameters=None): - if X is None: - Coords = image.get_coordinate_meshgrid() - X, Y = Coords - parameters["center"].value[..., None, None] + @forward + def brightness(self, x, y, Anm): + x, y = self.transform_coordinates(x, y) - phi = self.angular_metric(X, Y, image, parameters) + phi = self.angular_metric(x, y) - r = self.radius_metric(X, Y, image, parameters) + r = self.radius_metric(x, y) r = r / self.r_scale - G = torch.zeros_like(X) + G = torch.zeros_like(x) i = 0 - A = image.pixel_area * parameters["Anm"].value for n, m in self.nm_list: - G += A[i] * self.Z_n_m(r, phi, n, m) + G += Anm[i] * self.Z_n_m(r, phi, n, m) i += 1 G[r > 1] = 0.0 diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index bbfe8335..ce102c43 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -7,6 +7,13 @@ from .operations import fft_convolve_torch +def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): + prof = [0, min_pixels * pixelscale] + while prof[-1] < (np.max(shape) * pixelscale / 2): + prof.append(prof[-1] + max(min_pixels * pixelscale, prof[-1] * scale)) + return prof + + def _h_poly(t): """Helper function to compute the 'h' polynomial matrix used in the cubic spline. From d5c71c4959d879f6a17b25a43c3b9e9f09ede47b Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 23 Jun 2025 14:58:06 -0400 Subject: [PATCH 029/191] refine mixins for wedge ray warg etc --- astrophot/models/__init__.py | 55 ++-- astrophot/models/_shared_methods.py | 234 ++++------------ astrophot/models/{airy_psf.py => airy.py} | 0 .../models/{edgeon_model.py => edgeon.py} | 0 astrophot/models/{eigen_psf.py => eigen.py} | 0 astrophot/models/exponential.py | 67 +++++ astrophot/models/exponential_model.py | 159 ----------- .../models/{flatsky_model.py => flatsky.py} | 0 astrophot/models/foureirellipse_model.py | 224 --------------- astrophot/models/gaussian.py | 66 +++++ astrophot/models/gaussian_model.py | 153 ---------- astrophot/models/mixins/__init__.py | 23 +- astrophot/models/mixins/brightness.py | 265 ++++++++++++++++++ astrophot/models/mixins/exponential.py | 11 +- astrophot/models/mixins/spline.py | 51 ++-- .../models/{moffat_model.py => moffat.py} | 40 ++- ...n_model.py => multi_gaussian_expansion.py} | 0 astrophot/models/nuker.py | 70 +++++ astrophot/models/nuker_model.py | 187 ------------ ...ixelated_psf_model.py => pixelated_psf.py} | 0 .../models/{planesky_model.py => planesky.py} | 0 astrophot/models/ray_model.py | 89 ------ astrophot/models/sersic.py | 96 +++++++ astrophot/models/sersic_model.py | 186 ------------ astrophot/models/spline.py | 68 +++++ astrophot/models/spline_model.py | 160 ----------- astrophot/models/superellipse_model.py | 77 ----- astrophot/models/warp_model.py | 74 ----- astrophot/models/wedge_model.py | 70 ----- .../models/{zernike_model.py => zernike.py} | 0 30 files changed, 783 insertions(+), 1642 deletions(-) rename astrophot/models/{airy_psf.py => airy.py} (100%) rename astrophot/models/{edgeon_model.py => edgeon.py} (100%) rename astrophot/models/{eigen_psf.py => eigen.py} (100%) create mode 100644 astrophot/models/exponential.py delete mode 100644 astrophot/models/exponential_model.py rename astrophot/models/{flatsky_model.py => flatsky.py} (100%) delete mode 100644 astrophot/models/foureirellipse_model.py create mode 100644 astrophot/models/gaussian.py delete mode 100644 astrophot/models/gaussian_model.py rename astrophot/models/{moffat_model.py => moffat.py} (76%) rename astrophot/models/{multi_gaussian_expansion_model.py => multi_gaussian_expansion.py} (100%) create mode 100644 astrophot/models/nuker.py delete mode 100644 astrophot/models/nuker_model.py rename astrophot/models/{pixelated_psf_model.py => pixelated_psf.py} (100%) rename astrophot/models/{planesky_model.py => planesky.py} (100%) delete mode 100644 astrophot/models/ray_model.py create mode 100644 astrophot/models/sersic.py delete mode 100644 astrophot/models/sersic_model.py create mode 100644 astrophot/models/spline.py delete mode 100644 astrophot/models/spline_model.py delete mode 100644 astrophot/models/superellipse_model.py delete mode 100644 astrophot/models/warp_model.py delete mode 100644 astrophot/models/wedge_model.py rename astrophot/models/{zernike_model.py => zernike.py} (100%) diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 46502655..8f2ddf85 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -12,29 +12,22 @@ from .sky_model_object import SkyModel from .point_source import PointSource -# Subtypes of GalaxyModel -from .foureirellipse_model import FourierEllipseGalaxy -from .ray_model import RayGalaxy -from .superellipse_model import SuperEllipseGalaxy -from .wedge_model import WedgeGalaxy -from .warp_model import WarpGalaxy - # subtypes of PSFModel -from .eigen_psf import EigenPSF -from .airy_psf import AiryPSF -from .zernike_model import ZernikePSF -from .pixelated_psf_model import PixelatedPSF +from .eigen import EigenPSF +from .airy import AiryPSF +from .zernike import ZernikePSF +from .pixelated_psf import PixelatedPSF # Subtypes of SkyModel -from .flatsky_model import FlatSky -from .planesky_model import PlaneSky +from .flatsky import FlatSky +from .planesky import PlaneSky # Special galaxy types -from .edgeon_model import EdgeonModel, EdgeonSech, EdgeonIsothermal -from .multi_gaussian_expansion_model import MultiGaussianExpansion +from .edgeon import EdgeonModel, EdgeonSech, EdgeonIsothermal +from .multi_gaussian_expansion import MultiGaussianExpansion # Standard models based on a core radial profile -from .sersic_model import ( +from .sersic import ( SersicGalaxy, SersicPSF, SersicFourierEllipse, @@ -43,7 +36,7 @@ SersicRay, SersicWedge, ) -from .exponential_model import ( +from .exponential import ( ExponentialGalaxy, ExponentialPSF, ExponentialSuperEllipse, @@ -52,7 +45,7 @@ ExponentialRay, ExponentialWedge, ) -from .gaussian_model import ( +from .gaussian import ( GaussianGalaxy, GaussianPSF, GaussianSuperEllipse, @@ -61,17 +54,17 @@ GaussianRay, GaussianWedge, ) -from .moffat_model import ( +from .moffat import ( MoffatGalaxy, MoffatPSF, Moffat2DPSF, - MoffatFourierEllipseGalaxy, - MoffatRayGalaxy, - MoffatWedgeGalaxy, - MoffatWarpGalaxy, - MoffatSuperEllipseGalaxy, + MoffatFourierEllipse, + MoffatRay, + MoffatWedge, + MoffatWarp, + MoffatSuperEllipse, ) -from .nuker_model import ( +from .nuker import ( NukerGalaxy, NukerPSF, NukerFourierEllipse, @@ -80,7 +73,7 @@ NukerRay, NukerWedge, ) -from .spline_model import ( +from .spline import ( SplineGalaxy, SplinePSF, SplineFourierEllipse, @@ -139,11 +132,11 @@ "MoffatGalaxy", "MoffatPSF", "Moffat2DPSF", - "MoffatFourierEllipseGalaxy", - "MoffatRayGalaxy", - "MoffatWedgeGalaxy", - "MoffatWarpGalaxy", - "MoffatSuperEllipseGalaxy", + "MoffatFourierEllipse", + "MoffatRay", + "MoffatWedge", + "MoffatWarp", + "MoffatSuperEllipse", "NukerGalaxy", "NukerPSF", "NukerFourierEllipse", diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 4249755c..ff9bdd9c 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -3,13 +3,18 @@ import torch from scipy.optimize import minimize -from ..utils.initialize import isophotes from ..utils.decorators import ignore_numpy_warnings -from . import func from .. import AP_config -def _sample_image(image, transform, radius, rad_bins=None): +def _sample_image( + image, + transform, + radius, + angle=None, + rad_bins=None, + angle_range=None, +): dat = image.data.npvalue.copy() # Fill masked pixels if image.has_mask: @@ -22,13 +27,18 @@ def _sample_image(image, transform, radius, rad_bins=None): x, y = transform(*image.coordinate_center_meshgrid(), params=()) R = radius(x, y).detach().cpu().numpy().flatten() + if angle_range is not None: + T = angle(x, y).detach().cpu().numpy().flatten() + CHOOSE = ((T % (2 * np.pi)) > angle_range[0]) & ((T % (2 * np.pi)) < angle_range[1]) + R = R[CHOOSE] + dat = dat.flatten()[CHOOSE] + raveldat = dat.ravel() # Bin fluxes by radius if rad_bins is None: rad_bins = np.logspace(np.log10(R.min() * 0.9), np.log10(R.max() * 1.1), 11) else: rad_bins = np.array(rad_bins) - raveldat = dat.ravel() I = ( binned_statistic(R, raveldat, statistic="median", bins=rad_bins)[0] ) / image.pixel_area.item() @@ -112,187 +122,51 @@ def parametric_segment_initialize( params=None, x0_func=None, segments=None, - force_uncertainty=None, ): if all(list(model[param].value is not None for param in params)): return - # Get the sub-image area corresponding to the model image - target_area = target[model.window] - target_dat = target_area.data.detach().cpu().numpy() - if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() - target_dat[mask] = np.median(target_dat[np.logical_not(mask)]) - edge = np.concatenate( - ( - target_dat[:, 0], - target_dat[:, -1], - target_dat[0, :], - target_dat[-1, :], - ) - ) - edge_average = np.median(edge) - edge_scatter = iqr(edge, rng=(16, 84)) / 2 - # Convert center coordinates to target area array indices - icenter = target_area.plane_to_pixel(model["center"].value) - - iso_info = isophotes( - target_dat - edge_average, - (icenter[1].item(), icenter[0].item()), - threshold=3 * edge_scatter, - pa=(model["PA"].value - target.north).item() if "PA" in model else 0.0, - q=model["q"].value.item() if "q" in model else 1.0, - n_isophotes=15, - more=True, - ) - R = np.array(list(iso["R"] for iso in iso_info)) * target.pixel_length.item() - was_none = list(False for i in range(len(params))) - val = {} - unc = {} - for i, p in enumerate(params): - if model[p].value is None: - was_none[i] = True - val[p] = np.zeros(segments) - unc[p] = np.zeros(segments) - for r in range(segments): - flux = [] - for iso in iso_info: - modangles = ( - iso["angles"] - - ((model["PA"].value - target.north).detach().cpu().item() + r * np.pi / segments) - ) % np.pi - flux.append( - np.median( - iso["isovals"][ - np.logical_or( - modangles < (0.5 * np.pi / segments), - modangles >= (np.pi * (1 - 0.5 / segments)), - ) - ] - ) - ) - flux = np.array(flux) / target.pixel_area.item() - if np.sum(flux < 0) >= 1: - flux -= np.min(flux) - np.abs(np.min(flux) * 0.1) - flux = np.log10(flux) - x0 = list(x0_func(model, R, flux)) - for i, param in enumerate(params): - x0[i] = x0[i] if was_none[i] else model[param].value.detach().cpu().numpy()[r] - res = minimize( - lambda x: np.mean((flux - np.log10(prof_func(R, *x))) ** 2), - x0=x0, - method="Nelder-Mead", + cycle = np.pi if model.symmetric else 2 * np.pi + w = cycle / segments + v = w * np.arange(segments) + values = [] + uncertainties = [] + for s in range(segments): + angle_range = (v[s] - w / 2, v[s] + w / 2) + # Get the sub-image area corresponding to the model image + R, I, S = _sample_image( + target, + model.transform_coordinates, + model.radial_metric, + angle=model.angular_metric, + angle_range=angle_range, ) - if force_uncertainty is None: - reses = [] - for i in range(10): - N = np.random.randint(0, len(R), len(R)) - reses.append( - minimize( - lambda x: np.mean((flux - np.log10(prof_func(R, *x))) ** 2), - x0=x0, - method="Nelder-Mead", - ) - ) - for i, param in enumerate(params): - if was_none[i]: - val[param][r] = res.x[i] if res.success else x0[i] - if force_uncertainty is None and model[param].uncertainty is None: - unc[r] = np.std(list(subres.x[params.index(param)] for subres in reses)) - elif force_uncertainty is not None: - unc[r] = force_uncertainty[params.index(param)][r] - - with Param_Unlock(model[param]), Param_SoftLimits(model[param]): - model[param].value = val[param] - model[param].uncertainty = unc[param] + x0 = list(x0_func(model, R, I)) -# Spline -###################################################################### -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def spline_segment_initialize( -# self, target=None, parameters=None, segments=1, symmetric=True, **kwargs -# ): -# super(self.__class__, self).initialize(target=target, parameters=parameters) - -# if parameters["I(R)"].value is not None and parameters["I(R)"].prof is not None: -# return + def optim(x, r, f, u): + residual = ((f - np.log10(prof_func(r, *x))) / u) ** 2 + N = np.argsort(residual) + return np.mean(residual[N][:-2]) -# # Create the I(R) profile radii if needed -# if parameters["I(R)"].prof is None: -# new_prof = [0, 2 * target.pixel_length] -# while new_prof[-1] < torch.max(self.window.shape / 2): -# new_prof.append(new_prof[-1] + torch.max(2 * target.pixel_length, new_prof[-1] * 0.2)) -# new_prof.pop() -# new_prof.pop() -# new_prof.append(torch.sqrt(torch.sum((self.window.shape / 2) ** 2))) -# parameters["I(R)"].prof = new_prof - -# profR = parameters["I(R)"].prof.detach().cpu().numpy() -# target_area = target[self.window] -# target_dat = target_area.data.detach().cpu().numpy() -# if target_area.has_mask: -# mask = target_area.mask.detach().cpu().numpy() -# target_dat[mask] = np.median(target_dat[np.logical_not(mask)]) -# Coords = target_area.get_coordinate_meshgrid() -# X, Y = Coords - parameters["center"].value[..., None, None] -# X, Y = self.transform_coordinates(X, Y, target, parameters) -# R = self.radius_metric(X, Y, target, parameters).detach().cpu().numpy() -# T = self.angular_metric(X, Y, target, parameters).detach().cpu().numpy() -# rad_bins = [profR[0]] + list((profR[:-1] + profR[1:]) / 2) + [profR[-1] * 100] -# raveldat = target_dat.ravel() -# val = np.zeros((segments, len(parameters["I(R)"].prof))) -# unc = np.zeros((segments, len(parameters["I(R)"].prof))) -# for s in range(segments): -# if segments % 2 == 0 and symmetric: -# angles = (T - (s * np.pi / segments)) % np.pi -# TCHOOSE = np.logical_or( -# angles < (np.pi / segments), angles >= (np.pi * (1 - 1 / segments)) -# ) -# elif segments % 2 == 1 and symmetric: -# angles = (T - (s * np.pi / segments)) % (2 * np.pi) -# TCHOOSE = np.logical_or( -# angles < (np.pi / segments), angles >= (np.pi * (2 - 1 / segments)) -# ) -# angles = (T - (np.pi + s * np.pi / segments)) % (2 * np.pi) -# TCHOOSE = np.logical_or( -# TCHOOSE, -# np.logical_or(angles < (np.pi / segments), angles >= (np.pi * (2 - 1 / segments))), -# ) -# elif segments % 2 == 0 and not symmetric: -# angles = (T - (s * 2 * np.pi / segments)) % (2 * np.pi) -# TCHOOSE = torch.logical_or( -# angles < (2 * np.pi / segments), -# angles >= (2 * np.pi * (1 - 1 / segments)), -# ) -# else: -# angles = (T - (s * 2 * np.pi / segments)) % (2 * np.pi) -# TCHOOSE = torch.logical_or( -# angles < (2 * np.pi / segments), angles >= (np.pi * (2 - 1 / segments)) -# ) -# TCHOOSE = TCHOOSE.ravel() -# I = ( -# binned_statistic( -# R.ravel()[TCHOOSE], raveldat[TCHOOSE], statistic="median", bins=rad_bins -# )[0] -# ) / target.pixel_area.item() -# N = np.isfinite(I) -# if not np.all(N): -# I[np.logical_not(N)] = np.interp(profR[np.logical_not(N)], profR[N], I[N]) -# S = binned_statistic( -# R.ravel(), -# raveldat, -# statistic=lambda d: iqr(d, rng=[16, 84]) / 2, -# bins=rad_bins, -# )[0] -# N = np.isfinite(S) -# if not np.all(N): -# S[np.logical_not(N)] = np.interp(profR[np.logical_not(N)], profR[N], S[N]) -# val[s] = np.log10(np.abs(I)) -# unc[s] = S / (np.abs(I) * np.log(10)) -# with Param_Unlock(parameters["I(R)"]), Param_SoftLimits(parameters["I(R)"]): -# parameters["I(R)"].value = val -# parameters["I(R)"].uncertainty = unc + res = minimize(optim, x0=x0, args=(R, I, S), method="Nelder-Mead") + if not res.success: + if AP_config.ap_verbose >= 2: + AP_config.ap_logger.warning( + f"initialization fit not successful for {model.name}, falling back to defaults" + ) + else: + x0 = res.x + + reses = [] + for i in range(10): + N = np.random.randint(0, len(R), len(R)) + reses.append(minimize(optim, x0=x0, args=(R[N], I[N], S[N]), method="Nelder-Mead")) + values.append(x0) + uncertainties.append(np.std(np.stack(reses), axis=0)) + values = np.stack(values).T + uncertainties = np.stack(uncertainties).T + for param, v, u in zip(params, values, uncertainties): + if model[param].value is None: + model[param].dynamic_value = v + model[param].uncertainty = u diff --git a/astrophot/models/airy_psf.py b/astrophot/models/airy.py similarity index 100% rename from astrophot/models/airy_psf.py rename to astrophot/models/airy.py diff --git a/astrophot/models/edgeon_model.py b/astrophot/models/edgeon.py similarity index 100% rename from astrophot/models/edgeon_model.py rename to astrophot/models/edgeon.py diff --git a/astrophot/models/eigen_psf.py b/astrophot/models/eigen.py similarity index 100% rename from astrophot/models/eigen_psf.py rename to astrophot/models/eigen.py diff --git a/astrophot/models/exponential.py b/astrophot/models/exponential.py new file mode 100644 index 00000000..b291c272 --- /dev/null +++ b/astrophot/models/exponential.py @@ -0,0 +1,67 @@ +from .galaxy_model_object import GalaxyModel + +from .psf_model_object import PSFModel +from .mixins import ( + ExponentialMixin, + iExponentialMixin, + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, +) + +__all__ = [ + "ExponentialGalaxy", + "ExponentialPSF", + "ExponentialSuperEllipse", + "ExponentialFourierEllipse", + "ExponentialWarp", + "ExponentialRay", + "ExponentialWedge", +] + + +class ExponentialGalaxy(ExponentialMixin, RadialMixin, GalaxyModel): + """basic galaxy model with a exponential profile for the radial light + profile. The light profile is defined as: + + I(R) = Ie * exp(-b1(R/Re - 1)) + + where I(R) is the brightness as a function of semi-major axis, Ie + is the brightness at the half light radius, b1 is a constant not + involved in the fit, R is the semi-major axis, and Re is the + effective radius. + + Parameters: + Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness + Re: half light radius, represented in arcsec. This parameter cannot go below zero. + + """ + + usable = True + + +class ExponentialPSF(ExponentialMixin, RadialMixin, PSFModel): + usable = True + + +class ExponentialSuperEllipse(ExponentialMixin, SuperEllipseMixin, GalaxyModel): + usable = True + + +class ExponentialFourierEllipse(ExponentialMixin, FourierEllipseMixin, GalaxyModel): + usable = True + + +class ExponentialWarp(ExponentialMixin, WarpMixin, GalaxyModel): + usable = True + + +class ExponentialRay(iExponentialMixin, RayMixin, GalaxyModel): + usable = True + + +class ExponentialWedge(iExponentialMixin, WedgeMixin, GalaxyModel): + usable = True diff --git a/astrophot/models/exponential_model.py b/astrophot/models/exponential_model.py deleted file mode 100644 index eb869098..00000000 --- a/astrophot/models/exponential_model.py +++ /dev/null @@ -1,159 +0,0 @@ -from .galaxy_model_object import GalaxyModel - -from .warp_model import WarpGalaxy -from .ray_model import RayGalaxy -from .psf_model_object import PSFModel -from .superellipse_model import SuperEllipseGalaxy # , SuperEllipse_Warp -from .foureirellipse_model import FourierEllipseGalaxy # , FourierEllipse_Warp -from .wedge_model import WedgeGalaxy -from .mixins import ExponentialMixin, iExponentialMixin, RadialMixin - -__all__ = [ - "ExponentialGalaxy", - "ExponentialPSF", - "ExponentialSuperEllipse", - "ExponentialFourierEllipse", - "ExponentialWarp", - "ExponentialRay", - "ExponentialWedge", -] - - -class ExponentialGalaxy(ExponentialMixin, RadialMixin, GalaxyModel): - """basic galaxy model with a exponential profile for the radial light - profile. The light profile is defined as: - - I(R) = Ie * exp(-b1(R/Re - 1)) - - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. - - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. - - """ - - usable = True - - -class ExponentialPSF(ExponentialMixin, RadialMixin, PSFModel): - """basic point source model with a exponential profile for the radial light - profile. - - I(R) = Ie * exp(-b1(R/Re - 1)) - - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. - - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. - - """ - - usable = True - - -class ExponentialSuperEllipse(ExponentialMixin, RadialMixin, SuperEllipseGalaxy): - """super ellipse galaxy model with a exponential profile for the radial - light profile. - - I(R) = Ie * exp(-b1(R/Re - 1)) - - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. - - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. - - """ - - usable = True - - -class ExponentialFourierEllipse(ExponentialMixin, RadialMixin, FourierEllipseGalaxy): - """fourier mode perturbations to ellipse galaxy model with an - exponential profile for the radial light profile. - - I(R) = Ie * exp(-b1(R/Re - 1)) - - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. - - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. - - """ - - usable = True - - -class ExponentialWarp(ExponentialMixin, RadialMixin, WarpGalaxy): - """warped coordinate galaxy model with a exponential profile for the - radial light model. - - I(R) = Ie * exp(-b1(R/Re - 1)) - - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. - - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. - - """ - - usable = True - - -class ExponentialRay(iExponentialMixin, RayGalaxy): - """ray galaxy model with a sersic profile for the radial light - model. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius. - - Parameters: - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True - - -class ExponentialWedge(iExponentialMixin, WedgeGalaxy): - """wedge galaxy model with a exponential profile for the radial light - model. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius. - - Parameters: - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True diff --git a/astrophot/models/flatsky_model.py b/astrophot/models/flatsky.py similarity index 100% rename from astrophot/models/flatsky_model.py rename to astrophot/models/flatsky.py diff --git a/astrophot/models/foureirellipse_model.py b/astrophot/models/foureirellipse_model.py deleted file mode 100644 index 5bf49d7f..00000000 --- a/astrophot/models/foureirellipse_model.py +++ /dev/null @@ -1,224 +0,0 @@ -import torch -import numpy as np - -from ..utils.decorators import ignore_numpy_warnings -from .galaxy_model_object import GalaxyModel -from ..param import forward - -# from .warp_model import Warp_Galaxy -from .. import AP_config - -__all__ = [ - "FourierEllipseGalaxy", - # "FourierEllipse_Warp" -] - - -class FourierEllipseGalaxy(GalaxyModel): - """Expanded galaxy model which includes a Fourier transformation in - its radius metric. This allows for the expression of arbitrarily - complex isophotes instead of pure ellipses. This is a common - extension of the standard elliptical representation. The form of - the Fourier perturbations is: - - R' = R * exp(sum_m(a_m * cos(m * theta + phi_m))) - - where R' is the new radius value, R is the original ellipse - radius, a_m is the amplitude of the m'th Fourier mode, m is the - index of the Fourier mode, theta is the angle around the ellipse, - and phi_m is the phase of the m'th fourier mode. This - representation is somewhat different from other Fourier mode - implementations where instead of an expoenntial it is just 1 + - sum_m(...), we opt for this formulation as it is more numerically - stable. It cannot ever produce negative radii, but to first order - the two representation are the same as can be seen by a Taylor - expansion of exp(x) = 1 + x + O(x^2). - - One can create extremely complex shapes using different Fourier - modes, however usually it is only low order modes that are of - interest. For intuition, the first Fourier mode is roughly - equivalent to a lopsided galaxy, one side will be compressed and - the opposite side will be expanded. The second mode is almost - never used as it is nearly degenerate with ellipticity. The third - mode is an alternate kind of lopsidedness for a galaxy which makes - it somewhat triangular, meaning that it is wider on one side than - the other. The fourth mode is similar to a boxyness/diskyness - parameter which tends to make more pronounced peanut shapes since - it is more rounded than a superellipse representation. Modes - higher than 4 are only useful in very specialized situations. In - general one should consider carefully why the Fourier modes are - being used for the science case at hand. - - Parameters: - am: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. - phi_m: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) - - """ - - _model_type = "fourier" - _parameter_specs = { - "am": {"units": "none"}, - "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True}, - } - usable = False - _options = ("modes",) - - def __init__(self, *args, modes=(3, 4), **kwargs): - super().__init__(*args, **kwargs) - self.modes = torch.tensor(modes, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - @forward - def radius_metric(self, x, y, am, phim): - R = super().radius_metric(x, y) - theta = self.angular_metric(x, y) - return R * torch.exp( - torch.sum( - am.unsqueeze(-1) - * torch.cos(self.modes.unsqueeze(-1) * theta.flatten() + phim.unsqueeze(-1)), - 0, - ).reshape(x.shape) - ) - - @torch.no_grad() - @ignore_numpy_warnings - def initialize(self): - super().initialize() - - if self.am.value is None: - self.am.dynamic_value = torch.zeros( - len(self.modes), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - self.am.uncertainty = torch.tensor( - self.default_uncertainty * np.ones(len(self.modes)), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - if self.phim.value is None: - self.phim.value = torch.zeros( - len(self.modes), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - self.phim.uncertainty = torch.tensor( - (10 * np.pi / 180) * np.ones(len(self.modes)), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - - -# class FourierEllipse_Warp(Warp_Galaxy): -# """Expanded warp galaxy model which includes a Fourier transformation -# in its radius metric. This allows for the expression of -# arbitrarily complex isophotes instead of pure ellipses. This is a -# common extension of the standard elliptical representation. The -# form of the Fourier perturbations is: - -# R' = R * exp(sum_m(a_m * cos(m * theta + phi_m))) - -# where R' is the new radius value, R is the original ellipse -# radius, a_m is the amplitude of the m'th Fourier mode, m is the -# index of the Fourier mode, theta is the angle around the ellipse, -# and phi_m is the phase of the m'th fourier mode. This -# representation is somewhat different from other Fourier mode -# implementations where instead of an expoenntial it is just 1 + -# sum_m(...), we opt for this formulation as it is more numerically -# stable. It cannot ever produce negative radii, but to first order -# the two representation are the same as can be seen by a Taylor -# expansion of exp(x) = 1 + x + O(x^2). - -# One can create extremely complex shapes using different Fourier -# modes, however usually it is only low order modes that are of -# interest. For intuition, the first Fourier mode is roughly -# equivalent to a lopsided galaxy, one side will be compressed and -# the opposite side will be expanded. The second mode is almost -# never used as it is nearly degenerate with ellipticity. The third -# mode is an alternate kind of lopsidedness for a galaxy which makes -# it somewhat triangular, meaning that it is wider on one side than -# the other. The fourth mode is similar to a boxyness/diskyness -# parameter which tends to make more pronounced peanut shapes since -# it is more rounded than a superellipse representation. Modes -# higher than 4 are only useful in very specialized situations. In -# general one should consider carefully why the Fourier modes are -# being used for the science case at hand. - -# Parameters: -# am: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. -# phi_m: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) - -# """ - -# model_type = f"fourier {Warp_Galaxy.model_type}" -# parameter_specs = { -# "am": {"units": "none"}, -# "phim": {"units": "radians", "limits": (0, 2 * np.pi), "cyclic": True}, -# } -# _parameter_order = Warp_Galaxy._parameter_order + ("am", "phim") -# modes = (1, 3, 4) -# track_attrs = Galaxy_Model.track_attrs + ["modes"] -# usable = False - -# def __init__(self, *args, **kwargs): -# super().__init__(*args, **kwargs) -# self.modes = torch.tensor( -# kwargs.get("modes", FourierEllipse_Warp.modes), -# dtype=AP_config.ap_dtype, -# device=AP_config.ap_device, -# ) - -# @default_internal -# def angular_metric(self, X, Y, image=None, parameters=None): -# return torch.atan2(Y, X) - -# @default_internal -# def radius_metric(self, X, Y, image=None, parameters=None): -# R = super().radius_metric(X, Y, image, parameters) -# theta = self.angular_metric(X, Y, image, parameters) -# return R * torch.exp( -# torch.sum( -# parameters["am"].value.view(len(self.modes), -1) -# * torch.cos( -# self.modes.view(len(self.modes), -1) * theta.view(-1) -# + parameters["phim"].value.view(len(self.modes), -1) -# ), -# 0, -# ).view(theta.shape) -# ) - -# @torch.no_grad() -# @ignore_numpy_warnings -# @select_target -# @default_internal -# def initialize(self, target=None, parameters=None, **kwargs): -# super().initialize(target=target, parameters=parameters) - -# with Param_Unlock(parameters["am"]), Param_SoftLimits(parameters["am"]): -# if parameters["am"].value is None: -# parameters["am"].value = torch.zeros( -# len(self.modes), -# dtype=AP_config.ap_dtype, -# device=AP_config.ap_device, -# ) -# if parameters["am"].uncertainty is None: -# parameters["am"].uncertainty = torch.tensor( -# self.default_uncertainty * np.ones(len(self.modes)), -# dtype=AP_config.ap_dtype, -# device=AP_config.ap_device, -# ) -# with Param_Unlock(parameters["phim"]), Param_SoftLimits(parameters["phim"]): -# if parameters["phim"].value is None: -# parameters["phim"].value = torch.zeros( -# len(self.modes), -# dtype=AP_config.ap_dtype, -# device=AP_config.ap_device, -# ) -# if parameters["phim"].uncertainty is None: -# parameters["phim"].uncertainty = torch.tensor( -# (5 * np.pi / 180) -# * np.ones( -# len(self.modes) -# ), # Uncertainty assumed to be 5 degrees if not provided -# dtype=AP_config.ap_dtype, -# device=AP_config.ap_device, -# ) diff --git a/astrophot/models/gaussian.py b/astrophot/models/gaussian.py new file mode 100644 index 00000000..0a8c90af --- /dev/null +++ b/astrophot/models/gaussian.py @@ -0,0 +1,66 @@ +from .galaxy_model_object import GalaxyModel + +from .psf_model_object import PSFModel +from .mixins import ( + GaussianMixin, + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + iGaussianMixin, +) + +__all__ = [ + "GaussianGalaxy", + "GaussianPSF", + "GaussianSuperEllipse", + "GaussianFourierEllipse", + "GaussianWarp", + "GaussianRay", + "GaussianWedge", +] + + +class GaussianGalaxy(GaussianMixin, RadialMixin, GalaxyModel): + """Basic galaxy model with Gaussian as the radial light profile. The + gaussian radial profile is defined as: + + I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) + + where I(R) is the prightness as a function of semi-major axis + length, F is the total flux in the model, R is the semi-major + axis, and S is the standard deviation. + + Parameters: + sigma: standard deviation of the gaussian profile, must be a positive value + flux: the total flux in the gaussian model, represented as the log of the total + + """ + + usable = True + + +class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): + usable = True + + +class GaussianSuperEllipse(GaussianMixin, SuperEllipseMixin, GalaxyModel): + usable = True + + +class GaussianFourierEllipse(GaussianMixin, FourierEllipseMixin, GalaxyModel): + usable = True + + +class GaussianWarp(GaussianMixin, WarpMixin, GalaxyModel): + usable = True + + +class GaussianRay(iGaussianMixin, RayMixin, GalaxyModel): + usable = True + + +class GaussianWedge(iGaussianMixin, WedgeMixin, GalaxyModel): + usable = True diff --git a/astrophot/models/gaussian_model.py b/astrophot/models/gaussian_model.py deleted file mode 100644 index b9d3b059..00000000 --- a/astrophot/models/gaussian_model.py +++ /dev/null @@ -1,153 +0,0 @@ -from .galaxy_model_object import GalaxyModel - -from .warp_model import WarpGalaxy -from .superellipse_model import SuperEllipseGalaxy -from .foureirellipse_model import FourierEllipseGalaxy -from .ray_model import RayGalaxy -from .wedge_model import WedgeGalaxy -from .psf_model_object import PSFModel -from .mixins import GaussianMixin, RadialMixin - -__all__ = [ - "GaussianGalaxy", - "GaussianPSF", - "GaussianSuperEllipse", - "GaussianFourierEllipse", - "GaussianWarp", - "GaussianRay", - "GaussianWedge", -] - - -class GaussianGalaxy(GaussianMixin, RadialMixin, GalaxyModel): - """Basic galaxy model with Gaussian as the radial light profile. The - gaussian radial profile is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - - usable = True - - -class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): - """Basic point source model with a Gaussian as the radial light profile. The - gaussian radial profile is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - - usable = True - - -class GaussianSuperEllipse(GaussianMixin, RadialMixin, SuperEllipseGalaxy): - """Super ellipse galaxy model with Gaussian as the radial light - profile.The gaussian radial profile is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - - usable = True - - -class GaussianFourierEllipse(GaussianMixin, RadialMixin, FourierEllipseGalaxy): - """fourier mode perturbations to ellipse galaxy model with a gaussian - profile for the radial light profile. The gaussian radial profile - is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - - usable = True - - -class GaussianWarp(GaussianMixin, RadialMixin, WarpGalaxy): - """Coordinate warped galaxy model with Gaussian as the radial light - profile. The gaussian radial profile is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - - usable = True - - -class GaussianRay(iGaussianMixin, RayGalaxy): - """ray galaxy model with a gaussian profile for the radial light - model. The gaussian radial profile is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - - usable = True - - -class GaussianWedge(iGaussianMixin, WedgeGalaxy): - """wedge galaxy model with a gaussian profile for the radial light - model. The gaussian radial profile is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - - usable = True diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index b242e35a..7a937bce 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -1,18 +1,30 @@ -from .sersic import SersicMixin, iSersicMixin -from .brightness import RadialMixin +from .brightness import ( + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, +) from .transform import InclinedMixin +from .sersic import SersicMixin, iSersicMixin from .exponential import ExponentialMixin, iExponentialMixin from .moffat import MoffatMixin, iMoffatMixin from .gaussian import GaussianMixin, iGaussianMixin from .nuker import NukerMixin, iNukerMixin -from .spline import SplineMixin +from .spline import SplineMixin, iSplineMixin from .sample import SampleMixin __all__ = ( - "SersicMixin", - "iSersicMixin", "RadialMixin", + "WedgeMixin", + "RayMixin", + "SuperEllipseMixin", + "FourierEllipseMixin", + "WarpMixin", "InclinedMixin", + "SersicMixin", + "iSersicMixin", "ExponentialMixin", "iExponentialMixin", "MoffatMixin", @@ -22,5 +34,6 @@ "NukerMixin", "iNukerMixin", "SplineMixin", + "iSplineMixin", "SampleMixin", ) diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index 1c62b42c..caecab3d 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -1,4 +1,11 @@ +import torch +import numpy as np + from ...param import forward +from .. import func +from ...utils.decorators import ignore_numpy_warnings +from ...utils.interpolate import default_prof +from ... import AP_config class RadialMixin: @@ -10,3 +17,261 @@ def brightness(self, x, y): """ x, y = self.transform_coordinates(x, y) return self.radial_model(self.radius_metric(x, y)) + + +class SuperEllipseMixin: + """Expanded galaxy model which includes a superellipse transformation + in its radius metric. This allows for the expression of "boxy" and + "disky" isophotes instead of pure ellipses. This is a common + extension of the standard elliptical representation, especially + for early-type galaxies. The functional form for this is: + + R = (|X|^C + |Y|^C)^(1/C) + + where R is the new distance metric, X Y are the coordinates, and C + is the coefficient for the superellipse. C can take on any value + greater than zero where C = 2 is the standard distance metric, 0 < + C < 2 creates disky or pointed perturbations to an ellipse, and C + > 2 transforms an ellipse to be more boxy. + + Parameters: + C: superellipse distance metric parameter. + + """ + + _model_type = "superellipse" + _parameter_specs = { + "C": {"units": "none", "value": 2.0, "uncertainty": 1e-2, "valid": (0, None)}, + } + + def radius_metric(self, x, y, C): + return torch.pow(x.abs().pow(C) + y.abs().pow(C), 1.0 / C) + + +class FourierEllipseMixin: + """Expanded galaxy model which includes a Fourier transformation in + its radius metric. This allows for the expression of arbitrarily + complex isophotes instead of pure ellipses. This is a common + extension of the standard elliptical representation. The form of + the Fourier perturbations is: + + R' = R * exp(sum_m(a_m * cos(m * theta + phi_m))) + + where R' is the new radius value, R is the original ellipse + radius, a_m is the amplitude of the m'th Fourier mode, m is the + index of the Fourier mode, theta is the angle around the ellipse, + and phi_m is the phase of the m'th fourier mode. This + representation is somewhat different from other Fourier mode + implementations where instead of an expoenntial it is just 1 + + sum_m(...), we opt for this formulation as it is more numerically + stable. It cannot ever produce negative radii, but to first order + the two representation are the same as can be seen by a Taylor + expansion of exp(x) = 1 + x + O(x^2). + + One can create extremely complex shapes using different Fourier + modes, however usually it is only low order modes that are of + interest. For intuition, the first Fourier mode is roughly + equivalent to a lopsided galaxy, one side will be compressed and + the opposite side will be expanded. The second mode is almost + never used as it is nearly degenerate with ellipticity. The third + mode is an alternate kind of lopsidedness for a galaxy which makes + it somewhat triangular, meaning that it is wider on one side than + the other. The fourth mode is similar to a boxyness/diskyness + parameter which tends to make more pronounced peanut shapes since + it is more rounded than a superellipse representation. Modes + higher than 4 are only useful in very specialized situations. In + general one should consider carefully why the Fourier modes are + being used for the science case at hand. + + Parameters: + am: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. + phi_m: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) + + """ + + _model_type = "fourier" + _parameter_specs = { + "am": {"units": "none"}, + "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True}, + } + _options = ("modes",) + + def __init__(self, *args, modes=(3, 4), **kwargs): + super().__init__(*args, **kwargs) + self.modes = torch.tensor(modes, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + + @forward + def radius_metric(self, x, y, am, phim): + R = super().radius_metric(x, y) + theta = self.angular_metric(x, y) + return R * torch.exp( + torch.sum( + am.unsqueeze(-1) + * torch.cos(self.modes.unsqueeze(-1) * theta.flatten() + phim.unsqueeze(-1)), + 0, + ).reshape(x.shape) + ) + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if self.am.value is None: + self.am.dynamic_value = np.zeros(len(self.modes)) + self.am.uncertainty = self.default_uncertainty * np.ones(len(self.modes)) + if self.phim.value is None: + self.phim.value = np.zeros(len(self.modes)) + self.phim.uncertainty = (10 * np.pi / 180) * np.ones(len(self.modes)) + + +class WarpMixin: + """Galaxy model which includes radially varrying PA and q + profiles. This works by warping the coordinates using the same + transform for a global PA/q except applied to each pixel + individually. In the limit that PA and q are a constant, this + recovers a basic galaxy model with global PA/q. However, a linear + PA profile will give a spiral appearance, variations of PA/q + profiles can create complex galaxy models. The form of the + coordinate transformation looks like: + + X, Y = meshgrid(image) + R = sqrt(X^2 + Y^2) + X', Y' = Rot(theta(R), X, Y) + Y'' = Y' / q(R) + + where the definitions are the same as for a regular galaxy model, + except now the theta is a function of radius R (before + transformation) and the axis ratio q is also a function of radius + (before the transformation). + + Parameters: + q(R): Tensor of axis ratio values for axis ratio spline + PA(R): Tensor of position angle values as input to the spline + + """ + + _model_type = "warp" + _parameter_specs = { + "q_R": {"units": "b/a", "valid": (0.0, 1), "uncertainty": 0.04}, + "PA_R": { + "units": "radians", + "valid": (0, np.pi), + "cyclic": True, + "uncertainty": 0.08, + }, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if self.PA_R.value is None: + if self.PA_R.prof is None: + self.PA_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 + self.PA_R.uncertainty = (10 * np.pi / 180) * torch.ones_like(self.PA_R.value) + if self.q_R.value is None: + if self.q_R.prof is None: + self.q_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 + self.q_R.uncertainty = self.default_uncertainty * self.q_R.value + + @forward + def transform_coordinates(self, x, y, q_R, PA_R): + x, y = super().transform_coordinates(x, y) + R = self.radius_metric(x, y) + PA = func.spline(R, self.PA_R.prof, PA_R) + q = func.spline(R, self.q_R.prof, q_R) + x, y = func.rotate(PA, x, y) + return x, y / q + + +class WedgeMixin: + """Variant of the ray model where no smooth transition is performed + between regions as a function of theta, instead there is a sharp + trnasition boundary. This may be desirable as it cleanly + separates where the pixel information is going. Due to the sharp + transition though, it may cause unusual behaviour when fitting. If + problems occur, try fitting a ray model first then fix the center, + PA, and q and then fit the wedge model. Essentially this breaks + down the structure fitting and the light profile fitting into two + steps. The wedge model, like the ray model, defines no extra + parameters, however a new option can be supplied on instantiation + of the wedge model which is "wedges" or the number of wedges in + the model. + + """ + + _model_type = "wedge" + _options = ("segments", "symmetric") + + def __init__(self, *args, symmetric=True, segments=2, **kwargs): + super().__init__(*args, **kwargs) + self.symmetric = symmetric + self.segments = segments + + def polar_model(self, R, T): + model = torch.zeros_like(R) + cycle = np.pi if self.symmetric else 2 * np.pi + w = cycle / self.segments + angles = (T + w / 2) % cycle + v = w * np.arange(self.segments) + for s in range(self.segments): + indices = (angles >= v[s]) & (angles < (v[s] + w)) + model[indices] += self.iradial_model(s, R[indices]) + return model + + def brightness(self, x, y): + x, y = self.transform_coordinates(x, y) + return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) + + +class RayMixin: + """Variant of a galaxy model which defines multiple radial models + seprarately along some number of rays projected from the galaxy + center. These rays smoothly transition from one to another along + angles theta. The ray transition uses a cosine smoothing function + which depends on the number of rays, for example with two rays the + brightness would be: + + I(R,theta) = I1(R)*cos(theta % pi) + I2(R)*cos((theta + pi/2) % pi) + + Where I(R,theta) is the brightness function in polar coordinates, + R is the semi-major axis, theta is the polar angle (defined after + galaxy axis ratio is applied), I1(R) is the first brightness + profile, % is the modulo operator, and I2 is the second brightness + profile. The ray model defines no extra parameters, though now + every model parameter related to the brightness profile gains an + extra dimension for the ray number. Also a new input can be given + when instantiating the ray model: "rays" which is an integer for + the number of rays. + + """ + + _model_type = "ray" + _options = ("symmetric", "segments") + + def __init__(self, *args, symmetric=True, segments=2, **kwargs): + super().__init__(*args, **kwargs) + self.symmetric = symmetric + self.segments = segments + + def polar_model(self, R, T): + model = torch.zeros_like(R) + weight = torch.zeros_like(R) + cycle = np.pi if self.symmetric else 2 * np.pi + w = cycle / self.segments + v = w * np.arange(self.segments) + for s in range(self.segments): + angles = (T + cycle / 2 - v[s]) % cycle - cycle / 2 + indices = (angles >= -w) & (angles < w) + weights = (torch.cos(angles[indices] * self.segments) + 1) / 2 + model[indices] += self.iradial_model(s, R[indices]) + weight[indices] += weights + return model / weight + + def brightness(self, x, y): + x, y = self.transform_coordinates(x, y) + return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 9505a94d..1e0770fc 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -34,7 +34,7 @@ class ExponentialMixin: @torch.no_grad() @ignore_numpy_warnings - def initialize(self, **kwargs): + def initialize(self): super().initialize() parametric_initialize( @@ -63,19 +63,18 @@ class iExponentialMixin: _model_type = "exponential" parameter_specs = { - "Re": {"units": "arcsec", "limits": (0, None)}, + "Re": {"units": "arcsec", "valid": (0, None)}, "Ie": {"units": "flux/arcsec^2"}, } @torch.no_grad() @ignore_numpy_warnings - def initialize(self, target=None, parameters=None, **kwargs): - super().initialize(target=target, parameters=parameters) + def initialize(self): + super().initialize() parametric_segment_initialize( model=self, - target=target, - parameters=parameters, + target=self.target, prof_func=exponential_np, params=("Re", "Ie"), x0_func=_x0_func, diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 019febeb..77c4c660 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -1,8 +1,10 @@ import torch +import numpy as np from ...param import forward from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import _sample_image +from ...utils.interpolate import default_prof from .. import func @@ -24,16 +26,7 @@ def initialize(self): target_area = self.target[self.window] # Create the I_R profile radii if needed if self.I_R.prof is None: - prof = [0, 2 * target_area.pixel_length] - while prof[-1] < (max(self.window.shape) * target_area.pixel_length / 2): - prof.append(prof[-1] + torch.max(2 * target_area.pixel_length, prof[-1] * 0.2)) - prof.pop() - prof.append( - torch.sqrt( - torch.sum((self.window.shape[0] / 2) ** 2 + (self.window.shape[1] / 2) ** 2) - * target_area.pixel_length**2 - ) - ) + prof = default_prof(self.window.shape, target_area.pixel_length, 2, 0.2) self.I_R.prof = prof else: prof = self.I_R.prof @@ -70,28 +63,30 @@ def initialize(self): target_area = self.target[self.window] # Create the I_R profile radii if needed if self.I_R.prof is None: - prof = [0, 2 * target_area.pixel_length] - while prof[-1] < (max(self.window.shape) * target_area.pixel_length / 2): - prof.append(prof[-1] + torch.max(2 * target_area.pixel_length, prof[-1] * 0.2)) - prof.pop() - prof.append( - torch.sqrt( - torch.sum((self.window.shape[0] / 2) ** 2 + (self.window.shape[1] / 2) ** 2) - * target_area.pixel_length**2 - ) - ) + prof = default_prof(self.window.shape, target_area.pixel_length, 2, 0.2) self.I_R.prof = [prof] * self.segments else: prof = self.I_R.prof - R, I, S = _sample_image( - target_area, - self.transform_coordinates, - self.radius_metric, - rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], - ) - self.I_R.dynamic_value = I - self.I_R.uncertainty = S + value = np.zeros((self.segments, len(prof))) + uncertainty = np.zeros((self.segments, len(prof))) + cycle = np.pi if self.symmetric else 2 * np.pi + w = cycle / self.segments + v = w * np.arange(self.segments) + for s in range(self.segments): + angle_range = (v[s] - w / 2, v[s] + w / 2) + R, I, S = _sample_image( + target_area, + self.transform_coordinates, + self.radius_metric, + angle=self.angular_metric, + rad_bins=[0] + list((prof[s][:-1] + prof[s][1:]) / 2) + [prof[s][-1] * 100], + angle_range=angle_range, + ) + value[s] = I + uncertainty[s] = S + self.I_R.dynamic_value = value + self.I_R.uncertainty = uncertainty @forward def iradial_model(self, i, R, I_R): diff --git a/astrophot/models/moffat_model.py b/astrophot/models/moffat.py similarity index 76% rename from astrophot/models/moffat_model.py rename to astrophot/models/moffat.py index d42e1969..8690641d 100644 --- a/astrophot/models/moffat_model.py +++ b/astrophot/models/moffat.py @@ -2,15 +2,29 @@ from .galaxy_model_object import GalaxyModel from .psf_model_object import PSFModel -from .warp_model import WarpGalaxy -from .ray_model import RayGalaxy -from .wedge_model import WedgeGalaxy -from .superellipse_model import SuperEllipseGalaxy -from .foureirellipse_model import FourierEllipseGalaxy from ..utils.conversions.functions import moffat_I0_to_flux -from .mixins import MoffatMixin, InclinedMixin, RadialMixin - -__all__ = ("MoffatGalaxy", "MoffatPSF") +from .mixins import ( + MoffatMixin, + InclinedMixin, + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + iMoffatMixin, +) + +__all__ = ( + "MoffatGalaxy", + "MoffatPSF", + "Moffat2DPSF", + "MoffatSuperEllipse", + "MoffatFourierEllipse", + "MoffatWarp", + "MoffatRay", + "MoffatWedge", +) class MoffatGalaxy(MoffatMixin, RadialMixin, GalaxyModel): @@ -73,21 +87,21 @@ def total_flux(self, n, Rd, I0, q): return moffat_I0_to_flux(I0, n, Rd, q) -class MoffatSuperEllipseGalaxy(MoffatMixin, RadialMixin, SuperEllipseGalaxy): +class MoffatSuperEllipse(MoffatMixin, SuperEllipseMixin, GalaxyModel): usable = True -class MoffatFourierEllipseGalaxy(MoffatMixin, RadialMixin, FourierEllipseGalaxy): +class MoffatFourierEllipse(MoffatMixin, FourierEllipseMixin, GalaxyModel): usable = True -class MoffatWarpGalaxy(MoffatMixin, RadialMixin, WarpGalaxy): +class MoffatWarp(MoffatMixin, WarpMixin, GalaxyModel): usable = True -class MoffatWedgeGalaxy(MoffatMixin, WedgeGalaxy): +class MoffatRay(iMoffatMixin, RayMixin, GalaxyModel): usable = True -class MoffatRayGalaxy(MoffatMixin, RayGalaxy): +class MoffatWedge(iMoffatMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/models/multi_gaussian_expansion_model.py b/astrophot/models/multi_gaussian_expansion.py similarity index 100% rename from astrophot/models/multi_gaussian_expansion_model.py rename to astrophot/models/multi_gaussian_expansion.py diff --git a/astrophot/models/nuker.py b/astrophot/models/nuker.py new file mode 100644 index 00000000..667328c5 --- /dev/null +++ b/astrophot/models/nuker.py @@ -0,0 +1,70 @@ +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel +from .mixins import ( + NukerMixin, + RadialMixin, + iNukerMixin, + RayMixin, + WedgeMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, +) + +__all__ = [ + "NukerGalaxy", + "NukerPSF", + "NukerSuperEllipse", + "NukerFourierEllipse", + "NukerWarp", + "NukerWedge", + "NukerRay", +] + + +class NukerGalaxy(NukerMixin, RadialMixin, GalaxyModel): + """basic galaxy model with a Nuker profile for the radial light + profile. The functional form of the Nuker profile is defined as: + + I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) + + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ib is the flux density at + the scale radius Rb, Rb is the scale length for the profile, beta + is the outer power law slope, gamma is the iner power law slope, + and alpha is the sharpness of the transition. + + Parameters: + Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. + Rb: scale length radius + alpha: sharpness of transition between power law slopes + beta: outer power law slope + gamma: inner power law slope + + """ + + usable = True + + +class NukerPSF(NukerMixin, RadialMixin, PSFModel): + usable = True + + +class NukerSuperEllipse(NukerMixin, SuperEllipseMixin, GalaxyModel): + usable = True + + +class NukerFourierEllipse(NukerMixin, FourierEllipseMixin, GalaxyModel): + usable = True + + +class NukerWarp(NukerMixin, WarpMixin, GalaxyModel): + usable = True + + +class NukerRay(iNukerMixin, RayMixin, GalaxyModel): + usable = True + + +class NukerWedge(iNukerMixin, WedgeMixin, GalaxyModel): + usable = True diff --git a/astrophot/models/nuker_model.py b/astrophot/models/nuker_model.py deleted file mode 100644 index e3c58bcb..00000000 --- a/astrophot/models/nuker_model.py +++ /dev/null @@ -1,187 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from .warp_model import WarpGalaxy -from .ray_model import RayGalaxy -from .wedge_model import WedgeGalaxy -from .superellipse_model import SuperEllipseGalaxy -from .foureirellipse_model import FourierEllipseGalaxy -from .mixins import NukerMixin, RadialMixin - -__all__ = [ - "NukerGalaxy", - "NukerPSF", - "NukerSuperEllipse", - "NukerFourierEllipse", - "NukerWarp", - "NukerRay", -] - - -class NukerGalaxy(NukerMixin, RadialMixin, GalaxyModel): - """basic galaxy model with a Nuker profile for the radial light - profile. The functional form of the Nuker profile is defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - - usable = True - - -class NukerPSF(NukerMixin, RadialMixin, PSFModel): - """basic point source model with a Nuker profile for the radial light - profile. The functional form of the Nuker profile is defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - - usable = True - - -class NukerSuperEllipse(NukerMixin, RadialMixin, SuperEllipseGalaxy): - """super ellipse galaxy model with a Nuker profile for the radial - light profile. The functional form of the Nuker profile is defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - - usable = True - - -class NukerFourierEllipse(NukerMixin, RadialMixin, FourierEllipseGalaxy): - """fourier mode perturbations to ellipse galaxy model with a Nuker - profile for the radial light profile. The functional form of the - Nuker profile is defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - - usable = True - - -class NukerWarp(NukerMixin, RadialMixin, WarpGalaxy): - """warped coordinate galaxy model with a Nuker profile for the radial - light model. The functional form of the Nuker profile is defined - as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - - usable = True - - -class NukerRay(iNukerMixin, RayGalaxy): - """ray galaxy model with a nuker profile for the radial light - model. The functional form of the Sersic profile is defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - - usable = True - - -class NukerWedge(iNukerMixin, WedgeGalaxy): - """wedge galaxy model with a nuker profile for the radial light - model. The functional form of the Sersic profile is defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - - usable = True diff --git a/astrophot/models/pixelated_psf_model.py b/astrophot/models/pixelated_psf.py similarity index 100% rename from astrophot/models/pixelated_psf_model.py rename to astrophot/models/pixelated_psf.py diff --git a/astrophot/models/planesky_model.py b/astrophot/models/planesky.py similarity index 100% rename from astrophot/models/planesky_model.py rename to astrophot/models/planesky.py diff --git a/astrophot/models/ray_model.py b/astrophot/models/ray_model.py deleted file mode 100644 index 2ab48769..00000000 --- a/astrophot/models/ray_model.py +++ /dev/null @@ -1,89 +0,0 @@ -import numpy as np -import torch - -from .galaxy_model_object import GalaxyModel - -__all__ = ["RayGalaxy"] - - -class RayGalaxy(GalaxyModel): - """Variant of a galaxy model which defines multiple radial models - seprarately along some number of rays projected from the galaxy - center. These rays smoothly transition from one to another along - angles theta. The ray transition uses a cosine smoothing function - which depends on the number of rays, for example with two rays the - brightness would be: - - I(R,theta) = I1(R)*cos(theta % pi) + I2(R)*cos((theta + pi/2) % pi) - - Where I(R,theta) is the brightness function in polar coordinates, - R is the semi-major axis, theta is the polar angle (defined after - galaxy axis ratio is applied), I1(R) is the first brightness - profile, % is the modulo operator, and I2 is the second brightness - profile. The ray model defines no extra parameters, though now - every model parameter related to the brightness profile gains an - extra dimension for the ray number. Also a new input can be given - when instantiating the ray model: "rays" which is an integer for - the number of rays. - - """ - - _model_type = "segments" - usable = False - _options = ("symmetric_rays", "rays") - - def __init__(self, *args, symmetric_rays=True, segments=2, **kwargs): - super().__init__(*args, **kwargs) - self.symmetric_rays = symmetric_rays - self.segments = segments - - def polar_model(self, R, T): - model = torch.zeros_like(R) - if self.segments % 2 == 0 and self.symmetric_rays: - for r in range(self.segments): - angles = (T - (r * np.pi / self.segments)) % np.pi - indices = torch.logical_or( - angles < (np.pi / self.segments), - angles >= (np.pi * (1 - 1 / self.segments)), - ) - weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices]) - elif self.segments % 2 == 1 and self.symmetric_rays: - for r in range(self.segments): - angles = (T - (r * np.pi / self.segments)) % (2 * np.pi) - indices = torch.logical_or( - angles < (np.pi / self.segments), - angles >= (np.pi * (2 - 1 / self.segments)), - ) - weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices]) - angles = (T - (np.pi + r * np.pi / self.segments)) % (2 * np.pi) - indices = torch.logical_or( - angles < (np.pi / self.segments), - angles >= (np.pi * (2 - 1 / self.segments)), - ) - weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices]) - elif self.segments % 2 == 0 and not self.symmetric_rays: - for r in range(self.segments): - angles = (T - (r * 2 * np.pi / self.segments)) % (2 * np.pi) - indices = torch.logical_or( - angles < (2 * np.pi / self.segments), - angles >= (2 * np.pi * (1 - 1 / self.segments)), - ) - weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices]) - else: - for r in range(self.segments): - angles = (T - (r * 2 * np.pi / self.segments)) % (2 * np.pi) - indices = torch.logical_or( - angles < (2 * np.pi / self.segments), - angles >= (np.pi * (2 - 1 / self.segments)), - ) - weight = (torch.cos(angles[indices] * self.segments) + 1) / 2 - model[indices] += weight * self.iradial_model(r, R[indices]) - return model - - def brightness(self, x, y): - x, y = self.transform_coordinates(x, y) - return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) diff --git a/astrophot/models/sersic.py b/astrophot/models/sersic.py new file mode 100644 index 00000000..8a25bc7e --- /dev/null +++ b/astrophot/models/sersic.py @@ -0,0 +1,96 @@ +from ..param import forward +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel +from ..utils.conversions.functions import sersic_Ie_to_flux_torch +from .mixins import ( + SersicMixin, + RadialMixin, + WedgeMixin, + iSersicMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, +) + +__all__ = [ + "SersicGalaxy", + "SersicPSF", + "Sersic_Warp", + "Sersic_SuperEllipse", + "Sersic_FourierEllipse", + "Sersic_Ray", + "Sersic_Wedge", +] + + +class SersicGalaxy(SersicMixin, RadialMixin, GalaxyModel): + """basic galaxy model with a sersic profile for the radial light + profile. The functional form of the Sersic profile is defined as: + + I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) + + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius, and n is the sersic index + which controls the shape of the profile. + + Parameters: + n: Sersic index which controls the shape of the brightness profile + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius + + """ + + usable = True + + @forward + def total_flux(self, Ie, n, Re, q): + return sersic_Ie_to_flux_torch(Ie, n, Re, q) + + +class SersicPSF(SersicMixin, RadialMixin, PSFModel): + """basic point source model with a sersic profile for the radial light + profile. The functional form of the Sersic profile is defined as: + + I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) + + where I(R) is the brightness profile as a function of semi-major + axis, R is the semi-major axis length, Ie is the brightness as the + half light radius, bn is a function of n and is not involved in + the fit, Re is the half light radius, and n is the sersic index + which controls the shape of the profile. + + Parameters: + n: Sersic index which controls the shape of the brightness profile + Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. + Re: half light radius + + """ + + usable = True + + @forward + def total_flux(self, Ie, n, Re): + return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) + + +class SersicSuperEllipse(SersicMixin, SuperEllipseMixin, GalaxyModel): + usable = True + + +class SersicFourierEllipse(SersicMixin, FourierEllipseMixin, GalaxyModel): + usable = True + + +class SersicWarp(SersicMixin, WarpMixin, GalaxyModel): + usable = True + + +class SersicRay(iSersicMixin, RayMixin, GalaxyModel): + usable = True + + +class SersicWedge(iSersicMixin, WedgeMixin, GalaxyModel): + usable = True diff --git a/astrophot/models/sersic_model.py b/astrophot/models/sersic_model.py deleted file mode 100644 index e022b6b4..00000000 --- a/astrophot/models/sersic_model.py +++ /dev/null @@ -1,186 +0,0 @@ -from ..param import forward -from .galaxy_model_object import GalaxyModel - -from .warp_model import WarpGalaxy -from .ray_model import RayGalaxy -from .wedge_model import WedgeGalaxy -from .psf_model_object import PSFModel - -from .superellipse_model import SuperEllipseGalaxy # , SuperEllipse_Warp -from .foureirellipse_model import FourierEllipseGalaxy # , FourierEllipse_Warp -from ..utils.conversions.functions import sersic_Ie_to_flux_torch -from .mixins import SersicMixin, RadialMixin, iSersicMixin - -__all__ = [ - "SersicGalaxy", - "SersicPSF", - "Sersic_Warp", - "Sersic_SuperEllipse", - "Sersic_FourierEllipse", - "Sersic_Ray", - "Sersic_Wedge", -] - - -class SersicGalaxy(SersicMixin, RadialMixin, GalaxyModel): - """basic galaxy model with a sersic profile for the radial light - profile. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True - - @forward - def total_flux(self, Ie, n, Re, q): - return sersic_Ie_to_flux_torch(Ie, n, Re, q) - - -class SersicPSF(SersicMixin, RadialMixin, PSFModel): - """basic point source model with a sersic profile for the radial light - profile. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True - - @forward - def total_flux(self, Ie, n, Re): - return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) - - -class SersicSuperEllipse(SersicMixin, RadialMixin, SuperEllipseGalaxy): - """super ellipse galaxy model with a sersic profile for the radial - light profile. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True - - -class SersicFourierEllipse(SersicMixin, RadialMixin, FourierEllipseGalaxy): - """fourier mode perturbations to ellipse galaxy model with a sersic - profile for the radial light profile. The functional form of the - Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True - - -class SersicWarp(SersicMixin, RadialMixin, WarpGalaxy): - """warped coordinate galaxy model with a sersic profile for the radial - light model. The functional form of the Sersic profile is defined - as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True - - -class SersicRay(iSersicMixin, RayGalaxy): - """ray galaxy model with a sersic profile for the radial light - model. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True - - -class SersicWedge(iSersicMixin, WedgeGalaxy): - """wedge galaxy model with a sersic profile for the radial light - model. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - - usable = True diff --git a/astrophot/models/spline.py b/astrophot/models/spline.py new file mode 100644 index 00000000..c6be5f6d --- /dev/null +++ b/astrophot/models/spline.py @@ -0,0 +1,68 @@ +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel +from .mixins import ( + SplineMixin, + RadialMixin, + iSplineMixin, + RayMixin, + WedgeMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, +) + +__all__ = [ + "SplineGalaxy", + "SplinePSF", + "SplineWarp", + "SplineSuperEllipse", + "SplineFourierEllipse", + "SplineRay", + "SplineWedge", +] + + +# First Order +###################################################################### +class SplineGalaxy(SplineMixin, RadialMixin, GalaxyModel): + """Basic galaxy model with a spline radial light profile. The + light profile is defined as a cubic spline interpolation of the + stored brightness values: + + I(R) = interp(R, profR, I) + + where I(R) is the brightness along the semi-major axis, interp is + a cubic spline function, R is the semi-major axis length, profR is + a list of radii for the spline, I is a corresponding list of + brightnesses at each profR value. + + Parameters: + I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared + + """ + + usable = True + + +class SplinePSF(SplineMixin, RadialMixin, PSFModel): + usable = True + + +class SplineSuperEllipse(SplineMixin, SuperEllipseMixin, GalaxyModel): + usable = True + + +class SplineFourierEllipse(SplineMixin, FourierEllipseMixin, GalaxyModel): + usable = True + + +class SplineWarp(SplineMixin, WarpMixin, GalaxyModel): + usable = True + + +class SplineRay(iSplineMixin, RayMixin, GalaxyModel): + usable = True + + +class SplineWedge(iSplineMixin, WedgeMixin, GalaxyModel): + usable = True diff --git a/astrophot/models/spline_model.py b/astrophot/models/spline_model.py deleted file mode 100644 index 2845e89e..00000000 --- a/astrophot/models/spline_model.py +++ /dev/null @@ -1,160 +0,0 @@ -from .galaxy_model_object import GalaxyModel - -from .warp_model import WarpGalaxy -from .superellipse_model import SuperEllipseGalaxy # , SuperEllipse_Warp -from .foureirellipse_model import FourierEllipseGalaxy # , FourierEllipse_Warp -from .psf_model_object import PSFModel - -from .ray_model import RayGalaxy -from .wedge_model import WedgeGalaxy -from .mixins import SplineMixin, RadialMixin - -__all__ = [ - "SplineGalaxy", - "SplinePSF", - "SplineWarp", - "SplineSuperEllipse", - "SplineFourierEllipse", - "SplineRay", - "SplineWedge", -] - - -# First Order -###################################################################### -class SplineGalaxy(SplineMixin, RadialMixin, GalaxyModel): - """Basic galaxy model with a spline radial light profile. The - light profile is defined as a cubic spline interpolation of the - stored brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared - - """ - - usable = True - - -class SplinePSF(SplineMixin, RadialMixin, PSFModel): - """star model with a spline radial light profile. The light - profile is defined as a cubic spline interpolation of the stored - brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared - - """ - - usable = True - - -class SplineWarp(SplineMixin, RadialMixin, WarpGalaxy): - """warped coordinate galaxy model with a spline light - profile. The light profile is defined as a cubic spline - interpolation of the stored brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared - - """ - - usable = True - - -class SplineSuperEllipse(SplineMixin, RadialMixin, SuperEllipseGalaxy): - """The light profile is defined as a cubic spline interpolation of - the stored brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared - - """ - - usable = True - - -class SplineFourierEllipse(SplineMixin, RadialMixin, FourierEllipseGalaxy): - """The light profile is defined as a cubic spline interpolation of the - stored brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared - - """ - - usable = True - - -class SplineRay(iSplineMixin, RayGalaxy): - """ray galaxy model with a spline light profile. The light - profile is defined as a cubic spline interpolation of the stored - brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): 2D Tensor of brighntess values for each ray, represented as the log of the brightness divided by pixelscale squared - - """ - - usable = True - - -class SplineWedge(iSplineMixin, WedgeGalaxy): - """wedge galaxy model with a spline light profile. The light - profile is defined as a cubic spline interpolation of the stored - brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): 2D Tensor of brighntess values for each wedge, represented as the log of the brightness divided by pixelscale squared - - """ - - usable = True diff --git a/astrophot/models/superellipse_model.py b/astrophot/models/superellipse_model.py deleted file mode 100644 index 2b5ebf07..00000000 --- a/astrophot/models/superellipse_model.py +++ /dev/null @@ -1,77 +0,0 @@ -import torch - -from .galaxy_model_object import GalaxyModel - -# from .warp_model import Warp_Galaxy - -__all__ = [ - "SuperEllipseGalaxy", - # "SuperEllipse_Warp" -] - - -class SuperEllipseGalaxy(GalaxyModel): - """Expanded galaxy model which includes a superellipse transformation - in its radius metric. This allows for the expression of "boxy" and - "disky" isophotes instead of pure ellipses. This is a common - extension of the standard elliptical representation, especially - for early-type galaxies. The functional form for this is: - - R = (|X|^C + |Y|^C)^(1/C) - - where R is the new distance metric, X Y are the coordinates, and C - is the coefficient for the superellipse. C can take on any value - greater than zero where C = 2 is the standard distance metric, 0 < - C < 2 creates disky or pointed perturbations to an ellipse, and C - > 2 transforms an ellipse to be more boxy. - - Parameters: - C: superellipse distance metric parameter. - - """ - - _model_type = "superellipse" - _parameter_specs = { - "C": {"units": "none", "value": 2.0, "uncertainty": 1e-2, "valid": (0, None)}, - } - usable = False - - def radius_metric(self, x, y, C): - return torch.pow(x.abs().pow(C) + y.abs().pow(C), 1.0 / C) - - -# class SuperEllipse_Warp(Warp_Galaxy): -# """Expanded warp model which includes a superellipse transformation -# in its radius metric. This allows for the expression of "boxy" and -# "disky" isophotes instead of pure ellipses. This is a common -# extension of the standard elliptical representation, especially -# for early-type galaxies. The functional form for this is: - -# R = (|X|^C + |Y|^C)^(1/C) - -# where R is the new distance metric, X Y are the coordinates, and C -# is the coefficient for the superellipse. C can take on any value -# greater than zero where C = 2 is the standard distance metric, 0 < -# C < 2 creates disky or pointed perturbations to an ellipse, and C -# > 2 transforms an ellipse to be more boxy. - -# Parameters: -# C0: superellipse distance metric parameter where C0 = C-2 so that a value of zero is now a standard ellipse. - - -# """ - -# model_type = f"superellipse {Warp_Galaxy.model_type}" -# parameter_specs = { -# "C0": {"units": "C-2", "value": 0.0, "uncertainty": 1e-2, "limits": (-2, None)}, -# } -# _parameter_order = Warp_Galaxy._parameter_order + ("C0",) -# usable = False - -# @default_internal -# def radius_metric(self, X, Y, image=None, parameters=None): -# return torch.pow( -# torch.pow(torch.abs(X), parameters["C0"].value + 2.0) -# + torch.pow(torch.abs(Y), parameters["C0"].value + 2.0), -# 1.0 / (parameters["C0"].value + 2.0), -# ) # epsilon added for numerical stability of gradient diff --git a/astrophot/models/warp_model.py b/astrophot/models/warp_model.py deleted file mode 100644 index 43c9d145..00000000 --- a/astrophot/models/warp_model.py +++ /dev/null @@ -1,74 +0,0 @@ -import numpy as np -import torch - -from .galaxy_model_object import GalaxyModel -from ..utils.interpolate import default_prof -from ..utils.decorators import ignore_numpy_warnings -from . import func -from ..param import forward - -__all__ = ["WarpGalaxy"] - - -class WarpGalaxy(GalaxyModel): - """Galaxy model which includes radially varrying PA and q - profiles. This works by warping the coordinates using the same - transform for a global PA/q except applied to each pixel - individually. In the limit that PA and q are a constant, this - recovers a basic galaxy model with global PA/q. However, a linear - PA profile will give a spiral appearance, variations of PA/q - profiles can create complex galaxy models. The form of the - coordinate transformation looks like: - - X, Y = meshgrid(image) - R = sqrt(X^2 + Y^2) - X', Y' = Rot(theta(R), X, Y) - Y'' = Y' / q(R) - - where the definitions are the same as for a regular galaxy model, - except now the theta is a function of radius R (before - transformation) and the axis ratio q is also a function of radius - (before the transformation). - - Parameters: - q(R): Tensor of axis ratio values for axis ratio spline - PA(R): Tensor of position angle values as input to the spline - - """ - - _model_type = "warp" - _parameter_specs = { - "q_R": {"units": "b/a", "valid": (0.0, 1), "uncertainty": 0.04}, - "PA_R": { - "units": "radians", - "valid": (0, np.pi), - "cyclic": True, - "uncertainty": 0.08, - }, - } - usable = False - - @torch.no_grad() - @ignore_numpy_warnings - def initialize(self): - super().initialize() - - if self.PA_R.value is None: - if self.PA_R.prof is None: - self.PA_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) - self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 - self.PA_R.uncertainty = (10 * np.pi / 180) * torch.ones_like(self.PA_R.value) - if self.q_R.value is None: - if self.q_R.prof is None: - self.q_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) - self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 - self.q_R.uncertainty = self.default_uncertainty * self.q_R.value - - @forward - def transform_coordinates(self, x, y, q_R, PA_R): - x, y = super().transform_coordinates(x, y) - R = self.radius_metric(x, y) - PA = func.spline(R, self.PA_R.prof, PA_R) - q = func.spline(R, self.q_R.prof, q_R) - x, y = func.rotate(PA, x, y) - return x, y / q diff --git a/astrophot/models/wedge_model.py b/astrophot/models/wedge_model.py deleted file mode 100644 index 3cbbe5b7..00000000 --- a/astrophot/models/wedge_model.py +++ /dev/null @@ -1,70 +0,0 @@ -import numpy as np -import torch - -from .galaxy_model_object import GalaxyModel - -__all__ = ["WedgeGalaxy"] - - -class WedgeGalaxy(GalaxyModel): - """Variant of the ray model where no smooth transition is performed - between regions as a function of theta, instead there is a sharp - trnasition boundary. This may be desirable as it cleanly - separates where the pixel information is going. Due to the sharp - transition though, it may cause unusual behaviour when fitting. If - problems occur, try fitting a ray model first then fix the center, - PA, and q and then fit the wedge model. Essentially this breaks - down the structure fitting and the light profile fitting into two - steps. The wedge model, like the ray model, defines no extra - parameters, however a new option can be supplied on instantiation - of the wedge model which is "wedges" or the number of wedges in - the model. - - """ - - _model_type = "segments" - usable = False - _options = ("segmentss", "symmetric_wedges") - - def __init__(self, *args, symmetric_wedges=True, segments=2, **kwargs): - super().__init__(*args, **kwargs) - self.symmetric_wedges = symmetric_wedges - self.segments = segments - - def polar_model(self, R, T): - model = torch.zeros_like(R) - if self.segments % 2 == 0 and self.symmetric_wedges: - for w in range(self.segments): - angles = (T - (w * np.pi / self.segments)) % np.pi - indices = torch.logical_or( - angles < (np.pi / (2 * self.segments)), - angles >= (np.pi * (1 - 1 / (2 * self.segments))), - ) - model[indices] += self.iradial_model(w, R[indices]) - elif self.segments % 2 == 1 and self.symmetric_wedges: - for w in range(self.segments): - angles = (T - (w * np.pi / self.segments)) % (2 * np.pi) - indices = torch.logical_or( - angles < (np.pi / (2 * self.segments)), - angles >= (np.pi * (2 - 1 / (2 * self.segments))), - ) - model[indices] += self.iradial_model(w, R[indices]) - angles = (T - (np.pi + w * np.pi / self.segments)) % (2 * np.pi) - indices = torch.logical_or( - angles < (np.pi / (2 * self.segments)), - angles >= (np.pi * (2 - 1 / (2 * self.segments))), - ) - model[indices] += self.iradial_model(w, R[indices]) - else: - for w in range(self.segments): - angles = (T - (w * 2 * np.pi / self.segments)) % (2 * np.pi) - indices = torch.logical_or( - angles < (np.pi / self.segments), - angles >= (np.pi * (2 - 1 / self.segments)), - ) - model[indices] += self.iradial_model(w, R[indices]) - return model - - def brightness(self, x, y): - x, y = self.transform_coordinates(x, y) - return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) diff --git a/astrophot/models/zernike_model.py b/astrophot/models/zernike.py similarity index 100% rename from astrophot/models/zernike_model.py rename to astrophot/models/zernike.py From 66d1c2309afdd1e1c25c48b96bd221f430ebfedd Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 24 Jun 2025 11:33:17 -0400 Subject: [PATCH 030/191] getting all models to run --- astrophot/models/_shared_methods.py | 9 +- astrophot/models/airy.py | 4 +- astrophot/models/base.py | 7 +- astrophot/models/edgeon.py | 6 +- astrophot/models/eigen.py | 6 +- astrophot/models/exponential.py | 6 +- astrophot/models/flatsky.py | 2 + astrophot/models/func/gaussian.py | 4 +- astrophot/models/func/spline.py | 9 +- astrophot/models/group_model_object.py | 16 +- astrophot/models/mixins/__init__.py | 11 +- astrophot/models/mixins/brightness.py | 175 +-- astrophot/models/mixins/gaussian.py | 2 +- astrophot/models/mixins/moffat.py | 12 +- astrophot/models/mixins/spline.py | 9 +- astrophot/models/mixins/transform.py | 172 +++ astrophot/models/model_object.py | 7 +- astrophot/models/multi_gaussian_expansion.py | 1 + astrophot/models/pixelated_psf.py | 8 +- astrophot/models/planesky.py | 2 + astrophot/models/psf_model_object.py | 7 + astrophot/models/sersic.py | 6 +- astrophot/models/spline.py | 2 - astrophot/models/zernike.py | 2 +- astrophot/param/param.py | 15 +- astrophot/plots/profile.py | 24 +- docs/source/tutorials/GroupModels.ipynb | 18 +- docs/source/tutorials/ModelZoo.ipynb | 1256 +++--------------- 28 files changed, 441 insertions(+), 1357 deletions(-) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index ff9bdd9c..2db0f10b 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -4,6 +4,7 @@ from scipy.optimize import minimize from ..utils.decorators import ignore_numpy_warnings +from ..utils.interpolate import default_prof from .. import AP_config @@ -36,7 +37,9 @@ def _sample_image( # Bin fluxes by radius if rad_bins is None: - rad_bins = np.logspace(np.log10(R.min() * 0.9), np.log10(R.max() * 1.1), 11) + rad_bins = np.logspace( + np.log10(R.min() * 0.9 + image.pixel_length / 2), np.log10(R.max() * 1.1), 11 + ) else: rad_bins = np.array(rad_bins) I = ( @@ -80,7 +83,7 @@ def parametric_initialize(model, target, prof_func, params, x0_func): return # Get the sub-image area corresponding to the model image - R, I, S = _sample_image(target, model.transform_coordinates, model.radial_metric) + R, I, S = _sample_image(target, model.transform_coordinates, model.radius_metric) x0 = list(x0_func(model, R, I)) for i, param in enumerate(params): @@ -137,7 +140,7 @@ def parametric_segment_initialize( R, I, S = _sample_image( target, model.transform_coordinates, - model.radial_metric, + model.radius_metric, angle=model.angular_metric, angle_range=angle_range, ) diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index f0a7e178..45a4e160 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -3,6 +3,7 @@ from ..utils.decorators import ignore_numpy_warnings from .psf_model_object import PSFModel from .mixins import RadialMixin +from ..param import forward __all__ = ("AiryPSF",) @@ -63,6 +64,7 @@ def initialize(self): self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixel_length self.aRL.uncertainty = self.aRL.value * self.default_uncertainty + @forward def radial_model(self, R, I0, aRL): - x = 2 * torch.pi * aRL * R + x = 2 * torch.pi * aRL * (R + self.softening) return I0 * (2 * torch.special.bessel_j1(x) / x) ** 2 diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 69930bc2..ce0468f9 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -169,7 +169,8 @@ def build_parameter_specs(self, kwargs) -> dict: if isinstance(kwargs[p], dict): parameter_specs[p].update(kwargs.pop(p)) else: - parameter_specs[p]["value"] = kwargs.pop(p) + parameter_specs[p]["dynamic_value"] = kwargs.pop(p) + parameter_specs[p].pop("value", None) return parameter_specs @@ -269,9 +270,11 @@ def List_Models(cls, usable: Optional[bool] = None, types: bool = False) -> set: MODELS = func.all_subclasses(cls) result = set() for model in MODELS: + if not (model.__dict__.get("usable", False) is usable or usable is None): + continue if types: result.add(model.model_type) - elif model.usable is usable or usable is None: + else: result.add(model) return result diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index b1eae026..bc113577 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -4,6 +4,7 @@ from .model_object import ComponentModel from ..utils.decorators import ignore_numpy_warnings from . import func +from ..param import forward __all__ = ["EdgeonModel", "EdgeonSech", "EdgeonIsothermal"] @@ -20,7 +21,7 @@ class EdgeonModel(ComponentModel): _parameter_specs = { "PA": { "units": "radians", - "limits": (0, np.pi), + "valid": (0, np.pi), "cyclic": True, "uncertainty": 0.06, }, @@ -52,6 +53,7 @@ def initialize(self): self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi self.PA.uncertainty = self.PA.value * self.default_uncertainty + @forward def transform_coordinates(self, x, y, PA): x, y = super().transform_coordinates(x, y) return func.rotate(PA - np.pi / 2, x, y) @@ -90,6 +92,7 @@ def initialize(self): self.hs.value = torch.max(self.window.shape) * target_area.pixel_length * 0.1 self.hs.uncertainty = self.hs.value / 2 + @forward def brightness(self, x, y, I0, hs): x, y = self.transform_coordinates(x, y) return I0 * self.radial_model(x) / (torch.cosh((y + self.softening) / hs) ** 2) @@ -114,6 +117,7 @@ def initialize(self): self.rs.value = torch.max(self.window.shape) * self.target.pixel_length * 0.4 self.rs.uncertainty = self.rs.value / 2 + @forward def radial_model(self, R, rs): Rscaled = torch.abs(R / rs) return ( diff --git a/astrophot/models/eigen.py b/astrophot/models/eigen.py index c705bf2c..a45e54f9 100644 --- a/astrophot/models/eigen.py +++ b/astrophot/models/eigen.py @@ -6,6 +6,7 @@ from ..utils.interpolate import interp2d from .. import AP_config from ..errors import SpecificationConflict +from ..param import forward __all__ = ["EigenPSF"] @@ -51,9 +52,7 @@ def __init__(self, *args, eigen_basis=None, **kwargs): "EigenPSF model requires 'eigen_basis' argument to be provided." ) self.eigen_basis = torch.as_tensor( - kwargs["eigen_basis"], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + eigen_basis, dtype=AP_config.ap_dtype, device=AP_config.ap_device ) @torch.no_grad() @@ -70,6 +69,7 @@ def initialize(self): self.weights.dynamic_value = 1 / np.arange(len(self.eigen_basis)) self.weights.uncertainty = self.weights.value * self.default_uncertainty + @forward def brightness(self, x, y, flux, weights): x, y = self.transform_coordinates(x, y) diff --git a/astrophot/models/exponential.py b/astrophot/models/exponential.py index b291c272..dd99899e 100644 --- a/astrophot/models/exponential.py +++ b/astrophot/models/exponential.py @@ -47,15 +47,15 @@ class ExponentialPSF(ExponentialMixin, RadialMixin, PSFModel): usable = True -class ExponentialSuperEllipse(ExponentialMixin, SuperEllipseMixin, GalaxyModel): +class ExponentialSuperEllipse(ExponentialMixin, RadialMixin, SuperEllipseMixin, GalaxyModel): usable = True -class ExponentialFourierEllipse(ExponentialMixin, FourierEllipseMixin, GalaxyModel): +class ExponentialFourierEllipse(ExponentialMixin, RadialMixin, FourierEllipseMixin, GalaxyModel): usable = True -class ExponentialWarp(ExponentialMixin, WarpMixin, GalaxyModel): +class ExponentialWarp(ExponentialMixin, RadialMixin, WarpMixin, GalaxyModel): usable = True diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 9485d869..c61c67c0 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -4,6 +4,7 @@ from ..utils.decorators import ignore_numpy_warnings from .sky_model_object import SkyModel +from ..param import forward __all__ = ["FlatSky"] @@ -37,5 +38,6 @@ def initialize(self): iqr(dat, rng=(16, 84)) / (2.0 * self.target.pixel_area.item()) ) / np.sqrt(np.prod(self.window.shape)) + @forward def brightness(self, x, y, I): return torch.ones_like(x) * I diff --git a/astrophot/models/func/gaussian.py b/astrophot/models/func/gaussian.py index 382dded1..87b8b42d 100644 --- a/astrophot/models/func/gaussian.py +++ b/astrophot/models/func/gaussian.py @@ -1,6 +1,8 @@ import torch import numpy as np +sq_2pi = np.sqrt(2 * np.pi) + def gaussian(R, sigma, flux): """Gaussian 1d profile function, specifically designed for pytorch @@ -11,4 +13,4 @@ def gaussian(R, sigma, flux): sigma: standard deviation of the gaussian in the same units as R I0: central surface density """ - return (flux / (torch.sqrt(2 * np.pi) * sigma)) * torch.exp(-0.5 * torch.pow(R / sigma, 2)) + return (flux / (sq_2pi * sigma)) * torch.exp(-0.5 * torch.pow(R / sigma, 2)) diff --git a/astrophot/models/func/spline.py b/astrophot/models/func/spline.py index deef0c44..cf818c5f 100644 --- a/astrophot/models/func/spline.py +++ b/astrophot/models/func/spline.py @@ -49,7 +49,7 @@ def cubic_spline_torch(x: torch.Tensor, y: torch.Tensor, xs: torch.Tensor) -> to return ret -def spline(R, profR, profI): +def spline(R, profR, profI, extend="zeros"): """Spline 1d profile function, cubic spline between points up to second last point beyond which is linear @@ -59,5 +59,10 @@ def spline(R, profR, profI): profI: surface density values for the surface density profile """ I = cubic_spline_torch(profR, profI, R.view(-1)).reshape(*R.shape) - I[R > profR[-1]] = 0 + if extend == "zeros": + I[R > profR[-1]] = 0 + elif extend == "const": + I[R > profR[-1]] = profI[-1] + else: + raise ValueError(f"Unknown extend option: {extend}. Use 'zeros' or 'const'.") return I diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index b9dcbb7b..c9d48fdd 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -58,7 +58,7 @@ def update_window(self): """ if isinstance(self.target, ImageList): # WindowList if target is a TargetImageList new_window = [None] * len(self.target.images) - for model in self.models.values(): + for model in self.models: if isinstance(model.target, ImageList): for target, window in zip(model.target, model.window): index = self.target.index(target) @@ -79,7 +79,7 @@ def update_window(self): new_window = WindowList(new_window) else: new_window = None - for model in self.models.values(): + for model in self.models: if new_window is None: new_window = model.window.copy() else: @@ -97,7 +97,7 @@ def initialize(self): """ super().initialize() - for model in self.models.values(): + for model in self.models: model.initialize() def fit_mask(self) -> torch.Tensor: @@ -111,7 +111,7 @@ def fit_mask(self) -> torch.Tensor: subtarget = self.target[self.window] if isinstance(self.target, ImageList): mask = tuple(torch.ones_like(submask) for submask in subtarget.mask) - for model in self.models.values(): + for model in self.models: model_subtarget = model.target[model.window] model_fit_mask = model.fit_mask() if isinstance(model.target, ImageList): @@ -127,7 +127,7 @@ def fit_mask(self) -> torch.Tensor: mask[index][group_indices] &= model_fit_mask[model_indices] else: mask = torch.ones_like(subtarget.mask) - for model in self.models.values(): + for model in self.models: model_subtarget = model.target[model.window] group_indices = subtarget.get_indices(model_subtarget) model_indices = model_subtarget.get_indices(subtarget) @@ -153,7 +153,7 @@ def sample( else: image = self.target[window].model_image() - for model in self.models.values(): + for model in self.models: if window is None: use_window = model.window elif isinstance(image, ImageList) and isinstance(model.target, ImageList): @@ -207,7 +207,7 @@ def jacobian( else: jac_img = pass_jacobian - for model in self.models.values(): + for model in self.models: model.jacobian( pass_jacobian=jac_img, window=window, @@ -216,7 +216,7 @@ def jacobian( return jac_img def __iter__(self): - return (mod for mod in self.models.values()) + return (mod for mod in self.models) @property def target(self) -> Optional[Union[TargetImage, TargetImageList]]: diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index 7a937bce..12d36ac2 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -1,12 +1,5 @@ -from .brightness import ( - RadialMixin, - WedgeMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, -) -from .transform import InclinedMixin +from .brightness import RadialMixin, WedgeMixin, RayMixin +from .transform import InclinedMixin, SuperEllipseMixin, FourierEllipseMixin, WarpMixin from .sersic import SersicMixin, iSersicMixin from .exponential import ExponentialMixin, iExponentialMixin from .moffat import MoffatMixin, iMoffatMixin diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index caecab3d..8154b21d 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -2,10 +2,6 @@ import numpy as np from ...param import forward -from .. import func -from ...utils.decorators import ignore_numpy_warnings -from ...utils.interpolate import default_prof -from ... import AP_config class RadialMixin: @@ -19,175 +15,6 @@ def brightness(self, x, y): return self.radial_model(self.radius_metric(x, y)) -class SuperEllipseMixin: - """Expanded galaxy model which includes a superellipse transformation - in its radius metric. This allows for the expression of "boxy" and - "disky" isophotes instead of pure ellipses. This is a common - extension of the standard elliptical representation, especially - for early-type galaxies. The functional form for this is: - - R = (|X|^C + |Y|^C)^(1/C) - - where R is the new distance metric, X Y are the coordinates, and C - is the coefficient for the superellipse. C can take on any value - greater than zero where C = 2 is the standard distance metric, 0 < - C < 2 creates disky or pointed perturbations to an ellipse, and C - > 2 transforms an ellipse to be more boxy. - - Parameters: - C: superellipse distance metric parameter. - - """ - - _model_type = "superellipse" - _parameter_specs = { - "C": {"units": "none", "value": 2.0, "uncertainty": 1e-2, "valid": (0, None)}, - } - - def radius_metric(self, x, y, C): - return torch.pow(x.abs().pow(C) + y.abs().pow(C), 1.0 / C) - - -class FourierEllipseMixin: - """Expanded galaxy model which includes a Fourier transformation in - its radius metric. This allows for the expression of arbitrarily - complex isophotes instead of pure ellipses. This is a common - extension of the standard elliptical representation. The form of - the Fourier perturbations is: - - R' = R * exp(sum_m(a_m * cos(m * theta + phi_m))) - - where R' is the new radius value, R is the original ellipse - radius, a_m is the amplitude of the m'th Fourier mode, m is the - index of the Fourier mode, theta is the angle around the ellipse, - and phi_m is the phase of the m'th fourier mode. This - representation is somewhat different from other Fourier mode - implementations where instead of an expoenntial it is just 1 + - sum_m(...), we opt for this formulation as it is more numerically - stable. It cannot ever produce negative radii, but to first order - the two representation are the same as can be seen by a Taylor - expansion of exp(x) = 1 + x + O(x^2). - - One can create extremely complex shapes using different Fourier - modes, however usually it is only low order modes that are of - interest. For intuition, the first Fourier mode is roughly - equivalent to a lopsided galaxy, one side will be compressed and - the opposite side will be expanded. The second mode is almost - never used as it is nearly degenerate with ellipticity. The third - mode is an alternate kind of lopsidedness for a galaxy which makes - it somewhat triangular, meaning that it is wider on one side than - the other. The fourth mode is similar to a boxyness/diskyness - parameter which tends to make more pronounced peanut shapes since - it is more rounded than a superellipse representation. Modes - higher than 4 are only useful in very specialized situations. In - general one should consider carefully why the Fourier modes are - being used for the science case at hand. - - Parameters: - am: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. - phi_m: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) - - """ - - _model_type = "fourier" - _parameter_specs = { - "am": {"units": "none"}, - "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True}, - } - _options = ("modes",) - - def __init__(self, *args, modes=(3, 4), **kwargs): - super().__init__(*args, **kwargs) - self.modes = torch.tensor(modes, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - @forward - def radius_metric(self, x, y, am, phim): - R = super().radius_metric(x, y) - theta = self.angular_metric(x, y) - return R * torch.exp( - torch.sum( - am.unsqueeze(-1) - * torch.cos(self.modes.unsqueeze(-1) * theta.flatten() + phim.unsqueeze(-1)), - 0, - ).reshape(x.shape) - ) - - @torch.no_grad() - @ignore_numpy_warnings - def initialize(self): - super().initialize() - - if self.am.value is None: - self.am.dynamic_value = np.zeros(len(self.modes)) - self.am.uncertainty = self.default_uncertainty * np.ones(len(self.modes)) - if self.phim.value is None: - self.phim.value = np.zeros(len(self.modes)) - self.phim.uncertainty = (10 * np.pi / 180) * np.ones(len(self.modes)) - - -class WarpMixin: - """Galaxy model which includes radially varrying PA and q - profiles. This works by warping the coordinates using the same - transform for a global PA/q except applied to each pixel - individually. In the limit that PA and q are a constant, this - recovers a basic galaxy model with global PA/q. However, a linear - PA profile will give a spiral appearance, variations of PA/q - profiles can create complex galaxy models. The form of the - coordinate transformation looks like: - - X, Y = meshgrid(image) - R = sqrt(X^2 + Y^2) - X', Y' = Rot(theta(R), X, Y) - Y'' = Y' / q(R) - - where the definitions are the same as for a regular galaxy model, - except now the theta is a function of radius R (before - transformation) and the axis ratio q is also a function of radius - (before the transformation). - - Parameters: - q(R): Tensor of axis ratio values for axis ratio spline - PA(R): Tensor of position angle values as input to the spline - - """ - - _model_type = "warp" - _parameter_specs = { - "q_R": {"units": "b/a", "valid": (0.0, 1), "uncertainty": 0.04}, - "PA_R": { - "units": "radians", - "valid": (0, np.pi), - "cyclic": True, - "uncertainty": 0.08, - }, - } - - @torch.no_grad() - @ignore_numpy_warnings - def initialize(self): - super().initialize() - - if self.PA_R.value is None: - if self.PA_R.prof is None: - self.PA_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) - self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 - self.PA_R.uncertainty = (10 * np.pi / 180) * torch.ones_like(self.PA_R.value) - if self.q_R.value is None: - if self.q_R.prof is None: - self.q_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) - self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 - self.q_R.uncertainty = self.default_uncertainty * self.q_R.value - - @forward - def transform_coordinates(self, x, y, q_R, PA_R): - x, y = super().transform_coordinates(x, y) - R = self.radius_metric(x, y) - PA = func.spline(R, self.PA_R.prof, PA_R) - q = func.spline(R, self.q_R.prof, q_R) - x, y = func.rotate(PA, x, y) - return x, y / q - - class WedgeMixin: """Variant of the ray model where no smooth transition is performed between regions as a function of theta, instead there is a sharp @@ -268,7 +95,7 @@ def polar_model(self, R, T): angles = (T + cycle / 2 - v[s]) % cycle - cycle / 2 indices = (angles >= -w) & (angles < w) weights = (torch.cos(angles[indices] * self.segments) + 1) / 2 - model[indices] += self.iradial_model(s, R[indices]) + model[indices] += weights * self.iradial_model(s, R[indices]) weight[indices] += weights return model / weight diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 9a12213a..8f2fd77c 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], F[0] + return R[4], 10 ** F[0] class GaussianMixin: diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 6ca6a9e3..153710c1 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -8,15 +8,15 @@ def _x0_func(model_params, R, F): - return 2.0, R[4], F[0] + return 2.0, R[4], 10 ** F[0] class MoffatMixin: _model_type = "moffat" _parameter_specs = { - "n": {"units": "none", "limits": (0.1, 10), "uncertainty": 0.05}, - "Rd": {"units": "arcsec", "limits": (0, None)}, + "n": {"units": "none", "valid": (0.1, 10), "uncertainty": 0.05}, + "Rd": {"units": "arcsec", "valid": (0, None)}, "I0": {"units": "flux/arcsec^2"}, } @@ -26,7 +26,7 @@ def initialize(self): super().initialize() parametric_initialize( - self, self.target[self.window], moffat_np, ("n", "Re", "Ie"), _x0_func + self, self.target[self.window], moffat_np, ("n", "Rd", "I0"), _x0_func ) @forward @@ -38,8 +38,8 @@ class iMoffatMixin: _model_type = "moffat" _parameter_specs = { - "n": {"units": "none", "limits": (0.1, 10), "uncertainty": 0.05}, - "Rd": {"units": "arcsec", "limits": (0, None)}, + "n": {"units": "none", "valid": (0.1, 10), "uncertainty": 0.05}, + "Rd": {"units": "arcsec", "valid": (0, None)}, "I0": {"units": "flux/arcsec^2"}, } diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 77c4c660..fdc96408 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -11,9 +11,7 @@ class SplineMixin: _model_type = "spline" - parameter_specs = { - "I_R": {"units": "flux/arcsec^2"}, - } + _parameter_specs = {"I_R": {"units": "flux/arcsec^2"}} @torch.no_grad() @ignore_numpy_warnings @@ -42,15 +40,14 @@ def initialize(self): @forward def radial_model(self, R, I_R): + print(self.I_R.prof, I_R) return func.spline(R, self.I_R.prof, I_R) class iSplineMixin: _model_type = "spline" - parameter_specs = { - "I_R": {"units": "flux/arcsec^2"}, - } + _parameter_specs = {"I_R": {"units": "flux/arcsec^2"}} @torch.no_grad() @ignore_numpy_warnings diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index f092ba1e..f1ce61f2 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -2,8 +2,10 @@ import torch from ...utils.decorators import ignore_numpy_warnings +from ...utils.interpolate import default_prof from ...param import forward from .. import func +from ... import AP_config class InclinedMixin: @@ -72,3 +74,173 @@ def transform_coordinates(self, x, y, PA, q): x, y = super().transform_coordinates(x, y) x, y = func.rotate(-(PA + np.pi / 2), x, y) return x, y / q + + +class SuperEllipseMixin: + """Expanded galaxy model which includes a superellipse transformation + in its radius metric. This allows for the expression of "boxy" and + "disky" isophotes instead of pure ellipses. This is a common + extension of the standard elliptical representation, especially + for early-type galaxies. The functional form for this is: + + R = (|X|^C + |Y|^C)^(1/C) + + where R is the new distance metric, X Y are the coordinates, and C + is the coefficient for the superellipse. C can take on any value + greater than zero where C = 2 is the standard distance metric, 0 < + C < 2 creates disky or pointed perturbations to an ellipse, and C + > 2 transforms an ellipse to be more boxy. + + Parameters: + C: superellipse distance metric parameter. + + """ + + _model_type = "superellipse" + _parameter_specs = { + "C": {"units": "none", "value": 2.0, "uncertainty": 1e-2, "valid": (0, None)}, + } + + @forward + def radius_metric(self, x, y, C): + return torch.pow(x.abs().pow(C) + y.abs().pow(C), 1.0 / C) + + +class FourierEllipseMixin: + """Expanded galaxy model which includes a Fourier transformation in + its radius metric. This allows for the expression of arbitrarily + complex isophotes instead of pure ellipses. This is a common + extension of the standard elliptical representation. The form of + the Fourier perturbations is: + + R' = R * exp(sum_m(a_m * cos(m * theta + phi_m))) + + where R' is the new radius value, R is the original ellipse + radius, a_m is the amplitude of the m'th Fourier mode, m is the + index of the Fourier mode, theta is the angle around the ellipse, + and phi_m is the phase of the m'th fourier mode. This + representation is somewhat different from other Fourier mode + implementations where instead of an expoenntial it is just 1 + + sum_m(...), we opt for this formulation as it is more numerically + stable. It cannot ever produce negative radii, but to first order + the two representation are the same as can be seen by a Taylor + expansion of exp(x) = 1 + x + O(x^2). + + One can create extremely complex shapes using different Fourier + modes, however usually it is only low order modes that are of + interest. For intuition, the first Fourier mode is roughly + equivalent to a lopsided galaxy, one side will be compressed and + the opposite side will be expanded. The second mode is almost + never used as it is nearly degenerate with ellipticity. The third + mode is an alternate kind of lopsidedness for a galaxy which makes + it somewhat triangular, meaning that it is wider on one side than + the other. The fourth mode is similar to a boxyness/diskyness + parameter which tends to make more pronounced peanut shapes since + it is more rounded than a superellipse representation. Modes + higher than 4 are only useful in very specialized situations. In + general one should consider carefully why the Fourier modes are + being used for the science case at hand. + + Parameters: + am: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. + phi_m: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) + + """ + + _model_type = "fourier" + _parameter_specs = { + "am": {"units": "none"}, + "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True}, + } + _options = ("modes",) + + def __init__(self, *args, modes=(3, 4), **kwargs): + super().__init__(*args, **kwargs) + self.modes = torch.tensor(modes, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + + @forward + def radius_metric(self, x, y, am, phim): + R = super().radius_metric(x, y) + theta = self.angular_metric(x, y) + return R * torch.exp( + torch.sum( + am.unsqueeze(-1) + * torch.cos(self.modes.unsqueeze(-1) * theta.flatten() + phim.unsqueeze(-1)), + 0, + ).reshape(x.shape) + ) + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if self.am.value is None: + self.am.dynamic_value = np.zeros(len(self.modes)) + self.am.uncertainty = self.default_uncertainty * np.ones(len(self.modes)) + if self.phim.value is None: + self.phim.value = np.zeros(len(self.modes)) + self.phim.uncertainty = (10 * np.pi / 180) * np.ones(len(self.modes)) + + +class WarpMixin: + """Galaxy model which includes radially varrying PA and q + profiles. This works by warping the coordinates using the same + transform for a global PA/q except applied to each pixel + individually. In the limit that PA and q are a constant, this + recovers a basic galaxy model with global PA/q. However, a linear + PA profile will give a spiral appearance, variations of PA/q + profiles can create complex galaxy models. The form of the + coordinate transformation looks like: + + X, Y = meshgrid(image) + R = sqrt(X^2 + Y^2) + X', Y' = Rot(theta(R), X, Y) + Y'' = Y' / q(R) + + where the definitions are the same as for a regular galaxy model, + except now the theta is a function of radius R (before + transformation) and the axis ratio q is also a function of radius + (before the transformation). + + Parameters: + q(R): Tensor of axis ratio values for axis ratio spline + PA(R): Tensor of position angle values as input to the spline + + """ + + _model_type = "warp" + _parameter_specs = { + "q_R": {"units": "b/a", "valid": (0.0, 1), "uncertainty": 0.04}, + "PA_R": { + "units": "radians", + "valid": (0, np.pi), + "cyclic": True, + "uncertainty": 0.08, + }, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if self.PA_R.value is None: + if self.PA_R.prof is None: + self.PA_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 + self.PA_R.uncertainty = (10 * np.pi / 180) * torch.ones_like(self.PA_R.value) + if self.q_R.value is None: + if self.q_R.prof is None: + self.q_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 + self.q_R.uncertainty = self.default_uncertainty * self.q_R.value + + @forward + def transform_coordinates(self, x, y, q_R, PA_R): + x, y = super().transform_coordinates(x, y) + R = self.radius_metric(x, y) + PA = func.spline(R, self.PA_R.prof, PA_R, extend="const") + q = func.spline(R, self.q_R.prof, q_R, extend="const") + x, y = func.rotate(PA, x, y) + return x, y / q diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index b0412090..60fd4d4e 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -89,11 +89,12 @@ def psf(self, val): self._psf = val elif isinstance(val, Model): self._psf = PSFImage( - data=lambda p: p.psf_model().data.value, pixelscale=val.target.pixelscale + name="psf", data=val.target.data.value, pixelscale=val.target.pixelscale ) - self._psf.link("psf_model", val) + self._psf.data = lambda p: p.psf_model().data.value + self._psf.data.link("psf_model", val) else: - self._psf = PSFImage(data=val, pixelscale=self.target.pixelscale) + self._psf = PSFImage(name="psf", data=val, pixelscale=self.target.pixelscale) AP_config.ap_logger.warning( "Setting PSF with pixel image, assuming target pixelscale is the same as " "PSF pixelscale. To remove this warning, set PSFs as an ap.image.PSF_Image " diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 0c3efbbe..9b43c6a0 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -39,6 +39,7 @@ def __init__(self, *args, n_components=None, **kwargs): for key in ("q", "sigma", "flux"): if self[key].value is not None: self.n_components = self[key].value.shape[0] + break else: raise ValueError( f"n_components must be specified when initial values is not defined." diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index c250fcdd..3372f241 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -4,6 +4,7 @@ from ..utils.decorators import ignore_numpy_warnings from ..utils.interpolate import interp2d from caskade import OverrideParam +from ..param import forward __all__ = ["PixelatedPSF"] @@ -37,9 +38,7 @@ class PixelatedPSF(PSFModel): """ _model_type = "pixelated" - _parameter_specs = { - "pixels": {"units": "flux"}, - } + _parameter_specs = {"pixels": {"units": "flux/arcsec^2"}} usable = True @torch.no_grad() @@ -48,9 +47,10 @@ def initialize(self): super().initialize() if self.pixels.value is None: target_area = self.target[self.window] - self.pixels.dynamic_value = target_area.data.value + self.pixels.dynamic_value = target_area.data.value / target_area.pixel_area self.pixels.uncertainty = torch.abs(self.pixels.value) * self.default_uncertainty + @forward def brightness(self, x, y, pixels, center): with OverrideParam(self.target.crtan, center): pX, pY = self.target.plane_to_pixel(x, y) diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index 1eb5ea50..c3f419bf 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -4,6 +4,7 @@ from .sky_model_object import SkyModel from ..utils.decorators import ignore_numpy_warnings +from ..param import forward __all__ = ["PlaneSky"] @@ -53,5 +54,6 @@ def initialize(self): self.default_uncertainty, ] + @forward def brightness(self, x, y, I0, delta): return I0 + x * delta[0] + y * delta[1] diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 9f892325..d60f1e47 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -43,6 +43,9 @@ class PSFModel(SampleMixin, Model): # Parameters which are treated specially by the model object and should not be updated directly when initializing _options = ("softening", "normalize_psf") + def initialize(self): + pass + @forward def transform_coordinates(self, x, y, center): return x - center[0], y - center[1] @@ -102,3 +105,7 @@ def target(self, target): elif not isinstance(target, PSFImage): raise InvalidTarget(f"Target for PSF_Model must be a PSF_Image, not {type(target)}") self._target = target + + @forward + def __call__(self) -> ModelImage: + return self.sample() diff --git a/astrophot/models/sersic.py b/astrophot/models/sersic.py index 8a25bc7e..89cb3131 100644 --- a/astrophot/models/sersic.py +++ b/astrophot/models/sersic.py @@ -76,15 +76,15 @@ def total_flux(self, Ie, n, Re): return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) -class SersicSuperEllipse(SersicMixin, SuperEllipseMixin, GalaxyModel): +class SersicSuperEllipse(SersicMixin, RadialMixin, SuperEllipseMixin, GalaxyModel): usable = True -class SersicFourierEllipse(SersicMixin, FourierEllipseMixin, GalaxyModel): +class SersicFourierEllipse(SersicMixin, RadialMixin, FourierEllipseMixin, GalaxyModel): usable = True -class SersicWarp(SersicMixin, WarpMixin, GalaxyModel): +class SersicWarp(SersicMixin, RadialMixin, WarpMixin, GalaxyModel): usable = True diff --git a/astrophot/models/spline.py b/astrophot/models/spline.py index c6be5f6d..bbdc1d33 100644 --- a/astrophot/models/spline.py +++ b/astrophot/models/spline.py @@ -22,8 +22,6 @@ ] -# First Order -###################################################################### class SplineGalaxy(SplineMixin, RadialMixin, GalaxyModel): """Basic galaxy model with a spline radial light profile. The light profile is defined as a cubic spline interpolation of the diff --git a/astrophot/models/zernike.py b/astrophot/models/zernike.py index 97a4d161..22c343cd 100644 --- a/astrophot/models/zernike.py +++ b/astrophot/models/zernike.py @@ -33,7 +33,7 @@ def initialize(self): self.nm_list = self.iter_nm(self.order_n) # Set the scale radius for the Zernike area if self.r_scale is None: - self.r_scale = torch.max(self.window.shape) / 2 + self.r_scale = max(self.window.shape) / 2 # Check if user has already set the coefficients if self.Anm.value is not None: diff --git a/astrophot/param/param.py b/astrophot/param/param.py index b09efb9c..28707a9b 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -8,10 +8,12 @@ class Param(CParam): This class is used to define parameters for models in the AstroPhot package. """ - def __init__(self, *args, uncertainty=None, **kwargs): + def __init__(self, *args, uncertainty=None, prof=None, **kwargs): super().__init__(*args, **kwargs) self.uncertainty = uncertainty self.saveattrs.add("uncertainty") + self.prof = prof + self.saveattrs.add("prof") @property def uncertainty(self): @@ -23,3 +25,14 @@ def uncertainty(self, uncertainty): self._uncertainty = None else: self._uncertainty = torch.as_tensor(uncertainty) + + @property + def prof(self): + return self._prof + + @prof.setter + def prof(self, prof): + if prof is None: + self._prof = None + else: + self._prof = torch.as_tensor(prof) diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 577cf91c..11e26808 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -152,8 +152,8 @@ def radial_median_profile( "elinewidth": 1, "color": main_pallet["primary2"], "label": "data profile", + **plot_kwargs, } - kwargs.update(plot_kwargs) ax.errorbar( (Rbins[:-1] + Rbins[1:]) / 2, stat, @@ -175,24 +175,23 @@ def ray_light_profile( rad_unit="arcsec", extend_profile=1.0, resolution=1000, - doassert=True, ): xx = torch.linspace( 0, - torch.max(model.window.shape / 2) * extend_profile, + max(model.window.shape) * model.target.pixel_length * extend_profile / 2, int(resolution), dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) - for r in range(model.rays): - if model.rays <= 5: + for r in range(model.segments): + if model.segments <= 3: col = main_pallet[f"primary{r+1}"] else: - col = cmap_grad(r / model.rays) + col = cmap_grad(r / model.segments) with torch.no_grad(): ax.plot( xx.detach().cpu().numpy(), - np.log10(model.iradial_model(r, xx).detach().cpu().numpy()), + np.log10(model.iradial_model(r, xx, params=()).detach().cpu().numpy()), linewidth=2, color=col, label=f"{model.name} profile {r}", @@ -210,24 +209,23 @@ def wedge_light_profile( rad_unit="arcsec", extend_profile=1.0, resolution=1000, - doassert=True, ): xx = torch.linspace( 0, - torch.max(model.window.shape / 2) * extend_profile, + max(model.window.shape) * model.target.pixel_length * extend_profile / 2, int(resolution), dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) - for r in range(model.wedges): - if model.wedges <= 5: + for r in range(model.segments): + if model.segments <= 3: col = main_pallet[f"primary{r+1}"] else: - col = cmap_grad(r / model.wedges) + col = cmap_grad(r / model.segments) with torch.no_grad(): ax.plot( xx.detach().cpu().numpy(), - np.log10(model.iradial_model(r, xx).detach().cpu().numpy()), + np.log10(model.iradial_model(r, xx, params=()).detach().cpu().numpy()), linewidth=2, color=col, label=f"{model.name} profile {r}", diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index d2d5ac85..73ea3cf2 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -74,11 +74,11 @@ "outputs": [], "source": [ "pixelscale = 0.262\n", - "target = ap.image.Target_Image(\n", + "target = ap.image.TargetImage(\n", " data=target_data,\n", " pixelscale=pixelscale,\n", " zeropoint=22.5,\n", - " variance=\"auto\", # np.ones_like(target_data) * np.std(target_data[segmap == 0]) ** 2,\n", + " variance=\"auto\", # this will estimate the variance from the data\n", ")\n", "fig2, ax2 = plt.subplots(figsize=(8, 8))\n", "ap.plots.target_image(fig2, ax2, target)\n", @@ -124,26 +124,24 @@ "seg_models = []\n", "for win in windows:\n", " seg_models.append(\n", - " ap.models.AstroPhot_Model(\n", + " ap.models.Model(\n", " name=f\"object {win:02d}\",\n", " window=windows[win],\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " parameters={\n", - " \"center\": np.array(centers[win]) * pixelscale,\n", - " \"PA\": PAs[win],\n", - " \"q\": qs[win],\n", - " },\n", + " center=np.array(centers[win]) * pixelscale,\n", + " PA=PAs[win],\n", + " q=qs[win],\n", " )\n", " )\n", - "sky = ap.models.AstroPhot_Model(\n", + "sky = ap.models.Model(\n", " name=f\"sky level\",\n", " model_type=\"flat sky model\",\n", " target=target,\n", ")\n", "\n", "# We build the group model just like any other, except we pass a list of other models\n", - "groupmodel = ap.models.AstroPhot_Model(\n", + "groupmodel = ap.models.Model(\n", " name=\"group\", models=[sky] + seg_models, target=target, model_type=\"group model\"\n", ")\n", "\n", diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index cc8a5307..948a57fa 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -28,7 +28,7 @@ "import matplotlib.pyplot as plt\n", "\n", "%matplotlib inline\n", - "basic_target = ap.image.Target_Image(data=np.zeros((100, 100)), pixelscale=1, zeropoint=20)" + "basic_target = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1, zeropoint=20)" ] }, { @@ -51,11 +51,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"flat sky model\", parameters={\"center\": [50, 50], \"F\": 1}, target=basic_target\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", + "M = ap.models.Model(model_type=\"flat sky model\", center=[50, 50], I=1, target=basic_target)\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(figsize=(7, 6))\n", @@ -77,13 +73,13 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"plane sky model\",\n", - " parameters={\"center\": [50, 50], \"F\": 10, \"delta\": [1e-2, 2e-2]},\n", + " center=[50, 50],\n", + " I0=10,\n", + " delta=[1e-2, 2e-2],\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(figsize=(7, 6))\n", @@ -122,7 +118,7 @@ "psf += np.random.normal(scale=psf / 3)\n", "psf[psf < 0] = ap.utils.initialize.gaussian_psf(3.0, 101, 1.0)[psf < 0] + 1e-10\n", "\n", - "psf_target = ap.image.PSF_Image(\n", + "psf_target = ap.image.PSFImage(\n", " data=psf / np.sum(psf),\n", " pixelscale=1,\n", ")\n", @@ -155,15 +151,13 @@ "wgt = np.array((0.0001, 0.01, 1.0, 0.01, 0.0001))\n", "PSF[48:53] += (sinc(x[48:53]) ** 2) * wgt.reshape((-1, 1))\n", "PSF[:, 48:53] += (sinc(x[:, 48:53]) ** 2) * wgt\n", - "PSF = ap.image.PSF_Image(data=PSF, pixelscale=psf_target.pixelscale)\n", + "PSF = ap.image.PSFImage(data=PSF, pixelscale=psf_target.pixelscale)\n", "\n", - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"pixelated psf model\",\n", " target=psf_target,\n", - " parameters={\"pixels\": np.log10(PSF.data / psf_target.pixel_area)},\n", + " pixels=PSF.data.value / psf_target.pixel_area,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -190,13 +184,9 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"gaussian psf model\", parameters={\"sigma\": 10}, target=psf_target\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", + "M = ap.models.Model(model_type=\"gaussian psf model\", sigma=10, target=psf_target)\n", "M.initialize()\n", - "\n", + "print(M)\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", "ap.plots.psf_image(fig, ax[0], M)\n", "ap.plots.radial_light_profile(fig, ax[1], M)\n", @@ -217,11 +207,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"moffat psf model\", parameters={\"n\": 2.0, \"Rd\": 10.0}, target=psf_target\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", + "M = ap.models.Model(model_type=\"moffat psf model\", n=2.0, Rd=10.0, target=psf_target)\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -246,13 +232,14 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"moffat2d psf model\",\n", - " parameters={\"n\": 2.0, \"Rd\": 10.0, \"q\": 0.7, \"PA\": 3.14 / 3},\n", + "M = ap.models.Model(\n", + " model_type=\"2d moffat psf model\",\n", + " n=2.0,\n", + " Rd=10.0,\n", + " q=0.7,\n", + " PA=3.14 / 3,\n", " target=psf_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -275,13 +262,11 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"airy psf model\",\n", - " parameters={\"aRL\": 1.0 / 20},\n", + " aRL=1.0 / 20,\n", " target=psf_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -304,11 +289,9 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"zernike psf model\", order_n=4, integrate_mode=\"none\", target=psf_target\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, axarr = plt.subplots(3, 5, figsize=(18, 10))\n", @@ -337,8 +320,8 @@ "metadata": {}, "outputs": [], "source": [ - "super_basic_target = ap.image.Target_Image(data=np.zeros((101, 101)), pixelscale=1)\n", - "Z = ap.models.AstroPhot_Model(\n", + "super_basic_target = ap.image.TargetImage(data=np.zeros((101, 101)), pixelscale=1)\n", + "Z = ap.models.Model(\n", " model_type=\"zernike psf model\", order_n=4, integrate_mode=\"none\", target=psf_target\n", ")\n", "Z.initialize()\n", @@ -348,19 +331,16 @@ " Anm[0] = 1.0\n", " Anm[i] = 1.0\n", " Z[\"Anm\"].value = Anm\n", - " basis.append(Z().data)\n", + " basis.append(Z().data.value)\n", "basis = torch.stack(basis)\n", "\n", "W = np.linspace(1, 0.1, 10)\n", - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"eigen psf model\",\n", " eigen_basis=basis,\n", - " eigen_pixelscale=1,\n", - " parameters={\"weights\": W},\n", + " weights=W,\n", " target=psf_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -395,15 +375,15 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"point model\",\n", - " parameters={\"center\": [50, 50], \"flux\": 1},\n", + " center=[50, 50],\n", + " flux=1,\n", " psf=psf_target,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", + "M.to()\n", "\n", "fig, ax = plt.subplots(figsize=(7, 6))\n", "ap.plots.model_image(fig, ax, M)\n", @@ -424,24 +404,18 @@ "metadata": {}, "outputs": [], "source": [ - "psf = ap.models.AstroPhot_Model(\n", - " model_type=\"moffat psf model\", parameters={\"n\": 2.0, \"Rd\": 10.0}, target=psf_target\n", - ")\n", + "psf = ap.models.Model(model_type=\"moffat psf model\", n=2.0, Rd=10.0, target=psf_target)\n", "psf.initialize()\n", "\n", - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"point model\",\n", - " parameters={\"center\": [50, 50], \"flux\": 1},\n", + " center=[50, 50],\n", + " flux=1,\n", " psf=psf,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", - "\n", - "# Note that the PSF model now shows up as a \"parameter\" for the point model. In fact this is just a pointer to the PSF parameter graph which you can see by printing the parameters\n", - "print(M.parameters)\n", - "\n", + "print(M)\n", "fig, ax = plt.subplots(figsize=(7, 6))\n", "ap.plots.model_image(fig, ax, M)\n", "ax.set_title(M.name)\n", @@ -472,24 +446,20 @@ "source": [ "# Here we make an arbitrary spline profile out of a sine wave and a line\n", "x = np.linspace(0, 10, 14)\n", - "spline_profile = np.sin(x * 2 + 2) / 20 + 1 - x / 20\n", + "spline_profile = list(10 ** (np.sin(x * 2 + 2) / 20 + 1 - x / 20)) + [1e-4]\n", "# Here we write down some corresponding radii for the points in the non-parametric profile. AstroPhot will make\n", "# radii to match an input profile, but it is generally better to manually provide values so you have some control\n", "# over their placement. Just note that it is assumed the first point will be at R = 0.\n", - "NP_prof = [0] + list(np.logspace(np.log10(2), np.log10(50), 13))\n", + "NP_prof = [0] + list(np.logspace(np.log10(2), np.log10(50), 13)) + [200]\n", "\n", - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"spline galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"I(R)\": {\"value\": spline_profile, \"prof\": NP_prof},\n", - " },\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " I_R={\"value\": spline_profile, \"prof\": NP_prof},\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -512,13 +482,16 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", - " parameters={\"center\": [50, 50], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"n\": 3, \"Re\": 10, \"Ie\": 1},\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=3,\n", + " Re=10,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -541,13 +514,15 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"exponential galaxy model\",\n", - " parameters={\"center\": [50, 50], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"Re\": 10, \"Ie\": 1},\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " Re=10,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -570,13 +545,15 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"gaussian galaxy model\",\n", - " parameters={\"center\": [50, 50], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"sigma\": 20, \"flux\": 1},\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " sigma=20,\n", + " flux=1,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -599,22 +576,18 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"nuker galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"Rb\": 10.0,\n", - " \"Ib\": 1.0,\n", - " \"alpha\": 4.0,\n", - " \"beta\": 3.0,\n", - " \"gamma\": -0.2,\n", - " },\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " Rb=10.0,\n", + " Ib=1.0,\n", + " alpha=4.0,\n", + " beta=3.0,\n", + " gamma=-0.2,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -639,13 +612,15 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"isothermal sech2 edgeon model\",\n", - " parameters={\"center\": [50, 50], \"PA\": 60 * np.pi / 180, \"I0\": 0.0, \"hs\": 3.0, \"rs\": 5.0},\n", + " center=[50, 50],\n", + " PA=60 * np.pi / 180,\n", + " I0=1.0,\n", + " hs=3.0,\n", + " rs=5.0,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -672,19 +647,15 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"mge model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": [0.9, 0.8, 0.6, 0.5],\n", - " \"PA\": 30 * np.pi / 180,\n", - " \"sigma\": [4.0, 8.0, 16.0, 32.0],\n", - " \"flux\": np.ones(4) / 4,\n", - " },\n", + " center=[50, 50],\n", + " q=[0.9, 0.8, 0.6, 0.5],\n", + " PA=30 * np.pi / 180,\n", + " sigma=[4.0, 8.0, 16.0, 32.0],\n", + " flux=np.ones(4) / 4,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 1, figsize=(6, 6))\n", @@ -699,42 +670,9 @@ "source": [ "## Super Ellipse Models\n", "\n", - "A super ellipse is a regular ellipse, except the radius metric changes from R = sqrt(x^2 + y^2) to the more general: R = (x^C + y^C)^1/C. The parameter C = 2 for a regular ellipse, for 0 2 the shape becomes more \"boxy.\" In AstroPhot we use the parameter C0 = C-2 for simplicity." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Spline SuperEllipse" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"spline superellipse galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"C0\": 2,\n", - " \"I(R)\": {\"value\": spline_profile, \"prof\": NP_prof},\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", + "A super ellipse is a regular ellipse, except the radius metric changes from R = sqrt(x^2 + y^2) to the more general: R = (x^C + y^C)^1/C. The parameter C = 2 for a regular ellipse, for 0 2 the shape becomes more \"boxy.\" In AstroPhot we use the parameter C0 = C-2 for simplicity.\n", "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" + "There are superellipse versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" ] }, { @@ -750,125 +688,17 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"sersic superellipse galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"C0\": 2,\n", - " \"n\": 3,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Exponential SuperEllipse" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"exponential superellipse galaxy model\",\n", - " parameters={\"center\": [50, 50], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"C0\": 2, \"Re\": 10, \"Ie\": 1},\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Gaussian SuperEllipse" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"gaussian superellipse galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"C0\": 2,\n", - " \"sigma\": 20,\n", - " \"flux\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Nuker SuperEllipse" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"nuker superellipse galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"C0\": 2,\n", - " \"Rb\": 10.0,\n", - " \"Ib\": 1.0,\n", - " \"alpha\": 4.0,\n", - " \"beta\": 3.0,\n", - " \"gamma\": -0.2,\n", - " },\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " C=4,\n", + " n=3,\n", + " Re=10,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -884,14 +714,16 @@ "source": [ "## Fourier Ellipse Models\n", "\n", - "A Fourier ellipse is a scaling on the radius values as a function of theta. It takes the form: $R' = R * exp(\\sum_m am*cos(m*theta + phim))$, where am and phim are the parameters which describe the Fourier perturbations. Using the \"modes\" argument as a tuple, users can select which Fourier modes are used. As a rough intuition: mode 1 acts like a shift of the model; mode 2 acts like ellipticity; mode 3 makes a lopsided model (triangular in the extreme); and mode 4 makes peanut/diamond perturbations. " + "A Fourier ellipse is a scaling on the radius values as a function of theta. It takes the form: $R' = R * exp(\\sum_m am*cos(m*theta + phim))$, where am and phim are the parameters which describe the Fourier perturbations. Using the \"modes\" argument as a tuple, users can select which Fourier modes are used. As a rough intuition: mode 1 acts like a shift of the model; mode 2 acts like ellipticity; mode 3 makes a lopsided model (triangular in the extreme); and mode 4 makes peanut/diamond perturbations. \n", + "\n", + "There are Fourier Ellipse versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Spline Fourier" + "### Sersic Fourier" ] }, { @@ -902,172 +734,20 @@ "source": [ "fourier_am = np.array([0.1, 0.3, -0.2])\n", "fourier_phim = np.array([10 * np.pi / 180, 0, 40 * np.pi / 180])\n", - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"spline fourier galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"I(R)\": {\"value\": spline_profile, \"prof\": NP_prof},\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Sersic Fourier" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", + "M = ap.models.Model(\n", " model_type=\"sersic fourier galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"n\": 3,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Exponential Fourier" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"exponential fourier galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Gaussian Fourier" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"gaussian fourier galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"sigma\": 20,\n", - " \"flux\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Nuker Fourier" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"nuker fourier galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"Rb\": 10.0,\n", - " \"Ib\": 1.0,\n", - " \"alpha\": 4.0,\n", - " \"beta\": 3.0,\n", - " \"gamma\": -0.2,\n", - " },\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " am=fourier_am,\n", + " phim=fourier_phim,\n", + " modes=(2, 3, 4),\n", + " n=3,\n", + " Re=10,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -1091,14 +771,16 @@ "\n", "$Y = Y / q(R)$\n", "\n", - "The net effect is a radially varying PA and axis ratio which allows the model to represent spiral arms, bulges, or other features that change the apparent shape of a galaxy in a radially varying way." + "The net effect is a radially varying PA and axis ratio which allows the model to represent spiral arms, bulges, or other features that change the apparent shape of a galaxy in a radially varying way.\n", + "\n", + "There are warp versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Spline Warp" + "### Sersic Warp" ] }, { @@ -1109,20 +791,19 @@ "source": [ "warp_q = np.linspace(0.1, 0.4, 14)\n", "warp_pa = np.linspace(0, np.pi - 0.2, 14)\n", - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"spline warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"I(R)\": {\"value\": spline_profile, \"prof\": NP_prof},\n", - " },\n", + "prof = np.linspace(0.0, 50, 14)\n", + "M = ap.models.Model(\n", + " model_type=\"sersic warp galaxy model\",\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " q_R={\"dynamic_value\": warp_q, \"prof\": prof},\n", + " PA_R={\"dynamic_value\": warp_pa, \"prof\": prof},\n", + " n=3,\n", + " Re=10,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -1136,45 +817,20 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Sersic Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"sersic warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"n\": 3,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", + "## Ray Model\n", "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" + "A ray model allows the user to break the galaxy up into regions that can be fit separately. There are two basic kinds of ray model: symmetric and asymmetric. A symmetric ray model (symmetric_rays = True) assumes 180 degree symmetry of the galaxy and so each ray is reflected through the center. This means that essentially the major axes and the minor axes are being fit separately. For an asymmetric ray model (symmetric_rays = False) each ray is it's own profile to be fit separately. \n", + "\n", + "In a ray model there is a smooth boundary between the rays. This smoothness is accomplished by applying a $(\\cos(r*theta)+1)/2$ weight to each profile, where r is dependent on the number of rays and theta is shifted to center on each ray in turn. The exact cosine weighting is dependent on if the rays are symmetric and if there is an even or odd number of rays. \n", + "\n", + "There are ray versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Exponential Warp" + "### Sersic Ray" ] }, { @@ -1183,26 +839,23 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"exponential warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", + "M = ap.models.Model(\n", + " model_type=\"sersic ray galaxy model\",\n", + " symmetric=True,\n", + " segments=2,\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=[1, 3],\n", + " Re=[10, 5],\n", + " Ie=[1, 0.5],\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", + "ap.plots.ray_light_profile(fig, ax[1], M)\n", "ax[0].set_title(M.name)\n", "plt.show()" ] @@ -1211,44 +864,18 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Gaussian Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"gaussian warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"sigma\": 30,\n", - " \"flux\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", + "## Wedge Model\n", "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" + "A wedge model behaves just like a ray model, except the boundaries are sharp. This has the advantage that the wedges can be very different in brightness without the \"smoothing\" from the ray model washing out the dimmer one. It also has the advantage of less \"mixing\" of information between the rays, each one can be counted on to have fit only the pixels in it's wedge without any influence from a neighbor. However, it has the disadvantage that the discontinuity at the boundary makes fitting behave strangely when a bright spot lays near the boundary.\n", + "\n", + "There are wedge versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Nuker Warp" + "### Sersic Wedge" ] }, { @@ -1257,597 +884,26 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"nuker warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"Rb\": 10.0,\n", - " \"Ib\": 1.0,\n", - " \"alpha\": 4.0,\n", - " \"beta\": 3.0,\n", - " \"gamma\": -0.2,\n", - " },\n", + "M = ap.models.Model(\n", + " model_type=\"sersic wedge galaxy model\",\n", + " symmetric=True,\n", + " segments=2,\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=[1, 3],\n", + " Re=[10, 5],\n", + " Ie=[1, 0.5],\n", " target=basic_target,\n", ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", + "ap.plots.wedge_light_profile(fig, ax[1], M)\n", "ax[0].set_title(M.name)\n", "plt.show()" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Ray Model\n", - "\n", - "A ray model allows the user to break the galaxy up into regions that can be fit separately. There are two basic kinds of ray model: symmetric and asymmetric. A symmetric ray model (symmetric_rays = True) assumes 180 degree symmetry of the galaxy and so each ray is reflected through the center. This means that essentially the major axes and the minor axes are being fit separately. For an asymmetric ray model (symmetric_rays = False) each ray is it's own profile to be fit separately. \n", - "\n", - "In a ray model there is a smooth boundary between the rays. This smoothness is accomplished by applying a $(\\cos(r*theta)+1)/2$ weight to each profile, where r is dependent on the number of rays and theta is shifted to center on each ray in turn. The exact cosine weighting is dependent on if the rays are symmetric and if there is an even or odd number of rays. " - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Spline Ray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"spline ray galaxy model\",\n", - " symmetric_rays=True,\n", - " rays=2,\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"I(R)\": {\"value\": np.array([spline_profile * 2, spline_profile]), \"prof\": NP_prof},\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.ray_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Sersic Ray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"sersic ray galaxy model\",\n", - " symmetric_rays=True,\n", - " rays=2,\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"n\": [1, 3],\n", - " \"Re\": [10, 5],\n", - " \"Ie\": [1, 0.5],\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.ray_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Exponential Ray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"exponential ray galaxy model\",\n", - " symmetric_rays=True,\n", - " rays=2,\n", - " parameters={\"center\": [50, 50], \"q\": 0.6, \"PA\": 60 * np.pi / 180, \"Re\": [10, 5], \"Ie\": [1, 2]},\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.ray_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Gaussian Ray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"gaussian ray galaxy model\",\n", - " symmetric_rays=True,\n", - " rays=2,\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"sigma\": [10, 20],\n", - " \"flux\": [1.5, 1.0],\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.ray_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Nuker Ray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"nuker ray galaxy model\",\n", - " symmetric_rays=True,\n", - " rays=2,\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"Rb\": [10.0, 1.0],\n", - " \"Ib\": [1.0, 0.0],\n", - " \"alpha\": [4.0, 1.0],\n", - " \"beta\": [3.0, 1.0],\n", - " \"gamma\": [-0.2, 0.2],\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.ray_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Wedge Model\n", - "\n", - "A wedge model behaves just like a ray model, except the boundaries are sharp. This has the advantage that the wedges can be very different in brightness without the \"smoothing\" from the ray model washing out the dimmer one. It also has the advantage of less \"mixing\" of information between the rays, each one can be counted on to have fit only the pixels in it's wedge without any influence from a neighbor. However, it has the disadvantage that the discontinuity at the boundary makes fitting behave strangely when a bright spot lays near the boundary." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Spline Wedge" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"spline wedge galaxy model\",\n", - " symmetric_wedges=True,\n", - " wedges=2,\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"I(R)\": {\"value\": np.array([spline_profile, spline_profile * 2]), \"prof\": NP_prof},\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.wedge_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## High Order Warp Models\n", - "\n", - "The models below combine the Warp coordinate transform with radial behaviour transforms: SuperEllipse and Fourier. These higher order models can create highly complex shapes, though their scientific use-case is less clear. They are included for completeness as they may be useful in some specific instances. These models are also included to demonstrate the flexibility in making AstroPhot models, in a future tutorial we will discuss how to make your own model types." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Spline SuperEllipse Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"spline superellipse warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"C0\": 2,\n", - " \"I(R)\": {\"value\": spline_profile, \"prof\": NP_prof},\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Sersic SuperEllipse Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"sersic superellipse warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"C0\": 2,\n", - " \"n\": 3,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Exponential SuperEllipse Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"exponential superellipse warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"C0\": 2,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Gaussian SuperEllipse Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"gaussian superellipse warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"C0\": 2,\n", - " \"sigma\": 30,\n", - " \"flux\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Spline Fourier Warp\n", - "\n", - "not sure how this abomination would fit a galaxy, but you are welcome to try" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"spline fourier warp galaxy model\",\n", - " modes=(1, 3, 4),\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"I(R)\": {\"value\": spline_profile, \"prof\": NP_prof},\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Sersic Fourier Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"sersic fourier warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"n\": 3,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Exponential Fourier Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"exponential fourier warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"Re\": 10,\n", - " \"Ie\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Gassian Fourier Warp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.AstroPhot_Model(\n", - " model_type=\"gaussian fourier warp galaxy model\",\n", - " parameters={\n", - " \"center\": [50, 50],\n", - " \"q\": 0.6,\n", - " \"PA\": 60 * np.pi / 180,\n", - " \"q(R)\": warp_q,\n", - " \"PA(R)\": warp_pa,\n", - " \"am\": fourier_am,\n", - " \"phim\": fourier_phim,\n", - " \"sigma\": 20,\n", - " \"flux\": 1,\n", - " },\n", - " target=basic_target,\n", - ")\n", - "print(M.parameter_order)\n", - "print(tuple(P.units for P in M.parameters))\n", - "M.initialize()\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.radial_light_profile(fig, ax[1], M)\n", - "ax[0].set_title(M.name)\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { From 3b4524417c30256fc5739e1ece86c4039770d6b3 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 24 Jun 2025 13:54:12 -0400 Subject: [PATCH 031/191] group model starting to come online --- astrophot/image/image_object.py | 8 +++- astrophot/image/window.py | 3 ++ astrophot/models/_shared_methods.py | 6 +++ astrophot/models/base.py | 6 +-- astrophot/models/group_model_object.py | 2 - astrophot/models/mixins/exponential.py | 2 +- astrophot/models/mixins/nuker.py | 2 +- astrophot/models/mixins/sersic.py | 2 +- astrophot/models/mixins/spline.py | 4 +- astrophot/plots/image.py | 29 +++++--------- .../utils/initialize/segmentation_map.py | 40 +++++++++---------- docs/source/tutorials/GroupModels.ipynb | 2 +- 12 files changed, 53 insertions(+), 53 deletions(-) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 6584ba55..199d412a 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -487,14 +487,18 @@ def __add__(self, other): def __iadd__(self, other): if isinstance(other, Image): - self.data._value[self.get_indices(other)] += other.data.value[other.get_indices(self)] + self.data._value[self.get_indices(other.window)] += other.data.value[ + other.get_indices(self.window) + ] else: self.data._value = self.data._value + other return self def __isub__(self, other): if isinstance(other, Image): - self.data._value[self.get_indices(other)] -= other.data.value[other.get_indices(self)] + self.data._value[self.get_indices(other.window)] -= other.data.value[ + other.get_indices(self.window) + ] else: self.data._value = self.data._value - other return self diff --git a/astrophot/image/window.py b/astrophot/image/window.py index ce206d99..8965e5c6 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -73,6 +73,9 @@ def pad(self, pad: int): self.j_low -= pad self.j_high += pad + def copy(self): + return Window((self.i_low, self.i_high, self.j_low, self.j_high), self.image) + def __or__(self, other: "Window"): if not isinstance(other, Window): raise TypeError(f"Cannot combine Window with {type(other)}") diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 2db0f10b..66d719d5 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -102,7 +102,13 @@ def optim(x, r, f, u): ) else: x0 = res.x + # import matplotlib.pyplot as plt + # plt.plot(R, I, "o", label="data") + # plt.plot(R, np.log10(prof_func(R, *x0)), label="fit") + # plt.title(f"Initial fit for {model.name}") + # plt.legend() + # plt.show() reses = [] for i in range(10): N = np.random.randint(0, len(R), len(R)) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index ce0468f9..f29e2e90 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -258,10 +258,8 @@ def window(self, window): self._window = None elif isinstance(window, Window): self._window = window - elif len(window) == 2: - self._window = Window((window[1], window[0]), image=self.target) - elif len(window) == 4: - self._window = Window((window[2], window[3], window[0], window[1]), image=self.target) + elif len(window) in [2, 4]: + self._window = Window(window, image=self.target) else: raise InvalidWindow(f"Unrecognized window format: {str(window)}") diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index c9d48fdd..8ec7d41d 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -95,8 +95,6 @@ def initialize(self): Args: target (Optional["Target_Image"]): A Target_Image instance to use as the source for initializing the model parameters on this image. """ - super().initialize() - for model in self.models: model.initialize() diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 1e0770fc..cd506485 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], F[4] + return R[4], 10 ** F[4] class ExponentialMixin: diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 8c2db66d..5a269a93 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], F[4], 1.0, 2.0, 0.5 + return R[4], 10 ** F[4], 1.0, 2.0, 0.5 class NukerMixin: diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index f4732b2d..e93bd3d8 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -8,7 +8,7 @@ def _x0_func(model, R, F): - return 2.0, R[4], F[4] + return 2.0, R[4], 10 ** F[4] class SersicMixin: diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index fdc96408..da8c311e 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -35,7 +35,7 @@ def initialize(self): self.radius_metric, rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], ) - self.I_R.dynamic_value = I + self.I_R.dynamic_value = 10**I self.I_R.uncertainty = S @forward @@ -80,7 +80,7 @@ def initialize(self): rad_bins=[0] + list((prof[s][:-1] + prof[s][1:]) / 2) + [prof[s][-1] * 100], angle_range=angle_range, ) - value[s] = I + value[s] = 10**I uncertainty[s] = S self.I_R.dynamic_value = value self.I_R.uncertainty = uncertainty diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 6db1abfd..5b0eed37 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -410,33 +410,24 @@ def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): return fig, ax if isinstance(model, GroupModel): - for m in model.models.values(): + for m in model.models: if isinstance(m.window, WindowList): use_window = m.window.window_list[m.target.index(target)] else: use_window = m.window - lowright = use_window.pixel_shape.clone().to(dtype=AP_config.ap_dtype) - lowright[1] = 0.0 - lowright = use_window.origin + use_window.pixel_to_plane_delta(lowright) - lowright = lowright.detach().cpu().numpy() - upleft = use_window.pixel_shape.clone().to(dtype=AP_config.ap_dtype) - upleft[0] = 0.0 - upleft = use_window.origin + use_window.pixel_to_plane_delta(upleft) - upleft = upleft.detach().cpu().numpy() - end = use_window.origin + use_window.end - end = end.detach().cpu().numpy() + corners = target[use_window].corners() x = [ - use_window.origin[0].detach().cpu().numpy(), - lowright[0], - end[0], - upleft[0], + corners[0][0].item(), + corners[1][0].item(), + corners[2][0].item(), + corners[3][0].item(), ] y = [ - use_window.origin[1].detach().cpu().numpy(), - lowright[1], - end[1], - upleft[1], + corners[0][1].item(), + corners[1][1].item(), + corners[2][1].item(), + corners[3][1].item(), ] ax.add_patch( Polygon( diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index f81cf9c3..ecd8ee97 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -173,9 +173,9 @@ def windows_from_segmentation_map(seg_map, hdul_index=0, skip_index=(0,)): for index in np.unique(seg_map): if index is None or index in skip_index: continue - Yid, Xid = np.where(seg_map == index) + Iid, Jid = np.where(seg_map == index) # Get window from segmap - windows[index] = [[np.min(Xid), np.max(Xid)], [np.min(Yid), np.max(Yid)]] + windows[index] = [[np.min(Iid), np.min(Jid)], [np.max(Iid), np.max(Jid)]] return windows @@ -186,29 +186,29 @@ def scale_windows(windows, image_shape=None, expand_scale=1.0, expand_border=0.0 new_window = deepcopy(windows[index]) # Get center and shape of the window center = ( - (new_window[0][0] + new_window[0][1]) / 2, - (new_window[1][0] + new_window[1][1]) / 2, + (new_window[0][0] + new_window[1][0]) / 2, + (new_window[0][1] + new_window[1][1]) / 2, ) shape = ( - new_window[0][1] - new_window[0][0], - new_window[1][1] - new_window[1][0], + new_window[1][0] - new_window[0][0], + new_window[1][1] - new_window[0][1], ) # Update the window with any expansion coefficients new_window = [ [ int(center[0] - expand_scale * shape[0] / 2 - expand_border), - int(center[0] + expand_scale * shape[0] / 2 + expand_border), + int(center[1] - expand_scale * shape[1] / 2 - expand_border), ], [ - int(center[1] - expand_scale * shape[1] / 2 - expand_border), + int(center[0] + expand_scale * shape[0] / 2 + expand_border), int(center[1] + expand_scale * shape[1] / 2 + expand_border), ], ] # Ensure the window does not exceed the borders of the image if image_shape is not None: new_window = [ - [max(0, new_window[0][0]), min(image_shape[1], new_window[0][1])], - [max(0, new_window[1][0]), min(image_shape[0], new_window[1][1])], + [max(0, new_window[0][0]), max(0, new_window[0][1])], + [min(image_shape[0], new_window[1][0]), min(image_shape[1], new_window[1][1])], ] new_windows[index] = new_window return new_windows @@ -242,8 +242,8 @@ def filter_windows( if min_size is not None: if ( min( - windows[w][0][1] - windows[w][0][0], - windows[w][1][1] - windows[w][1][0], + windows[w][1][0] - windows[w][0][0], + windows[w][1][1] - windows[w][0][1], ) < min_size ): @@ -251,28 +251,28 @@ def filter_windows( if max_size is not None: if ( max( - windows[w][0][1] - windows[w][0][0], - windows[w][1][1] - windows[w][1][0], + windows[w][1][0] - windows[w][0][0], + windows[w][1][1] - windows[w][0][1], ) > max_size ): continue if min_area is not None: if ( - (windows[w][0][1] - windows[w][0][0]) * (windows[w][1][1] - windows[w][1][0]) + (windows[w][1][0] - windows[w][0][0]) * (windows[w][1][1] - windows[w][0][1]) ) < min_area: continue if max_area is not None: if ( - (windows[w][0][1] - windows[w][0][0]) * (windows[w][1][1] - windows[w][1][0]) + (windows[w][1][0] - windows[w][0][0]) * (windows[w][1][1] - windows[w][0][1]) ) > max_area: continue if min_flux is not None: if ( np.sum( image[ - windows[w][1][0] : windows[w][1][1], - windows[w][0][0] : windows[w][0][1], + windows[w][0][0] : windows[w][1][0], + windows[w][0][1] : windows[w][1][1], ] ) < min_flux @@ -282,8 +282,8 @@ def filter_windows( if ( np.sum( image[ - windows[w][1][0] : windows[w][1][1], - windows[w][0][0] : windows[w][0][1], + windows[w][0][0] : windows[w][1][0], + windows[w][0][1] : windows[w][1][1], ] ) > max_flux diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index 73ea3cf2..6f61eb60 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -177,7 +177,7 @@ "source": [ "# This is now a very complex model composed of 9 sub-models! In total 57 parameters!\n", "# Here we will limit it to 1 iteration so that it runs quickly. In general you should let it run to convergence\n", - "result = ap.fit.Iter(groupmodel, verbose=1, max_iter=1).fit()" + "result = ap.fit.LM(groupmodel, verbose=1, max_iter=10).fit()" ] }, { From ef7002d1725f287fdc61df29d1698548fc7a5cfa Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 25 Jun 2025 08:51:17 -0400 Subject: [PATCH 032/191] get group models online --- astrophot/fit/__init__.py | 7 +- astrophot/fit/iterative.py | 371 ++++++++++----------- astrophot/models/edgeon.py | 4 +- astrophot/models/group_model_object.py | 20 +- astrophot/models/mixins/sample.py | 8 +- astrophot/models/mixins/spline.py | 1 - astrophot/plots/image.py | 6 + astrophot/plots/profile.py | 50 ++- docs/source/tutorials/GettingStarted.ipynb | 2 +- docs/source/tutorials/GroupModels.ipynb | 2 +- docs/source/tutorials/ModelZoo.ipynb | 11 +- 11 files changed, 242 insertions(+), 240 deletions(-) diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 483487a0..87561bdc 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,8 +1,9 @@ # from .base import * -from .lm import * +from .lm import LM # from .gradient import * -# from .iterative import * +from .iterative import Iter + # from .minifit import * # try: @@ -12,6 +13,8 @@ # print("Could not load HMC or NUTS due to:", str(e)) # from .mhmcmc import * +__all__ = ["LM", "Iter"] + """ base: This module defines the base class BaseOptimizer, which is used as the parent class for all optimization algorithms in AstroPhot. diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index ff04b934..dbb9e9b2 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -8,12 +8,14 @@ import torch from .base import BaseOptimizer -from ..models import AstroPhot_Model +from ..models import Model from .lm import LM -from ..param import Param_Mask from .. import AP_config -__all__ = ["Iter", "Iter_LM"] +__all__ = [ + "Iter", + # "Iter_LM" +] class Iter(BaseOptimizer): @@ -41,7 +43,7 @@ class Iter(BaseOptimizer): def __init__( self, - model: AstroPhot_Model, + model: Model, method: BaseOptimizer = LM, initial_state: np.ndarray = None, max_iter: int = 100, @@ -50,6 +52,7 @@ def __init__( ) -> None: super().__init__(model, initial_state, max_iter=max_iter, **kwargs) + self.current_state = model.build_params_array() self.method = method self.method_kwargs = method_kwargs if "relative_tolerance" not in method_kwargs and isinstance(method, LM): @@ -64,7 +67,7 @@ def __init__( # subtract masked pixels from degrees of freedom self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() - def sub_step(self, model: "AstroPhot_Model") -> None: + def sub_step(self, model: Model) -> None: """ Perform optimization for a single model. @@ -72,14 +75,16 @@ def sub_step(self, model: "AstroPhot_Model") -> None: model: The model to perform optimization on. """ self.Y -= model() - initial_target = model.target - model.target = model.target[model.window] - self.Y[model.window] + initial_values = model.target[model.window].data.value.clone() + indices = model.target.get_indices(model.window) + model.target.data.value[indices] = ( + model.target[model.window] - self.Y[model.window] + ).data.value res = self.method(model, **self.method_kwargs).fit() - model.parameters.flat_detach() self.Y += model() if self.verbose > 1: AP_config.ap_logger.info(res.message) - model.target = initial_target + model.target.data.value[indices] = initial_values def step(self) -> None: """ @@ -89,21 +94,18 @@ def step(self) -> None: AP_config.ap_logger.info("--------iter-------") # Fit each model individually - for model in self.model.models.values(): + for model in self.model.models: if self.verbose > 0: AP_config.ap_logger.info(model.name) self.sub_step(model) # Update the current state - self.current_state = self.model.parameters.vector_representation() + self.current_state = self.model.build_params_array() # Update the loss value with torch.no_grad(): if self.verbose > 0: AP_config.ap_logger.info("Update Chi^2 with new parameters") - self.Y = self.model( - parameters=self.current_state, - as_representation=True, - ) + self.Y = self.model(params=self.current_state) D = self.model.target[self.model.window].flatten("data") V = ( self.model.target[self.model.window].flatten("variance") @@ -135,7 +137,7 @@ def step(self) -> None: self.iteration += 1 - def fit(self) -> "BaseOptimizer": + def fit(self) -> BaseOptimizer: """ Fit the models to the target. @@ -143,18 +145,11 @@ def fit(self) -> "BaseOptimizer": """ self.iteration = 0 - self.Y = self.model(parameters=self.current_state, as_representation=True) + self.Y = self.model(params=self.current_state) start_fit = time() try: while True: self.step() - if self.save_steps is not None: - self.model.save( - os.path.join( - self.save_steps, - f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", - ) - ) if self.iteration > 2 and self._count_finish >= 2: self.message = self.message + "success" break @@ -165,7 +160,9 @@ def fit(self) -> "BaseOptimizer": except KeyboardInterrupt: self.message = self.message + "fail interrupted" - self.model.parameters.vector_set_representation(self.res()) + self.model.fill_dynamic_values( + torch.tensor(self.res(), dtype=AP_config.ap_dtype, device=AP_config.ap_device) + ) if self.verbose > 1: AP_config.ap_logger.info( f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" @@ -174,165 +171,165 @@ def fit(self) -> "BaseOptimizer": return self -class Iter_LM(BaseOptimizer): - """Optimization wrapper that call LM optimizer on subsets of variables. - - Iter_LM takes the full set of parameters for a model and breaks - them down into chunks as specified by the user. It then calls - Levenberg-Marquardt optimization on the subset of parameters, and - iterates through all subsets until every parameter has been - optimized. It cycles through these chunks until convergence. This - method is very powerful in situations where the full optimization - problem cannot fit in memory, or where the optimization problem is - too complex to tackle as a single large problem. In full LM - optimization a single problematic parameter can ripple into issues - with every other parameter, so breaking the problem down can - sometimes make an otherwise intractable problem easier. For small - problems with only a few models, it is likely better to optimize - the full problem with LM as, when it works, LM is faster than the - Iter_LM method. - - Args: - chunks (Union[int, tuple]): Specify how to break down the model parameters. If an integer, at each iteration the algorithm will break the parameters into groups of that size. If a tuple, should be a tuple of tuples of strings which give an explicit pairing of parameters to optimize, note that it is allowed to have variable size chunks this way. Default: 50 - method (str): How to iterate through the chunks. Should be one of: random, sequential. Default: random - """ - - def __init__( - self, - model: "AstroPhot_Model", - initial_state: Sequence = None, - chunks: Union[int, tuple] = 50, - max_iter: int = 100, - method: str = "random", - LM_kwargs: dict = {}, - **kwargs: Dict[str, Any], - ) -> None: - super().__init__(model, initial_state, max_iter=max_iter, **kwargs) - - self.chunks = chunks - self.method = method - self.LM_kwargs = LM_kwargs - - # # pixels # parameters - self.ndf = self.model.target[self.model.window].flatten("data").numel() - len( - self.current_state - ) - if self.model.target.has_mask: - # subtract masked pixels from degrees of freedom - self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() - - def step(self): - # These store the chunking information depending on which chunk mode is selected - param_ids = list(self.model.parameters.vector_identities()) - init_param_ids = list(self.model.parameters.vector_identities()) - _chunk_index = 0 - _chunk_choices = None - res = None - - if self.verbose > 0: - AP_config.ap_logger.info("--------iter-------") - - # Loop through all the chunks - while True: - chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=AP_config.ap_device) - if isinstance(self.chunks, int): - if len(param_ids) == 0: - break - if self.method == "random": - # Draw a random chunk of ids - for pid in random.sample(param_ids, min(len(param_ids), self.chunks)): - chunk[init_param_ids.index(pid)] = True - else: - # Draw the next chunk of ids - for pid in param_ids[: self.chunks]: - chunk[init_param_ids.index(pid)] = True - # Remove the selected ids from the list - for p in np.array(init_param_ids)[chunk.detach().cpu().numpy()]: - param_ids.pop(param_ids.index(p)) - elif isinstance(self.chunks, (tuple, list)): - if _chunk_choices is None: - # Make a list of the chunks as given explicitly - _chunk_choices = list(range(len(self.chunks))) - if self.method == "random": - if len(_chunk_choices) == 0: - break - # Select a random chunk from the given groups - sub_index = random.choice(_chunk_choices) - _chunk_choices.pop(_chunk_choices.index(sub_index)) - for pid in self.chunks[sub_index]: - chunk[param_ids.index(pid)] = True - else: - if _chunk_index >= len(self.chunks): - break - # Select the next chunk in order - for pid in self.chunks[_chunk_index]: - chunk[param_ids.index(pid)] = True - _chunk_index += 1 - else: - raise ValueError( - "Unrecognized chunks value, should be one of int, tuple. not: {type(self.chunks)}" - ) - if self.verbose > 1: - AP_config.ap_logger.info(str(chunk)) - del res - with Param_Mask(self.model.parameters, chunk): - res = LM( - self.model, - ndf=self.ndf, - **self.LM_kwargs, - ).fit() - if self.verbose > 0: - AP_config.ap_logger.info(f"chunk loss: {res.res_loss()}") - if self.verbose > 1: - AP_config.ap_logger.info(f"chunk message: {res.message}") - - self.loss_history.append(res.res_loss()) - self.lambda_history.append( - self.model.parameters.vector_representation().detach().cpu().numpy() - ) - if self.verbose > 0: - AP_config.ap_logger.info(f"Loss: {self.loss_history[-1]}") - - # test for convergence - if self.iteration >= 2 and ( - (-self.relative_tolerance * 1e-3) - < ((self.loss_history[-2] - self.loss_history[-1]) / self.loss_history[-1]) - < (self.relative_tolerance / 10) - ): - self._count_finish += 1 - else: - self._count_finish = 0 - - self.iteration += 1 - - def fit(self): - self.iteration = 0 - - start_fit = time() - try: - while True: - self.step() - if self.save_steps is not None: - self.model.save( - os.path.join( - self.save_steps, - f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", - ) - ) - if self.iteration > 2 and self._count_finish >= 2: - self.message = self.message + "success" - break - elif self.iteration >= self.max_iter: - self.message = self.message + f"fail max iterations reached: {self.iteration}" - break - - except KeyboardInterrupt: - self.message = self.message + "fail interrupted" - - self.model.parameters.vector_set_representation(self.res()) - if self.verbose > 1: - AP_config.ap_logger.info( - f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" - ) - - return self +# class Iter_LM(BaseOptimizer): +# """Optimization wrapper that call LM optimizer on subsets of variables. + +# Iter_LM takes the full set of parameters for a model and breaks +# them down into chunks as specified by the user. It then calls +# Levenberg-Marquardt optimization on the subset of parameters, and +# iterates through all subsets until every parameter has been +# optimized. It cycles through these chunks until convergence. This +# method is very powerful in situations where the full optimization +# problem cannot fit in memory, or where the optimization problem is +# too complex to tackle as a single large problem. In full LM +# optimization a single problematic parameter can ripple into issues +# with every other parameter, so breaking the problem down can +# sometimes make an otherwise intractable problem easier. For small +# problems with only a few models, it is likely better to optimize +# the full problem with LM as, when it works, LM is faster than the +# Iter_LM method. + +# Args: +# chunks (Union[int, tuple]): Specify how to break down the model parameters. If an integer, at each iteration the algorithm will break the parameters into groups of that size. If a tuple, should be a tuple of tuples of strings which give an explicit pairing of parameters to optimize, note that it is allowed to have variable size chunks this way. Default: 50 +# method (str): How to iterate through the chunks. Should be one of: random, sequential. Default: random +# """ + +# def __init__( +# self, +# model: "AstroPhot_Model", +# initial_state: Sequence = None, +# chunks: Union[int, tuple] = 50, +# max_iter: int = 100, +# method: str = "random", +# LM_kwargs: dict = {}, +# **kwargs: Dict[str, Any], +# ) -> None: +# super().__init__(model, initial_state, max_iter=max_iter, **kwargs) + +# self.chunks = chunks +# self.method = method +# self.LM_kwargs = LM_kwargs + +# # # pixels # parameters +# self.ndf = self.model.target[self.model.window].flatten("data").numel() - len( +# self.current_state +# ) +# if self.model.target.has_mask: +# # subtract masked pixels from degrees of freedom +# self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() + +# def step(self): +# # These store the chunking information depending on which chunk mode is selected +# param_ids = list(self.model.parameters.vector_identities()) +# init_param_ids = list(self.model.parameters.vector_identities()) +# _chunk_index = 0 +# _chunk_choices = None +# res = None + +# if self.verbose > 0: +# AP_config.ap_logger.info("--------iter-------") + +# # Loop through all the chunks +# while True: +# chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=AP_config.ap_device) +# if isinstance(self.chunks, int): +# if len(param_ids) == 0: +# break +# if self.method == "random": +# # Draw a random chunk of ids +# for pid in random.sample(param_ids, min(len(param_ids), self.chunks)): +# chunk[init_param_ids.index(pid)] = True +# else: +# # Draw the next chunk of ids +# for pid in param_ids[: self.chunks]: +# chunk[init_param_ids.index(pid)] = True +# # Remove the selected ids from the list +# for p in np.array(init_param_ids)[chunk.detach().cpu().numpy()]: +# param_ids.pop(param_ids.index(p)) +# elif isinstance(self.chunks, (tuple, list)): +# if _chunk_choices is None: +# # Make a list of the chunks as given explicitly +# _chunk_choices = list(range(len(self.chunks))) +# if self.method == "random": +# if len(_chunk_choices) == 0: +# break +# # Select a random chunk from the given groups +# sub_index = random.choice(_chunk_choices) +# _chunk_choices.pop(_chunk_choices.index(sub_index)) +# for pid in self.chunks[sub_index]: +# chunk[param_ids.index(pid)] = True +# else: +# if _chunk_index >= len(self.chunks): +# break +# # Select the next chunk in order +# for pid in self.chunks[_chunk_index]: +# chunk[param_ids.index(pid)] = True +# _chunk_index += 1 +# else: +# raise ValueError( +# "Unrecognized chunks value, should be one of int, tuple. not: {type(self.chunks)}" +# ) +# if self.verbose > 1: +# AP_config.ap_logger.info(str(chunk)) +# del res +# with Param_Mask(self.model.parameters, chunk): +# res = LM( +# self.model, +# ndf=self.ndf, +# **self.LM_kwargs, +# ).fit() +# if self.verbose > 0: +# AP_config.ap_logger.info(f"chunk loss: {res.res_loss()}") +# if self.verbose > 1: +# AP_config.ap_logger.info(f"chunk message: {res.message}") + +# self.loss_history.append(res.res_loss()) +# self.lambda_history.append( +# self.model.parameters.vector_representation().detach().cpu().numpy() +# ) +# if self.verbose > 0: +# AP_config.ap_logger.info(f"Loss: {self.loss_history[-1]}") + +# # test for convergence +# if self.iteration >= 2 and ( +# (-self.relative_tolerance * 1e-3) +# < ((self.loss_history[-2] - self.loss_history[-1]) / self.loss_history[-1]) +# < (self.relative_tolerance / 10) +# ): +# self._count_finish += 1 +# else: +# self._count_finish = 0 + +# self.iteration += 1 + +# def fit(self): +# self.iteration = 0 + +# start_fit = time() +# try: +# while True: +# self.step() +# if self.save_steps is not None: +# self.model.save( +# os.path.join( +# self.save_steps, +# f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", +# ) +# ) +# if self.iteration > 2 and self._count_finish >= 2: +# self.message = self.message + "success" +# break +# elif self.iteration >= self.max_iter: +# self.message = self.message + f"fail max iterations reached: {self.iteration}" +# break + +# except KeyboardInterrupt: +# self.message = self.message + "fail interrupted" + +# self.model.parameters.vector_set_representation(self.res()) +# if self.verbose > 1: +# AP_config.ap_logger.info( +# f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" +# ) + +# return self diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index bc113577..323ba853 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -50,13 +50,13 @@ def initialize(self): if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): self.PA.dynamic_value = np.pi / 2 else: - self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi + self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi self.PA.uncertainty = self.PA.value * self.default_uncertainty @forward def transform_coordinates(self, x, y, PA): x, y = super().transform_coordinates(x, y) - return func.rotate(PA - np.pi / 2, x, y) + return func.rotate(-(PA + np.pi / 2), x, y) class EdgeonSech(EdgeonModel): diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 8ec7d41d..722eb33b 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -107,28 +107,28 @@ def fit_mask(self) -> torch.Tensor: """ subtarget = self.target[self.window] - if isinstance(self.target, ImageList): + if isinstance(subtarget, ImageList): mask = tuple(torch.ones_like(submask) for submask in subtarget.mask) for model in self.models: model_subtarget = model.target[model.window] model_fit_mask = model.fit_mask() - if isinstance(model.target, ImageList): + if isinstance(model_subtarget, ImageList): for target, submask in zip(model_subtarget, model_fit_mask): index = subtarget.index(target) - group_indices = subtarget.images[index].get_indices(target) - model_indices = target.get_indices(subtarget.images[index]) + group_indices = subtarget.images[index].get_indices(target.window) + model_indices = target.get_indices(subtarget.images[index].window) mask[index][group_indices] &= submask[model_indices] else: index = subtarget.index(model_subtarget) - group_indices = subtarget.images[index].get_indices(model_subtarget) - model_indices = model_subtarget.get_indices(subtarget.images[index]) + group_indices = subtarget.images[index].get_indices(model_subtarget.window) + model_indices = model_subtarget.get_indices(subtarget.images[index].window) mask[index][group_indices] &= model_fit_mask[model_indices] else: mask = torch.ones_like(subtarget.mask) for model in self.models: model_subtarget = model.target[model.window] - group_indices = subtarget.get_indices(model_subtarget) - model_indices = model_subtarget.get_indices(subtarget) + group_indices = subtarget.get_indices(model.window) + model_indices = model_subtarget.get_indices(subtarget.window) mask[group_indices] &= model.fit_mask()[model_indices] return mask @@ -186,6 +186,7 @@ def jacobian( self, pass_jacobian: Optional[JacobianImage] = None, window: Optional[Window] = None, + params=None, ) -> JacobianImage: """Compute the jacobian for this model. Done by first constructing a full jacobian (Npixels * Nparameters) of zeros then call the @@ -198,6 +199,9 @@ def jacobian( if window is None: window = self.window + if params is not None: + self.fill_dynamic_values(params) + if pass_jacobian is None: jac_img = self.target[window].jacobian_image( parameters=self.build_params_array_identities() diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 2a7bcd76..ea03d1cf 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -141,9 +141,6 @@ def jacobian( if window is None: window = self.window - if params is not None: - self.fill_dynamic_values(params) - if pass_jacobian is None: jac_img = self.target[window].jacobian_image( parameters=self.build_params_array_identities() @@ -159,10 +156,11 @@ def jacobian( n_pixels = np.prod(window.shape) if n_pixels > self.jacobian_maxpixels: for chunk in window.chunk(self.jacobian_maxpixels): - self.jacobian(window=chunk, pass_jacobian=jac_img) + self.jacobian(window=chunk, pass_jacobian=jac_img, params=params) return jac_img - params = self.build_params_array() + if params is None: + params = self.build_params_array() identities = self.build_params_array_identities() target = self.target[window] if len(params) > self.jacobian_maxparams: # handle large number of parameters diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index da8c311e..42c2b6d7 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -40,7 +40,6 @@ def initialize(self): @forward def radial_model(self, R, I_R): - print(self.I_R.prof, I_R) return func.spline(R, self.I_R.prof, I_R) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 5b0eed37..4152b5b1 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -11,12 +11,14 @@ from ..image import ImageList, WindowList from .. import AP_config from ..utils.conversions.units import flux_to_sb +from ..utils.decorators import ignore_numpy_warnings from .visuals import * __all__ = ["target_image", "psf_image", "model_image", "residual_image", "model_window"] +@ignore_numpy_warnings def target_image(fig, ax, target, window=None, **kwargs): """ This function is used to display a target image using the provided figure and axes. @@ -99,6 +101,7 @@ def target_image(fig, ax, target, window=None, **kwargs): @torch.no_grad() +@ignore_numpy_warnings def psf_image( fig, ax, @@ -145,6 +148,7 @@ def psf_image( @torch.no_grad() +@ignore_numpy_warnings def model_image( fig, ax, @@ -269,6 +273,7 @@ def model_image( @torch.no_grad() +@ignore_numpy_warnings def residual_image( fig, ax, @@ -401,6 +406,7 @@ def residual_image( return fig, ax +@ignore_numpy_warnings def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): if target is None: target = model.target diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 11e26808..7cc08324 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -9,14 +9,13 @@ # from ..models import Warp_Galaxy from ..utils.conversions.units import flux_to_sb from .visuals import * -from ..errors import InvalidModel __all__ = [ "radial_light_profile", "radial_median_profile", "ray_light_profile", "wedge_light_profile", - # "warp_phase_profile", + "warp_phase_profile", ] @@ -236,29 +235,24 @@ def wedge_light_profile( return fig, ax -# def warp_phase_profile(fig, ax, model, rad_unit="arcsec", doassert=True): -# if doassert: -# if not isinstance(model, Warp_Galaxy): -# raise InvalidModel( -# f"warp_phase_profile must be given a 'Warp_Galaxy' object. Not {type(model)}" -# ) - -# ax.plot( -# model.profR, -# model["q(R)"].value.detach().cpu().numpy(), -# linewidth=2, -# color=main_pallet["primary1"], -# label=f"{model.name} axis ratio", -# ) -# ax.plot( -# model.profR, -# model["PA(R)"].detach().cpu().numpy() / np.pi, -# linewidth=2, -# color=main_pallet["secondary1"], -# label=f"{model.name} position angle", -# ) -# ax.set_ylim([0, 1]) -# ax.set_ylabel("q [b/a], PA [rad/$\\pi$]") -# ax.set_xlabel(f"Radius [{rad_unit}]") - -# return fig, ax +def warp_phase_profile(fig, ax, model, rad_unit="arcsec"): + + ax.plot( + model.q_R.prof.detach().cpu().numpy(), + model.q_R.npvalue, + linewidth=2, + color=main_pallet["primary1"], + label=f"{model.name} axis ratio", + ) + ax.plot( + model.PA_R.prof.detach().cpu().numpy(), + model.PA_R.npvalue / np.pi, + linewidth=2, + color=main_pallet["primary2"], + label=f"{model.name} position angle", + ) + ax.set_ylim([0, 1]) + ax.set_ylabel("q [b/a], PA [rad/$\\pi$]") + ax.set_xlabel(f"Radius [{rad_unit}]") + + return fig, ax diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 9a24cce4..480eb582 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -248,7 +248,7 @@ "model3 = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " window=[480, 595, 555, 665], # this is a region in pixel coordinates ((xmin,xmax),(ymin,ymax))\n", + " window=[555, 665, 480, 595], # this is a region in pixel coordinates ((xmin,xmax),(ymin,ymax))\n", ")\n", "\n", "print(f\"automatically generated name: '{model3.name}'\")\n", diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index 6f61eb60..73ea3cf2 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -177,7 +177,7 @@ "source": [ "# This is now a very complex model composed of 9 sub-models! In total 57 parameters!\n", "# Here we will limit it to 1 iteration so that it runs quickly. In general you should let it run to convergence\n", - "result = ap.fit.LM(groupmodel, verbose=1, max_iter=10).fit()" + "result = ap.fit.Iter(groupmodel, verbose=1, max_iter=1).fit()" ] }, { diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index 948a57fa..916f5ba5 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -670,7 +670,7 @@ "source": [ "## Super Ellipse Models\n", "\n", - "A super ellipse is a regular ellipse, except the radius metric changes from R = sqrt(x^2 + y^2) to the more general: R = (x^C + y^C)^1/C. The parameter C = 2 for a regular ellipse, for 0 2 the shape becomes more \"boxy.\" In AstroPhot we use the parameter C0 = C-2 for simplicity.\n", + "A super ellipse is a regular ellipse, except the radius metric changes from $R = \\sqrt(x^2 + y^2)$ to the more general: $R = |x^C + y^C|^{1/C}$. The parameter $C = 2$ for a regular ellipse, for $0 2$ the shape becomes more \"boxy.\" \n", "\n", "There are superellipse versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" ] @@ -763,13 +763,13 @@ "source": [ "## Warp Model\n", "\n", - "A warp model performs a radially varying coordinate transform. Essentially instead of applying a rotation matrix **Rot** on all coordinates X,Y we instead construct a unique rotation matrix for each coordinate pair **Rot(R)** where $R = \\sqrt(X^2 + Y^2)$. We also apply a radially dependent axis ratio **q(R)** to all the coordinates:\n", + "A warp model performs a radially varying coordinate transform. Essentially instead of applying a rotation matrix **Rot** on all coordinates X,Y we instead construct a unique rotation matrix for each coordinate pair **Rot(R)** where $R = \\sqrt{X^2 + Y^2}$. We also apply a radially dependent axis ratio **q(R)** to all the coordinates:\n", "\n", - "$R = \\sqrt(X^2 + Y^2)$\n", + "$R = \\sqrt{X^2 + Y^2}$\n", "\n", "$X, Y = Rotate(X, Y, PA(R))$\n", "\n", - "$Y = Y / q(R)$\n", + "$Y = \\frac{Y}{q(R)}$\n", "\n", "The net effect is a radially varying PA and axis ratio which allows the model to represent spiral arms, bulges, or other features that change the apparent shape of a galaxy in a radially varying way.\n", "\n", @@ -806,9 +806,10 @@ ")\n", "M.initialize()\n", "\n", - "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", + "fig, ax = plt.subplots(1, 3, figsize=(20, 6))\n", "ap.plots.model_image(fig, ax[0], M)\n", "ap.plots.radial_light_profile(fig, ax[1], M)\n", + "ap.plots.warp_phase_profile(fig, ax[2], M)\n", "ax[0].set_title(M.name)\n", "plt.show()" ] From 13c027bb34326b68683ea17b4a8e59f0b5735129 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 25 Jun 2025 16:19:38 -0400 Subject: [PATCH 033/191] psf advanced tutorial online --- astrophot/fit/func/lm.py | 1 + astrophot/image/target_image.py | 6 +- astrophot/models/_shared_methods.py | 3 +- astrophot/models/base.py | 32 ++- astrophot/models/edgeon.py | 2 +- astrophot/models/exponential.py | 1 + astrophot/models/flatsky.py | 2 +- astrophot/models/func/__init__.py | 2 + astrophot/models/func/convolution.py | 14 + astrophot/models/gaussian.py | 1 + astrophot/models/mixins/sample.py | 2 +- astrophot/models/mixins/transform.py | 27 +- astrophot/models/model_object.py | 36 ++- astrophot/models/moffat.py | 3 + astrophot/models/multi_gaussian_expansion.py | 33 +-- astrophot/models/nuker.py | 1 + astrophot/models/pixelated_psf.py | 2 +- astrophot/models/point_source.py | 16 +- astrophot/models/psf_model_object.py | 9 +- astrophot/models/sersic.py | 1 + docs/source/tutorials/AdvancedPSFModels.ipynb | 257 +++--------------- docs/source/tutorials/ModelZoo.ipynb | 2 +- 22 files changed, 166 insertions(+), 287 deletions(-) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index d76a21b7..569fc7b8 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -82,6 +82,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. if nostep: if scary["h"] is not None: + print("scary") return scary raise OptimizeStop("Could not find step to improve chi^2") diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index b8c21a92..172c21a2 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -301,11 +301,7 @@ def psf(self, psf): elif isinstance(psf, PSFImage): self._psf = psf elif isinstance(psf, Model): - self._psf = PSFImage( - data=lambda p: p.psf_model().data.value, - pixelscale=psf.target.pixelscale, - ) - self._psf.link("psf_model", psf) + self._psf = psf else: AP_config.ap_logger.warning( "PSF provided is not a PSF_Image or AstroPhot PSF_Model, assuming its pixelscale is the same as this Target_Image." diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 66d719d5..11f03375 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -55,7 +55,8 @@ def _sample_image( if np.sum(I > 0) <= 3: I = np.abs(I) N = I > 0 - I[~N] = np.interp(R[~N], R[N], I[N]) + if not np.all(N): + I[~N] = np.interp(R[~N], R[N], I[N]) # Ensure decreasing brightness with radius in outer regions for i in range(5, len(I)): if I[i] >= I[i - 1]: diff --git a/astrophot/models/base.py b/astrophot/models/base.py index f29e2e90..80edde55 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -7,6 +7,7 @@ from ..utils.decorators import classproperty from ..image import Window, ImageList, ModelImage, ModelImageList from ..errors import UnrecognizedModel, InvalidWindow +from .. import AP_config from . import func __all__ = ("Model",) @@ -117,9 +118,17 @@ def __init__(self, *, name=None, target=None, window=None, mask=None, filename=N setattr(self, kwarg, kwargs.pop(kwarg)) # Create Param objects for this Module - parameter_specs = self.build_parameter_specs(kwargs) + parameter_specs = self.build_parameter_specs(kwargs, self.parameter_specs) for key in parameter_specs: setattr(self, key, Param(key, **parameter_specs[key])) + overload_specs = self.build_parameter_specs(kwargs, self.overload_parameter_specs) + for key in overload_specs: + overload = overload_specs[key].pop("overloads") + if self[overload].value is not None: + continue + self[overload].value = overload_specs[key].pop("overload_function") + setattr(self, key, Param(key, **overload_specs[key])) + self[overload].link(key, self[key]) self.saveattrs.update(self.options) self.saveattrs.add("window.extent") @@ -160,8 +169,18 @@ def parameter_specs(cls) -> dict: specs.update(getattr(subcls, "_parameter_specs", {})) return specs - def build_parameter_specs(self, kwargs) -> dict: - parameter_specs = deepcopy(self.parameter_specs) + @classproperty + def overload_parameter_specs(cls) -> dict: + """Collects all parameter specifications from the class hierarchy.""" + specs = {} + for subcls in reversed(cls.mro()): + if subcls is object: + continue + specs.update(getattr(subcls, "_overload_parameter_specs", {})) + return specs + + def build_parameter_specs(self, kwargs, parameter_specs) -> dict: + parameter_specs = deepcopy(parameter_specs) for p in list(kwargs.keys()): if p not in parameter_specs: @@ -282,6 +301,13 @@ def radius_metric(self, x, y): def angular_metric(self, x, y): return torch.atan2(y, x) + def to(self, dtype=None, device=None): + if dtype is None: + dtype = AP_config.ap_dtype + if device is None: + device = AP_config.ap_device + super().to(dtype=dtype, device=device) + @forward def __call__( self, diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index 323ba853..98b16875 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -35,7 +35,7 @@ def initialize(self): if self.PA.value is not None: return target_area = self.target[self.window] - dat = target_area.data.npvalue + dat = target_area.data.npvalue.copy() edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) dat = dat - edge_average diff --git a/astrophot/models/exponential.py b/astrophot/models/exponential.py index dd99899e..33f73f43 100644 --- a/astrophot/models/exponential.py +++ b/astrophot/models/exponential.py @@ -44,6 +44,7 @@ class ExponentialGalaxy(ExponentialMixin, RadialMixin, GalaxyModel): class ExponentialPSF(ExponentialMixin, RadialMixin, PSFModel): + _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0}} usable = True diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index c61c67c0..2450b839 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -32,7 +32,7 @@ def initialize(self): if self.I.value is not None: return - dat = self.target[self.window].data.npvalue + dat = self.target[self.window].data.npvalue.copy() self.I.value = np.median(dat) / self.target.pixel_area.item() self.I.uncertainty = ( iqr(dat, rng=(16, 84)) / (2.0 * self.target.pixel_area.item()) diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 9992414c..41fce168 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -12,6 +12,7 @@ from .convolution import ( lanczos_kernel, bilinear_kernel, + convolve, convolve_and_shift, curvature_kernel, ) @@ -32,6 +33,7 @@ "pixel_quad_integrator", "lanczos_kernel", "bilinear_kernel", + "convolve", "convolve_and_shift", "curvature_kernel", "sersic", diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index 5a4a0f9b..a094577f 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -30,6 +30,20 @@ def bilinear_kernel(dx, dy): return kernel +def convolve(image, psf): + + image_fft = torch.fft.rfft2(image, s=image.shape) + psf_fft = torch.fft.rfft2(psf, s=image.shape) + + convolved_fft = image_fft * psf_fft + convolved = torch.fft.irfft2(convolved_fft, s=image.shape) + return torch.roll( + convolved, + shifts=(-psf.shape[0] // 2, -psf.shape[1] // 2), + dims=(0, 1), + ) + + def convolve_and_shift(image, shift_kernel, psf): image_fft = torch.fft.rfft2(image, s=image.shape) diff --git a/astrophot/models/gaussian.py b/astrophot/models/gaussian.py index 0a8c90af..c35f3b69 100644 --- a/astrophot/models/gaussian.py +++ b/astrophot/models/gaussian.py @@ -43,6 +43,7 @@ class GaussianGalaxy(GaussianMixin, RadialMixin, GalaxyModel): class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): + _parameter_specs = {"flux": {"units": "flux", "value": 1.0}} usable = True diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index ea03d1cf..41a5d9c0 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -20,7 +20,7 @@ class SampleMixin: jacobian_maxparams = 10 jacobian_maxpixels = 1000**2 integrate_mode = "threshold" # none, threshold - integrate_tolerance = 1e-3 # total flux fraction + integrate_tolerance = 1e-4 # total flux fraction integrate_max_depth = 3 integrate_gridding = 5 integrate_quad_order = 3 diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index f1ce61f2..883d9518 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -29,29 +29,22 @@ def initialize(self): if not (self.PA.value is None or self.q.value is None): return target_area = self.target[self.window] - target_dat = target_area.data.npvalue + dat = target_area.data.npvalue.copy() if target_area.has_mask: mask = target_area.mask.detach().cpu().numpy() - target_dat[mask] = np.median(target_dat[~mask]) - edge = np.concatenate( - ( - target_dat[:, 0], - target_dat[:, -1], - target_dat[0, :], - target_dat[-1, :], - ) - ) + dat[mask] = np.median(dat[~mask]) + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) - target_dat -= edge_average + dat -= edge_average x, y = target_area.coordinate_center_meshgrid() x = (x - self.center.value[0]).detach().cpu().numpy() y = (y - self.center.value[1]).detach().cpu().numpy() - mu20 = np.median(target_dat * np.abs(x)) - mu02 = np.median(target_dat * np.abs(y)) - mu11 = np.median(target_dat * x * y / np.sqrt(np.abs(x * y))) - # mu20 = np.median(target_dat * x**2) - # mu02 = np.median(target_dat * y**2) - # mu11 = np.median(target_dat * x * y) + mu20 = np.median(dat * np.abs(x)) + mu02 = np.median(dat * np.abs(y)) + mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y))) + # mu20 = np.median(dat * x**2) + # mu02 = np.median(dat * y**2) + # mu11 = np.median(dat * x * y) M = np.array([[mu20, mu11], [mu11, mu02]]) if self.PA.value is None: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 60fd4d4e..0f668cf3 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -15,7 +15,7 @@ from ..utils.initialize import recursive_center_of_mass from ..utils.decorators import ignore_numpy_warnings from .. import AP_config -from ..errors import SpecificationConflict, InvalidTarget +from ..errors import InvalidTarget from .mixins import SampleMixin __all__ = ["ComponentModel"] @@ -88,11 +88,7 @@ def psf(self, val): elif isinstance(val, PSFImage): self._psf = val elif isinstance(val, Model): - self._psf = PSFImage( - name="psf", data=val.target.data.value, pixelscale=val.target.pixelscale - ) - self._psf.data = lambda p: p.psf_model().data.value - self._psf.data.link("psf_model", val) + self._psf = val else: self._psf = PSFImage(name="psf", data=val, pixelscale=self.target.pixelscale) AP_config.ap_logger.warning( @@ -195,8 +191,22 @@ def sample( raise NotImplementedError("PSF convolution in sub-window not available yet") if "full" in self.psf_mode: - psf_upscale = torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() - psf_pad = np.max(self.psf.shape) // 2 + if isinstance(self.psf, PSFImage): + psf_upscale = ( + torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() + ) + psf_pad = np.max(self.psf.shape) // 2 + psf = self.psf.data.value + elif isinstance(self.psf, Model): + psf_upscale = ( + torch.round(self.target.pixel_length / self.psf.target.pixelscale).int().item() + ) + psf_pad = np.max(self.psf.window.shape) // 2 + psf = self.psf().data.value + else: + raise TypeError( + f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." + ) working_image = ModelImage(window=window, upsample=psf_upscale, pad=psf_pad) @@ -214,10 +224,12 @@ def sample( sample = self.sample_image(working_image) - shift_kernel = self.shift_kernel(pixel_shift) - working_image.data = func.convolve_and_shift(sample, shift_kernel, self.psf.data.value) - working_image.crtan = working_image.crtan.value - center_shift - + if self.psf_subpixel_shift != "none": + shift_kernel = self.shift_kernel(pixel_shift) + working_image.data = func.convolve_and_shift(sample, shift_kernel, psf) + working_image.crtan = working_image.crtan.value - center_shift + else: + working_image.data = func.convolve(sample, psf) working_image = working_image.crop([psf_pad]).reduce(psf_upscale) else: diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py index 8690641d..5887db17 100644 --- a/astrophot/models/moffat.py +++ b/astrophot/models/moffat.py @@ -70,6 +70,8 @@ class MoffatPSF(MoffatMixin, RadialMixin, PSFModel): """ + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + usable = True @forward @@ -80,6 +82,7 @@ def total_flux(self, n, Rd, I0): class Moffat2DPSF(InclinedMixin, MoffatPSF): _model_type = "2d" + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} usable = True @forward diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 9b43c6a0..1fde4843 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -53,10 +53,13 @@ def initialize(self): super().initialize() target_area = self.target[self.window] - dat = target_area.data.npvalue + dat = target_area.data.npvalue.copy() if target_area.has_mask: mask = target_area.mask.detach().cpu().numpy() dat[mask] = np.median(dat[~mask]) + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) + edge_average = np.nanmedian(edge) + dat -= edge_average if self.sigma.value is None: self.sigma.dynamic_value = np.logspace( @@ -71,30 +74,16 @@ def initialize(self): if not (self.PA.value is None or self.q.value is None): return - target_area = self.target[self.window] - target_dat = target_area.data.npvalue - if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() - target_dat[mask] = np.median(target_dat[~mask]) - edge = np.concatenate( - ( - target_dat[:, 0], - target_dat[:, -1], - target_dat[0, :], - target_dat[-1, :], - ) - ) - edge_average = np.nanmedian(edge) - target_dat -= edge_average + x, y = target_area.coordinate_center_meshgrid() x = (x - self.center.value[0]).detach().cpu().numpy() y = (y - self.center.value[1]).detach().cpu().numpy() - mu20 = np.median(target_dat * np.abs(x)) - mu02 = np.median(target_dat * np.abs(y)) - mu11 = np.median(target_dat * x * y / np.sqrt(np.abs(x * y))) - # mu20 = np.median(target_dat * x**2) - # mu02 = np.median(target_dat * y**2) - # mu11 = np.median(target_dat * x * y) + mu20 = np.median(dat * np.abs(x)) + mu02 = np.median(dat * np.abs(y)) + mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y))) + # mu20 = np.median(dat * x**2) + # mu02 = np.median(dat * y**2) + # mu11 = np.median(dat * x * y) M = np.array([[mu20, mu11], [mu11, mu02]]) ones = np.ones(self.n_components) if self.PA.value is None: diff --git a/astrophot/models/nuker.py b/astrophot/models/nuker.py index 667328c5..12a244b8 100644 --- a/astrophot/models/nuker.py +++ b/astrophot/models/nuker.py @@ -47,6 +47,7 @@ class NukerGalaxy(NukerMixin, RadialMixin, GalaxyModel): class NukerPSF(NukerMixin, RadialMixin, PSFModel): + _parameter_specs = {"Ib": {"units": "flux/arcsec^2", "value": 1.0}} usable = True diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index 3372f241..2407a8c9 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -47,7 +47,7 @@ def initialize(self): super().initialize() if self.pixels.value is None: target_area = self.target[self.window] - self.pixels.dynamic_value = target_area.data.value / target_area.pixel_area + self.pixels.dynamic_value = target_area.data.value.clone() / target_area.pixel_area self.pixels.uncertainty = torch.abs(self.pixels.value) * self.default_uncertainty @forward diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 1be26bb7..eeab4365 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -25,6 +25,14 @@ class PointSource(ComponentModel): _parameter_specs = { "flux": {"units": "flux", "shape": ()}, } + _overload_parameter_specs = { + "logflux": { + "units": "log10(flux)", + "shape": (), + "overloads": "flux", + "overload_function": lambda p: 10**p.logflux.value, + } + } usable = True def __init__(self, *args, **kwargs): @@ -39,14 +47,14 @@ def __init__(self, *args, **kwargs): def initialize(self): super().initialize() - if self.flux.value is not None: + if not hasattr(self, "logflux") or self.logflux.value is not None: return target_area = self.target[self.window] - dat = target_area.data.npvalue + dat = target_area.data.npvalue.copy() edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) - self.flux.dynamic_value = np.abs(np.sum(dat - edge_average)) - self.flux.uncertainty = torch.std(dat) / np.sqrt(np.prod(dat.shape)) + self.logflux.dynamic_value = np.log10(np.abs(np.sum(dat - edge_average))) + self.logflux.uncertainty = torch.std(dat) / np.sqrt(np.prod(dat.shape)) # Psf convolution should be on by default since this is a delta function @property diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index d60f1e47..5f1efd8b 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -53,7 +53,7 @@ def transform_coordinates(self, x, y, center): # Fit loop functions ###################################################################### @forward - def sample(self): + def sample(self, window=None): """Evaluate the model on the space covered by an image object. This function properly calls integration methods. This should not be overloaded except in special cases. @@ -91,6 +91,9 @@ def sample(self): return working_image + def fit_mask(self): + return torch.zeros_like(self.target[self.window].mask, dtype=torch.bool) + @property def target(self): try: @@ -107,5 +110,5 @@ def target(self, target): self._target = target @forward - def __call__(self) -> ModelImage: - return self.sample() + def __call__(self, window=None) -> ModelImage: + return self.sample(window=window) diff --git a/astrophot/models/sersic.py b/astrophot/models/sersic.py index 89cb3131..0f1ea475 100644 --- a/astrophot/models/sersic.py +++ b/astrophot/models/sersic.py @@ -69,6 +69,7 @@ class SersicPSF(SersicMixin, RadialMixin, PSFModel): """ + _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0}} usable = True @forward diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index a3080d09..55e2b30d 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -50,7 +50,7 @@ "psf += np.random.normal(scale=np.sqrt(variance))\n", "# psf[psf < 0] = 0 #ap.utils.initialize.moffat_psf(2.0, 3.0, 101, 0.5)[psf < 0]\n", "\n", - "psf_target = ap.image.PSF_Image(\n", + "psf_target = ap.image.PSFImage(\n", " data=psf,\n", " pixelscale=0.5,\n", ")\n", @@ -72,7 +72,7 @@ "outputs": [], "source": [ "# Now we initialize on the image\n", - "psf_model = ap.models.AstroPhot_Model(\n", + "psf_model = ap.models.Model(\n", " name=\"init psf\",\n", " model_type=\"moffat psf model\",\n", " target=psf_target,\n", @@ -118,40 +118,46 @@ "outputs": [], "source": [ "# Lets make some data that we need to fit\n", + "psf_target = ap.image.PSFImage(\n", + " data=np.zeros((51, 51)),\n", + " pixelscale=1.0,\n", + ")\n", "\n", - "true_psf = ap.utils.initialize.moffat_psf(\n", - " 2.0, # n !!!!! Take note, we want to get n = 2. !!!!!!\n", - " 3.0, # Rd !!!!! Take note, we want to get Rd = 3.!!!!!!\n", - " 51, # pixels\n", - " 1.0, # pixelscale\n", + "true_psf_model = ap.models.Model(\n", + " name=\"true psf\",\n", + " model_type=\"moffat psf model\",\n", + " target=psf_target,\n", + " n=2,\n", + " Rd=3,\n", ")\n", + "true_psf = true_psf_model().data.value\n", "\n", - "target = ap.image.Target_Image(\n", + "target = ap.image.TargetImage(\n", " data=torch.zeros(100, 100),\n", " pixelscale=1.0,\n", " psf=true_psf,\n", ")\n", "\n", - "true_model = ap.models.AstroPhot_Model(\n", + "true_model = ap.models.Model(\n", " name=\"true model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " parameters={\n", - " \"center\": [50.0, 50.0],\n", - " \"q\": 0.4,\n", - " \"PA\": np.pi / 3,\n", - " \"n\": 2,\n", - " \"Re\": 25,\n", - " \"Ie\": 1,\n", - " },\n", + " center=[50.0, 50.0],\n", + " q=0.4,\n", + " PA=np.pi / 3,\n", + " n=2,\n", + " Re=25,\n", + " Ie=10,\n", + " psf_subpixel_shift=\"none\",\n", " psf_mode=\"full\",\n", ")\n", + "true_model.to()\n", "\n", "# use the true model to make some data\n", "sample = true_model()\n", "torch.manual_seed(61803398)\n", - "target.data = sample.data + torch.normal(torch.zeros_like(sample.data), 0.1)\n", - "target.variance = 0.01 * torch.ones_like(sample.data)\n", + "target.data = sample.data.value + torch.normal(torch.zeros_like(sample.data.value), 0.1)\n", + "target.variance = 0.01 * torch.ones_like(sample.data.value)\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", "ap.plots.model_image(fig, ax[0], true_model)\n", @@ -171,7 +177,7 @@ "# Now we will try and fit the data using just a plain sersic\n", "\n", "# Here we set up a sersic model for the galaxy\n", - "plain_galaxy_model = ap.models.AstroPhot_Model(\n", + "plain_galaxy_model = ap.models.Model(\n", " name=\"galaxy model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", @@ -213,33 +219,28 @@ "# Now we will try and fit the data with a sersic model and a \"live\" psf\n", "\n", "# Here we create a target psf model which will determine the specs of our live psf model\n", - "psf_target = ap.image.PSF_Image(\n", + "psf_target = ap.image.PSFImage(\n", " data=np.zeros((51, 51)),\n", " pixelscale=target.pixelscale,\n", ")\n", "\n", - "# Here we create a moffat model for the PSF. Note that this is just a regular AstroPhot model that we have chosen\n", - "# to be a moffat, really any model can be used. To make it suitable as a PSF we will need to apply some very\n", - "# specific settings.\n", - "live_psf_model = ap.models.AstroPhot_Model(\n", + "live_psf_model = ap.models.Model(\n", " name=\"psf\",\n", " model_type=\"moffat psf model\",\n", " target=psf_target,\n", - " parameters={\n", - " \"n\": 1.0, # True value is 2.\n", - " \"Rd\": 2.0, # True value is 3.\n", - " },\n", + " n=2.0, # True value is 2.\n", + " Rd=3.0, # True value is 3.\n", ")\n", "\n", "# Here we set up a sersic model for the galaxy\n", - "live_galaxy_model = ap.models.AstroPhot_Model(\n", + "live_galaxy_model = ap.models.Model(\n", " name=\"galaxy model\",\n", " model_type=\"sersic galaxy model\",\n", + " psf_subpixel_shift=\"none\",\n", " target=target,\n", " psf_mode=\"full\",\n", " psf=live_psf_model, # Here we bind the PSF model to the galaxy model, this will add the psf_model parameters to the galaxy_model\n", ")\n", - "\n", "live_psf_model.initialize()\n", "live_galaxy_model.initialize()\n", "\n", @@ -254,15 +255,14 @@ "metadata": {}, "outputs": [], "source": [ - "print(\n", - " \"fitted n for moffat PSF: \", live_galaxy_model[\"psf:n\"].value.item(), \"we were hoping to get 2!\"\n", - ")\n", - "print(\n", - " \"fitted Rd for moffat PSF: \",\n", - " live_galaxy_model[\"psf:Rd\"].value.item(),\n", - " \"we were hoping to get 3!\",\n", + "print(f\"fitted n for moffat PSF: {live_psf_model.n.value.item()} we were hoping to get 2!\")\n", + "print(f\"fitted Rd for moffat PSF: {live_psf_model.Rd.value.item()} we were hoping to get 3!\")\n", + "fig, ax = ap.plots.covariance_matrix(\n", + " result.covariance_matrix.detach().cpu().numpy(),\n", + " live_galaxy_model.build_params_array().detach().cpu().numpy(),\n", + " live_galaxy_model.build_params_array_names(),\n", ")\n", - "print(live_galaxy_model.parameters)" + "plt.show()" ] }, { @@ -300,179 +300,6 @@ "cell_type": "markdown", "id": "15", "metadata": {}, - "source": [ - "## PSF fitting with a faint star\n", - "\n", - "Fitting a PSF to a galaxy is perhaps not the most stable way to get a good model. However, there is a very common situation where this kind of fitting is quite helpful. Consider the scenario that there is a star, but it is not very bright and it is right next to a galaxy. Now we need to model the galaxy and the star simultaneously, but the galaxy should be convolved with the PSF for the fit to be stable (otherwise you'll have to do several iterations to converge). If there were many stars you could perhaps just stack a bunch of them and hope the average is close enough, but in this case we don't have many to work with so we need to squeeze out as much statistical power as possible. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [ - "# Lets make some data that we need to fit\n", - "\n", - "true_psf2 = ap.utils.initialize.moffat_psf(\n", - " 2.0, # n !!!!! Take note, we want to get n = 2. !!!!!!\n", - " 3.0, # Rd !!!!! Take note, we want to get Rd = 3.!!!!!!\n", - " 51, # pixels\n", - " 1.0, # pixelscale\n", - ")\n", - "\n", - "target2 = ap.image.Target_Image(\n", - " data=torch.zeros(100, 100),\n", - " pixelscale=1.0,\n", - " psf=true_psf,\n", - ")\n", - "\n", - "true_galaxy2 = ap.models.AstroPhot_Model(\n", - " name=\"true galaxy\",\n", - " model_type=\"sersic galaxy model\",\n", - " target=target2,\n", - " parameters={\n", - " \"center\": [50.0, 50.0],\n", - " \"q\": 0.4,\n", - " \"PA\": np.pi / 3,\n", - " \"n\": 2,\n", - " \"Re\": 25,\n", - " \"Ie\": 1,\n", - " },\n", - " psf_mode=\"full\",\n", - ")\n", - "true_star2 = ap.models.AstroPhot_Model(\n", - " name=\"true star\",\n", - " model_type=\"point model\",\n", - " target=target2,\n", - " parameters={\n", - " \"center\": [70, 70],\n", - " \"flux\": 2.0,\n", - " },\n", - ")\n", - "true_model2 = ap.models.AstroPhot_Model(\n", - " name=\"true model\",\n", - " model_type=\"group model\",\n", - " target=target2,\n", - " models=[true_galaxy2, true_star2],\n", - ")\n", - "\n", - "# use the true model to make some data\n", - "sample2 = true_model2()\n", - "torch.manual_seed(1618033988)\n", - "target2.data = sample2.data + torch.normal(torch.zeros_like(sample2.data), 0.1)\n", - "target2.variance = 0.01 * torch.ones_like(sample2.data)\n", - "\n", - "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", - "ap.plots.model_image(fig, ax[0], true_model2)\n", - "ap.plots.target_image(fig, ax[1], target2)\n", - "ax[0].set_title(\"true model\")\n", - "ax[1].set_title(\"mock observed data\")\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "# Now we will try and fit the data\n", - "\n", - "psf_model2 = ap.models.AstroPhot_Model(\n", - " name=\"psf\",\n", - " model_type=\"moffat psf model\",\n", - " target=psf_target,\n", - " parameters={\n", - " \"n\": 1.0, # True value is 2.\n", - " \"Rd\": 2.0, # True value is 3.\n", - " },\n", - ")\n", - "\n", - "# Here we set up a sersic model for the galaxy\n", - "galaxy_model2 = ap.models.AstroPhot_Model(\n", - " name=\"galaxy model\",\n", - " model_type=\"sersic galaxy model\",\n", - " target=target,\n", - " psf_mode=\"full\",\n", - " psf=psf_model2,\n", - ")\n", - "\n", - "# Let AstroPhot determine its own initial parameters, so it has to start with whatever it decides automatically,\n", - "# just like a real fit.\n", - "galaxy_model2.initialize()\n", - "\n", - "star_model2 = ap.models.AstroPhot_Model(\n", - " name=\"star model\",\n", - " model_type=\"point model\",\n", - " target=target2,\n", - " psf=psf_model2,\n", - " parameters={\n", - " \"center\": [70, 70], # start the star in roughly the right location\n", - " \"flux\": 2.5,\n", - " },\n", - ")\n", - "\n", - "star_model2.initialize()\n", - "\n", - "full_model2 = ap.models.AstroPhot_Model(\n", - " name=\"full model\",\n", - " model_type=\"group model\",\n", - " models=[galaxy_model2, star_model2],\n", - " target=target2,\n", - ")\n", - "\n", - "result = ap.fit.LM(full_model2, verbose=1).fit()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18", - "metadata": {}, - "outputs": [], - "source": [ - "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", - "ap.plots.model_image(fig, ax[0], full_model2)\n", - "ap.plots.residual_image(fig, ax[1], full_model2)\n", - "ax[0].set_title(\"fitted sersic+star model\")\n", - "ax[1].set_title(\"residuals\")\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], - "source": [ - "print(\"fitted n for moffat PSF: \", galaxy_model2[\"psf:n\"].value.item(), \"we were hoping to get 2!\")\n", - "print(\n", - " \"fitted Rd for moffat PSF: \", galaxy_model2[\"psf:Rd\"].value.item(), \"we were hoping to get 3!\"\n", - ")\n", - "\n", - "print(\n", - " \"---Note that we can just as well look at the star model parameters since they are the same---\"\n", - ")\n", - "print(\"fitted n for moffat PSF: \", psf_model2[\"n\"].value.item(), \"we were hoping to get 2!\")\n", - "print(\"fitted Rd for moffat PSF: \", psf_model2[\"Rd\"].value.item(), \"we were hoping to get 3!\")" - ] - }, - { - "cell_type": "markdown", - "id": "20", - "metadata": {}, - "source": [ - "Note that the fitted moffat parameters aren't much better than they were earlier when we just fit the galaxy alone. This shows us that extended objects have plenty of constraining power when it comes to PSF fitting, all this information has previously been left on the table! It makes sense that the galaxy dominates the PSF fit here, while the star is very simple to fit, it has much less light than the galaxy in this scenario so the S/N for the galaxy dominates. The reason this works really well is of course that the true data is in fact a sersic model, so we are working in a very idealized scenario. Real world galaxies are not necessarily well described by a sersic, so it is worthwhile to be cautious when doing this kind of fitting. Always make sure the results make sense before storming ahead with galaxy based PSF models, that said the payoff can be well worth it." - ] - }, - { - "cell_type": "markdown", - "id": "21", - "metadata": {}, "source": [ "## PSF fitting for faint stars\n", "\n", @@ -482,7 +309,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -491,7 +318,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "17", "metadata": {}, "source": [ "## PSF fitting for saturated stars\n", @@ -502,7 +329,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "18", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index 916f5ba5..bfa0e9ef 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -378,7 +378,7 @@ "M = ap.models.Model(\n", " model_type=\"point model\",\n", " center=[50, 50],\n", - " flux=1,\n", + " logflux=1,\n", " psf=psf_target,\n", " target=basic_target,\n", ")\n", From 24daa07ea6a28e2cabc153c3d0332225d88a5f3a Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 26 Jun 2025 22:04:18 -0400 Subject: [PATCH 034/191] getting psf convolution settled --- astrophot/image/image_object.py | 36 ++++--- astrophot/image/jacobian_image.py | 2 +- astrophot/image/model_image.py | 2 + astrophot/image/target_image.py | 7 +- astrophot/models/func/__init__.py | 2 + astrophot/models/func/convolution.py | 54 ++++++---- astrophot/models/group_model_object.py | 100 +++++++++++++----- astrophot/models/mixins/sample.py | 17 --- astrophot/models/model_object.py | 27 +++-- astrophot/models/point_source.py | 23 ++-- astrophot/plots/image.py | 4 +- docs/source/tutorials/AdvancedPSFModels.ipynb | 6 +- docs/source/tutorials/BasicPSFModels.ipynb | 47 +++++++- docs/source/tutorials/JointModels.ipynb | 95 ++++++++++------- 14 files changed, 268 insertions(+), 154 deletions(-) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 199d412a..ea1adf6c 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -8,7 +8,7 @@ from ..param import Module, Param, forward from .. import AP_config from ..utils.conversions.units import deg_to_arcsec -from .window import Window +from .window import Window, WindowList from ..errors import InvalidImage from . import func @@ -152,9 +152,6 @@ def pixelscale(self, pixelscale): elif isinstance(pixelscale, (float, int)) or ( isinstance(pixelscale, torch.Tensor) and pixelscale.numel() == 1 ): - AP_config.ap_logger.warning( - "Assuming diagonal pixelscale with the same value on both axes, please provide a full matrix to remove this message!" - ) pixelscale = ((pixelscale, 0.0), (0.0, pixelscale)) self._pixelscale = torch.as_tensor( pixelscale, dtype=AP_config.ap_dtype, device=AP_config.ap_device @@ -278,6 +275,7 @@ def copy(self, **kwargs): "crtan": self.crtan.value, "zeropoint": self.zeropoint, "identity": self.identity, + "name": self.name, **kwargs, } return self.__class__(**kwargs) @@ -295,6 +293,7 @@ def blank_copy(self, **kwargs): "crtan": self.crtan.value, "zeropoint": self.zeropoint, "identity": self.identity, + "name": self.name, **kwargs, } return self.__class__(**kwargs) @@ -510,7 +509,8 @@ def __getitem__(self, *args): class ImageList(Module): - def __init__(self, images): + def __init__(self, images, name=None): + super().__init__(name=name) self.images = list(images) if not all(isinstance(image, Image) for image in self.images): raise InvalidImage( @@ -628,13 +628,25 @@ def __iadd__(self, other): return self def __getitem__(self, *args): - if len(args) == 1 and isinstance(args[0], ImageList): - new_list = [] - for other_image in args[0].images: - i = self.index(other_image) - self_image = self.images[i] - new_list.append(self_image.get_window(other_image)) - return self.__class__(new_list) + if len(args) == 1: + if isinstance(args[0], ImageList): + new_list = [] + for other_image in args[0].images: + i = self.index(other_image) + new_list.append(self.images[i].get_window(other_image)) + return self.__class__(new_list) + elif isinstance(args[0], WindowList): + new_list = [] + for other_window in args[0].windows: + i = self.index(other_window.image) + new_list.append(self.images[i].get_window(other_window)) + return self.__class__(new_list) + elif isinstance(args[0], Image): + i = self.index(args[0]) + return self.images[i].get_window(args[0]) + elif isinstance(args[0], Window): + i = self.index(args[0].image) + return self.images[i].get_window(args[0]) super().__getitem__(*args) def __iter__(self): diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 7806b1fe..c809c56a 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -73,7 +73,7 @@ def __iadd__(self, other: "JacobianImage"): ###################################################################### -class JacobianImageList(ImageList, JacobianImage): +class JacobianImageList(ImageList): """For joint modelling, represents Jacobians evaluated on a list of images. diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index d07e3b25..e5b42de6 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -37,6 +37,8 @@ def __init__(self, *args, window=None, upsample=1, pad=0, **kwargs): device=AP_config.ap_device, ) kwargs["zeropoint"] = window.image.zeropoint + kwargs["identity"] = window.image.identity + kwargs["name"] = window.image.name + "_model" super().__init__(*args, **kwargs) def clear_image(self): diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 172c21a2..5b79376c 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -227,7 +227,7 @@ def mask(self): In a mask, a True value indicates that the pixel is masked and should be ignored. False indicates a normal pixel which will - inter into most calculaitons. + inter into most calculations. If no mask is provided, all pixels are assumed valid. @@ -303,9 +303,6 @@ def psf(self, psf): elif isinstance(psf, Model): self._psf = psf else: - AP_config.ap_logger.warning( - "PSF provided is not a PSF_Image or AstroPhot PSF_Model, assuming its pixelscale is the same as this Target_Image." - ) self._psf = PSFImage( data=psf, pixelscale=self.pixelscale, @@ -418,6 +415,7 @@ def jacobian_image( "crtan": self.crtan.value, "zeropoint": self.zeropoint, "identity": self.identity, + "name": self.name + "_jacobian", **kwargs, } return JacobianImage(parameters=parameters, data=data, **kwargs) @@ -434,6 +432,7 @@ def model_image(self, **kwargs): "crtan": self.crtan.value, "zeropoint": self.zeropoint, "identity": self.identity, + "name": self.name + "_model", **kwargs, } return ModelImage(**kwargs) diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 41fce168..181c832a 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -12,6 +12,7 @@ from .convolution import ( lanczos_kernel, bilinear_kernel, + fft_shift_kernel, convolve, convolve_and_shift, curvature_kernel, @@ -33,6 +34,7 @@ "pixel_quad_integrator", "lanczos_kernel", "bilinear_kernel", + "fft_shift_kernel", "convolve", "convolve_and_shift", "curvature_kernel", diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index a094577f..b62ce2b0 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -9,27 +9,36 @@ def lanczos_1d(x, order): return torch.sinc(x) * torch.sinc(x / order) * mask -def lanczos_kernel(dx, dy, order): - grid = torch.arange(-order, order + 1, dtype=dx.dtype, device=dx.device) - lx = lanczos_1d(grid - dx, order) - ly = lanczos_1d(grid - dy, order) - kernel = torch.outer(ly, lx) +def lanczos_kernel(di, dj, order): + grid = torch.arange(-order, order + 1, dtype=di.dtype, device=di.device) + li = lanczos_1d(grid - di, order) + lj = lanczos_1d(grid - dj, order) + kernel = torch.outer(li, lj) return kernel / kernel.sum() -def bilinear_kernel(dx, dy): +def bilinear_kernel(di, dj): """Bilinear kernel for sub-pixel shifting.""" - kernel = torch.tensor( - [ - [1 - dx, dx], - [dy, 1 - dy], - ], - dtype=dx.dtype, - device=dx.device, - ) + w00 = (1 - di) * (1 - dj) + w10 = di * (1 - dj) + w01 = (1 - di) * dj + w11 = di * dj + + kernel = torch.stack([w00, w10, w01, w11]).reshape(2, 2) return kernel +def fft_shift_kernel(shape, di, dj): + """FFT shift theorem gives "exact" shift in phase space. Not really exact for DFT""" + ni, nj = shape + ki = torch.fft.fftfreq(ni, dtype=di.dtype, device=di.device) + kj = torch.fft.rfftfreq(nj, dtype=di.dtype, device=di.device) + + Ki, Kj = torch.meshgrid(ki, kj, indexing="ij") + phase = -2j * torch.pi * (Ki * torch.arctan(di) + Kj * torch.arctan(dj)) + return torch.exp(phase) + + def convolve(image, psf): image_fft = torch.fft.rfft2(image, s=image.shape) @@ -39,25 +48,26 @@ def convolve(image, psf): convolved = torch.fft.irfft2(convolved_fft, s=image.shape) return torch.roll( convolved, - shifts=(-psf.shape[0] // 2, -psf.shape[1] // 2), + shifts=(-(psf.shape[0] // 2), -(psf.shape[1] // 2)), dims=(0, 1), ) -def convolve_and_shift(image, shift_kernel, psf): +def convolve_and_shift(image, psf, shift): image_fft = torch.fft.rfft2(image, s=image.shape) psf_fft = torch.fft.rfft2(psf, s=image.shape) - shift_fft = torch.fft.rfft2(shift_kernel, s=image.shape) - convolved_fft = image_fft * psf_fft * shift_fft + if shift is None: + convolved_fft = image_fft * psf_fft + else: + shift_kernel = fft_shift_kernel(image.shape, shift[0], shift[1]) + convolved_fft = image_fft * psf_fft * shift_kernel + convolved = torch.fft.irfft2(convolved_fft, s=image.shape) return torch.roll( convolved, - shifts=( - -psf.shape[0] // 2 - shift_kernel.shape[0] // 2, - -psf.shape[1] // 2 - shift_kernel.shape[1] // 2, - ), + shifts=(-(psf.shape[0] // 2), -(psf.shape[1] // 2)), dims=(0, 1), ) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 722eb33b..20171061 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -14,9 +14,10 @@ Window, WindowList, JacobianImage, + JacobianImageList, ) from ..utils.decorators import ignore_numpy_warnings -from ..errors import InvalidTarget +from ..errors import InvalidTarget, InvalidWindow __all__ = ["GroupModel"] @@ -132,6 +133,34 @@ def fit_mask(self) -> torch.Tensor: mask[group_indices] &= model.fit_mask()[model_indices] return mask + def match_window(self, image, window, model): + if isinstance(image, ImageList) and isinstance(model.target, ImageList): + indices = image.match_indices(model.target) + if len(indices) == 0: + raise IndexError + use_window = WindowList(window_list=list(image.images[i].window for i in indices)) + elif isinstance(image, ImageList) and isinstance(model.target, Image): + try: + image.index(model.target) + except ValueError: + raise IndexError + use_window = model.window + elif isinstance(image, Image) and isinstance(model.target, ImageList): + try: + i = model.target.index(image) + except ValueError: + raise IndexError + use_window = model.window[i] + elif isinstance(image, Image) and isinstance(model.target, Image): + if image.identity != model.target.identity: + raise IndexError + use_window = window + else: + raise NotImplementedError( + f"Group_Model cannot sample with {type(image)} and {type(model.target)}" + ) + return use_window + @forward def sample( self, @@ -154,29 +183,12 @@ def sample( for model in self.models: if window is None: use_window = model.window - elif isinstance(image, ImageList) and isinstance(model.target, ImageList): - indices = image.match_indices(model.target) - if len(indices) == 0: - continue - use_window = WindowList(window_list=list(image.images[i].window for i in indices)) - elif isinstance(image, ImageList) and isinstance(model.target, Image): - try: - image.index(model.target) - except ValueError: - continue - elif isinstance(image, Image) and isinstance(model.target, ImageList): + else: try: - model.target.index(image) - except ValueError: - continue - elif isinstance(image, Image) and isinstance(model.target, Image): - if image.identity != model.target.identity: + use_window = self.match_window(image, window, model) + except IndexError: + # If the model target is not in the image, skip it continue - use_window = window - else: - raise NotImplementedError( - f"Group_Model cannot sample with {type(image)} and {type(model.target)}" - ) image += model(window=model.window & use_window) return image @@ -184,8 +196,8 @@ def sample( @torch.no_grad() def jacobian( self, - pass_jacobian: Optional[JacobianImage] = None, - window: Optional[Window] = None, + pass_jacobian: Optional[Union[JacobianImage, JacobianImageList]] = None, + window: Optional[Union[Window, WindowList]] = None, params=None, ) -> JacobianImage: """Compute the jacobian for this model. Done by first constructing a @@ -210,9 +222,14 @@ def jacobian( jac_img = pass_jacobian for model in self.models: + try: + use_window = self.match_window(jac_img, window, model) + except IndexError: + # If the model target is not in the image, skip it + continue model.jacobian( pass_jacobian=jac_img, - window=window, + window=use_window & model.window, ) return jac_img @@ -232,3 +249,36 @@ def target(self, tar: Optional[Union[TargetImage, TargetImageList]]): if not (tar is None or isinstance(tar, (TargetImage, TargetImageList))): raise InvalidTarget("Group_Model target must be a Target_Image instance.") self._target = tar + + @property + def window(self) -> Optional[Window]: + """The window defines a region on the sky in which this model will be + optimized and typically evaluated. Two models with + non-overlapping windows are in effect independent of each + other. If there is another model with a window that spans both + of them, then they are tenuously connected. + + If not provided, the model will assume a window equal to the + target it is fitting. Note that in this case the window is not + explicitly set to the target window, so if the model is moved + to another target then the fitting window will also change. + + """ + if self._window is None: + if self.target is None: + raise ValueError( + "This model has no target or window, these must be provided by the user" + ) + return self.target.window + return self._window + + @window.setter + def window(self, window): + if window is None: + self._window = None + elif isinstance(window, (Window, WindowList)): + self._window = window + elif len(window) in [2, 4]: + self._window = Window(window, image=self.target) + else: + raise InvalidWindow(f"Unrecognized window format: {str(window)}") diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 41a5d9c0..5eaa0dcf 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -37,23 +37,6 @@ class SampleMixin: "integrate_quad_order", ) - def shift_kernel(self, shift): - if self.psf_subpixel_shift == "bilinear": - return func.bilinear_kernel(shift[0], shift[1]) - elif self.psf_subpixel_shift.startswith("lanczos:"): - order = int(self.psf_subpixel_shift.split(":")[1]) - return func.lanczos_kernel(shift[0], shift[1], order) - elif self.psf_subpixel_shift == "none": - return torch.tensor( - [[0, 0, 0], [0, 1, 0], [0, 0, 0]], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - else: - raise SpecificationConflict( - f"Unknown PSF subpixel shift mode {self.psf_subpixel_shift} for model {self.name}" - ) - @forward def _sample_integrate(self, sample, image: Image): i, j = image.pixel_center_meshgrid() diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 0f668cf3..ad922fd9 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -60,7 +60,9 @@ class ComponentModel(SampleMixin, Model): # Scope for PSF convolution psf_mode = "none" # none, full # Method to use when performing subpixel shifts. - psf_subpixel_shift = "lanczos:3" # bilinear, lanczos:2, lanczos:3, lanczos:5, none + psf_subpixel_shift = ( + False # False: no shift to align sampling with pixel center, True: use FFT shift theorem + ) # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) softening = 1e-3 # arcsec @@ -199,7 +201,9 @@ def sample( psf = self.psf.data.value elif isinstance(self.psf, Model): psf_upscale = ( - torch.round(self.target.pixel_length / self.psf.target.pixelscale).int().item() + torch.round(self.target.pixel_length / self.psf.target.pixel_length) + .int() + .item() ) psf_pad = np.max(self.psf.window.shape) // 2 psf = self.psf().data.value @@ -211,25 +215,18 @@ def sample( working_image = ModelImage(window=window, upsample=psf_upscale, pad=psf_pad) # Sub pixel shift to align the model with the center of a pixel - if self.psf_subpixel_shift != "none": + if self.psf_subpixel_shift: pixel_center = torch.stack(working_image.plane_to_pixel(*center)) pixel_shift = pixel_center - torch.round(pixel_center) - center_shift = center - torch.stack( - working_image.pixel_to_plane(*torch.round(pixel_center)) - ) - working_image.crtan = working_image.crtan.value + center_shift + working_image.crpix = working_image.crpix.value - pixel_shift else: - pixel_shift = torch.zeros_like(center) - center_shift = torch.zeros_like(center) + pixel_shift = None sample = self.sample_image(working_image) - if self.psf_subpixel_shift != "none": - shift_kernel = self.shift_kernel(pixel_shift) - working_image.data = func.convolve_and_shift(sample, shift_kernel, psf) - working_image.crtan = working_image.crtan.value - center_shift - else: - working_image.data = func.convolve(sample, psf) + working_image.data = func.convolve_and_shift(sample, psf, pixel_shift) + if self.psf_subpixel_shift: + working_image.crpix = working_image.crpix.value + pixel_shift working_image = working_image.crop([psf_pad]).reduce(psf_upscale) else: diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index eeab4365..08e0e099 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -8,6 +8,7 @@ from ..image import Window, ModelImage from ..errors import SpecificationConflict from ..param import forward +from . import func __all__ = ("PointSource",) @@ -105,16 +106,18 @@ def sample(self, window: Optional[Window] = None, center=None, flux=None): # Compute the center offset pixel_center = torch.stack(working_image.plane_to_pixel(*center)) pixel_shift = pixel_center - torch.round(pixel_center) - shift_kernel = self.shift_kernel(pixel_shift) - psf = ( - torch.nn.functional.conv2d( - self.psf.data.value.view(1, 1, *self.psf.data.shape), - shift_kernel.view(1, 1, *shift_kernel.shape), - padding="valid", # fixme add note about valid padding - ) - .squeeze(0) - .squeeze(0) - ) + psf = self.psf.data.value + shift_kernel = func.fft_shift_kernel(psf.shape, pixel_shift[0], pixel_shift[1]) + psf = torch.fft.irfft2(shift_kernel * torch.fft.rfft2(psf, s=psf.shape), s=psf.shape) + # ( + # torch.nn.functional.conv2d( + # self.psf.data.value.view(1, 1, *self.psf.data.shape), + # shift_kernel.view(1, 1, *shift_kernel.shape), + # padding="valid", # fixme add note about valid padding + # ) + # .squeeze(0) + # .squeeze(0) + # ) psf = flux * psf # Fill pixels with the PSF image diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 4152b5b1..6b8ce757 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -42,8 +42,8 @@ def target_image(fig, ax, target, window=None, **kwargs): # recursive call for target image list if isinstance(target, ImageList): - for i in range(len(target.image_list)): - target_image(fig, ax[i], target.image_list[i], window=window, **kwargs) + for i in range(len(target.images)): + target_image(fig, ax[i], target.images[i], window=window, **kwargs) return fig, ax if window is None: window = target.window diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index 55e2b30d..e141908a 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -148,7 +148,6 @@ " n=2,\n", " Re=25,\n", " Ie=10,\n", - " psf_subpixel_shift=\"none\",\n", " psf_mode=\"full\",\n", ")\n", "true_model.to()\n", @@ -236,15 +235,14 @@ "live_galaxy_model = ap.models.Model(\n", " name=\"galaxy model\",\n", " model_type=\"sersic galaxy model\",\n", - " psf_subpixel_shift=\"none\",\n", " target=target,\n", " psf_mode=\"full\",\n", " psf=live_psf_model, # Here we bind the PSF model to the galaxy model, this will add the psf_model parameters to the galaxy_model\n", ")\n", "live_psf_model.initialize()\n", "live_galaxy_model.initialize()\n", - "\n", - "result = ap.fit.LM(live_galaxy_model, verbose=1).fit()\n", + "print(live_galaxy_model.center.value)\n", + "result = ap.fit.LM(live_galaxy_model, verbose=3).fit()\n", "result.update_uncertainty()" ] }, diff --git a/docs/source/tutorials/BasicPSFModels.ipynb b/docs/source/tutorials/BasicPSFModels.ipynb index 59090019..44efdb7d 100644 --- a/docs/source/tutorials/BasicPSFModels.ipynb +++ b/docs/source/tutorials/BasicPSFModels.ipynb @@ -35,10 +35,10 @@ "source": [ "## PSF Images\n", "\n", - "A `PSF_Image` is an AstroPhot object which stores the data for a PSF. It records the pixel values for the PSF as well as meta-data like the pixelscale at which it was taken. The point source function (PSF) is a description of how light is distributed into pixels when the light source is a delta function. In Astronomy we are blessed/cursed with many delta function like sources in our images and so PSF modelling is a major component of astronomical image analysis. Here are some points to keep in mind about a PSF.\n", + "A `PSFImage` is an AstroPhot object which stores the data for a PSF. It records the pixel values for the PSF as well as meta-data like the pixelscale at which it was taken. The point source function (PSF) is a description of how light is distributed into pixels when the light source is a delta function. In Astronomy we are blessed/cursed with many delta function like sources in our images and so PSF modelling is a major component of astronomical image analysis. Here are some points to keep in mind about a PSF.\n", "\n", "- PSF images are always odd in shape (e.g. 25x25 pixels, not 24x24 pixels), at the center pixel, in the center of that pixel is where the delta function point source is located by definition\n", - "- In AstroPhot, the coordinates of the center of the center pixel in a `PSF_Image` are always (0,0). \n", + "- In AstroPhot, the coordinates of the center of the center pixel in a `PSFImage` are always (0,0). \n", "- The light in each pixel of a PSF image is already integrated. That is to say, the flux value for a pixel does not represent some model evaluated at the center of the pixel, it instead represents an integral over the whole area of the pixel" ] }, @@ -186,7 +186,9 @@ "id": "8", "metadata": {}, "source": [ - "That covers the basics of adding PSF convolution kernels to AstroPhot models! These techniques assume you already have a model for the PSF that you got with some other algorithm (ie PSFEx), however AstroPhot also has the ability to model the PSF live along with the rest of the models in an image. If you are interested in extracting the PSF from an image using AstroPhot, check out the `AdvancedPSFModels` tutorial. " + "## Supersampled PSF models\n", + "\n", + "It is generally best practice to use a PSF model that has been determined at a higher resolution than the image you are analyzing. In AstroPhot this can be easily handled by ensuring that the `PSFImage` has an appropriate pixelscale that shows how it is upsampled. For example if our target has a pixelscale of 0.5 and the PSFImage has a pixelscale of 0.25 then AstroPhot will automatically infer that it should work at 2x higher resolution. Note that AstroPhot assumes the PSF has been determined at an integer level of upsampling, so in the example if you set the PSFImage pixelscale to 0.3 then strange things would likely happen to your images!" ] }, { @@ -195,6 +197,45 @@ "id": "9", "metadata": {}, "outputs": [], + "source": [ + "upsample_psf_target = ap.image.PSFImage(\n", + " data=ap.utils.initialize.gaussian_psf(2.0, 51, 0.25),\n", + " pixelscale=0.25,\n", + ")\n", + "target.psf = upsample_psf_target\n", + "\n", + "model_upsamplepsf = ap.models.Model(\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + " center=[75, 75],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=3,\n", + " Re=10,\n", + " Ie=1,\n", + " psf_mode=\"full\", # now the full window will be PSF convolved using the PSF from the target\n", + ")\n", + "model_upsamplepsf.initialize()\n", + "fig, ax = plt.subplots(1, 1, figsize=(5, 5))\n", + "ap.plots.model_image(fig, ax, model_upsamplepsf)\n", + "ax.set_title(\"With PSF convolution (upsampled PSF)\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "That covers the basics of adding PSF convolution kernels to AstroPhot models! These techniques assume you already have a model for the PSF that you got with some other algorithm (ie PSFEx), however AstroPhot also has the ability to model the PSF live along with the rest of the models in an image. If you are interested in extracting the PSF from an image using AstroPhot, check out the `AdvancedPSFModels` tutorial. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], "source": [] } ], diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index b141b0d5..99f5d30b 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -48,7 +48,7 @@ "lrimg = fits.open(\n", " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=500&layer=ls-dr9&pixscale=0.262&bands=r\"\n", ")\n", - "target_r = ap.image.Target_Image(\n", + "target_r = ap.image.TargetImage(\n", " data=np.array(lrimg[0].data, dtype=np.float64),\n", " zeropoint=22.5,\n", " variance=\"auto\", # auto variance gets it roughly right, use better estimate for science!\n", @@ -56,6 +56,7 @@ " 1.12 / 2.355, 51, 0.262\n", " ), # we construct a basic gaussian psf for each image by giving the simga (arcsec), image width (pixels), and pixelscale (arcsec/pixel)\n", " wcs=WCS(lrimg[0].header), # note pixelscale and origin not needed when we have a WCS object!\n", + " name=\"rband\",\n", ")\n", "\n", "\n", @@ -63,35 +64,40 @@ "lw1img = fits.open(\n", " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1\"\n", ")\n", - "target_W1 = ap.image.Target_Image(\n", + "target_W1 = ap.image.TargetImage(\n", " data=np.array(lw1img[0].data, dtype=np.float64),\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", " wcs=WCS(lw1img[0].header),\n", - " reference_radec=target_r.window.reference_radec,\n", + " name=\"W1band\",\n", ")\n", + "target_W1.crtan.to_dynamic()\n", "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel and is 90 pixels across\n", "lnuvimg = fits.open(\n", " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=90&layer=galex&pixscale=1.5&bands=n\"\n", ")\n", - "target_NUV = ap.image.Target_Image(\n", + "target_NUV = ap.image.TargetImage(\n", " data=np.array(lnuvimg[0].data, dtype=np.float64),\n", " zeropoint=20.08,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5),\n", " wcs=WCS(lnuvimg[0].header),\n", - " reference_radec=target_r.window.reference_radec,\n", + " name=\"NUVband\",\n", ")\n", + "# target_NUV.crtan.to_dynamic()\n", "\n", "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.target_image(fig1, ax1[0], target_r, flipx=True)\n", + "ap.plots.target_image(fig1, ax1[0], target_r)\n", "ax1[0].set_title(\"r-band image\")\n", - "ap.plots.target_image(fig1, ax1[1], target_W1, flipx=True)\n", + "ax1[0].invert_xaxis()\n", + "ap.plots.target_image(fig1, ax1[1], target_W1)\n", "ax1[1].set_title(\"W1-band image\")\n", - "ap.plots.target_image(fig1, ax1[2], target_NUV, flipx=True)\n", + "ax1[1].invert_xaxis()\n", + "ap.plots.target_image(fig1, ax1[2], target_NUV)\n", "ax1[2].set_title(\"NUV-band image\")\n", + "ax1[2].invert_xaxis()\n", "plt.show()" ] }, @@ -103,7 +109,7 @@ "source": [ "# The joint model will need a target to try and fit, but now that we have multiple images the \"target\" is\n", "# a Target_Image_List object which points to all three.\n", - "target_full = ap.image.Target_Image_List((target_r, target_W1, target_NUV))\n", + "target_full = ap.image.TargetImageList((target_r, target_W1, target_NUV))\n", "# It doesn't really need any other information since everything is already available in the individual targets" ] }, @@ -116,19 +122,19 @@ "# To make things easy to start, lets just fit a sersic model to all three. In principle one can use arbitrary\n", "# group models designed for each band individually, but that would be unnecessarily complex for a tutorial\n", "\n", - "model_r = ap.models.AstroPhot_Model(\n", + "model_r = ap.models.Model(\n", " name=\"rband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_r,\n", " psf_mode=\"full\",\n", ")\n", - "model_W1 = ap.models.AstroPhot_Model(\n", + "model_W1 = ap.models.Model(\n", " name=\"W1band model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", " psf_mode=\"full\",\n", ")\n", - "model_NUV = ap.models.AstroPhot_Model(\n", + "model_NUV = ap.models.Model(\n", " name=\"NUVband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", @@ -152,15 +158,14 @@ "source": [ "# We can now make the joint model object\n", "\n", - "model_full = ap.models.AstroPhot_Model(\n", + "model_full = ap.models.Model(\n", " name=\"LEDA 41136\",\n", " model_type=\"group model\",\n", " models=[model_r, model_W1, model_NUV],\n", " target=target_full,\n", ")\n", "\n", - "model_full.initialize()\n", - "model_full.parameters" + "model_full.initialize()" ] }, { @@ -183,10 +188,13 @@ "# that the colour bars represent significantly different ranges since each model was allowed to fit its own Ie.\n", "# meanwhile the center, PA, q, and Re is the same for every model.\n", "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.model_image(fig1, ax1, model_full, flipx=True)\n", + "ap.plots.model_image(fig1, ax1, model_full)\n", "ax1[0].set_title(\"r-band model image\")\n", + "ax1[0].invert_xaxis()\n", "ax1[1].set_title(\"W1-band model image\")\n", + "ax1[1].invert_xaxis()\n", "ax1[2].set_title(\"NUV-band model image\")\n", + "ax1[2].invert_xaxis()\n", "plt.show()" ] }, @@ -200,10 +208,13 @@ "# with the majority of the light removed in all bands. A residual can be seen in the r band. This is likely\n", "# due to there being more structure in the r-band than just a sersic. The W1 and NUV bands look excellent though\n", "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.residual_image(fig1, ax1, model_full, flipx=True, normalize_residuals=True)\n", + "ap.plots.residual_image(fig1, ax1, model_full, normalize_residuals=True)\n", "ax1[0].set_title(\"r-band residual image\")\n", + "ax1[0].invert_xaxis()\n", "ax1[1].set_title(\"W1-band residual image\")\n", + "ax1[1].invert_xaxis()\n", "ax1[2].set_title(\"NUV-band residual image\")\n", + "ax1[2].invert_xaxis()\n", "plt.show()" ] }, @@ -239,11 +250,9 @@ "rwcs = WCS(rimg[0].header)\n", "\n", "# dont do this unless you've read and understand the coordinates explainer in the docs!\n", - "ref_loc = rwcs.pixel_to_world(0, 0)\n", - "target_r.header.reference_radec = (ref_loc.ra.deg, ref_loc.dec.deg)\n", "\n", "# Now we make our targets\n", - "target_r = ap.image.Target_Image(\n", + "target_r = ap.image.TargetImage(\n", " data=rimg_data,\n", " zeropoint=22.5,\n", " variance=\"auto\", # Note that the variance is important to ensure all images are compared with proper statistical weight. Use better estimate than auto for science!\n", @@ -251,6 +260,7 @@ " 1.12 / 2.355, 51, 0.262\n", " ), # we construct a basic gaussian psf for each image by giving the simga (arcsec), image width (pixels), and pixelscale (arcsec/pixel)\n", " wcs=rwcs,\n", + " name=\"rband\",\n", ")\n", "\n", "# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel\n", @@ -258,13 +268,13 @@ "w1img = fits.open(\n", " f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={wsize}&layer=unwise-neo7&pixscale=2.75&bands=1\"\n", ")\n", - "target_W1 = ap.image.Target_Image(\n", + "target_W1 = ap.image.TargetImage(\n", " data=np.array(w1img[0].data, dtype=np.float64),\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", " wcs=WCS(w1img[0].header),\n", - " reference_radec=target_r.window.reference_radec,\n", + " name=\"W1band\",\n", ")\n", "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel\n", @@ -272,18 +282,18 @@ "nuvimg = fits.open(\n", " f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={gsize}&layer=galex&pixscale=1.5&bands=n\"\n", ")\n", - "target_NUV = ap.image.Target_Image(\n", + "target_NUV = ap.image.TargetImage(\n", " data=np.array(nuvimg[0].data, dtype=np.float64),\n", " zeropoint=20.08,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5),\n", " wcs=WCS(nuvimg[0].header),\n", - " reference_radec=target_r.window.reference_radec,\n", + " name=\"NUVband\",\n", ")\n", - "target_full = ap.image.Target_Image_List((target_r, target_W1, target_NUV))\n", + "target_full = ap.image.TargetImageList((target_r, target_W1, target_NUV))\n", "\n", "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.target_image(fig1, ax1, target_full, flipx=True)\n", + "ap.plots.target_image(fig1, ax1, target_full)\n", "ax1[0].set_title(\"r-band image\")\n", "ax1[1].set_title(\"W1-band image\")\n", "ax1[2].set_title(\"NUV-band image\")\n", @@ -343,21 +353,19 @@ " # create the submodels for this object\n", " sub_list = []\n", " sub_list.append(\n", - " ap.models.AstroPhot_Model(\n", + " ap.models.Model(\n", " name=f\"rband model {i}\",\n", " model_type=\"sersic galaxy model\", # we could use spline models for the r-band since it is well resolved\n", " target=target_r,\n", " window=rwindows[window],\n", " psf_mode=\"full\",\n", - " parameters={\n", - " \"center\": target_r.pixel_to_plane(torch.tensor(centers[window])),\n", - " \"PA\": -PAs[window],\n", - " \"q\": qs[window],\n", - " },\n", + " center=target_r.pixel_to_plane(torch.tensor(centers[window])),\n", + " PA=-PAs[window],\n", + " q=qs[window],\n", " )\n", " )\n", " sub_list.append(\n", - " ap.models.AstroPhot_Model(\n", + " ap.models.Model(\n", " name=f\"W1band model {i}\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", @@ -366,7 +374,7 @@ " )\n", " )\n", " sub_list.append(\n", - " ap.models.AstroPhot_Model(\n", + " ap.models.Model(\n", " name=f\"NUVband model {i}\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", @@ -382,7 +390,7 @@ "\n", " # Make the multiband model for this object\n", " model_list.append(\n", - " ap.models.AstroPhot_Model(\n", + " ap.models.Model(\n", " name=f\"model {i}\",\n", " model_type=\"group model\",\n", " target=target_full,\n", @@ -390,18 +398,21 @@ " )\n", " )\n", "# Make the full model for this system of objects\n", - "MODEL = ap.models.AstroPhot_Model(\n", + "MODEL = ap.models.Model(\n", " name=f\"full model\",\n", " model_type=\"group model\",\n", " target=target_full,\n", " models=model_list,\n", ")\n", "fig, ax = plt.subplots(1, 3, figsize=(16, 5))\n", - "ap.plots.target_image(fig, ax, MODEL.target, flipx=True)\n", + "ap.plots.target_image(fig, ax, MODEL.target)\n", "ap.plots.model_window(fig, ax, MODEL)\n", "ax[0].set_title(\"r-band image\")\n", + "ax[0].invert_xaxis()\n", "ax[1].set_title(\"W1-band image\")\n", + "ax[1].invert_xaxis()\n", "ax[2].set_title(\"NUV-band image\")\n", + "ax[2].invert_xaxis()\n", "plt.show()" ] }, @@ -424,10 +435,13 @@ "outputs": [], "source": [ "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 4))\n", - "ap.plots.model_image(fig1, ax1, MODEL, flipx=True, vmax=30)\n", + "ap.plots.model_image(fig1, ax1, MODEL, vmax=30)\n", "ax1[0].set_title(\"r-band model image\")\n", + "ax1[0].invert_xaxis()\n", "ax1[1].set_title(\"W1-band model image\")\n", + "ax1[1].invert_xaxis()\n", "ax1[2].set_title(\"NUV-band model image\")\n", + "ax1[2].invert_xaxis()\n", "plt.show()" ] }, @@ -447,10 +461,13 @@ "outputs": [], "source": [ "fig, ax = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.residual_image(fig, ax, MODEL, flipx=True, normalize_residuals=True)\n", + "ap.plots.residual_image(fig, ax, MODEL, normalize_residuals=True)\n", "ax[0].set_title(\"r-band residual image\")\n", + "ax[0].invert_xaxis()\n", "ax[1].set_title(\"W1-band residual image\")\n", + "ax[1].invert_xaxis()\n", "ax[2].set_title(\"NUV-band residual image\")\n", + "ax[2].invert_xaxis()\n", "plt.show()" ] }, From dbfe1f1ab5338057ed8547dac5e6b877894e019c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 27 Jun 2025 09:23:03 -0400 Subject: [PATCH 035/191] tuning group fitting --- astrophot/fit/func/lm.py | 2 +- astrophot/image/image_object.py | 1 + astrophot/image/target_image.py | 1 + astrophot/models/group_model_object.py | 1 + astrophot/plots/visuals.py | 2 +- docs/source/tutorials/JointModels.ipynb | 16 +++++++++++----- 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 569fc7b8..bfcd1a63 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -54,7 +54,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h + 2 * grad.T @ h).item() # Avoid highly non-linear regions - if rho < 0.1 or rho > 10: + if rho < 0.05 or rho > 2: L *= Lup if improving is True: break diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index ea1adf6c..92f8ea85 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -93,6 +93,7 @@ def __init__( crval = wcs.wcs.crval crpix = wcs.wcs.crpix + print(crval, crpix) if pixelscale is not None: AP_config.ap_logger.warning( diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 5b79376c..4b107331 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -306,6 +306,7 @@ def psf(self, psf): self._psf = PSFImage( data=psf, pixelscale=self.pixelscale, + name=self.name + "_psf", ) def to(self, dtype=None, device=None): diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 20171061..d246faa1 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -97,6 +97,7 @@ def initialize(self): target (Optional["Target_Image"]): A Target_Image instance to use as the source for initializing the model parameters on this image. """ for model in self.models: + print(f"Initializing model {model.name}") model.initialize() def fit_mask(self) -> torch.Tensor: diff --git a/astrophot/plots/visuals.py b/astrophot/plots/visuals.py index 5c8e10fb..37af1d89 100644 --- a/astrophot/plots/visuals.py +++ b/astrophot/plots/visuals.py @@ -15,7 +15,7 @@ } cmap_grad = get_cmap("inferno") -cmap_div = get_cmap("twilight") # RdBu_r +cmap_div = get_cmap("seismic") # twilight RdBu_r # print(__file__) # colors = np.load(f"{__file__[:-10]}/managua_cmap.npy") # cmap_div = ListedColormap(list(reversed(colors)), name="mangua") diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 99f5d30b..6a02d0c9 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -72,7 +72,7 @@ " wcs=WCS(lw1img[0].header),\n", " name=\"W1band\",\n", ")\n", - "target_W1.crtan.to_dynamic()\n", + "# target_W1.crtan.to_dynamic()\n", "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel and is 90 pixels across\n", "lnuvimg = fits.open(\n", @@ -128,25 +128,30 @@ " target=target_r,\n", " psf_mode=\"full\",\n", ")\n", + "\n", "model_W1 = ap.models.Model(\n", " name=\"W1band model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", + " center=[0, 0],\n", " psf_mode=\"full\",\n", + " sampling_mode=\"midpoint\",\n", ")\n", + "\n", "model_NUV = ap.models.Model(\n", " name=\"NUVband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", + " center=[0, 0],\n", " psf_mode=\"full\",\n", ")\n", "\n", "# At this point we would just be fitting three separate models at the same time, not very interesting. Next\n", "# we add constraints so that some parameters are shared between all the models. It makes sense to fix\n", "# structure parameters while letting brightness parameters vary between bands so that's what we do here.\n", - "for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", - " model_W1[p].value = model_r[p]\n", - " model_NUV[p].value = model_r[p]\n", + "# for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", + "# model_W1[p].value = model_r[p]\n", + "# model_NUV[p].value = model_r[p]\n", "# Now every model will have a unique Ie, but every other parameter is shared for all three" ] }, @@ -165,7 +170,8 @@ " target=target_full,\n", ")\n", "\n", - "model_full.initialize()" + "model_full.initialize()\n", + "model_full.graphviz()" ] }, { From 9e1f9204894a5ed50b6ab8ac3c659c10d3f3e25d Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 27 Jun 2025 13:19:09 -0400 Subject: [PATCH 036/191] add ferrer and king profiles --- astrophot/models/__init__.py | 32 +++++++++++ astrophot/models/empirical_king.py | 51 ++++++++++++++++ astrophot/models/func/__init__.py | 4 ++ astrophot/models/func/empirical_king.py | 25 ++++++++ astrophot/models/func/modified_ferrer.py | 23 ++++++++ astrophot/models/mixins/__init__.py | 6 ++ astrophot/models/mixins/empirical_king.py | 67 ++++++++++++++++++++++ astrophot/models/mixins/modified_ferrer.py | 67 ++++++++++++++++++++++ astrophot/models/mixins/moffat.py | 2 +- astrophot/models/modified_ferrer.py | 51 ++++++++++++++++ 10 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 astrophot/models/empirical_king.py create mode 100644 astrophot/models/func/empirical_king.py create mode 100644 astrophot/models/func/modified_ferrer.py create mode 100644 astrophot/models/mixins/empirical_king.py create mode 100644 astrophot/models/mixins/modified_ferrer.py create mode 100644 astrophot/models/modified_ferrer.py diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 8f2ddf85..847209cf 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -64,6 +64,24 @@ MoffatWarp, MoffatSuperEllipse, ) +from .modified_ferrer import ( + ModifiedFerrerGalaxy, + ModifiedFerrerPSF, + ModifiedFerrerSuperEllipse, + ModifiedFerrerFourierEllipse, + ModifiedFerrerWarp, + ModifiedFerrerRay, + ModifiedFerrerWedge, +) +from .empirical_king import ( + EmpiricalKingGalaxy, + EmpiricalKingPSF, + EmpiricalKingSuperEllipse, + EmpiricalKingFourierEllipse, + EmpiricalKingWarp, + EmpiricalKingRay, + EmpiricalKingWedge, +) from .nuker import ( NukerGalaxy, NukerPSF, @@ -137,6 +155,20 @@ "MoffatWedge", "MoffatWarp", "MoffatSuperEllipse", + "ModifiedFerrerGalaxy", + "ModifiedFerrerPSF", + "ModifiedFerrerSuperEllipse", + "ModifiedFerrerFourierEllipse", + "ModifiedFerrerWarp", + "ModifiedFerrerRay", + "ModifiedFerrerWedge", + "EmpiricalKingGalaxy", + "EmpiricalKingPSF", + "EmpiricalKingSuperEllipse", + "EmpiricalKingFourierEllipse", + "EmpiricalKingWarp", + "EmpiricalKingRay", + "EmpiricalKingWedge", "NukerGalaxy", "NukerPSF", "NukerFourierEllipse", diff --git a/astrophot/models/empirical_king.py b/astrophot/models/empirical_king.py new file mode 100644 index 00000000..8d71d348 --- /dev/null +++ b/astrophot/models/empirical_king.py @@ -0,0 +1,51 @@ +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel +from .mixins import ( + EmpiricalKingMixin, + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + iEmpiricalKingMixin, +) + +__all__ = ( + "EmpiricalKingGalaxy", + "EmpiricalKingPSF", + "EmpiricalKingSuperEllipse", + "EmpiricalKingFourierEllipse", + "EmpiricalKingWarp", + "EmpiricalKingRay", + "EmpiricalKingWedge", +) + + +class EmpiricalKingGalaxy(EmpiricalKingMixin, RadialMixin, GalaxyModel): + usable = True + + +class EmpiricalKingPSF(EmpiricalKingMixin, RadialMixin, PSFModel): + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + usable = True + + +class EmpiricalKingSuperEllipse(EmpiricalKingMixin, SuperEllipseMixin, GalaxyModel): + usable = True + + +class EmpiricalKingFourierEllipse(EmpiricalKingMixin, FourierEllipseMixin, GalaxyModel): + usable = True + + +class EmpiricalKingWarp(EmpiricalKingMixin, WarpMixin, GalaxyModel): + usable = True + + +class EmpiricalKingRay(iEmpiricalKingMixin, RayMixin, GalaxyModel): + usable = True + + +class EmpiricalKingWedge(iEmpiricalKingMixin, WedgeMixin, GalaxyModel): + usable = True diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 181c832a..562905ad 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -19,6 +19,8 @@ ) from .sersic import sersic, sersic_n_to_b from .moffat import moffat +from .modified_ferrer import modified_ferrer +from .empirical_king import empirical_king from .gaussian import gaussian from .exponential import exponential from .nuker import nuker @@ -41,6 +43,8 @@ "sersic", "sersic_n_to_b", "moffat", + "modified_ferrer", + "empirical_king", "gaussian", "exponential", "nuker", diff --git a/astrophot/models/func/empirical_king.py b/astrophot/models/func/empirical_king.py new file mode 100644 index 00000000..542ccd16 --- /dev/null +++ b/astrophot/models/func/empirical_king.py @@ -0,0 +1,25 @@ +def empirical_king(R, Rc, Rt, alpha, I0): + """ + Empirical King profile. + + Parameters + ---------- + R : array_like + The radial distance from the center. + Rc : float + The core radius of the profile. + Rt : float + The truncation radius of the profile. + alpha : float + The power-law index of the profile. + I0 : float + The central intensity of the profile. + + Returns + ------- + array_like + The intensity at each radial distance. + """ + beta = 1 / (1 + (Rt / Rc) ** 2) ** (1 / alpha) + gamma = 1 / (1 + (R / Rc) ** 2) ** (1 / alpha) + return I0 * (R < Rt) * ((gamma - beta) / (1 - beta)) ** alpha diff --git a/astrophot/models/func/modified_ferrer.py b/astrophot/models/func/modified_ferrer.py new file mode 100644 index 00000000..fbe0327b --- /dev/null +++ b/astrophot/models/func/modified_ferrer.py @@ -0,0 +1,23 @@ +def modified_ferrer(R, rout, alpha, beta, I0): + """ + Modified Ferrer profile. + + Parameters + ---------- + R : array_like + Radial distance from the center. + rout : float + Outer radius of the profile. + alpha : float + Power-law index. + beta : float + Exponent for the modified Ferrer function. + I0 : float + Central intensity. + + Returns + ------- + array_like + The modified Ferrer profile evaluated at R. + """ + return (I0 * (1 + (R / rout) ** alpha) ** (2 - beta)) * (R < rout) diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index 12d36ac2..341e1834 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -3,6 +3,8 @@ from .sersic import SersicMixin, iSersicMixin from .exponential import ExponentialMixin, iExponentialMixin from .moffat import MoffatMixin, iMoffatMixin +from .modified_ferrer import ModifiedFerrerMixin, iModifiedFerrerMixin +from .empirical_king import EmpiricalKingMixin, iEmpiricalKingMixin from .gaussian import GaussianMixin, iGaussianMixin from .nuker import NukerMixin, iNukerMixin from .spline import SplineMixin, iSplineMixin @@ -22,6 +24,10 @@ "iExponentialMixin", "MoffatMixin", "iMoffatMixin", + "ModifiedFerrerMixin", + "iModifiedFerrerMixin", + "EmpiricalKingMixin", + "iEmpiricalKingMixin", "GaussianMixin", "iGaussianMixin", "NukerMixin", diff --git a/astrophot/models/mixins/empirical_king.py b/astrophot/models/mixins/empirical_king.py new file mode 100644 index 00000000..5fb08b2a --- /dev/null +++ b/astrophot/models/mixins/empirical_king.py @@ -0,0 +1,67 @@ +import torch + +from ...param import forward +from ...utils.decorators import ignore_numpy_warnings +from .._shared_methods import parametric_initialize, parametric_segment_initialize +from .. import func + + +def x0_func(model_params, R, F): + return R[2], R[5], 2, 10 ** F[0] + + +class EmpiricalKingMixin: + + _model_type = "empiricalking" + _parameter_specs = { + "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, + "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, + "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, + "I0": {"units": "flux/arcsec^2", "shape": ()}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + func.empirical_king, + ("Rc", "Rt", "alpha", "I0"), + x0_func, + ) + + @forward + def radial_model(self, R, Rc, Rt, alpha, I0): + return func.empirical_king(R, Rc, Rt, alpha, I0) + + +class iEmpiricalKingMixin: + + _model_type = "empiricalking" + _parameter_specs = { + "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, + "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, + "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, + "I0": {"units": "flux/arcsec^2", "shape": ()}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_segment_initialize( + model=self, + target=self.target[self.window], + prof_func=func.empirical_king, + params=("Rc", "Rt", "alpha", "I0"), + x0_func=x0_func, + segments=self.segments, + ) + + @forward + def iradial_model(self, i, R, Rc, Rt, alpha, I0): + return func.empirical_king(R, Rc[i], Rt[i], alpha[i], I0[i]) diff --git a/astrophot/models/mixins/modified_ferrer.py b/astrophot/models/mixins/modified_ferrer.py new file mode 100644 index 00000000..7cd85057 --- /dev/null +++ b/astrophot/models/mixins/modified_ferrer.py @@ -0,0 +1,67 @@ +import torch + +from ...param import forward +from ...utils.decorators import ignore_numpy_warnings +from .._shared_methods import parametric_initialize, parametric_segment_initialize +from .. import func + + +def x0_func(model_params, R, F): + return R[5], 1, 1, 10 ** F[0] + + +class ModifiedFerrerMixin: + + _model_type = "modifiedferrer" + _parameter_specs = { + "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, + "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, + "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, + "I0": {"units": "flux/arcsec^2", "shape": ()}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + func.modified_ferrer, + ("rout", "alpha", "beta", "I0"), + x0_func, + ) + + @forward + def radial_model(self, R, rout, alpha, beta, I0): + return func.modified_ferrer(R, rout, alpha, beta, I0) + + +class iModifiedFerrerMixin: + + _model_type = "modifiedferrer" + _parameter_specs = { + "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, + "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, + "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, + "I0": {"units": "flux/arcsec^2", "shape": ()}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_segment_initialize( + model=self, + target=self.target[self.window], + prof_func=func.modified_ferrer, + params=("rout", "alpha", "beta", "I0"), + x0_func=x0_func, + segments=self.segments, + ) + + @forward + def iradial_model(self, i, R, rout, alpha, beta, I0): + return func.modified_ferrer(R, rout[i], alpha[i], beta[i], I0[i]) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 153710c1..f5a568f0 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -58,5 +58,5 @@ def initialize(self): ) @forward - def radial_model(self, i, R, n, Rd, I0): + def iradial_model(self, i, R, n, Rd, I0): return func.moffat(R, n[i], Rd[i], I0[i]) diff --git a/astrophot/models/modified_ferrer.py b/astrophot/models/modified_ferrer.py new file mode 100644 index 00000000..8d77d175 --- /dev/null +++ b/astrophot/models/modified_ferrer.py @@ -0,0 +1,51 @@ +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel +from .mixins import ( + ModifiedFerrerMixin, + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + iModifiedFerrerMixin, +) + +__all__ = ( + "ModifiedFerrerGalaxy", + "ModifiedFerrerPSF", + "ModifiedFerrerSuperEllipse", + "ModifiedFerrerFourierEllipse", + "ModifiedFerrerWarp", + "ModifiedFerrerRay", + "ModifiedFerrerWedge", +) + + +class ModifiedFerrerGalaxy(ModifiedFerrerMixin, RadialMixin, GalaxyModel): + usable = True + + +class ModifiedFerrerPSF(ModifiedFerrerMixin, RadialMixin, PSFModel): + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + usable = True + + +class ModifiedFerrerSuperEllipse(ModifiedFerrerMixin, SuperEllipseMixin, GalaxyModel): + usable = True + + +class ModifiedFerrerFourierEllipse(ModifiedFerrerMixin, FourierEllipseMixin, GalaxyModel): + usable = True + + +class ModifiedFerrerWarp(ModifiedFerrerMixin, WarpMixin, GalaxyModel): + usable = True + + +class ModifiedFerrerRay(iModifiedFerrerMixin, RayMixin, GalaxyModel): + usable = True + + +class ModifiedFerrerWedge(iModifiedFerrerMixin, WedgeMixin, GalaxyModel): + usable = True From ccbc15fcb20acb52e766db8f5e388d380ef7181c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 27 Jun 2025 14:20:46 -0400 Subject: [PATCH 037/191] add softening start docstrings --- astrophot/models/mixins/__init__.py | 9 ++++- astrophot/models/mixins/empirical_king.py | 4 +- astrophot/models/mixins/exponential.py | 6 +-- astrophot/models/mixins/gaussian.py | 4 +- astrophot/models/mixins/modified_ferrer.py | 8 ++-- astrophot/models/mixins/moffat.py | 4 +- astrophot/models/mixins/nuker.py | 4 +- astrophot/models/mixins/sersic.py | 24 ++++++++++- astrophot/models/mixins/transform.py | 47 ++++++++++++++++++++-- astrophot/models/sersic.py | 33 +++++++-------- astrophot/utils/decorators.py | 9 +++++ docs/source/tutorials/GettingStarted.ipynb | 1 + docs/source/tutorials/JointModels.ipynb | 7 ++-- 13 files changed, 118 insertions(+), 42 deletions(-) diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index 341e1834..75f21d8a 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -1,5 +1,11 @@ from .brightness import RadialMixin, WedgeMixin, RayMixin -from .transform import InclinedMixin, SuperEllipseMixin, FourierEllipseMixin, WarpMixin +from .transform import ( + InclinedMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + TruncationMixin, +) from .sersic import SersicMixin, iSersicMixin from .exponential import ExponentialMixin, iExponentialMixin from .moffat import MoffatMixin, iMoffatMixin @@ -17,6 +23,7 @@ "SuperEllipseMixin", "FourierEllipseMixin", "WarpMixin", + "TruncationMixin", "InclinedMixin", "SersicMixin", "iSersicMixin", diff --git a/astrophot/models/mixins/empirical_king.py b/astrophot/models/mixins/empirical_king.py index 5fb08b2a..a44678fa 100644 --- a/astrophot/models/mixins/empirical_king.py +++ b/astrophot/models/mixins/empirical_king.py @@ -35,7 +35,7 @@ def initialize(self): @forward def radial_model(self, R, Rc, Rt, alpha, I0): - return func.empirical_king(R, Rc, Rt, alpha, I0) + return func.empirical_king(R + self.softening, Rc, Rt, alpha, I0) class iEmpiricalKingMixin: @@ -64,4 +64,4 @@ def initialize(self): @forward def iradial_model(self, i, R, Rc, Rt, alpha, I0): - return func.empirical_king(R, Rc[i], Rt[i], alpha[i], I0[i]) + return func.empirical_king(R + self.softening, Rc[i], Rt[i], alpha[i], I0[i]) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index cd506485..0f751f4a 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -43,7 +43,7 @@ def initialize(self): @forward def radial_model(self, R, Re, Ie): - return func.exponential(R, Re, Ie) + return func.exponential(R + self.softening, Re, Ie) class iExponentialMixin: @@ -82,5 +82,5 @@ def initialize(self): ) @forward - def radial_model(self, i, R, Re, Ie): - return func.exponential(R, Re[i], Ie[i]) + def iradial_model(self, i, R, Re, Ie): + return func.exponential(R + self.softening, Re[i], Ie[i]) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 8f2fd77c..1644cfd1 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -30,7 +30,7 @@ def initialize(self): @forward def radial_model(self, R, sigma, flux): - return func.gaussian(R, sigma, flux) + return func.gaussian(R + self.softening, sigma, flux) class iGaussianMixin: @@ -57,4 +57,4 @@ def initialize(self): @forward def iradial_model(self, i, R, sigma, flux): - return func.gaussian(R, sigma[i], flux[i]) + return func.gaussian(R + self.softening, sigma[i], flux[i]) diff --git a/astrophot/models/mixins/modified_ferrer.py b/astrophot/models/mixins/modified_ferrer.py index 7cd85057..6e2376e6 100644 --- a/astrophot/models/mixins/modified_ferrer.py +++ b/astrophot/models/mixins/modified_ferrer.py @@ -15,7 +15,7 @@ class ModifiedFerrerMixin: _model_type = "modifiedferrer" _parameter_specs = { "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, "I0": {"units": "flux/arcsec^2", "shape": ()}, } @@ -35,7 +35,7 @@ def initialize(self): @forward def radial_model(self, R, rout, alpha, beta, I0): - return func.modified_ferrer(R, rout, alpha, beta, I0) + return func.modified_ferrer(R + self.softening, rout, alpha, beta, I0) class iModifiedFerrerMixin: @@ -43,7 +43,7 @@ class iModifiedFerrerMixin: _model_type = "modifiedferrer" _parameter_specs = { "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, "I0": {"units": "flux/arcsec^2", "shape": ()}, } @@ -64,4 +64,4 @@ def initialize(self): @forward def iradial_model(self, i, R, rout, alpha, beta, I0): - return func.modified_ferrer(R, rout[i], alpha[i], beta[i], I0[i]) + return func.modified_ferrer(R + self.softening, rout[i], alpha[i], beta[i], I0[i]) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index f5a568f0..83d426cc 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -31,7 +31,7 @@ def initialize(self): @forward def radial_model(self, R, n, Rd, I0): - return func.moffat(R, n, Rd, I0) + return func.moffat(R + self.softening, n, Rd, I0) class iMoffatMixin: @@ -59,4 +59,4 @@ def initialize(self): @forward def iradial_model(self, i, R, n, Rd, I0): - return func.moffat(R, n[i], Rd[i], I0[i]) + return func.moffat(R + self.softening, n[i], Rd[i], I0[i]) diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 5a269a93..56d2067f 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -37,7 +37,7 @@ def initialize(self): @forward def radial_model(self, R, Rb, Ib, alpha, beta, gamma): - return func.nuker(R, Rb, Ib, alpha, beta, gamma) + return func.nuker(R + self.softening, Rb, Ib, alpha, beta, gamma) class iNukerMixin: @@ -67,4 +67,4 @@ def initialize(self): @forward def iradial_model(self, i, R, Rb, Ib, alpha, beta, gamma): - return func.nuker(R, Rb[i], Ib[i], alpha[i], beta[i], gamma[i]) + return func.nuker(R + self.softening, Rb[i], Ib[i], alpha[i], beta[i], gamma[i]) diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index e93bd3d8..2370dd51 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -12,6 +12,16 @@ def _x0_func(model, R, F): class SersicMixin: + """Sersic radial light profile. The functional form of the Sersic profile is defined as: + + $$I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1))$$ + + Parameters: + n: Sersic index which controls the shape of the brightness profile + Re: half light radius [arcsec] + Ie: intensity at the half light radius [flux/arcsec^2] + + """ _model_type = "sersic" _parameter_specs = { @@ -31,10 +41,20 @@ def initialize(self): @forward def radial_model(self, R, n, Re, Ie): - return func.sersic(R, n, Re, Ie) + return func.sersic(R + self.softening, n, Re, Ie) class iSersicMixin: + """Sersic radial light profile. The functional form of the Sersic profile is defined as: + + $$I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1))$$ + + Parameters: + n: Sersic index which controls the shape of the brightness profile + Re: half light radius [arcsec] + Ie: intensity at the half light radius [flux/arcsec^2] + + """ _model_type = "sersic" _parameter_specs = { @@ -59,4 +79,4 @@ def initialize(self): @forward def iradial_model(self, i, R, n, Re, Ie): - return func.sersic(R, n[i], Re[i], Ie[i]) + return func.sersic(R + self.softening, n[i], Re[i], Ie[i]) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 883d9518..1512ed1d 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -41,7 +41,7 @@ def initialize(self): y = (y - self.center.value[1]).detach().cpu().numpy() mu20 = np.median(dat * np.abs(x)) mu02 = np.median(dat * np.abs(y)) - mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y))) + mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) # mu20 = np.median(dat * x**2) # mu02 = np.median(dat * y**2) # mu11 = np.median(dat * x * y) @@ -54,9 +54,10 @@ def initialize(self): 0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2 ) % np.pi if self.q.value is None: - l = np.sort(np.linalg.eigvals(M)) - if np.any(np.iscomplex(l)) or np.any(~np.isfinite(l)): + if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): l = (0.7, 1.0) + else: + l = np.sort(np.linalg.eigvals(M)) self.q.dynamic_value = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) @forward @@ -237,3 +238,43 @@ def transform_coordinates(self, x, y, q_R, PA_R): q = func.spline(R, self.q_R.prof, q_R, extend="const") x, y = func.rotate(PA, x, y) return x, y / q + + +class TruncationMixin: + """Mixin for models that include a truncation radius. This is used to + limit the radial extent of the model, effectively setting a maximum + radius beyond which the model's brightness is zero. + + Parameters: + R_trunc: The truncation radius in arcseconds. + """ + + _model_type = "truncated" + _parameter_specs = { + "Rt": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "sharpness": {"units": "none", "valid": (0, None), "shape": ()}, + } + _options = ("outer_truncation",) + + def __init__(self, *args, outer_truncation=True, **kwargs): + super().__init__(*args, **kwargs) + self.outer_truncation = outer_truncation + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + if self.Rt.value is None: + prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.Rt.dynamic_value = prof[len(prof) // 2] + self.Rt.uncertainty = 0.1 + if self.sharpness.value is None: + self.sharpness.dynamic_value = 1.0 + self.sharpness.uncertainty = 0.1 + + @forward + def radial_model(self, R, Rt, sharpness): + I = super().radial_model(R) + if self.outer_truncation: + return I * (1 - torch.tanh(sharpness * (R - Rt))) / 2 + return I * (torch.tanh(sharpness * (R - Rt)) + 1) / 2 diff --git a/astrophot/models/sersic.py b/astrophot/models/sersic.py index 0f1ea475..7f4545ee 100644 --- a/astrophot/models/sersic.py +++ b/astrophot/models/sersic.py @@ -2,6 +2,7 @@ from .galaxy_model_object import GalaxyModel from .psf_model_object import PSFModel from ..utils.conversions.functions import sersic_Ie_to_flux_torch +from ..utils.decorators import combine_docstrings from .mixins import ( SersicMixin, RadialMixin, @@ -11,10 +12,12 @@ SuperEllipseMixin, FourierEllipseMixin, WarpMixin, + TruncationMixin, ) __all__ = [ "SersicGalaxy", + "TSersicGalaxy", "SersicPSF", "Sersic_Warp", "Sersic_SuperEllipse", @@ -24,25 +27,8 @@ ] +@combine_docstrings class SersicGalaxy(SersicMixin, RadialMixin, GalaxyModel): - """basic galaxy model with a sersic profile for the radial light - profile. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - usable = True @forward @@ -50,6 +36,12 @@ def total_flux(self, Ie, n, Re, q): return sersic_Ie_to_flux_torch(Ie, n, Re, q) +@combine_docstrings +class TSersicGalaxy(TruncationMixin, SersicMixin, RadialMixin, GalaxyModel): + usable = True + + +@combine_docstrings class SersicPSF(SersicMixin, RadialMixin, PSFModel): """basic point source model with a sersic profile for the radial light profile. The functional form of the Sersic profile is defined as: @@ -77,21 +69,26 @@ def total_flux(self, Ie, n, Re): return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) +@combine_docstrings class SersicSuperEllipse(SersicMixin, RadialMixin, SuperEllipseMixin, GalaxyModel): usable = True +@combine_docstrings class SersicFourierEllipse(SersicMixin, RadialMixin, FourierEllipseMixin, GalaxyModel): usable = True +@combine_docstrings class SersicWarp(SersicMixin, RadialMixin, WarpMixin, GalaxyModel): usable = True +@combine_docstrings class SersicRay(iSersicMixin, RayMixin, GalaxyModel): usable = True +@combine_docstrings class SersicWedge(iSersicMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/utils/decorators.py b/astrophot/utils/decorators.py index 238c2f20..97b1070e 100644 --- a/astrophot/utils/decorators.py +++ b/astrophot/utils/decorators.py @@ -32,3 +32,12 @@ def wrapped(*args, **kwargs): return result return wrapped + + +def combine_docstrings(cls): + combined_docs = [cls.__doc__ or ""] + for base in cls.__bases__: + if base.__doc__: + combined_docs.append(f"\n[UNIT {base.__name__}]\n{base.__doc__}") + cls.__doc__ = "\n".join(combined_docs).strip() + return cls diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 480eb582..ebc83f47 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -123,6 +123,7 @@ " name=\"model with target\",\n", " model_type=\"sersic galaxy model\", # feel free to swap out sersic with other profile types\n", " target=target, # now the model knows what its trying to match\n", + " sampling_mode=\"quad:5\",\n", ")\n", "\n", "# Instead of giving initial values for all the parameters, it is possible to simply call \"initialize\" and AstroPhot\n", diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 6a02d0c9..2e3ca716 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -69,7 +69,9 @@ " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", - " wcs=WCS(lw1img[0].header),\n", + " # wcs=WCS(lw1img[0].header),\n", + " pixelscale=2.75,\n", + " crpix=(26, 26),\n", " name=\"W1band\",\n", ")\n", "# target_W1.crtan.to_dynamic()\n", @@ -133,9 +135,8 @@ " name=\"W1band model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", - " center=[0, 0],\n", + " center=[0, 0.1],\n", " psf_mode=\"full\",\n", - " sampling_mode=\"midpoint\",\n", ")\n", "\n", "model_NUV = ap.models.Model(\n", From f6e51fd54af611645edaa9a608f75911b2290eec Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 2 Jul 2025 15:56:37 -0400 Subject: [PATCH 038/191] fix softening bug, more robust initialize --- astrophot/fit/func/lm.py | 13 +++++++--- astrophot/image/image_object.py | 2 +- astrophot/models/_shared_methods.py | 10 ++++---- astrophot/models/airy.py | 8 +++--- astrophot/models/base.py | 6 +++-- astrophot/models/edgeon.py | 10 ++++---- astrophot/models/eigen.py | 4 +-- astrophot/models/flatsky.py | 2 +- astrophot/models/mixins/empirical_king.py | 4 +-- astrophot/models/mixins/exponential.py | 4 +-- astrophot/models/mixins/gaussian.py | 4 +-- astrophot/models/mixins/modified_ferrer.py | 4 +-- astrophot/models/mixins/moffat.py | 4 +-- astrophot/models/mixins/nuker.py | 4 +-- astrophot/models/mixins/sersic.py | 4 +-- astrophot/models/mixins/transform.py | 18 ++++++------- astrophot/models/model_object.py | 3 --- astrophot/models/multi_gaussian_expansion.py | 12 ++++----- astrophot/models/pixelated_psf.py | 9 ++++--- astrophot/models/planesky.py | 4 +-- astrophot/models/point_source.py | 2 +- astrophot/models/psf_model_object.py | 5 +--- astrophot/models/zernike.py | 2 +- astrophot/param/param.py | 9 +++++++ docs/source/tutorials/GettingStarted.ipynb | 3 ++- docs/source/tutorials/JointModels.ipynb | 27 +++++++++++++++----- 26 files changed, 102 insertions(+), 75 deletions(-) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index bfcd1a63..075076cb 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -19,7 +19,7 @@ def damp_hessian(hess, L): def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): - + print("LM step") chi20 = chi2 M0 = model(x) # (M,) J = jacobian(x) # (M, N) @@ -33,6 +33,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. nostep = True improving = None for _ in range(10): + print(_) hessD = damp_hessian(hess, L) # (N, N) h = torch.linalg.solve(hessD, grad) # (N, 1) M1 = model(x + h.squeeze(1)) # (M,) @@ -41,6 +42,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. # Handle nan chi2 if not np.isfinite(chi21): + print("NaN chi2, trying to damp more") L *= Lup if improving is True: break @@ -52,9 +54,10 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. # actual chi2 improvement vs expected from linearization rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h + 2 * grad.T @ h).item() - + print("rho", rho) # Avoid highly non-linear regions - if rho < 0.05 or rho > 2: + if rho < 0.1 or rho > 2: + print(f"rho shows non-linearity: {rho:.3f}, trying to damp more") L *= Lup if improving is True: break @@ -62,6 +65,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. continue if chi21 < best["chi2"]: # new best + print(f"Found new best chi2: {chi21:.3f} (was {best['chi2']:.3f})") best = {"h": h.squeeze(1), "chi2": chi21, "L": L} nostep = False L /= Ldn @@ -69,8 +73,10 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. break improving = True elif improving is True: # were improving, now not improving + print("were improving, now not improving") break else: # not improving and bad chi2, damp more + print(f"Not improving chi2: {chi21:.3f} (was {best['chi2']:.3f}), trying to damp more") L *= Lup if L >= 1e9: break @@ -78,6 +84,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. # If we are improving chi2 by more than 10% then we can stop if (best["chi2"] - chi20) / chi20 < -0.1: + print("significant improvement going to next step") break if nostep: diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 92f8ea85..87a4ddb0 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -92,7 +92,7 @@ def __init__( ) crval = wcs.wcs.crval - crpix = wcs.wcs.crpix + crpix = np.array(wcs.wcs.crpix) - 1 # handle FITS 1-indexing print(crval, crpix) if pixelscale is not None: diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 11f03375..a7e70fe4 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -80,7 +80,7 @@ def _sample_image( @torch.no_grad() @ignore_numpy_warnings def parametric_initialize(model, target, prof_func, params, x0_func): - if all(list(model[param].value is not None for param in params)): + if all(list(model[param].initialized for param in params)): return # Get the sub-image area corresponding to the model image @@ -88,7 +88,7 @@ def parametric_initialize(model, target, prof_func, params, x0_func): x0 = list(x0_func(model, R, I)) for i, param in enumerate(params): - x0[i] = x0[i] if model[param].value is None else model[param].npvalue + x0[i] = x0[i] if not model[param].initialized else model[param].npvalue def optim(x, r, f, u): residual = ((f - np.log10(prof_func(r, *x))) / u) ** 2 @@ -115,7 +115,7 @@ def optim(x, r, f, u): N = np.random.randint(0, len(R), len(R)) reses.append(minimize(optim, x0=x0, args=(R[N], I[N], S[N]), method="Nelder-Mead")) for param, x0x in zip(params, x0): - if model[param].value is None: + if not model[param].initialized: model[param].dynamic_value = x0x if model[param].uncertainty is None: model[param].uncertainty = np.std( @@ -133,7 +133,7 @@ def parametric_segment_initialize( x0_func=None, segments=None, ): - if all(list(model[param].value is not None for param in params)): + if all(list(model[param].initialized for param in params)): return cycle = np.pi if model.symmetric else 2 * np.pi @@ -177,6 +177,6 @@ def optim(x, r, f, u): values = np.stack(values).T uncertainties = np.stack(uncertainties).T for param, v, u in zip(params, values, uncertainties): - if model[param].value is None: + if not model[param].initialized: model[param].dynamic_value = v model[param].uncertainty = u diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index 45a4e160..58077f5a 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -49,22 +49,22 @@ class AiryPSF(RadialMixin, PSFModel): def initialize(self): super().initialize() - if (self.I0.value is not None) and (self.aRL.value is not None): + if self.I0.initialized and self.aRL.initialized: return icenter = self.target.plane_to_pixel(*self.center.value) - if self.I0.value is None: + if not self.I0.initialized: mid_chunk = self.target.data.value[ int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] self.I0.dynamic_value = torch.mean(mid_chunk) / self.target.pixel_area self.I0.uncertainty = torch.std(mid_chunk) / self.target.pixel_area - if self.aRL.value is None: + if not self.aRL.initialized: self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixel_length self.aRL.uncertainty = self.aRL.value * self.default_uncertainty @forward def radial_model(self, R, I0, aRL): - x = 2 * torch.pi * aRL * (R + self.softening) + x = 2 * torch.pi * aRL * R return I0 * (2 * torch.special.bessel_j1(x) / x) ** 2 diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 80edde55..ffa7d95d 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -84,7 +84,9 @@ class defines the signatures to interact with AstroPhot models _model_type = "model" _parameter_specs = {} default_uncertainty = 1e-2 # During initialization, uncertainty will be assumed 1% of initial value if no uncertainty is given - _options = ("default_uncertainty",) + # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) + softening = 1e-3 # arcsec + _options = ("default_uncertainty", "softening") usable = False def __new__(cls, *, filename=None, model_type=None, **kwargs): @@ -296,7 +298,7 @@ def List_Models(cls, usable: Optional[bool] = None, types: bool = False) -> set: return result def radius_metric(self, x, y): - return (x**2 + y**2).sqrt() + return (x**2 + y**2 + self.softening**2).sqrt() def angular_metric(self, x, y): return torch.atan2(y, x) diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index 98b16875..52ecd22d 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -32,7 +32,7 @@ class EdgeonModel(ComponentModel): @ignore_numpy_warnings def initialize(self): super().initialize() - if self.PA.value is not None: + if self.PA.initialized: return target_area = self.target[self.window] dat = target_area.data.npvalue.copy() @@ -76,19 +76,19 @@ class EdgeonSech(EdgeonModel): @ignore_numpy_warnings def initialize(self): super().initialize() - if (self.I0.value is not None) and (self.hs.value is not None): + if self.I0.initialized and self.hs.initialized: return target_area = self.target[self.window] icenter = target_area.plane_to_pixel(*self.center.value) - if self.I0.value is None: + if not self.I0.initialized: chunk = target_area.data.value[ int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] self.I0.dynamic_value = torch.mean(chunk) / self.target.pixel_area self.I0.uncertainty = torch.std(chunk) / self.target.pixel_area - if self.hs.value is None: + if not self.hs.initialized: self.hs.value = torch.max(self.window.shape) * target_area.pixel_length * 0.1 self.hs.uncertainty = self.hs.value / 2 @@ -112,7 +112,7 @@ class EdgeonIsothermal(EdgeonSech): @ignore_numpy_warnings def initialize(self): super().initialize() - if self.rs.value is not None: + if self.rs.initialized: return self.rs.value = torch.max(self.window.shape) * self.target.pixel_length * 0.4 self.rs.uncertainty = self.rs.value / 2 diff --git a/astrophot/models/eigen.py b/astrophot/models/eigen.py index a45e54f9..00d9afcc 100644 --- a/astrophot/models/eigen.py +++ b/astrophot/models/eigen.py @@ -60,12 +60,12 @@ def __init__(self, *args, eigen_basis=None, **kwargs): def initialize(self): super().initialize() target_area = self.target[self.window] - if self.flux.value is None: + if not self.flux.initialized: self.flux.dynamic_value = ( torch.abs(torch.sum(target_area.data)) / target_area.pixel_area ) self.flux.uncertainty = self.flux.value * self.default_uncertainty - if self.weights.value is None: + if not self.weights.initialized: self.weights.dynamic_value = 1 / np.arange(len(self.eigen_basis)) self.weights.uncertainty = self.weights.value * self.default_uncertainty diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 2450b839..39e7f6fb 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -29,7 +29,7 @@ class FlatSky(SkyModel): def initialize(self): super().initialize() - if self.I.value is not None: + if self.I.initialized: return dat = self.target[self.window].data.npvalue.copy() diff --git a/astrophot/models/mixins/empirical_king.py b/astrophot/models/mixins/empirical_king.py index a44678fa..5fb08b2a 100644 --- a/astrophot/models/mixins/empirical_king.py +++ b/astrophot/models/mixins/empirical_king.py @@ -35,7 +35,7 @@ def initialize(self): @forward def radial_model(self, R, Rc, Rt, alpha, I0): - return func.empirical_king(R + self.softening, Rc, Rt, alpha, I0) + return func.empirical_king(R, Rc, Rt, alpha, I0) class iEmpiricalKingMixin: @@ -64,4 +64,4 @@ def initialize(self): @forward def iradial_model(self, i, R, Rc, Rt, alpha, I0): - return func.empirical_king(R + self.softening, Rc[i], Rt[i], alpha[i], I0[i]) + return func.empirical_king(R, Rc[i], Rt[i], alpha[i], I0[i]) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 0f751f4a..911086a0 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -43,7 +43,7 @@ def initialize(self): @forward def radial_model(self, R, Re, Ie): - return func.exponential(R + self.softening, Re, Ie) + return func.exponential(R, Re, Ie) class iExponentialMixin: @@ -83,4 +83,4 @@ def initialize(self): @forward def iradial_model(self, i, R, Re, Ie): - return func.exponential(R + self.softening, Re[i], Ie[i]) + return func.exponential(R, Re[i], Ie[i]) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 1644cfd1..8f2fd77c 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -30,7 +30,7 @@ def initialize(self): @forward def radial_model(self, R, sigma, flux): - return func.gaussian(R + self.softening, sigma, flux) + return func.gaussian(R, sigma, flux) class iGaussianMixin: @@ -57,4 +57,4 @@ def initialize(self): @forward def iradial_model(self, i, R, sigma, flux): - return func.gaussian(R + self.softening, sigma[i], flux[i]) + return func.gaussian(R, sigma[i], flux[i]) diff --git a/astrophot/models/mixins/modified_ferrer.py b/astrophot/models/mixins/modified_ferrer.py index 6e2376e6..6edc44b5 100644 --- a/astrophot/models/mixins/modified_ferrer.py +++ b/astrophot/models/mixins/modified_ferrer.py @@ -35,7 +35,7 @@ def initialize(self): @forward def radial_model(self, R, rout, alpha, beta, I0): - return func.modified_ferrer(R + self.softening, rout, alpha, beta, I0) + return func.modified_ferrer(R, rout, alpha, beta, I0) class iModifiedFerrerMixin: @@ -64,4 +64,4 @@ def initialize(self): @forward def iradial_model(self, i, R, rout, alpha, beta, I0): - return func.modified_ferrer(R + self.softening, rout[i], alpha[i], beta[i], I0[i]) + return func.modified_ferrer(R, rout[i], alpha[i], beta[i], I0[i]) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 83d426cc..f5a568f0 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -31,7 +31,7 @@ def initialize(self): @forward def radial_model(self, R, n, Rd, I0): - return func.moffat(R + self.softening, n, Rd, I0) + return func.moffat(R, n, Rd, I0) class iMoffatMixin: @@ -59,4 +59,4 @@ def initialize(self): @forward def iradial_model(self, i, R, n, Rd, I0): - return func.moffat(R + self.softening, n[i], Rd[i], I0[i]) + return func.moffat(R, n[i], Rd[i], I0[i]) diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 56d2067f..5a269a93 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -37,7 +37,7 @@ def initialize(self): @forward def radial_model(self, R, Rb, Ib, alpha, beta, gamma): - return func.nuker(R + self.softening, Rb, Ib, alpha, beta, gamma) + return func.nuker(R, Rb, Ib, alpha, beta, gamma) class iNukerMixin: @@ -67,4 +67,4 @@ def initialize(self): @forward def iradial_model(self, i, R, Rb, Ib, alpha, beta, gamma): - return func.nuker(R + self.softening, Rb[i], Ib[i], alpha[i], beta[i], gamma[i]) + return func.nuker(R, Rb[i], Ib[i], alpha[i], beta[i], gamma[i]) diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index 2370dd51..4c594108 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -41,7 +41,7 @@ def initialize(self): @forward def radial_model(self, R, n, Re, Ie): - return func.sersic(R + self.softening, n, Re, Ie) + return func.sersic(R, n, Re, Ie) class iSersicMixin: @@ -79,4 +79,4 @@ def initialize(self): @forward def iradial_model(self, i, R, n, Re, Ie): - return func.sersic(R + self.softening, n[i], Re[i], Ie[i]) + return func.sersic(R, n[i], Re[i], Ie[i]) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 1512ed1d..319f9d20 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -26,7 +26,7 @@ class InclinedMixin: def initialize(self): super().initialize() - if not (self.PA.value is None or self.q.value is None): + if self.PA.initialized and self.q.initialized: return target_area = self.target[self.window] dat = target_area.data.npvalue.copy() @@ -46,14 +46,14 @@ def initialize(self): # mu02 = np.median(dat * y**2) # mu11 = np.median(dat * x * y) M = np.array([[mu20, mu11], [mu11, mu02]]) - if self.PA.value is None: + if not self.PA.initialized: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): self.PA.dynamic_value = np.pi / 2 else: self.PA.dynamic_value = ( 0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2 ) % np.pi - if self.q.value is None: + if not self.q.initialized: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): l = (0.7, 1.0) else: @@ -169,10 +169,10 @@ def radius_metric(self, x, y, am, phim): def initialize(self): super().initialize() - if self.am.value is None: + if not self.am.initialized: self.am.dynamic_value = np.zeros(len(self.modes)) self.am.uncertainty = self.default_uncertainty * np.ones(len(self.modes)) - if self.phim.value is None: + if not self.phim.initialized: self.phim.value = np.zeros(len(self.modes)) self.phim.uncertainty = (10 * np.pi / 180) * np.ones(len(self.modes)) @@ -219,12 +219,12 @@ class WarpMixin: def initialize(self): super().initialize() - if self.PA_R.value is None: + if not self.PA_R.initialized: if self.PA_R.prof is None: self.PA_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 self.PA_R.uncertainty = (10 * np.pi / 180) * torch.ones_like(self.PA_R.value) - if self.q_R.value is None: + if not self.q_R.initialized: if self.q_R.prof is None: self.q_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 @@ -264,11 +264,11 @@ def __init__(self, *args, outer_truncation=True, **kwargs): @ignore_numpy_warnings def initialize(self): super().initialize() - if self.Rt.value is None: + if not self.Rt.initialize: prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) self.Rt.dynamic_value = prof[len(prof) // 2] self.Rt.uncertainty = 0.1 - if self.sharpness.value is None: + if not self.sharpness.initialized: self.sharpness.dynamic_value = 1.0 self.sharpness.uncertainty = 0.1 diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index ad922fd9..9f36fb0c 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -63,13 +63,10 @@ class ComponentModel(SampleMixin, Model): psf_subpixel_shift = ( False # False: no shift to align sampling with pixel center, True: use FFT shift theorem ) - # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) - softening = 1e-3 # arcsec _options = ( "psf_mode", "psf_subpixel_shift", - "softening", ) usable = False diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 1fde4843..c76b58d8 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -61,18 +61,18 @@ def initialize(self): edge_average = np.nanmedian(edge) dat -= edge_average - if self.sigma.value is None: + if not self.sigma.initialized: self.sigma.dynamic_value = np.logspace( np.log10(target_area.pixel_length.item() * 3), max(target_area.shape) * target_area.pixel_length.item() * 0.7, self.n_components, ) self.sigma.uncertainty = self.default_uncertainty * self.sigma.value - if self.flux.value is None: + if not self.flux.initialized: self.flux.dynamic_value = (np.sum(dat) / self.n_components) * np.ones(self.n_components) self.flux.uncertainty = self.default_uncertainty * self.flux.value - if not (self.PA.value is None or self.q.value is None): + if self.PA.initialized or self.q.initialized: return x, y = target_area.coordinate_center_meshgrid() @@ -80,20 +80,20 @@ def initialize(self): y = (y - self.center.value[1]).detach().cpu().numpy() mu20 = np.median(dat * np.abs(x)) mu02 = np.median(dat * np.abs(y)) - mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y))) + mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) # mu20 = np.median(dat * x**2) # mu02 = np.median(dat * y**2) # mu11 = np.median(dat * x * y) M = np.array([[mu20, mu11], [mu11, mu02]]) ones = np.ones(self.n_components) - if self.PA.value is None: + if not self.PA.initialized: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): self.PA.dynamic_value = ones * np.pi / 2 else: self.PA.dynamic_value = ( ones * (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi ) - if self.q.value is None: + if not self.q.initialized: l = np.sort(np.linalg.eigvals(M)) if np.any(np.iscomplex(l)) or np.any(~np.isfinite(l)): l = (0.7, 1.0) diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index 2407a8c9..0e1e92de 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -45,10 +45,11 @@ class PixelatedPSF(PSFModel): @ignore_numpy_warnings def initialize(self): super().initialize() - if self.pixels.value is None: - target_area = self.target[self.window] - self.pixels.dynamic_value = target_area.data.value.clone() / target_area.pixel_area - self.pixels.uncertainty = torch.abs(self.pixels.value) * self.default_uncertainty + if self.pixels.initialized: + return + target_area = self.target[self.window] + self.pixels.dynamic_value = target_area.data.value.clone() / target_area.pixel_area + self.pixels.uncertainty = torch.abs(self.pixels.value) * self.default_uncertainty @forward def brightness(self, x, y, pixels, center): diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index c3f419bf..d7d47d2f 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -36,7 +36,7 @@ class PlaneSky(SkyModel): def initialize(self): super().initialize() - if self.I0.value is None: + if not self.I0.initialized: self.I0.dynamic_value = ( np.median(self.target[self.window].data.npvalue) / self.target.pixel_area.item() ) @@ -47,7 +47,7 @@ def initialize(self): ) / 2.0 ) / np.sqrt(np.prod(self.window.shape.detach().cpu().numpy())) - if self.delta.value is None: + if not self.delta.initialized: self.delta.dynamic_value = [0.0, 0.0] self.delta.uncertainty = [ self.default_uncertainty, diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 08e0e099..36b8b032 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -48,7 +48,7 @@ def __init__(self, *args, **kwargs): def initialize(self): super().initialize() - if not hasattr(self, "logflux") or self.logflux.value is not None: + if not hasattr(self, "logflux") or self.logflux.initialized: return target_area = self.target[self.window] dat = target_area.data.npvalue.copy() diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 5f1efd8b..59f2824e 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -37,11 +37,8 @@ class PSFModel(SampleMixin, Model): # The sampled PSF will be normalized to a total flux of 1 within the window normalize_psf = True - # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) - softening = 1e-3 # arcsec - # Parameters which are treated specially by the model object and should not be updated directly when initializing - _options = ("softening", "normalize_psf") + _options = ("normalize_psf",) def initialize(self): pass diff --git a/astrophot/models/zernike.py b/astrophot/models/zernike.py index 22c343cd..815e23e8 100644 --- a/astrophot/models/zernike.py +++ b/astrophot/models/zernike.py @@ -36,7 +36,7 @@ def initialize(self): self.r_scale = max(self.window.shape) / 2 # Check if user has already set the coefficients - if self.Anm.value is not None: + if self.Anm.initialized: if len(self.nm_list) != len(self.Anm.value): raise SpecificationConflict( f"nm_list length ({len(self.nm_list)}) must match coefficients ({len(self.Anm.value)})" diff --git a/astrophot/param/param.py b/astrophot/param/param.py index 28707a9b..90dbb43b 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -36,3 +36,12 @@ def prof(self, prof): self._prof = None else: self._prof = torch.as_tensor(prof) + + @property + def initialized(self): + """Check if the parameter is initialized.""" + if self.pointer: + return True + if self.value is not None: + return True + return False diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index ebc83f47..16146084 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -123,7 +123,6 @@ " name=\"model with target\",\n", " model_type=\"sersic galaxy model\", # feel free to swap out sersic with other profile types\n", " target=target, # now the model knows what its trying to match\n", - " sampling_mode=\"quad:5\",\n", ")\n", "\n", "# Instead of giving initial values for all the parameters, it is possible to simply call \"initialize\" and AstroPhot\n", @@ -161,6 +160,7 @@ "metadata": {}, "outputs": [], "source": [ + "print(model2)\n", "# we now plot the fitted model and the image residuals\n", "fig5, ax5 = plt.subplots(1, 2, figsize=(16, 6))\n", "ap.plots.model_image(fig5, ax5[0], model2)\n", @@ -278,6 +278,7 @@ "outputs": [], "source": [ "# Note that when only a window is fit, the default plotting methods will only show that window\n", + "print(model3)\n", "fig7, ax7 = plt.subplots(1, 2, figsize=(16, 6))\n", "ap.plots.model_image(fig7, ax7[0], model3)\n", "ap.plots.residual_image(fig7, ax7[1], model3, normalize_residuals=True)\n", diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 2e3ca716..06ef98e4 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -64,14 +64,15 @@ "lw1img = fits.open(\n", " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1\"\n", ")\n", + "print(WCS(lw1img[0].header))\n", "target_W1 = ap.image.TargetImage(\n", " data=np.array(lw1img[0].data, dtype=np.float64),\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", - " # wcs=WCS(lw1img[0].header),\n", - " pixelscale=2.75,\n", - " crpix=(26, 26),\n", + " wcs=WCS(lw1img[0].header),\n", + " # pixelscale=[[-2.75,0],[0,2.75]],\n", + " # crpix=(26, 26),\n", " name=\"W1band\",\n", ")\n", "# target_W1.crtan.to_dynamic()\n", @@ -128,6 +129,7 @@ " name=\"rband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_r,\n", + " center=[0, 0],\n", " psf_mode=\"full\",\n", ")\n", "\n", @@ -135,7 +137,8 @@ " name=\"W1band model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", - " center=[0, 0.1],\n", + " center=[0, 0],\n", + " PA=-2.37,\n", " psf_mode=\"full\",\n", ")\n", "\n", @@ -150,9 +153,9 @@ "# At this point we would just be fitting three separate models at the same time, not very interesting. Next\n", "# we add constraints so that some parameters are shared between all the models. It makes sense to fix\n", "# structure parameters while letting brightness parameters vary between bands so that's what we do here.\n", - "# for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", - "# model_W1[p].value = model_r[p]\n", - "# model_NUV[p].value = model_r[p]\n", + "for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", + " model_W1[p].value = model_r[p]\n", + " model_NUV[p].value = model_r[p]\n", "# Now every model will have a unique Ie, but every other parameter is shared for all three" ] }, @@ -172,6 +175,16 @@ ")\n", "\n", "model_full.initialize()\n", + "print(model_full)\n", + "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", + "ap.plots.model_image(fig1, ax1, model_full)\n", + "ax1[0].set_title(\"r-band model image\")\n", + "ax1[0].invert_xaxis()\n", + "ax1[1].set_title(\"W1-band model image\")\n", + "ax1[1].invert_xaxis()\n", + "ax1[2].set_title(\"NUV-band model image\")\n", + "ax1[2].invert_xaxis()\n", + "plt.show()\n", "model_full.graphviz()" ] }, From 64517eda552c827b283dd2e5d9b65b7115acd630 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 2 Jul 2025 16:02:14 -0400 Subject: [PATCH 039/191] cleanup --- docs/source/tutorials/JointModels.ipynb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 06ef98e4..4608bb2d 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -64,18 +64,14 @@ "lw1img = fits.open(\n", " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1\"\n", ")\n", - "print(WCS(lw1img[0].header))\n", "target_W1 = ap.image.TargetImage(\n", " data=np.array(lw1img[0].data, dtype=np.float64),\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", " wcs=WCS(lw1img[0].header),\n", - " # pixelscale=[[-2.75,0],[0,2.75]],\n", - " # crpix=(26, 26),\n", " name=\"W1band\",\n", ")\n", - "# target_W1.crtan.to_dynamic()\n", "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel and is 90 pixels across\n", "lnuvimg = fits.open(\n", @@ -89,7 +85,6 @@ " wcs=WCS(lnuvimg[0].header),\n", " name=\"NUVband\",\n", ")\n", - "# target_NUV.crtan.to_dynamic()\n", "\n", "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", "ap.plots.target_image(fig1, ax1[0], target_r)\n", From 18bf0376ae0f078ccdeeebab17f8c402c9b9c56a Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 2 Jul 2025 21:55:11 -0400 Subject: [PATCH 040/191] working on joint model tutorial --- astrophot/fit/func/lm.py | 15 +++---------- astrophot/models/mixins/sersic.py | 22 ++++++++++++++++--- astrophot/plots/image.py | 2 +- .../utils/initialize/segmentation_map.py | 22 ++++++++++++++----- docs/source/tutorials/JointModels.ipynb | 6 ++++- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 075076cb..3cbf6327 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -16,10 +16,10 @@ def damp_hessian(hess, L): I = torch.eye(len(hess), dtype=hess.dtype, device=hess.device) D = torch.ones_like(hess) - I return hess * (I + D / (1 + L)) + L * I * (1 + torch.diag(hess)) + # return hess + L * I * torch.diag(hess) def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): - print("LM step") chi20 = chi2 M0 = model(x) # (M,) J = jacobian(x) # (M, N) @@ -33,7 +33,6 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. nostep = True improving = None for _ in range(10): - print(_) hessD = damp_hessian(hess, L) # (N, N) h = torch.linalg.solve(hessD, grad) # (N, 1) M1 = model(x + h.squeeze(1)) # (M,) @@ -42,7 +41,6 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. # Handle nan chi2 if not np.isfinite(chi21): - print("NaN chi2, trying to damp more") L *= Lup if improving is True: break @@ -53,11 +51,9 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. scary = {"h": h.squeeze(1), "chi2": chi21, "L": L} # actual chi2 improvement vs expected from linearization - rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h + 2 * grad.T @ h).item() - print("rho", rho) + rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() # Avoid highly non-linear regions - if rho < 0.1 or rho > 2: - print(f"rho shows non-linearity: {rho:.3f}, trying to damp more") + if rho < 0.2 or rho > 2: L *= Lup if improving is True: break @@ -65,7 +61,6 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. continue if chi21 < best["chi2"]: # new best - print(f"Found new best chi2: {chi21:.3f} (was {best['chi2']:.3f})") best = {"h": h.squeeze(1), "chi2": chi21, "L": L} nostep = False L /= Ldn @@ -73,10 +68,8 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. break improving = True elif improving is True: # were improving, now not improving - print("were improving, now not improving") break else: # not improving and bad chi2, damp more - print(f"Not improving chi2: {chi21:.3f} (was {best['chi2']:.3f}), trying to damp more") L *= Lup if L >= 1e9: break @@ -84,12 +77,10 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. # If we are improving chi2 by more than 10% then we can stop if (best["chi2"] - chi20) / chi20 < -0.1: - print("significant improvement going to next step") break if nostep: if scary["h"] is not None: - print("scary") return scary raise OptimizeStop("Could not find step to improve chi^2") diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index 4c594108..3ef07dff 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -8,7 +8,7 @@ def _x0_func(model, R, F): - return 2.0, R[4], 10 ** F[4] + return 2.0, R[4], F[4] class SersicMixin: @@ -29,6 +29,14 @@ class SersicMixin: "Re": {"units": "arcsec", "valid": (0, None), "shape": ()}, "Ie": {"units": "flux/arcsec^2", "shape": ()}, } + _overload_parameter_specs = { + "logIe": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "Ie", + "overload_function": lambda p: 10**p.logIe.value, + } + } @torch.no_grad() @ignore_numpy_warnings @@ -36,7 +44,7 @@ def initialize(self): super().initialize() parametric_initialize( - self, self.target[self.window], sersic_np, ("n", "Re", "Ie"), _x0_func + self, self.target[self.window], sersic_np, ("n", "Re", "logIe"), _x0_func ) @forward @@ -62,6 +70,14 @@ class iSersicMixin: "Re": {"units": "arcsec", "valid": (0, None)}, "Ie": {"units": "flux/arcsec^2"}, } + _overload_parameter_specs = { + "logIe": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "Ie", + "overload_function": lambda p: 10**p.logIe.value, + } + } @torch.no_grad() @ignore_numpy_warnings @@ -72,7 +88,7 @@ def initialize(self): model=self, target=self.target[self.window], prof_func=sersic_np, - params=("n", "Re", "Ie"), + params=("n", "Re", "logIe"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 6b8ce757..ef4f686e 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -418,7 +418,7 @@ def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): if isinstance(model, GroupModel): for m in model.models: if isinstance(m.window, WindowList): - use_window = m.window.window_list[m.target.index(target)] + use_window = m.window.windows[m.target.index(target)] else: use_window = m.window diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index ecd8ee97..1cfa8528 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -313,30 +313,40 @@ def transfer_windows(windows, base_image, new_image): for w in list(windows.keys()): bottom_corner = np.clip( np.floor( - new_image.plane_to_pixel( - base_image.pixel_to_plane(torch.tensor([windows[w][0][0], windows[w][1][0]])) + torch.stack( + new_image.plane_to_pixel( + *base_image.pixel_to_plane( + *torch.tensor([windows[w][0][0], windows[w][0][1]]) + ) + ) ) .detach() .cpu() .numpy() + .astype(int) ), a_min=0, a_max=np.array(new_image.shape) - 1, ) top_corner = np.clip( np.ceil( - new_image.plane_to_pixel( - base_image.pixel_to_plane(torch.tensor([windows[w][0][1], windows[w][1][1]])) + torch.stack( + new_image.plane_to_pixel( + *base_image.pixel_to_plane( + *torch.tensor([windows[w][1][0], windows[w][1][1]]) + ) + ) ) .detach() .cpu() .numpy() + .astype(int) ), a_min=0, a_max=np.array(new_image.shape) - 1, ) new_windows[w] = [ - [bottom_corner[0], top_corner[0]], - [bottom_corner[1], top_corner[1]], + [int(bottom_corner[0]), int(bottom_corner[1])], + [int(top_corner[0]), int(top_corner[1])], ] return new_windows diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 4608bb2d..eb7373f8 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -144,6 +144,8 @@ " center=[0, 0],\n", " psf_mode=\"full\",\n", ")\n", + "model_NUV.initialize()\n", + "result = ap.fit.LM(model_NUV, verbose=1).fit()\n", "\n", "# At this point we would just be fitting three separate models at the same time, not very interesting. Next\n", "# we add constraints so that some parameters are shared between all the models. It makes sense to fix\n", @@ -341,6 +343,8 @@ ")\n", "w1windows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_W1)\n", "nuvwindows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_NUV)\n", + "print(f\"W1-band windows: {w1windows}\")\n", + "print(f\"NUV-band windows: {nuvwindows}\")\n", "# Here we get some basic starting parameters for the galaxies (center, position angle, axis ratio)\n", "centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, rimg_data)\n", "PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, rimg_data, centers)\n", @@ -374,7 +378,7 @@ " target=target_r,\n", " window=rwindows[window],\n", " psf_mode=\"full\",\n", - " center=target_r.pixel_to_plane(torch.tensor(centers[window])),\n", + " center=torch.stack(target_r.pixel_to_plane(*torch.tensor(centers[window]))),\n", " PA=-PAs[window],\n", " q=qs[window],\n", " )\n", From e8534ac1b9b513525f566c72e178a98ec8791ebe Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 2 Jul 2025 22:32:46 -0400 Subject: [PATCH 041/191] sersic now with logIe --- astrophot/models/mixins/sersic.py | 8 ++++++-- docs/source/tutorials/JointModels.ipynb | 7 +------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index 3ef07dff..64e227e2 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -44,7 +44,11 @@ def initialize(self): super().initialize() parametric_initialize( - self, self.target[self.window], sersic_np, ("n", "Re", "logIe"), _x0_func + self, + self.target[self.window], + lambda r, *x: sersic_np(r, x[0], x[1], 10 ** x[2]), + ("n", "Re", "logIe"), + _x0_func, ) @forward @@ -87,7 +91,7 @@ def initialize(self): parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=sersic_np, + prof_func=lambda r, *x: sersic_np(r, x[0], x[1], 10 ** x[2]), params=("n", "Re", "logIe"), x0_func=_x0_func, segments=self.segments, diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index eb7373f8..7671c125 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -124,7 +124,6 @@ " name=\"rband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_r,\n", - " center=[0, 0],\n", " psf_mode=\"full\",\n", ")\n", "\n", @@ -132,8 +131,6 @@ " name=\"W1band model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", - " center=[0, 0],\n", - " PA=-2.37,\n", " psf_mode=\"full\",\n", ")\n", "\n", @@ -141,11 +138,8 @@ " name=\"NUVband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", - " center=[0, 0],\n", " psf_mode=\"full\",\n", ")\n", - "model_NUV.initialize()\n", - "result = ap.fit.LM(model_NUV, verbose=1).fit()\n", "\n", "# At this point we would just be fitting three separate models at the same time, not very interesting. Next\n", "# we add constraints so that some parameters are shared between all the models. It makes sense to fix\n", @@ -204,6 +198,7 @@ "# here we plot the results of the fitting, notice that each band has a different PSF and pixelscale. Also, notice\n", "# that the colour bars represent significantly different ranges since each model was allowed to fit its own Ie.\n", "# meanwhile the center, PA, q, and Re is the same for every model.\n", + "print(model_full)\n", "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", "ap.plots.model_image(fig1, ax1, model_full)\n", "ax1[0].set_title(\"r-band model image\")\n", From e9032f028a0b739d14c1e6af6d489dad6d943e25 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 3 Jul 2025 14:55:51 -0400 Subject: [PATCH 042/191] valid is the problem for joint models --- astrophot/fit/func/lm.py | 4 +- astrophot/fit/iterative.py | 9 +- astrophot/fit/lm.py | 4 +- astrophot/image/image_object.py | 1 - astrophot/image/jacobian_image.py | 2 +- astrophot/image/window.py | 16 ++- astrophot/models/_shared_methods.py | 11 +- astrophot/models/group_model_object.py | 8 +- astrophot/models/group_psf_model.py | 5 + astrophot/models/model_object.py | 4 + astrophot/models/psf_model_object.py | 5 + .../utils/initialize/segmentation_map.py | 59 ++++----- docs/source/tutorials/GettingStarted.ipynb | 2 +- docs/source/tutorials/JointModels.ipynb | 118 +++++++++++++----- 14 files changed, 165 insertions(+), 83 deletions(-) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 3cbf6327..23f9a002 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -19,7 +19,7 @@ def damp_hessian(hess, L): # return hess + L * I * torch.diag(hess) -def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10.0): +def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11.0): chi20 = chi2 M0 = model(x) # (M,) J = jacobian(x) # (M, N) @@ -53,7 +53,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=10. # actual chi2 improvement vs expected from linearization rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() # Avoid highly non-linear regions - if rho < 0.2 or rho > 2: + if rho < 0.1 or rho > 2: L *= Lup if improving is True: break diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index dbb9e9b2..3da95027 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -75,16 +75,13 @@ def sub_step(self, model: Model) -> None: model: The model to perform optimization on. """ self.Y -= model() - initial_values = model.target[model.window].data.value.clone() - indices = model.target.get_indices(model.window) - model.target.data.value[indices] = ( - model.target[model.window] - self.Y[model.window] - ).data.value + initial_values = model.target.copy() + model.target = model.target - self.Y res = self.method(model, **self.method_kwargs).fit() self.Y += model() if self.verbose > 1: AP_config.ap_logger.info(res.message) - model.target.data.value[indices] = initial_values + model.target = initial_values def step(self) -> None: """ diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index d6e7f128..0c3ea3a8 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -278,7 +278,7 @@ def fit(self) -> BaseOptimizer: jacobian=self.jacobian, ndf=self.ndf, chi2=self.loss_history[-1], - L=self.L, + L=self.L / self.Ldn, Lup=self.Lup, Ldn=self.Ldn, ) @@ -288,7 +288,7 @@ def fit(self) -> BaseOptimizer: self.message = self.message + "fail. Could not find step to improve Chi^2" break - self.L = res["L"] / self.Ldn + self.L = res["L"] self.current_state = (self.current_state + res["h"]).detach() self.L_history.append(res["L"]) self.loss_history.append(res["chi2"]) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 87a4ddb0..3f2d20a0 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -93,7 +93,6 @@ def __init__( crval = wcs.wcs.crval crpix = np.array(wcs.wcs.crpix) - 1 # handle FITS 1-indexing - print(crval, crpix) if pixelscale is not None: AP_config.ap_logger.warning( diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index c809c56a..e244dfce 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -93,4 +93,4 @@ def flatten(self, attribute="data"): raise SpecificationConflict( "Jacobian image list sub-images track different parameters. Please initialize with all parameters that will be used." ) - return torch.cat(tuple(image.flatten(attribute) for image in self.images)) + return torch.cat(tuple(image.flatten(attribute) for image in self.images), dim=0) diff --git a/astrophot/image/window.py b/astrophot/image/window.py index 8965e5c6..2da02c45 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -136,7 +136,21 @@ def index(self, other: Window): if other.identity == window.identity: return i else: - raise ValueError("Could not find identity match between window list and input window") + raise IndexError("Could not find identity match between window list and input window") + + def __and__(self, other: "WindowList"): + if not isinstance(other, WindowList): + raise TypeError(f"Cannot intersect WindowList with {type(other)}") + if len(self.windows) == 0 or len(other.windows) == 0: + return WindowList([]) + new_windows = [] + for other_window in other.windows: + try: + i = self.index(other_window) + except IndexError: + continue # skip if the window is not in self.windows + new_windows.append(self.windows[i] & other_window) + return WindowList(new_windows) def __getitem__(self, index): return self.windows[index] diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index a7e70fe4..5c5be6a4 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -82,7 +82,6 @@ def _sample_image( def parametric_initialize(model, target, prof_func, params, x0_func): if all(list(model[param].initialized for param in params)): return - # Get the sub-image area corresponding to the model image R, I, S = _sample_image(target, model.transform_coordinates, model.radius_metric) @@ -116,6 +115,16 @@ def optim(x, r, f, u): reses.append(minimize(optim, x0=x0, args=(R[N], I[N], S[N]), method="Nelder-Mead")) for param, x0x in zip(params, x0): if not model[param].initialized: + if ( + model[param].valid[0] is not None + and x0x < model[param].valid[0].detach().cpu().numpy() + ) or ( + model[param].valid[1] is not None + and x0x > model[param].valid[1].detach().cpu().numpy() + ): + x0x = model[param].from_valid( + torch.tensor(x0x, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + ) model[param].dynamic_value = x0x if model[param].uncertainty is None: model[param].uncertainty = np.std( diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index d246faa1..1de48465 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -16,6 +16,7 @@ JacobianImage, JacobianImageList, ) +from .. import AP_config from ..utils.decorators import ignore_numpy_warnings from ..errors import InvalidTarget, InvalidWindow @@ -97,7 +98,7 @@ def initialize(self): target (Optional["Target_Image"]): A Target_Image instance to use as the source for initializing the model parameters on this image. """ for model in self.models: - print(f"Initializing model {model.name}") + AP_config.ap_logger.info(f"Initializing model {model.name}") model.initialize() def fit_mask(self) -> torch.Tensor: @@ -249,6 +250,11 @@ def target(self) -> Optional[Union[TargetImage, TargetImageList]]: def target(self, tar: Optional[Union[TargetImage, TargetImageList]]): if not (tar is None or isinstance(tar, (TargetImage, TargetImageList))): raise InvalidTarget("Group_Model target must be a Target_Image instance.") + try: + del self._target # Remove old target if it exists + except AttributeError: + pass + self._target = tar @property diff --git a/astrophot/models/group_psf_model.py b/astrophot/models/group_psf_model.py index 023501bb..f552c748 100644 --- a/astrophot/models/group_psf_model.py +++ b/astrophot/models/group_psf_model.py @@ -21,4 +21,9 @@ def target(self): def target(self, target): if not (target is None or isinstance(target, PSFImage)): raise InvalidTarget("Group_Model target must be a PSF_Image instance.") + try: + del self._target # Remove old target if it exists + except AttributeError: + pass + self._target = target diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 9f36fb0c..a049d6e7 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -107,6 +107,10 @@ def target(self, tar): return elif not isinstance(tar, TargetImage): raise InvalidTarget("AstroPhot Model target must be a Target_Image instance.") + try: + del self._target # Remove old target if it exists + except AttributeError: + pass self._target = tar # Initialization functions diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 59f2824e..a319a0f1 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -104,6 +104,11 @@ def target(self, target): self._target = None elif not isinstance(target, PSFImage): raise InvalidTarget(f"Target for PSF_Model must be a PSF_Image, not {type(target)}") + try: + del self._target # Remove old target if it exists + except AttributeError: + pass + self._target = target @forward diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index 1cfa8528..4c297400 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -101,7 +101,7 @@ def PA_from_segmentation_map( PA = ( Angle_COM_PA(image[N], XX[N] - centroids[index][0], YY[N] - centroids[index][1]) + north ) - PAs[index] = PA + PAs[index] = PA % np.pi return PAs @@ -151,7 +151,7 @@ def windows_from_segmentation_map(seg_map, hdul_index=0, skip_index=(0,)): boxes according to given factors and returns the coordinates. each window is formatted as a list of lists with: - window = [[xmin,xmax],[ymin,ymax]] + window = [[xmin,ymin],[xmax,ymax]] expand_scale changes the base window by the given factor. expand_border is added afterwards on all sides (so an @@ -303,7 +303,7 @@ def transfer_windows(windows, base_image, new_image): ---------- windows : dict A dictionary of windows to be transferred. Each window is formatted as a list of lists with: - window = [[xmin,xmax],[ymin,ymax]] + window = [[xmin,ymin],[xmax,ymax]] base_image : Image The image object from which the windows are being transferred. new_image : Image @@ -311,40 +311,25 @@ def transfer_windows(windows, base_image, new_image): """ new_windows = {} for w in list(windows.keys()): - bottom_corner = np.clip( - np.floor( - torch.stack( - new_image.plane_to_pixel( - *base_image.pixel_to_plane( - *torch.tensor([windows[w][0][0], windows[w][0][1]]) - ) - ) - ) - .detach() - .cpu() - .numpy() - .astype(int) - ), - a_min=0, - a_max=np.array(new_image.shape) - 1, - ) - top_corner = np.clip( - np.ceil( - torch.stack( - new_image.plane_to_pixel( - *base_image.pixel_to_plane( - *torch.tensor([windows[w][1][0], windows[w][1][1]]) - ) - ) - ) - .detach() - .cpu() - .numpy() - .astype(int) - ), - a_min=0, - a_max=np.array(new_image.shape) - 1, - ) + four_corners_base = torch.tensor( + [ + windows[w][0], + windows[w][1], + [windows[w][0][0], windows[w][1][1]], + [windows[w][1][0], windows[w][0][1]], + ] + ) # (4,2) + four_corners_new = ( + torch.stack( + new_image.plane_to_pixel(*base_image.pixel_to_plane(*four_corners_base.T)), dim=-1 + ) + .detach() + .cpu() + .numpy() + ) # (4,2) + + bottom_corner = np.floor(np.min(four_corners_new, axis=0)).astype(int) + top_corner = np.ceil(np.max(four_corners_new, axis=0)).astype(int) new_windows[w] = [ [int(bottom_corner[0]), int(bottom_corner[1])], [int(top_corner[0]), int(top_corner[1])], diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 16146084..38bacd2b 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -51,7 +51,7 @@ " PA=60 * np.pi / 180,\n", " n=2,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " target=ap.image.TargetImage(\n", " data=np.zeros((100, 100)), zeropoint=22.5, pixelscale=1.0\n", " ), # every model needs a target, more on this later\n", diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 7671c125..2c7fad70 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -131,6 +131,8 @@ " name=\"W1band model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", + " center=[0, 0],\n", + " PA=-2.3,\n", " psf_mode=\"full\",\n", ")\n", "\n", @@ -138,15 +140,25 @@ " name=\"NUVband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", + " center=[0, 0],\n", + " PA=-2.3,\n", " psf_mode=\"full\",\n", ")\n", "\n", "# At this point we would just be fitting three separate models at the same time, not very interesting. Next\n", "# we add constraints so that some parameters are shared between all the models. It makes sense to fix\n", "# structure parameters while letting brightness parameters vary between bands so that's what we do here.\n", - "for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", - " model_W1[p].value = model_r[p]\n", - " model_NUV[p].value = model_r[p]\n", + "# for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", + "# print(model_r[p].valid)\n", + "# print(model_W1[p].valid)\n", + "# print(model_NUV[p].valid)\n", + "# if p in [\"PA\", \"Re\"]:\n", + "# continue\n", + "# model_r[p].valid = (None, None)\n", + "# model_W1[p].valid = (None, None)\n", + "# model_NUV[p].valid = (None, None)\n", + "# model_W1[p].value = model_r[p]\n", + "# model_NUV[p].value = model_r[p]\n", "# Now every model will have a unique Ie, but every other parameter is shared for all three" ] }, @@ -185,8 +197,17 @@ "metadata": {}, "outputs": [], "source": [ - "result = ap.fit.LM(model_full, verbose=1).fit()\n", - "print(result.message)" + "import caskade\n", + "\n", + "Jo = model_r.jacobian()\n", + "with caskade.ValidContext(model_r):\n", + " Jv = model_r.jacobian()\n", + "print(Jv.data.shape, Jo.data.shape)\n", + "fig, axarr = plt.subplots(7, 1, figsize=(2, 14))\n", + "for j in range(7):\n", + " axarr[j].imshow(Jo.data.value[..., j] - Jv.data.value[..., j], origin=\"lower\")\n", + " axarr[j].set_title(f\"{model_r.name} {Jv.parameters[j]}\")\n", + " axarr[j].axis(\"off\")" ] }, { @@ -195,19 +216,20 @@ "metadata": {}, "outputs": [], "source": [ - "# here we plot the results of the fitting, notice that each band has a different PSF and pixelscale. Also, notice\n", - "# that the colour bars represent significantly different ranges since each model was allowed to fit its own Ie.\n", - "# meanwhile the center, PA, q, and Re is the same for every model.\n", - "print(model_full)\n", - "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.model_image(fig1, ax1, model_full)\n", - "ax1[0].set_title(\"r-band model image\")\n", - "ax1[0].invert_xaxis()\n", - "ax1[1].set_title(\"W1-band model image\")\n", - "ax1[1].invert_xaxis()\n", - "ax1[2].set_title(\"NUV-band model image\")\n", - "ax1[2].invert_xaxis()\n", - "plt.show()" + "import caskade\n", + "\n", + "Jo = model_full.jacobian()\n", + "with caskade.ValidContext(model_full):\n", + " Jv = model_full.jacobian()\n", + "print(Jv.data[0].shape)\n", + "fig, axarr = plt.subplots(21, 3, figsize=(6, 42))\n", + "for i in range(3):\n", + " print(torch.all(torch.isfinite(Jv.data[i])))\n", + " print(f\"{model_full.models[i].name}\")\n", + " for j in range(21):\n", + " axarr[j, i].imshow(Jo.data[i][..., j] - Jv.data[i][..., j], origin=\"lower\")\n", + " axarr[j, i].set_title(f\"{model_full.models[i].name} {Jv.images[i].parameters[j]}\")\n", + " axarr[j, i].axis(\"off\")" ] }, { @@ -216,17 +238,44 @@ "metadata": {}, "outputs": [], "source": [ - "# We can also plot the residual images. As can be seen, the galaxy is fit in all three bands simultaneously\n", - "# with the majority of the light removed in all bands. A residual can be seen in the r band. This is likely\n", - "# due to there being more structure in the r-band than just a sersic. The W1 and NUV bands look excellent though\n", - "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.residual_image(fig1, ax1, model_full, normalize_residuals=True)\n", - "ax1[0].set_title(\"r-band residual image\")\n", - "ax1[0].invert_xaxis()\n", - "ax1[1].set_title(\"W1-band residual image\")\n", - "ax1[1].invert_xaxis()\n", - "ax1[2].set_title(\"NUV-band residual image\")\n", - "ax1[2].invert_xaxis()\n", + "# result = ap.fit.LM(model_r, verbose=1).fit() # fit r band first since it dominates the SNR\n", + "# result = ap.fit.LM(model_r, verbose=1).fit()\n", + "# result = ap.fit.LM(model_W1, verbose=1).fit()\n", + "# result = ap.fit.LM(model_NUV, verbose=1).fit()\n", + "print(model_full.build_params_array())\n", + "print(model_full.to_valid(model_full.build_params_array()))\n", + "op = ap.fit.LM(model_full, verbose=1)\n", + "print(torch.all(op.mask), op.mask.shape)\n", + "print(op.fit_window)\n", + "result = op.fit()\n", + "print(result.message)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# here we plot the results of the fitting, notice that each band has a different PSF and pixelscale. Also, notice\n", + "# that the colour bars represent significantly different ranges since each model was allowed to fit its own Ie.\n", + "# meanwhile the center, PA, q, and Re is the same for every model.\n", + "print(model_full)\n", + "fig1, ax1 = plt.subplots(2, 3, figsize=(18, 12))\n", + "ap.plots.model_image(fig1, ax1[0], model_full)\n", + "ax1[0][0].set_title(\"r-band model image\")\n", + "ax1[0][0].invert_xaxis()\n", + "ax1[0][1].set_title(\"W1-band model image\")\n", + "ax1[0][1].invert_xaxis()\n", + "ax1[0][2].set_title(\"NUV-band model image\")\n", + "ax1[0][2].invert_xaxis()\n", + "ap.plots.residual_image(fig1, ax1[1], model_full, normalize_residuals=True)\n", + "ax1[1][0].set_title(\"r-band residual image\")\n", + "ax1[1][0].invert_xaxis()\n", + "ax1[1][1].set_title(\"W1-band residual image\")\n", + "ax1[1][1].invert_xaxis()\n", + "ax1[1][2].set_title(\"NUV-band residual image\")\n", + "ax1[1][2].invert_xaxis()\n", "plt.show()" ] }, @@ -437,7 +486,16 @@ "outputs": [], "source": [ "MODEL.initialize()\n", - "\n", + "print(MODEL)\n", + "MODEL.graphviz()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ "# We give it only one iteration for runtime/demo purposes, you should let these algorithms run to convergence\n", "result = ap.fit.Iter(MODEL, verbose=1, max_iter=1).fit()" ] From 7ab74579ae5243f27d147c60484fd56190fbabbf Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 3 Jul 2025 21:10:03 -0400 Subject: [PATCH 043/191] its the valid context --- astrophot/models/mixins/sample.py | 6 +++--- astrophot/models/mixins/transform.py | 2 +- docs/source/tutorials/JointModels.ipynb | 19 +++++++++++-------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 5eaa0dcf..a3924460 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -132,7 +132,9 @@ def jacobian( jac_img = pass_jacobian # No dynamic params - if len(self.build_params_list()) == 0: + if params is None: + params = self.build_params_array() + if params.shape[-1] == 0: return jac_img # handle large images @@ -142,8 +144,6 @@ def jacobian( self.jacobian(window=chunk, pass_jacobian=jac_img, params=params) return jac_img - if params is None: - params = self.build_params_array() identities = self.build_params_array_identities() target = self.target[window] if len(params) > self.jacobian_maxparams: # handle large number of parameters diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 319f9d20..6b8b7cee 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -66,7 +66,7 @@ def transform_coordinates(self, x, y, PA, q): Transform coordinates based on the position angle and axis ratio. """ x, y = super().transform_coordinates(x, y) - x, y = func.rotate(-(PA + np.pi / 2), x, y) + x, y = func.rotate(-PA + np.pi / 2, x, y) return x, y / q diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 2c7fad70..af2e843b 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -152,7 +152,7 @@ "# print(model_r[p].valid)\n", "# print(model_W1[p].valid)\n", "# print(model_NUV[p].valid)\n", - "# if p in [\"PA\", \"Re\"]:\n", + "# if p in [\"PA\"]:\n", "# continue\n", "# model_r[p].valid = (None, None)\n", "# model_W1[p].valid = (None, None)\n", @@ -173,7 +173,7 @@ "model_full = ap.models.Model(\n", " name=\"LEDA 41136\",\n", " model_type=\"group model\",\n", - " models=[model_r, model_W1, model_NUV],\n", + " models=[model_W1, model_r, model_NUV],\n", " target=target_full,\n", ")\n", "\n", @@ -199,14 +199,14 @@ "source": [ "import caskade\n", "\n", - "Jo = model_r.jacobian()\n", + "Jo = model_W1.jacobian()\n", "with caskade.ValidContext(model_r):\n", - " Jv = model_r.jacobian()\n", + " Jv = model_W1.jacobian()\n", "print(Jv.data.shape, Jo.data.shape)\n", "fig, axarr = plt.subplots(7, 1, figsize=(2, 14))\n", "for j in range(7):\n", " axarr[j].imshow(Jo.data.value[..., j] - Jv.data.value[..., j], origin=\"lower\")\n", - " axarr[j].set_title(f\"{model_r.name} {Jv.parameters[j]}\")\n", + " axarr[j].set_title(f\"{model_W1.name} {Jv.parameters[j]}\")\n", " axarr[j].axis(\"off\")" ] }, @@ -219,16 +219,19 @@ "import caskade\n", "\n", "Jo = model_full.jacobian()\n", + "# print(model_full.build_params_array())\n", "with caskade.ValidContext(model_full):\n", + " # print(model_full.build_params_array())\n", + " # print(list(model.build_params_array() for model in model_full.models))\n", " Jv = model_full.jacobian()\n", "print(Jv.data[0].shape)\n", "fig, axarr = plt.subplots(21, 3, figsize=(6, 42))\n", "for i in range(3):\n", - " print(torch.all(torch.isfinite(Jv.data[i])))\n", " print(f\"{model_full.models[i].name}\")\n", " for j in range(21):\n", - " axarr[j, i].imshow(Jo.data[i][..., j] - Jv.data[i][..., j], origin=\"lower\")\n", - " axarr[j, i].set_title(f\"{model_full.models[i].name} {Jv.images[i].parameters[j]}\")\n", + " im = axarr[j, i].imshow(Jo.data[i][..., j] - Jv.data[i][..., j], origin=\"lower\")\n", + " plt.colorbar(im, ax=axarr[j, i])\n", + " axarr[j, i].set_title(f\"{model_full.models[i].name}\")\n", " axarr[j, i].axis(\"off\")" ] }, From e17b501bfe7a3544b78493a65c6728d6cd1423c6 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 5 Jul 2025 22:39:34 -0400 Subject: [PATCH 044/191] finally works logit is more stable --- astrophot/fit/lm.py | 1 - astrophot/image/image_object.py | 16 ++++-- astrophot/image/jacobian_image.py | 1 + astrophot/image/target_image.py | 9 ++-- astrophot/models/base.py | 10 +++- astrophot/models/group_model_object.py | 4 +- astrophot/models/mixins/sample.py | 2 +- docs/source/tutorials/JointModels.ipynb | 72 ++----------------------- 8 files changed, 32 insertions(+), 83 deletions(-) diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 0c3ea3a8..2a8147d0 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -173,7 +173,6 @@ def __init__( relative_tolerance=relative_tolerance, **kwargs, ) - # Maximum number of iterations of the algorithm self.max_iter = max_iter # Maximum number of steps while searching for chi^2 improvement on a single jacobian evaluation diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 3f2d20a0..3bf5ceb6 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -67,10 +67,18 @@ def __init__( """ super().__init__(name=name) - self.data = Param("data", units="flux") - self.crval = Param("crval", units="deg") - self.crtan = Param("crtan", units="arcsec") - self.crpix = Param("crpix", units="pixel") + self.data = Param( + "data", units="flux", dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + self.crval = Param( + "crval", units="deg", dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + self.crtan = Param( + "crtan", units="arcsec", dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + self.crpix = Param( + "crpix", units="pixel", dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) if filename is not None: self.load(filename) diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index e244dfce..5065f03b 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -4,6 +4,7 @@ from .image_object import Image, ImageList from .. import AP_config +from ..param import forward from ..errors import SpecificationConflict, InvalidImage __all__ = ["JacobianImage", "JacobianImageList"] diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 4b107331..2c1f5e2d 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -393,17 +393,14 @@ def load(self, filename: str): def jacobian_image( self, - parameters: Optional[List[str]] = None, + parameters: List[str], data: Optional[torch.Tensor] = None, **kwargs, ): """ Construct a blank `Jacobian_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ - if parameters is None: - data = None - parameters = [] - elif data is None: + if data is None: data = torch.zeros( (*self.data.shape, len(parameters)), dtype=AP_config.ap_dtype, @@ -509,7 +506,7 @@ def has_weight(self): def jacobian_image(self, parameters: List[str], data: Optional[List[torch.Tensor]] = None): if data is None: - data = [None] * len(self.images) + data = tuple(None for _ in range(len(self.images))) return JacobianImageList( list(image.jacobian_image(parameters, dat) for image, dat in zip(self.images, data)) ) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index ffa7d95d..9e043709 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -122,14 +122,20 @@ def __init__(self, *, name=None, target=None, window=None, mask=None, filename=N # Create Param objects for this Module parameter_specs = self.build_parameter_specs(kwargs, self.parameter_specs) for key in parameter_specs: - setattr(self, key, Param(key, **parameter_specs[key])) + param = Param( + key, **parameter_specs[key], dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + setattr(self, key, param) overload_specs = self.build_parameter_specs(kwargs, self.overload_parameter_specs) for key in overload_specs: overload = overload_specs[key].pop("overloads") if self[overload].value is not None: continue self[overload].value = overload_specs[key].pop("overload_function") - setattr(self, key, Param(key, **overload_specs[key])) + param = Param( + key, **overload_specs[key], dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + setattr(self, key, param) self[overload].link(key, self[key]) self.saveattrs.update(self.options) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 1de48465..f938cbae 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -223,13 +223,13 @@ def jacobian( else: jac_img = pass_jacobian - for model in self.models: + for model in reversed(self.models): try: use_window = self.match_window(jac_img, window, model) except IndexError: # If the model target is not in the image, skip it continue - model.jacobian( + jac_img = model.jacobian( pass_jacobian=jac_img, window=use_window & model.window, ) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index a3924460..1788ab5c 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -150,8 +150,8 @@ def jacobian( chunksize = len(params) // self.jacobian_maxparams + 1 for i in range(chunksize, len(params), chunksize): params_pre = params[:i] - params_post = params[i + chunksize :] params_chunk = params[i : i + chunksize] + params_post = params[i + chunksize :] jac_chunk = self._jacobian(window, params_pre, params_chunk, params_post) jac_img += target.jacobian_image( parameters=identities[i : i + chunksize], diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index af2e843b..2387b52a 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -148,17 +148,9 @@ "# At this point we would just be fitting three separate models at the same time, not very interesting. Next\n", "# we add constraints so that some parameters are shared between all the models. It makes sense to fix\n", "# structure parameters while letting brightness parameters vary between bands so that's what we do here.\n", - "# for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", - "# print(model_r[p].valid)\n", - "# print(model_W1[p].valid)\n", - "# print(model_NUV[p].valid)\n", - "# if p in [\"PA\"]:\n", - "# continue\n", - "# model_r[p].valid = (None, None)\n", - "# model_W1[p].valid = (None, None)\n", - "# model_NUV[p].valid = (None, None)\n", - "# model_W1[p].value = model_r[p]\n", - "# model_NUV[p].value = model_r[p]\n", + "for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", + " model_W1[p].value = model_r[p]\n", + " model_NUV[p].value = model_r[p]\n", "# Now every model will have a unique Ie, but every other parameter is shared for all three" ] }, @@ -173,7 +165,7 @@ "model_full = ap.models.Model(\n", " name=\"LEDA 41136\",\n", " model_type=\"group model\",\n", - " models=[model_W1, model_r, model_NUV],\n", + " models=[model_r, model_W1, model_NUV],\n", " target=target_full,\n", ")\n", "\n", @@ -197,60 +189,7 @@ "metadata": {}, "outputs": [], "source": [ - "import caskade\n", - "\n", - "Jo = model_W1.jacobian()\n", - "with caskade.ValidContext(model_r):\n", - " Jv = model_W1.jacobian()\n", - "print(Jv.data.shape, Jo.data.shape)\n", - "fig, axarr = plt.subplots(7, 1, figsize=(2, 14))\n", - "for j in range(7):\n", - " axarr[j].imshow(Jo.data.value[..., j] - Jv.data.value[..., j], origin=\"lower\")\n", - " axarr[j].set_title(f\"{model_W1.name} {Jv.parameters[j]}\")\n", - " axarr[j].axis(\"off\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import caskade\n", - "\n", - "Jo = model_full.jacobian()\n", - "# print(model_full.build_params_array())\n", - "with caskade.ValidContext(model_full):\n", - " # print(model_full.build_params_array())\n", - " # print(list(model.build_params_array() for model in model_full.models))\n", - " Jv = model_full.jacobian()\n", - "print(Jv.data[0].shape)\n", - "fig, axarr = plt.subplots(21, 3, figsize=(6, 42))\n", - "for i in range(3):\n", - " print(f\"{model_full.models[i].name}\")\n", - " for j in range(21):\n", - " im = axarr[j, i].imshow(Jo.data[i][..., j] - Jv.data[i][..., j], origin=\"lower\")\n", - " plt.colorbar(im, ax=axarr[j, i])\n", - " axarr[j, i].set_title(f\"{model_full.models[i].name}\")\n", - " axarr[j, i].axis(\"off\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# result = ap.fit.LM(model_r, verbose=1).fit() # fit r band first since it dominates the SNR\n", - "# result = ap.fit.LM(model_r, verbose=1).fit()\n", - "# result = ap.fit.LM(model_W1, verbose=1).fit()\n", - "# result = ap.fit.LM(model_NUV, verbose=1).fit()\n", - "print(model_full.build_params_array())\n", - "print(model_full.to_valid(model_full.build_params_array()))\n", - "op = ap.fit.LM(model_full, verbose=1)\n", - "print(torch.all(op.mask), op.mask.shape)\n", - "print(op.fit_window)\n", - "result = op.fit()\n", + "result = ap.fit.LM(model_full, verbose=1).fit()\n", "print(result.message)" ] }, @@ -263,7 +202,6 @@ "# here we plot the results of the fitting, notice that each band has a different PSF and pixelscale. Also, notice\n", "# that the colour bars represent significantly different ranges since each model was allowed to fit its own Ie.\n", "# meanwhile the center, PA, q, and Re is the same for every model.\n", - "print(model_full)\n", "fig1, ax1 = plt.subplots(2, 3, figsize=(18, 12))\n", "ap.plots.model_image(fig1, ax1[0], model_full)\n", "ax1[0][0].set_title(\"r-band model image\")\n", From 599f0dddfc38583ad8f9c2c3c222c9a98239862a Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 6 Jul 2025 17:06:39 -0400 Subject: [PATCH 045/191] convert crpix and data to non-params --- astrophot/fit/base.py | 15 +- astrophot/fit/func/lm.py | 11 +- astrophot/fit/lm.py | 27 +++- astrophot/image/image_object.py | 131 ++++++++---------- astrophot/image/jacobian_image.py | 16 +-- astrophot/image/model_image.py | 28 ++-- astrophot/image/psf_image.py | 10 +- astrophot/image/target_image.py | 28 ++-- astrophot/models/_shared_methods.py | 2 +- astrophot/models/airy.py | 2 +- astrophot/models/edgeon.py | 4 +- astrophot/models/flatsky.py | 2 +- astrophot/models/mixins/sample.py | 10 +- astrophot/models/mixins/transform.py | 5 +- astrophot/models/model_object.py | 16 ++- astrophot/models/multi_gaussian_expansion.py | 2 +- astrophot/models/pixelated_psf.py | 2 +- astrophot/models/planesky.py | 13 +- astrophot/models/point_source.py | 8 +- astrophot/models/psf_model_object.py | 4 +- astrophot/models/zernike.py | 4 +- astrophot/plots/image.py | 8 +- astrophot/plots/profile.py | 2 +- astrophot/utils/initialize/__init__.py | 4 +- astrophot/utils/initialize/center.py | 44 +----- astrophot/utils/initialize/construct_psf.py | 1 - .../utils/initialize/segmentation_map.py | 63 +++++---- docs/source/tutorials/GettingStarted.ipynb | 8 +- docs/source/tutorials/JointModels.ipynb | 52 +++---- 29 files changed, 233 insertions(+), 289 deletions(-) diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index 4fe40882..aea7a22b 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -32,7 +32,10 @@ def __init__( initial_state: Sequence = None, relative_tolerance: float = 1e-3, fit_window: Optional[Window] = None, - **kwargs, + verbose: int = 0, + max_iter: int = None, + save_steps: Optional[str] = None, + fit_valid: bool = True, ) -> None: """ Initializes a new instance of the class. @@ -59,11 +62,10 @@ def __init__( """ self.model = model - self.verbose = kwargs.get("verbose", 0) + self.verbose = verbose if initial_state is None: - with ValidContext(model): - self.current_state = model.build_params_array() + self.current_state = model.build_params_array() else: self.current_state = torch.as_tensor( initial_state, dtype=model.dtype, device=model.device @@ -74,9 +76,10 @@ def __init__( else: self.fit_window = fit_window & self.model.window - self.max_iter = kwargs.get("max_iter", 100 * len(self.current_state)) + self.max_iter = max_iter if max_iter is not None else 100 * len(self.current_state) self.iteration = 0 - self.save_steps = kwargs.get("save_steps", None) + self.save_steps = save_steps + self.fit_valid = fit_valid self.relative_tolerance = relative_tolerance self.lambda_history = [] diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 23f9a002..2b6640cb 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -16,7 +16,6 @@ def damp_hessian(hess, L): I = torch.eye(len(hess), dtype=hess.dtype, device=hess.device) D = torch.ones_like(hess) - I return hess * (I + D / (1 + L)) + L * I * (1 + torch.diag(hess)) - # return hess + L * I * torch.diag(hess) def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11.0): @@ -27,8 +26,8 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. grad = gradient(J, weight, R) # (N, 1) hess = hessian(J, weight) # (N, N) - best = {"h": torch.zeros_like(x), "chi2": chi20, "L": L} - scary = {"h": None, "chi2": chi20, "L": L} + best = {"x": torch.zeros_like(x), "chi2": chi20, "L": L} + scary = {"x": None, "chi2": chi20, "L": L} nostep = True improving = None @@ -48,7 +47,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. continue if chi21 < scary["chi2"]: - scary = {"h": h.squeeze(1), "chi2": chi21, "L": L} + scary = {"x": x + h.squeeze(1), "chi2": chi21, "L": L} # actual chi2 improvement vs expected from linearization rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() @@ -61,7 +60,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. continue if chi21 < best["chi2"]: # new best - best = {"h": h.squeeze(1), "chi2": chi21, "L": L} + best = {"x": x + h.squeeze(1), "chi2": chi21, "L": L} nostep = False L /= Ldn if L < 1e-8 or improving is False: @@ -80,7 +79,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. break if nostep: - if scary["h"] is not None: + if scary["x"] is not None: return scary raise OptimizeStop("Could not find step to improve chi^2") diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 2a8147d0..1c853ca1 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -236,8 +236,7 @@ def __init__( self.ndf = ndf def chi2_ndf(self): - with ValidContext(self.model): - return torch.sum(self.W * (self.Y - self.forward(self.current_state)) ** 2) / self.ndf + return torch.sum(self.W * (self.Y - self.forward(self.current_state)) ** 2) / self.ndf @torch.no_grad() def fit(self) -> BaseOptimizer: @@ -268,7 +267,22 @@ def fit(self) -> BaseOptimizer: if self.verbose > 0: AP_config.ap_logger.info(f"Chi^2/DoF: {self.loss_history[-1]:.4g}, L: {self.L:.3g}") try: - with ValidContext(self.model): + if self.fit_valid: + with ValidContext(self.model): + res = func.lm_step( + x=self.model.to_valid(self.current_state), + data=self.Y, + model=self.forward, + weight=self.W, + jacobian=self.jacobian, + ndf=self.ndf, + chi2=self.loss_history[-1], + L=self.L / self.Ldn, + Lup=self.Lup, + Ldn=self.Ldn, + ) + self.current_state = self.model.from_valid(res["x"]).detach() + else: res = func.lm_step( x=self.current_state, data=self.Y, @@ -281,6 +295,7 @@ def fit(self) -> BaseOptimizer: Lup=self.Lup, Ldn=self.Ldn, ) + self.current_state = res["x"].detach() except OptimizeStop: if self.verbose > 0: AP_config.ap_logger.warning("Could not find step to improve Chi^2, stopping") @@ -288,7 +303,6 @@ def fit(self) -> BaseOptimizer: break self.L = res["L"] - self.current_state = (self.current_state + res["h"]).detach() self.L_history.append(res["L"]) self.loss_history.append(res["chi2"]) self.lambda_history.append(self.current_state.detach().clone().cpu().numpy()) @@ -316,8 +330,7 @@ def fit(self) -> BaseOptimizer: f"Final Chi^2/DoF: {self.loss_history[-1]:.4g}, L: {self.L_history[-1]:.3g}. Converged: {self.message}" ) - with ValidContext(self.model): - self.model.fill_dynamic_values(self.current_state) + self.model.fill_dynamic_values(self.current_state) return self @@ -334,7 +347,7 @@ def covariance_matrix(self) -> torch.Tensor: if self._covariance_matrix is not None: return self._covariance_matrix - J = self.jacobian(self.model.from_valid(self.current_state)) + J = self.jacobian(self.current_state) hess = func.hessian(J, self.W) try: self._covariance_matrix = torch.linalg.inv(hess) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 3bf5ceb6..64bbfa36 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Any +from typing import Optional, Union import torch import numpy as np @@ -31,9 +31,6 @@ class Image(Module): origin: The origin of the image in the coordinate system. """ - default_crpix = (0, 0) - default_crtan = (0.0, 0.0) - default_crval = (0.0, 0.0) default_pixelscale = ((1.0, 0.0), (0.0, 1.0)) def __init__( @@ -67,18 +64,13 @@ def __init__( """ super().__init__(name=name) - self.data = Param( - "data", units="flux", dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) + self.data = data # units: flux self.crval = Param( "crval", units="deg", dtype=AP_config.ap_dtype, device=AP_config.ap_device ) self.crtan = Param( "crtan", units="arcsec", dtype=AP_config.ap_dtype, device=AP_config.ap_device ) - self.crpix = Param( - "crpix", units="pixel", dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) if filename is not None: self.load(filename) @@ -109,7 +101,6 @@ def __init__( pixelscale = deg_to_arcsec * wcs.pixel_scale_matrix # set the data - self.data = data self.crval = crval self.crtan = crtan self.crpix = crpix @@ -118,6 +109,30 @@ def __init__( self.zeropoint = zeropoint + @property + def data(self): + """The image data, which is a tensor of pixel values.""" + return self._data + + @data.setter + def data(self, value: Optional[torch.Tensor]): + """Set the image data. If value is None, the data is initialized to an empty tensor.""" + if value is None: + self._data = torch.empty((0, 0), dtype=AP_config.ap_dtype, device=AP_config.ap_device) + else: + self._data = torch.as_tensor( + value, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + + @property + def crpix(self): + """The reference pixel coordinates in the image, which is used to convert from pixel coordinates to tangent plane coordinates.""" + return self._crpix + + @crpix.setter + def crpix(self, value: Union[torch.Tensor, tuple]): + self._crpix = np.asarray(value, dtype=np.float64) + @property def zeropoint(self): """The zeropoint of the image, which is used to convert from pixel flux to magnitude.""" @@ -194,12 +209,12 @@ def pixelscale_inv(self): return self._pixelscale_inv @forward - def pixel_to_plane(self, i, j, crpix, crtan): - return func.pixel_to_plane_linear(i, j, *crpix, self.pixelscale, *crtan) + def pixel_to_plane(self, i, j, crtan): + return func.pixel_to_plane_linear(i, j, *self.crpix, self.pixelscale, *crtan) @forward - def plane_to_pixel(self, x, y, crpix, crtan): - return func.plane_to_pixel_linear(x, y, *crpix, self.pixelscale_inv, *crtan) + def plane_to_pixel(self, x, y, crtan): + return func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale_inv, *crtan) @forward def plane_to_world(self, x, y, crval, crtan): @@ -227,6 +242,13 @@ def pixel_to_world(self, i, j): """ return self.plane_to_world(*self.pixel_to_plane(i, j)) + @forward + def pixel_angle_to_plane_angle(self, theta, crtan): + """Convert an angle in pixel space (in radians) to an angle in the tangent plane (in radians).""" + i, j = torch.cos(theta), torch.sin(theta) + x, y = self.pixel_to_plane(i, j) + return torch.atan2(y - crtan[1], x - crtan[0]) + def pixel_center_meshgrid(self): """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" return func.pixel_center_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) @@ -276,9 +298,9 @@ def copy(self, **kwargs): """ kwargs = { - "data": torch.clone(self.data.value.detach()), + "data": torch.clone(self.data.detach()), "pixelscale": self.pixelscale, - "crpix": self.crpix.value, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -294,9 +316,9 @@ def blank_copy(self, **kwargs): """ kwargs = { - "data": torch.zeros_like(self.data.value), + "data": torch.zeros_like(self.data), "pixelscale": self.pixelscale, - "crpix": self.crpix.value, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -317,9 +339,7 @@ def to(self, dtype=None, device=None): return self def flatten(self, attribute: str = "data") -> torch.Tensor: - if attribute in self.children: - return getattr(self, attribute).value.reshape(-1) - return getattr(self, attribute).reshape(-1) + return getattr(self, attribute).flatten(end_dim=1) def fits_info(self): return { @@ -327,8 +347,8 @@ def fits_info(self): "CTYPE2": "DEC--TAN", "CRVAL1": self.crval.value[0].item(), "CRVAL2": self.crval.value[1].item(), - "CRPIX1": self.crpix.value[0].item(), - "CRPIX2": self.crpix.value[1].item(), + "CRPIX1": self.crpix[0], + "CRPIX2": self.crpix[1], "CRTAN1": self.crtan.value[0].item(), "CRTAN2": self.crtan.value[1].item(), "CD1_1": self.pixelscale[0][0].item(), @@ -341,7 +361,7 @@ def fits_info(self): def fits_images(self): return [ - fits.PrimaryHDU(self.data.value.cpu().numpy(), header=fits.Header(self.fits_info())) + fits.PrimaryHDU(self.data.detach().cpu().numpy(), header=fits.Header(self.fits_info())) ] def get_astropywcs(self, **kwargs): @@ -365,11 +385,7 @@ def load(self, filename: str): """ hdulist = fits.open(filename) - self.data = torch.as_tensor( - np.array(hdulist[0].data, dtype=np.float64), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) + self.data = np.array(hdulist[0].data, dtype=np.float64) self.pixelscale = ( (hdulist[0].header["CD1_1"], hdulist[0].header["CD1_2"]), (hdulist[0].header["CD2_1"], hdulist[0].header["CD2_2"]), @@ -410,11 +426,11 @@ def corners(self): @torch.no_grad() def get_indices(self, other: Window): - if other.image == self: + if other.image is self: return slice(max(0, other.i_low), min(self.shape[0], other.i_high)), slice( max(0, other.j_low), min(self.shape[1], other.j_high) ) - shift = np.round(self.crpix.npvalue - other.crpix.npvalue).astype(int) + shift = np.round(self.crpix - other.crpix).astype(int) return slice( min(max(0, other.i_low + shift[0]), self.shape[0]), max(0, min(other.i_high + shift[0], self.shape[0])), @@ -431,23 +447,6 @@ def get_other_indices(self, other: Window): max(0, -other.j_low), min(self.shape[1] - other.j_low, shape[1]) ) raise ValueError() - # origin_pix = torch.tensor( - # (-0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device - # ) - # origin_pix = self.plane_to_pixel(*other.pixel_to_plane(*origin_pix)) - # origin_pix = torch.round(torch.stack(origin_pix) + 0.5).int() - # new_origin_pix = torch.maximum(torch.zeros_like(origin_pix), origin_pix) - - # end_pix = torch.tensor( - # (other.data.shape[0] - 0.5, other.data.shape[1] - 0.5), - # dtype=AP_config.ap_dtype, - # device=AP_config.ap_device, - # ) - # end_pix = self.plane_to_pixel(*other.pixel_to_plane(*end_pix)) - # end_pix = torch.round(torch.stack(end_pix) + 0.5).int() - # shape = torch.tensor(self.data.shape[:2], dtype=torch.int32, device=AP_config.ap_device) - # new_end_pix = torch.minimum(shape, end_pix) - # return slice(new_origin_pix[0], new_end_pix[0]), slice(new_origin_pix[1], new_end_pix[1]) def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): """Get a new image object which is a window of this image @@ -461,13 +460,8 @@ def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): else: indices = _indices new_img = self.copy( - data=self.data.value[indices], - crpix=self.crpix.value - - torch.tensor( - (indices[0].start, indices[1].start), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ), + data=self.data[indices], + crpix=self.crpix - np.array((indices[0].start, indices[1].start)), **kwargs, ) return new_img @@ -475,39 +469,35 @@ def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): def __sub__(self, other): if isinstance(other, Image): new_img = self[other] - new_img.data._value = new_img.data._value - other[self].data.value + new_img.data = new_img.data - other[self].data return new_img else: new_img = self.copy() - new_img.data._value = new_img.data._value - other + new_img.data = new_img.data - other return new_img def __add__(self, other): if isinstance(other, Image): new_img = self[other] - new_img.data._value = new_img.data._value + other[self].data.value + new_img.data = new_img.data + other[self].data return new_img else: new_img = self.copy() - new_img.data._value = new_img.data._value + other + new_img.data = new_img.data + other return new_img def __iadd__(self, other): if isinstance(other, Image): - self.data._value[self.get_indices(other.window)] += other.data.value[ - other.get_indices(self.window) - ] + self.data[self.get_indices(other.window)] += other.data[other.get_indices(self.window)] else: - self.data._value = self.data._value + other + self.data = self.data + other return self def __isub__(self, other): if isinstance(other, Image): - self.data._value[self.get_indices(other.window)] -= other.data.value[ - other.get_indices(self.window) - ] + self.data[self.get_indices(other.window)] -= other.data[other.get_indices(self.window)] else: - self.data._value = self.data._value - other + self.data = self.data - other return self def __getitem__(self, *args): @@ -535,7 +525,7 @@ def zeropoint(self): @property def data(self): - return tuple(image.data.value for image in self.images) + return tuple(image.data for image in self.images) @data.setter def data(self, data): @@ -583,9 +573,6 @@ def to(self, dtype=None, device=None): super().to(dtype=dtype, device=device) return self - def crop(self, *pixels): - raise NotImplementedError("Crop function not available for Image_List object") - def flatten(self, attribute="data"): return torch.cat(tuple(image.flatten(attribute) for image in self.images)) diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 5065f03b..a2ae6dfd 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -4,7 +4,6 @@ from .image_object import Image, ImageList from .. import AP_config -from ..param import forward from ..errors import SpecificationConflict, InvalidImage __all__ = ["JacobianImage", "JacobianImageList"] @@ -32,11 +31,6 @@ def __init__( if len(self.parameters) != len(set(self.parameters)): raise SpecificationConflict("Every parameter should be unique upon jacobian creation") - def flatten(self, attribute: str = "data"): - if attribute in self.children: - return getattr(self, attribute).value.reshape((-1, len(self.parameters))) - return getattr(self, attribute).reshape((-1, len(self.parameters))) - def copy(self, **kwargs): return super().copy(parameters=self.parameters, **kwargs) @@ -44,12 +38,6 @@ def __iadd__(self, other: "JacobianImage"): if not isinstance(other, JacobianImage): raise InvalidImage("Jacobian images can only add with each other, not: type(other)") - # exclude null jacobian images - if other.data.value is None: - return self - if self.data.value is None: - return other - self_indices = self.get_indices(other.window) other_indices = other.get_indices(self.window) for i, other_identity in enumerate(other.parameters): @@ -63,11 +51,11 @@ def __iadd__(self, other: "JacobianImage"): dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) - data[:, :, :-1] = self.data.value + data[:, :, :-1] = self.data self.data = data self.parameters.append(other_identity) other_loc = -1 - self.data.value[self_indices[0], self_indices[1], other_loc] += other.data.value[ + self.data[self_indices[0], self_indices[1], other_loc] += other.data[ other_indices[0], other_indices[1], i ] return self diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index e5b42de6..c90d565f 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -3,7 +3,7 @@ from .. import AP_config from .image_object import Image, ImageList -from ..errors import InvalidImage +from ..errors import InvalidImage, SpecificationConflict __all__ = ["ModelImage", "ModelImageList"] @@ -22,9 +22,7 @@ def __init__(self, *args, window=None, upsample=1, pad=0, **kwargs): if window is not None: kwargs["pixelscale"] = window.image.pixelscale / upsample kwargs["crpix"] = ( - (window.crpix.npvalue - np.array((window.i_low, window.j_low)) + 0.5) * upsample - + pad - - 0.5 + (window.crpix - np.array((window.i_low, window.j_low)) + 0.5) * upsample + pad - 0.5 ) kwargs["crval"] = window.image.crval.value kwargs["crtan"] = window.image.crtan.value @@ -42,7 +40,7 @@ def __init__(self, *args, window=None, upsample=1, pad=0, **kwargs): super().__init__(*args, **kwargs) def clear_image(self): - self.data._value = torch.zeros_like(self.data.value) + self.data = torch.zeros_like(self.data) def crop(self, pixels, **kwargs): """Crop the image by the number of pixels given. This will crop @@ -56,23 +54,23 @@ def crop(self, pixels, **kwargs): """ if len(pixels) == 1: # same crop in all dimension crop = pixels if isinstance(pixels, int) else pixels[0] - data = self.data.value[ + data = self.data[ crop : self.data.shape[0] - crop, crop : self.data.shape[1] - crop, ] - crpix = self.crpix.value - crop + crpix = self.crpix - crop elif len(pixels) == 2: # different crop in each dimension - data = self.data.value[ + data = self.data[ pixels[1] : self.data.shape[0] - pixels[1], pixels[0] : self.data.shape[1] - pixels[0], ] - crpix = self.crpix.value - pixels + crpix = self.crpix - pixels elif len(pixels) == 4: # different crop on all sides - data = self.data.value[ + data = self.data[ pixels[2] : self.data.shape[0] - pixels[3], pixels[0] : self.data.shape[1] - pixels[1], ] - crpix = self.crpix.value - pixels[0::2] # fixme + crpix = self.crpix - pixels[0::2] # fixme else: raise ValueError( f"Invalid crop shape {pixels}, must be (int,), (int, int), or (int, int, int, int)!" @@ -102,13 +100,9 @@ def reduce(self, scale: int, **kwargs): MS = self.data.shape[0] // scale NS = self.data.shape[1] // scale - data = ( - self.data.value[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .sum(axis=(1, 3)) - ) + data = self.data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) pixelscale = self.pixelscale * scale - crpix = (self.crpix.value + 0.5) / scale - 0.5 + crpix = (self.crpix + 0.5) / scale - 0.5 return self.copy( data=data, pixelscale=pixelscale, diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 96990663..750a392a 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -40,11 +40,11 @@ def __init__(self, *args, **kwargs): def normalize(self): """Normalizes the PSF image to have a sum of 1.""" - self.data._value /= torch.sum(self.data.value) + self.data = self.data / torch.sum(self.data) @property def mask(self): - return torch.zeros_like(self.data.value, dtype=bool) + return torch.zeros_like(self.data, dtype=bool) @property def psf_border_int(self): @@ -81,7 +81,7 @@ def jacobian_image( ) kwargs = { "pixelscale": self.pixelscale, - "crpix": self.crpix.value, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -95,9 +95,9 @@ def model_image(self, **kwargs): Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ kwargs = { - "data": torch.zeros_like(self.data.value), + "data": torch.zeros_like(self.data), "pixelscale": self.pixelscale, - "crpix": self.crpix.value, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 2c1f5e2d..c6833426 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -96,8 +96,8 @@ def __init__(self, *args, mask=None, variance=None, psf=None, weight=None, **kwa self.psf = psf # Set nan pixels to be masked automatically - if torch.any(torch.isnan(self.data.value)).item(): - self.mask = self.mask | torch.isnan(self.data.value) + if torch.any(torch.isnan(self.data)).item(): + self.mask = self.mask | torch.isnan(self.data) @property def standard_deviation(self): @@ -114,7 +114,7 @@ def standard_deviation(self): """ if self.has_variance: return torch.sqrt(self.variance) - return torch.ones_like(self.data.value) + return torch.ones_like(self.data) @property def variance(self): @@ -131,7 +131,7 @@ def variance(self): """ if self.has_variance: return torch.where(self._weight == 0, torch.inf, 1 / self._weight) - return torch.ones_like(self.data.value) + return torch.ones_like(self.data) @variance.setter def variance(self, variance): @@ -189,7 +189,7 @@ def weight(self): """ if self.has_weight: return self._weight - return torch.ones_like(self.data.value) + return torch.ones_like(self.data) @weight.setter def weight(self, weight): @@ -197,7 +197,7 @@ def weight(self, weight): self._weight = None return if isinstance(weight, str) and weight == "auto": - weight = 1 / auto_variance(self.data.value, self.mask) + weight = 1 / auto_variance(self.data, self.mask) if weight.shape != self.data.shape: raise SpecificationConflict( f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" @@ -234,7 +234,7 @@ def mask(self): """ if self.has_mask: return self._mask - return torch.zeros_like(self.data.value, dtype=torch.bool) + return torch.zeros_like(self.data, dtype=torch.bool) @mask.setter def mask(self, mask): @@ -358,14 +358,16 @@ def get_window(self, other: Union[Image, Window], **kwargs): def fits_images(self): images = super().fits_images() if self.has_variance: - images.append(fits.ImageHDU(self.weight.cpu().numpy(), name="WEIGHT")) + images.append(fits.ImageHDU(self.weight.detach().cpu().numpy(), name="WEIGHT")) if self.has_mask: - images.append(fits.ImageHDU(self.mask.cpu().numpy(), name="MASK")) + images.append(fits.ImageHDU(self.mask.detach().cpu().numpy(), name="MASK")) if self.has_psf: if isinstance(self.psf, PSFImage): images.append( fits.ImageHDU( - self.psf.data.npvalue, name="PSF", header=fits.Header(self.psf.fits_info()) + self.psf.data.detach().cpu().numpy(), + name="PSF", + header=fits.Header(self.psf.fits_info()), ) ) else: @@ -408,7 +410,7 @@ def jacobian_image( ) kwargs = { "pixelscale": self.pixelscale, - "crpix": self.crpix.value, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, @@ -423,9 +425,9 @@ def model_image(self, **kwargs): Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ kwargs = { - "data": torch.zeros_like(self.data.value), + "data": torch.zeros_like(self.data), "pixelscale": self.pixelscale, - "crpix": self.crpix.value, + "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, "zeropoint": self.zeropoint, diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 5c5be6a4..a8fc36d2 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -16,7 +16,7 @@ def _sample_image( rad_bins=None, angle_range=None, ): - dat = image.data.npvalue.copy() + dat = image.data.detach().cpu().numpy().copy() # Fill masked pixels if image.has_mask: mask = image.mask.detach().cpu().numpy() diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index 58077f5a..2c274293 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -54,7 +54,7 @@ def initialize(self): icenter = self.target.plane_to_pixel(*self.center.value) if not self.I0.initialized: - mid_chunk = self.target.data.value[ + mid_chunk = self.target.data[ int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index 52ecd22d..feab4425 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -35,7 +35,7 @@ def initialize(self): if self.PA.initialized: return target_area = self.target[self.window] - dat = target_area.data.npvalue.copy() + dat = target_area.data.detach().cpu().numpy().copy() edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) dat = dat - edge_average @@ -82,7 +82,7 @@ def initialize(self): icenter = target_area.plane_to_pixel(*self.center.value) if not self.I0.initialized: - chunk = target_area.data.value[ + chunk = target_area.data[ int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 39e7f6fb..163a7ae4 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -32,7 +32,7 @@ def initialize(self): if self.I.initialized: return - dat = self.target[self.window].data.npvalue.copy() + dat = self.target[self.window].data.detach().cpu().numpy().copy() self.I.value = np.median(dat) / self.target.pixel_area.item() self.I.uncertainty = ( iqr(dat, rng=(16, 84)) / (2.0 * self.target.pixel_area.item()) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 1788ab5c..83a2624e 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -108,7 +108,7 @@ def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_p return jacobian( lambda x: self.sample( window=window, params=torch.cat((params_pre, x, params_post), dim=-1) - ).data.value, + ).data, params, strategy="forward-mode", vectorize=True, @@ -175,16 +175,16 @@ def gradient( jacobian_image = self.jacobian(window=window, params=params) - data = self.target[window].data.value - model = self.sample(window=window).data.value + data = self.target[window].data + model = self.sample(window=window).data if likelihood == "gaussian": weight = self.target[window].weight gradient = torch.sum( - jacobian_image.data.value * ((data - model) * weight).unsqueeze(-1), dim=(0, 1) + jacobian_image.data * ((data - model) * weight).unsqueeze(-1), dim=(0, 1) ) elif likelihood == "poisson": gradient = torch.sum( - jacobian_image.data.value * (1 - data / model).unsqueeze(-1), + jacobian_image.data * (1 - data / model).unsqueeze(-1), dim=(0, 1), ) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 6b8b7cee..2ec7c0f5 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -29,7 +29,7 @@ def initialize(self): if self.PA.initialized and self.q.initialized: return target_area = self.target[self.window] - dat = target_area.data.npvalue.copy() + dat = target_area.data.detach().cpu().numpy().copy() if target_area.has_mask: mask = target_area.mask.detach().cpu().numpy() dat[mask] = np.median(dat[~mask]) @@ -42,9 +42,6 @@ def initialize(self): mu20 = np.median(dat * np.abs(x)) mu02 = np.median(dat * np.abs(y)) mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) - # mu20 = np.median(dat * x**2) - # mu02 = np.median(dat * y**2) - # mu11 = np.median(dat * x * y) M = np.array([[mu20, mu11], [mu11, mu02]]) if not self.PA.initialized: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index a049d6e7..28778552 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -135,12 +135,12 @@ def initialize(self): else: return - dat = np.copy(target_area.data.npvalue) + dat = np.copy(target_area.data.detach().cpu().numpy()) if target_area.has_mask: mask = target_area.mask.detach().cpu().numpy() dat[mask] = np.nanmedian(dat[~mask]) - COM = recursive_center_of_mass(target_area.data.npvalue) + COM = recursive_center_of_mass(dat) if not np.all(np.isfinite(COM)): return COM_center = target_area.pixel_to_plane( @@ -199,7 +199,7 @@ def sample( torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() ) psf_pad = np.max(self.psf.shape) // 2 - psf = self.psf.data.value + psf = self.psf.data elif isinstance(self.psf, Model): psf_upscale = ( torch.round(self.target.pixel_length / self.psf.target.pixel_length) @@ -207,7 +207,7 @@ def sample( .item() ) psf_pad = np.max(self.psf.window.shape) // 2 - psf = self.psf().data.value + psf = self.psf().data else: raise TypeError( f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." @@ -219,7 +219,9 @@ def sample( if self.psf_subpixel_shift: pixel_center = torch.stack(working_image.plane_to_pixel(*center)) pixel_shift = pixel_center - torch.round(pixel_center) - working_image.crpix = working_image.crpix.value - pixel_shift + working_image.crpix = ( + working_image.crpix.value - pixel_shift + ) # fixme move the model else: pixel_shift = None @@ -227,7 +229,7 @@ def sample( working_image.data = func.convolve_and_shift(sample, psf, pixel_shift) if self.psf_subpixel_shift: - working_image.crpix = working_image.crpix.value + pixel_shift + working_image.crpix = working_image.crpix.value + pixel_shift # fixme working_image = working_image.crop([psf_pad]).reduce(psf_upscale) else: @@ -236,7 +238,7 @@ def sample( working_image.data = sample # Units from flux/arcsec^2 to flux - working_image.data = working_image.data.value * working_image.pixel_area + working_image.data = working_image.data * working_image.pixel_area if self.mask is not None: working_image.data = working_image.data * (~self.mask) diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index c76b58d8..51a35952 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -53,7 +53,7 @@ def initialize(self): super().initialize() target_area = self.target[self.window] - dat = target_area.data.npvalue.copy() + dat = target_area.data.detach().cpu().numpy().copy() if target_area.has_mask: mask = target_area.mask.detach().cpu().numpy() dat[mask] = np.median(dat[~mask]) diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index 0e1e92de..bc36e9ff 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -48,7 +48,7 @@ def initialize(self): if self.pixels.initialized: return target_area = self.target[self.window] - self.pixels.dynamic_value = target_area.data.value.clone() / target_area.pixel_area + self.pixels.dynamic_value = target_area.data.clone() / target_area.pixel_area self.pixels.uncertainty = torch.abs(self.pixels.value) * self.default_uncertainty @forward diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index d7d47d2f..09455d26 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -37,16 +37,11 @@ def initialize(self): super().initialize() if not self.I0.initialized: - self.I0.dynamic_value = ( - np.median(self.target[self.window].data.npvalue) / self.target.pixel_area.item() + dat = self.target[self.window].data.detach().cpu().numpy().copy() + self.I0.dynamic_value = np.median(dat) / self.target.pixel_area.item() + self.I0.uncertainty = (iqr(dat, rng=(16, 84)) / 2.0) / np.sqrt( + np.prod(self.window.shape.detach().cpu().numpy()) ) - self.I0.uncertainty = ( - iqr( - self.target[self.window].data.npvalue, - rng=(16, 84), - ) - / 2.0 - ) / np.sqrt(np.prod(self.window.shape.detach().cpu().numpy())) if not self.delta.initialized: self.delta.dynamic_value = [0.0, 0.0] self.delta.uncertainty = [ diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 36b8b032..933ee0b2 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -51,7 +51,7 @@ def initialize(self): if not hasattr(self, "logflux") or self.logflux.initialized: return target_area = self.target[self.window] - dat = target_area.data.npvalue.copy() + dat = target_area.data.detach().cpu().numpy().copy() edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) self.logflux.dynamic_value = np.log10(np.abs(np.sum(dat - edge_average))) @@ -106,7 +106,7 @@ def sample(self, window: Optional[Window] = None, center=None, flux=None): # Compute the center offset pixel_center = torch.stack(working_image.plane_to_pixel(*center)) pixel_shift = pixel_center - torch.round(pixel_center) - psf = self.psf.data.value + psf = self.psf.data shift_kernel = func.fft_shift_kernel(psf.shape, pixel_shift[0], pixel_shift[1]) psf = torch.fft.irfft2(shift_kernel * torch.fft.rfft2(psf, s=psf.shape), s=psf.shape) # ( @@ -131,11 +131,11 @@ def sample(self, window: Optional[Window] = None, center=None, flux=None): ), image=working_image, ) - working_image[psf_window].data._value += psf[working_image.get_other_indices(psf_window)] + working_image[psf_window].data += psf[working_image.get_other_indices(psf_window)] working_image = working_image.reduce(psf_upscale) # Return to image pixelscale if self.mask is not None: - working_image.data = working_image.data.value * (~self.mask) + working_image.data = working_image.data * (~self.mask) return working_image diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index a319a0f1..4f9ded23 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -81,10 +81,10 @@ def sample(self, window=None): # normalize to total flux 1 if self.normalize_psf: - working_image.data = working_image.data.value / torch.sum(working_image.data.value) + working_image.data = working_image.data / torch.sum(working_image.data) if self.mask is not None: - working_image.data = working_image.data.value * (~self.mask) + working_image.data = working_image.data * (~self.mask) return working_image diff --git a/astrophot/models/zernike.py b/astrophot/models/zernike.py index 815e23e8..c5cbcea2 100644 --- a/astrophot/models/zernike.py +++ b/astrophot/models/zernike.py @@ -47,9 +47,7 @@ def initialize(self): self.Anm.dynamic_value = torch.zeros(len(self.nm_list)) self.Anm.uncertainty = self.default_uncertainty * torch.ones_like(self.Anm.value) if self.nm_list[0] == (0, 0): - self.Anm.value[0] = ( - torch.median(self.target[self.window].data.value) / self.target.pixel_area - ) + self.Anm.value[0] = torch.median(self.target[self.window].data) / self.target.pixel_area def iter_nm(self, n): nm = [] diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index ef4f686e..f401f5a9 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -48,7 +48,7 @@ def target_image(fig, ax, target, window=None, **kwargs): if window is None: window = target.window target_area = target[window] - dat = np.copy(target_area.data.npvalue) + dat = np.copy(target_area.data.detach().cpu().numpy()) if target_area.has_mask: dat[target_area.mask.detach().cpu().numpy()] = np.nan X, Y = target_area.pixel_to_plane(*target_area.pixel_corner_meshgrid()) @@ -121,7 +121,7 @@ def psf_image( x, y = psf.coordinate_corner_meshgrid() x = x.detach().cpu().numpy() y = y.detach().cpu().numpy() - psf = psf.data.value.detach().cpu().numpy() + psf = psf.data.detach().cpu().numpy() # Default kwargs for image kwargs = { @@ -228,7 +228,7 @@ def model_image( X, Y = sample_image.coordinate_corner_meshgrid() X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() - sample_image = sample_image.data.npvalue + sample_image = sample_image.data.detach().cpu().numpy() # Default kwargs for image kwargs = { @@ -348,7 +348,7 @@ def residual_image( X, Y = sample_image.coordinate_corner_meshgrid() X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() - residuals = (target - sample_image).data.value + residuals = (target - sample_image).data if normalize_residuals is True: residuals = residuals / torch.sqrt(target.variance) elif isinstance(normalize_residuals, torch.Tensor): diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 7cc08324..a74e106d 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -112,7 +112,7 @@ def radial_median_profile( R = (x**2 + y**2).sqrt() R = R.detach().cpu().numpy() - dat = image.data.value.detach().cpu().numpy() + dat = image.data.detach().cpu().numpy() count, bins, binnum = binned_statistic( R.ravel(), dat.ravel(), diff --git a/astrophot/utils/initialize/__init__.py b/astrophot/utils/initialize/__init__.py index 5224110e..1e631ee5 100644 --- a/astrophot/utils/initialize/__init__.py +++ b/astrophot/utils/initialize/__init__.py @@ -1,6 +1,6 @@ from .segmentation_map import * from .initialize import isophotes -from .center import center_of_mass, recursive_center_of_mass, GaussianDensity_Peak, Lanczos_peak +from .center import center_of_mass, recursive_center_of_mass from .construct_psf import gaussian_psf, moffat_psf, construct_psf from .variance import auto_variance @@ -8,8 +8,6 @@ "isophotes", "center_of_mass", "recursive_center_of_mass", - "GaussianDensity_Peak", - "Lanczos_peak", "gaussian_psf", "moffat_psf", "construct_psf", diff --git a/astrophot/utils/initialize/center.py b/astrophot/utils/initialize/center.py index c4294192..0977d42b 100644 --- a/astrophot/utils/initialize/center.py +++ b/astrophot/utils/initialize/center.py @@ -1,17 +1,15 @@ import numpy as np -from scipy.optimize import minimize - -from ..interpolate import point_Lanczos def center_of_mass(image): """Determines the light weighted center of mass""" - xx, yy = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij") - center = np.array((np.sum(image * xx), np.sum(image * yy))) / np.sum(image) + ii, jj = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij") + center = np.array((np.sum(image * ii), np.sum(image * jj))) / np.sum(image) return center def recursive_center_of_mass(image, max_iter=10, tol=1e-1): + """Determines the light weighted center of mass in a progressively smaller window each time centered on the previous center.""" center = center_of_mass(image) for i in range(max_iter): @@ -35,39 +33,3 @@ def recursive_center_of_mass(image, max_iter=10, tol=1e-1): center = new_center return center - - -def GaussianDensity_Peak(center, image, window=10, std=0.5): - init_center = center - window += window % 2 - - def _add_flux(c): - r = np.round(center) - xx, yy = np.meshgrid( - np.arange(r[0] - window / 2, r[0] + window / 2 + 1) - c[0], - np.arange(r[1] - window / 2, r[1] + window / 2 + 1) - c[1], - ) - rr2 = xx**2 + yy**2 - f = image[ - int(r[1] - window / 2) : int(r[1] + window / 2 + 1), - int(r[0] - window / 2) : int(r[0] + window / 2 + 1), - ] - return -np.sum(np.exp(-rr2 / (2 * std)) * f) - - res = minimize(_add_flux, x0=center) - return res.x - - -def Lanczos_peak(center, image, Lanczos_scale=3): - best = [np.inf, None] - for dx in np.arange(-3, 4): - for dy in np.arange(-3, 4): - res = minimize( - lambda x: -point_Lanczos(image, x[0], x[1], scale=Lanczos_scale), - x0=(center[0] + dx, center[1] + dy), - method="Nelder-Mead", - ) - if res.fun < best[0]: - best[0] = res.fun - best[1] = res.x - return best[1] diff --git a/astrophot/utils/initialize/construct_psf.py b/astrophot/utils/initialize/construct_psf.py index 1094c0cc..b1e70298 100644 --- a/astrophot/utils/initialize/construct_psf.py +++ b/astrophot/utils/initialize/construct_psf.py @@ -1,6 +1,5 @@ import numpy as np -from .center import GaussianDensity_Peak from ..interpolate import shift_Lanczos_np diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index 4c297400..e7f4df1a 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -4,8 +4,6 @@ import numpy as np import torch from astropy.io import fits -from ..angle_operations import Angle_COM_PA -from ..operations import axis_ratio_com __all__ = ( "centroids_from_segmentation_map", @@ -60,15 +58,15 @@ def centroids_from_segmentation_map( centroids = {} - XX, YY = np.meshgrid(np.arange(seg_map.shape[1]), np.arange(seg_map.shape[0])) + II, JJ = np.meshgrid(np.arange(seg_map.shape[0]), np.arange(seg_map.shape[1]), indexing="ij") for index in np.unique(seg_map): if index is None or index in skip_index: continue N = seg_map == index - xcentroid = np.sum(XX[N] * image[N]) / np.sum(image[N]) - ycentroid = np.sum(YY[N] * image[N]) / np.sum(image[N]) - centroids[index] = [xcentroid, ycentroid] + icentroid = np.sum(II[N] * image[N]) / np.sum(image[N]) + jcentroid = np.sum(JJ[N] * image[N]) / np.sum(image[N]) + centroids[index] = [icentroid, jcentroid] return centroids @@ -77,31 +75,40 @@ def PA_from_segmentation_map( seg_map: Union[np.ndarray, str], image: Union[np.ndarray, str], centroids=None, + sky_level=None, hdul_index_seg: int = 0, hdul_index_img: int = 0, skip_index: tuple = (0,), - north=np.pi / 2, + softening=1e-3, ): seg_map = _select_img(seg_map, hdul_index_seg) image = _select_img(image, hdul_index_img) + if sky_level is None: + sky_level = np.nanmedian(image) if centroids is None: centroids = centroids_from_segmentation_map( seg_map=seg_map, image=image, skip_index=skip_index ) - XX, YY = np.meshgrid(np.arange(image.shape[1]), np.arange(image.shape[0])) - + II, JJ = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij") PAs = {} for index in np.unique(seg_map): if index is None or index in skip_index: continue N = seg_map == index - PA = ( - Angle_COM_PA(image[N], XX[N] - centroids[index][0], YY[N] - centroids[index][1]) + north - ) - PAs[index] = PA % np.pi + dat = image[N] - sky_level + ii = II[N] - centroids[index][0] + jj = JJ[N] - centroids[index][1] + mu20 = np.median(dat * np.abs(ii)) + mu02 = np.median(dat * np.abs(jj)) + mu11 = np.median(dat * ii * jj / np.sqrt(np.abs(ii * jj) + softening**2)) + M = np.array([[mu20, mu11], [mu11, mu02]]) + if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): + PAs[index] = np.pi / 2 + else: + PAs[index] = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi return PAs @@ -110,35 +117,41 @@ def q_from_segmentation_map( seg_map: Union[np.ndarray, str], image: Union[np.ndarray, str], centroids=None, - PAs=None, + sky_level=None, hdul_index_seg: int = 0, hdul_index_img: int = 0, skip_index: tuple = (0,), - north=np.pi / 2, + softening=1e-3, ): seg_map = _select_img(seg_map, hdul_index_seg) image = _select_img(image, hdul_index_img) + if sky_level is None: + sky_level = np.nanmedian(image) if centroids is None: centroids = centroids_from_segmentation_map( seg_map=seg_map, image=image, skip_index=skip_index ) - if PAs is None: - PAs = PA_from_segmentation_map( - seg_map=seg_map, image=image, centroids=centroids, skip_index=skip_index - ) - - XX, YY = np.meshgrid(np.arange(image.shape[1]), np.arange(image.shape[0])) + II, JJ = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij") qs = {} for index in np.unique(seg_map): if index is None or index in skip_index: continue N = seg_map == index - qs[index] = axis_ratio_com( - image[N], PAs[index] + north, XX[N] - centroids[index][0], YY[N] - centroids[index][1] - ) + dat = image[N] - sky_level + ii = II[N] - centroids[index][0] + jj = JJ[N] - centroids[index][1] + mu20 = np.median(dat * np.abs(ii)) + mu02 = np.median(dat * np.abs(jj)) + mu11 = np.median(dat * ii * jj / np.sqrt(np.abs(ii * jj) + softening**2)) + M = np.array([[mu20, mu11], [mu11, mu02]]) + if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): + qs[index] = 0.7 + else: + l = np.sort(np.linalg.eigvals(M)) + qs[index] = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) return qs @@ -329,7 +342,9 @@ def transfer_windows(windows, base_image, new_image): ) # (4,2) bottom_corner = np.floor(np.min(four_corners_new, axis=0)).astype(int) + bottom_corner = np.clip(bottom_corner, 0, np.array(new_image.shape)) top_corner = np.ceil(np.max(four_corners_new, axis=0)).astype(int) + top_corner = np.clip(top_corner, 0, np.array(new_image.shape)) new_windows[w] = [ [int(bottom_corner[0]), int(bottom_corner[1])], [int(top_corner[0]), int(top_corner[1])], diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 38bacd2b..e19770cc 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -454,7 +454,7 @@ "\n", "fig2, ax2 = plt.subplots(figsize=(8, 8))\n", "\n", - "pixels = model2().data.npvalue\n", + "pixels = model2().data.detach().cpu().numpy()\n", "\n", "im = plt.imshow(\n", " np.log10(pixels), # take log10 for better dynamic range\n", @@ -567,14 +567,14 @@ "# Now new AstroPhot objects will be made with single bit precision\n", "T1 = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1.0)\n", "T1.to()\n", - "print(\"now a single:\", T1.data.value.dtype)\n", + "print(\"now a single:\", T1.data.dtype)\n", "\n", "# Here we switch back to double precision\n", "ap.AP_config.ap_dtype = torch.float64\n", "T2 = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1.0)\n", "T2.to()\n", - "print(\"back to double:\", T2.data.value.dtype)\n", - "print(\"old image is still single!:\", T1.data.value.dtype)" + "print(\"back to double:\", T2.data.dtype)\n", + "print(\"old image is still single!:\", T1.data.dtype)" ] }, { diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 2387b52a..a9f97263 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -24,8 +24,7 @@ "import torch\n", "from astropy.io import fits\n", "from astropy.wcs import WCS\n", - "import matplotlib.pyplot as plt\n", - "from scipy.stats import iqr" + "import matplotlib.pyplot as plt" ] }, { @@ -326,14 +325,18 @@ "rwindows = ap.utils.initialize.scale_windows(\n", " rwindows, image_shape=rimg_data.shape, expand_scale=1.5, expand_border=10\n", ")\n", + "print(f\"Initial windows: {rwindows}\")\n", "w1windows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_W1)\n", "nuvwindows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_NUV)\n", "print(f\"W1-band windows: {w1windows}\")\n", "print(f\"NUV-band windows: {nuvwindows}\")\n", "# Here we get some basic starting parameters for the galaxies (center, position angle, axis ratio)\n", "centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, rimg_data)\n", + "print(f\"Centroids: {centers}\")\n", "PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, rimg_data, centers)\n", - "qs = ap.utils.initialize.q_from_segmentation_map(segmap, rimg_data, centers, PAs)" + "print(f\"Position angles: {PAs}\")\n", + "qs = ap.utils.initialize.q_from_segmentation_map(segmap, rimg_data, centers)\n", + "print(f\"Axis ratios: {qs}\")" ] }, { @@ -364,7 +367,7 @@ " window=rwindows[window],\n", " psf_mode=\"full\",\n", " center=torch.stack(target_r.pixel_to_plane(*torch.tensor(centers[window]))),\n", - " PA=-PAs[window],\n", + " PA=target_r.pixel_angle_to_plane_angle(torch.tensor(PAs[window])),\n", " q=qs[window],\n", " )\n", " )\n", @@ -427,7 +430,6 @@ "outputs": [], "source": [ "MODEL.initialize()\n", - "print(MODEL)\n", "MODEL.graphviz()" ] }, @@ -447,14 +449,21 @@ "metadata": {}, "outputs": [], "source": [ - "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 4))\n", - "ap.plots.model_image(fig1, ax1, MODEL, vmax=30)\n", - "ax1[0].set_title(\"r-band model image\")\n", - "ax1[0].invert_xaxis()\n", - "ax1[1].set_title(\"W1-band model image\")\n", - "ax1[1].invert_xaxis()\n", - "ax1[2].set_title(\"NUV-band model image\")\n", - "ax1[2].invert_xaxis()\n", + "fig1, ax1 = plt.subplots(2, 3, figsize=(18, 11))\n", + "ap.plots.model_image(fig1, ax1[0], MODEL, vmax=30)\n", + "ax1[0][0].set_title(\"r-band model image\")\n", + "ax1[0][0].invert_xaxis()\n", + "ax1[0][1].set_title(\"W1-band model image\")\n", + "ax1[0][1].invert_xaxis()\n", + "ax1[0][2].set_title(\"NUV-band model image\")\n", + "ax1[0][2].invert_xaxis()\n", + "ap.plots.residual_image(fig, ax1[1], MODEL, normalize_residuals=True)\n", + "ax1[1][0].set_title(\"r-band residual image\")\n", + "ax1[1][0].invert_xaxis()\n", + "ax1[1][1].set_title(\"W1-band residual image\")\n", + "ax1[1][1].invert_xaxis()\n", + "ax1[1][2].set_title(\"NUV-band residual image\")\n", + "ax1[1][2].invert_xaxis()\n", "plt.show()" ] }, @@ -467,23 +476,6 @@ "An important note here is that the SB levels for the W1 and NUV data are quire reasonable. While the structure (center, PA, q, n, Re) was shared between bands and therefore mostly driven by the r-band, the brightness is entirely independent between bands meaning the Ie (and therefore SB) values are right from the W1 and NUV data!" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig, ax = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.residual_image(fig, ax, MODEL, normalize_residuals=True)\n", - "ax[0].set_title(\"r-band residual image\")\n", - "ax[0].invert_xaxis()\n", - "ax[1].set_title(\"W1-band residual image\")\n", - "ax[1].invert_xaxis()\n", - "ax[2].set_title(\"NUV-band residual image\")\n", - "ax[2].invert_xaxis()\n", - "plt.show()" - ] - }, { "cell_type": "markdown", "metadata": {}, From 11d638477cf67a8c6f3e765dd3490665c44336ba Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 6 Jul 2025 23:27:51 -0400 Subject: [PATCH 046/191] starting to add sip --- astrophot/fit/lm.py | 2 +- astrophot/image/distort_image.py | 23 ++++++++ astrophot/image/func/__init__.py | 2 + astrophot/image/func/wcs.py | 25 ++++++++- astrophot/image/image_object.py | 8 +-- astrophot/image/model_image.py | 3 ++ astrophot/image/sip_target.py | 52 +++++++++++++++++++ astrophot/models/func/convolution.py | 1 - astrophot/models/model_object.py | 19 ++++--- astrophot/param/__init__.py | 4 +- .../utils/initialize/segmentation_map.py | 4 +- docs/source/tutorials/BasicPSFModels.ipynb | 10 ++-- docs/source/tutorials/JointModels.ipynb | 12 ++++- 13 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 astrophot/image/distort_image.py create mode 100644 astrophot/image/sip_target.py diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 1c853ca1..88203b46 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -265,7 +265,7 @@ def fit(self) -> BaseOptimizer: for _ in range(self.max_iter): if self.verbose > 0: - AP_config.ap_logger.info(f"Chi^2/DoF: {self.loss_history[-1]:.4g}, L: {self.L:.3g}") + AP_config.ap_logger.info(f"Chi^2/DoF: {self.loss_history[-1]:.6g}, L: {self.L:.3g}") try: if self.fit_valid: with ValidContext(self.model): diff --git a/astrophot/image/distort_image.py b/astrophot/image/distort_image.py new file mode 100644 index 00000000..1f3d752e --- /dev/null +++ b/astrophot/image/distort_image.py @@ -0,0 +1,23 @@ +from ..param import forward +from . import func +from ..utils.interpolate import interp2d + + +class DistortImageMixin: + """ + DistortImage is a subclass of Image that applies a distortion to the image. + This is typically used for images that have been distorted by a telescope or camera. + """ + + @forward + def pixel_to_plane(self, i, j, crtan): + di = interp2d(self.distortion_ij[0], i, j) + dj = interp2d(self.distortion_ij[1], i, j) + return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, self.pixelscale, *crtan) + + @forward + def plane_to_pixel(self, x, y, crtan): + I, J = func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale, *crtan) + dI = interp2d(self.distortion_IJ[0], I, J) + dJ = interp2d(self.distortion_IJ[1], I, J) + return I + dI, J + dJ diff --git a/astrophot/image/func/__init__.py b/astrophot/image/func/__init__.py index f346ed70..c00031dd 100644 --- a/astrophot/image/func/__init__.py +++ b/astrophot/image/func/__init__.py @@ -9,6 +9,7 @@ plane_to_world_gnomonic, pixel_to_plane_linear, plane_to_pixel_linear, + sip_delta, ) from .window import window_or, window_and @@ -21,6 +22,7 @@ "plane_to_world_gnomonic", "pixel_to_plane_linear", "plane_to_pixel_linear", + "sip_delta", "window_or", "window_and", ) diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 1b6a8def..083e9f83 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -118,6 +118,29 @@ def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): return xy[:, 0].reshape(i.shape) + x0, xy[:, 1].reshape(j.shape) + y0 +def sip_delta(u, v, sipA=(), sipB=()): + """ + u = j - j0 + v = i - i0 + sipA = dict(tuple(int,int), float) + The SIP coefficients, where the keys are tuples of powers (i, j) and the values are the coefficients. + For example, {(1, 2): 0.1} means delta_u = 0.1 * (u * v^2). + """ + delta_u = torch.zeros_like(u) + delta_v = torch.zeros_like(v) + # Get all used coefficient powers + all_a = set(s[0] for s in sipA) | set(s[0] for s in sipB) + all_b = set(s[1] for s in sipA) | set(s[1] for s in sipB) + # Pre-compute all powers of u and v + u_a = dict((a, u**a) for a in all_a) + v_b = dict((b, v**b) for b in all_b) + for a, b in sipA: + delta_u = delta_u + sipA[(a, b)] * (u_a[a] * v_b[b]) + for a, b in sipB: + delta_v = delta_v + sipB[(a, b)] * (u_a[a] * v_b[b]) + return delta_u, delta_v + + def pixel_to_plane_sip(i, j, i0, j0, CD, sip_powers=[], sip_coefs=[], x0=0.0, y0=0.0): """ Convert pixel coordinates to a tangent plane using the WCS information. This @@ -173,7 +196,7 @@ def pixel_to_plane_sip(i, j, i0, j0, CD, sip_powers=[], sip_coefs=[], x0=0.0, y0 Tuple: [Tensor, Tensor] Tuple containing the x and y tangent plane coordinates in arcsec. """ - uv = torch.stack((j - j0, i - i0), -1) + uv = torch.stack((j.reshape(-1) - j0, i.reshape(-1) - i0), dim=1) delta_p = torch.zeros_like(uv) for p in range(len(sip_powers)): delta_p += sip_coefs[p] * torch.prod(uv ** sip_powers[p], dim=-1).unsqueeze(-1) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 64bbfa36..39363c3b 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -217,12 +217,12 @@ def plane_to_pixel(self, x, y, crtan): return func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale_inv, *crtan) @forward - def plane_to_world(self, x, y, crval, crtan): - return func.plane_to_world_gnomonic(x, y, *crval, *crtan) + def plane_to_world(self, x, y, crval): + return func.plane_to_world_gnomonic(x, y, *crval) @forward - def world_to_plane(self, ra, dec, crval, crtan): - return func.world_to_plane_gnomonic(ra, dec, *crval, *crtan) + def world_to_plane(self, ra, dec, crval): + return func.world_to_plane_gnomonic(ra, dec, *crval) @forward def world_to_pixel(self, ra, dec): diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index c90d565f..14d0605f 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -110,6 +110,9 @@ def reduce(self, scale: int, **kwargs): **kwargs, ) + def fluxdensity_to_flux(self): + self.data = self.data * self.pixel_area + ###################################################################### class ModelImageList(ImageList): diff --git a/astrophot/image/sip_target.py b/astrophot/image/sip_target.py new file mode 100644 index 00000000..79a54a88 --- /dev/null +++ b/astrophot/image/sip_target.py @@ -0,0 +1,52 @@ +from target_image import TargetImage +from distort_image import DistortImageMixin +from . import func + + +class SIPTargetImage(DistortImageMixin, TargetImage): + + def __init__(self, *args, sipA=(), sipB=(), sipAP=(), sipBP=(), pixel_area_map=None, **kwargs): + super().__init__(*args, **kwargs) + self.sipA = sipA + self.sipB = sipB + self.sipAP = sipAP + self.sipBP = sipBP + + i, j = self.pixel_center_meshgrid() + u, v = i - self.crpix[0], j - self.crpix[1] + self.distortion_ij = func.sip_delta(u, v, self.sipA, self.sipB) + self.distortion_IJ = func.sip_delta(u, v, self.sipAP, self.sipBP) # fixme maybe + + if pixel_area_map is None: + self.update_pixel_area_map() + else: + self._pixel_area_map = pixel_area_map + + @property + def pixel_area_map(self): + return self._pixel_area_map + + def update_pixel_area_map(self): + """ + Update the pixel area map based on the current SIP coefficients. + """ + i, j = self.pixel_corner_meshgrid() + x, y = self.pixel_to_plane(i, j) + + # 1: [:-1, :-1] + # 2: [:-1, 1:] + # 3: [1:, 1:] + # 4: [1:, :-1] + A = 0.5 * ( + x[:-1, :-1] * y[:-1, 1:] + + x[:-1, 1:] * y[1:, 1:] + + x[1:, 1:] * y[1:, :-1] + + x[1:, :-1] * y[:-1, :-1] + - ( + x[:-1, 1:] * y[:-1, :-1] + + x[1:, 1:] * y[:-1, 1:] + + x[1:, :-1] * y[1:, 1:] + + x[:-1, :-1] * y[1:, :-1] + ) + ) + self._pixel_area_map = A.abs() diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index b62ce2b0..592cb4f2 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -33,7 +33,6 @@ def fft_shift_kernel(shape, di, dj): ni, nj = shape ki = torch.fft.fftfreq(ni, dtype=di.dtype, device=di.device) kj = torch.fft.rfftfreq(nj, dtype=di.dtype, device=di.device) - Ki, Kj = torch.meshgrid(ki, kj, indexing="ij") phase = -2j * torch.pi * (Ki * torch.arctan(di) + Kj * torch.arctan(dj)) return torch.exp(phase) diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 28778552..0bd732b8 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -3,7 +3,7 @@ import numpy as np import torch -from ..param import forward +from ..param import forward, OverrideParam from .base import Model from . import func from ..image import ( @@ -218,18 +218,17 @@ def sample( # Sub pixel shift to align the model with the center of a pixel if self.psf_subpixel_shift: pixel_center = torch.stack(working_image.plane_to_pixel(*center)) - pixel_shift = pixel_center - torch.round(pixel_center) - working_image.crpix = ( - working_image.crpix.value - pixel_shift - ) # fixme move the model + pixel_centered = torch.round(pixel_center) + pixel_shift = pixel_center - pixel_centered + with OverrideParam( + self.center, torch.stack(working_image.pixel_to_plane(*pixel_centered)) + ): + sample = self.sample_image(working_image) else: pixel_shift = None - - sample = self.sample_image(working_image) + sample = self.sample_image(working_image) working_image.data = func.convolve_and_shift(sample, psf, pixel_shift) - if self.psf_subpixel_shift: - working_image.crpix = working_image.crpix.value + pixel_shift # fixme working_image = working_image.crop([psf_pad]).reduce(psf_upscale) else: @@ -238,7 +237,7 @@ def sample( working_image.data = sample # Units from flux/arcsec^2 to flux - working_image.data = working_image.data * working_image.pixel_area + working_image.data = working_image.fluxdensity_to_flux() if self.mask is not None: working_image.data = working_image.data * (~self.mask) diff --git a/astrophot/param/__init__.py b/astrophot/param/__init__.py index 1de02ba6..1b780893 100644 --- a/astrophot/param/__init__.py +++ b/astrophot/param/__init__.py @@ -1,5 +1,5 @@ -from caskade import forward, ValidContext +from caskade import forward, ValidContext, OverrideParam from .module import Module from .param import Param -__all__ = ["Module", "Param", "forward", "ValidContext"] +__all__ = ["Module", "Param", "forward", "ValidContext", "OverrideParam"] diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index e7f4df1a..dee180a9 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -330,7 +330,9 @@ def transfer_windows(windows, base_image, new_image): windows[w][1], [windows[w][0][0], windows[w][1][1]], [windows[w][1][0], windows[w][0][1]], - ] + ], + dtype=base_image.data.dtype, + device=base_image.data.device, ) # (4,2) four_corners_new = ( torch.stack( diff --git a/docs/source/tutorials/BasicPSFModels.ipynb b/docs/source/tutorials/BasicPSFModels.ipynb index 44efdb7d..1d2e628c 100644 --- a/docs/source/tutorials/BasicPSFModels.ipynb +++ b/docs/source/tutorials/BasicPSFModels.ipynb @@ -22,7 +22,6 @@ "\n", "import astrophot as ap\n", "import numpy as np\n", - "import torch\n", "import matplotlib.pyplot as plt\n", "\n", "%matplotlib inline" @@ -131,7 +130,7 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " psf_mode=\"none\", # no PSF convolution will be done\n", ")\n", "model_nopsf.initialize()\n", @@ -143,8 +142,9 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " psf_mode=\"full\", # now the full window will be PSF convolved using the PSF from the target\n", + " psf_subpixel_shift=True,\n", ")\n", "model_psf.initialize()\n", "\n", @@ -164,7 +164,7 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " psf_mode=\"full\",\n", " psf=psf_target_2, # Now this model has its own PSF, instead of using the target psf\n", ")\n", @@ -200,7 +200,7 @@ "source": [ "upsample_psf_target = ap.image.PSFImage(\n", " data=ap.utils.initialize.gaussian_psf(2.0, 51, 0.25),\n", - " pixelscale=0.25,\n", + " pixelscale=0.25, # This PSF is at a higher resolution than the target\n", ")\n", "target.psf = upsample_psf_target\n", "\n", diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index a9f97263..7b709907 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -189,7 +189,17 @@ "outputs": [], "source": [ "result = ap.fit.LM(model_full, verbose=1).fit()\n", - "print(result.message)" + "print(result.message)\n", + "print(model_full)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(model_full.models[0].center.value)" ] }, { From 7930e65d6a7c3e2e36b29ec8bbe1dcb55d326f2c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 7 Jul 2025 22:27:27 -0400 Subject: [PATCH 047/191] remove automatic uncertainty better point model --- astrophot/fit/lm.py | 3 +- astrophot/image/distort_image.py | 13 - astrophot/image/image_object.py | 79 ++--- astrophot/image/mixins/__init__.py | 4 + astrophot/image/mixins/data_mixin.py | 312 ++++++++++++++++++ astrophot/image/mixins/sip_mixin.py | 140 ++++++++ astrophot/image/model_image.py | 23 +- astrophot/image/psf_image.py | 60 ++-- astrophot/image/sip_target.py | 108 +++--- astrophot/image/target_image.py | 256 ++------------ astrophot/models/_shared_methods.py | 48 +-- astrophot/models/airy.py | 2 - astrophot/models/base.py | 3 +- astrophot/models/edgeon.py | 17 +- astrophot/models/eigen.py | 2 - astrophot/models/flatsky.py | 3 - astrophot/models/func/convolution.py | 7 +- astrophot/models/mixins/moffat.py | 8 +- astrophot/models/mixins/sersic.py | 4 +- astrophot/models/mixins/spline.py | 4 - astrophot/models/mixins/transform.py | 27 +- astrophot/models/model_object.py | 39 ++- astrophot/models/multi_gaussian_expansion.py | 2 - astrophot/models/pixelated_psf.py | 1 - astrophot/models/planesky.py | 7 - astrophot/models/point_source.py | 68 ++-- astrophot/models/psf_model_object.py | 9 +- astrophot/models/zernike.py | 1 - astrophot/param/param.py | 15 + astrophot/plots/image.py | 6 +- astrophot/utils/interpolate.py | 5 +- docs/source/tutorials/AdvancedPSFModels.ipynb | 28 +- docs/source/tutorials/BasicPSFModels.ipynb | 24 +- docs/source/tutorials/GettingStarted.ipynb | 1 - 34 files changed, 742 insertions(+), 587 deletions(-) create mode 100644 astrophot/image/mixins/__init__.py create mode 100644 astrophot/image/mixins/data_mixin.py create mode 100644 astrophot/image/mixins/sip_mixin.py diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 88203b46..c2b9fb13 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -2,6 +2,7 @@ from typing import Sequence import torch +import numpy as np from .base import BaseOptimizer from .. import AP_config @@ -302,7 +303,7 @@ def fit(self) -> BaseOptimizer: self.message = self.message + "fail. Could not find step to improve Chi^2" break - self.L = res["L"] + self.L = np.clip(res["L"], 1e-9, 1e9) self.L_history.append(res["L"]) self.loss_history.append(res["chi2"]) self.lambda_history.append(self.current_state.detach().clone().cpu().numpy()) diff --git a/astrophot/image/distort_image.py b/astrophot/image/distort_image.py index 1f3d752e..a45fd709 100644 --- a/astrophot/image/distort_image.py +++ b/astrophot/image/distort_image.py @@ -8,16 +8,3 @@ class DistortImageMixin: DistortImage is a subclass of Image that applies a distortion to the image. This is typically used for images that have been distorted by a telescope or camera. """ - - @forward - def pixel_to_plane(self, i, j, crtan): - di = interp2d(self.distortion_ij[0], i, j) - dj = interp2d(self.distortion_ij[1], i, j) - return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, self.pixelscale, *crtan) - - @forward - def plane_to_pixel(self, x, y, crtan): - I, J = func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale, *crtan) - dI = interp2d(self.distortion_IJ[0], I, J) - dJ = interp2d(self.distortion_IJ[1], I, J) - return I + dI, J + dJ diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 39363c3b..3663c7cf 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -66,10 +66,21 @@ def __init__( super().__init__(name=name) self.data = data # units: flux self.crval = Param( - "crval", units="deg", dtype=AP_config.ap_dtype, device=AP_config.ap_device + "crval", shape=(2,), units="deg", dtype=AP_config.ap_dtype, device=AP_config.ap_device ) self.crtan = Param( - "crtan", units="arcsec", dtype=AP_config.ap_dtype, device=AP_config.ap_device + "crtan", + shape=(2,), + units="arcsec", + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + self.pixelscale = Param( + "pixelscale", + shape=(2, 2), + units="arcsec/pixel", + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, ) if filename is not None: @@ -105,6 +116,8 @@ def __init__( self.crtan = crtan self.crpix = crpix + if isinstance(pixelscale, (float, int)): + pixelscale = np.array([[pixelscale, 0.0], [0.0, pixelscale]], dtype=np.float64) self.pixelscale = pixelscale self.zeropoint = zeropoint @@ -165,30 +178,13 @@ def shape(self): return self.data.shape @property - def pixelscale(self): - return self._pixelscale - - @pixelscale.setter - def pixelscale(self, pixelscale): - if pixelscale is None: - pixelscale = self.default_pixelscale - elif isinstance(pixelscale, (float, int)) or ( - isinstance(pixelscale, torch.Tensor) and pixelscale.numel() == 1 - ): - pixelscale = ((pixelscale, 0.0), (0.0, pixelscale)) - self._pixelscale = torch.as_tensor( - pixelscale, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - self._pixel_area = torch.linalg.det(self._pixelscale).abs() - self._pixel_length = self._pixel_area.sqrt() - self._pixelscale_inv = torch.linalg.inv(self._pixelscale) - - @property - def pixel_area(self): + @forward + def pixel_area(self, pixelscale): """The area inside a pixel in arcsec^2""" - return self._pixel_area + return torch.linalg.det(pixelscale).abs() @property + @forward def pixel_length(self): """The approximate length of a pixel, which is just sqrt(pixel_area). For square pixels this is the actual pixel @@ -198,19 +194,20 @@ def pixel_length(self): and instead sets a size scale within an image. """ - return self._pixel_length + return self.pixel_area.sqrt() @property - def pixelscale_inv(self): + @forward + def pixelscale_inv(self, pixelscale): """The inverse of the pixel scale matrix, which is used to transform tangent plane coordinates into pixel coordinates. """ - return self._pixelscale_inv + return torch.linalg.inv(pixelscale) @forward - def pixel_to_plane(self, i, j, crtan): - return func.pixel_to_plane_linear(i, j, *self.crpix, self.pixelscale, *crtan) + def pixel_to_plane(self, i, j, crtan, pixelscale): + return func.pixel_to_plane_linear(i, j, *self.crpix, pixelscale, *crtan) @forward def plane_to_pixel(self, x, y, crtan): @@ -299,7 +296,7 @@ def copy(self, **kwargs): """ kwargs = { "data": torch.clone(self.data.detach()), - "pixelscale": self.pixelscale, + "pixelscale": self.pixelscale.value, "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, @@ -317,7 +314,7 @@ def blank_copy(self, **kwargs): """ kwargs = { "data": torch.zeros_like(self.data), - "pixelscale": self.pixelscale, + "pixelscale": self.pixelscale.value, "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, @@ -351,10 +348,10 @@ def fits_info(self): "CRPIX2": self.crpix[1], "CRTAN1": self.crtan.value[0].item(), "CRTAN2": self.crtan.value[1].item(), - "CD1_1": self.pixelscale[0][0].item(), - "CD1_2": self.pixelscale[0][1].item(), - "CD2_1": self.pixelscale[1][0].item(), - "CD2_2": self.pixelscale[1][1].item(), + "CD1_1": self.pixelscale.value[0][0].item(), + "CD1_2": self.pixelscale.value[0][1].item(), + "CD2_1": self.pixelscale.value[1][0].item(), + "CD2_2": self.pixelscale.value[1][1].item(), "MAGZP": self.zeropoint.item() if self.zeropoint is not None else -999, "IDNTY": self.identity, } @@ -448,17 +445,15 @@ def get_other_indices(self, other: Window): ) raise ValueError() - def get_window(self, other: Union[Window, "Image"], _indices=None, **kwargs): + def get_window(self, other: Union[Window, "Image"], indices=None, **kwargs): """Get a new image object which is a window of this image corresponding to the other image's window. This will return a new image object with the same properties as this one, but with the data cropped to the other image's window. """ - if _indices is None: + if indices is None: indices = self.get_indices(other if isinstance(other, Window) else other.window) - else: - indices = _indices new_img = self.copy( data=self.data[indices], crpix=self.crpix - np.array((indices[0].start, indices[1].start)), @@ -515,14 +510,6 @@ def __init__(self, images, name=None): f"Image_List can only hold Image objects, not {tuple(type(image) for image in self.images)}" ) - @property - def pixelscale(self): - return tuple(image.pixelscale for image in self.images) - - @property - def zeropoint(self): - return tuple(image.zeropoint for image in self.images) - @property def data(self): return tuple(image.data for image in self.images) diff --git a/astrophot/image/mixins/__init__.py b/astrophot/image/mixins/__init__.py new file mode 100644 index 00000000..c8a342e8 --- /dev/null +++ b/astrophot/image/mixins/__init__.py @@ -0,0 +1,4 @@ +from .data_mixin import DataMixin +from .sip_mixin import SIPMixin + +__all__ = ("DataMixin", "SIPMixin") diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py new file mode 100644 index 00000000..f966a7f3 --- /dev/null +++ b/astrophot/image/mixins/data_mixin.py @@ -0,0 +1,312 @@ +from typing import Union + +import torch +import numpy as np +from astropy.io import fits + +from ...utils.initialize import auto_variance +from ... import AP_config +from ...errors import SpecificationConflict +from ..image_object import Image +from ..window import Window + + +class DataMixin: + + def __init__(self, *args, mask=None, std=None, variance=None, weight=None, **kwargs): + super().__init__(*args, **kwargs) + + self.mask = mask + if (std is not None) + (variance is not None) + (weight is not None) > 1: + raise SpecificationConflict( + "Can only define one of: std, variance, or weight for a given image." + ) + + if std is not None: + self.std = std + elif variance is not None: + self.variance = variance + else: + self.weight = weight + + # Set nan pixels to be masked automatically + if torch.any(torch.isnan(self.data)).item(): + self.mask = self.mask | torch.isnan(self.data) + + @property + def std(self): + """Stores the standard deviation of the image pixels. This represents + the uncertainty in each pixel value. It should always have the + same shape as the image data. In the case where the standard + deviation is not known, a tensor of ones will be created to + stand in as the standard deviation values. + + The standard deviation is not stored directly, instead it is + computed as :math:`\\sqrt{1/W}` where :math:`W` is the + weights. + + """ + if self.has_variance: + return torch.sqrt(self.variance) + return torch.ones_like(self.data) + + @std.setter + def std(self, std): + if std is None: + self._weight = None + return + if isinstance(std, str) and std == "auto": + self.weight = "auto" + return + self.weight = 1 / std**2 + + @property + def has_std(self): + """Returns True when the image object has stored standard deviation values. If + this is False and the std property is called then a + tensor of ones will be returned. + + """ + try: + return self._weight is not None + except AttributeError: + return False + + @property + def variance(self): + """Stores the variance of the image pixels. This represents the + uncertainty in each pixel value. It should always have the + same shape as the image data. In the case where the variance + is not known, a tensor of ones will be created to stand in as + the variance values. + + The variance is not stored directly, instead it is + computed as :math:`\\frac{1}{W}` where :math:`W` is the + weights. + + """ + if self.has_variance: + return torch.where(self._weight == 0, torch.inf, 1 / self._weight) + return torch.ones_like(self.data) + + @variance.setter + def variance(self, variance): + if variance is None: + self._weight = None + return + if isinstance(variance, str) and variance == "auto": + self.weight = "auto" + return + self.weight = 1 / variance + + @property + def has_variance(self): + """Returns True when the image object has stored variance values. If + this is False and the variance property is called then a + tensor of ones will be returned. + + """ + try: + return self._weight is not None + except AttributeError: + return False + + @property + def weight(self): + """Stores the weight of the image pixels. This represents the + uncertainty in each pixel value. It should always have the + same shape as the image data. In the case where the weight + is not known, a tensor of ones will be created to stand in as + the weight values. + + The weights are used to proprtionately scale residuals in the + likelihood. Most commonly this shows up as a :math:`\\chi^2` + like: + + .. math:: + + \\chi^2 = (\\vec{y} - \\vec{f(\\theta)})^TW(\\vec{y} - \\vec{f(\\theta)}) + + which can be optimized to find parameter values. Using the + Jacobian, which in this case is the derivative of every pixel + wrt every parameter, the weight matrix also appears in the + gradient: + + .. math:: + + \\vec{g} = J^TW(\\vec{y} - \\vec{f(\\theta)}) + + and the hessian approximation used in Levenberg-Marquardt: + + .. math:: + + H \\approx J^TWJ + + """ + if self.has_weight: + return self._weight + return torch.ones_like(self.data) + + @weight.setter + def weight(self, weight): + if weight is None: + self._weight = None + return + if isinstance(weight, str) and weight == "auto": + weight = 1 / auto_variance(self.data, self.mask) + if weight.shape != self.data.shape: + raise SpecificationConflict( + f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" + ) + self._weight = torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + + @property + def has_weight(self): + """Returns True when the image object has stored weight values. If + this is False and the weight property is called then a + tensor of ones will be returned. + + """ + try: + return self._weight is not None + except AttributeError: + self._weight = None + return False + + @property + def mask(self): + """The mask stores a tensor of boolean values which indicate any + pixels to be ignored. These pixels will be skipped in + likelihood evaluations and in parameter optimization. It is + common practice to mask pixels with pathological values such + as due to cosmic rays or satellites passing through the image. + + In a mask, a True value indicates that the pixel is masked and + should be ignored. False indicates a normal pixel which will + inter into most calculations. + + If no mask is provided, all pixels are assumed valid. + + """ + if self.has_mask: + return self._mask + return torch.zeros_like(self.data, dtype=torch.bool) + + @mask.setter + def mask(self, mask): + if mask is None: + self._mask = None + return + if mask.shape != self.data.shape: + raise SpecificationConflict( + f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" + ) + self._mask = torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device) + + @property + def has_mask(self): + """ + Single boolean to indicate if a mask has been provided by the user. + """ + try: + return self._mask is not None + except AttributeError: + return False + + def to(self, dtype=None, device=None): + """Converts the stored `Target_Image` data, variance, psf, etc to a + given data type and device. + + """ + if dtype is not None: + dtype = AP_config.ap_dtype + if device is not None: + device = AP_config.ap_device + super().to(dtype=dtype, device=device) + + if self.has_weight: + self._weight = self._weight.to(dtype=dtype, device=device) + if self.has_mask: + self._mask = self.mask.to(dtype=torch.bool, device=device) + return self + + def copy(self, **kwargs): + """Produce a copy of this image with all of the same properties. This + can be used when one wishes to make temporary modifications to + an image and then will want the original again. + + """ + kwargs = {"mask": self._mask, "weight": self._weight, **kwargs} + return super().copy(**kwargs) + + def blank_copy(self, **kwargs): + """Produces a blank copy of the image which has the same properties + except that its data is now filled with zeros. + + """ + kwargs = {"mask": self._mask, "weight": self._weight, **kwargs} + return super().blank_copy(**kwargs) + + def get_window(self, other: Union[Image, Window], indices=None, **kwargs): + """Get a sub-region of the image as defined by an other image on the sky.""" + if indices is None: + indices = self.get_indices(other if isinstance(other, Window) else other.window) + return super().get_window( + other, + weight=self._weight[indices] if self.has_weight else None, + mask=self._mask[indices] if self.has_mask else None, + indices=indices, + **kwargs, + ) + + def fits_images(self): + images = super().fits_images() + if self.has_weight: + images.append(fits.ImageHDU(self.weight.detach().cpu().numpy(), name="WEIGHT")) + if self.has_mask: + images.append(fits.ImageHDU(self.mask.detach().cpu().numpy(), name="MASK")) + return images + + def load(self, filename: str): + """Load the image from a FITS file. This will load the data, WCS, and + any ancillary data such as variance, mask, and PSF. + + """ + hdulist = super().load(filename) + if "WEIGHT" in hdulist: + self.weight = np.array(hdulist["WEIGHT"].data, dtype=np.float64) + if "MASK" in hdulist: + self.mask = np.array(hdulist["MASK"].data, dtype=bool) + + def reduce(self, scale, **kwargs): + """Returns a new `Target_Image` object with a reduced resolution + compared to the current image. `scale` should be an integer + indicating how much to reduce the resolution. If the + `Target_Image` was originally (48,48) pixels across with a + pixelscale of 1 and `reduce(2)` is called then the image will + be (24,24) pixels and the pixelscale will be 2. If `reduce(3)` + is called then the returned image will be (16,16) pixels + across and the pixelscale will be 3. + + """ + MS = self.data.shape[0] // scale + NS = self.data.shape[1] // scale + + return super().reduce( + scale=scale, + variance=( + self.variance[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .sum(axis=(1, 3)) + if self.has_variance + else None + ), + mask=( + self.mask[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .amax(axis=(1, 3)) + if self.has_mask + else None + ), + **kwargs, + ) diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py new file mode 100644 index 00000000..7a22e483 --- /dev/null +++ b/astrophot/image/mixins/sip_mixin.py @@ -0,0 +1,140 @@ +from typing import Union + +from ..image_object import Image +from ..window import Window +from .. import func +from ...utils.interpolate import interp2d +from ...param import forward + + +class SIPMixin: + + def __init__(self, *args, sipA=(), sipB=(), sipAP=(), sipBP=(), pixel_area_map=None, **kwargs): + super().__init__(*args, **kwargs) + self.sipA = sipA + self.sipB = sipB + self.sipAP = sipAP + self.sipBP = sipBP + + i, j = self.pixel_center_meshgrid() + u, v = i - self.crpix[0], j - self.crpix[1] + self.distortion_ij = func.sip_delta(u, v, self.sipA, self.sipB) + self.distortion_IJ = func.sip_delta(u, v, self.sipAP, self.sipBP) # fixme maybe + + if pixel_area_map is None: + self.update_pixel_area_map() + else: + self._pixel_area_map = pixel_area_map + + @forward + def pixel_to_plane(self, i, j, crtan, pixelscale): + di = interp2d(self.distortion_ij[0], i, j) + dj = interp2d(self.distortion_ij[1], i, j) + return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, pixelscale, *crtan) + + @forward + def plane_to_pixel(self, x, y, crtan): + I, J = func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale_inv, *crtan) + dI = interp2d(self.distortion_IJ[0], I, J) + dJ = interp2d(self.distortion_IJ[1], I, J) + return I + dI, J + dJ + + @property + def pixel_area_map(self): + return self._pixel_area_map + + def update_pixel_area_map(self): + """ + Update the pixel area map based on the current SIP coefficients. + """ + i, j = self.pixel_corner_meshgrid() + x, y = self.pixel_to_plane(i, j) + + # 1: [:-1, :-1] + # 2: [:-1, 1:] + # 3: [1:, 1:] + # 4: [1:, :-1] + A = 0.5 * ( + x[:-1, :-1] * y[:-1, 1:] + + x[:-1, 1:] * y[1:, 1:] + + x[1:, 1:] * y[1:, :-1] + + x[1:, :-1] * y[:-1, :-1] + - ( + x[:-1, 1:] * y[:-1, :-1] + + x[1:, 1:] * y[:-1, 1:] + + x[1:, :-1] * y[1:, 1:] + + x[:-1, :-1] * y[1:, :-1] + ) + ) + self._pixel_area_map = A.abs() + + def copy(self, **kwargs): + kwargs = { + "sipA": self.sipA, + "sipB": self.sipB, + "sipAP": self.sipAP, + "sipBP": self.sipBP, + "pixel_area_map": self.pixel_area_map, + **kwargs, + } + return super().copy(**kwargs) + + def blank_copy(self, **kwargs): + kwargs = { + "sipA": self.sipA, + "sipB": self.sipB, + "sipAP": self.sipAP, + "sipBP": self.sipBP, + "pixel_area_map": self.pixel_area_map, + **kwargs, + } + return super().blank_copy(**kwargs) + + def get_window(self, other: Union[Image, Window], indices=None, **kwargs): + """Get a sub-region of the image as defined by an other image on the sky.""" + if indices is None: + indices = self.get_indices(other if isinstance(other, Window) else other.window) + return super().get_window( + other, + pixel_area_map=self.pixel_area_map[indices], + indices=indices, + **kwargs, + ) + + def fits_info(self): + info = super().fits_info() + info["CTYPE1"] = "RA---TAN-SIP" + info["CTYPE2"] = "DEC--TAN-SIP" + for a, b in self.sipA: + info[f"A{a}_{b}"] = self.sipA[(a, b)] + for a, b in self.sipB: + info[f"B{a}_{b}"] = self.sipB[(a, b)] + for a, b in self.sipAP: + info[f"AP{a}_{b}"] = self.sipAP[(a, b)] + for a, b in self.sipBP: + info[f"BP{a}_{b}"] = self.sipBP[(a, b)] + return info + + def reduce(self, scale, **kwargs): + MS = self.data.shape[0] // scale + NS = self.data.shape[1] // scale + + return super().reduce( + scale=scale, + pixel_area_map=( + self.pixel_area_map[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .sum(axis=(1, 3)) + ), + distortion_ij=( + self.distortion_ij[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .mean(axis=(1, 3)) + ), + distortion_IJ=( + self.distortion_IJ[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .mean(axis=(1, 3)) + ), + **kwargs, + ) diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 14d0605f..99345dd6 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -18,27 +18,6 @@ class ModelImage(Image): """ - def __init__(self, *args, window=None, upsample=1, pad=0, **kwargs): - if window is not None: - kwargs["pixelscale"] = window.image.pixelscale / upsample - kwargs["crpix"] = ( - (window.crpix - np.array((window.i_low, window.j_low)) + 0.5) * upsample + pad - 0.5 - ) - kwargs["crval"] = window.image.crval.value - kwargs["crtan"] = window.image.crtan.value - kwargs["data"] = torch.zeros( - ( - (window.i_high - window.i_low) * upsample + 2 * pad, - (window.j_high - window.j_low) * upsample + 2 * pad, - ), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - kwargs["zeropoint"] = window.image.zeropoint - kwargs["identity"] = window.image.identity - kwargs["name"] = window.image.name + "_model" - super().__init__(*args, **kwargs) - def clear_image(self): self.data = torch.zeros_like(self.data) @@ -101,7 +80,7 @@ def reduce(self, scale: int, **kwargs): NS = self.data.shape[1] // scale data = self.data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) - pixelscale = self.pixelscale * scale + pixelscale = self.pixelscale.value * scale crpix = (self.crpix + 0.5) / scale - 0.5 return self.copy( data=data, diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 750a392a..82ae79ac 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -7,11 +7,12 @@ from .model_image import ModelImage from .jacobian_image import JacobianImage from .. import AP_config +from .mixins import DataMixin __all__ = ["PSFImage"] -class PSFImage(Image): +class PSFImage(DataMixin, Image): """Image object which represents a model of PSF (Point Spread Function). PSF_Image inherits from the base Image class and represents the model of a point spread function. @@ -30,36 +31,18 @@ class PSFImage(Image): reduce: Reduces the size of the image using a given scale factor. """ - has_mask = False - has_variance = False - def __init__(self, *args, **kwargs): - kwargs.update({"crval": (0, 0), "crpix": (0, 0), "crtan": (0, 0)}) + kwargs.update({"crpix": (0, 0), "crtan": (0, 0)}) super().__init__(*args, **kwargs) self.crpix = (np.array(self.data.shape, dtype=float) - 1.0) / 2 + del self.crval def normalize(self): """Normalizes the PSF image to have a sum of 1.""" - self.data = self.data / torch.sum(self.data) - - @property - def mask(self): - return torch.zeros_like(self.data, dtype=bool) - - @property - def psf_border_int(self): - """Calculates and returns the border size of the PSF image in integer - format. This is the border used for padding before convolution. - - Returns: - torch.Tensor: The border size of the PSF image in integer format. - - """ - return torch.tensor( - self.data.shape, - dtype=torch.int32, - device=AP_config.ap_device, - ) + norm = torch.sum(self.data) + self.data = self.data / norm + if self.has_weight: + self.weight = self.weight * norm**2 def jacobian_image( self, @@ -80,10 +63,10 @@ def jacobian_image( device=AP_config.ap_device, ) kwargs = { - "pixelscale": self.pixelscale, + "pixelscale": self.pixelscale.value, "crpix": self.crpix, - "crval": self.crval.value, "crtan": self.crtan.value, + "crval": (0.0, 0.0), "zeropoint": self.zeropoint, "identity": self.identity, **kwargs, @@ -96,12 +79,31 @@ def model_image(self, **kwargs): """ kwargs = { "data": torch.zeros_like(self.data), - "pixelscale": self.pixelscale, + "pixelscale": self.pixelscale.value, "crpix": self.crpix, - "crval": self.crval.value, "crtan": self.crtan.value, + "crval": (0.0, 0.0), "zeropoint": self.zeropoint, "identity": self.identity, **kwargs, } return ModelImage(**kwargs) + + @property + def zeropoint(self): + return None + + @zeropoint.setter + def zeropoint(self, value): + """PSFImage does not support zeropoint.""" + pass + + def plane_to_world(self, x, y): + raise NotImplementedError( + "PSFImage does not support plane_to_world conversion. There is no meaningful world position of a PSF image." + ) + + def world_to_plane(self, ra, dec): + raise NotImplementedError( + "PSFImage does not support world_to_plane conversion. There is no meaningful world position of a PSF image." + ) diff --git a/astrophot/image/sip_target.py b/astrophot/image/sip_target.py index 79a54a88..0a912b3c 100644 --- a/astrophot/image/sip_target.py +++ b/astrophot/image/sip_target.py @@ -1,52 +1,58 @@ -from target_image import TargetImage -from distort_image import DistortImageMixin -from . import func - - -class SIPTargetImage(DistortImageMixin, TargetImage): - - def __init__(self, *args, sipA=(), sipB=(), sipAP=(), sipBP=(), pixel_area_map=None, **kwargs): - super().__init__(*args, **kwargs) - self.sipA = sipA - self.sipB = sipB - self.sipAP = sipAP - self.sipBP = sipBP - - i, j = self.pixel_center_meshgrid() - u, v = i - self.crpix[0], j - self.crpix[1] - self.distortion_ij = func.sip_delta(u, v, self.sipA, self.sipB) - self.distortion_IJ = func.sip_delta(u, v, self.sipAP, self.sipBP) # fixme maybe - - if pixel_area_map is None: - self.update_pixel_area_map() - else: - self._pixel_area_map = pixel_area_map - - @property - def pixel_area_map(self): - return self._pixel_area_map - - def update_pixel_area_map(self): - """ - Update the pixel area map based on the current SIP coefficients. - """ - i, j = self.pixel_corner_meshgrid() - x, y = self.pixel_to_plane(i, j) - - # 1: [:-1, :-1] - # 2: [:-1, 1:] - # 3: [1:, 1:] - # 4: [1:, :-1] - A = 0.5 * ( - x[:-1, :-1] * y[:-1, 1:] - + x[:-1, 1:] * y[1:, 1:] - + x[1:, 1:] * y[1:, :-1] - + x[1:, :-1] * y[:-1, :-1] - - ( - x[:-1, 1:] * y[:-1, :-1] - + x[1:, 1:] * y[:-1, 1:] - + x[1:, :-1] * y[1:, 1:] - + x[:-1, :-1] * y[1:, :-1] +import torch + +from .target_image import TargetImage +from .mixins import SIPMixin + + +class SIPTargetImage(SIPMixin, TargetImage): + """ + A TargetImage with SIP distortion coefficients. + This class is used to represent a target image with SIP distortion coefficients. + It inherits from TargetImage and SIPMixin. + """ + + def jacobian_image(self, **kwargs): + kwargs = { + "pixel_area_map": self.pixel_area_map, + "sipA": self.sipA, + "sipB": self.sipB, + "sipAP": self.sipAP, + "sipBP": self.sipBP, + "distortion_ij": self.distortion_ij, + "distortion_IJ": self.distortion_IJ, + **kwargs, + } + return super().jacobian_image(**kwargs) + + def model_image(self, upsample=1, pad=0, **kwargs): + new_area_map = self.pixel_area_map + new_distortion_ij = self.distortion_ij + new_distortion_IJ = self.distortion_IJ + if upsample > 1: + new_area_map = self.pixel_area_map.repeat_interleave(upsample, dim=0) + new_area_map = new_area_map.repeat_interleave(upsample, dim=1) + new_area_map = new_area_map / upsample**2 + U = torch.nn.Upsample(scale_factor=upsample, mode="bilinear", align_corners=False) + new_distortion_ij = U(self.distortion_ij) + new_distortion_IJ = U(self.distortion_IJ) + if pad > 0: + new_area_map = torch.nn.functional.pad( + new_area_map, (pad, pad, pad, pad), mode="replicate" + ) + new_distortion_ij = torch.nn.functional.pad( + new_distortion_ij, (pad, pad, pad, pad), mode="replicate" + ) + new_distortion_IJ = torch.nn.functional.pad( + new_distortion_IJ, (pad, pad, pad, pad), mode="replicate" ) - ) - self._pixel_area_map = A.abs() + kwargs = { + "pixel_area_map": new_area_map, + "sipA": self.sipA, + "sipB": self.sipB, + "sipAP": self.sipAP, + "sipBP": self.sipBP, + "distortion_ij": new_distortion_ij, + "distortion_IJ": new_distortion_IJ, + **kwargs, + } + return super().model_image(**kwargs) diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index c6833426..48a4ad3f 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -10,13 +10,13 @@ from .model_image import ModelImage, ModelImageList from .psf_image import PSFImage from .. import AP_config -from ..utils.initialize import auto_variance -from ..errors import SpecificationConflict, InvalidImage +from ..errors import InvalidImage +from .mixins import DataMixin __all__ = ["TargetImage", "TargetImageList"] -class TargetImage(Image): +class TargetImage(DataMixin, Image): """Image object which represents the data to be fit by a model. It can include a variance image, mask, and PSF as anciliary data which describes the target image. @@ -81,182 +81,12 @@ class TargetImage(Image): """ - image_count = 0 - - def __init__(self, *args, mask=None, variance=None, psf=None, weight=None, **kwargs): + def __init__(self, *args, psf=None, **kwargs): super().__init__(*args, **kwargs) - if not self.has_mask: - self.mask = mask - if not self.has_weight and variance is None: - self.weight = weight - elif not self.has_variance: - self.variance = variance if not self.has_psf: self.psf = psf - # Set nan pixels to be masked automatically - if torch.any(torch.isnan(self.data)).item(): - self.mask = self.mask | torch.isnan(self.data) - - @property - def standard_deviation(self): - """Stores the standard deviation of the image pixels. This represents - the uncertainty in each pixel value. It should always have the - same shape as the image data. In the case where the standard - deviation is not known, a tensor of ones will be created to - stand in as the standard deviation values. - - The standard deviation is not stored directly, instead it is - computed as :math:`\\sqrt{1/W}` where :math:`W` is the - weights. - - """ - if self.has_variance: - return torch.sqrt(self.variance) - return torch.ones_like(self.data) - - @property - def variance(self): - """Stores the variance of the image pixels. This represents the - uncertainty in each pixel value. It should always have the - same shape as the image data. In the case where the variance - is not known, a tensor of ones will be created to stand in as - the variance values. - - The variance is not stored directly, instead it is - computed as :math:`\\frac{1}{W}` where :math:`W` is the - weights. - - """ - if self.has_variance: - return torch.where(self._weight == 0, torch.inf, 1 / self._weight) - return torch.ones_like(self.data) - - @variance.setter - def variance(self, variance): - if variance is None: - self._weight = None - return - if isinstance(variance, str) and variance == "auto": - self.weight = "auto" - return - self.weight = 1 / variance - - @property - def has_variance(self): - """Returns True when the image object has stored variance values. If - this is False and the variance property is called then a - tensor of ones will be returned. - - """ - try: - return self._weight is not None - except AttributeError: - return False - - @property - def weight(self): - """Stores the weight of the image pixels. This represents the - uncertainty in each pixel value. It should always have the - same shape as the image data. In the case where the weight - is not known, a tensor of ones will be created to stand in as - the weight values. - - The weights are used to proprtionately scale residuals in the - likelihood. Most commonly this shows up as a :math:`\\chi^2` - like: - - .. math:: - - \\chi^2 = (\\vec{y} - \\vec{f(\\theta)})^TW(\\vec{y} - \\vec{f(\\theta)}) - - which can be optimized to find parameter values. Using the - Jacobian, which in this case is the derivative of every pixel - wrt every parameter, the weight matrix also appears in the - gradient: - - .. math:: - - \\vec{g} = J^TW(\\vec{y} - \\vec{f(\\theta)}) - - and the hessian approximation used in Levenberg-Marquardt: - - .. math:: - - H \\approx J^TWJ - - """ - if self.has_weight: - return self._weight - return torch.ones_like(self.data) - - @weight.setter - def weight(self, weight): - if weight is None: - self._weight = None - return - if isinstance(weight, str) and weight == "auto": - weight = 1 / auto_variance(self.data, self.mask) - if weight.shape != self.data.shape: - raise SpecificationConflict( - f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" - ) - self._weight = torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - - @property - def has_weight(self): - """Returns True when the image object has stored weight values. If - this is False and the weight property is called then a - tensor of ones will be returned. - - """ - try: - return self._weight is not None - except AttributeError: - self._weight = None - return False - - @property - def mask(self): - """The mask stores a tensor of boolean values which indicate any - pixels to be ignored. These pixels will be skipped in - likelihood evaluations and in parameter optimization. It is - common practice to mask pixels with pathological values such - as due to cosmic rays or satellites passing through the image. - - In a mask, a True value indicates that the pixel is masked and - should be ignored. False indicates a normal pixel which will - inter into most calculations. - - If no mask is provided, all pixels are assumed valid. - - """ - if self.has_mask: - return self._mask - return torch.zeros_like(self.data, dtype=torch.bool) - - @mask.setter - def mask(self, mask): - if mask is None: - self._mask = None - return - if mask.shape != self.data.shape: - raise SpecificationConflict( - f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" - ) - self._mask = torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device) - - @property - def has_mask(self): - """ - Single boolean to indicate if a mask has been provided by the user. - """ - try: - return self._mask is not None - except AttributeError: - return False - @property def has_psf(self): """Returns True when the target image object has a PSF model.""" @@ -309,30 +139,13 @@ def psf(self, psf): name=self.name + "_psf", ) - def to(self, dtype=None, device=None): - """Converts the stored `Target_Image` data, variance, psf, etc to a - given data type and device. - - """ - if dtype is not None: - dtype = AP_config.ap_dtype - if device is not None: - device = AP_config.ap_device - super().to(dtype=dtype, device=device) - - if self.has_weight: - self._weight = self._weight.to(dtype=dtype, device=device) - if self.has_mask: - self._mask = self.mask.to(dtype=torch.bool, device=device) - return self - def copy(self, **kwargs): """Produce a copy of this image with all of the same properties. This can be used when one wishes to make temporary modifications to an image and then will want the original again. """ - kwargs = {"mask": self._mask, "psf": self.psf, "weight": self._weight, **kwargs} + kwargs = {"psf": self.psf, **kwargs} return super().copy(**kwargs) def blank_copy(self, **kwargs): @@ -340,27 +153,20 @@ def blank_copy(self, **kwargs): except that its data is now filled with zeros. """ - kwargs = {"mask": self._mask, "psf": self.psf, "weight": self._weight, **kwargs} + kwargs = {"psf": self.psf, **kwargs} return super().blank_copy(**kwargs) - def get_window(self, other: Union[Image, Window], **kwargs): + def get_window(self, other: Union[Image, Window], indices=None, **kwargs): """Get a sub-region of the image as defined by an other image on the sky.""" - indices = self.get_indices(other if isinstance(other, Window) else other.window) return super().get_window( other, - weight=self._weight[indices] if self.has_weight else None, - mask=self._mask[indices] if self.has_mask else None, psf=self.psf, - _indices=indices, + indices=indices, **kwargs, ) def fits_images(self): images = super().fits_images() - if self.has_variance: - images.append(fits.ImageHDU(self.weight.detach().cpu().numpy(), name="WEIGHT")) - if self.has_mask: - images.append(fits.ImageHDU(self.mask.detach().cpu().numpy(), name="MASK")) if self.has_psf: if isinstance(self.psf, PSFImage): images.append( @@ -380,10 +186,6 @@ def load(self, filename: str): """ hdulist = super().load(filename) - if "WEIGHT" in hdulist: - self.weight = np.array(hdulist["WEIGHT"].data, dtype=np.float64) - if "MASK" in hdulist: - self.mask = np.array(hdulist["MASK"].data, dtype=bool) if "PSF" in hdulist: self.psf = PSFImage( data=np.array(hdulist["PSF"].data, dtype=np.float64), @@ -409,10 +211,10 @@ def jacobian_image( device=AP_config.ap_device, ) kwargs = { - "pixelscale": self.pixelscale, + "pixelscale": self.pixelscale.value, "crpix": self.crpix, - "crval": self.crval.value, "crtan": self.crtan.value, + "crval": self.crval.value, "zeropoint": self.zeropoint, "identity": self.identity, "name": self.name + "_jacobian", @@ -420,16 +222,20 @@ def jacobian_image( } return JacobianImage(parameters=parameters, data=data, **kwargs) - def model_image(self, **kwargs): + def model_image(self, upsample=1, pad=0, **kwargs): """ Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ kwargs = { - "data": torch.zeros_like(self.data), - "pixelscale": self.pixelscale, - "crpix": self.crpix, - "crval": self.crval.value, + "data": torch.zeros( + (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), + dtype=self.data.dtype, + device=self.data.device, + ), + "pixelscale": self.pixelscale.value / upsample, + "crpix": (self.crpix + 0.5) * upsample + pad - 0.5, "crtan": self.crtan.value, + "crval": self.crval.value, "zeropoint": self.zeropoint, "identity": self.identity, "name": self.name + "_model", @@ -448,28 +254,8 @@ def reduce(self, scale, **kwargs): across and the pixelscale will be 3. """ - MS = self.data.shape[0] // scale - NS = self.data.shape[1] // scale - - return super().reduce( - scale=scale, - variance=( - self.variance[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .sum(axis=(1, 3)) - if self.has_variance - else None - ), - mask=( - self.mask[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .amax(axis=(1, 3)) - if self.has_mask - else None - ), - psf=self.psf if self.has_psf else None, - **kwargs, - ) + + return super().reduce(scale=scale, psf=self.psf, **kwargs) class TargetImageList(ImageList): diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index a8fc36d2..0fa51eab 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -4,7 +4,6 @@ from scipy.optimize import minimize from ..utils.decorators import ignore_numpy_warnings -from ..utils.interpolate import default_prof from .. import AP_config @@ -95,41 +94,20 @@ def optim(x, r, f, u): return np.mean(residual[N][:-2]) res = minimize(optim, x0=x0, args=(R, I, S), method="Nelder-Mead") - if not res.success: - if AP_config.ap_verbose >= 2: - AP_config.ap_logger.warning( - f"initialization fit not successful for {model.name}, falling back to defaults" - ) - else: + if res.success: x0 = res.x - # import matplotlib.pyplot as plt - - # plt.plot(R, I, "o", label="data") - # plt.plot(R, np.log10(prof_func(R, *x0)), label="fit") - # plt.title(f"Initial fit for {model.name}") - # plt.legend() - # plt.show() - reses = [] - for i in range(10): - N = np.random.randint(0, len(R), len(R)) - reses.append(minimize(optim, x0=x0, args=(R[N], I[N], S[N]), method="Nelder-Mead")) + elif AP_config.ap_verbose >= 2: + AP_config.ap_logger.warning( + f"initialization fit not successful for {model.name}, falling back to defaults" + ) + for param, x0x in zip(params, x0): if not model[param].initialized: - if ( - model[param].valid[0] is not None - and x0x < model[param].valid[0].detach().cpu().numpy() - ) or ( - model[param].valid[1] is not None - and x0x > model[param].valid[1].detach().cpu().numpy() - ): - x0x = model[param].from_valid( + if not model[param].is_valid(x0x): + x0x = model[param].soft_valid( torch.tensor(x0x, dtype=AP_config.ap_dtype, device=AP_config.ap_device) ) model[param].dynamic_value = x0x - if model[param].uncertainty is None: - model[param].uncertainty = np.std( - list(subres.x[params.index(param)] for subres in reses) - ) @torch.no_grad() @@ -149,7 +127,6 @@ def parametric_segment_initialize( w = cycle / segments v = w * np.arange(segments) values = [] - uncertainties = [] for s in range(segments): angle_range = (v[s] - w / 2, v[s] + w / 2) # Get the sub-image area corresponding to the model image @@ -177,15 +154,8 @@ def optim(x, r, f, u): else: x0 = res.x - reses = [] - for i in range(10): - N = np.random.randint(0, len(R), len(R)) - reses.append(minimize(optim, x0=x0, args=(R[N], I[N], S[N]), method="Nelder-Mead")) values.append(x0) - uncertainties.append(np.std(np.stack(reses), axis=0)) values = np.stack(values).T - uncertainties = np.stack(uncertainties).T - for param, v, u in zip(params, values, uncertainties): + for param, v in zip(params, values): if not model[param].initialized: model[param].dynamic_value = v - model[param].uncertainty = u diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index 2c274293..3b5f14f9 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -59,10 +59,8 @@ def initialize(self): int(icenter[1]) - 2 : int(icenter[1]) + 2, ] self.I0.dynamic_value = torch.mean(mid_chunk) / self.target.pixel_area - self.I0.uncertainty = torch.std(mid_chunk) / self.target.pixel_area if not self.aRL.initialized: self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixel_length - self.aRL.uncertainty = self.aRL.value * self.default_uncertainty @forward def radial_model(self, R, I0, aRL): diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 9e043709..c85fdf53 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -83,10 +83,9 @@ class defines the signatures to interact with AstroPhot models _model_type = "model" _parameter_specs = {} - default_uncertainty = 1e-2 # During initialization, uncertainty will be assumed 1% of initial value if no uncertainty is given # Softening length used for numerical stability and/or integration stability to avoid discontinuities (near R=0) softening = 1e-3 # arcsec - _options = ("default_uncertainty", "softening") + _options = ("softening",) usable = False def __new__(cls, *, filename=None, model_type=None, **kwargs): diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index feab4425..f0b56fea 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -19,12 +19,7 @@ class EdgeonModel(ComponentModel): _model_type = "edgeon" _parameter_specs = { - "PA": { - "units": "radians", - "valid": (0, np.pi), - "cyclic": True, - "uncertainty": 0.06, - }, + "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "shape": ()}, } usable = False @@ -51,7 +46,6 @@ def initialize(self): self.PA.dynamic_value = np.pi / 2 else: self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi - self.PA.uncertainty = self.PA.value * self.default_uncertainty @forward def transform_coordinates(self, x, y, PA): @@ -67,8 +61,8 @@ class EdgeonSech(EdgeonModel): _model_type = "sech2" _parameter_specs = { - "I0": {"units": "flux/arcsec^2"}, - "hs": {"units": "arcsec", "valid": (0, None)}, + "I0": {"units": "flux/arcsec^2", "shape": ()}, + "hs": {"units": "arcsec", "valid": (0, None), "shape": ()}, } usable = False @@ -87,10 +81,8 @@ def initialize(self): int(icenter[1]) - 2 : int(icenter[1]) + 2, ] self.I0.dynamic_value = torch.mean(chunk) / self.target.pixel_area - self.I0.uncertainty = torch.std(chunk) / self.target.pixel_area if not self.hs.initialized: self.hs.value = torch.max(self.window.shape) * target_area.pixel_length * 0.1 - self.hs.uncertainty = self.hs.value / 2 @forward def brightness(self, x, y, I0, hs): @@ -105,7 +97,7 @@ class EdgeonIsothermal(EdgeonSech): """ _model_type = "isothermal" - _parameter_specs = {"rs": {"units": "arcsec", "valid": (0, None)}} + _parameter_specs = {"rs": {"units": "arcsec", "valid": (0, None), "shape": ()}} usable = True @torch.no_grad() @@ -115,7 +107,6 @@ def initialize(self): if self.rs.initialized: return self.rs.value = torch.max(self.window.shape) * self.target.pixel_length * 0.4 - self.rs.uncertainty = self.rs.value / 2 @forward def radial_model(self, R, rs): diff --git a/astrophot/models/eigen.py b/astrophot/models/eigen.py index 00d9afcc..2db23053 100644 --- a/astrophot/models/eigen.py +++ b/astrophot/models/eigen.py @@ -64,10 +64,8 @@ def initialize(self): self.flux.dynamic_value = ( torch.abs(torch.sum(target_area.data)) / target_area.pixel_area ) - self.flux.uncertainty = self.flux.value * self.default_uncertainty if not self.weights.initialized: self.weights.dynamic_value = 1 / np.arange(len(self.eigen_basis)) - self.weights.uncertainty = self.weights.value * self.default_uncertainty @forward def brightness(self, x, y, flux, weights): diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 163a7ae4..db035e84 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -34,9 +34,6 @@ def initialize(self): dat = self.target[self.window].data.detach().cpu().numpy().copy() self.I.value = np.median(dat) / self.target.pixel_area.item() - self.I.uncertainty = ( - iqr(dat, rng=(16, 84)) / (2.0 * self.target.pixel_area.item()) - ) / np.sqrt(np.prod(self.window.shape)) @forward def brightness(self, x, y, I): diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index 592cb4f2..6a02ac6b 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -32,10 +32,11 @@ def fft_shift_kernel(shape, di, dj): """FFT shift theorem gives "exact" shift in phase space. Not really exact for DFT""" ni, nj = shape ki = torch.fft.fftfreq(ni, dtype=di.dtype, device=di.device) - kj = torch.fft.rfftfreq(nj, dtype=di.dtype, device=di.device) + kj = torch.fft.fftfreq(nj, dtype=di.dtype, device=di.device) Ki, Kj = torch.meshgrid(ki, kj, indexing="ij") - phase = -2j * torch.pi * (Ki * torch.arctan(di) + Kj * torch.arctan(dj)) - return torch.exp(phase) + phase = -2j * torch.pi * (Ki * di + Kj * dj) + gauss = torch.exp(-0.5 * (Ki**2 + Kj**2) * 5**2) + return torch.exp(phase) * gauss def convolve(image, psf): diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index f5a568f0..df83fa97 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -15,9 +15,9 @@ class MoffatMixin: _model_type = "moffat" _parameter_specs = { - "n": {"units": "none", "valid": (0.1, 10), "uncertainty": 0.05}, - "Rd": {"units": "arcsec", "valid": (0, None)}, - "I0": {"units": "flux/arcsec^2"}, + "n": {"units": "none", "valid": (0.1, 10), "shape": ()}, + "Rd": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "I0": {"units": "flux/arcsec^2", "shape": ()}, } @torch.no_grad() @@ -38,7 +38,7 @@ class iMoffatMixin: _model_type = "moffat" _parameter_specs = { - "n": {"units": "none", "valid": (0.1, 10), "uncertainty": 0.05}, + "n": {"units": "none", "valid": (0.1, 10)}, "Rd": {"units": "arcsec", "valid": (0, None)}, "I0": {"units": "flux/arcsec^2"}, } diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index 64e227e2..02fae43e 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -25,7 +25,7 @@ class SersicMixin: _model_type = "sersic" _parameter_specs = { - "n": {"units": "none", "valid": (0.36, 8), "uncertainty": 0.05, "shape": ()}, + "n": {"units": "none", "valid": (0.36, 8), "shape": ()}, "Re": {"units": "arcsec", "valid": (0, None), "shape": ()}, "Ie": {"units": "flux/arcsec^2", "shape": ()}, } @@ -70,7 +70,7 @@ class iSersicMixin: _model_type = "sersic" _parameter_specs = { - "n": {"units": "none", "valid": (0.36, 8), "uncertainty": 0.05}, + "n": {"units": "none", "valid": (0.36, 8)}, "Re": {"units": "arcsec", "valid": (0, None)}, "Ie": {"units": "flux/arcsec^2"}, } diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 42c2b6d7..7f8cf344 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -36,7 +36,6 @@ def initialize(self): rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], ) self.I_R.dynamic_value = 10**I - self.I_R.uncertainty = S @forward def radial_model(self, R, I_R): @@ -65,7 +64,6 @@ def initialize(self): prof = self.I_R.prof value = np.zeros((self.segments, len(prof))) - uncertainty = np.zeros((self.segments, len(prof))) cycle = np.pi if self.symmetric else 2 * np.pi w = cycle / self.segments v = w * np.arange(self.segments) @@ -80,9 +78,7 @@ def initialize(self): angle_range=angle_range, ) value[s] = 10**I - uncertainty[s] = S self.I_R.dynamic_value = value - self.I_R.uncertainty = uncertainty @forward def iradial_model(self, i, R, I_R): diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 2ec7c0f5..6d48b1d0 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -11,14 +11,8 @@ class InclinedMixin: _parameter_specs = { - "q": {"units": "b/a", "valid": (0, 1), "uncertainty": 0.03, "shape": ()}, - "PA": { - "units": "radians", - "valid": (0, np.pi), - "cyclic": True, - "uncertainty": 0.06, - "shape": (), - }, + "q": {"units": "b/a", "valid": (0, 1), "shape": ()}, + "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "shape": ()}, } @torch.no_grad() @@ -89,7 +83,7 @@ class SuperEllipseMixin: _model_type = "superellipse" _parameter_specs = { - "C": {"units": "none", "value": 2.0, "uncertainty": 1e-2, "valid": (0, None)}, + "C": {"units": "none", "value": 2.0, "valid": (0, None)}, } @forward @@ -168,10 +162,8 @@ def initialize(self): if not self.am.initialized: self.am.dynamic_value = np.zeros(len(self.modes)) - self.am.uncertainty = self.default_uncertainty * np.ones(len(self.modes)) if not self.phim.initialized: self.phim.value = np.zeros(len(self.modes)) - self.phim.uncertainty = (10 * np.pi / 180) * np.ones(len(self.modes)) class WarpMixin: @@ -202,13 +194,8 @@ class WarpMixin: _model_type = "warp" _parameter_specs = { - "q_R": {"units": "b/a", "valid": (0.0, 1), "uncertainty": 0.04}, - "PA_R": { - "units": "radians", - "valid": (0, np.pi), - "cyclic": True, - "uncertainty": 0.08, - }, + "q_R": {"units": "b/a", "valid": (0, 1)}, + "PA_R": {"units": "radians", "valid": (0, np.pi), "cyclic": True}, } @torch.no_grad() @@ -220,12 +207,10 @@ def initialize(self): if self.PA_R.prof is None: self.PA_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 - self.PA_R.uncertainty = (10 * np.pi / 180) * torch.ones_like(self.PA_R.value) if not self.q_R.initialized: if self.q_R.prof is None: self.q_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 - self.q_R.uncertainty = self.default_uncertainty * self.q_R.value @forward def transform_coordinates(self, x, y, q_R, PA_R): @@ -264,10 +249,8 @@ def initialize(self): if not self.Rt.initialize: prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) self.Rt.dynamic_value = prof[len(prof) // 2] - self.Rt.uncertainty = 0.1 if not self.sharpness.initialized: self.sharpness.dynamic_value = 1.0 - self.sharpness.uncertainty = 0.1 @forward def radial_model(self, R, Rt, sharpness): diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 0bd732b8..7c08f622 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -52,9 +52,8 @@ class ComponentModel(SampleMixin, Model): """ - # Specifications for the model parameters including units, value, uncertainty, limits, locked, and cyclic _parameter_specs = { - "center": {"units": "arcsec", "uncertainty": [0.1, 0.1], "shape": (2,)}, + "center": {"units": "arcsec", "shape": (2,)}, } # Scope for PSF convolution @@ -95,6 +94,24 @@ def psf(self, val): "PSF pixelscale. To remove this warning, set PSFs as an ap.image.PSF_Image " "or ap.models.PSF_Model object instead." ) + self.update_psf_upscale() + + def update_psf_upscale(self): + """Update the PSF upscale factor based on the current target pixel length.""" + if self.psf is None: + self.psf_upscale = 1 + elif isinstance(self.psf, PSFImage): + self.psf_upscale = ( + torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() + ) + elif isinstance(self.psf, Model): + self.psf_upscale = ( + torch.round(self.target.pixel_length / self.psf.target.pixel_length).int().item() + ) + else: + raise TypeError( + f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." + ) @property def target(self): @@ -106,12 +123,16 @@ def target(self, tar): self._target = None return elif not isinstance(tar, TargetImage): - raise InvalidTarget("AstroPhot Model target must be a Target_Image instance.") + raise InvalidTarget("AstroPhot Model target must be a TargetImage instance.") try: del self._target # Remove old target if it exists except AttributeError: pass self._target = tar + try: + self.update_psf_upscale() + except AttributeError: + pass # Initialization functions ###################################################################### @@ -127,6 +148,9 @@ def initialize(self): target (Optional[Target_Image]): A target image object to use as a reference when setting parameter values """ + if self.psf is not None and isinstance(self.psf, Model): + self.psf.initialize() + target_area = self.target[self.window] # Use center of window if a center hasn't been set yet @@ -213,7 +237,7 @@ def sample( f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." ) - working_image = ModelImage(window=window, upsample=psf_upscale, pad=psf_pad) + working_image = self.target[window].model_image(upsample=psf_upscale, pad=psf_pad) # Sub pixel shift to align the model with the center of a pixel if self.psf_subpixel_shift: @@ -232,14 +256,11 @@ def sample( working_image = working_image.crop([psf_pad]).reduce(psf_upscale) else: - working_image = ModelImage(window=window) + working_image = self.target[window].model_image() sample = self.sample_image(working_image) working_image.data = sample # Units from flux/arcsec^2 to flux - working_image.data = working_image.fluxdensity_to_flux() - - if self.mask is not None: - working_image.data = working_image.data * (~self.mask) + working_image.fluxdensity_to_flux() return working_image diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 51a35952..0cca30fa 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -67,10 +67,8 @@ def initialize(self): max(target_area.shape) * target_area.pixel_length.item() * 0.7, self.n_components, ) - self.sigma.uncertainty = self.default_uncertainty * self.sigma.value if not self.flux.initialized: self.flux.dynamic_value = (np.sum(dat) / self.n_components) * np.ones(self.n_components) - self.flux.uncertainty = self.default_uncertainty * self.flux.value if self.PA.initialized or self.q.initialized: return diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index bc36e9ff..ee20f4be 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -49,7 +49,6 @@ def initialize(self): return target_area = self.target[self.window] self.pixels.dynamic_value = target_area.data.clone() / target_area.pixel_area - self.pixels.uncertainty = torch.abs(self.pixels.value) * self.default_uncertainty @forward def brightness(self, x, y, pixels, center): diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index 09455d26..7c335037 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -39,15 +39,8 @@ def initialize(self): if not self.I0.initialized: dat = self.target[self.window].data.detach().cpu().numpy().copy() self.I0.dynamic_value = np.median(dat) / self.target.pixel_area.item() - self.I0.uncertainty = (iqr(dat, rng=(16, 84)) / 2.0) / np.sqrt( - np.prod(self.window.shape.detach().cpu().numpy()) - ) if not self.delta.initialized: self.delta.dynamic_value = [0.0, 0.0] - self.delta.uncertainty = [ - self.default_uncertainty, - self.default_uncertainty, - ] @forward def brightness(self, x, y, I0, delta): diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 933ee0b2..0a621aaa 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -3,9 +3,11 @@ import torch import numpy as np +from .base import Model from .model_object import ComponentModel from ..utils.decorators import ignore_numpy_warnings -from ..image import Window, ModelImage +from ..utils.interpolate import interp2d +from ..image import Window, PSFImage from ..errors import SpecificationConflict from ..param import forward from . import func @@ -41,7 +43,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.psf is None: - raise SpecificationConflict("Point_Source needs psf information") + raise SpecificationConflict("Point_Source needs a psf!") @torch.no_grad() @ignore_numpy_warnings @@ -55,7 +57,6 @@ def initialize(self): edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) self.logflux.dynamic_value = np.log10(np.abs(np.sum(dat - edge_average))) - self.logflux.uncertainty = torch.std(dat) / np.sqrt(np.prod(dat.shape)) # Psf convolution should be on by default since this is a delta function @property @@ -66,6 +67,14 @@ def psf_mode(self): def psf_mode(self, value): pass + @property + def integrate_mode(self): + return "none" + + @integrate_mode.setter + def integrate_mode(self, value): + pass + @forward def sample(self, window: Optional[Window] = None, center=None, flux=None): """Evaluate the model on the space covered by an image object. This @@ -97,45 +106,26 @@ def sample(self, window: Optional[Window] = None, center=None, flux=None): if window is None: window = self.window - # Adjust for supersampled PSF - psf_upscale = torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() + if isinstance(self.psf, PSFImage): + psf = self.psf.data + elif isinstance(self.psf, Model): + psf = self.psf().data + else: + raise TypeError( + f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." + ) # Make the image object to which the samples will be tracked - working_image = ModelImage(window=window, upsample=psf_upscale) - - # Compute the center offset - pixel_center = torch.stack(working_image.plane_to_pixel(*center)) - pixel_shift = pixel_center - torch.round(pixel_center) - psf = self.psf.data - shift_kernel = func.fft_shift_kernel(psf.shape, pixel_shift[0], pixel_shift[1]) - psf = torch.fft.irfft2(shift_kernel * torch.fft.rfft2(psf, s=psf.shape), s=psf.shape) - # ( - # torch.nn.functional.conv2d( - # self.psf.data.value.view(1, 1, *self.psf.data.shape), - # shift_kernel.view(1, 1, *shift_kernel.shape), - # padding="valid", # fixme add note about valid padding - # ) - # .squeeze(0) - # .squeeze(0) - # ) - psf = flux * psf - - # Fill pixels with the PSF image - pixel_center = torch.round(pixel_center).int() - psf_window = Window( - ( - pixel_center[0] - psf.shape[0] // 2, - pixel_center[0] + psf.shape[0] // 2 + 1, - pixel_center[1] - psf.shape[1] // 2, - pixel_center[1] + psf.shape[1] // 2 + 1, - ), - image=working_image, + working_image = self.target[window].model_image(upsample=self.psf_upscale) + + i, j = working_image.pixel_center_meshgrid() + i0, j0 = working_image.plane_to_pixel(*center) + working_image.data = interp2d( + psf, i - i0 + (psf.shape[0] // 2), j - j0 + (psf.shape[1] // 2) ) - working_image[psf_window].data += psf[working_image.get_other_indices(psf_window)] - working_image = working_image.reduce(psf_upscale) - # Return to image pixelscale - if self.mask is not None: - working_image.data = working_image.data * (~self.mask) + working_image.data = flux * working_image.data + + working_image = working_image.reduce(self.psf_upscale) return working_image diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 4f9ded23..5f107cf1 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -23,13 +23,8 @@ class PSFModel(SampleMixin, Model): """ - # Specifications for the model parameters including units, value, uncertainty, limits, locked, and cyclic _parameter_specs = { - "center": { - "units": "arcsec", - "value": (0.0, 0.0), - "uncertainty": (0.1, 0.1), - }, + "center": {"units": "arcsec", "value": (0.0, 0.0), "shape": (2,)}, } _model_type = "psf" usable = False @@ -76,7 +71,7 @@ def sample(self, window=None): """ # Create an image to store pixel samples - working_image = ModelImage(window=self.window) + working_image = self.target[self.window].model_image() working_image.data = self.sample_image(working_image) # normalize to total flux 1 diff --git a/astrophot/models/zernike.py b/astrophot/models/zernike.py index c5cbcea2..ae646d4f 100644 --- a/astrophot/models/zernike.py +++ b/astrophot/models/zernike.py @@ -45,7 +45,6 @@ def initialize(self): # Set the default coefficients to zeros self.Anm.dynamic_value = torch.zeros(len(self.nm_list)) - self.Anm.uncertainty = self.default_uncertainty * torch.ones_like(self.Anm.value) if self.nm_list[0] == (0, 0): self.Anm.value[0] = torch.median(self.target[self.window].data) / self.target.pixel_area diff --git a/astrophot/param/param.py b/astrophot/param/param.py index 90dbb43b..2da534eb 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -45,3 +45,18 @@ def initialized(self): if self.value is not None: return True return False + + def is_valid(self, value): + if self.valid[0] is not None and torch.any(value <= self.valid[0]): + return False + if self.valid[1] is not None and torch.any(value >= self.valid[1]): + return False + return True + + def soft_valid(self, value): + if self.valid[0] is None and self.valid[1] is None: + return value + vrange = self.valid[1] - self.valid[0] + return torch.clamp( + value, min=self.valid[0] + 0.1 * vrange, max=self.valid[1] - 0.1 * vrange + ) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index f401f5a9..c87f263e 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -231,9 +231,13 @@ def model_image( sample_image = sample_image.data.detach().cpu().numpy() # Default kwargs for image + vmin = kwargs.pop("vmin", None) + vmax = kwargs.pop("vmax", None) kwargs = { "cmap": cmap_grad, - "norm": matplotlib.colors.LogNorm(), # "norm": ImageNormalize(stretch=LogStretch(), clip=False), + "norm": matplotlib.colors.LogNorm( + vmin=vmin, vmax=vmax + ), # "norm": ImageNormalize(stretch=LogStretch(), clip=False), **kwargs, } diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index ce102c43..d48e5e28 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -312,6 +312,9 @@ def interp2d( x = x.view(-1) y = y.view(-1) + # valid + valid = (x >= -0.5) & (x < (w - 0.5)) & (y >= -0.5) & (y < (h - 0.5)) + x0 = x.floor().long() y0 = y.floor().long() x1 = x0 + 1 @@ -333,7 +336,7 @@ def interp2d( result = fa * wa + fb * wb + fc * wc + fd * wd - return result.view(*start_shape) + return (result * valid).view(*start_shape) @lru_cache(maxsize=32) diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index e141908a..d86ae1c7 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -17,13 +17,11 @@ "metadata": {}, "outputs": [], "source": [ + "%matplotlib inline\n", "import astrophot as ap\n", "import numpy as np\n", "import torch\n", - "from astropy.io import fits\n", - "import matplotlib.pyplot as plt\n", - "\n", - "%matplotlib inline" + "import matplotlib.pyplot as plt" ] }, { @@ -44,15 +42,15 @@ "outputs": [], "source": [ "# First make a mock empirical PSF image\n", - "# np.random.seed(124)\n", + "np.random.seed(124)\n", "psf = ap.utils.initialize.moffat_psf(2.0, 3.0, 101, 0.5)\n", "variance = psf**2 / 100\n", "psf += np.random.normal(scale=np.sqrt(variance))\n", - "# psf[psf < 0] = 0 #ap.utils.initialize.moffat_psf(2.0, 3.0, 101, 0.5)[psf < 0]\n", "\n", "psf_target = ap.image.PSFImage(\n", " data=psf,\n", " pixelscale=0.5,\n", + " variance=variance,\n", ")\n", "\n", "# To ensure the PSF has a normalized flux of 1, we call\n", @@ -82,12 +80,12 @@ "\n", "# PSF model can be fit to it's own target for good initial values\n", "# Note we provide the weight map (1/variance) since a PSF_Image can't store that information.\n", - "ap.fit.LM(psf_model, verbose=1, W=1 / variance).fit()\n", + "ap.fit.LM(psf_model, verbose=1).fit()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(13, 5))\n", "ap.plots.psf_image(fig, ax[0], psf_model)\n", "ax[0].set_title(\"PSF model fit to mock empirical PSF\")\n", - "ap.plots.residual_image(fig, ax[1], psf_model, normalize_residuals=torch.tensor(variance))\n", + "ap.plots.residual_image(fig, ax[1], psf_model, normalize_residuals=True)\n", "ax[1].set_title(\"residuals\")\n", "plt.show()" ] @@ -130,7 +128,7 @@ " n=2,\n", " Rd=3,\n", ")\n", - "true_psf = true_psf_model().data.value\n", + "true_psf = true_psf_model().data\n", "\n", "target = ap.image.TargetImage(\n", " data=torch.zeros(100, 100),\n", @@ -150,13 +148,12 @@ " Ie=10,\n", " psf_mode=\"full\",\n", ")\n", - "true_model.to()\n", "\n", "# use the true model to make some data\n", "sample = true_model()\n", "torch.manual_seed(61803398)\n", - "target.data = sample.data.value + torch.normal(torch.zeros_like(sample.data.value), 0.1)\n", - "target.variance = 0.01 * torch.ones_like(sample.data.value)\n", + "target.data = sample.data + torch.normal(torch.zeros_like(sample.data), 0.1)\n", + "target.variance = 0.01 * torch.ones_like(sample.data)\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", "ap.plots.model_image(fig, ax[0], true_model)\n", @@ -227,8 +224,8 @@ " name=\"psf\",\n", " model_type=\"moffat psf model\",\n", " target=psf_target,\n", - " n=2.0, # True value is 2.\n", - " Rd=3.0, # True value is 3.\n", + " n=1.0, # True value is 2.\n", + " Rd=3.5, # True value is 3.\n", ")\n", "\n", "# Here we set up a sersic model for the galaxy\n", @@ -239,9 +236,8 @@ " psf_mode=\"full\",\n", " psf=live_psf_model, # Here we bind the PSF model to the galaxy model, this will add the psf_model parameters to the galaxy_model\n", ")\n", - "live_psf_model.initialize()\n", "live_galaxy_model.initialize()\n", - "print(live_galaxy_model.center.value)\n", + "\n", "result = ap.fit.LM(live_galaxy_model, verbose=3).fit()\n", "result.update_uncertainty()" ] diff --git a/docs/source/tutorials/BasicPSFModels.ipynb b/docs/source/tutorials/BasicPSFModels.ipynb index 1d2e628c..3274ebd3 100644 --- a/docs/source/tutorials/BasicPSFModels.ipynb +++ b/docs/source/tutorials/BasicPSFModels.ipynb @@ -92,12 +92,11 @@ "pointsource = ap.models.Model(\n", " model_type=\"point model\",\n", " target=target,\n", - " center=[75, 75],\n", + " center=[75.25, 75.9],\n", " flux=1,\n", " psf=psf_target,\n", ")\n", "pointsource.initialize()\n", - "pointsource.to()\n", "# With a convolved sersic the center is much more smoothed out\n", "fig, ax = plt.subplots(figsize=(6, 6))\n", "ap.plots.model_image(fig, ax, pointsource, showcbar=False)\n", @@ -109,6 +108,14 @@ "cell_type": "markdown", "id": "6", "metadata": {}, + "source": [ + "Don't worry about the \"fuzz\" of values outside the PSF model. These values are of order 1e-18 and are an artefact of the sub-pixel shift using the FFT shift theorem. They may be treated as zero for numerical purposes." + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, "source": [ "## Extended model PSF convolution\n", "\n", @@ -118,7 +125,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -144,7 +151,6 @@ " Re=10,\n", " logIe=1,\n", " psf_mode=\"full\", # now the full window will be PSF convolved using the PSF from the target\n", - " psf_subpixel_shift=True,\n", ")\n", "model_psf.initialize()\n", "\n", @@ -183,7 +189,7 @@ }, { "cell_type": "markdown", - "id": "8", + "id": "9", "metadata": {}, "source": [ "## Supersampled PSF models\n", @@ -194,7 +200,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -212,7 +218,7 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " psf_mode=\"full\", # now the full window will be PSF convolved using the PSF from the target\n", ")\n", "model_upsamplepsf.initialize()\n", @@ -224,7 +230,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "11", "metadata": {}, "source": [ "That covers the basics of adding PSF convolution kernels to AstroPhot models! These techniques assume you already have a model for the PSF that you got with some other algorithm (ie PSFEx), however AstroPhot also has the ability to model the PSF live along with the rest of the models in an image. If you are interested in extracting the PSF from an image using AstroPhot, check out the `AdvancedPSFModels` tutorial. " @@ -233,7 +239,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [] diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index e19770cc..890e53aa 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -71,7 +71,6 @@ "# AstroPhot has built in methods to plot relevant information. We didn't specify the region on the sky for\n", "# this model to focus on, so we just made a 100x100 window. Unless you are very lucky this won't\n", "# line up with what you're trying to fit, so next we'll see how to give the model a target.\n", - "\n", "fig, ax = plt.subplots(figsize=(8, 7))\n", "ap.plots.model_image(fig, ax, model1)\n", "plt.show()" From c8909a95cf33480355234a80458a588b84e04322 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 7 Jul 2025 23:18:07 -0400 Subject: [PATCH 048/191] deleting unneeded utils files --- astrophot/fit/gradient.py | 42 ++- astrophot/fit/mhmcmc.py | 120 +++----- astrophot/models/base.py | 14 +- astrophot/utils/__init__.py | 14 +- astrophot/utils/angle_operations.py | 56 ---- astrophot/utils/conversions/coordinates.py | 56 ---- astrophot/utils/conversions/dict_to_hdf5.py | 38 --- astrophot/utils/conversions/optimization.py | 75 ----- astrophot/utils/initialize/__init__.py | 2 - astrophot/utils/initialize/construct_psf.py | 2 - astrophot/utils/initialize/initialize.py | 113 -------- astrophot/utils/interpolate.py | 298 -------------------- astrophot/utils/isophote/__init__.py | 0 astrophot/utils/isophote/ellipse.py | 37 --- astrophot/utils/isophote/extract.py | 249 ---------------- astrophot/utils/isophote/integrate.py | 210 -------------- astrophot/utils/operations.py | 247 ---------------- astrophot/utils/parametric_profiles.py | 100 ------- docs/source/tutorials/GettingStarted.ipynb | 9 +- 19 files changed, 79 insertions(+), 1603 deletions(-) delete mode 100644 astrophot/utils/angle_operations.py delete mode 100644 astrophot/utils/conversions/coordinates.py delete mode 100644 astrophot/utils/conversions/dict_to_hdf5.py delete mode 100644 astrophot/utils/conversions/optimization.py delete mode 100644 astrophot/utils/initialize/initialize.py delete mode 100644 astrophot/utils/isophote/__init__.py delete mode 100644 astrophot/utils/isophote/ellipse.py delete mode 100644 astrophot/utils/isophote/extract.py delete mode 100644 astrophot/utils/isophote/integrate.py delete mode 100644 astrophot/utils/operations.py diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 4152be4c..24ffe0e3 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -6,6 +6,7 @@ from .base import BaseOptimizer from .. import AP_config +from ..models import Model __all__ = ["Grad"] @@ -39,7 +40,9 @@ class Grad(BaseOptimizer): """ - def __init__(self, model: "AstroPhot_Model", initial_state: Sequence = None, **kwargs) -> None: + def __init__( + self, model: Model, initial_state: Sequence = None, likelihood="gaussian", **kwargs + ) -> None: """Initialize the gradient descent optimizer. Args: @@ -51,7 +54,8 @@ def __init__(self, model: "AstroPhot_Model", initial_state: Sequence = None, **k """ super().__init__(model, initial_state, **kwargs) - self.model.parameters.flat_detach() + + self.likelihood = likelihood # set parameters from the user self.patience = kwargs.get("patience", None) @@ -69,23 +73,17 @@ def __init__(self, model: "AstroPhot_Model", initial_state: Sequence = None, **k (self.current_state,), **self.optim_kwargs ) - def compute_loss(self) -> torch.Tensor: - Ym = self.model(parameters=self.current_state, as_representation=True).flatten("data") - Yt = self.model.target[self.model.window].flatten("data") - W = ( - self.model.target[self.model.window].flatten("variance") - if self.model.target.has_variance - else 1.0 - ) - ndf = len(Yt) - len(self.current_state) - if self.model.target.has_mask: - mask = self.model.target[self.model.window].flatten("mask") - ndf -= torch.sum(mask) - mask = torch.logical_not(mask) - loss = torch.sum((Ym[mask] - Yt[mask]) ** 2 / W[mask]) / ndf + def density(self, state: torch.Tensor) -> torch.Tensor: + """ + Returns the density of the model at the given state vector. + This is used to calculate the likelihood of the model at the given state. + """ + if self.likelihood == "gaussian": + return self.model.gaussian_log_likelihood(state) + elif self.likelihood == "poisson": + return self.model.poisson_log_likelihood(state) else: - loss = torch.sum((Ym - Yt) ** 2 / W) / ndf - return loss + raise ValueError(f"Unknown likelihood type: {self.likelihood}") def step(self) -> None: """Take a single gradient step. Take a single gradient step. @@ -98,9 +96,8 @@ def step(self) -> None: self.iteration += 1 self.optimizer.zero_grad() - self.model.parameters.flat_detach() - - loss = self.compute_loss() + self.current_state.requires_grad = True + loss = self.density(self.current_state) loss.backward() @@ -145,10 +142,9 @@ def fit(self) -> "BaseOptimizer": self.message = self.message + " fail interrupted" # Set the model parameters to the best values from the fit and clear any previous model sampling - self.model.parameters.vector_set_representation(self.res()) + self.model.fill_dynamic_values(self.res()) if self.verbose > 1: AP_config.ap_logger.info( f"Grad Fitting complete in {time() - start_fit} sec with message: {self.message}" ) - self.model.parameters.flat_detach() return self diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index ffb437eb..641f44ea 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -1,9 +1,16 @@ # Metropolis-Hasting Markov-Chain Monte-Carlo from typing import Optional, Sequence + import torch -from tqdm import tqdm import numpy as np + +try: + import emcee +except ImportError: + emcee = None + from .base import BaseOptimizer +from ..models import Model from .. import AP_config __all__ = ["MHMCMC"] @@ -11,41 +18,47 @@ class MHMCMC(BaseOptimizer): """Metropolis-Hastings Markov-Chain Monte-Carlo sampler, based on: - https://en.wikipedia.org/wiki/Metropolis-Hastings_algorithm . This - is a naive implementation of a standard MCMC, it is far from - optimal and should not be used for anything but the most basic - scenarios. - - Args: - model (AstroPhot_Model): The model which will be sampled. - initial_state (Optional[Sequence]): A 1D array with the values for each parameter in the model. Note that these values should be in the form of "as_representation" in the model. - max_iter (int): The number of sampling steps to perform. Default 1000 - epsilon (float or array): The random step length to take at each iteration. This is the standard deviation for the normal distribution sampling. Default 1e-2 - + https://en.wikipedia.org/wiki/Metropolis-Hastings_algorithm . This is simply + a thin wrapper for the Emcee package, which is a well-known MCMC sampler. """ def __init__( self, - model: "AstroPhot_Model", + model: Model, initial_state: Optional[Sequence] = None, max_iter: int = 1000, + likelihood="gaussian", **kwargs, ): super().__init__(model, initial_state, max_iter=max_iter, **kwargs) - self.epsilon = kwargs.get("epsilon", 1e-2) - self.progress_bar = kwargs.get("progress_bar", True) - self.report_after = kwargs.get("report_after", int(self.max_iter / 10)) + if emcee is None: + raise ImportError( + "The emcee package is required for MHMCMC sampling. Please install it with `pip install emcee` or the like." + ) + self.likelihood = likelihood self.chain = [] - self._accepted = 0 - self._sampled = 0 + + def density(self, state: np.ndarray) -> np.ndarray: + """ + Returns the density of the model at the given state vector. + This is used to calculate the likelihood of the model at the given state. + """ + state = torch.tensor(state, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + if self.likelihood == "gaussian": + return np.array(list(self.model.gaussian_log_likelihood(s).item() for s in state)) + elif self.likelihood == "poisson": + return np.array(list(self.model.poisson_log_likelihood(s).item() for s in state)) + else: + raise ValueError(f"Unknown likelihood type: {self.likelihood}") def fit( self, - state: Optional[torch.Tensor] = None, + state: Optional[np.ndarray] = None, nsamples: Optional[int] = None, restart_chain: bool = True, + skip_initial_state_check: bool = True, ): """ Performs the MCMC sampling using a Metropolis Hastings acceptance step and records the chain for later examination. @@ -56,66 +69,17 @@ def fit( if state is None: state = self.current_state - chi2 = self.sample(state) + if len(state.shape) == 1: + nwalkers = state.shape[0] * 2 + state = state * np.random.normal(loc=1, scale=0.01, size=(nwalkers, state.shape[0])) + else: + nwalkers = state.shape[0] + ndim = state.shape[1] + sampler = emcee.EnsembleSampler(nwalkers, ndim, self.density, vectorize=True) + state = sampler.run_mcmc(state, nsamples, skip_initial_state_check=skip_initial_state_check) if restart_chain: - self.chain = [] + self.chain = sampler.get_chain() else: - self.chain = list(self.chain) - - iterator = tqdm(range(nsamples)) if self.progress_bar else range(nsamples) - for i in iterator: - state, chi2 = self.step(state, chi2) - self.append_chain(state) - if i % self.report_after == 0 and i > 0 and self.verbose > 0: - AP_config.ap_logger.info(f"Acceptance: {self.acceptance}") - if self.verbose > 0: - AP_config.ap_logger.info(f"Acceptance: {self.acceptance}") - self.current_state = state - self.chain = np.stack(self.chain) + self.chain = np.append(self.chain, sampler.get_chain(), axis=0) return self - - def append_chain(self, state: torch.Tensor): - """ - Add a state vector to the MCMC chain - """ - - self.chain.append( - self.model.parameters.vector_transform_rep_to_val(state).detach().cpu().clone().numpy() - ) - - @staticmethod - def accept(log_alpha): - """ - Evaluates randomly if a given proposal is accepted. This is done in log space which is more natural for the evaluation in the step. - """ - return torch.log(torch.rand(log_alpha.shape)) < log_alpha - - @torch.no_grad() - def sample(self, state: torch.Tensor): - """ - Samples the model at the proposed state vector values - """ - return self.model.negative_log_likelihood(parameters=state, as_representation=True) - - @torch.no_grad() - def step(self, state: torch.Tensor, chi2: torch.Tensor) -> torch.Tensor: - """ - Takes one step of the HMC sampler by integrating along a path initiated with a random momentum. - """ - - proposal_state = torch.normal(mean=state, std=self.epsilon) - proposal_chi2 = self.sample(proposal_state) - log_alpha = chi2 - proposal_chi2 - accept = self.accept(log_alpha) - self._accepted += accept - self._sampled += 1 - return proposal_state if accept else state, proposal_chi2 if accept else chi2 - - @property - def acceptance(self): - """ - Returns the ratio of accepted states to total states sampled. - """ - - return self._accepted / self._sampled diff --git a/astrophot/models/base.py b/astrophot/models/base.py index c85fdf53..29d90bea 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -201,7 +201,7 @@ def build_parameter_specs(self, kwargs, parameter_specs) -> dict: return parameter_specs @forward - def gaussian_negative_log_likelihood( + def gaussian_log_likelihood( self, window: Optional[Window] = None, ) -> torch.Tensor: @@ -217,17 +217,17 @@ def gaussian_negative_log_likelihood( mask = data.mask data = data.data if isinstance(data, ImageList): - nll = sum( - torch.sum(((mo - da) ** 2 * wgt)[~ma]) / 2.0 + nll = 0.5 * sum( + torch.sum(((mo - da) ** 2 * wgt)[~ma]) for mo, da, wgt, ma in zip(model, data, weight, mask) ) else: - nll = torch.sum(((model - data) ** 2 * weight)[~mask]) / 2.0 + nll = 0.5 * torch.sum(((model - data) ** 2 * weight)[~mask]) - return nll + return -nll @forward - def poisson_negative_log_likelihood( + def poisson_log_likelihood( self, window: Optional[Window] = None, ) -> torch.Tensor: @@ -249,7 +249,7 @@ def poisson_negative_log_likelihood( else: nll = torch.sum((model - data * (model + 1e-10).log() + torch.lgamma(data + 1))[~mask]) - return nll + return -nll @forward def total_flux(self, window=None) -> torch.Tensor: diff --git a/astrophot/utils/__init__.py b/astrophot/utils/__init__.py index dec9f641..4e70516c 100644 --- a/astrophot/utils/__init__.py +++ b/astrophot/utils/__init__.py @@ -1,23 +1,19 @@ from . import ( - optimization, - angle_operations, + conversions, + initialize, decorators, + integration, interpolate, - operations, + optimization, parametric_profiles, - isophote, - initialize, - conversions, ) __all__ = [ "optimization", - "angle_operations", "decorators", "interpolate", - "operations", + "integration", "parametric_profiles", - "isophote", "initialize", "conversions", ] diff --git a/astrophot/utils/angle_operations.py b/astrophot/utils/angle_operations.py deleted file mode 100644 index e4119e64..00000000 --- a/astrophot/utils/angle_operations.py +++ /dev/null @@ -1,56 +0,0 @@ -import numpy as np -from scipy.stats import iqr - - -def Angle_Average(a): - """ - Compute the average for a list of angles, which may wrap around a cyclic boundary. - - a: list of angles in the range [0,2pi] - """ - i = np.cos(a) + 1j * np.sin(a) - return np.angle(np.mean(i)) - - -def Angle_Median(a): - """ - Compute the median for a list of angles, which may wrap around a cyclic boundary. - - a: list of angles in the range [0,2pi] - """ - i = np.median(np.cos(a)) + 1j * np.median(np.sin(a)) - return np.angle(i) - - -def Angle_Scatter(a): - """ - Compute the scatter for a list of angles, which may wrap around a cyclic boundary. - - a: list of angles in the range [0,2pi] - """ - i = np.cos(a) + 1j * np.sin(a) - return iqr(np.angle(1j * i / np.mean(i)), rng=[16, 84]) - - -def Angle_COM_PA(flux, X=None, Y=None): - """Performs a center of angular mass calculation by using the flux as - weights to compute a position angle which accounts for the general - "direction" of the light. This PA is computed mod pi since these - are 180 degree rotation symmetric. - - Args: - flux: the weight values for each element (by assumption, pixel fluxes) in a 2D array - X: x coordinate of the flux points. Assumed centered pixel indices if not given - Y: y coordinate of the flux points. Assumed centered pixel indices if not given - - """ - if X is None: - S = flux.shape - X, Y = np.meshgrid(np.arange(S[1]) - S[1] / 2, np.arange(S[0]) - S[0] / 2, indexing="xy") - - theta = np.arctan2(Y, X) - - ang_com_cos = np.sum(flux * np.cos(2 * theta)) / np.sum(flux) - ang_com_sin = np.sum(flux * np.sin(2 * theta)) / np.sum(flux) - - return np.arctan2(ang_com_sin, ang_com_cos) / 2 % np.pi diff --git a/astrophot/utils/conversions/coordinates.py b/astrophot/utils/conversions/coordinates.py deleted file mode 100644 index 30deb64d..00000000 --- a/astrophot/utils/conversions/coordinates.py +++ /dev/null @@ -1,56 +0,0 @@ -import torch -import numpy as np - - -def Rotate_Cartesian(theta, X, Y=None): - """ - Applies a rotation matrix to the X,Y coordinates - """ - s = torch.sin(theta) - c = torch.cos(theta) - if Y is None: - return c * X[0] - s * X[1], s * X[0] + c * X[1] - return c * X - s * Y, s * X + c * Y - - -def Rotate_Cartesian_np(theta, X, Y): - """ - Applies a rotation matrix to the X,Y coordinates - """ - s = np.sin(theta) - c = np.cos(theta) - return c * X - s * Y, c * Y + s * X - - -def Axis_Ratio_Cartesian(q, X, Y, theta=0.0, inv_scale=False): - """ - Applies the transformation: R(theta) Q R(-theta) - where R is the rotation matrix and Q is the matrix which scales the y component by 1/q. - This effectively counter-rotates the coordinates so that the angle theta is along the x-axis - then applies the y-axis scaling, then re-rotates everything back to where it was. - """ - if inv_scale: - scale = (1 / q) - 1 - else: - scale = q - 1 - ss = 1 + scale * torch.pow(torch.sin(theta), 2) - cc = 1 + scale * torch.pow(torch.cos(theta), 2) - s2 = scale * torch.sin(2 * theta) - return ss * X - s2 * Y / 2, -s2 * X / 2 + cc * Y - - -def Axis_Ratio_Cartesian_np(q, X, Y, theta=0.0, inv_scale=False): - """ - Applies the transformation: R(theta) Q R(-theta) - where R is the rotation matrix and Q is the matrix which scales the y component by 1/q. - This effectively counter-rotates the coordinates so that the angle theta is along the x-axis - then applies the y-axis scaling, then re-rotates everything back to where it was. - """ - if inv_scale: - scale = (1 / q) - 1 - else: - scale = q - 1 - ss = 1 + scale * np.sin(theta) ** 2 - cc = 1 + scale * np.cos(theta) ** 2 - s2 = scale * np.sin(2 * theta) - return ss * X - s2 * Y / 2, -s2 * X / 2 + cc * Y diff --git a/astrophot/utils/conversions/dict_to_hdf5.py b/astrophot/utils/conversions/dict_to_hdf5.py deleted file mode 100644 index d1b02354..00000000 --- a/astrophot/utils/conversions/dict_to_hdf5.py +++ /dev/null @@ -1,38 +0,0 @@ -def to_hdf5_has_None(l): - for i in range(len(l)): - if hasattr(l[i], "__iter__") and not isinstance(l[i], str): - l[i] = to_hdf5_has_None(l[i]) - elif l[i] is None: - return True - return False - - -def dict_to_hdf5(h, D): - for key in D: - if isinstance(D[key], dict): - n = h.create_group(key) - dict_to_hdf5(n, D[key]) - else: - if hasattr(D[key], "__iter__") and not isinstance(D[key], str): - if to_hdf5_has_None(D[key]): - h[key] = str(D[key]) - else: - h.create_dataset(key, data=D[key]) - elif D[key] is not None: - h[key] = D[key] - else: - h[key] = "None" - - -def hdf5_to_dict(h): - import h5py - - D = {} - for key in h.keys(): - if isinstance(h[key], h5py.Group): - D[key] = hdf5_to_dict(h[key]) - elif isinstance(h[key], str) and "None" in h[key]: - D[key] = eval(h[key]) - else: - D[key] = h[key] - return D diff --git a/astrophot/utils/conversions/optimization.py b/astrophot/utils/conversions/optimization.py deleted file mode 100644 index ca3696a6..00000000 --- a/astrophot/utils/conversions/optimization.py +++ /dev/null @@ -1,75 +0,0 @@ -import numpy as np -import torch -from ... import AP_config - - -def boundaries(val, limits): - """val in limits expanded to range -inf to inf""" - tval = torch.as_tensor(val, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - - if limits[0] is None: - return tval - 1.0 / (tval - limits[1]) - elif limits[1] is None: - return tval - 1.0 / (tval - limits[0]) - return torch.tan((tval - limits[0]) * np.pi / (limits[1] - limits[0]) - np.pi / 2) - - -def inv_boundaries(val, limits): - """val in range -inf to inf compressed to within the limits""" - tval = torch.as_tensor(val, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - - if limits[0] is None: - return (tval + limits[1] - torch.sqrt(torch.pow(tval - limits[1], 2) + 4)) * 0.5 - elif limits[1] is None: - return (tval + limits[0] + torch.sqrt(torch.pow(tval - limits[0], 2) + 4)) * 0.5 - return (torch.arctan(tval) + np.pi / 2) * (limits[1] - limits[0]) / np.pi + limits[0] - - -def d_boundaries_dval(val, limits): - """derivative of: val in limits expanded to range -inf to inf""" - tval = torch.as_tensor(val, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - if limits[0] is None: - return 1.0 + 1.0 / (tval - limits[1]) ** 2 - elif limits[1] is None: - return 1.0 - 1.0 / (tval - limits[0]) ** 2 - return (np.pi / (limits[1] - limits[0])) / torch.cos( - (tval - limits[0]) * np.pi / (limits[1] - limits[0]) - np.pi / 2 - ) ** 2 - - -def d_inv_boundaries_dval(val, limits): - """derivative of: val in range -inf to inf compressed to within the limits""" - tval = torch.as_tensor(val, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - if limits[0] is None: - return 0.5 - 0.5 * (tval - limits[1]) / torch.sqrt(torch.pow(tval - limits[1], 2) + 4) - elif limits[1] is None: - return 0.5 + 0.5 * (tval - limits[0]) / torch.sqrt(torch.pow(tval - limits[0], 2) + 4) - return (limits[1] - limits[0]) / (np.pi * (tval**2 + 1)) - - -def cyclic_boundaries(val, limits): - """Applies cyclic boundary conditions to the input value.""" - tval = torch.as_tensor(val, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - - return limits[0] + ((tval - limits[0]) % (limits[1] - limits[0])) - - -def cyclic_difference_torch(val1, val2, period): - """Applies the difference operation between two values with cyclic - boundary conditions. - - """ - tval1 = torch.as_tensor(val1, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - tval2 = torch.as_tensor(val2, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - - return torch.arcsin(torch.sin((tval1 - tval2) * np.pi / period)) * period / np.pi - - -def cyclic_difference_np(val1, val2, period): - """Applies the difference operation between two values with cyclic - boundary conditions. - - """ - tval1 = torch.as_tensor(val1, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - tval2 = torch.as_tensor(val2, device=AP_config.ap_device, dtype=AP_config.ap_dtype) - return np.arcsin(np.sin((tval1 - tval2) * np.pi / period)) * period / np.pi diff --git a/astrophot/utils/initialize/__init__.py b/astrophot/utils/initialize/__init__.py index 1e631ee5..57e5e683 100644 --- a/astrophot/utils/initialize/__init__.py +++ b/astrophot/utils/initialize/__init__.py @@ -1,11 +1,9 @@ from .segmentation_map import * -from .initialize import isophotes from .center import center_of_mass, recursive_center_of_mass from .construct_psf import gaussian_psf, moffat_psf, construct_psf from .variance import auto_variance __all__ = ( - "isophotes", "center_of_mass", "recursive_center_of_mass", "gaussian_psf", diff --git a/astrophot/utils/initialize/construct_psf.py b/astrophot/utils/initialize/construct_psf.py index b1e70298..7b6921c0 100644 --- a/astrophot/utils/initialize/construct_psf.py +++ b/astrophot/utils/initialize/construct_psf.py @@ -1,7 +1,5 @@ import numpy as np -from ..interpolate import shift_Lanczos_np - def gaussian_psf(sigma, img_width, pixelscale, upsample=4): assert img_width % 2 == 1, "psf images should have an odd shape" diff --git a/astrophot/utils/initialize/initialize.py b/astrophot/utils/initialize/initialize.py deleted file mode 100644 index 3f03ca5f..00000000 --- a/astrophot/utils/initialize/initialize.py +++ /dev/null @@ -1,113 +0,0 @@ -import numpy as np -from scipy.stats import iqr -from scipy.fftpack import fft - -from ..isophote.extract import _iso_extract - - -def isophotes(image, center, threshold=None, pa=None, q=None, R=None, n_isophotes=3, more=False): - """Method for quickly extracting a small number of elliptical - isophotes for the sake of initializing other models. - - """ - - if pa is None: - pa = 0.0 - - if q is None: - q = 1.0 - - if R is None: - # Determine basic threshold if none given - if threshold is None: - threshold = np.nanmedian(image) + 3 * iqr(image[np.isfinite(image)], rng=(16, 84)) / 2 - - # Sample growing isophotes until threshold is reached - ellipse_radii = [1.0] - while ellipse_radii[-1] < (max(image.shape) / 2): - ellipse_radii.append(ellipse_radii[-1] * (1 + 0.2)) - isovals = _iso_extract( - image, - ellipse_radii[-1], - { - "q": q if isinstance(q, float) else np.max(q), - "pa": pa if isinstance(pa, float) else np.min(pa), - }, - {"x": center[0], "y": center[1]}, - more=False, - sigmaclip=True, - sclip_nsigma=3, - ) - if len(isovals) < 3: - continue - # Stop when at 3 time background noise - if (np.quantile(isovals, 0.8) < threshold) and len(ellipse_radii) > 4: - break - R = ellipse_radii[-1] - - # Determine which radii to sample based on input R, pa, and q - if isinstance(pa, float) and isinstance(q, float) and isinstance(R, float): - if n_isophotes == 1: - isophote_radii = [R] - else: - isophote_radii = np.linspace(0, R, n_isophotes) - elif hasattr(R, "__len__"): - isophote_radii = R - elif hasattr(pa, "__len__"): - isophote_radii = np.ones(len(pa)) * R - elif hasattr(q, "__len__"): - isophote_radii = np.ones(len(q)) * R - - # Sample the requested isophotes and record desired info - iso_info = [] - for i, r in enumerate(isophote_radii): - iso_info.append({"R": r}) - isovals = _iso_extract( - image, - r, - { - "q": q if isinstance(q, float) else q[i], - "pa": pa if isinstance(pa, float) else pa[i], - }, - {"x": center[0], "y": center[1]}, - more=more, - sigmaclip=True, - sclip_nsigma=3, - interp_mask=True, - ) - if more: - angles = isovals[1] - isovals = isovals[0] - if len(isovals) < 3: - iso_info[-1] = None - continue - coefs = fft(isovals) - iso_info[-1]["phase1"] = np.angle(coefs[1]) - iso_info[-1]["phase2"] = np.angle(coefs[2]) - iso_info[-1]["flux"] = np.median(isovals) - iso_info[-1]["noise"] = iqr(isovals, rng=(16, 84)) / 2 - iso_info[-1]["amplitude1"] = np.abs(coefs[1]) / ( - len(isovals) * (max(0, iso_info[-1]["flux"]) + iso_info[-1]["noise"]) - ) - iso_info[-1]["amplitude2"] = np.abs(coefs[2]) / ( - len(isovals) * (max(0, iso_info[-1]["flux"]) + iso_info[-1]["noise"]) - ) - iso_info[-1]["N"] = len(isovals) - if more: - iso_info[-1]["isovals"] = isovals - iso_info[-1]["angles"] = angles - - # recover lost isophotes just to keep code moving - for i in reversed(range(len(iso_info))): - if iso_info[i] is not None: - good_index = i - break - else: - raise ValueError( - "Unable to recover any isophotes, try on a better band or manually provide values" - ) - for i in range(len(iso_info)): - if iso_info[i] is None: - iso_info[i] = iso_info[good_index] - iso_info[i]["R"] = isophote_radii[i] - return iso_info diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index d48e5e28..f645bdad 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -1,10 +1,4 @@ -from functools import lru_cache - -import numpy as np import torch -from astropy.convolution import convolve_fft - -from .operations import fft_convolve_torch def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): @@ -14,272 +8,6 @@ def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): return prof -def _h_poly(t): - """Helper function to compute the 'h' polynomial matrix used in the - cubic spline. - - Args: - t (Tensor): A 1D tensor representing the normalized x values. - - Returns: - Tensor: A 2D tensor of size (4, len(t)) representing the 'h' polynomial matrix. - - """ - - tt = t[None, :] ** (torch.arange(4, device=t.device)[:, None]) - A = torch.tensor( - [[1, 0, -3, 2], [0, 1, -2, 1], [0, 0, 3, -2], [0, 0, -1, 1]], - dtype=t.dtype, - device=t.device, - ) - return A @ tt - - -def cubic_spline_torch( - x: torch.Tensor, y: torch.Tensor, xs: torch.Tensor, extend: str = "const" -) -> torch.Tensor: - """Compute the 1D cubic spline interpolation for the given data points - using PyTorch. - - Args: - x (Tensor): A 1D tensor representing the x-coordinates of the known data points. - y (Tensor): A 1D tensor representing the y-coordinates of the known data points. - xs (Tensor): A 1D tensor representing the x-coordinates of the positions where - the cubic spline function should be evaluated. - extend (str, optional): The method for handling extrapolation, either "const" or "linear". - Default is "const". - "const": Use the value of the last known data point for extrapolation. - "linear": Use linear extrapolation based on the last two known data points. - - Returns: - Tensor: A 1D tensor representing the interpolated values at the specified positions (xs). - - """ - m = (y[1:] - y[:-1]) / (x[1:] - x[:-1]) - m = torch.cat([m[[0]], (m[1:] + m[:-1]) / 2, m[[-1]]]) - idxs = torch.searchsorted(x[:-1], xs) - 1 - dx = x[idxs + 1] - x[idxs] - hh = _h_poly((xs - x[idxs]) / dx) - ret = hh[0] * y[idxs] + hh[1] * m[idxs] * dx + hh[2] * y[idxs + 1] + hh[3] * m[idxs + 1] * dx - if extend == "const": - ret[xs > x[-1]] = y[-1] - elif extend == "linear": - indices = xs > x[-1] - ret[indices] = y[-1] + (xs[indices] - x[-1]) * (y[-1] - y[-2]) / (x[-1] - x[-2]) - return ret - - -def interpolate_bicubic(img, X, Y): - """ - wrapper for scipy bivariate spline interpolation - """ - f_interp = RectBivariateSpline( - np.arange(dat.shape[0], dtype=np.float32), - np.arange(dat.shape[1], dtype=np.float32), - dat, - ) - return f_interp(Y, X, grid=False) - - -def Lanczos_kernel_np(dx, dy, scale): - """convolution kernel for shifting all pixels in a grid by some - sub-pixel length. - - """ - xx = np.arange(-scale, scale + 1) - dx - if dx < 0: - xx *= -1 - Lx = np.sinc(xx) * np.sinc(xx / scale) - if dx > 0: - Lx[0] = 0 - else: - Lx[-1] = 0 - - yy = np.arange(-scale, scale + 1) - dy - if dy < 0: - yy *= -1 - Ly = np.sinc(yy) * np.sinc(yy / scale) - if dx > 0: - Ly[0] = 0 - else: - Ly[-1] = 0 - - LXX, LYY = np.meshgrid(Lx, Ly, indexing="xy") - LL = LXX * LYY - w = np.sum(LL) - LL /= w - # plt.imshow(LL.detach().numpy(), origin = "lower") - # plt.show() - return LL - - -def Lanczos_kernel(dx, dy, scale): - """Kernel function for Lanczos interpolation, defines the - interpolation behavior between pixels. - - """ - xx = np.arange(-scale + 1, scale + 1) + dx - yy = np.arange(-scale + 1, scale + 1) + dy - Lx = np.sinc(xx) * np.sinc(xx / scale) - Ly = np.sinc(yy) * np.sinc(yy / scale) - LXX, LYY = np.meshgrid(Lx, Ly) - LL = LXX * LYY - w = np.sum(LL) - LL /= w - return LL - - -def point_Lanczos(I, X, Y, scale): - """ - Apply Lanczos interpolation to evaluate a single point. - """ - ranges = [ - [int(np.floor(X) - scale + 1), int(np.floor(X) + scale + 1)], - [int(np.floor(Y) - scale + 1), int(np.floor(Y) + scale + 1)], - ] - LL = Lanczos_kernel(np.floor(X) - X, np.floor(Y) - Y, scale) - LL = LL[ - max(0, -ranges[1][0]) : LL.shape[0] + min(0, I.shape[0] - ranges[1][1]), - max(0, -ranges[0][0]) : LL.shape[1] + min(0, I.shape[1] - ranges[0][1]), - ] - F = I[ - max(0, ranges[1][0]) : min(I.shape[0], ranges[1][1]), - max(0, ranges[0][0]) : min(I.shape[1], ranges[0][1]), - ] - return np.sum(F * LL) - - -def _shift_Lanczos_kernel_torch(dx, dy, scale, dtype, device): - """convolution kernel for shifting all pixels in a grid by some - sub-pixel length. - - """ - xsign = 1 - 2 * (dx < 0).to(dtype=torch.int32) # flips the kernel if the shift is negative - xx = xsign * (torch.arange(int(-scale), int(scale + 1), dtype=dtype, device=device) - dx) - Lx = torch.sinc(xx) * torch.sinc(xx / scale) - - ysign = 1 - 2 * (dy < 0).to(dtype=torch.int32) - yy = ysign * (torch.arange(int(-scale), int(scale + 1), dtype=dtype, device=device) - dy) - Ly = torch.sinc(yy) * torch.sinc(yy / scale) - - LXX, LYY = torch.meshgrid(Lx, Ly, indexing="xy") - LL = LXX * LYY - w = torch.sum(LL) - # plt.imshow(LL.detach().numpy(), origin = "lower") - # plt.show() - return LL / w - - -def shift_Lanczos_torch(I, dx, dy, scale, dtype, device, img_prepadded=True): - """Apply Lanczos interpolation to shift by less than a pixel in x and - y. - - """ - LL = _shift_Lanczos_kernel_torch(dx, dy, scale, dtype, device) - ret = fft_convolve_torch(I, LL, img_prepadded=img_prepadded) - return ret - - -def shift_Lanczos_np(I, dx, dy, scale): - """Apply Lanczos interpolation to shift by less than a pixel in x and - y. - - I: the image - dx: amount by which the grid will be moved in the x-axis (the "data" is fixed and the grid moves). Should be a value from (-0.5,0.5) - dy: amount by which the grid will be moved in the y-axis (the "data" is fixed and the grid moves). Should be a value from (-0.5,0.5) - scale: dictates size of the Lanczos kernel. Full kernel size is 2*scale+1 - """ - LL = Lanczos_kernel_np(dx, dy, scale) - return convolve_fft(I, LL, boundary="fill") - - -def interpolate_Lanczos_grid(img, X, Y, scale): - """ - Perform Lanczos interpolation at a grid of points. - https://pixinsight.com/doc/docs/InterpolationAlgorithms/InterpolationAlgorithms.html - """ - - sinc_X = list( - np.sinc(np.arange(-scale + 1, scale + 1) - X[i] + np.floor(X[i])) - * np.sinc((np.arange(-scale + 1, scale + 1) - X[i] + np.floor(X[i])) / scale) - for i in range(len(X)) - ) - sinc_Y = list( - np.sinc(np.arange(-scale + 1, scale + 1) - Y[i] + np.floor(Y[i])) - * np.sinc((np.arange(-scale + 1, scale + 1) - Y[i] + np.floor(Y[i])) / scale) - for i in range(len(Y)) - ) - - # Extract an image which has the required dimensions - use_img = np.take( - np.take( - img, - np.arange(int(np.floor(Y[0]) - step + 1), int(np.floor(Y[-1]) + step + 1)), - 0, - mode="clip", - ), - np.arange(int(np.floor(X[0]) - step + 1), int(np.floor(X[-1]) + step + 1)), - 1, - mode="clip", - ) - - # Create a sliding window view of the image with the dimensions of the lanczos scale grid - # window = np.lib.stride_tricks.sliding_window_view(use_img, (2*scale, 2*scale)) - - # fixme going to need some broadcasting magic - XX = np.ones((2 * scale, 2 * scale)) - res = np.zeros((len(Y), len(X))) - for x, lowx, highx in zip(range(len(X)), np.floor(X) - step + 1, np.floor(X) + step + 1): - for y, lowy, highy in zip(range(len(Y)), np.floor(Y) - step + 1, np.floor(Y) + step + 1): - L = XX * sinc_X[x] * sinc_Y[y].reshape((sinc_Y[y].size, -1)) - res[y, x] = np.sum(use_img[lowy:highy, lowx:highx] * L) / np.sum(L) - return res - - -def interpolate_Lanczos(img, X, Y, scale): - """ - Perform Lanczos interpolation on an image at a series of specified points. - https://pixinsight.com/doc/docs/InterpolationAlgorithms/InterpolationAlgorithms.html - """ - flux = [] - - for i in range(len(X)): - box = [ - [ - max(0, int(round(np.floor(X[i]) - scale + 1))), - min(img.shape[1], int(round(np.floor(X[i]) + scale + 1))), - ], - [ - max(0, int(round(np.floor(Y[i]) - scale + 1))), - min(img.shape[0], int(round(np.floor(Y[i]) + scale + 1))), - ], - ] - chunk = img[box[1][0] : box[1][1], box[0][0] : box[0][1]] - XX = np.ones(chunk.shape) - Lx = ( - np.sinc(np.arange(-scale + 1, scale + 1) - X[i] + np.floor(X[i])) - * np.sinc((np.arange(-scale + 1, scale + 1) - X[i] + np.floor(X[i])) / scale) - )[ - box[0][0] - - int(round(np.floor(X[i]) - scale + 1)) : 2 * scale - + box[0][1] - - int(round(np.floor(X[i]) + scale + 1)) - ] - Ly = ( - np.sinc(np.arange(-scale + 1, scale + 1) - Y[i] + np.floor(Y[i])) - * np.sinc((np.arange(-scale + 1, scale + 1) - Y[i] + np.floor(Y[i])) / scale) - )[ - box[1][0] - - int(round(np.floor(Y[i]) - scale + 1)) : 2 * scale - + box[1][1] - - int(round(np.floor(Y[i]) + scale + 1)) - ] - L = XX * Lx * Ly.reshape((Ly.size, -1)) - w = np.sum(L) - flux.append(np.sum(chunk * L) / w) - return np.array(flux) - - def interp1d_torch(x_in, y_in, x_out): indices = torch.searchsorted(x_in[:-1], x_out) - 1 weights = (y_in[1:] - y_in[:-1]) / (x_in[1:] - x_in[:-1]) @@ -337,29 +65,3 @@ def interp2d( result = fa * wa + fb * wb + fc * wc + fd * wd return (result * valid).view(*start_shape) - - -@lru_cache(maxsize=32) -def curvature_kernel(dtype, device): - kernel = torch.tensor( - [ - [0.0, 1.0, 0.0], - [1.0, -4, 1.0], - [0.0, 1.0, 0.0], - ], # [[1., -2.0, 1.], [-2.0, 4, -2.0], [1.0, -2.0, 1.0]], - device=device, - dtype=dtype, - ) - return kernel - - -@lru_cache(maxsize=32) -def simpsons_kernel(dtype, device): - kernel = torch.ones(1, 1, 3, 3, dtype=dtype, device=device) - kernel[0, 0, 1, 1] = 16.0 - kernel[0, 0, 1, 0] = 4.0 - kernel[0, 0, 0, 1] = 4.0 - kernel[0, 0, 1, 2] = 4.0 - kernel[0, 0, 2, 1] = 4.0 - kernel = kernel / 36.0 - return kernel diff --git a/astrophot/utils/isophote/__init__.py b/astrophot/utils/isophote/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/astrophot/utils/isophote/ellipse.py b/astrophot/utils/isophote/ellipse.py deleted file mode 100644 index 279ab618..00000000 --- a/astrophot/utils/isophote/ellipse.py +++ /dev/null @@ -1,37 +0,0 @@ -import numpy as np - - -def Rscale_Fmodes(theta, modes, Am, Phim): - """Factor to scale radius values given a set of fourier mode - amplitudes. - - """ - return np.exp(sum(Am[m] * np.cos(modes[m] * (theta + Phim[m])) for m in range(len(modes)))) - - -def parametric_Fmodes(theta, modes, Am, Phim): - """determines a number of scaled radius samples with fourier mode - perturbations for a unit circle. - - """ - x = np.cos(theta) - y = np.sin(theta) - Rscale = Rscale_Fmodes(theta, modes, Am, Phim) - return x * Rscale, y * Rscale - - -def Rscale_SuperEllipse(theta, ellip, C=2): - """Scale factor for radius values given a super ellipse coefficient.""" - res = (1 - ellip) / ( - np.abs((1 - ellip) * np.cos(theta)) ** (C) + np.abs(np.sin(theta)) ** (C) - ) ** (1.0 / C) - return res - - -def parametric_SuperEllipse(theta, ellip, C=2): - """determines a number of scaled radius samples with super ellipse - perturbations for a unit circle. - - """ - rs = Rscale_SuperEllipse(theta, ellip, C) - return rs * np.cos(theta), rs * np.sin(theta) diff --git a/astrophot/utils/isophote/extract.py b/astrophot/utils/isophote/extract.py deleted file mode 100644 index 5dbcf2ee..00000000 --- a/astrophot/utils/isophote/extract.py +++ /dev/null @@ -1,249 +0,0 @@ -import numpy as np -import logging -from scipy.stats import iqr - -from .ellipse import parametric_SuperEllipse, Rscale_SuperEllipse -from ..conversions.coordinates import Rotate_Cartesian_np -from ..interpolate import interpolate_Lanczos - - -def Sigma_Clip_Upper(v, iterations=10, nsigma=5): - """ - Perform sigma clipping on the "v" array. Each iteration involves - computing the median and 16-84 range, these are used to clip beyond - "nsigma" number of sigma above the median. This is repeated for - "iterations" number of iterations, or until convergence if None. - """ - - v2 = np.sort(v) - i = 0 - old_lim = 0 - lim = np.inf - while i < iterations and old_lim != lim: - med = np.median(v2[v2 < lim]) - rng = iqr(v2[v2 < lim], rng=[16, 84]) / 2 - old_lim = lim - lim = med + rng * nsigma - i += 1 - return lim - - -def _iso_between( - IMG, - sma_low, - sma_high, - PARAMS, - c, - more=False, - mask=None, - sigmaclip=False, - sclip_iterations=10, - sclip_nsigma=5, -): - if "m" not in PARAMS: - PARAMS["m"] = None - if "C" not in PARAMS: - PARAMS["C"] = None - Rlim = sma_high * ( - 1.0 - if PARAMS["m"] is None - else np.exp(sum(np.abs(PARAMS["Am"][m]) for m in range(len(PARAMS["m"])))) - ) - ranges = [ - [max(0, int(c["x"] - Rlim - 2)), min(IMG.shape[1], int(c["x"] + Rlim + 2))], - [max(0, int(c["y"] - Rlim - 2)), min(IMG.shape[0], int(c["y"] + Rlim + 2))], - ] - XX, YY = np.meshgrid( - np.arange(ranges[0][1] - ranges[0][0], dtype=float) - c["x"] + float(ranges[0][0]), - np.arange(ranges[1][1] - ranges[1][0], dtype=float) - c["y"] + float(ranges[1][0]), - ) - theta = np.arctan(YY / XX) + np.pi * (XX < 0) - RR = np.sqrt(XX**2 + YY**2) - Fmode_Rscale = ( - 1.0 - if PARAMS["m"] is None - else Rscale_Fmodes(theta - PARAMS["pa"], PARAMS["m"], PARAMS["Am"], PARAMS["Phim"]) - ) - SuperEllipse_Rscale = Rscale_SuperEllipse( - theta - PARAMS["pa"], PARAMS["ellip"], 2 if PARAMS["C"] is None else PARAMS["C"] - ) - RR /= SuperEllipse_Rscale * Fmode_Rscale - rselect = np.logical_and(RR < sma_high, RR > sma_low) - fluxes = IMG[ranges[1][0] : ranges[1][1], ranges[0][0] : ranges[0][1]][rselect] - CHOOSE = None - if mask is not None and sma_high > 5: - CHOOSE = np.logical_not( - mask[ranges[1][0] : ranges[1][1], ranges[0][0] : ranges[0][1]][rselect] - ) - # Perform sigma clipping if requested - if sigmaclip: - sclim = Sigma_Clip_Upper(fluxes, sclip_iterations, sclip_nsigma) - if CHOOSE is None: - CHOOSE = fluxes < sclim - else: - CHOOSE = np.logical_or(CHOOSE, fluxes < sclim) - if CHOOSE is not None and np.sum(CHOOSE) < 5: - logging.warning( - "Entire Isophote is Masked! R_l: %.3f, R_h: %.3f, PA: %.3f, ellip: %.3f" - % (sma_low, sma_high, PARAMS["pa"] * 180 / np.pi, PARAMS["ellip"]) - ) - CHOOSE = np.ones(CHOOSE.shape).astype(bool) - if CHOOSE is not None: - countmasked = np.sum(np.logical_not(CHOOSE)) - else: - countmasked = 0 - if more: - if CHOOSE is not None and sma_high > 5: - return fluxes[CHOOSE], theta[rselect][CHOOSE], countmasked - else: - return fluxes, theta[rselect], countmasked - else: - if CHOOSE is not None and sma_high > 5: - return fluxes[CHOOSE] - else: - return fluxes - - -def _iso_extract( - IMG, - sma, - PARAMS, - c, - more=False, - minN=None, - mask=None, - interp_mask=False, - rad_interp=30, - interp_method="lanczos", - interp_window=5, - sigmaclip=False, - sclip_iterations=10, - sclip_nsigma=5, -): - """ - Internal, basic function for extracting the pixel fluxes along an isophote - """ - if "m" not in PARAMS: - PARAMS["m"] = None - if "C" not in PARAMS: - PARAMS["C"] = None - N = max(15, int(0.9 * 2 * np.pi * sma)) - if minN is not None: - N = max(minN, N) - # points along ellipse to evaluate - theta = np.linspace(0, 2 * np.pi * (1.0 - 1.0 / N), N) - theta = np.arctan(PARAMS["q"] * np.tan(theta)) + np.pi * (np.cos(theta) < 0) - Fmode_Rscale = ( - 1.0 - if PARAMS["m"] is None - else Rscale_Fmodes(theta, PARAMS["m"], PARAMS["Am"], PARAMS["Phim"]) - ) - R = sma * Fmode_Rscale - # Define ellipse - X, Y = parametric_SuperEllipse( - theta, 1.0 - PARAMS["q"], 2 if PARAMS["C"] is None else PARAMS["C"] - ) - X, Y = R * X, R * Y - # rotate ellipse by PA - X, Y = Rotate_Cartesian_np(PARAMS["pa"], X, Y) - theta = (theta + PARAMS["pa"]) % (2 * np.pi) - # shift center - X, Y = X + c["x"], Y + c["y"] - - # Reject samples from outside the image - BORDER = np.logical_and( - np.logical_and(X >= 0, X < (IMG.shape[1] - 1)), - np.logical_and(Y >= 0, Y < (IMG.shape[0] - 1)), - ) - if not np.all(BORDER): - X = X[BORDER] - Y = Y[BORDER] - theta = theta[BORDER] - - Rlim = np.max(R) - if Rlim < rad_interp: - box = [ - [max(0, int(c["x"] - Rlim - 5)), min(IMG.shape[1], int(c["x"] + Rlim + 5))], - [max(0, int(c["y"] - Rlim - 5)), min(IMG.shape[0], int(c["y"] + Rlim + 5))], - ] - if interp_method == "bicubic": - flux = interpolate_bicubic( - IMG[box[1][0] : box[1][1], box[0][0] : box[0][1]], - X - box[0][0], - Y - box[1][0], - ) - elif interp_method == "lanczos": - flux = interpolate_Lanczos(IMG, X, Y, interp_window) - else: - raise ValueError( - "Unknown interpolate method %s. Should be one of lanczos or bicubic" % interp_method - ) - else: - # round to integers and sample pixels values - flux = IMG[np.rint(Y).astype(np.int32), np.rint(X).astype(np.int32)] - # CHOOSE holds boolean array for which flux values to keep, initialized as None for no clipping - CHOOSE = None - # Mask pixels if a mask is given - if mask is not None: - CHOOSE = np.logical_not(mask[np.rint(Y).astype(np.int32), np.rint(X).astype(np.int32)]) - # Perform sigma clipping if requested - if sigmaclip and len(flux) > 30: - sclim = Sigma_Clip_Upper(flux, sclip_iterations, sclip_nsigma) - if CHOOSE is None: - CHOOSE = flux < sclim - else: - CHOOSE = np.logical_or(CHOOSE, flux < sclim) - # Dont clip pixels if that removes all of the pixels - countmasked = 0 - if CHOOSE is not None and np.sum(CHOOSE) <= 0: - logging.warning( - "Entire Isophote was Masked! R: %.3f, PA: %.3f, q: %.3f" - % (sma, PARAMS["pa"] * 180 / np.pi, PARAMS["q"]) - ) - # Interpolate clipped flux values if requested - elif CHOOSE is not None and interp_mask: - flux[np.logical_not(CHOOSE)] = np.interp( - theta[np.logical_not(CHOOSE)], theta[CHOOSE], flux[CHOOSE], period=2 * np.pi - ) - # simply remove all clipped pixels if user doesn't request another option - elif CHOOSE is not None: - flux = flux[CHOOSE] - theta = theta[CHOOSE] - countmasked = np.sum(np.logical_not(CHOOSE)) - - # Return just the flux values, or flux and angle values - if more: - return flux, theta, countmasked - else: - return flux - - -def _iso_line(IMG, length, width, pa, c, more=False): - start = np.array([c["x"], c["y"]]) - end = start + length * np.array([np.cos(pa), np.sin(pa)]) - - ranges = [ - [ - max(0, int(min(start[0], end[0]) - 2)), - min(IMG.shape[1], int(max(start[0], end[0]) + 2)), - ], - [ - max(0, int(min(start[1], end[1]) - 2)), - min(IMG.shape[0], int(max(start[1], end[1]) + 2)), - ], - ] - XX, YY = np.meshgrid( - np.arange(ranges[0][1] - ranges[0][0], dtype=float), - np.arange(ranges[1][1] - ranges[1][0], dtype=float), - ) - XX -= c["x"] - float(ranges[0][0]) - YY -= c["y"] - float(ranges[1][0]) - XX, YY = (XX * np.cos(-pa) - YY * np.sin(-pa), XX * np.sin(-pa) + YY * np.cos(-pa)) - - lselect = np.logical_and.reduce((XX >= -0.5, XX <= length, np.abs(YY) <= (width / 2))) - flux = IMG[ranges[1][0] : ranges[1][1], ranges[0][0] : ranges[0][1]][lselect] - - if more: - return flux, XX[lselect], YY[lselect] - else: - return flux, XX[lselect] diff --git a/astrophot/utils/isophote/integrate.py b/astrophot/utils/isophote/integrate.py deleted file mode 100644 index eb3490b1..00000000 --- a/astrophot/utils/isophote/integrate.py +++ /dev/null @@ -1,210 +0,0 @@ -import numpy as np - - -def fluxdens_to_fluxsum(R, I, axisratio): - """ - Integrate a flux density profile - - R: semi-major axis length (arcsec) - I: flux density (flux/arcsec^2) - axisratio: b/a profile - """ - - S = np.zeros(len(R)) - S[0] = I[0] * np.pi * axisratio[0] * (R[0] ** 2) - for i in range(1, len(R)): - S[i] = trapz(2 * np.pi * I[: i + 1] * R[: i + 1] * axisratio[: i + 1], R[: i + 1]) + S[0] - return S - - -def fluxdens_to_fluxsum_errorprop( - R, I, IE, axisratio, axisratioE=None, N=100, symmetric_error=True -): - """ - Integrate a flux density profile - - R: semi-major axis length (arcsec) - I: flux density (flux/arcsec^2) - axisratio: b/a profile - """ - if axisratioE is None: - axisratioE = np.zeros(len(R)) - - # Create container for the monte-carlo iterations - sum_results = np.zeros((N, len(R))) - 99.999 - I_CHOOSE = np.logical_and(np.isfinite(I), I > 0) - if np.sum(I_CHOOSE) < 5: - return (None, None) if symmetric_error else (None, None, None) - sum_results[0][I_CHOOSE] = fluxdens_to_fluxsum(R[I_CHOOSE], I[I_CHOOSE], axisratio[I_CHOOSE]) - for i in range(1, N): - # Randomly sampled SB profile - tempI = np.random.normal(loc=I, scale=np.abs(IE)) - # Randomly sampled axis ratio profile - tempq = np.clip( - np.random.normal(loc=axisratio, scale=np.abs(axisratioE)), - a_min=1e-3, - a_max=1 - 1e-3, - ) - # Compute COG with sampled data - sum_results[i][I_CHOOSE] = fluxdens_to_fluxsum( - R[I_CHOOSE], tempI[I_CHOOSE], tempq[I_CHOOSE] - ) - - # Condense monte-carlo evaluations into profile and uncertainty envelope - sum_lower = sum_results[0] - np.quantile(sum_results, 0.317310507863 / 2, axis=0) - sum_upper = np.quantile(sum_results, 1.0 - 0.317310507863 / 2, axis=0) - sum_results[0] - - # Return requested uncertainty format - if symmetric_error: - return sum_results[0], np.abs(sum_lower + sum_upper) / 2 - else: - return sum_results[0], sum_lower, sum_upper - - -def _Fmode_integrand(t, parameters): - fsum = sum( - parameters["Am"][m] * np.cos(parameters["m"][m] * (t + parameters["Phim"][m])) - for m in range(len(parameters["m"])) - ) - dfsum = sum( - parameters["m"][m] - * parameters["Am"][m] - * np.sin(parameters["m"][m] * (t + parameters["Phim"][m])) - for m in range(len(parameters["m"])) - ) - return (np.sin(t) ** 2) * np.exp(2 * fsum) + np.sin(t) * np.cos(t) * np.exp(fsum) * dfsum - - -def Fmode_Areas(R, parameters): - A = [] - for i in range(len(R)): - A.append((R[i] ** 2) * quad(_Fmode_integrand, 0, 2 * np.pi, args=(parameters[i],))[0]) - return np.array(A) - - -def Fmode_fluxdens_to_fluxsum(R, I, parameters, A=None): - """ - Integrate a flux density profile, with isophotes including Fourier perturbations. - - Arguments - --------- - R: arcsec - semi-major axis length - - I: flux/arcsec^2 - flux density - - parameters: list of dictionaries - list of dictionary of isophote shape parameters for each radius. - formatted as - - .. code-block:: python - - { - "ellip": "ellipticity", - "m": "list of modes used", - "Am": "list of mode powers", - "Phim": "list of mode phases", - } - - entries for each radius. - """ - if all(parameters[p]["m"] is None for p in range(len(parameters))): - return fluxdens_to_fluxsum( - R, - I, - 1.0 - np.array(list(parameters[p]["ellip"] for p in range(len(parameters)))), - ) - - S = np.zeros(len(R)) - if A is None: - A = Fmode_Areas(R, parameters) - # update the Area calculation to be scaled by the ellipticity - Aq = A * np.array(list((1 - parameters[i]["ellip"]) for i in range(len(R)))) - S[0] = I[0] * Aq[0] - Adiff = np.array([Aq[0]] + list(Aq[1:] - Aq[:-1])) - for i in range(1, len(R)): - S[i] = trapz(I[: i + 1] * Adiff[: i + 1], R[: i + 1]) + S[0] - return S - - -def Fmode_fluxdens_to_fluxsum_errorprop(R, I, IE, parameters, N=100, symmetric_error=True): - """ - Integrate a flux density profile, with isophotes including Fourier perturbations. - - Arguments - --------- - R: arcsec - semi-major axis length - - I: flux/arcsec^2 - flux density - - parameters: list of dictionaries - list of dictionary of isophote shape parameters for each radius. - formatted as - - .. code-block:: python - - { - "ellip": "ellipticity", - "m": "list of modes used", - "Am": "list of mode powers", - "Phim": "list of mode phases", - } - - entries for each radius. - """ - - for i in range(len(R)): - if "ellip err" not in parameters[i]: - parameters[i]["ellip err"] = np.zeros(len(R)) - if all(parameters[p]["m"] is None for p in range(len(parameters))): - return fluxdens_to_fluxsum_errorprop( - R, - I, - IE, - 1.0 - np.array(list(parameters[p]["ellip"] for p in range(len(parameters)))), - np.array(list(parameters[p]["ellip err"] for p in range(len(parameters)))), - N=N, - symmetric_error=symmetric_error, - ) - - # Create container for the monte-carlo iterations - sum_results = np.zeros((N, len(R))) - 99.999 - I_CHOOSE = np.logical_and(np.isfinite(I), I > 0) - if np.sum(I_CHOOSE) < 5: - return (None, None) if symmetric_error else (None, None, None) - cut_parameters = list(compress(parameters, I_CHOOSE)) - A = Fmode_Areas(R[I_CHOOSE], cut_parameters) - sum_results[0][I_CHOOSE] = Fmode_fluxdens_to_fluxsum( - R[I_CHOOSE], I[I_CHOOSE], cut_parameters, A - ) - for i in range(1, N): - # Randomly sampled SB profile - tempI = np.random.normal(loc=I, scale=np.abs(IE)) - # Randomly sampled axis ratio profile - temp_parameters = deepcopy(cut_parameters) - for p in range(len(cut_parameters)): - temp_parameters[p]["ellip"] = np.clip( - np.random.normal( - loc=cut_parameters[p]["ellip"], - scale=np.abs(cut_parameters[p]["ellip err"]), - ), - a_min=1e-3, - a_max=1 - 1e-3, - ) - # Compute COG with sampled data - sum_results[i][I_CHOOSE] = Fmode_fluxdens_to_fluxsum( - R[I_CHOOSE], tempI[I_CHOOSE], temp_parameters, A - ) - - # Condense monte-carlo evaluations into profile and uncertainty envelope - sum_lower = sum_results[0] - np.quantile(sum_results, 0.317310507863 / 2, axis=0) - sum_upper = np.quantile(sum_results, 1.0 - 0.317310507863 / 2, axis=0) - sum_results[0] - - # Return requested uncertainty format - if symmetric_error: - return sum_results[0], np.abs(sum_lower + sum_upper) / 2 - else: - return sum_results[0], sum_lower, sum_upper diff --git a/astrophot/utils/operations.py b/astrophot/utils/operations.py deleted file mode 100644 index 9f403726..00000000 --- a/astrophot/utils/operations.py +++ /dev/null @@ -1,247 +0,0 @@ -from functools import lru_cache - -import torch -from scipy.fft import next_fast_len -from scipy.special import roots_legendre -import numpy as np - - -def fft_convolve_torch(img, psf, psf_fft=False, img_prepadded=False): - # Ensure everything is tensor - img = torch.as_tensor(img) - psf = torch.as_tensor(psf) - - if img_prepadded: - s = img.size() - else: - s = tuple( - next_fast_len(int(d + (p + 1) / 2), real=True) for d, p in zip(img.size(), psf.size()) - ) # list(int(d + (p + 1) / 2) for d, p in zip(img.size(), psf.size())) - - img_f = torch.fft.rfft2(img, s=s) - - if not psf_fft: - psf_f = torch.fft.rfft2(psf, s=s) - else: - psf_f = psf - - conv_f = img_f * psf_f - conv = torch.fft.irfft2(conv_f, s=s) - - # Roll the tensor to correct centering and crop to original image size - return torch.roll( - conv, - shifts=(-int((psf.size()[0] - 1) / 2), -int((psf.size()[1] - 1) / 2)), - dims=(0, 1), - )[: img.size()[0], : img.size()[1]] - - -def fft_convolve_multi_torch( - img, kernels, kernel_fft=False, img_prepadded=False, dtype=None, device=None -): - # Ensure everything is tensor - img = torch.as_tensor(img, dtype=dtype, device=device) - for k in range(len(kernels)): - kernels[k] = torch.as_tensor(kernels[k], dtype=dtype, device=device) - - if img_prepadded: - s = img.size() - else: - s = list(int(d + (p + 1) / 2) for d, p in zip(img.size(), kernels[0].size())) - - img_f = torch.fft.rfft2(img, s=s) - - if not kernel_fft: - kernels_f = list(torch.fft.rfft2(kernel, s=s) for kernel in kernels) - else: - psf_f = psf - - conv_f = img_f - - for kernel_f in kernels_f: - conv_f *= kernel_f - - conv = torch.fft.irfft2(conv_f, s=s) - - # Roll the tensor to correct centering and crop to original image size - return torch.roll( - conv, - shifts=( - -int((sum(kernel.size()[0] for kernel in kernels) - 1) / 2), - -int((sum(kernel.size()[1] for kernel in kernels) - 1) / 2), - ), - dims=(0, 1), - )[: img.size()[0], : img.size()[1]] - - -def axis_ratio_com(data, PA, X=None, Y=None, mask=None): - """get center of mass like quantity for axis ratio""" - if X is None: - S = data.shape - X, Y = np.meshgrid(np.arange(S[1]) - S[1] / 2, np.arange(S[0]) - S[0] / 2, indexing="xy") - if mask is None: - mask = np.zeros_like(data, dtype=bool) - mask = np.logical_not(mask) - - theta = np.arctan2(Y, X) - PA - theta = theta[mask] - data = data[mask] - ang_com_cos = np.sum(data * np.cos(theta) ** 2) / np.sum(data) - ang_com_sin = np.sum(data * np.sin(theta) ** 2) / np.sum(data) - return ang_com_sin / max(ang_com_sin, ang_com_cos) - - -def displacement_spacing(N, dtype=torch.float64, device="cpu"): - return torch.linspace(-(N - 1) / (2 * N), (N - 1) / (2 * N), N, dtype=dtype, device=device) - - -def displacement_grid(Nx, Ny, pixelscale=None, dtype=torch.float64, device="cpu"): - px = displacement_spacing(Nx, dtype=dtype, device=device) - py = displacement_spacing(Ny, dtype=dtype, device=device) - PX, PY = torch.meshgrid(px, py, indexing="xy") - return (pixelscale @ torch.stack((PX, PY)).view(2, -1)).reshape((2, *PX.shape)) - - -@lru_cache(maxsize=32) -def quad_table(n, p, dtype, device): - """ - from: https://pomax.github.io/bezierinfo/legendre-gauss.html - """ - abscissa, weights = roots_legendre(n) - - w = torch.tensor(weights, dtype=dtype, device=device) - a = torch.tensor(abscissa, dtype=dtype, device=device) - X, Y = torch.meshgrid(a, a, indexing="xy") - - W = torch.outer(w, w) / 4.0 - - X, Y = p @ (torch.stack((X, Y)).view(2, -1) / 2.0) - - return X, Y, W.reshape(-1) - - -def single_quad_integrate( - X, Y, image_header, eval_brightness, eval_parameters, dtype, device, quad_level=3 -): - - # collect gaussian quadrature weights - abscissaX, abscissaY, weight = quad_table(quad_level, image_header.pixelscale, dtype, device) - # Specify coordinates at which to evaluate function - Xs = torch.repeat_interleave(X[..., None], quad_level**2, -1) + abscissaX - Ys = torch.repeat_interleave(Y[..., None], quad_level**2, -1) + abscissaY - - # Evaluate the model at the quadrature points - res = eval_brightness( - X=Xs, - Y=Ys, - image=image_header, - parameters=eval_parameters, - ) - - # Reference flux for pixel is simply the mean of the evaluations - ref = res[..., (quad_level**2) // 2] # res.mean(axis=-1) # # alternative, use midpoint - - # Apply the weights and reduce to original pixel space - res = (res * weight).sum(axis=-1) - - return res, ref - - -def grid_integrate( - X, - Y, - image_header, - eval_brightness, - eval_parameters, - dtype, - device, - quad_level=3, - gridding=5, - _current_depth=1, - max_depth=2, - reference=None, -): - """The grid_integrate function performs adaptive quadrature - integration over a given pixel grid, offering precision control - where it is needed most. - - Args: - X (torch.Tensor): A 2D tensor representing the x-coordinates of the grid on which the function will be integrated. - Y (torch.Tensor): A 2D tensor representing the y-coordinates of the grid on which the function will be integrated. - image_header (ImageHeader): An object containing meta-information about the image. - eval_brightness (callable): A function that evaluates the brightness at each grid point. This function should be compatible with PyTorch tensor operations. - eval_parameters (Parameter_Group): An object containing parameters that are passed to the eval_brightness function. - dtype (torch.dtype): The data type of the output tensor. The dtype argument should be a valid PyTorch data type. - device (torch.device): The device on which to perform the computations. The device argument should be a valid PyTorch device. - quad_level (int, optional): The initial level of quadrature used in the integration. Defaults to 3. - gridding (int, optional): The factor by which the grid is subdivided when the integration error for a pixel is above the allowed threshold. Defaults to 5. - _current_depth (int, optional): The current depth level of the grid subdivision. Used for recursive calls to the function. Defaults to 1. - max_depth (int, optional): The maximum depth level of grid subdivision. Once this level is reached, no further subdivision is performed. Defaults to 2. - reference (torch.Tensor or None, optional): A scalar value that represents the allowed threshold for the integration error. - - Returns: - torch.Tensor: A tensor of the same shape as X and Y that represents the result of the integration on the grid. - - This function operates by first performing a quadrature - integration over the given pixels. If the maximum depth level has - been reached, it simply returns the result. Otherwise, it - calculates the integration error for each pixel and selects those - that have an error above the allowed threshold. For pixels that - have low error, the result is set as computed. For those with high - error, it sets up a finer sampling grid and recursively evaluates - the quadrature integration on it. Finally, it integrates the - results from the finer sampling grid back to the current - resolution. - - """ - # perform quadrature integration on the given pixels - res, ref = single_quad_integrate( - X, - Y, - image_header, - eval_brightness, - eval_parameters, - dtype, - device, - quad_level=quad_level, - ) - - # if the max depth is reached, simply return the integrated pixels - if _current_depth >= max_depth: - return res - - # Begin integral - integral = torch.zeros_like(X) - - # Select pixels which have errors above the allowed threshold - select = torch.abs((res - ref)) > reference - - # For pixels with low error, set the results as computed - integral[torch.logical_not(select)] = res[torch.logical_not(select)] - - # Set up sub-gridding to super resolve problem pixels - stepx, stepy = displacement_grid(gridding, gridding, image_header.pixelscale, dtype, device) - # Write out the coordinates for the super resolved pixels - subgridX = torch.repeat_interleave(X[select].unsqueeze(-1), gridding**2, -1) + stepx.reshape(-1) - subgridY = torch.repeat_interleave(Y[select].unsqueeze(-1), gridding**2, -1) + stepy.reshape(-1) - - # Recursively evaluate the quadrature integration on the finer sampling grid - subgridres = grid_integrate( - subgridX, - subgridY, - image_header.rescale_pixel(1 / gridding), - eval_brightness, - eval_parameters, - dtype, - device, - quad_level=quad_level, - gridding=gridding, - _current_depth=_current_depth + 1, - max_depth=max_depth, - reference=reference * gridding**2, - ) - - # Integrate the finer sampling grid back to current resolution - integral[select] = subgridres.sum(axis=(-1,)) - - return integral diff --git a/astrophot/utils/parametric_profiles.py b/astrophot/utils/parametric_profiles.py index bce0d7a5..5593b904 100644 --- a/astrophot/utils/parametric_profiles.py +++ b/astrophot/utils/parametric_profiles.py @@ -1,21 +1,5 @@ -import torch import numpy as np from .conversions.functions import sersic_n_to_b -from .interpolate import cubic_spline_torch - - -def sersic_torch(R, n, Re, Ie): - """Seric 1d profile function, specifically designed for pytorch - operations - - Parameters: - R: Radii tensor at which to evaluate the sersic function - n: sersic index restricted to n > 0.36 - Re: Effective radius in the same units as R - Ie: Effective surface density - """ - bn = sersic_n_to_b(n) - return Ie * torch.exp(-bn * (torch.pow(R / Re, 1 / n) - 1)) def sersic_np(R, n, Re, Ie): @@ -36,18 +20,6 @@ def sersic_np(R, n, Re, Ie): return Ie * np.exp(-bn * ((R / Re) ** (1 / n) - 1)) -def gaussian_torch(R, sigma, I0): - """Gaussian 1d profile function, specifically designed for pytorch - operations. - - Parameters: - R: Radii tensor at which to evaluate the sersic function - sigma: standard deviation of the gaussian in the same units as R - I0: central surface density - """ - return (I0 / torch.sqrt(2 * np.pi * sigma**2)) * torch.exp(-0.5 * torch.pow(R / sigma, 2)) - - def gaussian_np(R, sigma, I0): """Gaussian 1d profile function, works more generally with numpy operations. @@ -60,20 +32,6 @@ def gaussian_np(R, sigma, I0): return (I0 / np.sqrt(2 * np.pi * sigma**2)) * np.exp(-0.5 * ((R / sigma) ** 2)) -def exponential_torch(R, Re, Ie): - """Exponential 1d profile function, specifically designed for pytorch - operations. - - Parameters: - R: Radii tensor at which to evaluate the sersic function - Re: Effective radius in the same units as R - Ie: Effective surface density - """ - return Ie * torch.exp( - -sersic_n_to_b(torch.tensor(1.0, dtype=R.dtype, device=R.device)) * ((R / Re) - 1.0) - ) - - def exponential_np(R, Ie, Re): """Exponential 1d profile function, works more generally with numpy operations. @@ -86,20 +44,6 @@ def exponential_np(R, Ie, Re): return Ie * np.exp(-sersic_n_to_b(1.0) * (R / Re - 1.0)) -def moffat_torch(R, n, Rd, I0): - """Moffat 1d profile function, specifically designed for pytorch - operations - - Parameters: - R: Radii tensor at which to evaluate the moffat function - n: concentration index - Rd: scale length in the same units as R - I0: central surface density - - """ - return I0 / (1 + (R / Rd) ** 2) ** n - - def moffat_np(R, n, Rd, I0): """Moffat 1d profile function, works with numpy operations. @@ -113,27 +57,6 @@ def moffat_np(R, n, Rd, I0): return I0 / (1 + (R / Rd) ** 2) ** n -def nuker_torch(R, Rb, Ib, alpha, beta, gamma): - """Nuker 1d profile function, specifically designed for pytorch - operations - - Parameters: - R: Radii tensor at which to evaluate the nuker function - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - return ( - Ib - * (2 ** ((beta - gamma) / alpha)) - * ((R / Rb) ** (-gamma)) - * ((1 + (R / Rb) ** alpha) ** ((gamma - beta) / alpha)) - ) - - def nuker_np(R, Rb, Ib, alpha, beta, gamma): """Nuker 1d profile function, works with numpy functions @@ -152,26 +75,3 @@ def nuker_np(R, Rb, Ib, alpha, beta, gamma): * ((R / Rb) ** (-gamma)) * ((1 + (R / Rb) ** alpha) ** ((gamma - beta) / alpha)) ) - - -def spline_torch(R, profR, profI, extend): - """Spline 1d profile function, cubic spline between points up - to second last point beyond which is linear, specifically designed - for pytorch. - - Parameters: - R: Radii tensor at which to evaluate the sersic function - profR: radius values for the surface density profile in the same units as R - profI: surface density values for the surface density profile - """ - I = cubic_spline_torch(profR, profI, R.view(-1), extend="none").view(*R.shape) - res = torch.zeros_like(I) - res[R <= profR[-1]] = 10 ** (I[R <= profR[-1]]) - if extend: - res[R > profR[-1]] = 10 ** ( - profI[-2] - + (R[R > profR[-1]] - profR[-2]) * ((profI[-1] - profI[-2]) / (profR[-1] - profR[-2])) - ) - else: - res[R > profR[-1]] = 0 - return res diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 890e53aa..1bb7efe3 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -248,7 +248,7 @@ "model3 = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " window=[555, 665, 480, 595], # this is a region in pixel coordinates ((xmin,xmax),(ymin,ymax))\n", + " window=[555, 665, 480, 595], # this is a region in pixel coordinates (xmin,xmax,ymin,ymax)\n", ")\n", "\n", "print(f\"automatically generated name: '{model3.name}'\")\n", @@ -309,6 +309,7 @@ " q={\"valid\": (0.4, 0.6)},\n", " n={\"valid\": (2, 3)},\n", " PA={\"value\": 60 * np.pi / 180},\n", + " target=target,\n", ")" ] }, @@ -326,9 +327,11 @@ "outputs": [], "source": [ "# model 1 is a sersic model\n", - "model_1 = ap.models.Model(model_type=\"sersic galaxy model\", center=[50, 50], PA=np.pi / 4)\n", + "model_1 = ap.models.Model(\n", + " model_type=\"sersic galaxy model\", center=[50, 50], PA=np.pi / 4, target=target\n", + ")\n", "# model 2 is an exponential model\n", - "model_2 = ap.models.Model(model_type=\"exponential galaxy model\")\n", + "model_2 = ap.models.Model(model_type=\"exponential galaxy model\", target=target)\n", "\n", "# Here we add the constraint for \"PA\" to be the same for each model.\n", "# In doing so we provide the model and parameter name which should\n", From fcdf36ec07146c6836499ffe9d28e6aca4fb24da Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 8 Jul 2025 15:46:51 -0400 Subject: [PATCH 049/191] log space definitions for core models --- astrophot/image/image_object.py | 29 ++-- astrophot/image/mixins/data_mixin.py | 1 + astrophot/image/psf_image.py | 3 +- astrophot/image/target_image.py | 1 + astrophot/models/__init__.py | 2 + astrophot/models/bilinear_sky.py | 92 ++++++++++++ astrophot/models/func/modified_ferrer.py | 2 +- astrophot/models/mixins/empirical_king.py | 36 ++++- astrophot/models/mixins/exponential.py | 38 ++++- astrophot/models/mixins/gaussian.py | 36 ++++- astrophot/models/mixins/modified_ferrer.py | 34 ++++- astrophot/models/mixins/moffat.py | 36 ++++- astrophot/models/mixins/nuker.py | 34 ++++- astrophot/models/mixins/sersic.py | 8 ++ astrophot/models/mixins/spline.py | 27 +++- astrophot/models/model_object.py | 7 +- astrophot/models/planesky.py | 1 - astrophot/models/sky_model_object.py | 3 + astrophot/utils/conversions/units.py | 1 + docs/source/tutorials/GettingStarted.ipynb | 30 +++- docs/source/tutorials/JointModels.ipynb | 75 +++------- docs/source/tutorials/ModelZoo.ipynb | 154 ++++++++++++++++++--- 22 files changed, 514 insertions(+), 136 deletions(-) create mode 100644 astrophot/models/bilinear_sky.py diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 3663c7cf..3ab30ad0 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -7,7 +7,7 @@ from ..param import Module, Param, forward from .. import AP_config -from ..utils.conversions.units import deg_to_arcsec +from ..utils.conversions.units import deg_to_arcsec, arcsec_to_deg from .window import Window, WindowList from ..errors import InvalidImage from . import func @@ -82,6 +82,7 @@ def __init__( dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) + self.zeropoint = zeropoint if filename is not None: self.load(filename) @@ -120,8 +121,6 @@ def __init__( pixelscale = np.array([[pixelscale, 0.0], [0.0, pixelscale]], dtype=np.float64) self.pixelscale = pixelscale - self.zeropoint = zeropoint - @property def data(self): """The image data, which is a tensor of pixel values.""" @@ -344,14 +343,14 @@ def fits_info(self): "CTYPE2": "DEC--TAN", "CRVAL1": self.crval.value[0].item(), "CRVAL2": self.crval.value[1].item(), - "CRPIX1": self.crpix[0], - "CRPIX2": self.crpix[1], + "CRPIX1": self.crpix[0] + 1, + "CRPIX2": self.crpix[1] + 1, "CRTAN1": self.crtan.value[0].item(), "CRTAN2": self.crtan.value[1].item(), - "CD1_1": self.pixelscale.value[0][0].item(), - "CD1_2": self.pixelscale.value[0][1].item(), - "CD2_1": self.pixelscale.value[1][0].item(), - "CD2_2": self.pixelscale.value[1][1].item(), + "CD1_1": self.pixelscale.value[0][0].item() * arcsec_to_deg, + "CD1_2": self.pixelscale.value[0][1].item() * arcsec_to_deg, + "CD2_1": self.pixelscale.value[1][0].item() * arcsec_to_deg, + "CD2_2": self.pixelscale.value[1][1].item() * arcsec_to_deg, "MAGZP": self.zeropoint.item() if self.zeropoint is not None else -999, "IDNTY": self.identity, } @@ -384,10 +383,16 @@ def load(self, filename: str): hdulist = fits.open(filename) self.data = np.array(hdulist[0].data, dtype=np.float64) self.pixelscale = ( - (hdulist[0].header["CD1_1"], hdulist[0].header["CD1_2"]), - (hdulist[0].header["CD2_1"], hdulist[0].header["CD2_2"]), + np.array( + ( + (hdulist[0].header["CD1_1"], hdulist[0].header["CD1_2"]), + (hdulist[0].header["CD2_1"], hdulist[0].header["CD2_2"]), + ), + dtype=np.float64, + ) + * deg_to_arcsec ) - self.crpix = (hdulist[0].header["CRPIX1"], hdulist[0].header["CRPIX2"]) + self.crpix = (hdulist[0].header["CRPIX1"] - 1, hdulist[0].header["CRPIX2"] - 1) self.crval = (hdulist[0].header["CRVAL1"], hdulist[0].header["CRVAL2"]) if "CRTAN1" in hdulist[0].header and "CRTAN2" in hdulist[0].header: self.crtan = (hdulist[0].header["CRTAN1"], hdulist[0].header["CRTAN2"]) diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index f966a7f3..fbb25cfe 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -277,6 +277,7 @@ def load(self, filename: str): self.weight = np.array(hdulist["WEIGHT"].data, dtype=np.float64) if "MASK" in hdulist: self.mask = np.array(hdulist["MASK"].data, dtype=bool) + return hdulist def reduce(self, scale, **kwargs): """Returns a new `Target_Image` object with a reduced resolution diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 82ae79ac..4b6f5770 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -32,10 +32,9 @@ class PSFImage(DataMixin, Image): """ def __init__(self, *args, **kwargs): - kwargs.update({"crpix": (0, 0), "crtan": (0, 0)}) + kwargs.update({"crval": (0, 0), "crpix": (0, 0), "crtan": (0, 0)}) super().__init__(*args, **kwargs) self.crpix = (np.array(self.data.shape, dtype=float) - 1.0) / 2 - del self.crval def normalize(self): """Normalizes the PSF image to have a sum of 1.""" diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 48a4ad3f..1b1d956d 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -194,6 +194,7 @@ def load(self, filename: str): (hdulist["PSF"].header["CD2_1"], hdulist["PSF"].header["CD2_2"]), ), ) + return hdulist def jacobian_image( self, diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 847209cf..627ff069 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -21,6 +21,7 @@ # Subtypes of SkyModel from .flatsky import FlatSky from .planesky import PlaneSky +from .bilinear_sky import BilinearSky # Special galaxy types from .edgeon import EdgeonModel, EdgeonSech, EdgeonIsothermal @@ -121,6 +122,7 @@ "PixelatedPSF", "FlatSky", "PlaneSky", + "BilinearSky", "EdgeonModel", "EdgeonSech", "EdgeonIsothermal", diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py new file mode 100644 index 00000000..c428c866 --- /dev/null +++ b/astrophot/models/bilinear_sky.py @@ -0,0 +1,92 @@ +import numpy as np +import torch + +from .sky_model_object import SkyModel +from ..utils.decorators import ignore_numpy_warnings +from ..utils.interpolate import interp2d +from ..param import forward +from .. import AP_config + +__all__ = ["BilinearSky"] + + +class BilinearSky(SkyModel): + """Sky background model using a coarse bilinear grid for the sky flux. + + Parameters: + I: sky brightness grid + + """ + + _model_type = "bilinear" + _parameter_specs = { + "I": {"units": "flux/arcsec^2"}, + } + sampling_mode = "midpoint" + usable = True + + def __init__(self, *args, nodes=(3, 3), **kwargs): + """Initialize the BilinearSky model with a grid of nodes.""" + super().__init__(*args, **kwargs) + self.nodes = nodes + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if self.I.initialized: + self.nodes = tuple(self.I.value.shape) + self.update_transform() + return + + target_dat = self.target[self.window] + dat = target_dat.data.detach().cpu().numpy().copy() + if self.target.has_mask: + mask = target_dat.mask.detach().cpu().numpy().copy() + dat[mask] = np.nanmedian(dat) + iS = dat.shape[0] // self.nodes[0] + jS = dat.shape[1] // self.nodes[1] + + self.I.dynamic_value = ( + np.median( + dat[: iS * self.nodes[0], : jS * self.nodes[1]].reshape( + iS, self.nodes[0], jS, self.nodes[1] + ), + axis=(0, 2), + ) + / self.target.pixel_area.item() + ) + self.update_transform() + + def update_transform(self): + target_dat = self.target[self.window] + P = torch.stack(list(torch.stack(c) for c in target_dat.corners())) + centroid = P.mean(dim=0) + dP = P - centroid + evec = torch.linalg.eig(dP.T @ dP / 4)[1].real.to( + dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + if torch.dot(evec[0], P[3] - P[0]).abs() < torch.dot(evec[1], P[3] - P[0]).abs(): + evec = evec.flip(0) + evec[0] = evec[0] * self.nodes[0] / torch.linalg.norm(P[3] - P[0]) + evec[1] = evec[1] * self.nodes[1] / torch.linalg.norm(P[1] - P[0]) + self.evec = evec + self.shift = torch.tensor( + [(self.nodes[0] - 1) / 2, (self.nodes[1] - 1) / 2], + dtype=AP_config.ap_dtype, + device=AP_config.ap_device, + ) + + @forward + def transform_coordinates(self, x, y): + x, y = super().transform_coordinates(x, y) + xy = torch.stack((x, y), dim=-1) + xy = xy @ self.evec + xy = xy + self.shift + return xy[..., 0], xy[..., 1] + + @forward + def brightness(self, x, y, I): + x, y = self.transform_coordinates(x, y) + return interp2d(I, x, y) diff --git a/astrophot/models/func/modified_ferrer.py b/astrophot/models/func/modified_ferrer.py index fbe0327b..c4ca6b4b 100644 --- a/astrophot/models/func/modified_ferrer.py +++ b/astrophot/models/func/modified_ferrer.py @@ -20,4 +20,4 @@ def modified_ferrer(R, rout, alpha, beta, I0): array_like The modified Ferrer profile evaluated at R. """ - return (I0 * (1 + (R / rout) ** alpha) ** (2 - beta)) * (R < rout) + return I0 * ((1 - (R / rout) ** (2 - beta)) ** alpha) * (R < rout) diff --git a/astrophot/models/mixins/empirical_king.py b/astrophot/models/mixins/empirical_king.py index 5fb08b2a..398c3f78 100644 --- a/astrophot/models/mixins/empirical_king.py +++ b/astrophot/models/mixins/empirical_king.py @@ -7,7 +7,7 @@ def x0_func(model_params, R, F): - return R[2], R[5], 2, 10 ** F[0] + return R[2], R[5], 2, F[0] class EmpiricalKingMixin: @@ -19,17 +19,29 @@ class EmpiricalKingMixin: "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, "I0": {"units": "flux/arcsec^2", "shape": ()}, } + _overload_parameter_specs = { + "logI0": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "I0", + "overload_function": lambda p: 10**p.logI0.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logI0"): + return + parametric_initialize( self, self.target[self.window], - func.empirical_king, - ("Rc", "Rt", "alpha", "I0"), + lambda r, *x: func.empirical_king(r, x[0], x[1], x[2], 10 ** x[3]), + ("Rc", "Rt", "alpha", "logI0"), x0_func, ) @@ -44,20 +56,32 @@ class iEmpiricalKingMixin: _parameter_specs = { "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, "I0": {"units": "flux/arcsec^2", "shape": ()}, } + _overload_parameter_specs = { + "logI0": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "I0", + "overload_function": lambda p: 10**p.logI0.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logI0"): + return + parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=func.empirical_king, - params=("Rc", "Rt", "alpha", "I0"), + prof_func=lambda r, *x: func.empirical_king(r, x[0], x[1], x[2], 10 ** x[3]), + params=("Rc", "Rt", "alpha", "logI0"), x0_func=x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 911086a0..c1ca4350 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], 10 ** F[4] + return R[4], F[4] class ExponentialMixin: @@ -31,14 +31,30 @@ class ExponentialMixin: "Re": {"units": "arcsec", "valid": (0, None)}, "Ie": {"units": "flux/arcsec^2"}, } + _overload_parameter_specs = { + "logIe": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "Ie", + "overload_function": lambda p: 10**p.logIe.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logIe"): + return + parametric_initialize( - self, self.target[self.window], exponential_np, ("Re", "Ie"), _x0_func + self, + self.target[self.window], + lambda r, *x: exponential_np(r, x[0], 10 ** x[1]), + ("Re", "logIe"), + _x0_func, ) @forward @@ -66,17 +82,29 @@ class iExponentialMixin: "Re": {"units": "arcsec", "valid": (0, None)}, "Ie": {"units": "flux/arcsec^2"}, } + _overload_parameter_specs = { + "logIe": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "Ie", + "overload_function": lambda p: 10**p.logIe.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logIe"): + return + parametric_segment_initialize( model=self, - target=self.target, - prof_func=exponential_np, - params=("Re", "Ie"), + target=self.target[self.window], + prof_func=lambda r, *x: exponential_np(r, x[0], 10 ** x[1]), + params=("Re", "logIe"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 8f2fd77c..b02b6f80 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], 10 ** F[0] + return R[4], F[0] class GaussianMixin: @@ -18,14 +18,30 @@ class GaussianMixin: "sigma": {"units": "arcsec", "valid": (0, None), "shape": ()}, "flux": {"units": "flux", "shape": ()}, } + _overload_parameter_specs = { + "logflux": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "flux", + "overload_function": lambda p: 10**p.logflux.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logflux"): + return + parametric_initialize( - self, self.target[self.window], gaussian_np, ("sigma", "flux"), _x0_func + self, + self.target[self.window], + lambda r, *x: gaussian_np(r, x[0], 10 ** x[1]), + ("sigma", "logflux"), + _x0_func, ) @forward @@ -40,17 +56,29 @@ class iGaussianMixin: "sigma": {"units": "arcsec", "valid": (0, None)}, "flux": {"units": "flux"}, } + _overload_parameter_specs = { + "logflux": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "flux", + "overload_function": lambda p: 10**p.logflux.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logflux"): + return + parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=gaussian_np, - params=("sigma", "flux"), + prof_func=lambda r, *x: gaussian_np(r, x[0], 10 ** x[1]), + params=("sigma", "logflux"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/modified_ferrer.py b/astrophot/models/mixins/modified_ferrer.py index 6edc44b5..37996385 100644 --- a/astrophot/models/mixins/modified_ferrer.py +++ b/astrophot/models/mixins/modified_ferrer.py @@ -7,7 +7,7 @@ def x0_func(model_params, R, F): - return R[5], 1, 1, 10 ** F[0] + return R[5], 1, 1, F[0] class ModifiedFerrerMixin: @@ -19,17 +19,29 @@ class ModifiedFerrerMixin: "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, "I0": {"units": "flux/arcsec^2", "shape": ()}, } + _overload_parameter_specs = { + "logI0": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "I0", + "overload_function": lambda p: 10**p.logI0.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logI0"): + return + parametric_initialize( self, self.target[self.window], - func.modified_ferrer, - ("rout", "alpha", "beta", "I0"), + lambda r, *x: func.modified_ferrer(r, x[0], x[1], x[2], 10 ** x[3]), + ("rout", "alpha", "beta", "logI0"), x0_func, ) @@ -47,17 +59,29 @@ class iModifiedFerrerMixin: "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, "I0": {"units": "flux/arcsec^2", "shape": ()}, } + _overload_parameter_specs = { + "logI0": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "I0", + "overload_function": lambda p: 10**p.logI0.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logI0"): + return + parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=func.modified_ferrer, - params=("rout", "alpha", "beta", "I0"), + prof_func=lambda r, *x: func.modified_ferrer(r, x[0], x[1], x[2], 10 ** x[3]), + params=("rout", "alpha", "beta", "logI0"), x0_func=x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index df83fa97..6ab54d80 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return 2.0, R[4], 10 ** F[0] + return 2.0, R[4], F[0] class MoffatMixin: @@ -19,14 +19,30 @@ class MoffatMixin: "Rd": {"units": "arcsec", "valid": (0, None), "shape": ()}, "I0": {"units": "flux/arcsec^2", "shape": ()}, } + _overload_parameter_specs = { + "logI0": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "I0", + "overload_function": lambda p: 10**p.logI0.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logI0"): + return + parametric_initialize( - self, self.target[self.window], moffat_np, ("n", "Rd", "I0"), _x0_func + self, + self.target[self.window], + lambda r, *x: moffat_np(r, x[0], x[1], 10 ** x[2]), + ("n", "Rd", "logI0"), + _x0_func, ) @forward @@ -42,17 +58,29 @@ class iMoffatMixin: "Rd": {"units": "arcsec", "valid": (0, None)}, "I0": {"units": "flux/arcsec^2"}, } + _overload_parameter_specs = { + "logI0": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "I0", + "overload_function": lambda p: 10**p.logI0.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logI0"): + return + parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=moffat_np, - params=("n", "Rd", "I0"), + prof_func=lambda r, *x: moffat_np(r, x[0], x[1], 10 ** x[2]), + params=("n", "Rd", "logI0"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 5a269a93..51d89dfc 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], 10 ** F[4], 1.0, 2.0, 0.5 + return R[4], F[4], 1.0, 2.0, 0.5 class NukerMixin: @@ -21,17 +21,29 @@ class NukerMixin: "beta": {"units": "none", "valid": (0, None), "shape": ()}, "gamma": {"units": "none", "shape": ()}, } + _overload_parameter_specs = { + "logIb": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "Ib", + "overload_function": lambda p: 10**p.logIb.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logIb"): + return + parametric_initialize( self, self.target[self.window], - nuker_np, - ("Rb", "Ib", "alpha", "beta", "gamma"), + lambda r, *x: nuker_np(r, x[0], 10 ** x[1], x[2], x[3], x[4]), + ("Rb", "logIb", "alpha", "beta", "gamma"), _x0_func, ) @@ -50,17 +62,29 @@ class iNukerMixin: "beta": {"units": "none", "valid": (0, None)}, "gamma": {"units": "none"}, } + _overload_parameter_specs = { + "logIb": { + "units": "log10(flux/arcsec^2)", + "shape": (), + "overloads": "Ib", + "overload_function": lambda p: 10**p.logIb.value, + } + } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logIb"): + return + parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=nuker_np, - params=("Rb", "Ib", "alpha", "beta", "gamma"), + prof_func=lambda r, *x: nuker_np(r, x[0], 10 ** x[1], x[2], x[3], x[4]), + params=("Rb", "logIb", "alpha", "beta", "gamma"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index 02fae43e..78d9d234 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -43,6 +43,10 @@ class SersicMixin: def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logIe"): + return + parametric_initialize( self, self.target[self.window], @@ -88,6 +92,10 @@ class iSersicMixin: def initialize(self): super().initialize() + # Only auto initialize for standard parametrization + if not hasattr(self, "logIe"): + return + parametric_segment_initialize( model=self, target=self.target[self.window], diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 7f8cf344..895fcf6a 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -12,6 +12,13 @@ class SplineMixin: _model_type = "spline" _parameter_specs = {"I_R": {"units": "flux/arcsec^2"}} + _overload_parameter_specs = { + "logI_R": { + "units": "log10(flux/arcsec^2)", + "overloads": "I_R", + "overload_function": lambda p: 10**p.logI_R.value, + } + } @torch.no_grad() @ignore_numpy_warnings @@ -35,7 +42,10 @@ def initialize(self): self.radius_metric, rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], ) - self.I_R.dynamic_value = 10**I + if hasattr(self, "logI_R"): + self.logI_R.dynamic_value = I + else: + self.I_R.dynamic_value = 10**I @forward def radial_model(self, R, I_R): @@ -46,6 +56,13 @@ class iSplineMixin: _model_type = "spline" _parameter_specs = {"I_R": {"units": "flux/arcsec^2"}} + _overload_parameter_specs = { + "logI_R": { + "units": "log10(flux/arcsec^2)", + "overloads": "I_R", + "overload_function": lambda p: 10**p.logI_R.value, + } + } @torch.no_grad() @ignore_numpy_warnings @@ -77,8 +94,12 @@ def initialize(self): rad_bins=[0] + list((prof[s][:-1] + prof[s][1:]) / 2) + [prof[s][-1] * 100], angle_range=angle_range, ) - value[s] = 10**I - self.I_R.dynamic_value = value + value[s] = I + + if hasattr(self, "logI_R"): + self.logI_R.dynamic_value = value + else: + self.I_R.dynamic_value = 10**value @forward def iradial_model(self, i, R, I_R): diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 7c08f622..889e07c2 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -151,14 +151,11 @@ def initialize(self): if self.psf is not None and isinstance(self.psf, Model): self.psf.initialize() - target_area = self.target[self.window] - # Use center of window if a center hasn't been set yet - if self.center.value is None: - self.center.dynamic_value = target_area.center - else: + if self.center.initialized: return + target_area = self.target[self.window] dat = np.copy(target_area.data.detach().cpu().numpy()) if target_area.has_mask: mask = target_area.mask.detach().cpu().numpy() diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index 7c335037..ce34644c 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -1,5 +1,4 @@ import numpy as np -from scipy.stats import iqr import torch from .sky_model_object import SkyModel diff --git a/astrophot/models/sky_model_object.py b/astrophot/models/sky_model_object.py index a7117f36..4112ec17 100644 --- a/astrophot/models/sky_model_object.py +++ b/astrophot/models/sky_model_object.py @@ -20,6 +20,9 @@ def initialize(self): created and before it is used. This is where we can set the center to be a locked parameter. """ + if not self.center.initialized: + target_area = self.target[self.window] + self.center.value = target_area.center super().initialize() self.center.to_static() diff --git a/astrophot/utils/conversions/units.py b/astrophot/utils/conversions/units.py index 64961906..e8ff6436 100644 --- a/astrophot/utils/conversions/units.py +++ b/astrophot/utils/conversions/units.py @@ -1,6 +1,7 @@ import numpy as np deg_to_arcsec = 3600.0 +arcsec_to_deg = 1.0 / deg_to_arcsec def flux_to_sb(flux, pixel_area, zeropoint): diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 1bb7efe3..fdbba48e 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -481,9 +481,8 @@ "outputs": [], "source": [ "# first let's download an image to play with\n", - "hdu = fits.open(\n", - " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - ")\n", + "filename = \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "hdu = fits.open(filename)\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", "wcs = WCS(hdu[0].header)\n", @@ -501,6 +500,31 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Even better, just load directly from a FITS file\n", + "\n", + "AstroPhot recognizes standard FITS keywords to extract a target image. Note that this wont work for all FITS files, just ones that define the following keywords: `CTYPE1`, `CTYPE2`, `CRVAL1`, `CRVAL2`, `CRPIX1`, `CRPIX2`, `CD1_1`, `CD1_2`, `CD2_1`, `CD2_2`, and `MAGZP` with the usual meanings. AstroPhot can also handle SIP, see the SIP tutorial for details there.\n", + "\n", + "Further keywords specific to AstroPhot that it uses for some advanced features like multi-band fitting are: `CRTAN1`, `CRTAN2` used for aligning images, and `IDNTY` used for identifying when two images are actually cutouts of the same image. And AstroPhot also will store the `PSF`, `WEIGHT`, and `MASK` in extra extensions of the FITS file when it makes one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "target = ap.image.TargetImage(filename=filename)\n", + "\n", + "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", + "ax3.invert_xaxis() # note we flip the x-axis since RA coordinates are backwards\n", + "ap.plots.target_image(fig3, ax3, target)\n", + "plt.show()" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 7b709907..4d9af117 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -20,10 +20,7 @@ "outputs": [], "source": [ "import astrophot as ap\n", - "import numpy as np\n", "import torch\n", - "from astropy.io import fits\n", - "from astropy.wcs import WCS\n", "import matplotlib.pyplot as plt" ] }, @@ -36,52 +33,37 @@ "# First we need some data to work with, let's use LEDA 41136 as our example galaxy\n", "\n", "# The images must be aligned to a common coordinate system. From the DESI Legacy survey we are extracting\n", - "# each image from a common center coordinate, so we set the center as (0,0) for all the images and they\n", - "# should be aligned.\n", + "# each image using its RA and DEC coordinates, the WCS in the FITS header will ensure a common coordinate system.\n", "\n", "# It is also important to have a good estimate of the variance and the PSF for each image since these\n", "# affect the relative weight of each image. For the tutorial we use simple approximations, but in\n", "# science level analysis one should endeavor to get the best measure available for these.\n", "\n", "# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel and is 500 pixels across\n", - "lrimg = fits.open(\n", - " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=500&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - ")\n", "target_r = ap.image.TargetImage(\n", - " data=np.array(lrimg[0].data, dtype=np.float64),\n", + " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=500&layer=ls-dr9&pixscale=0.262&bands=r\",\n", " zeropoint=22.5,\n", " variance=\"auto\", # auto variance gets it roughly right, use better estimate for science!\n", - " psf=ap.utils.initialize.gaussian_psf(\n", - " 1.12 / 2.355, 51, 0.262\n", - " ), # we construct a basic gaussian psf for each image by giving the simga (arcsec), image width (pixels), and pixelscale (arcsec/pixel)\n", - " wcs=WCS(lrimg[0].header), # note pixelscale and origin not needed when we have a WCS object!\n", + " psf=ap.utils.initialize.gaussian_psf(1.12 / 2.355, 51, 0.262),\n", " name=\"rband\",\n", ")\n", "\n", "\n", "# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel and is 52 pixels across\n", - "lw1img = fits.open(\n", - " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1\"\n", - ")\n", "target_W1 = ap.image.TargetImage(\n", - " data=np.array(lw1img[0].data, dtype=np.float64),\n", + " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1\",\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", - " wcs=WCS(lw1img[0].header),\n", " name=\"W1band\",\n", ")\n", "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel and is 90 pixels across\n", - "lnuvimg = fits.open(\n", - " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=90&layer=galex&pixscale=1.5&bands=n\"\n", - ")\n", "target_NUV = ap.image.TargetImage(\n", - " data=np.array(lnuvimg[0].data, dtype=np.float64),\n", + " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=90&layer=galex&pixscale=1.5&bands=n\",\n", " zeropoint=20.08,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5),\n", - " wcs=WCS(lnuvimg[0].header),\n", " name=\"NUVband\",\n", ")\n", "\n", @@ -254,51 +236,33 @@ "DEC = 15.5512\n", "# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel\n", "rsize = 90\n", - "rimg = fits.open(\n", - " f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={rsize}&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - ")\n", - "rimg_data = np.array(rimg[0].data, dtype=np.float64)\n", - "rwcs = WCS(rimg[0].header)\n", - "\n", - "# dont do this unless you've read and understand the coordinates explainer in the docs!\n", "\n", "# Now we make our targets\n", "target_r = ap.image.TargetImage(\n", - " data=rimg_data,\n", + " filename=f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={rsize}&layer=ls-dr9&pixscale=0.262&bands=r\",\n", " zeropoint=22.5,\n", - " variance=\"auto\", # Note that the variance is important to ensure all images are compared with proper statistical weight. Use better estimate than auto for science!\n", - " psf=ap.utils.initialize.gaussian_psf(\n", - " 1.12 / 2.355, 51, 0.262\n", - " ), # we construct a basic gaussian psf for each image by giving the simga (arcsec), image width (pixels), and pixelscale (arcsec/pixel)\n", - " wcs=rwcs,\n", + " variance=\"auto\",\n", + " psf=ap.utils.initialize.gaussian_psf(1.12 / 2.355, 51, 0.262),\n", " name=\"rband\",\n", ")\n", "\n", "# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel\n", "wsize = int(rsize * 0.262 / 2.75)\n", - "w1img = fits.open(\n", - " f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={wsize}&layer=unwise-neo7&pixscale=2.75&bands=1\"\n", - ")\n", "target_W1 = ap.image.TargetImage(\n", - " data=np.array(w1img[0].data, dtype=np.float64),\n", + " filename=f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={wsize}&layer=unwise-neo7&pixscale=2.75&bands=1\",\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", - " wcs=WCS(w1img[0].header),\n", " name=\"W1band\",\n", ")\n", "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel\n", "gsize = int(rsize * 0.262 / 1.5)\n", - "nuvimg = fits.open(\n", - " f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={gsize}&layer=galex&pixscale=1.5&bands=n\"\n", - ")\n", "target_NUV = ap.image.TargetImage(\n", - " data=np.array(nuvimg[0].data, dtype=np.float64),\n", + " filename=f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={gsize}&layer=galex&pixscale=1.5&bands=n\",\n", " zeropoint=20.08,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5),\n", - " wcs=WCS(nuvimg[0].header),\n", " name=\"NUVband\",\n", ")\n", "target_full = ap.image.TargetImageList((target_r, target_W1, target_NUV))\n", @@ -324,8 +288,9 @@ "#########################################\n", "from photutils.segmentation import detect_sources, deblend_sources\n", "\n", - "initsegmap = detect_sources(rimg_data, threshold=0.01, npixels=10)\n", - "segmap = deblend_sources(rimg_data, initsegmap, npixels=5).data\n", + "rdata = target_r.data.detach().cpu().numpy()\n", + "initsegmap = detect_sources(rdata, threshold=0.01, npixels=10)\n", + "segmap = deblend_sources(rdata, initsegmap, npixels=5).data\n", "fig8, ax8 = plt.subplots(figsize=(8, 8))\n", "ax8.imshow(segmap, origin=\"lower\", cmap=\"inferno\")\n", "plt.show()\n", @@ -333,20 +298,14 @@ "rwindows = ap.utils.initialize.windows_from_segmentation_map(segmap)\n", "# Next we scale up the windows so that AstroPhot can fit the faint parts of each object as well\n", "rwindows = ap.utils.initialize.scale_windows(\n", - " rwindows, image_shape=rimg_data.shape, expand_scale=1.5, expand_border=10\n", + " rwindows, image_shape=rdata.shape, expand_scale=1.5, expand_border=10\n", ")\n", - "print(f\"Initial windows: {rwindows}\")\n", "w1windows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_W1)\n", "nuvwindows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_NUV)\n", - "print(f\"W1-band windows: {w1windows}\")\n", - "print(f\"NUV-band windows: {nuvwindows}\")\n", "# Here we get some basic starting parameters for the galaxies (center, position angle, axis ratio)\n", - "centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, rimg_data)\n", - "print(f\"Centroids: {centers}\")\n", - "PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, rimg_data, centers)\n", - "print(f\"Position angles: {PAs}\")\n", - "qs = ap.utils.initialize.q_from_segmentation_map(segmap, rimg_data, centers)\n", - "print(f\"Axis ratios: {qs}\")" + "centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, rdata)\n", + "PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, rdata, centers)\n", + "qs = ap.utils.initialize.q_from_segmentation_map(segmap, rdata, centers)" ] }, { diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index bfa0e9ef..dc93baba 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -88,6 +88,33 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Bilinear Sky Model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "np.random.seed(42)\n", + "M = ap.models.Model(\n", + " model_type=\"bilinear sky model\",\n", + " I=np.random.uniform(0, 1, (5, 5)) + 1,\n", + " target=basic_target,\n", + ")\n", + "M.initialize()\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 6))\n", + "ap.plots.model_image(fig, ax, M)\n", + "ax.set_title(M.name)\n", + "plt.show()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -156,7 +183,7 @@ "M = ap.models.Model(\n", " model_type=\"pixelated psf model\",\n", " target=psf_target,\n", - " pixels=PSF.data.value / psf_target.pixel_area,\n", + " pixels=PSF.data / psf_target.pixel_area,\n", ")\n", "M.initialize()\n", "\n", @@ -331,7 +358,7 @@ " Anm[0] = 1.0\n", " Anm[i] = 1.0\n", " Z[\"Anm\"].value = Anm\n", - " basis.append(Z().data.value)\n", + " basis.append(Z().data)\n", "basis = torch.stack(basis)\n", "\n", "W = np.linspace(1, 0.1, 10)\n", @@ -426,7 +453,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Galaxy Models" + "## Core Galaxy Models\n", + "\n", + "These models are represented mostly by their radial profile and are numerically straightforward to work with. All of these models also have perturbative extensions described below in the SuperEllipse, Fourier, Warp, Ray, and Wedge sections." ] }, { @@ -446,7 +475,7 @@ "source": [ "# Here we make an arbitrary spline profile out of a sine wave and a line\n", "x = np.linspace(0, 10, 14)\n", - "spline_profile = list(10 ** (np.sin(x * 2 + 2) / 20 + 1 - x / 20)) + [1e-4]\n", + "spline_profile = list((np.sin(x * 2 + 2) / 20 + 1 - x / 20)) + [-4]\n", "# Here we write down some corresponding radii for the points in the non-parametric profile. AstroPhot will make\n", "# radii to match an input profile, but it is generally better to manually provide values so you have some control\n", "# over their placement. Just note that it is assumed the first point will be at R = 0.\n", @@ -457,7 +486,8 @@ " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", - " I_R={\"value\": spline_profile, \"prof\": NP_prof},\n", + " logI_R={\"value\": spline_profile},\n", + " I_R={\"prof\": NP_prof},\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -489,7 +519,7 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -520,7 +550,7 @@ " q=0.6,\n", " PA=60 * np.pi / 180,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -551,7 +581,7 @@ " q=0.6,\n", " PA=60 * np.pi / 180,\n", " sigma=20,\n", - " flux=1,\n", + " logflux=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -582,7 +612,7 @@ " q=0.6,\n", " PA=60 * np.pi / 180,\n", " Rb=10.0,\n", - " Ib=1.0,\n", + " logIb=1.0,\n", " alpha=4.0,\n", " beta=3.0,\n", " gamma=-0.2,\n", @@ -601,7 +631,80 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Edge on model\n", + "### Modified Ferrer Model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "M = ap.models.Model(\n", + " model_type=\"modifiedferrer galaxy model\",\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " rout=40.0,\n", + " alpha=2.0,\n", + " beta=1.0,\n", + " logI0=1.0,\n", + " target=basic_target,\n", + ")\n", + "M.initialize()\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", + "ap.plots.model_image(fig, ax[0], M)\n", + "ap.plots.radial_light_profile(fig, ax[1], M)\n", + "ax[0].set_title(M.name)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Empirical King Model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "M = ap.models.Model(\n", + " model_type=\"empiricalking galaxy model\",\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " Rc=10.0,\n", + " Rt=40.0,\n", + " alpha=1.0,\n", + " logI0=1.0,\n", + " target=basic_target,\n", + ")\n", + "M.initialize()\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", + "ap.plots.model_image(fig, ax[0], M)\n", + "ap.plots.radial_light_profile(fig, ax[1], M)\n", + "ax[0].set_title(M.name)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Special Galaxy Models" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Edge on model\n", "\n", "Currently there is only one dedicared edge on model, the self gravitating isothermal disk from van der Kruit & Searle 1981. If you know of another common edge on model, feel free to let us know and we can add it in!" ] @@ -634,7 +737,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Multi Gaussian Expansion\n", + "### Multi Gaussian Expansion\n", "\n", "A multi gaussian expansion is essentially a model made of overlapping gaussian models that share the same center. However, they are combined into a single model for computational efficiency. Another advantage of the MGE is that it is possible to determine a deprojection of the model from 2D into a 3D shape since the projection of a 3D gaussian is a 2D gaussian. Note however, that in some configurations this deprojection is not unique. See Cappellari 2002 for more details.\n", "\n", @@ -672,7 +775,7 @@ "\n", "A super ellipse is a regular ellipse, except the radius metric changes from $R = \\sqrt(x^2 + y^2)$ to the more general: $R = |x^C + y^C|^{1/C}$. The parameter $C = 2$ for a regular ellipse, for $0 2$ the shape becomes more \"boxy.\" \n", "\n", - "There are superellipse versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" + "There are superellipse versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" ] }, { @@ -696,7 +799,7 @@ " C=4,\n", " n=3,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -714,9 +817,9 @@ "source": [ "## Fourier Ellipse Models\n", "\n", - "A Fourier ellipse is a scaling on the radius values as a function of theta. It takes the form: $R' = R * exp(\\sum_m am*cos(m*theta + phim))$, where am and phim are the parameters which describe the Fourier perturbations. Using the \"modes\" argument as a tuple, users can select which Fourier modes are used. As a rough intuition: mode 1 acts like a shift of the model; mode 2 acts like ellipticity; mode 3 makes a lopsided model (triangular in the extreme); and mode 4 makes peanut/diamond perturbations. \n", + "A Fourier ellipse is a scaling on the radius values as a function of theta. It takes the form: $R' = R * \\exp(\\sum_m a_m*\\cos(m*\\theta + \\phi_m))$, where am and phim are the parameters which describe the Fourier perturbations. Using the \"modes\" argument as a tuple, users can select which Fourier modes are used. As a rough intuition: mode 1 acts like a shift of the model; mode 2 acts like ellipticity; mode 3 makes a lopsided model (triangular in the extreme); and mode 4 makes peanut/diamond perturbations. \n", "\n", - "There are Fourier Ellipse versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" + "There are Fourier Ellipse versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" ] }, { @@ -745,7 +848,7 @@ " modes=(2, 3, 4),\n", " n=3,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -773,7 +876,7 @@ "\n", "The net effect is a radially varying PA and axis ratio which allows the model to represent spiral arms, bulges, or other features that change the apparent shape of a galaxy in a radially varying way.\n", "\n", - "There are warp versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" + "There are warp versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" ] }, { @@ -801,7 +904,7 @@ " PA_R={\"dynamic_value\": warp_pa, \"prof\": prof},\n", " n=3,\n", " Re=10,\n", - " Ie=1,\n", + " logIe=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -824,7 +927,7 @@ "\n", "In a ray model there is a smooth boundary between the rays. This smoothness is accomplished by applying a $(\\cos(r*theta)+1)/2$ weight to each profile, where r is dependent on the number of rays and theta is shifted to center on each ray in turn. The exact cosine weighting is dependent on if the rays are symmetric and if there is an even or odd number of rays. \n", "\n", - "There are ray versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" + "There are ray versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" ] }, { @@ -849,7 +952,7 @@ " PA=60 * np.pi / 180,\n", " n=[1, 3],\n", " Re=[10, 5],\n", - " Ie=[1, 0.5],\n", + " logIe=[1, 0.5],\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -869,7 +972,7 @@ "\n", "A wedge model behaves just like a ray model, except the boundaries are sharp. This has the advantage that the wedges can be very different in brightness without the \"smoothing\" from the ray model washing out the dimmer one. It also has the advantage of less \"mixing\" of information between the rays, each one can be counted on to have fit only the pixels in it's wedge without any influence from a neighbor. However, it has the disadvantage that the discontinuity at the boundary makes fitting behave strangely when a bright spot lays near the boundary.\n", "\n", - "There are wedge versions of: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, and `nuker`" + "There are wedge versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" ] }, { @@ -894,7 +997,7 @@ " PA=60 * np.pi / 180,\n", " n=[1, 3],\n", " Re=[10, 5],\n", - " Ie=[1, 0.5],\n", + " logIe=[1, 0.5],\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -905,6 +1008,13 @@ "ax[0].set_title(M.name)\n", "plt.show()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { From a2b3c6ada2dd802f948fe6c605821a436aa80f29 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 8 Jul 2025 23:50:24 -0400 Subject: [PATCH 050/191] SIP target basic functions now run --- astrophot/image/__init__.py | 2 + astrophot/image/distort_image.py | 10 -- astrophot/image/func/wcs.py | 14 +-- astrophot/image/image_object.py | 44 +++++---- astrophot/image/mixins/data_mixin.py | 4 +- astrophot/image/mixins/sip_mixin.py | 110 ++++++++++++++++----- astrophot/image/target_image.py | 4 +- astrophot/plots/image.py | 18 ++-- astrophot/utils/interpolate.py | 6 +- docs/source/tutorials/GettingStarted.ipynb | 14 ++- docs/source/tutorials/ModelZoo.ipynb | 2 +- 11 files changed, 150 insertions(+), 78 deletions(-) delete mode 100644 astrophot/image/distort_image.py diff --git a/astrophot/image/__init__.py b/astrophot/image/__init__.py index 730b026e..91f6aa93 100644 --- a/astrophot/image/__init__.py +++ b/astrophot/image/__init__.py @@ -1,5 +1,6 @@ from .image_object import Image, ImageList from .target_image import TargetImage, TargetImageList +from .sip_target import SIPTargetImage from .jacobian_image import JacobianImage, JacobianImageList from .psf_image import PSFImage from .model_image import ModelImage, ModelImageList @@ -11,6 +12,7 @@ "ImageList", "TargetImage", "TargetImageList", + "SIPTargetImage", "JacobianImage", "JacobianImageList", "PSFImage", diff --git a/astrophot/image/distort_image.py b/astrophot/image/distort_image.py deleted file mode 100644 index a45fd709..00000000 --- a/astrophot/image/distort_image.py +++ /dev/null @@ -1,10 +0,0 @@ -from ..param import forward -from . import func -from ..utils.interpolate import interp2d - - -class DistortImageMixin: - """ - DistortImage is a subclass of Image that applies a distortion to the image. - This is typically used for images that have been distorted by a telescope or camera. - """ diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 083e9f83..21590e91 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -112,10 +112,10 @@ def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): Tuple: [Tensor, Tensor] Tuple containing the x and y tangent plane coordinates in arcsec. """ - uv = torch.stack((j.reshape(-1) - j0, i.reshape(-1) - i0), dim=1) - xy = (CD @ uv.T).T + uv = torch.stack((j.flatten() - j0, i.flatten() - i0), dim=0) + xy = CD @ uv - return xy[:, 0].reshape(i.shape) + x0, xy[:, 1].reshape(j.shape) + y0 + return xy[0].reshape(i.shape) + x0, xy[1].reshape(i.shape) + y0 def sip_delta(u, v, sipA=(), sipB=()): @@ -138,7 +138,7 @@ def sip_delta(u, v, sipA=(), sipB=()): delta_u = delta_u + sipA[(a, b)] * (u_a[a] * v_b[b]) for a, b in sipB: delta_v = delta_v + sipB[(a, b)] * (u_a[a] * v_b[b]) - return delta_u, delta_v + return delta_v, delta_u def pixel_to_plane_sip(i, j, i0, j0, CD, sip_powers=[], sip_coefs=[], x0=0.0, y0=0.0): @@ -204,7 +204,7 @@ def pixel_to_plane_sip(i, j, i0, j0, CD, sip_powers=[], sip_coefs=[], x0=0.0, y0 return plane[..., 0] + x0, plane[..., 1] + y0 -def plane_to_pixel_linear(x, y, i0, j0, iCD, x0=0.0, y0=0.0): +def plane_to_pixel_linear(x, y, i0, j0, CD, x0=0.0, y0=0.0): """ Convert tangent plane coordinates to pixel coordinates using the WCS information. This matches the FITS convention for linear transformations. @@ -232,7 +232,7 @@ def plane_to_pixel_linear(x, y, i0, j0, iCD, x0=0.0, y0=0.0): Tuple: [Tensor, Tensor] Tuple containing the i and j pixel coordinates in pixel units. """ - xy = torch.stack((x.reshape(-1) - x0, y.reshape(-1) - y0), dim=1) - uv = (iCD @ xy.T).T + xy = torch.stack((x.flatten() - x0, y.flatten() - y0), dim=0) + uv = torch.linalg.inv(CD) @ xy return uv[:, 1].reshape(x.shape) + i0, uv[:, 0].reshape(y.shape) + j0 diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 3ab30ad0..c9a8b90c 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -32,6 +32,7 @@ class Image(Module): """ default_pixelscale = ((1.0, 0.0), (0.0, 1.0)) + expect_ctype = (("RA---TAN",), ("DEC--TAN",)) def __init__( self, @@ -44,6 +45,7 @@ def __init__( crval: Union[torch.Tensor, tuple] = (0.0, 0.0), wcs: Optional[AstropyWCS] = None, filename: Optional[str] = None, + hduext=0, identity: str = None, name: Optional[str] = None, ) -> None: @@ -85,7 +87,7 @@ def __init__( self.zeropoint = zeropoint if filename is not None: - self.load(filename) + self.load(filename, hduext=hduext) return if identity is None: @@ -94,17 +96,17 @@ def __init__( self.identity = identity if wcs is not None: - if wcs.wcs.ctype[0] != "RA---TAN": # fixme handle sip + if wcs.wcs.ctype[0] not in self.expect_ctype[0]: AP_config.ap_logger.warning( "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." ) - if wcs.wcs.ctype[1] != "DEC--TAN": + if wcs.wcs.ctype[1] not in self.expect_ctype[1]: AP_config.ap_logger.warning( "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." ) crval = wcs.wcs.crval - crpix = np.array(wcs.wcs.crpix) - 1 # handle FITS 1-indexing + crpix = np.array(wcs.wcs.crpix)[::-1] - 1 # handle FITS 1-indexing if pixelscale is not None: AP_config.ap_logger.warning( @@ -209,8 +211,8 @@ def pixel_to_plane(self, i, j, crtan, pixelscale): return func.pixel_to_plane_linear(i, j, *self.crpix, pixelscale, *crtan) @forward - def plane_to_pixel(self, x, y, crtan): - return func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale_inv, *crtan) + def plane_to_pixel(self, x, y, crtan, pixelscale): + return func.plane_to_pixel_linear(x, y, *self.crpix, pixelscale, *crtan) @forward def plane_to_world(self, x, y, crval): @@ -343,8 +345,8 @@ def fits_info(self): "CTYPE2": "DEC--TAN", "CRVAL1": self.crval.value[0].item(), "CRVAL2": self.crval.value[1].item(), - "CRPIX1": self.crpix[0] + 1, - "CRPIX2": self.crpix[1] + 1, + "CRPIX2": self.crpix[0] + 1, + "CRPIX1": self.crpix[1] + 1, "CRTAN1": self.crtan.value[0].item(), "CRTAN2": self.crtan.value[1].item(), "CD1_1": self.pixelscale.value[0][0].item() * arcsec_to_deg, @@ -363,8 +365,8 @@ def fits_images(self): def get_astropywcs(self, **kwargs): kwargs = { "NAXIS": 2, - "NAXIS1": self.shape[0].item(), - "NAXIS2": self.shape[1].item(), + "NAXIS2": self.shape[0].item(), + "NAXIS1": self.shape[1].item(), **self.fits_info(), **kwargs, } @@ -374,35 +376,35 @@ def save(self, filename: str): hdulist = fits.HDUList(self.fits_images()) hdulist.writeto(filename, overwrite=True) - def load(self, filename: str): + def load(self, filename: str, hduext=0): """Load an image from a FITS file. This will load the primary HDU and set the data, pixelscale, crpix, crval, and crtan attributes accordingly. If the WCS is not tangent plane, it will warn the user. """ hdulist = fits.open(filename) - self.data = np.array(hdulist[0].data, dtype=np.float64) + self.data = np.array(hdulist[hduext].data, dtype=np.float64) self.pixelscale = ( np.array( ( - (hdulist[0].header["CD1_1"], hdulist[0].header["CD1_2"]), - (hdulist[0].header["CD2_1"], hdulist[0].header["CD2_2"]), + (hdulist[hduext].header["CD1_1"], hdulist[hduext].header["CD1_2"]), + (hdulist[hduext].header["CD2_1"], hdulist[hduext].header["CD2_2"]), ), dtype=np.float64, ) * deg_to_arcsec ) - self.crpix = (hdulist[0].header["CRPIX1"] - 1, hdulist[0].header["CRPIX2"] - 1) - self.crval = (hdulist[0].header["CRVAL1"], hdulist[0].header["CRVAL2"]) - if "CRTAN1" in hdulist[0].header and "CRTAN2" in hdulist[0].header: - self.crtan = (hdulist[0].header["CRTAN1"], hdulist[0].header["CRTAN2"]) + self.crpix = (hdulist[hduext].header["CRPIX2"] - 1, hdulist[hduext].header["CRPIX1"] - 1) + self.crval = (hdulist[hduext].header["CRVAL1"], hdulist[hduext].header["CRVAL2"]) + if "CRTAN1" in hdulist[hduext].header and "CRTAN2" in hdulist[hduext].header: + self.crtan = (hdulist[hduext].header["CRTAN1"], hdulist[hduext].header["CRTAN2"]) else: self.crtan = (0.0, 0.0) - if "MAGZP" in hdulist[0].header and hdulist[0].header["MAGZP"] > -998: - self.zeropoint = hdulist[0].header["MAGZP"] + if "MAGZP" in hdulist[hduext].header and hdulist[hduext].header["MAGZP"] > -998: + self.zeropoint = hdulist[hduext].header["MAGZP"] else: self.zeropoint = None - self.identity = hdulist[0].header.get("IDNTY", str(id(self))) + self.identity = hdulist[hduext].header.get("IDNTY", str(id(self))) return hdulist def corners(self): diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index fbb25cfe..d07679d2 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -267,12 +267,12 @@ def fits_images(self): images.append(fits.ImageHDU(self.mask.detach().cpu().numpy(), name="MASK")) return images - def load(self, filename: str): + def load(self, filename: str, hduext=0): """Load the image from a FITS file. This will load the data, WCS, and any ancillary data such as variance, mask, and PSF. """ - hdulist = super().load(filename) + hdulist = super().load(filename, hduext=hduext) if "WEIGHT" in hdulist: self.weight = np.array(hdulist["WEIGHT"].data, dtype=np.float64) if "MASK" in hdulist: diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 7a22e483..114abf3b 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -9,47 +9,76 @@ class SIPMixin: - def __init__(self, *args, sipA=(), sipB=(), sipAP=(), sipBP=(), pixel_area_map=None, **kwargs): - super().__init__(*args, **kwargs) + expect_ctype = (("RA---TAN-SIP",), ("DEC--TAN-SIP",)) + + def __init__( + self, + *args, + sipA={}, + sipB={}, + sipAP={}, + sipBP={}, + pixel_area_map=None, + distortion_ij=None, + distortion_IJ=None, + filename=None, + **kwargs, + ): + super().__init__(*args, filename=filename, **kwargs) + if filename is not None: + return self.sipA = sipA self.sipB = sipB self.sipAP = sipAP self.sipBP = sipBP - i, j = self.pixel_center_meshgrid() - u, v = i - self.crpix[0], j - self.crpix[1] - self.distortion_ij = func.sip_delta(u, v, self.sipA, self.sipB) - self.distortion_IJ = func.sip_delta(u, v, self.sipAP, self.sipBP) # fixme maybe - - if pixel_area_map is None: - self.update_pixel_area_map() - else: - self._pixel_area_map = pixel_area_map + self.update_distortion_model( + distortion_ij=distortion_ij, distortion_IJ=distortion_IJ, pixel_area_map=pixel_area_map + ) @forward def pixel_to_plane(self, i, j, crtan, pixelscale): - di = interp2d(self.distortion_ij[0], i, j) - dj = interp2d(self.distortion_ij[1], i, j) + di = interp2d(self.distortion_ij[0], j, i) + dj = interp2d(self.distortion_ij[1], j, i) return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, pixelscale, *crtan) @forward - def plane_to_pixel(self, x, y, crtan): - I, J = func.plane_to_pixel_linear(x, y, *self.crpix, self.pixelscale_inv, *crtan) - dI = interp2d(self.distortion_IJ[0], I, J) - dJ = interp2d(self.distortion_IJ[1], I, J) + def plane_to_pixel(self, x, y, crtan, pixelscale): + I, J = func.plane_to_pixel_linear(x, y, *self.crpix, pixelscale, *crtan) + dI = interp2d(self.distortion_IJ[0], J, I) + dJ = interp2d(self.distortion_IJ[1], J, I) return I + dI, J + dJ @property def pixel_area_map(self): return self._pixel_area_map - def update_pixel_area_map(self): + def update_distortion_model(self, distortion_ij=None, distortion_IJ=None, pixel_area_map=None): """ Update the pixel area map based on the current SIP coefficients. """ + + # Pixelized distortion model + ############################################################# + if distortion_ij is None or distortion_IJ is None: + i, j = self.pixel_center_meshgrid() + v, u = i - self.crpix[0], j - self.crpix[1] + if distortion_ij is None: + distortion_ij = func.sip_delta(u, v, self.sipA, self.sipB) + if distortion_IJ is None: + distortion_IJ = func.sip_delta(u, v, self.sipAP, self.sipBP) # fixme maybe + self.distortion_ij = distortion_ij + self.distortion_IJ = distortion_IJ + + # Pixel area map + ############################################################# + if pixel_area_map is not None: + self._pixel_area_map = pixel_area_map + return i, j = self.pixel_corner_meshgrid() x, y = self.pixel_to_plane(i, j) + # Shoelace formula for pixel area # 1: [:-1, :-1] # 2: [:-1, 1:] # 3: [1:, 1:] @@ -106,15 +135,52 @@ def fits_info(self): info["CTYPE1"] = "RA---TAN-SIP" info["CTYPE2"] = "DEC--TAN-SIP" for a, b in self.sipA: - info[f"A{a}_{b}"] = self.sipA[(a, b)] + info[f"A_{a}_{b}"] = self.sipA[(a, b)] for a, b in self.sipB: - info[f"B{a}_{b}"] = self.sipB[(a, b)] + info[f"B_{a}_{b}"] = self.sipB[(a, b)] for a, b in self.sipAP: - info[f"AP{a}_{b}"] = self.sipAP[(a, b)] + info[f"AP_{a}_{b}"] = self.sipAP[(a, b)] for a, b in self.sipBP: - info[f"BP{a}_{b}"] = self.sipBP[(a, b)] + info[f"BP_{a}_{b}"] = self.sipBP[(a, b)] return info + def load(self, filename: str, hduext=0): + hdulist = super().load(filename, hduext=hduext) + self.sipA = {} + if "A_ORDER" in hdulist[hduext].header: + a_order = hdulist[hduext].header["A_ORDER"] + for i in range(a_order + 1): + for j in range(a_order + 1 - i): + key = (i, j) + if f"A_{i}_{j}" in hdulist[hduext].header: + self.sipA[key] = hdulist[hduext].header[f"A_{i}_{j}"] + self.sipB = {} + if "B_ORDER" in hdulist[hduext].header: + b_order = hdulist[hduext].header["B_ORDER"] + for i in range(b_order + 1): + for j in range(b_order + 1 - i): + key = (i, j) + if f"B_{i}_{j}" in hdulist[hduext].header: + self.sipB[key] = hdulist[hduext].header[f"B_{i}_{j}"] + self.sipAP = {} + if "AP_ORDER" in hdulist[hduext].header: + ap_order = hdulist[hduext].header["AP_ORDER"] + for i in range(ap_order + 1): + for j in range(ap_order + 1 - i): + key = (i, j) + if f"AP_{i}_{j}" in hdulist[hduext].header: + self.sipAP[key] = hdulist[hduext].header[f"AP_{i}_{j}"] + self.sipBP = {} + if "BP_ORDER" in hdulist[hduext].header: + bp_order = hdulist[hduext].header["BP_ORDER"] + for i in range(bp_order + 1): + for j in range(bp_order + 1 - i): + key = (i, j) + if f"BP_{i}_{j}" in hdulist[hduext].header: + self.sipBP[key] = hdulist[hduext].header[f"BP_{i}_{j}"] + self.update_distortion_model() + return hdulist + def reduce(self, scale, **kwargs): MS = self.data.shape[0] // scale NS = self.data.shape[1] // scale diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 1b1d956d..ac0acc2c 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -180,12 +180,12 @@ def fits_images(self): AP_config.ap_logger.warning("Unable to save PSF to FITS, not a PSF_Image.") return images - def load(self, filename: str): + def load(self, filename: str, hduext=0): """Load the image from a FITS file. This will load the data, WCS, and any ancillary data such as variance, mask, and PSF. """ - hdulist = super().load(filename) + hdulist = super().load(filename, hduext=hduext) if "PSF" in hdulist: self.psf = PSFImage( data=np.array(hdulist["PSF"].data, dtype=np.float64), diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index c87f263e..2eee53fb 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -70,9 +70,9 @@ def target_image(fig, ax, target, window=None, **kwargs): ) else: im = ax.pcolormesh( - X.T, - Y.T, - dat.T, + X, + Y, + dat, cmap="gray_r", norm=ImageNormalize( stretch=HistEqStretch( @@ -85,9 +85,9 @@ def target_image(fig, ax, target, window=None, **kwargs): ) im = ax.pcolormesh( - X.T, - Y.T, - np.ma.masked_where(dat < (sky + 3 * noise), dat).T, + X, + Y, + np.ma.masked_where(dat < (sky + 3 * noise), dat), cmap=cmap_grad, norm=matplotlib.colors.LogNorm(), clim=[sky + 3 * noise, None], @@ -137,7 +137,7 @@ def psf_image( ) # Plot the image - ax.pcolormesh(x.T, y.T, psf.T, **kwargs) + ax.pcolormesh(x, y, psf, **kwargs) # Enforce equal spacing on x y ax.axis("equal") @@ -258,7 +258,7 @@ def model_image( sample_image[target.mask.detach().cpu().numpy()] = np.nan # Plot the image - im = ax.pcolormesh(X.T, Y.T, sample_image.T, **kwargs) + im = ax.pcolormesh(X, Y, sample_image, **kwargs) # Enforce equal spacing on x y ax.axis("equal") @@ -398,7 +398,7 @@ def residual_image( "vmax": vmax, } imshow_kwargs.update(kwargs) - im = ax.pcolormesh(X.T, Y.T, residuals.T, **imshow_kwargs) + im = ax.pcolormesh(X, Y, residuals, **imshow_kwargs) ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") ax.set_ylabel("Tangent Plane Y [arcsec]") diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index f645bdad..9baf6278 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -37,11 +37,11 @@ def interp2d( # reshape for indexing purposes start_shape = x.shape - x = x.view(-1) - y = y.view(-1) + x = x.flatten() + y = y.flatten() # valid - valid = (x >= -0.5) & (x < (w - 0.5)) & (y >= -0.5) & (y < (h - 0.5)) + valid = (x >= -0.5) & (x <= (w - 0.5)) & (y >= -0.5) & (y <= (h - 0.5)) x0 = x.floor().long() y0 = y.floor().long() diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index fdbba48e..b70ab450 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -96,6 +96,16 @@ " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", ")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", + "plt.imshow(\n", + " target_data,\n", + " origin=\"lower\",\n", + " cmap=\"gray_r\",\n", + " vmin=np.percentile(target_data, 1),\n", + " vmax=np.percentile(target_data, 99),\n", + ")\n", + "plt.colorbar()\n", + "plt.title(\"Target Image\")\n", + "\n", "\n", "# Create a target object with specified pixelscale and zeropoint\n", "target = ap.image.TargetImage(\n", @@ -104,6 +114,8 @@ " zeropoint=22.5, # optionally, you can give a zeropoint to tell AstroPhot what the pixel flux units are\n", " variance=\"auto\", # Automatic variance estimate for testing and demo purposes, in real analysis use weight maps, counts, gain, etc to compute variance!\n", ")\n", + "i, j = target.pixel_center_meshgrid()\n", + "print(torch.all(torch.tensor(target_data) == target_data[i.int(), j.int()]))\n", "\n", "# The default AstroPhot target plotting method uses log scaling in bright areas and histogram scaling in faint areas\n", "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", @@ -248,7 +260,7 @@ "model3 = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " window=[555, 665, 480, 595], # this is a region in pixel coordinates (xmin,xmax,ymin,ymax)\n", + " window=[555, 665, 480, 595], # this is a region in pixel coordinates (imin,imax,jmin,jmax)\n", ")\n", "\n", "print(f\"automatically generated name: '{model3.name}'\")\n", diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index dc93baba..53b2762e 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -773,7 +773,7 @@ "source": [ "## Super Ellipse Models\n", "\n", - "A super ellipse is a regular ellipse, except the radius metric changes from $R = \\sqrt(x^2 + y^2)$ to the more general: $R = |x^C + y^C|^{1/C}$. The parameter $C = 2$ for a regular ellipse, for $0 2$ the shape becomes more \"boxy.\" \n", + "A super ellipse is a regular ellipse, except the radius metric changes from $R = \\sqrt{x^2 + y^2}$ to the more general: $R = |x^C + y^C|^{1/C}$. The parameter $C = 2$ for a regular ellipse, for $0 2$ the shape becomes more \"boxy.\" \n", "\n", "There are superellipse versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" ] From 112b22f9d84ed8977e2c604b78f43acabc77aea1 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 9 Jul 2025 22:21:18 -0400 Subject: [PATCH 051/191] sip target now works in fitting --- astrophot/image/__init__.py | 2 +- astrophot/image/base.py | 192 +++++++++++++++++++++++++++ astrophot/image/image_object.py | 78 ++++++++++- astrophot/image/jacobian_image.py | 12 +- astrophot/image/mixins/data_mixin.py | 2 + astrophot/image/mixins/sip_mixin.py | 7 +- astrophot/image/model_image.py | 68 ---------- astrophot/image/psf_image.py | 9 +- astrophot/image/sip_image.py | 148 +++++++++++++++++++++ astrophot/image/sip_target.py | 58 -------- astrophot/image/target_image.py | 14 -- astrophot/image/window.py | 3 + astrophot/models/flatsky.py | 2 +- astrophot/models/model_object.py | 50 ++----- astrophot/models/psf_model_object.py | 5 +- astrophot/plots/image.py | 7 + astrophot/utils/interpolate.py | 57 +++++++- 17 files changed, 508 insertions(+), 206 deletions(-) create mode 100644 astrophot/image/base.py create mode 100644 astrophot/image/sip_image.py delete mode 100644 astrophot/image/sip_target.py diff --git a/astrophot/image/__init__.py b/astrophot/image/__init__.py index 91f6aa93..88be690f 100644 --- a/astrophot/image/__init__.py +++ b/astrophot/image/__init__.py @@ -1,6 +1,6 @@ from .image_object import Image, ImageList from .target_image import TargetImage, TargetImageList -from .sip_target import SIPTargetImage +from .sip_image import SIPTargetImage from .jacobian_image import JacobianImage, JacobianImageList from .psf_image import PSFImage from .model_image import ModelImage, ModelImageList diff --git a/astrophot/image/base.py b/astrophot/image/base.py new file mode 100644 index 00000000..758f5df1 --- /dev/null +++ b/astrophot/image/base.py @@ -0,0 +1,192 @@ +from typing import Optional, Union + +import torch +import numpy as np + +from ..param import Module +from .. import AP_config +from .window import Window +from . import func + + +class BaseImage(Module): + + def __init__( + self, + *, + data: Optional[torch.Tensor] = None, + crpix: Union[torch.Tensor, tuple] = (0.0, 0.0), + identity: str = None, + name: Optional[str] = None, + ) -> None: + + super().__init__(name=name) + self.data = data # units: flux + self.crpix = crpix + + if identity is None: + self.identity = id(self) + else: + self.identity = identity + + @property + def data(self): + """The image data, which is a tensor of pixel values.""" + return self._data + + @data.setter + def data(self, value: Optional[torch.Tensor]): + """Set the image data. If value is None, the data is initialized to an empty tensor.""" + if value is None: + self._data = torch.empty((0, 0), dtype=AP_config.ap_dtype, device=AP_config.ap_device) + else: + self._data = torch.as_tensor( + value, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + + @property + def crpix(self): + """The reference pixel coordinates in the image, which is used to convert from pixel coordinates to tangent plane coordinates.""" + return self._crpix + + @crpix.setter + def crpix(self, value: Union[torch.Tensor, tuple]): + self._crpix = np.asarray(value, dtype=np.float64) + + @property + def window(self): + return Window(window=((0, 0), self.data.shape[:2]), image=self) + + @property + def shape(self): + """The shape of the image data.""" + return self.data.shape + + def pixel_center_meshgrid(self): + """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" + return func.pixel_center_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + + def pixel_corner_meshgrid(self): + """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" + return func.pixel_corner_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + + def pixel_simpsons_meshgrid(self): + """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" + return func.pixel_simpsons_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + + def pixel_quad_meshgrid(self, order=3): + """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" + return func.pixel_quad_meshgrid( + self.shape, AP_config.ap_dtype, AP_config.ap_device, order=order + ) + + def copy(self, **kwargs): + """Produce a copy of this image with all of the same properties. This + can be used when one wishes to make temporary modifications to + an image and then will want the original again. + + """ + kwargs = { + "data": torch.clone(self.data.detach()), + "crpix": self.crpix, + "identity": self.identity, + "name": self.name, + **kwargs, + } + return self.__class__(**kwargs) + + def blank_copy(self, **kwargs): + """Produces a blank copy of the image which has the same properties + except that its data is now filled with zeros. + + """ + kwargs = { + "data": torch.zeros_like(self.data), + "crpix": self.crpix, + "identity": self.identity, + "name": self.name, + **kwargs, + } + return self.__class__(**kwargs) + + def flatten(self, attribute: str = "data") -> torch.Tensor: + return getattr(self, attribute).flatten(end_dim=1) + + @torch.no_grad() + def get_indices(self, other: Window): + if other.image is self: + return slice(max(0, other.i_low), min(self.shape[0], other.i_high)), slice( + max(0, other.j_low), min(self.shape[1], other.j_high) + ) + shift = np.round(self.crpix - other.crpix).astype(int) + return slice( + min(max(0, other.i_low + shift[0]), self.shape[0]), + max(0, min(other.i_high + shift[0], self.shape[0])), + ), slice( + min(max(0, other.j_low + shift[1]), self.shape[1]), + max(0, min(other.j_high + shift[1], self.shape[1])), + ) + + @torch.no_grad() + def get_other_indices(self, other: Window): + if other.image == self: + shape = other.shape + return slice(max(0, -other.i_low), min(self.shape[0] - other.i_low, shape[0])), slice( + max(0, -other.j_low), min(self.shape[1] - other.j_low, shape[1]) + ) + raise ValueError() + + def get_window(self, other: Union[Window, "BaseImage"], indices=None, **kwargs): + """Get a new image object which is a window of this image + corresponding to the other image's window. This will return a + new image object with the same properties as this one, but with + the data cropped to the other image's window. + + """ + if indices is None: + indices = self.get_indices(other if isinstance(other, Window) else other.window) + new_img = self.copy( + data=self.data[indices], + crpix=self.crpix - np.array((indices[0].start, indices[1].start)), + **kwargs, + ) + return new_img + + def __sub__(self, other): + if isinstance(other, BaseImage): + new_img = self[other] + new_img.data = new_img.data - other[self].data + return new_img + else: + new_img = self.copy() + new_img.data = new_img.data - other + return new_img + + def __add__(self, other): + if isinstance(other, BaseImage): + new_img = self[other] + new_img.data = new_img.data + other[self].data + return new_img + else: + new_img = self.copy() + new_img.data = new_img.data + other + return new_img + + def __iadd__(self, other): + if isinstance(other, BaseImage): + self.data[self.get_indices(other.window)] += other.data[other.get_indices(self.window)] + else: + self.data = self.data + other + return self + + def __isub__(self, other): + if isinstance(other, BaseImage): + self.data[self.get_indices(other.window)] -= other.data[other.get_indices(self.window)] + else: + self.data = self.data - other + return self + + def __getitem__(self, *args): + if len(args) == 1 and isinstance(args[0], (BaseImage, Window)): + return self.get_window(args[0]) + return super().__getitem__(*args) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index c9a8b90c..f50cc6fd 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -9,7 +9,7 @@ from .. import AP_config from ..utils.conversions.units import deg_to_arcsec, arcsec_to_deg from .window import Window, WindowList -from ..errors import InvalidImage +from ..errors import InvalidImage, SpecificationConflict from . import func __all__ = ["Image", "ImageList"] @@ -40,7 +40,7 @@ def __init__( data: Optional[torch.Tensor] = None, pixelscale: Optional[Union[float, torch.Tensor]] = None, zeropoint: Optional[Union[float, torch.Tensor]] = None, - crpix: Union[torch.Tensor, tuple] = (0, 0), + crpix: Union[torch.Tensor, tuple] = (0.0, 0.0), crtan: Union[torch.Tensor, tuple] = (0.0, 0.0), crval: Union[torch.Tensor, tuple] = (0.0, 0.0), wcs: Optional[AstropyWCS] = None, @@ -326,6 +326,74 @@ def blank_copy(self, **kwargs): } return self.__class__(**kwargs) + def crop(self, pixels, **kwargs): + """Crop the image by the number of pixels given. This will crop + the image in all four directions by the number of pixels given. + + given data shape (N, M) the new shape will be: + + crop - int: crop the same number of pixels on all sides. new shape (N - 2*crop, M - 2*crop) + crop - (int, int): crop each dimension by the number of pixels given. new shape (N - 2*crop[1], M - 2*crop[0]) + crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) + """ + if len(pixels) == 1: # same crop in all dimension + crop = pixels if isinstance(pixels, int) else pixels[0] + data = self.data[ + crop : self.data.shape[0] - crop, + crop : self.data.shape[1] - crop, + ] + crpix = self.crpix - crop + elif len(pixels) == 2: # different crop in each dimension + data = self.data[ + pixels[1] : self.data.shape[0] - pixels[1], + pixels[0] : self.data.shape[1] - pixels[0], + ] + crpix = self.crpix - pixels + elif len(pixels) == 4: # different crop on all sides + data = self.data[ + pixels[2] : self.data.shape[0] - pixels[3], + pixels[0] : self.data.shape[1] - pixels[1], + ] + crpix = self.crpix - pixels[0::2] # fixme + else: + raise ValueError( + f"Invalid crop shape {pixels}, must be (int,), (int, int), or (int, int, int, int)!" + ) + return self.copy(data=data, crpix=crpix, **kwargs) + + def reduce(self, scale: int, **kwargs): + """This operation will downsample an image by the factor given. If + scale = 2 then 2x2 blocks of pixels will be summed together to + form individual larger pixels. A new image object will be + returned with the appropriate pixelscale and data tensor. Note + that the window does not change in this operation since the + pixels are condensed, but the pixel size is increased + correspondingly. + + Parameters: + scale: factor by which to condense the image pixels. Each scale X scale region will be summed [int] + + """ + if not isinstance(scale, int) and not ( + isinstance(scale, torch.Tensor) and scale.dtype is torch.int32 + ): + raise SpecificationConflict(f"Reduce scale must be an integer! not {type(scale)}") + if scale == 1: + return self + + MS = self.data.shape[0] // scale + NS = self.data.shape[1] // scale + + data = self.data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) + pixelscale = self.pixelscale.value * scale + crpix = (self.crpix + 0.5) / scale - 0.5 + return self.copy( + data=data, + pixelscale=pixelscale, + crpix=crpix, + **kwargs, + ) + def to(self, dtype=None, device=None): if dtype is None: dtype = AP_config.ap_dtype @@ -384,6 +452,12 @@ def load(self, filename: str, hduext=0): """ hdulist = fits.open(filename) self.data = np.array(hdulist[hduext].data, dtype=np.float64) + + # NOTE: numpy arrays are indexed backwards as array[axis2,axis1], therefore we should + # import the CD matrix as ((CD1_2, CD1_1), (CD2_2, CD2_1)) since CD is indexed as CD{world}_{pixel} + # but it would be unweildy to use a CD matrix that includes an axis reversal, so instead we manually + # perform the axis reversal internally to the pixel_to_plane and plane_to_pixel methods. This fully + # accounts for the FITS vs numpy indexing differences, so other things like CRPIX must be flipped on import. self.pixelscale = ( np.array( ( diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index a2ae6dfd..f6779665 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -44,17 +44,7 @@ def __iadd__(self, other: "JacobianImage"): if other_identity in self.parameters: other_loc = self.parameters.index(other_identity) else: - data = torch.zeros( - self.data.shape[0], - self.data.shape[1], - self.data.shape[2] + 1, - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) - data[:, :, :-1] = self.data - self.data = data - self.parameters.append(other_identity) - other_loc = -1 + continue self.data[self_indices[0], self_indices[1], other_loc] += other.data[ other_indices[0], other_indices[1], i ] diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index d07679d2..0597a2fe 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -277,6 +277,8 @@ def load(self, filename: str, hduext=0): self.weight = np.array(hdulist["WEIGHT"].data, dtype=np.float64) if "MASK" in hdulist: self.mask = np.array(hdulist["MASK"].data, dtype=bool) + elif "DQ" in hdulist: + self.mask = np.array(hdulist["DQ"].data, dtype=bool) return hdulist def reduce(self, scale, **kwargs): diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 114abf3b..afb591d3 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -1,5 +1,7 @@ from typing import Union +import torch + from ..image_object import Image from ..window import Window from .. import func @@ -64,9 +66,10 @@ def update_distortion_model(self, distortion_ij=None, distortion_IJ=None, pixel_ i, j = self.pixel_center_meshgrid() v, u = i - self.crpix[0], j - self.crpix[1] if distortion_ij is None: - distortion_ij = func.sip_delta(u, v, self.sipA, self.sipB) + distortion_ij = torch.stack(func.sip_delta(u, v, self.sipA, self.sipB), dim=0) if distortion_IJ is None: - distortion_IJ = func.sip_delta(u, v, self.sipAP, self.sipBP) # fixme maybe + # fixme maybe + distortion_IJ = torch.stack(func.sip_delta(u, v, self.sipAP, self.sipBP), dim=0) self.distortion_ij = distortion_ij self.distortion_IJ = distortion_IJ diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 99345dd6..ca11b5d3 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -21,74 +21,6 @@ class ModelImage(Image): def clear_image(self): self.data = torch.zeros_like(self.data) - def crop(self, pixels, **kwargs): - """Crop the image by the number of pixels given. This will crop - the image in all four directions by the number of pixels given. - - given data shape (N, M) the new shape will be: - - crop - int: crop the same number of pixels on all sides. new shape (N - 2*crop, M - 2*crop) - crop - (int, int): crop each dimension by the number of pixels given. new shape (N - 2*crop[1], M - 2*crop[0]) - crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) - """ - if len(pixels) == 1: # same crop in all dimension - crop = pixels if isinstance(pixels, int) else pixels[0] - data = self.data[ - crop : self.data.shape[0] - crop, - crop : self.data.shape[1] - crop, - ] - crpix = self.crpix - crop - elif len(pixels) == 2: # different crop in each dimension - data = self.data[ - pixels[1] : self.data.shape[0] - pixels[1], - pixels[0] : self.data.shape[1] - pixels[0], - ] - crpix = self.crpix - pixels - elif len(pixels) == 4: # different crop on all sides - data = self.data[ - pixels[2] : self.data.shape[0] - pixels[3], - pixels[0] : self.data.shape[1] - pixels[1], - ] - crpix = self.crpix - pixels[0::2] # fixme - else: - raise ValueError( - f"Invalid crop shape {pixels}, must be (int,), (int, int), or (int, int, int, int)!" - ) - return self.copy(data=data, crpix=crpix, **kwargs) - - def reduce(self, scale: int, **kwargs): - """This operation will downsample an image by the factor given. If - scale = 2 then 2x2 blocks of pixels will be summed together to - form individual larger pixels. A new image object will be - returned with the appropriate pixelscale and data tensor. Note - that the window does not change in this operation since the - pixels are condensed, but the pixel size is increased - correspondingly. - - Parameters: - scale: factor by which to condense the image pixels. Each scale X scale region will be summed [int] - - """ - if not isinstance(scale, int) and not ( - isinstance(scale, torch.Tensor) and scale.dtype is torch.int32 - ): - raise SpecificationConflict(f"Reduce scale must be an integer! not {type(scale)}") - if scale == 1: - return self - - MS = self.data.shape[0] // scale - NS = self.data.shape[1] // scale - - data = self.data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) - pixelscale = self.pixelscale.value * scale - crpix = (self.crpix + 0.5) / scale - 0.5 - return self.copy( - data=data, - pixelscale=pixelscale, - crpix=crpix, - **kwargs, - ) - def fluxdensity_to_flux(self): self.data = self.data * self.pixel_area diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 4b6f5770..3421f8a9 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -34,7 +34,7 @@ class PSFImage(DataMixin, Image): def __init__(self, *args, **kwargs): kwargs.update({"crval": (0, 0), "crpix": (0, 0), "crtan": (0, 0)}) super().__init__(*args, **kwargs) - self.crpix = (np.array(self.data.shape, dtype=float) - 1.0) / 2 + self.crpix = (np.array(self.data.shape, dtype=np.float64) - 1.0) / 2 def normalize(self): """Normalizes the PSF image to have a sum of 1.""" @@ -65,7 +65,7 @@ def jacobian_image( "pixelscale": self.pixelscale.value, "crpix": self.crpix, "crtan": self.crtan.value, - "crval": (0.0, 0.0), + "crval": self.crval.value, "zeropoint": self.zeropoint, "identity": self.identity, **kwargs, @@ -81,12 +81,11 @@ def model_image(self, **kwargs): "pixelscale": self.pixelscale.value, "crpix": self.crpix, "crtan": self.crtan.value, - "crval": (0.0, 0.0), - "zeropoint": self.zeropoint, + "crval": self.crval.value, "identity": self.identity, **kwargs, } - return ModelImage(**kwargs) + return PSFImage(**kwargs) @property def zeropoint(self): diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py new file mode 100644 index 00000000..61dc5f83 --- /dev/null +++ b/astrophot/image/sip_image.py @@ -0,0 +1,148 @@ +import torch + +from .target_image import TargetImage +from .model_image import ModelImage +from .mixins import SIPMixin + + +class SIPModelImage(SIPMixin, ModelImage): + + def crop(self, pixels, **kwargs): + """ + Crop the image by the number of pixels given. This will crop + the image in all four directions by the number of pixels given. + """ + if isinstance(pixels, int): # same crop in all dimension + crop = (slice(pixels, -pixels), slice(pixels, -pixels)) + elif len(pixels) == 1: # same crop in all dimension + crop = (slice(pixels[0], -pixels[0]), slice(pixels[0], -pixels[0])) + elif len(pixels) == 2: # different crop in each dimension + crop = ( + slice(pixels[1], -pixels[1]), + slice(pixels[0], -pixels[0]), + ) + elif len(pixels) == 4: # different crop on all sides + crop = ( + slice(pixels[0], -pixels[1]), + slice(pixels[2], -pixels[3]), + ) + else: + raise ValueError( + f"Invalid crop shape {pixels}, must be int, (int,), (int, int), or (int, int, int, int)!" + ) + kwargs = { + "pixel_area_map": self.pixel_area_map[crop], + "distortion_ij": self.distortion_ij[crop], + "distortion_IJ": self.distortion_IJ[crop], + **kwargs, + } + return super().crop(pixels, **kwargs) + + def reduce(self, scale: int, **kwargs): + """This operation will downsample an image by the factor given. If + scale = 2 then 2x2 blocks of pixels will be summed together to + form individual larger pixels. A new image object will be + returned with the appropriate pixelscale and data tensor. Note + that the window does not change in this operation since the + pixels are condensed, but the pixel size is increased + correspondingly. + + Parameters: + scale: factor by which to condense the image pixels. Each scale X scale region will be summed [int] + + """ + if not isinstance(scale, int) and not ( + isinstance(scale, torch.Tensor) and scale.dtype is torch.int32 + ): + raise SpecificationConflict(f"Reduce scale must be an integer! not {type(scale)}") + if scale == 1: + return self + + MS = self.data.shape[0] // scale + NS = self.data.shape[1] // scale + + kwargs = { + "pixel_area_map": ( + self.pixel_area_map[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .sum(axis=(1, 3)) + ), + "distortion_ij": ( + self.distortion_ij[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .mean(axis=(1, 3)) + ), + "distortion_IJ": ( + self.distortion_IJ[: MS * scale, : NS * scale] + .reshape(MS, scale, NS, scale) + .mean(axis=(1, 3)) + ), + **kwargs, + } + return super().reduce( + scale=scale, + **kwargs, + ) + + def fluxdensity_to_flux(self): + self.data = self.data * self.pixel_area_map + + +class SIPTargetImage(SIPMixin, TargetImage): + """ + A TargetImage with SIP distortion coefficients. + This class is used to represent a target image with SIP distortion coefficients. + It inherits from TargetImage and SIPMixin. + """ + + def model_image(self, upsample=1, pad=0, **kwargs): + new_area_map = self.pixel_area_map + new_distortion_ij = self.distortion_ij + new_distortion_IJ = self.distortion_IJ + if upsample > 1: + U = torch.nn.Upsample(scale_factor=upsample, mode="nearest") + new_area_map = U(new_area_map) / upsample**2 + U = torch.nn.Upsample(scale_factor=upsample, mode="bilinear", align_corners=False) + new_distortion_ij = U(self.distortion_ij) + new_distortion_IJ = U(self.distortion_IJ) + if pad > 0: + new_area_map = ( + torch.nn.functional.pad( + new_area_map.unsqueeze(0).unsqueeze(0), (pad, pad, pad, pad), mode="replicate" + ) + .squeeze(0) + .squeeze(0) + ) + new_distortion_ij = torch.nn.functional.pad( + new_distortion_ij.unsqueeze(1), + (pad, pad, pad, pad), + mode="replicate", + ).squeeze(1) + new_distortion_IJ = torch.nn.functional.pad( + new_distortion_IJ.unsqueeze(1), + (pad, pad, pad, pad), + mode="replicate", + ).squeeze(1) + kwargs = { + "pixel_area_map": new_area_map, + "sipA": self.sipA, + "sipB": self.sipB, + "sipAP": self.sipAP, + "sipBP": self.sipBP, + "distortion_ij": new_distortion_ij, + "distortion_IJ": new_distortion_IJ, + "data": torch.zeros( + (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), + dtype=self.data.dtype, + device=self.data.device, + ), + "pixelscale": self.pixelscale.value / upsample, + "crpix": (self.crpix + 0.5) * upsample + pad - 0.5, + "crtan": self.crtan.value, + "crval": self.crval.value, + "zeropoint": self.zeropoint, + "identity": self.identity, + "name": self.name + "_model", + **kwargs, + } + return SIPModelImage(**kwargs) diff --git a/astrophot/image/sip_target.py b/astrophot/image/sip_target.py deleted file mode 100644 index 0a912b3c..00000000 --- a/astrophot/image/sip_target.py +++ /dev/null @@ -1,58 +0,0 @@ -import torch - -from .target_image import TargetImage -from .mixins import SIPMixin - - -class SIPTargetImage(SIPMixin, TargetImage): - """ - A TargetImage with SIP distortion coefficients. - This class is used to represent a target image with SIP distortion coefficients. - It inherits from TargetImage and SIPMixin. - """ - - def jacobian_image(self, **kwargs): - kwargs = { - "pixel_area_map": self.pixel_area_map, - "sipA": self.sipA, - "sipB": self.sipB, - "sipAP": self.sipAP, - "sipBP": self.sipBP, - "distortion_ij": self.distortion_ij, - "distortion_IJ": self.distortion_IJ, - **kwargs, - } - return super().jacobian_image(**kwargs) - - def model_image(self, upsample=1, pad=0, **kwargs): - new_area_map = self.pixel_area_map - new_distortion_ij = self.distortion_ij - new_distortion_IJ = self.distortion_IJ - if upsample > 1: - new_area_map = self.pixel_area_map.repeat_interleave(upsample, dim=0) - new_area_map = new_area_map.repeat_interleave(upsample, dim=1) - new_area_map = new_area_map / upsample**2 - U = torch.nn.Upsample(scale_factor=upsample, mode="bilinear", align_corners=False) - new_distortion_ij = U(self.distortion_ij) - new_distortion_IJ = U(self.distortion_IJ) - if pad > 0: - new_area_map = torch.nn.functional.pad( - new_area_map, (pad, pad, pad, pad), mode="replicate" - ) - new_distortion_ij = torch.nn.functional.pad( - new_distortion_ij, (pad, pad, pad, pad), mode="replicate" - ) - new_distortion_IJ = torch.nn.functional.pad( - new_distortion_IJ, (pad, pad, pad, pad), mode="replicate" - ) - kwargs = { - "pixel_area_map": new_area_map, - "sipA": self.sipA, - "sipB": self.sipB, - "sipAP": self.sipAP, - "sipBP": self.sipBP, - "distortion_ij": new_distortion_ij, - "distortion_IJ": new_distortion_IJ, - **kwargs, - } - return super().model_image(**kwargs) diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index ac0acc2c..4626f98a 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -244,20 +244,6 @@ def model_image(self, upsample=1, pad=0, **kwargs): } return ModelImage(**kwargs) - def reduce(self, scale, **kwargs): - """Returns a new `Target_Image` object with a reduced resolution - compared to the current image. `scale` should be an integer - indicating how much to reduce the resolution. If the - `Target_Image` was originally (48,48) pixels across with a - pixelscale of 1 and `reduce(2)` is called then the image will - be (24,24) pixels and the pixelscale will be 2. If `reduce(3)` - is called then the returned image will be (16,16) pixels - across and the pixelscale will be 3. - - """ - - return super().reduce(scale=scale, psf=self.psf, **kwargs) - class TargetImageList(ImageList): def __init__(self, *args, **kwargs): diff --git a/astrophot/image/window.py b/astrophot/image/window.py index 2da02c45..1f3be919 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -122,6 +122,9 @@ def __and__(self, other: "Window"): new_j_high = min(self.j_high, other.j_high) return Window((new_i_low, new_i_high, new_j_low, new_j_high), self.image) + def __str__(self): + return f"Window({self.i_low}, {self.i_high}, {self.j_low}, {self.j_high})" + class WindowList: def __init__(self, windows: list[Window]): diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index db035e84..0541414c 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -33,7 +33,7 @@ def initialize(self): return dat = self.target[self.window].data.detach().cpu().numpy().copy() - self.I.value = np.median(dat) / self.target.pixel_area.item() + self.I.dynamic_value = np.median(dat) / self.target.pixel_area.item() @forward def brightness(self, x, y, I): diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 889e07c2..e601acee 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -3,11 +3,10 @@ import numpy as np import torch -from ..param import forward, OverrideParam +from ..param import forward from .base import Model from . import func from ..image import ( - ModelImage, TargetImage, Window, PSFImage, @@ -15,7 +14,7 @@ from ..utils.initialize import recursive_center_of_mass from ..utils.decorators import ignore_numpy_warnings from .. import AP_config -from ..errors import InvalidTarget +from ..errors import InvalidTarget, SpecificationConflict from .mixins import SampleMixin __all__ = ["ComponentModel"] @@ -52,21 +51,12 @@ class ComponentModel(SampleMixin, Model): """ - _parameter_specs = { - "center": {"units": "arcsec", "shape": (2,)}, - } + _parameter_specs = {"center": {"units": "arcsec", "shape": (2,)}} # Scope for PSF convolution psf_mode = "none" # none, full - # Method to use when performing subpixel shifts. - psf_subpixel_shift = ( - False # False: no shift to align sampling with pixel center, True: use FFT shift theorem - ) - - _options = ( - "psf_mode", - "psf_subpixel_shift", - ) + + _options = ("psf_mode",) usable = False def __init__(self, *args, psf=None, **kwargs): @@ -211,9 +201,6 @@ def sample( if window is None: window = self.window - if "window" in self.psf_mode: - raise NotImplementedError("PSF convolution in sub-window not available yet") - if "full" in self.psf_mode: if isinstance(self.psf, PSFImage): psf_upscale = ( @@ -235,27 +222,18 @@ def sample( ) working_image = self.target[window].model_image(upsample=psf_upscale, pad=psf_pad) - - # Sub pixel shift to align the model with the center of a pixel - if self.psf_subpixel_shift: - pixel_center = torch.stack(working_image.plane_to_pixel(*center)) - pixel_centered = torch.round(pixel_center) - pixel_shift = pixel_center - pixel_centered - with OverrideParam( - self.center, torch.stack(working_image.pixel_to_plane(*pixel_centered)) - ): - sample = self.sample_image(working_image) - else: - pixel_shift = None - sample = self.sample_image(working_image) - - working_image.data = func.convolve_and_shift(sample, psf, pixel_shift) + sample = self.sample_image(working_image) + working_image.data = func.convolve(sample, psf) working_image = working_image.crop([psf_pad]).reduce(psf_upscale) - else: + elif "none" in self.psf_mode: working_image = self.target[window].model_image() - sample = self.sample_image(working_image) - working_image.data = sample + working_image.data = self.sample_image(working_image) + else: + raise SpecificationConflict( + f"Unknown PSF mode {self.psf_mode} for model {self.name}. " + "Must be one of 'none' or 'full'." + ) # Units from flux/arcsec^2 to flux working_image.fluxdensity_to_flux() diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 5f107cf1..7836c415 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -76,10 +76,7 @@ def sample(self, window=None): # normalize to total flux 1 if self.normalize_psf: - working_image.data = working_image.data / torch.sum(working_image.data) - - if self.mask is not None: - working_image.data = working_image.data * (~self.mask) + working_image.normalize() return working_image diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 2eee53fb..afe71da5 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -93,6 +93,8 @@ def target_image(fig, ax, target, window=None, **kwargs): clim=[sky + 3 * noise, None], ) + if torch.linalg.det(target.pixelscale.value) < 0: + ax.invert_xaxis() ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") ax.set_ylabel("Tangent Plane Y [arcsec]") @@ -260,6 +262,9 @@ def model_image( # Plot the image im = ax.pcolormesh(X, Y, sample_image, **kwargs) + if torch.linalg.det(target.pixelscale.value) < 0: + ax.invert_xaxis() + # Enforce equal spacing on x y ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") @@ -399,6 +404,8 @@ def residual_image( } imshow_kwargs.update(kwargs) im = ax.pcolormesh(X, Y, residuals, **imshow_kwargs) + if torch.linalg.det(target.pixelscale.value) < 0: + ax.invert_xaxis() ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") ax.set_ylabel("Tangent Plane Y [arcsec]") diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index 9baf6278..d95af539 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -45,12 +45,10 @@ def interp2d( x0 = x.floor().long() y0 = y.floor().long() - x1 = x0 + 1 - y1 = y0 + 1 x0 = x0.clamp(0, w - 2) - x1 = x1.clamp(1, w - 1) + x1 = x0 + 1 y0 = y0.clamp(0, h - 2) - y1 = y1.clamp(1, h - 1) + y1 = y0 + 1 fa = im[y0, x0] fb = im[y1, x0] @@ -64,4 +62,55 @@ def interp2d( result = fa * wa + fb * wb + fc * wc + fd * wd + return (result * valid).reshape(start_shape) + + +def interp2d_ij( + im: torch.Tensor, + i: torch.Tensor, + j: torch.Tensor, +) -> torch.Tensor: + """ + Interpolates a 2D image at specified coordinates. + Similar to `torch.nn.functional.grid_sample` with `align_corners=False`. + + Args: + im (Tensor): A 2D tensor representing the image. + x (Tensor): A tensor of x coordinates (in pixel space) at which to interpolate. + y (Tensor): A tensor of y coordinates (in pixel space) at which to interpolate. + + Returns: + Tensor: Tensor with the same shape as `x` and `y` containing the interpolated values. + """ + + # Convert coordinates to pixel indices + h, w = im.shape + + # reshape for indexing purposes + start_shape = i.shape + i = i.flatten() + j = j.flatten() + + # valid + valid = (i >= -0.5) & (i <= (h - 0.5)) & (j >= -0.5) & (j <= (w - 0.5)) + + i0 = i.floor().long() + j0 = j.floor().long() + i0 = i0.clamp(0, h - 2) + i1 = i0 + 1 + j0 = j0.clamp(0, w - 2) + j1 = j0 + 1 + + fa = im[i0, j0] + fb = im[i0, j1] + fc = im[i1, j0] + fd = im[i1, j1] + + wa = (i1 - i) * (j1 - j) + wb = (i1 - i) * (j - j0) + wc = (i - i0) * (j1 - j) + wd = (i - i0) * (j - j0) + + result = fa * wa + fb * wb + fc * wc + fd * wd + return (result * valid).view(*start_shape) From c0c668a5ca015d891f5d200e29a6fb8946c5f341 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 10 Jul 2025 12:04:15 -0400 Subject: [PATCH 052/191] add scipy fit update LM --- astrophot/fit/__init__.py | 4 +- astrophot/fit/func/lm.py | 20 ++- astrophot/fit/lm.py | 4 +- astrophot/fit/scipy_fit.py | 135 +++++++++++++++++++++ astrophot/image/func/wcs.py | 2 +- astrophot/models/mixins/sample.py | 1 - docs/source/tutorials/GettingStarted.ipynb | 2 - docs/source/tutorials/JointModels.ipynb | 21 ---- 8 files changed, 157 insertions(+), 32 deletions(-) create mode 100644 astrophot/fit/scipy_fit.py diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 87561bdc..c9e31578 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -4,6 +4,8 @@ # from .gradient import * from .iterative import Iter +from .scipy_fit import ScipyFit + # from .minifit import * # try: @@ -13,7 +15,7 @@ # print("Could not load HMC or NUTS due to:", str(e)) # from .mhmcmc import * -__all__ = ["LM", "Iter"] +__all__ = ["LM", "Iter", "ScipyFit"] """ base: This module defines the base class BaseOptimizer, diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 2b6640cb..31b5c5e5 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -15,7 +15,20 @@ def gradient(J, W, R): def damp_hessian(hess, L): I = torch.eye(len(hess), dtype=hess.dtype, device=hess.device) D = torch.ones_like(hess) - I - return hess * (I + D / (1 + L)) + L * I * (1 + torch.diag(hess)) + return hess * (I + D / (1 + L)) + L * I * torch.diag(hess) + + +def solve(hess, grad, L): + hessD = damp_hessian(hess, L) # (N, N) + while True: + try: + h = torch.linalg.solve(hessD, grad) + break + except torch._C._LinAlgError: + print("Damping Hessian", L) + hessD = hessD + L * torch.eye(len(hessD), dtype=hessD.dtype, device=hessD.device) + L = L * 2 + return hessD, h def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11.0): @@ -32,8 +45,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. nostep = True improving = None for _ in range(10): - hessD = damp_hessian(hess, L) # (N, N) - h = torch.linalg.solve(hessD, grad) # (N, 1) + hessD, h = solve(hess, grad, L) # (N, N), (N, 1) M1 = model(x + h.squeeze(1)) # (M,) chi21 = torch.sum(weight * (data - M1) ** 2).item() / ndf @@ -52,7 +64,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. # actual chi2 improvement vs expected from linearization rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() # Avoid highly non-linear regions - if rho < 0.1 or rho > 2: + if rho < 0.1 or rho > 10: L *= Lup if improving is True: break diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index c2b9fb13..3aea1ac8 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -278,7 +278,7 @@ def fit(self) -> BaseOptimizer: jacobian=self.jacobian, ndf=self.ndf, chi2=self.loss_history[-1], - L=self.L / self.Ldn, + L=self.L, Lup=self.Lup, Ldn=self.Ldn, ) @@ -292,7 +292,7 @@ def fit(self) -> BaseOptimizer: jacobian=self.jacobian, ndf=self.ndf, chi2=self.loss_history[-1], - L=self.L / self.Ldn, + L=self.L, Lup=self.Lup, Ldn=self.Ldn, ) diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py new file mode 100644 index 00000000..0a036de8 --- /dev/null +++ b/astrophot/fit/scipy_fit.py @@ -0,0 +1,135 @@ +from typing import Sequence + +import torch +from scipy.optimize import minimize + +from .base import BaseOptimizer +from .. import AP_config +from ..errors import OptimizeStop + +__all__ = ("ScipyFit",) + + +class ScipyFit(BaseOptimizer): + + def __init__( + self, + model, + initial_state: Sequence = None, + method="Nelder-Mead", + max_iter: int = 100, + ndf=None, + **kwargs, + ): + + super().__init__( + model, + initial_state, + max_iter=max_iter, + **kwargs, + ) + self.method = method + # Maximum number of iterations of the algorithm + self.max_iter = max_iter + # mask + fit_mask = self.model.fit_mask() + if isinstance(fit_mask, tuple): + fit_mask = torch.cat(tuple(FM.flatten() for FM in fit_mask)) + else: + fit_mask = fit_mask.flatten() + if torch.sum(fit_mask).item() == 0: + fit_mask = None + + if model.target.has_mask: + mask = self.model.target[self.fit_window].flatten("mask") + if fit_mask is not None: + mask = mask | fit_mask + self.mask = ~mask + elif fit_mask is not None: + self.mask = ~fit_mask + else: + self.mask = torch.ones_like( + self.model.target[self.fit_window].flatten("data"), dtype=torch.bool + ) + if self.mask is not None and torch.sum(self.mask).item() == 0: + raise OptimizeStop("No data to fit. All pixels are masked") + + # Initialize optimizer attributes + self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] + + # 1 / (sigma^2) + kW = kwargs.get("W", None) + if kW is not None: + self.W = torch.as_tensor( + kW, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ).flatten()[self.mask] + elif model.target.has_variance: + self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] + else: + self.W = torch.ones_like(self.Y) + + # The forward model which computes the output image given input parameters + self.forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[self.mask] + # Compute the jacobian in representation units (defined for -inf, inf) + self.jacobian = lambda x: model.jacobian(window=self.fit_window, params=x).flatten("data")[ + self.mask + ] + + # variable to store covariance matrix if it is ever computed + self._covariance_matrix = None + + # Degrees of freedom + if ndf is None: + self.ndf = max(1.0, len(self.Y) - len(self.current_state)) + else: + self.ndf = ndf + + def chi2_ndf(self, x): + return torch.sum(self.W * (self.Y - self.forward(x)) ** 2) / self.ndf + + def numpy_bounds(self): + """Convert the model's parameter bounds to a format suitable for scipy.optimize.""" + bounds = [] + for param in self.model.dynamic_params: + if param.shape == (): + bound = [None, None] + if param.valid[0] is not None: + bound[0] = param.valid[0].detach().cpu().numpy() + if param.valid[1] is not None: + bound[1] = param.valid[1].detach().cpu().numpy() + bounds.append(tuple(bound)) + else: + for i in range(param.value.numel()): + bound = [None, None] + if param.valid[0] is not None: + bound[0] = param.valid[0].flatten()[i].detach().cpu().numpy() + if param.valid[1] is not None: + bound[1] = param.valid[1].flatten()[i].detach().cpu().numpy() + bounds.append(tuple(bound)) + return bounds + + def fit(self): + + res = minimize( + lambda x: self.chi2_ndf( + torch.tensor(x, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + ).item(), + self.current_state, + method=self.method, + bounds=self.numpy_bounds(), + options={ + "maxiter": self.max_iter, + }, + ) + self.scipy_res = res + self.message = self.message + f"success: {res.success}, message: {res.message}" + self.current_state = torch.tensor( + res.x, dtype=AP_config.ap_dtype, device=AP_config.ap_device + ) + if self.verbose > 0: + AP_config.ap_logger.info( + f"Final Chi^2/DoF: {self.chi2_ndf(self.current_state):.6g}. Converged: {self.message}" + ) + self.model.fill_dynamic_values(self.current_state) + + return self diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 21590e91..9a056c44 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -235,4 +235,4 @@ def plane_to_pixel_linear(x, y, i0, j0, CD, x0=0.0, y0=0.0): xy = torch.stack((x.flatten() - x0, y.flatten() - y0), dim=0) uv = torch.linalg.inv(CD) @ xy - return uv[:, 1].reshape(x.shape) + i0, uv[:, 0].reshape(y.shape) + j0 + return uv[1].reshape(x.shape) + i0, uv[0].reshape(y.shape) + j0 diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 83a2624e..c0a85ba0 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -29,7 +29,6 @@ class SampleMixin: "sampling_mode", "jacobian_maxparams", "jacobian_maxpixels", - "psf_subpixel_shift", "integrate_mode", "integrate_tolerance", "integrate_max_depth", diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index b70ab450..3104dbff 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -507,7 +507,6 @@ ")\n", "\n", "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", - "ax3.invert_xaxis() # note we flip the x-axis since RA coordinates are backwards\n", "ap.plots.target_image(fig3, ax3, target)\n", "plt.show()" ] @@ -532,7 +531,6 @@ "target = ap.image.TargetImage(filename=filename)\n", "\n", "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", - "ax3.invert_xaxis() # note we flip the x-axis since RA coordinates are backwards\n", "ap.plots.target_image(fig3, ax3, target)\n", "plt.show()" ] diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 4d9af117..18846626 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -70,13 +70,10 @@ "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", "ap.plots.target_image(fig1, ax1[0], target_r)\n", "ax1[0].set_title(\"r-band image\")\n", - "ax1[0].invert_xaxis()\n", "ap.plots.target_image(fig1, ax1[1], target_W1)\n", "ax1[1].set_title(\"W1-band image\")\n", - "ax1[1].invert_xaxis()\n", "ap.plots.target_image(fig1, ax1[2], target_NUV)\n", "ax1[2].set_title(\"NUV-band image\")\n", - "ax1[2].invert_xaxis()\n", "plt.show()" ] }, @@ -155,11 +152,8 @@ "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", "ap.plots.model_image(fig1, ax1, model_full)\n", "ax1[0].set_title(\"r-band model image\")\n", - "ax1[0].invert_xaxis()\n", "ax1[1].set_title(\"W1-band model image\")\n", - "ax1[1].invert_xaxis()\n", "ax1[2].set_title(\"NUV-band model image\")\n", - "ax1[2].invert_xaxis()\n", "plt.show()\n", "model_full.graphviz()" ] @@ -196,18 +190,12 @@ "fig1, ax1 = plt.subplots(2, 3, figsize=(18, 12))\n", "ap.plots.model_image(fig1, ax1[0], model_full)\n", "ax1[0][0].set_title(\"r-band model image\")\n", - "ax1[0][0].invert_xaxis()\n", "ax1[0][1].set_title(\"W1-band model image\")\n", - "ax1[0][1].invert_xaxis()\n", "ax1[0][2].set_title(\"NUV-band model image\")\n", - "ax1[0][2].invert_xaxis()\n", "ap.plots.residual_image(fig1, ax1[1], model_full, normalize_residuals=True)\n", "ax1[1][0].set_title(\"r-band residual image\")\n", - "ax1[1][0].invert_xaxis()\n", "ax1[1][1].set_title(\"W1-band residual image\")\n", - "ax1[1][1].invert_xaxis()\n", "ax1[1][2].set_title(\"NUV-band residual image\")\n", - "ax1[1][2].invert_xaxis()\n", "plt.show()" ] }, @@ -384,11 +372,8 @@ "ap.plots.target_image(fig, ax, MODEL.target)\n", "ap.plots.model_window(fig, ax, MODEL)\n", "ax[0].set_title(\"r-band image\")\n", - "ax[0].invert_xaxis()\n", "ax[1].set_title(\"W1-band image\")\n", - "ax[1].invert_xaxis()\n", "ax[2].set_title(\"NUV-band image\")\n", - "ax[2].invert_xaxis()\n", "plt.show()" ] }, @@ -421,18 +406,12 @@ "fig1, ax1 = plt.subplots(2, 3, figsize=(18, 11))\n", "ap.plots.model_image(fig1, ax1[0], MODEL, vmax=30)\n", "ax1[0][0].set_title(\"r-band model image\")\n", - "ax1[0][0].invert_xaxis()\n", "ax1[0][1].set_title(\"W1-band model image\")\n", - "ax1[0][1].invert_xaxis()\n", "ax1[0][2].set_title(\"NUV-band model image\")\n", - "ax1[0][2].invert_xaxis()\n", "ap.plots.residual_image(fig, ax1[1], MODEL, normalize_residuals=True)\n", "ax1[1][0].set_title(\"r-band residual image\")\n", - "ax1[1][0].invert_xaxis()\n", "ax1[1][1].set_title(\"W1-band residual image\")\n", - "ax1[1][1].invert_xaxis()\n", "ax1[1][2].set_title(\"NUV-band residual image\")\n", - "ax1[1][2].invert_xaxis()\n", "plt.show()" ] }, From 83ef9a4f434e73ec534f2fc79add20fb9c976027 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 10 Jul 2025 14:51:13 -0400 Subject: [PATCH 053/191] change to coordinate indexing for image data --- astrophot/image/base.py | 9 +-- astrophot/image/func/wcs.py | 6 +- astrophot/image/image_object.py | 70 ++++++++++++---------- astrophot/image/jacobian_image.py | 2 +- astrophot/image/mixins/data_mixin.py | 58 +++++++++++++----- astrophot/image/mixins/sip_mixin.py | 2 +- astrophot/image/model_image.py | 5 +- astrophot/image/sip_image.py | 2 +- astrophot/image/target_image.py | 6 +- astrophot/models/model_object.py | 4 +- astrophot/plots/image.py | 8 +-- astrophot/utils/initialize/variance.py | 4 +- docs/source/tutorials/GettingStarted.ipynb | 25 +++----- 13 files changed, 111 insertions(+), 90 deletions(-) diff --git a/astrophot/image/base.py b/astrophot/image/base.py index 758f5df1..3342c79c 100644 --- a/astrophot/image/base.py +++ b/astrophot/image/base.py @@ -40,8 +40,9 @@ def data(self, value: Optional[torch.Tensor]): if value is None: self._data = torch.empty((0, 0), dtype=AP_config.ap_dtype, device=AP_config.ap_device) else: - self._data = torch.as_tensor( - value, dtype=AP_config.ap_dtype, device=AP_config.ap_device + # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates + self._data = torch.transpose( + torch.as_tensor(value, dtype=AP_config.ap_dtype, device=AP_config.ap_device), 0, 1 ) @property @@ -87,7 +88,7 @@ def copy(self, **kwargs): """ kwargs = { - "data": torch.clone(self.data.detach()), + "data": torch.transpose(torch.clone(self.data.detach()), 0, 1), "crpix": self.crpix, "identity": self.identity, "name": self.name, @@ -101,7 +102,7 @@ def blank_copy(self, **kwargs): """ kwargs = { - "data": torch.zeros_like(self.data), + "data": torch.transpose(torch.zeros_like(self.data), 0, 1), "crpix": self.crpix, "identity": self.identity, "name": self.name, diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 9a056c44..e2ae3f72 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -112,7 +112,7 @@ def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): Tuple: [Tensor, Tensor] Tuple containing the x and y tangent plane coordinates in arcsec. """ - uv = torch.stack((j.flatten() - j0, i.flatten() - i0), dim=0) + uv = torch.stack((i.flatten() - i0, j.flatten() - j0), dim=0) xy = CD @ uv return xy[0].reshape(i.shape) + x0, xy[1].reshape(i.shape) + y0 @@ -138,7 +138,7 @@ def sip_delta(u, v, sipA=(), sipB=()): delta_u = delta_u + sipA[(a, b)] * (u_a[a] * v_b[b]) for a, b in sipB: delta_v = delta_v + sipB[(a, b)] * (u_a[a] * v_b[b]) - return delta_v, delta_u + return delta_u, delta_v def pixel_to_plane_sip(i, j, i0, j0, CD, sip_powers=[], sip_coefs=[], x0=0.0, y0=0.0): @@ -235,4 +235,4 @@ def plane_to_pixel_linear(x, y, i0, j0, CD, x0=0.0, y0=0.0): xy = torch.stack((x.flatten() - x0, y.flatten() - y0), dim=0) uv = torch.linalg.inv(CD) @ xy - return uv[1].reshape(x.shape) + i0, uv[0].reshape(y.shape) + j0 + return uv[0].reshape(x.shape) + i0, uv[1].reshape(y.shape) + j0 diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index f50cc6fd..880369fe 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -10,6 +10,8 @@ from ..utils.conversions.units import deg_to_arcsec, arcsec_to_deg from .window import Window, WindowList from ..errors import InvalidImage, SpecificationConflict + +# from .base import BaseImage from . import func __all__ = ["Image", "ImageList"] @@ -48,6 +50,7 @@ def __init__( hduext=0, identity: str = None, name: Optional[str] = None, + _data: Optional[torch.Tensor] = None, ) -> None: """Initialize an instance of the APImage class. @@ -66,7 +69,10 @@ def __init__( """ super().__init__(name=name) - self.data = data # units: flux + if _data is None: + self.data = data # units: flux + else: + self._data = _data self.crval = Param( "crval", shape=(2,), units="deg", dtype=AP_config.ap_dtype, device=AP_config.ap_device ) @@ -134,8 +140,9 @@ def data(self, value: Optional[torch.Tensor]): if value is None: self._data = torch.empty((0, 0), dtype=AP_config.ap_dtype, device=AP_config.ap_device) else: - self._data = torch.as_tensor( - value, dtype=AP_config.ap_dtype, device=AP_config.ap_device + # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates + self._data = torch.transpose( + torch.as_tensor(value, dtype=AP_config.ap_dtype, device=AP_config.ap_device), 0, 1 ) @property @@ -296,7 +303,7 @@ def copy(self, **kwargs): """ kwargs = { - "data": torch.clone(self.data.detach()), + "_data": torch.clone(self.data.detach()), "pixelscale": self.pixelscale.value, "crpix": self.crpix, "crval": self.crval.value, @@ -314,7 +321,7 @@ def blank_copy(self, **kwargs): """ kwargs = { - "data": torch.zeros_like(self.data), + "_data": torch.zeros_like(self.data), "pixelscale": self.pixelscale.value, "crpix": self.crpix, "crval": self.crval.value, @@ -345,21 +352,21 @@ def crop(self, pixels, **kwargs): crpix = self.crpix - crop elif len(pixels) == 2: # different crop in each dimension data = self.data[ - pixels[1] : self.data.shape[0] - pixels[1], - pixels[0] : self.data.shape[1] - pixels[0], + pixels[0] : self.data.shape[0] - pixels[0], + pixels[1] : self.data.shape[1] - pixels[1], ] crpix = self.crpix - pixels elif len(pixels) == 4: # different crop on all sides data = self.data[ - pixels[2] : self.data.shape[0] - pixels[3], - pixels[0] : self.data.shape[1] - pixels[1], + pixels[0] : self.data.shape[0] - pixels[1], + pixels[2] : self.data.shape[1] - pixels[3], ] - crpix = self.crpix - pixels[0::2] # fixme + crpix = self.crpix - pixels[0::2] else: raise ValueError( f"Invalid crop shape {pixels}, must be (int,), (int, int), or (int, int, int, int)!" ) - return self.copy(data=data, crpix=crpix, **kwargs) + return self.copy(_data=data, crpix=crpix, **kwargs) def reduce(self, scale: int, **kwargs): """This operation will downsample an image by the factor given. If @@ -388,7 +395,7 @@ def reduce(self, scale: int, **kwargs): pixelscale = self.pixelscale.value * scale crpix = (self.crpix + 0.5) / scale - 0.5 return self.copy( - data=data, + _data=data, pixelscale=pixelscale, crpix=crpix, **kwargs, @@ -400,6 +407,7 @@ def to(self, dtype=None, device=None): if device is None: device = AP_config.ap_device super().to(dtype=dtype, device=device) + self._data = self._data.to(dtype=dtype, device=device) if self.zeropoint is not None: self.zeropoint = self.zeropoint.to(dtype=dtype, device=device) return self @@ -413,8 +421,8 @@ def fits_info(self): "CTYPE2": "DEC--TAN", "CRVAL1": self.crval.value[0].item(), "CRVAL2": self.crval.value[1].item(), - "CRPIX2": self.crpix[0] + 1, - "CRPIX1": self.crpix[1] + 1, + "CRPIX1": self.crpix[0] + 1, + "CRPIX2": self.crpix[1] + 1, "CRTAN1": self.crtan.value[0].item(), "CRTAN2": self.crtan.value[1].item(), "CD1_1": self.pixelscale.value[0][0].item() * arcsec_to_deg, @@ -427,14 +435,17 @@ def fits_info(self): def fits_images(self): return [ - fits.PrimaryHDU(self.data.detach().cpu().numpy(), header=fits.Header(self.fits_info())) + fits.PrimaryHDU( + torch.transpose(self.data, 0, 1).detach().cpu().numpy(), + header=fits.Header(self.fits_info()), + ) ] def get_astropywcs(self, **kwargs): kwargs = { "NAXIS": 2, - "NAXIS2": self.shape[0].item(), - "NAXIS1": self.shape[1].item(), + "NAXIS1": self.shape[0].item(), + "NAXIS2": self.shape[1].item(), **self.fits_info(), **kwargs, } @@ -453,11 +464,6 @@ def load(self, filename: str, hduext=0): hdulist = fits.open(filename) self.data = np.array(hdulist[hduext].data, dtype=np.float64) - # NOTE: numpy arrays are indexed backwards as array[axis2,axis1], therefore we should - # import the CD matrix as ((CD1_2, CD1_1), (CD2_2, CD2_1)) since CD is indexed as CD{world}_{pixel} - # but it would be unweildy to use a CD matrix that includes an axis reversal, so instead we manually - # perform the axis reversal internally to the pixel_to_plane and plane_to_pixel methods. This fully - # accounts for the FITS vs numpy indexing differences, so other things like CRPIX must be flipped on import. self.pixelscale = ( np.array( ( @@ -468,7 +474,7 @@ def load(self, filename: str, hduext=0): ) * deg_to_arcsec ) - self.crpix = (hdulist[hduext].header["CRPIX2"] - 1, hdulist[hduext].header["CRPIX1"] - 1) + self.crpix = (hdulist[hduext].header["CRPIX1"] - 1, hdulist[hduext].header["CRPIX2"] - 1) self.crval = (hdulist[hduext].header["CRVAL1"], hdulist[hduext].header["CRVAL2"]) if "CRTAN1" in hdulist[hduext].header and "CRTAN2" in hdulist[hduext].header: self.crtan = (hdulist[hduext].header["CRTAN1"], hdulist[hduext].header["CRTAN2"]) @@ -536,7 +542,7 @@ def get_window(self, other: Union[Window, "Image"], indices=None, **kwargs): if indices is None: indices = self.get_indices(other if isinstance(other, Window) else other.window) new_img = self.copy( - data=self.data[indices], + _data=self.data[indices], crpix=self.crpix - np.array((indices[0].start, indices[1].start)), **kwargs, ) @@ -545,35 +551,35 @@ def get_window(self, other: Union[Window, "Image"], indices=None, **kwargs): def __sub__(self, other): if isinstance(other, Image): new_img = self[other] - new_img.data = new_img.data - other[self].data + new_img._data = new_img.data - other[self].data return new_img else: new_img = self.copy() - new_img.data = new_img.data - other + new_img._data = new_img.data - other return new_img def __add__(self, other): if isinstance(other, Image): new_img = self[other] - new_img.data = new_img.data + other[self].data + new_img._data = new_img.data + other[self].data return new_img else: new_img = self.copy() - new_img.data = new_img.data + other + new_img._data = new_img.data + other return new_img def __iadd__(self, other): if isinstance(other, Image): - self.data[self.get_indices(other.window)] += other.data[other.get_indices(self.window)] + self._data[self.get_indices(other.window)] += other.data[other.get_indices(self.window)] else: - self.data = self.data + other + self._data = self.data + other return self def __isub__(self, other): if isinstance(other, Image): - self.data[self.get_indices(other.window)] -= other.data[other.get_indices(self.window)] + self._data[self.get_indices(other.window)] -= other.data[other.get_indices(self.window)] else: - self.data = self.data - other + self._data = self.data - other return self def __getitem__(self, *args): diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index f6779665..a527caa3 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -45,7 +45,7 @@ def __iadd__(self, other: "JacobianImage"): other_loc = self.parameters.index(other_identity) else: continue - self.data[self_indices[0], self_indices[1], other_loc] += other.data[ + self._data[self_indices[0], self_indices[1], other_loc] += other.data[ other_indices[0], other_indices[1], i ] return self diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 0597a2fe..300d5312 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -13,16 +13,31 @@ class DataMixin: - def __init__(self, *args, mask=None, std=None, variance=None, weight=None, **kwargs): + def __init__( + self, + *args, + mask=None, + std=None, + variance=None, + weight=None, + _mask=None, + _weight=None, + **kwargs, + ): super().__init__(*args, **kwargs) - self.mask = mask + if _mask is None: + self.mask = mask + else: + self._mask = _mask if (std is not None) + (variance is not None) + (weight is not None) > 1: raise SpecificationConflict( "Can only define one of: std, variance, or weight for a given image." ) - if std is not None: + if _weight is not None: + self._weight = _weight + elif std is not None: self.std = std elif variance is not None: self.variance = variance @@ -31,7 +46,7 @@ def __init__(self, *args, mask=None, std=None, variance=None, weight=None, **kwa # Set nan pixels to be masked automatically if torch.any(torch.isnan(self.data)).item(): - self.mask = self.mask | torch.isnan(self.data) + self._mask = self.mask | torch.isnan(self.data) @property def std(self): @@ -153,12 +168,15 @@ def weight(self, weight): self._weight = None return if isinstance(weight, str) and weight == "auto": - weight = 1 / auto_variance(self.data, self.mask) - if weight.shape != self.data.shape: + weight = 1 / auto_variance(self.data, self.mask).T + self._weight = torch.transpose( + torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device), 0, 1 + ) + if self._weight.shape != self.data.shape: + self._weight = None raise SpecificationConflict( f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" ) - self._weight = torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device) @property def has_weight(self): @@ -197,11 +215,14 @@ def mask(self, mask): if mask is None: self._mask = None return + self._mask = torch.transpose( + torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device), 0, 1 + ) if mask.shape != self.data.shape: + self._mask = None raise SpecificationConflict( f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" ) - self._mask = torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device) @property def has_mask(self): @@ -227,7 +248,7 @@ def to(self, dtype=None, device=None): if self.has_weight: self._weight = self._weight.to(dtype=dtype, device=device) if self.has_mask: - self._mask = self.mask.to(dtype=torch.bool, device=device) + self._mask = self._mask.to(dtype=torch.bool, device=device) return self def copy(self, **kwargs): @@ -236,7 +257,7 @@ def copy(self, **kwargs): an image and then will want the original again. """ - kwargs = {"mask": self._mask, "weight": self._weight, **kwargs} + kwargs = {"_mask": self._mask, "_weight": self._weight, **kwargs} return super().copy(**kwargs) def blank_copy(self, **kwargs): @@ -244,7 +265,7 @@ def blank_copy(self, **kwargs): except that its data is now filled with zeros. """ - kwargs = {"mask": self._mask, "weight": self._weight, **kwargs} + kwargs = {"_mask": self._mask, "_weight": self._weight, **kwargs} return super().blank_copy(**kwargs) def get_window(self, other: Union[Image, Window], indices=None, **kwargs): @@ -253,8 +274,8 @@ def get_window(self, other: Union[Image, Window], indices=None, **kwargs): indices = self.get_indices(other if isinstance(other, Window) else other.window) return super().get_window( other, - weight=self._weight[indices] if self.has_weight else None, - mask=self._mask[indices] if self.has_mask else None, + _weight=self._weight[indices] if self.has_weight else None, + _mask=self._mask[indices] if self.has_mask else None, indices=indices, **kwargs, ) @@ -262,9 +283,15 @@ def get_window(self, other: Union[Image, Window], indices=None, **kwargs): def fits_images(self): images = super().fits_images() if self.has_weight: - images.append(fits.ImageHDU(self.weight.detach().cpu().numpy(), name="WEIGHT")) + images.append( + fits.ImageHDU( + torch.transpose(self.weight, 0, 1).detach().cpu().numpy(), name="WEIGHT" + ) + ) if self.has_mask: - images.append(fits.ImageHDU(self.mask.detach().cpu().numpy(), name="MASK")) + images.append( + fits.ImageHDU(torch.transpose(self.mask, 0, 1).detach().cpu().numpy(), name="MASK") + ) return images def load(self, filename: str, hduext=0): @@ -301,6 +328,7 @@ def reduce(self, scale, **kwargs): self.variance[: MS * scale, : NS * scale] .reshape(MS, scale, NS, scale) .sum(axis=(1, 3)) + .T if self.has_variance else None ), diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index afb591d3..325d2062 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -64,7 +64,7 @@ def update_distortion_model(self, distortion_ij=None, distortion_IJ=None, pixel_ ############################################################# if distortion_ij is None or distortion_IJ is None: i, j = self.pixel_center_meshgrid() - v, u = i - self.crpix[0], j - self.crpix[1] + u, v = i - self.crpix[0], j - self.crpix[1] if distortion_ij is None: distortion_ij = torch.stack(func.sip_delta(u, v, self.sipA, self.sipB), dim=0) if distortion_IJ is None: diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index ca11b5d3..10e07ed4 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -18,11 +18,8 @@ class ModelImage(Image): """ - def clear_image(self): - self.data = torch.zeros_like(self.data) - def fluxdensity_to_flux(self): - self.data = self.data * self.pixel_area + self._data = self.data * self.pixel_area ###################################################################### diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index 61dc5f83..c19fed29 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -131,7 +131,7 @@ def model_image(self, upsample=1, pad=0, **kwargs): "sipBP": self.sipBP, "distortion_ij": new_distortion_ij, "distortion_IJ": new_distortion_IJ, - "data": torch.zeros( + "_data": torch.zeros( (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), dtype=self.data.dtype, device=self.data.device, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 4626f98a..4d51c16b 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -171,7 +171,7 @@ def fits_images(self): if isinstance(self.psf, PSFImage): images.append( fits.ImageHDU( - self.psf.data.detach().cpu().numpy(), + torch.transpose(self.psf.data, 0, 1).detach().cpu().numpy(), name="PSF", header=fits.Header(self.psf.fits_info()), ) @@ -221,14 +221,14 @@ def jacobian_image( "name": self.name + "_jacobian", **kwargs, } - return JacobianImage(parameters=parameters, data=data, **kwargs) + return JacobianImage(parameters=parameters, _data=data, **kwargs) def model_image(self, upsample=1, pad=0, **kwargs): """ Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. """ kwargs = { - "data": torch.zeros( + "_data": torch.zeros( (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), dtype=self.data.dtype, device=self.data.device, diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index e601acee..83c6302a 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -223,12 +223,12 @@ def sample( working_image = self.target[window].model_image(upsample=psf_upscale, pad=psf_pad) sample = self.sample_image(working_image) - working_image.data = func.convolve(sample, psf) + working_image._data = func.convolve(sample, psf) working_image = working_image.crop([psf_pad]).reduce(psf_upscale) elif "none" in self.psf_mode: working_image = self.target[window].model_image() - working_image.data = self.sample_image(working_image) + working_image._data = self.sample_image(working_image) else: raise SpecificationConflict( f"Unknown PSF mode {self.psf_mode} for model {self.name}. " diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index afe71da5..d32872a1 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -51,7 +51,7 @@ def target_image(fig, ax, target, window=None, **kwargs): dat = np.copy(target_area.data.detach().cpu().numpy()) if target_area.has_mask: dat[target_area.mask.detach().cpu().numpy()] = np.nan - X, Y = target_area.pixel_to_plane(*target_area.pixel_corner_meshgrid()) + X, Y = target_area.coordinate_corner_meshgrid() X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() sky = np.nanmedian(dat) @@ -63,9 +63,9 @@ def target_image(fig, ax, target, window=None, **kwargs): if kwargs.get("linear", False): im = ax.pcolormesh( - X.T, - Y.T, - dat.T, + X, + Y, + dat, cmap=cmap_grad, ) else: diff --git a/astrophot/utils/initialize/variance.py b/astrophot/utils/initialize/variance.py index 9b8b65e9..16ae21cc 100644 --- a/astrophot/utils/initialize/variance.py +++ b/astrophot/utils/initialize/variance.py @@ -46,9 +46,7 @@ def auto_variance(data, mask=None): # Check if the variance is increasing with flux if p[0] < 0: - raise InvalidData( - "Variance appears to be decreasing with flux! Cannot accurately estimate variance." - ) + return np.ones_like(data) * var # Compute the approximate variance map variance = np.clip(p[0] * data + p[1], np.min(std) ** 2, None) variance[np.logical_not(mask)] = np.inf diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 3104dbff..a692b350 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -95,17 +95,7 @@ "hdu = fits.open(\n", " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", ")\n", - "target_data = np.array(hdu[0].data, dtype=np.float64)\n", - "plt.imshow(\n", - " target_data,\n", - " origin=\"lower\",\n", - " cmap=\"gray_r\",\n", - " vmin=np.percentile(target_data, 1),\n", - " vmax=np.percentile(target_data, 99),\n", - ")\n", - "plt.colorbar()\n", - "plt.title(\"Target Image\")\n", - "\n", + "target_data = np.array(hdu[0].data, dtype=np.float64) # [:-50]\n", "\n", "# Create a target object with specified pixelscale and zeropoint\n", "target = ap.image.TargetImage(\n", @@ -114,8 +104,6 @@ " zeropoint=22.5, # optionally, you can give a zeropoint to tell AstroPhot what the pixel flux units are\n", " variance=\"auto\", # Automatic variance estimate for testing and demo purposes, in real analysis use weight maps, counts, gain, etc to compute variance!\n", ")\n", - "i, j = target.pixel_center_meshgrid()\n", - "print(torch.all(torch.tensor(target_data) == target_data[i.int(), j.int()]))\n", "\n", "# The default AstroPhot target plotting method uses log scaling in bright areas and histogram scaling in faint areas\n", "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", @@ -141,7 +129,8 @@ "# to set just a few parameters and let AstroPhot try to figure out the rest. For example you could give it an initial\n", "# Guess for the center and it will work from there.\n", "model2.initialize()\n", - "\n", + "print(model2.window)\n", + "print(model2().window)\n", "# Plotting the initial parameters and residuals, we see it gets the rough shape of the galaxy right, but still has some fitting to do\n", "fig4, ax4 = plt.subplots(1, 2, figsize=(16, 6))\n", "ap.plots.model_image(fig4, ax4[0], model2)\n", @@ -260,9 +249,10 @@ "model3 = ap.models.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " window=[555, 665, 480, 595], # this is a region in pixel coordinates (imin,imax,jmin,jmax)\n", + " window=[480, 595, 555, 665], # this is a region in pixel coordinates (imin,imax,jmin,jmax)\n", ")\n", - "\n", + "print(model3.window)\n", + "print(target[model3.window].shape)\n", "print(f\"automatically generated name: '{model3.name}'\")\n", "\n", "# We can plot the \"model window\" to show us what part of the image will be analyzed by that model\n", @@ -468,7 +458,8 @@ "\n", "fig2, ax2 = plt.subplots(figsize=(8, 8))\n", "\n", - "pixels = model2().data.detach().cpu().numpy()\n", + "# Transpose because AstroPhot indexes with (i,j) while numpy uses (j,i)\n", + "pixels = model2().data.T.detach().cpu().numpy()\n", "\n", "im = plt.imshow(\n", " np.log10(pixels), # take log10 for better dynamic range\n", From 969af31b43811a984d56d7569f5159065a6ace23 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 11 Jul 2025 16:24:34 -0400 Subject: [PATCH 054/191] getting tests online pixelscale to CD --- astrophot/__init__.py | 44 +- astrophot/fit/func/lm.py | 1 - astrophot/image/func/wcs.py | 2 +- astrophot/image/image_object.py | 124 +-- astrophot/image/jacobian_image.py | 7 + astrophot/image/mixins/data_mixin.py | 14 +- astrophot/image/mixins/sip_mixin.py | 8 +- astrophot/image/model_image.py | 12 +- astrophot/image/psf_image.py | 9 +- astrophot/image/sip_image.py | 4 +- astrophot/image/target_image.py | 115 +- astrophot/image/window.py | 3 +- astrophot/models/_shared_methods.py | 2 +- astrophot/models/airy.py | 2 +- astrophot/models/edgeon.py | 4 +- astrophot/models/mixins/sample.py | 4 + astrophot/models/mixins/spline.py | 4 +- astrophot/models/mixins/transform.py | 6 +- astrophot/models/model_object.py | 61 +- astrophot/models/multi_gaussian_expansion.py | 4 +- astrophot/param/param.py | 15 +- astrophot/plots/image.py | 18 +- astrophot/plots/profile.py | 21 +- docs/source/tutorials/GettingStarted.ipynb | 37 +- tests/test_image.py | 1014 ++++++------------ tests/test_image_header.py | 144 --- tests/test_image_list.py | 628 +++-------- tests/test_model.py | 379 ++----- tests/test_param.py | 32 + tests/test_parameter.py | 570 ---------- tests/test_plots.py | 341 +++--- tests/utils.py | 27 +- 32 files changed, 1064 insertions(+), 2592 deletions(-) delete mode 100644 tests/test_image_header.py create mode 100644 tests/test_param.py delete mode 100644 tests/test_parameter.py diff --git a/astrophot/__init__.py b/astrophot/__init__.py index 43d99468..c36afa98 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -1,7 +1,23 @@ import argparse import requests import torch -from . import models, image, plots, utils, fit, AP_config +from . import models, plots, utils, fit, AP_config + +from .image import ( + Image, + ImageList, + TargetImage, + TargetImageList, + SIPTargetImage, + JacobianImage, + JacobianImageList, + PSFImage, + ModelImage, + ModelImageList, + Window, + WindowList, +) +from .models import Model try: from ._version import version as VERSION # noqa @@ -119,3 +135,29 @@ def run_from_terminal() -> None: AP_config.ap_logger.info("collected the tutorials") else: raise ValueError(f"Unrecognized request") + + +__all__ = ( + "models", + "Model", + "Image", + "ImageList", + "TargetImage", + "TargetImageList", + "SIPTargetImage", + "JacobianImage", + "JacobianImageList", + "PSFImage", + "ModelImage", + "ModelImageList", + "Window", + "WindowList", + "plots", + "utils", + "fit", + "AP_config", + "run_from_terminal", + "__version__", + "__author__", + "__email__", +) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 31b5c5e5..eb1763d3 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -25,7 +25,6 @@ def solve(hess, grad, L): h = torch.linalg.solve(hessD, grad) break except torch._C._LinAlgError: - print("Damping Hessian", L) hessD = hessD + L * torch.eye(len(hessD), dtype=hessD.dtype, device=hessD.device) L = L * 2 return hessD, h diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index e2ae3f72..b716e320 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -43,7 +43,7 @@ def world_to_plane_gnomonic(ra, dec, ra0, dec0, x0=0.0, y0=0.0): return x * rad_to_arcsec / cosc + x0, y * rad_to_arcsec / cosc + y0 -def plane_to_world_gnomonic(x, y, ra0, dec0, x0=0.0, y0=0.0, s=1e-3): +def plane_to_world_gnomonic(x, y, ra0, dec0, x0=0.0, y0=0.0, s=1e-10): """ Convert plane coordinates (x, y) to world coordinates (RA, Dec) using the gnomonic projection. Parameters diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 880369fe..cebfc274 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -19,32 +19,24 @@ class Image(Module): """Core class to represent images with pixel values, pixel scale, - and a window defining the spatial coordinates on the sky. - It supports arithmetic operations with other image objects while preserving logical image boundaries. - It also provides methods for determining the coordinate locations of pixels - - Parameters: - data: the matrix of pixel values for the image - pixelscale: the length of one side of a pixel in arcsec/pixel - window: an AstroPhot Window object which defines the spatial coordinates on the sky - filename: a filename from which to load the image. - zeropoint: photometric zero point for converting from pixel flux to magnitude - metadata: Any information the user wishes to associate with this image, stored in a python dictionary - origin: The origin of the image in the coordinate system. + and a window defining the spatial coordinates on the sky. + It supports arithmetic operations with other image objects while preserving logical image boundaries. + It also provides methods for determining the coordinate locations of pixels """ - default_pixelscale = ((1.0, 0.0), (0.0, 1.0)) + default_CD = ((1.0, 0.0), (0.0, 1.0)) expect_ctype = (("RA---TAN",), ("DEC--TAN",)) def __init__( self, *, data: Optional[torch.Tensor] = None, - pixelscale: Optional[Union[float, torch.Tensor]] = None, + CD: Optional[Union[float, torch.Tensor]] = None, zeropoint: Optional[Union[float, torch.Tensor]] = None, crpix: Union[torch.Tensor, tuple] = (0.0, 0.0), crtan: Union[torch.Tensor, tuple] = (0.0, 0.0), crval: Union[torch.Tensor, tuple] = (0.0, 0.0), + pixelscale: Optional[Union[torch.Tensor, float]] = None, wcs: Optional[AstropyWCS] = None, filename: Optional[str] = None, hduext=0, @@ -83,8 +75,8 @@ def __init__( dtype=AP_config.ap_dtype, device=AP_config.ap_device, ) - self.pixelscale = Param( - "pixelscale", + self.CD = Param( + "CD", shape=(2, 2), units="arcsec/pixel", dtype=AP_config.ap_dtype, @@ -114,20 +106,24 @@ def __init__( crval = wcs.wcs.crval crpix = np.array(wcs.wcs.crpix)[::-1] - 1 # handle FITS 1-indexing - if pixelscale is not None: + if CD is not None: AP_config.ap_logger.warning( - "WCS pixelscale set with supplied WCS, ignoring user supplied pixelscale!" + "WCS CD set with supplied WCS, ignoring user supplied CD!" ) - pixelscale = deg_to_arcsec * wcs.pixel_scale_matrix + CD = deg_to_arcsec * wcs.pixel_scale_matrix # set the data self.crval = crval self.crtan = crtan self.crpix = crpix - if isinstance(pixelscale, (float, int)): - pixelscale = np.array([[pixelscale, 0.0], [0.0, pixelscale]], dtype=np.float64) - self.pixelscale = pixelscale + if isinstance(CD, (float, int)): + CD = np.array([[CD, 0.0], [0.0, CD]], dtype=np.float64) + elif CD is None and pixelscale is not None: + CD = np.array([[pixelscale, 0.0], [0.0, pixelscale]], dtype=np.float64) + elif CD is None: + CD = self.default_CD + self.CD = CD @property def data(self): @@ -178,7 +174,7 @@ def center(self): shape = torch.as_tensor( self.data.shape[:2], dtype=AP_config.ap_dtype, device=AP_config.ap_device ) - return self.pixel_to_plane(*((shape - 1) / 2)) + return torch.stack(self.pixel_to_plane(*((shape - 1) / 2))) @property def shape(self): @@ -187,39 +183,30 @@ def shape(self): @property @forward - def pixel_area(self, pixelscale): + def pixel_area(self, CD): """The area inside a pixel in arcsec^2""" - return torch.linalg.det(pixelscale).abs() + return torch.linalg.det(CD).abs() @property @forward - def pixel_length(self): - """The approximate length of a pixel, which is just + def pixelscale(self): + """The approximate side length of a pixel, which is just sqrt(pixel_area). For square pixels this is the actual pixel length, for rectangular pixels it is a kind of average. - The pixel_length is typically not used for exact calculations + The pixelscale is not used for exact calculations and instead sets a size scale within an image. """ return self.pixel_area.sqrt() - @property - @forward - def pixelscale_inv(self, pixelscale): - """The inverse of the pixel scale matrix, which is used to - transform tangent plane coordinates into pixel coordinates. - - """ - return torch.linalg.inv(pixelscale) - @forward - def pixel_to_plane(self, i, j, crtan, pixelscale): - return func.pixel_to_plane_linear(i, j, *self.crpix, pixelscale, *crtan) + def pixel_to_plane(self, i, j, crtan, CD): + return func.pixel_to_plane_linear(i, j, *self.crpix, CD, *crtan) @forward - def plane_to_pixel(self, x, y, crtan, pixelscale): - return func.plane_to_pixel_linear(x, y, *self.crpix, pixelscale, *crtan) + def plane_to_pixel(self, x, y, crtan, CD): + return func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) @forward def plane_to_world(self, x, y, crval): @@ -304,7 +291,7 @@ def copy(self, **kwargs): """ kwargs = { "_data": torch.clone(self.data.detach()), - "pixelscale": self.pixelscale.value, + "CD": self.CD.value, "crpix": self.crpix, "crval": self.crval.value, "crtan": self.crtan.value, @@ -322,16 +309,9 @@ def blank_copy(self, **kwargs): """ kwargs = { "_data": torch.zeros_like(self.data), - "pixelscale": self.pixelscale.value, - "crpix": self.crpix, - "crval": self.crval.value, - "crtan": self.crtan.value, - "zeropoint": self.zeropoint, - "identity": self.identity, - "name": self.name, **kwargs, } - return self.__class__(**kwargs) + return self.copy(**kwargs) def crop(self, pixels, **kwargs): """Crop the image by the number of pixels given. This will crop @@ -392,11 +372,11 @@ def reduce(self, scale: int, **kwargs): NS = self.data.shape[1] // scale data = self.data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) - pixelscale = self.pixelscale.value * scale + CD = self.CD.value * scale crpix = (self.crpix + 0.5) / scale - 0.5 return self.copy( _data=data, - pixelscale=pixelscale, + CD=CD, crpix=crpix, **kwargs, ) @@ -425,10 +405,10 @@ def fits_info(self): "CRPIX2": self.crpix[1] + 1, "CRTAN1": self.crtan.value[0].item(), "CRTAN2": self.crtan.value[1].item(), - "CD1_1": self.pixelscale.value[0][0].item() * arcsec_to_deg, - "CD1_2": self.pixelscale.value[0][1].item() * arcsec_to_deg, - "CD2_1": self.pixelscale.value[1][0].item() * arcsec_to_deg, - "CD2_2": self.pixelscale.value[1][1].item() * arcsec_to_deg, + "CD1_1": self.CD.value[0][0].item() * arcsec_to_deg, + "CD1_2": self.CD.value[0][1].item() * arcsec_to_deg, + "CD2_1": self.CD.value[1][0].item() * arcsec_to_deg, + "CD2_2": self.CD.value[1][1].item() * arcsec_to_deg, "MAGZP": self.zeropoint.item() if self.zeropoint is not None else -999, "IDNTY": self.identity, } @@ -457,14 +437,14 @@ def save(self, filename: str): def load(self, filename: str, hduext=0): """Load an image from a FITS file. This will load the primary HDU - and set the data, pixelscale, crpix, crval, and crtan attributes + and set the data, CD, crpix, crval, and crtan attributes accordingly. If the WCS is not tangent plane, it will warn the user. """ hdulist = fits.open(filename) self.data = np.array(hdulist[hduext].data, dtype=np.float64) - self.pixelscale = ( + self.CD = ( np.array( ( (hdulist[hduext].header["CD1_1"], hdulist[hduext].header["CD1_2"]), @@ -601,11 +581,6 @@ def __init__(self, images, name=None): def data(self): return tuple(image.data for image in self.images) - @data.setter - def data(self, data): - for image, dat in zip(self.images, data): - image.data = dat - def copy(self): return self.__class__( tuple(image.copy() for image in self.images), @@ -626,7 +601,9 @@ def index(self, other: Image): if other.identity == image.identity: return i else: - raise ValueError("Could not find identity match between image list and input image") + raise IndexError( + f"Could not find identity match between image list {self.name} and input image {other.name}" + ) def match_indices(self, other: "ImageList"): """Match the indices of the images in this list with those in another Image_List.""" @@ -634,7 +611,7 @@ def match_indices(self, other: "ImageList"): for other_image in other.images: try: i = self.index(other_image) - except ValueError: + except IndexError: continue indices.append(i) return indices @@ -665,7 +642,10 @@ def __add__(self, other): if isinstance(other, ImageList): new_list = [] for other_image in other.images: - i = self.index(other_image) + try: + i = self.index(other_image) + except IndexError: + continue self_image = self.images[i] new_list.append(self_image + other_image) return self.__class__(new_list) @@ -675,7 +655,10 @@ def __add__(self, other): def __isub__(self, other): if isinstance(other, ImageList): for other_image in other.images: - i = self.index(other_image) + try: + i = self.index(other_image) + except IndexError: + continue self.images[i] -= other_image elif isinstance(other, Image): i = self.index(other) @@ -687,7 +670,10 @@ def __isub__(self, other): def __iadd__(self, other): if isinstance(other, ImageList): for other_image in other.images: - i = self.index(other_image) + try: + i = self.index(other_image) + except IndexError: + continue self.images[i] += other_image elif isinstance(other, Image): i = self.index(other) @@ -716,6 +702,8 @@ def __getitem__(self, *args): elif isinstance(args[0], Window): i = self.index(args[0].image) return self.images[i].get_window(args[0]) + elif isinstance(args[0], int): + return self.images[args[0]] super().__getitem__(*args) def __iter__(self): diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index a527caa3..7c3666cd 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -65,6 +65,13 @@ class JacobianImageList(ImageList): """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not all(isinstance(image, (JacobianImage, JacobianImageList)) for image in self.images): + raise InvalidImage( + f"JacobianImageList can only hold JacobianImage objects, not {tuple(type(image) for image in self.images)}" + ) + def flatten(self, attribute="data"): if len(self.images) > 1: for image in self.images[1:]: diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 300d5312..0475e41d 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -218,7 +218,7 @@ def mask(self, mask): self._mask = torch.transpose( torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device), 0, 1 ) - if mask.shape != self.data.shape: + if self._mask.shape != self.data.shape: self._mask = None raise SpecificationConflict( f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" @@ -290,7 +290,9 @@ def fits_images(self): ) if self.has_mask: images.append( - fits.ImageHDU(torch.transpose(self.mask, 0, 1).detach().cpu().numpy(), name="MASK") + fits.ImageHDU( + torch.transpose(self.mask, 0, 1).detach().cpu().numpy().astype(int), name="MASK" + ) ) return images @@ -324,15 +326,15 @@ def reduce(self, scale, **kwargs): return super().reduce( scale=scale, - variance=( - self.variance[: MS * scale, : NS * scale] + _weight=( + 1 + / self.variance[: MS * scale, : NS * scale] .reshape(MS, scale, NS, scale) .sum(axis=(1, 3)) - .T if self.has_variance else None ), - mask=( + _mask=( self.mask[: MS * scale, : NS * scale] .reshape(MS, scale, NS, scale) .amax(axis=(1, 3)) diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 325d2062..ee5d6037 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -39,14 +39,14 @@ def __init__( ) @forward - def pixel_to_plane(self, i, j, crtan, pixelscale): + def pixel_to_plane(self, i, j, crtan, CD): di = interp2d(self.distortion_ij[0], j, i) dj = interp2d(self.distortion_ij[1], j, i) - return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, pixelscale, *crtan) + return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, CD, *crtan) @forward - def plane_to_pixel(self, x, y, crtan, pixelscale): - I, J = func.plane_to_pixel_linear(x, y, *self.crpix, pixelscale, *crtan) + def plane_to_pixel(self, x, y, crtan, CD): + I, J = func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) dI = interp2d(self.distortion_IJ[0], J, I) dJ = interp2d(self.distortion_IJ[1], J, I) return I + dI, J + dJ diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 10e07ed4..4ac940d7 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -1,9 +1,5 @@ -import numpy as np -import torch - -from .. import AP_config from .image_object import Image, ImageList -from ..errors import InvalidImage, SpecificationConflict +from ..errors import InvalidImage __all__ = ["ModelImage", "ModelImageList"] @@ -26,11 +22,7 @@ def fluxdensity_to_flux(self): class ModelImageList(ImageList): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not all(isinstance(image, ModelImage) for image in self.images): + if not all(isinstance(image, (ModelImage, ModelImageList)) for image in self.images): raise InvalidImage( f"Model_Image_List can only hold Model_Image objects, not {tuple(type(image) for image in self.images)}" ) - - def clear_image(self): - for image in self.images: - image.clear_image() diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 3421f8a9..46725be6 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -4,7 +4,6 @@ import numpy as np from .image_object import Image -from .model_image import ModelImage from .jacobian_image import JacobianImage from .. import AP_config from .mixins import DataMixin @@ -43,6 +42,10 @@ def normalize(self): if self.has_weight: self.weight = self.weight * norm**2 + @property + def psf_pad(self): + return np.max(self.data.shape) // 2 + def jacobian_image( self, parameters: Optional[List[str]] = None, @@ -62,7 +65,7 @@ def jacobian_image( device=AP_config.ap_device, ) kwargs = { - "pixelscale": self.pixelscale.value, + "CD": self.CD.value, "crpix": self.crpix, "crtan": self.crtan.value, "crval": self.crval.value, @@ -78,7 +81,7 @@ def model_image(self, **kwargs): """ kwargs = { "data": torch.zeros_like(self.data), - "pixelscale": self.pixelscale.value, + "CD": self.CD.value, "crpix": self.crpix, "crtan": self.crtan.value, "crval": self.crval.value, diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index c19fed29..42bbacb0 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -85,7 +85,7 @@ def reduce(self, scale: int, **kwargs): ) def fluxdensity_to_flux(self): - self.data = self.data * self.pixel_area_map + self._data = self.data * self.pixel_area_map class SIPTargetImage(SIPMixin, TargetImage): @@ -136,7 +136,7 @@ def model_image(self, upsample=1, pad=0, **kwargs): dtype=self.data.dtype, device=self.data.device, ), - "pixelscale": self.pixelscale.value / upsample, + "CD": self.CD.value / upsample, "crpix": (self.crpix + 0.5) * upsample + pad - 0.5, "crtan": self.crtan.value, "crval": self.crval.value, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 4d51c16b..3c1fc51d 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -135,7 +135,7 @@ def psf(self, psf): else: self._psf = PSFImage( data=psf, - pixelscale=self.pixelscale, + CD=self.CD, name=self.name + "_psf", ) @@ -148,23 +148,6 @@ def copy(self, **kwargs): kwargs = {"psf": self.psf, **kwargs} return super().copy(**kwargs) - def blank_copy(self, **kwargs): - """Produces a blank copy of the image which has the same properties - except that its data is now filled with zeros. - - """ - kwargs = {"psf": self.psf, **kwargs} - return super().blank_copy(**kwargs) - - def get_window(self, other: Union[Image, Window], indices=None, **kwargs): - """Get a sub-region of the image as defined by an other image on the sky.""" - return super().get_window( - other, - psf=self.psf, - indices=indices, - **kwargs, - ) - def fits_images(self): images = super().fits_images() if self.has_psf: @@ -189,7 +172,7 @@ def load(self, filename: str, hduext=0): if "PSF" in hdulist: self.psf = PSFImage( data=np.array(hdulist["PSF"].data, dtype=np.float64), - pixelscale=( + CD=( (hdulist["PSF"].header["CD1_1"], hdulist["PSF"].header["CD1_2"]), (hdulist["PSF"].header["CD2_1"], hdulist["PSF"].header["CD2_2"]), ), @@ -212,7 +195,7 @@ def jacobian_image( device=AP_config.ap_device, ) kwargs = { - "pixelscale": self.pixelscale.value, + "CD": self.CD.value, "crpix": self.crpix, "crtan": self.crtan.value, "crval": self.crval.value, @@ -233,7 +216,7 @@ def model_image(self, upsample=1, pad=0, **kwargs): dtype=self.data.dtype, device=self.data.device, ), - "pixelscale": self.pixelscale.value / upsample, + "CD": self.CD.value / upsample, "crpix": (self.crpix + 0.5) * upsample + pad - 0.5, "crtan": self.crtan.value, "crval": self.crval.value, @@ -244,11 +227,39 @@ def model_image(self, upsample=1, pad=0, **kwargs): } return ModelImage(**kwargs) + def psf_image(self, data, upscale=1, **kwargs): + kwargs = { + "_data": data, + "CD": self.CD.value / upscale, + "identity": self.identity, + "name": self.name + "_psf", + **kwargs, + } + return PSFImage(**kwargs) + + def reduce(self, scale, **kwargs): + """Returns a new `Target_Image` object with a reduced resolution + compared to the current image. `scale` should be an integer + indicating how much to reduce the resolution. If the + `Target_Image` was originally (48,48) pixels across with a + pixelscale of 1 and `reduce(2)` is called then the image will + be (24,24) pixels and the pixelscale will be 2. If `reduce(3)` + is called then the returned image will be (16,16) pixels + across and the pixelscale will be 3. + + """ + + return super().reduce( + scale=scale, + psf=(self.psf.reduce(scale) if isinstance(self.psf, PSFImage) else None), + **kwargs, + ) + class TargetImageList(ImageList): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not all(isinstance(image, TargetImage) for image in self.images): + if not all(isinstance(image, (TargetImage, TargetImageList)) for image in self.images): raise InvalidImage( f"Target_Image_List can only hold Target_Image objects, not {tuple(type(image) for image in self.images)}" ) @@ -289,58 +300,6 @@ def jacobian_image(self, parameters: List[str], data: Optional[List[torch.Tensor def model_image(self): return ModelImageList(list(image.model_image() for image in self.images)) - def match_indices(self, other): - indices = [] - if isinstance(other, TargetImageList): - for other_image in other.images: - for isi, self_image in enumerate(self.images): - if other_image.identity == self_image.identity: - indices.append(isi) - break - else: - indices.append(None) - elif isinstance(other, TargetImage): - for isi, self_image in enumerate(self.images): - if other.identity == self_image.identity: - indices = isi - break - else: - indices = None - return indices - - def __isub__(self, other): - if isinstance(other, ImageList): - for other_image in other.images: - for self_image in self.images: - if other_image.identity == self_image.identity: - self_image -= other_image - break - elif isinstance(other, Image): - for self_image in self.images: - if other.identity == self_image.identity: - self_image -= other - break - else: - for self_image, other_image in zip(self.images, other): - self_image -= other_image - return self - - def __iadd__(self, other): - if isinstance(other, ImageList): - for other_image in other.images: - for self_image in self.images: - if other_image.identity == self_image.identity: - self_image += other_image - break - elif isinstance(other, Image): - for self_image in self.images: - if other.identity == self_image.identity: - self_image += other - else: - for self_image, other_image in zip(self.images, other): - self_image += other_image - return self - @property def mask(self): return tuple(image.mask for image in self.images) @@ -366,11 +325,3 @@ def psf(self, psf): @property def has_psf(self): return any(image.has_psf for image in self.images) - - @property - def psf_border(self): - return tuple(image.psf_border for image in self.images) - - @property - def psf_border_int(self): - return tuple(image.psf_border_int for image in self.images) diff --git a/astrophot/image/window.py b/astrophot/image/window.py index 1f3be919..efd697a7 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -105,7 +105,7 @@ def __ior__(self, other: "Window"): def __and__(self, other: "Window"): if not isinstance(other, Window): raise TypeError(f"Cannot intersect Window with {type(other)}") - if self.image != other.image: + if self.image.identity != other.image.identity: raise InvalidWindow( f"Cannot combine Windows from different images: {self.image.identity} and {other.image.identity}" ) @@ -116,6 +116,7 @@ def __and__(self, other: "Window"): or self.j_low >= other.j_high ): return Window((0, 0, 0, 0), self.image) + # fixme handle crpix new_i_low = max(self.i_low, other.i_low) new_i_high = min(self.i_high, other.i_high) new_j_low = max(self.j_low, other.j_low) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 0fa51eab..56dff9a7 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -37,7 +37,7 @@ def _sample_image( # Bin fluxes by radius if rad_bins is None: rad_bins = np.logspace( - np.log10(R.min() * 0.9 + image.pixel_length / 2), np.log10(R.max() * 1.1), 11 + np.log10(R.min() * 0.9 + image.pixelscale / 2), np.log10(R.max() * 1.1), 11 ) else: rad_bins = np.array(rad_bins) diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index 3b5f14f9..7637ca29 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -60,7 +60,7 @@ def initialize(self): ] self.I0.dynamic_value = torch.mean(mid_chunk) / self.target.pixel_area if not self.aRL.initialized: - self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixel_length + self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixelscale @forward def radial_model(self, R, I0, aRL): diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index f0b56fea..f6a54cb1 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -82,7 +82,7 @@ def initialize(self): ] self.I0.dynamic_value = torch.mean(chunk) / self.target.pixel_area if not self.hs.initialized: - self.hs.value = torch.max(self.window.shape) * target_area.pixel_length * 0.1 + self.hs.value = torch.max(self.window.shape) * target_area.pixelscale * 0.1 @forward def brightness(self, x, y, I0, hs): @@ -106,7 +106,7 @@ def initialize(self): super().initialize() if self.rs.initialized: return - self.rs.value = torch.max(self.window.shape) * self.target.pixel_length * 0.4 + self.rs.value = torch.max(self.window.shape) * self.target.pixelscale * 0.4 @forward def radial_model(self, R, rs): diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index c0a85ba0..6d9e0ce5 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -101,6 +101,10 @@ def sample_image(self, image: Image): ) if self.integrate_mode == "threshold": sample = self._sample_integrate(sample, image) + elif self.integrate_mode != "none": + raise SpecificationConflict( + f"Unknown integrate mode {self.integrate_mode} for model {self.name}" + ) return sample def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor): diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 895fcf6a..674a552b 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -31,7 +31,7 @@ def initialize(self): target_area = self.target[self.window] # Create the I_R profile radii if needed if self.I_R.prof is None: - prof = default_prof(self.window.shape, target_area.pixel_length, 2, 0.2) + prof = default_prof(self.window.shape, target_area.pixelscale, 2, 0.2) self.I_R.prof = prof else: prof = self.I_R.prof @@ -75,7 +75,7 @@ def initialize(self): target_area = self.target[self.window] # Create the I_R profile radii if needed if self.I_R.prof is None: - prof = default_prof(self.window.shape, target_area.pixel_length, 2, 0.2) + prof = default_prof(self.window.shape, target_area.pixelscale, 2, 0.2) self.I_R.prof = [prof] * self.segments else: prof = self.I_R.prof diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 6d48b1d0..30b74114 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -205,11 +205,11 @@ def initialize(self): if not self.PA_R.initialized: if self.PA_R.prof is None: - self.PA_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.PA_R.prof = default_prof(self.window.shape, self.target.pixelscale, 2, 0.2) self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 if not self.q_R.initialized: if self.q_R.prof is None: - self.q_R.prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + self.q_R.prof = default_prof(self.window.shape, self.target.pixelscale, 2, 0.2) self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 @forward @@ -247,7 +247,7 @@ def __init__(self, *args, outer_truncation=True, **kwargs): def initialize(self): super().initialize() if not self.Rt.initialize: - prof = default_prof(self.window.shape, self.target.pixel_length, 2, 0.2) + prof = default_prof(self.window.shape, self.target.pixelscale, 2, 0.2) self.Rt.dynamic_value = prof[len(prof) // 2] if not self.sharpness.initialized: self.sharpness.dynamic_value = 1.0 diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 83c6302a..feacad76 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -14,7 +14,7 @@ from ..utils.initialize import recursive_center_of_mass from ..utils.decorators import ignore_numpy_warnings from .. import AP_config -from ..errors import InvalidTarget, SpecificationConflict +from ..errors import InvalidTarget from .mixins import SampleMixin __all__ = ["ComponentModel"] @@ -54,9 +54,9 @@ class ComponentModel(SampleMixin, Model): _parameter_specs = {"center": {"units": "arcsec", "shape": (2,)}} # Scope for PSF convolution - psf_mode = "none" # none, full + psf_convolve = False - _options = ("psf_mode",) + _options = ("psf_convolve",) usable = False def __init__(self, *args, psf=None, **kwargs): @@ -75,15 +75,13 @@ def psf(self, val): self._psf = None elif isinstance(val, PSFImage): self._psf = val + self.psf_convolve = True elif isinstance(val, Model): self._psf = val + self.psf_convolve = True else: - self._psf = PSFImage(name="psf", data=val, pixelscale=self.target.pixelscale) - AP_config.ap_logger.warning( - "Setting PSF with pixel image, assuming target pixelscale is the same as " - "PSF pixelscale. To remove this warning, set PSFs as an ap.image.PSF_Image " - "or ap.models.PSF_Model object instead." - ) + self._psf = self.target.psf_image(data=val) + self.psf_convolve = True self.update_psf_upscale() def update_psf_upscale(self): @@ -92,11 +90,11 @@ def update_psf_upscale(self): self.psf_upscale = 1 elif isinstance(self.psf, PSFImage): self.psf_upscale = ( - torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() + torch.round(self.target.pixelscale / self.psf.pixelscale).int().item() ) elif isinstance(self.psf, Model): self.psf_upscale = ( - torch.round(self.target.pixel_length / self.psf.target.pixel_length).int().item() + torch.round(self.target.pixelscale / self.psf.target.pixelscale).int().item() ) else: raise TypeError( @@ -170,7 +168,6 @@ def transform_coordinates(self, x, y, center): def sample( self, window: Optional[Window] = None, - center=None, ): """Evaluate the model on the pixels defined in an image. This function properly calls integration methods and PSF @@ -201,41 +198,21 @@ def sample( if window is None: window = self.window - if "full" in self.psf_mode: - if isinstance(self.psf, PSFImage): - psf_upscale = ( - torch.round(self.target.pixel_length / self.psf.pixel_length).int().item() - ) - psf_pad = np.max(self.psf.shape) // 2 - psf = self.psf.data - elif isinstance(self.psf, Model): - psf_upscale = ( - torch.round(self.target.pixel_length / self.psf.target.pixel_length) - .int() - .item() - ) - psf_pad = np.max(self.psf.window.shape) // 2 - psf = self.psf().data - else: - raise TypeError( - f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." - ) - - working_image = self.target[window].model_image(upsample=psf_upscale, pad=psf_pad) + if self.psf_convolve: + psf = self.psf() if isinstance(self.psf, Model) else self.psf + + working_image = self.target[window].model_image( + upsample=self.psf_upscale, pad=psf.psf_pad + ) sample = self.sample_image(working_image) - working_image._data = func.convolve(sample, psf) - working_image = working_image.crop([psf_pad]).reduce(psf_upscale) + working_image._data = func.convolve(sample, psf.data) + working_image = working_image.crop(psf.psf_pad).reduce(self.psf_upscale) - elif "none" in self.psf_mode: + else: working_image = self.target[window].model_image() working_image._data = self.sample_image(working_image) - else: - raise SpecificationConflict( - f"Unknown PSF mode {self.psf_mode} for model {self.name}. " - "Must be one of 'none' or 'full'." - ) - # Units from flux/arcsec^2 to flux + # Units from flux/arcsec^2 to flux, multiply by pixel area working_image.fluxdensity_to_flux() return working_image diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 0cca30fa..a9436a95 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -63,8 +63,8 @@ def initialize(self): if not self.sigma.initialized: self.sigma.dynamic_value = np.logspace( - np.log10(target_area.pixel_length.item() * 3), - max(target_area.shape) * target_area.pixel_length.item() * 0.7, + np.log10(target_area.pixelscale.item() * 3), + max(target_area.shape) * target_area.pixelscale.item() * 0.7, self.n_components, ) if not self.flux.initialized: diff --git a/astrophot/param/param.py b/astrophot/param/param.py index 2da534eb..7d6504e8 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -56,7 +56,14 @@ def is_valid(self, value): def soft_valid(self, value): if self.valid[0] is None and self.valid[1] is None: return value - vrange = self.valid[1] - self.valid[0] - return torch.clamp( - value, min=self.valid[0] + 0.1 * vrange, max=self.valid[1] - 0.1 * vrange - ) + if self.valid[0] is not None and self.valid[1] is not None: + vrange = 0.1 * (self.valid[1] - self.valid[0]) + smin = self.valid[0] + 0.1 * vrange + smax = self.valid[1] - 0.1 * vrange + elif self.valid[0] is not None: + smin = self.valid[0] + 0.1 + smax = None + elif self.valid[1] is not None: + smin = None + smax = self.valid[1] - 0.1 + return torch.clamp(value, min=smin, max=smax) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index d32872a1..1f935e45 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -93,7 +93,7 @@ def target_image(fig, ax, target, window=None, **kwargs): clim=[sky + 3 * noise, None], ) - if torch.linalg.det(target.pixelscale.value) < 0: + if torch.linalg.det(target.CD.value) < 0: ax.invert_xaxis() ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") @@ -231,7 +231,7 @@ def model_image( X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() sample_image = sample_image.data.detach().cpu().numpy() - + print("sample_image shape", sample_image.shape) # Default kwargs for image vmin = kwargs.pop("vmin", None) vmax = kwargs.pop("vmax", None) @@ -262,7 +262,7 @@ def model_image( # Plot the image im = ax.pcolormesh(X, Y, sample_image, **kwargs) - if torch.linalg.det(target.pixelscale.value) < 0: + if torch.linalg.det(target.CD.value) < 0: ax.invert_xaxis() # Enforce equal spacing on x y @@ -357,7 +357,17 @@ def residual_image( X, Y = sample_image.coordinate_corner_meshgrid() X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() + print("target crpix", target.crpix, "sample crpix", sample_image.crpix) residuals = (target - sample_image).data + print( + "residuals shape", + residuals.shape, + "target shape", + target.data.shape, + "sample shape", + sample_image.data.shape, + ) + if normalize_residuals is True: residuals = residuals / torch.sqrt(target.variance) elif isinstance(normalize_residuals, torch.Tensor): @@ -404,7 +414,7 @@ def residual_image( } imshow_kwargs.update(kwargs) im = ax.pcolormesh(X, Y, residuals, **imshow_kwargs) - if torch.linalg.det(target.pixelscale.value) < 0: + if torch.linalg.det(target.CD.value) < 0: ax.invert_xaxis() ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index a74e106d..80697cf2 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -5,6 +5,7 @@ from scipy.stats import binned_statistic, iqr from .. import AP_config +from ..models import Model # from ..models import Warp_Galaxy from ..utils.conversions.units import flux_to_sb @@ -22,7 +23,7 @@ def radial_light_profile( fig, ax, - model, + model: Model, rad_unit="arcsec", extend_profile=1.0, R0=0.0, @@ -32,7 +33,7 @@ def radial_light_profile( xx = torch.linspace( R0, max(model.window.shape) - * model.target.pixel_length.detach().cpu().numpy() + * model.target.pixelscale.detach().cpu().numpy() * extend_profile / 2, int(resolution), @@ -72,7 +73,7 @@ def radial_light_profile( def radial_median_profile( fig, ax, - model: "Model", + model: Model, count_limit: int = 10, return_profile: bool = False, rad_unit: str = "arcsec", @@ -98,11 +99,11 @@ def radial_median_profile( """ Rlast_pix = max(model.window.shape) / 2 - Rlast_phys = Rlast_pix * model.target.pixel_length.item() + Rlast_phys = Rlast_pix * model.target.pixelscale.item() Rbins = [0.0] while Rbins[-1] < Rlast_phys: - Rbins.append(Rbins[-1] + max(2 * model.target.pixel_length.item(), Rbins[-1] * 0.1)) + Rbins.append(Rbins[-1] + max(2 * model.target.pixelscale.item(), Rbins[-1] * 0.1)) Rbins = np.array(Rbins) with torch.no_grad(): @@ -170,14 +171,14 @@ def radial_median_profile( def ray_light_profile( fig, ax, - model, + model: Model, rad_unit="arcsec", extend_profile=1.0, resolution=1000, ): xx = torch.linspace( 0, - max(model.window.shape) * model.target.pixel_length * extend_profile / 2, + max(model.window.shape) * model.target.pixelscale * extend_profile / 2, int(resolution), dtype=AP_config.ap_dtype, device=AP_config.ap_device, @@ -204,14 +205,14 @@ def ray_light_profile( def wedge_light_profile( fig, ax, - model, + model: Model, rad_unit="arcsec", extend_profile=1.0, resolution=1000, ): xx = torch.linspace( 0, - max(model.window.shape) * model.target.pixel_length * extend_profile / 2, + max(model.window.shape) * model.target.pixelscale * extend_profile / 2, int(resolution), dtype=AP_config.ap_dtype, device=AP_config.ap_device, @@ -235,7 +236,7 @@ def wedge_light_profile( return fig, ax -def warp_phase_profile(fig, ax, model, rad_unit="arcsec"): +def warp_phase_profile(fig, ax, model: Model, rad_unit="arcsec"): ax.plot( model.q_R.prof.detach().cpu().numpy(), diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index a692b350..237e0e10 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -43,7 +43,7 @@ "metadata": {}, "outputs": [], "source": [ - "model1 = ap.models.Model(\n", + "model1 = ap.Model(\n", " name=\"model1\", # every model must have a unique name\n", " model_type=\"sersic galaxy model\", # this specifies the kind of model\n", " center=[50, 50], # here we set initial values for each parameter\n", @@ -52,8 +52,8 @@ " n=2,\n", " Re=10,\n", " logIe=1,\n", - " target=ap.image.TargetImage(\n", - " data=np.zeros((100, 100)), zeropoint=22.5, pixelscale=1.0\n", + " target=ap.TargetImage(\n", + " data=np.zeros((100, 100)), zeropoint=22.5\n", " ), # every model needs a target, more on this later\n", ")\n", "model1.initialize() # before using the model it is good practice to call initialize so the model can get itself ready\n", @@ -98,7 +98,7 @@ "target_data = np.array(hdu[0].data, dtype=np.float64) # [:-50]\n", "\n", "# Create a target object with specified pixelscale and zeropoint\n", - "target = ap.image.TargetImage(\n", + "target = ap.TargetImage(\n", " data=target_data,\n", " pixelscale=0.262, # Every target image needs to know it's pixelscale in arcsec/pixel\n", " zeropoint=22.5, # optionally, you can give a zeropoint to tell AstroPhot what the pixel flux units are\n", @@ -118,7 +118,7 @@ "outputs": [], "source": [ "# This model now has a target that it will attempt to match\n", - "model2 = ap.models.Model(\n", + "model2 = ap.Model(\n", " name=\"model with target\",\n", " model_type=\"sersic galaxy model\", # feel free to swap out sersic with other profile types\n", " target=target, # now the model knows what its trying to match\n", @@ -129,8 +129,7 @@ "# to set just a few parameters and let AstroPhot try to figure out the rest. For example you could give it an initial\n", "# Guess for the center and it will work from there.\n", "model2.initialize()\n", - "print(model2.window)\n", - "print(model2().window)\n", + "\n", "# Plotting the initial parameters and residuals, we see it gets the rough shape of the galaxy right, but still has some fitting to do\n", "fig4, ax4 = plt.subplots(1, 2, figsize=(16, 6))\n", "ap.plots.model_image(fig4, ax4[0], model2)\n", @@ -246,13 +245,11 @@ "outputs": [], "source": [ "# note, we don't provide a name here. A unique name will automatically be generated using the model type\n", - "model3 = ap.models.Model(\n", + "model3 = ap.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", " window=[480, 595, 555, 665], # this is a region in pixel coordinates (imin,imax,jmin,jmax)\n", ")\n", - "print(model3.window)\n", - "print(target[model3.window].shape)\n", "print(f\"automatically generated name: '{model3.name}'\")\n", "\n", "# We can plot the \"model window\" to show us what part of the image will be analyzed by that model\n", @@ -305,7 +302,7 @@ "source": [ "# here we make a sersic model that can only have q and n in a narrow range\n", "# Also, we give PA and initial value and lock that so it does not change during fitting\n", - "constrained_param_model = ap.models.Model(\n", + "constrained_param_model = ap.Model(\n", " name=\"constrained parameters\",\n", " model_type=\"sersic galaxy model\",\n", " q={\"valid\": (0.4, 0.6)},\n", @@ -329,11 +326,9 @@ "outputs": [], "source": [ "# model 1 is a sersic model\n", - "model_1 = ap.models.Model(\n", - " model_type=\"sersic galaxy model\", center=[50, 50], PA=np.pi / 4, target=target\n", - ")\n", + "model_1 = ap.Model(model_type=\"sersic galaxy model\", center=[50, 50], PA=np.pi / 4, target=target)\n", "# model 2 is an exponential model\n", - "model_2 = ap.models.Model(model_type=\"exponential galaxy model\", target=target)\n", + "model_2 = ap.Model(model_type=\"exponential galaxy model\", target=target)\n", "\n", "# Here we add the constraint for \"PA\" to be the same for each model.\n", "# In doing so we provide the model and parameter name which should\n", @@ -441,7 +436,7 @@ "target.save(\"target.fits\")\n", "\n", "# Note that it is often also possible to load from regular FITS files\n", - "new_target = ap.image.TargetImage(filename=\"target.fits\")\n", + "new_target = ap.TargetImage(filename=\"target.fits\")\n", "\n", "fig, ax = plt.subplots(figsize=(8, 8))\n", "ap.plots.target_image(fig, ax, new_target)\n", @@ -491,7 +486,7 @@ "wcs = WCS(hdu[0].header)\n", "\n", "# Create a target object with WCS which will specify the pixelscale and origin for us!\n", - "target = ap.image.TargetImage(\n", + "target = ap.TargetImage(\n", " data=target_data,\n", " zeropoint=22.5,\n", " wcs=wcs,\n", @@ -519,7 +514,7 @@ "metadata": {}, "outputs": [], "source": [ - "target = ap.image.TargetImage(filename=filename)\n", + "target = ap.TargetImage(filename=filename)\n", "\n", "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", "ap.plots.target_image(fig3, ax3, target)\n", @@ -536,7 +531,7 @@ "\n", "# AstroPhot keeps track of all the subclasses of the AstroPhot Model object, this list will\n", "# include all models even ones added by the user\n", - "print(ap.models.Model.List_Models(usable=True, types=True))\n", + "print(ap.Model.List_Models(usable=True, types=True))\n", "print(\"---------------------------\")\n", "# It is also possible to get all sub models of a specific Type\n", "print(\"only galaxy models: \", ap.models.GalaxyModel.List_Models(types=True))" @@ -592,13 +587,13 @@ "ap.AP_config.ap_dtype = torch.float32\n", "\n", "# Now new AstroPhot objects will be made with single bit precision\n", - "T1 = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1.0)\n", + "T1 = ap.TargetImage(data=np.zeros((100, 100)))\n", "T1.to()\n", "print(\"now a single:\", T1.data.dtype)\n", "\n", "# Here we switch back to double precision\n", "ap.AP_config.ap_dtype = torch.float64\n", - "T2 = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1.0)\n", + "T2 = ap.TargetImage(data=np.zeros((100, 100)))\n", "T2.to()\n", "print(\"back to double:\", T2.data.dtype)\n", "print(\"old image is still single!:\", T1.data.dtype)" diff --git a/tests/test_image.py b/tests/test_image.py index 02919ecc..a2a5aea3 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,684 +1,350 @@ -import unittest -from astrophot import image import astrophot as ap import torch import numpy as np -from utils import get_astropy_wcs, make_basic_sersic +from utils import make_basic_sersic +import pytest ###################################################################### # Image Objects ###################################################################### -class TestImage(unittest.TestCase): - def test_image_creation(self): - arr = torch.zeros((10, 15)) - base_image = image.Image( - data=arr, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - metadata={"note": "test image"}, - ) - - self.assertEqual(base_image.pixel_length, 1.0, "image should track pixelscale") - self.assertEqual(base_image.zeropoint, 1.0, "image should track zeropoint") - self.assertEqual(base_image.origin[0], 0, "image should track origin") - self.assertEqual(base_image.origin[1], 0, "image should track origin") - self.assertEqual(base_image.metadata["note"], "test image", "image should track note") - - slicer = image.Window(origin=(3, 2), pixel_shape=(4, 5)) - sliced_image = base_image[slicer] - self.assertEqual(sliced_image.origin[0], 3, "image should track origin") - self.assertEqual(sliced_image.origin[1], 2, "image should track origin") - self.assertEqual(base_image.origin[0], 0, "subimage should not change image origin") - self.assertEqual(base_image.origin[1], 0, "subimage should not change image origin") - - second_base_image = image.Image(data=arr, pixelscale=1.0, metadata={"note": "test image"}) - self.assertEqual(base_image.pixel_length, 1.0, "image should track pixelscale") - self.assertIsNone(second_base_image.zeropoint, "image should track zeropoint") - self.assertEqual(second_base_image.origin[0], 0, "image should track origin") - self.assertEqual(second_base_image.origin[1], 0, "image should track origin") - self.assertEqual( - second_base_image.metadata["note"], "test image", "image should track note" - ) - - def test_copy(self): - - new_image = image.Image( - data=torch.zeros((10, 15)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - - copy_image = new_image.copy() - self.assertEqual( - new_image.pixel_length, - copy_image.pixel_length, - "copied image should have same pixelscale", - ) - self.assertEqual( - new_image.zeropoint, - copy_image.zeropoint, - "copied image should have same zeropoint", - ) - self.assertEqual( - new_image.window, copy_image.window, "copied image should have same window" - ) - copy_image += 1 - self.assertEqual( - new_image.data[0][0], - 0.0, - "copied image should not share data with original", - ) - - blank_copy_image = new_image.blank_copy() - self.assertEqual( - new_image.pixel_length, - blank_copy_image.pixel_length, - "copied image should have same pixelscale", - ) - self.assertEqual( - new_image.zeropoint, - blank_copy_image.zeropoint, - "copied image should have same zeropoint", - ) - self.assertEqual( - new_image.window, - blank_copy_image.window, - "copied image should have same window", - ) - blank_copy_image += 1 - self.assertEqual( - new_image.data[0][0], - 0.0, - "copied image should not share data with original", - ) - - def test_image_arithmetic(self): - - arr = torch.zeros((10, 12)) - base_image = image.Image( - data=arr, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.ones(2), - ) - slicer = image.Window(origin=(0, 0), pixel_shape=(5, 5)) - sliced_image = base_image[slicer] - sliced_image += 1 - - self.assertEqual(base_image.data[1][1], 1, "slice should update base image") - self.assertEqual(base_image.data[5][5], 0, "slice should only update its region") - - second_image = image.Image( - data=torch.ones((5, 5)), - pixelscale=1.0, - zeropoint=1.0, - origin=[3, 3], - ) - - # Test iadd - base_image += second_image - self.assertEqual(base_image.data[1][1], 1, "image addition should only update its region") - self.assertEqual(base_image.data[3][3], 2, "image addition should update its region") - self.assertEqual(base_image.data[5][5], 1, "image addition should update its region") - self.assertEqual(base_image.data[8][8], 0, "image addition should only update its region") - - # Test isubtract - base_image -= second_image - self.assertEqual( - base_image.data[1][1], 1, "image subtraction should only update its region" - ) - self.assertEqual(base_image.data[3][3], 1, "image subtraction should update its region") - self.assertEqual(base_image.data[5][5], 0, "image subtraction should update its region") - self.assertEqual( - base_image.data[8][8], 0, "image subtraction should only update its region" - ) - - base_image.data[6:, 6:] += 1.0 - - self.assertEqual(base_image.data[1][1], 1, "array addition should only update its region") - self.assertEqual(base_image.data[6][6], 1, "array addition should update its region") - self.assertEqual(base_image.data[8][8], 1, "array addition should update its region") - - def test_excersize_arithmatic(self): - - arr = torch.zeros((10, 12)) - base_image = image.Image( - data=arr, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.ones(2), - ) - second_image = image.Image( - data=torch.ones((5, 5)), - pixelscale=1.0, - zeropoint=1.0, - origin=[3, 3], - ) - - new_img = base_image + second_image - new_img = new_img - second_image - - self.assertTrue( - torch.allclose(new_img.data, torch.zeros_like(new_img.data)), - "addition and subtraction should produce no change", - ) - - base_image += second_image - base_image -= second_image - - self.assertTrue( - torch.allclose(base_image.data, torch.zeros_like(base_image.data)), - "addition and subtraction should produce no change", - ) - - new_img = base_image + 10.0 - new_img = new_img - 10.0 - - self.assertTrue( - torch.allclose(new_img.data, torch.zeros_like(new_img.data)), - "addition and subtraction should produce no change", - ) - - base_image += 10.0 - base_image -= 10.0 - - self.assertTrue( - torch.allclose(base_image.data, torch.zeros_like(base_image.data)), - "addition and subtraction should produce no change", - ) - - def test_image_manipulation(self): - - new_image = image.Image( - data=torch.ones((16, 32)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - - # image reduction - for scale in [2, 4, 8, 16]: - reduced_image = new_image.reduce(scale) - - self.assertEqual( - reduced_image.data[0][0], - scale**2, - "reduced image should sum sub pixels", - ) - self.assertEqual( - reduced_image.pixel_length, - scale, - "pixelscale should increase with reduced image", - ) - self.assertEqual( - reduced_image.origin[0], - new_image.origin[0], - "origin should not change with reduced image", - ) - self.assertEqual( - reduced_image.shape[0], - new_image.shape[0], - "shape should not change with reduced image", - ) - - # image cropping - new_image.crop( - [torch.tensor(1, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device)] - ) - self.assertEqual( - new_image.data.shape[0], 14, "crop should cut 1 pixel from both sides here" - ) - new_image.crop( - torch.tensor([3, 2], dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device) - ) - self.assertEqual( - new_image.data.shape[1], - 24, - "previous crop and current crop should have cut from this axis", - ) - new_image.crop( - torch.tensor([3, 2, 1, 0], dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device) - ) - self.assertEqual( - new_image.data.shape[0], - 9, - "previous crop and current crop should have cut from this axis", - ) - - def test_image_save_load(self): - - new_image = image.Image( - data=torch.ones((16, 32)), - pixelscale=0.76, - zeropoint=21.4, - origin=torch.zeros(2) + 0.1, - ) - - new_image.save("Test_AstroPhot.fits") - - loaded_image = ap.image.Image(filename="Test_AstroPhot.fits") - - self.assertTrue( - torch.all(new_image.data == loaded_image.data), - "Loaded image should have same pixel values", - ) - self.assertTrue( - torch.all(new_image.origin == loaded_image.origin), - "Loaded image should have same origin", - ) - self.assertEqual( - new_image.pixel_length, - loaded_image.pixel_length, - "Loaded image should have same pixel scale", - ) - self.assertEqual( - new_image.zeropoint, - loaded_image.zeropoint, - "Loaded image should have same zeropoint", - ) - - def test_image_wcs_roundtrip(self): - - wcs = get_astropy_wcs() - # Minimal input - I = ap.image.Image( - data=torch.zeros((20, 20)), - zeropoint=22.5, - wcs=wcs, - ) - - self.assertTrue( - torch.allclose( - I.world_to_plane(I.plane_to_world(torch.zeros_like(I.window.reference_radec))), - torch.zeros_like(I.window.reference_radec), - ), - "WCS world/plane roundtrip should return input value", - ) - self.assertTrue( - torch.allclose( - I.pixel_to_plane(I.plane_to_pixel(torch.zeros_like(I.window.reference_radec))), - torch.zeros_like(I.window.reference_radec), - ), - "WCS pixel/plane roundtrip should return input value", - ) - self.assertTrue( - torch.allclose( - I.world_to_pixel(I.pixel_to_world(torch.zeros_like(I.window.reference_radec))), - torch.zeros_like(I.window.reference_radec), - atol=1e-6, - ), - "WCS world/pixel roundtrip should return input value", - ) - - self.assertTrue( - torch.allclose( - I.pixel_to_plane_delta( - I.plane_to_pixel_delta(torch.ones_like(I.window.reference_radec)) - ), - torch.ones_like(I.window.reference_radec), - ), - "WCS pixel/plane delta roundtrip should return input value", - ) - - def test_image_display(self): - new_image = image.Image( - data=torch.ones((16, 32)), - pixelscale=0.76, - zeropoint=21.4, - origin=torch.zeros(2) + 0.1, - ) - - self.assertIsInstance(str(new_image), str, "String representation should be a string!") - self.assertIsInstance(repr(new_image), str, "Repr should be a string!") - - def test_image_errors(self): - - new_image = image.Image( - data=torch.ones((16, 32)), - pixelscale=0.76, - zeropoint=21.4, - origin=torch.zeros(2) + 0.1, - ) - - # Change data badly - with self.assertRaises(ap.errors.SpecificationConflict): - new_image.data = np.zeros((5, 5)) - - # Fractional image reduction - with self.assertRaises(ap.errors.SpecificationConflict): - reduced = new_image.reduce(0.2) - - # Negative expand image - with self.assertRaises(ap.errors.SpecificationConflict): - unexpanded = new_image.expand((-2, 3)) - - -class TestTargetImage(unittest.TestCase): - def test_variance(self): - - new_image = image.Target_Image( - data=torch.ones((16, 32)), - variance=torch.ones((16, 32)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - - self.assertTrue(new_image.has_variance, "target image should store variance") - - reduced_image = new_image.reduce(2) - self.assertEqual(reduced_image.variance[0][0], 4, "reduced image should sum sub pixels") - - new_image.to() - new_image.variance = None - self.assertFalse(new_image.has_variance, "target image update to no variance") - - def test_mask(self): - - new_image = image.Target_Image( - data=torch.ones((16, 32)), - mask=torch.ones((16, 32)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - self.assertTrue(new_image.has_mask, "target image should store mask") - - reduced_image = new_image.reduce(2) - self.assertEqual(reduced_image.mask[0][0], 1, "reduced image should mask appropriately") - - new_image.mask = None - self.assertFalse(new_image.has_mask, "target image update to no mask") - - data = torch.ones((16, 32)) - data[1, 1] = torch.nan - data[5, 5] = torch.nan - - new_image = image.Target_Image( - data=data, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - self.assertTrue(new_image.has_mask, "target image with nans should create mask") - self.assertEqual(new_image.mask[1][1].item(), True, "nan should be masked") - self.assertEqual(new_image.mask[5][5].item(), True, "nan should be masked") - - def test_psf(self): - - new_image = image.Target_Image( - data=torch.ones((15, 33)), - psf=torch.ones((9, 9)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - self.assertTrue(new_image.has_psf, "target image should store variance") - self.assertEqual( - new_image.psf.psf_border_int[0], - 5, - "psf border should be half psf size, rounded up ", - ) - - reduced_image = new_image.reduce(3) - self.assertEqual( - reduced_image.psf.data[0][0], - 9, - "reduced image should sum sub pixels in psf", - ) - - new_image.psf = None - self.assertFalse(new_image.has_psf, "target image update to no variance") - - def test_reduce(self): - new_image = image.Target_Image( - data=torch.ones((30, 36)), - psf=torch.ones((9, 9)), - variance="auto", - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - smaller_image = new_image.reduce(3) - self.assertEqual(smaller_image.data[0][0], 9, "reduction should sum flux") - self.assertEqual( - tuple(smaller_image.data.shape), - (10, 12), - "reduction should decrease image size", - ) - self.assertEqual(smaller_image.psf.data[0][0], 9, "reduction should sum psf flux") - self.assertEqual( - tuple(smaller_image.psf.data.shape), - (3, 3), - "reduction should decrease psf image size", - ) - - def test_target_save_load(self): - new_image = image.Target_Image( - data=torch.ones((16, 32)), - variance="auto", - mask=torch.zeros((16, 32)), - psf=torch.ones((9, 9)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - - new_image.save("Test_target_AstroPhot.fits") - - loaded_image = ap.image.Target_Image(filename="Test_target_AstroPhot.fits") - - self.assertTrue( - torch.all(new_image.variance == loaded_image.variance), - "Loaded image should have same variance", - ) - self.assertTrue( - torch.all(new_image.psf.data == loaded_image.psf.data), - "Loaded image should have same psf", - ) - - def test_auto_var(self): - target = make_basic_sersic() - target.variance = "auto" - - def test_target_errors(self): - new_image = image.Target_Image( - data=torch.ones((16, 32)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - - # bad variance - with self.assertRaises(ap.errors.SpecificationConflict): - new_image.variance = np.ones((5, 5)) - - # bad mask - with self.assertRaises(ap.errors.SpecificationConflict): - new_image.mask = np.zeros((5, 5)) - - -class TestPSFImage(unittest.TestCase): - def test_copying(self): - psf_image = image.PSF_Image( - data=torch.ones((15, 15)), - pixelscale=1.0, - ) - - copy_psf = psf_image.copy() - self.assertEqual( - psf_image.data[0][0], - copy_psf.data[0][0], - "copied image should have same data", - ) - blank_psf = psf_image.blank_copy() - self.assertNotEqual( - psf_image.data[0][0], - blank_psf.data[0][0], - "blank copied image should not have same data", - ) - - psf_image.to(dtype=torch.float32) - - def test_reducing(self): - psf_image = image.PSF_Image( - data=torch.ones((15, 15)), - pixelscale=1.0, - ) - new_image = image.Target_Image( - data=torch.ones((36, 45)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - psf=psf_image, - ) - - reduce_image = new_image.reduce(3) - self.assertEqual( - tuple(reduce_image.psf.data.shape), - (5, 5), - "reducing image should reduce psf", - ) - self.assertEqual( - reduce_image.psf.pixel_length, - 3, - "reducing image should update pixelscale factor", - ) - - def test_psf_errors(self): - with self.assertRaises(ap.errors.SpecificationConflict): - psf_image = image.PSF_Image( - data=torch.ones((18, 15)), - pixelscale=1.0, - ) - - -class TestModelImage(unittest.TestCase): - def test_replace(self): - new_image = image.Model_Image( - data=torch.ones((16, 32)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - other_image = image.Model_Image( - data=5 * torch.ones((4, 4)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 4 + 0.1, - ) - - new_image.replace(other_image) - new_image.replace(other_image.window, other_image.data) - - self.assertEqual( - new_image.data[0][0], - 1, - "image replace should occur at proper location in image, this data should be untouched", - ) - self.assertEqual( - new_image.data[5][5], 5, "image replace should update values in its window" - ) - - def test_shift(self): - - new_image = image.Model_Image( - data=torch.ones((16, 32)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - new_image.shift_origin( - torch.tensor((-0.1, -0.1), dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - is_prepadded=False, - ) - - self.assertAlmostEqual( - torch.sum(new_image.data).item(), - 16 * 32, - delta=1, - msg="Shifting field of ones should give field of ones", - ) - - def test_errors(self): - - with self.assertRaises(ap.errors.InvalidData): - new_image = image.Model_Image() - - -class TestJacobianImage(unittest.TestCase): - def test_jacobian_add(self): - - new_image = ap.image.Jacobian_Image( - parameters=["a", "b", "c"], - target_identity="target1", - data=torch.ones((16, 32, 3)), - pixelscale=1.0, - zeropoint=1.0, - window=ap.image.Window(origin=torch.zeros(2) + 0.1, pixel_shape=torch.tensor((32, 16))), - ) - other_image = ap.image.Jacobian_Image( - parameters=["b", "d"], - target_identity="target1", - data=5 * torch.ones((4, 4, 2)), - pixelscale=1.0, - zeropoint=1.0, - window=ap.image.Window( - origin=torch.zeros(2) + 4 + 0.1, pixel_shape=torch.tensor((4, 4)) - ), - ) - - new_image += other_image - - self.assertEqual( - tuple(new_image.data.shape), - (16, 32, 4), - "Jacobian addition should manage parameter identities", - ) - self.assertEqual( - tuple(new_image.flatten("data").shape), - (512, 4), - "Jacobian should flatten to Npix*Nparams tensor", - ) - - def test_jacobian_error(self): - - # Create parameter list with multiple same entries - with self.assertRaises(ap.errors.SpecificationConflict): - new_image = ap.image.Jacobian_Image( - parameters=["a", "b", "c", "a"], - target_identity="target1", - data=torch.ones((16, 32, 3)), - pixelscale=1.0, - zeropoint=1.0, - window=ap.image.Window( - origin=torch.zeros(2) + 0.1, pixel_shape=torch.tensor((32, 16)) - ), - ) - - # Adding a model image to a jacobian image - new_image = ap.image.Jacobian_Image( - parameters=["a", "b", "c"], - target_identity="target1", - data=torch.ones((16, 32, 3)), - pixelscale=1.0, - zeropoint=1.0, - window=ap.image.Window(origin=torch.zeros(2) + 0.1, pixel_shape=torch.tensor((32, 16))), - ) - bad_image = image.Model_Image( - data=torch.ones((16, 32)), - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2) + 0.1, - ) - with self.assertRaises(ap.errors.InvalidImage): - new_image += bad_image - - -if __name__ == "__main__": - unittest.main() +def test_image_creation(): + arr = torch.zeros((10, 15)) + base_image = ap.Image( + data=arr, + pixelscale=1.0, + zeropoint=1.0, + ) + + assert base_image.pixelscale == 1.0, "image should track pixelscale" + assert base_image.zeropoint == 1.0, "image should track zeropoint" + assert base_image.crpix[0] == 0, "image should track crpix" + assert base_image.crpix[1] == 0, "image should track crpix" + + slicer = ap.Window((7, 13, 4, 7), base_image) + sliced_image = base_image[slicer] + assert sliced_image.crpix[0] == -7, "crpix of subimage should give relative position" + assert sliced_image.crpix[1] == -4, "crpix of subimage should give relative position" + assert sliced_image.shape == (6, 3), "sliced image should have correct shape" + + +def test_copy(): + new_image = ap.Image( + data=torch.zeros((10, 15)), + pixelscale=1.0, + zeropoint=1.0, + ) + + copy_image = new_image.copy() + assert new_image.pixelscale == copy_image.pixelscale, "copied image should have same pixelscale" + assert new_image.zeropoint == copy_image.zeropoint, "copied image should have same zeropoint" + assert ( + new_image.window.extent == copy_image.window.extent + ), "copied image should have same window" + copy_image += 1 + assert new_image.data[0][0] == 0.0, "copied image should not share data with original" + + blank_copy_image = new_image.blank_copy() + assert ( + new_image.pixelscale == blank_copy_image.pixelscale + ), "copied image should have same pixelscale" + assert ( + new_image.zeropoint == blank_copy_image.zeropoint + ), "copied image should have same zeropoint" + assert ( + new_image.window.extent == blank_copy_image.window.extent + ), "copied image should have same window" + blank_copy_image += 1 + assert new_image.data[0][0] == 0.0, "copied image should not share data with original" + + +def test_image_arithmetic(): + arr = torch.zeros((10, 12)) + base_image = ap.Image( + data=arr, + pixelscale=1.0, + zeropoint=1.0, + ) + slicer = ap.Window((-1, 5, 6, 15), base_image) + sliced_image = base_image[slicer] + sliced_image += 1 + + assert base_image.data[1][8] == 0, "slice should not update base image" + assert base_image.data[5][5] == 0, "slice should not update base image" + + second_image = ap.Image( + data=torch.ones((5, 5)), + pixelscale=1.0, + zeropoint=1.0, + crpix=(-1, 1), + ) + + # Test iadd + base_image += second_image + assert base_image.data[0][0] == 0, "image addition should only update its region" + assert base_image.data[3][3] == 1, "image addition should update its region" + assert base_image.data[3][4] == 0, "image addition should only update its region" + assert base_image.data[5][3] == 1, "image addition should update its region" + + # Test isubtract + base_image -= second_image + assert torch.all( + torch.isclose(base_image.data, torch.zeros_like(base_image.data)) + ), "image subtraction should only update its region" + + +def test_image_manipulation(): + new_image = ap.Image( + data=torch.ones((16, 32)), + pixelscale=1.0, + zeropoint=1.0, + ) + + # image reduction + for scale in [2, 4, 8, 16]: + reduced_image = new_image.reduce(scale) + + assert reduced_image.data[0][0] == scale**2, "reduced image should sum sub pixels" + assert reduced_image.pixelscale == scale, "pixelscale should increase with reduced image" + + # image cropping + crop_image = new_image.crop([1]) + assert crop_image.shape[1] == 14, "crop should cut 1 pixel from both sides here" + crop_image = new_image.crop([3, 2]) + assert ( + crop_image.data.shape[0] == 26 + ), "crop should have cut 3 pixels from both sides of this axis" + crop_image = new_image.crop([3, 2, 1, 0]) + assert ( + crop_image.data.shape[0] == 27 + ), "crop should have cut 3 pixels from left, 2 from right, 1 from top, and 0 from bottom" + + +def test_image_save_load(): + new_image = ap.Image( + data=torch.ones((16, 32)), + pixelscale=0.76, + zeropoint=21.4, + crtan=(8.0, 1.2), + crpix=(2, 3), + crval=(100.0, -32.1), + ) + + new_image.save("Test_AstroPhot.fits") + + loaded_image = ap.Image(filename="Test_AstroPhot.fits") + + assert torch.all( + new_image.data == loaded_image.data + ), "Loaded image should have same pixel values" + assert torch.all( + new_image.crtan.value == loaded_image.crtan.value + ), "Loaded image should have same tangent plane origin" + assert np.all( + new_image.crpix == loaded_image.crpix + ), "Loaded image should have same reference pixel" + assert torch.all( + new_image.crval.value == loaded_image.crval.value + ), "Loaded image should have same reference world coordinates" + assert torch.allclose( + new_image.pixelscale, loaded_image.pixelscale + ), "Loaded image should have same pixel scale" + assert torch.allclose( + new_image.CD.value, loaded_image.CD.value + ), "Loaded image should have same pixel scale" + assert new_image.zeropoint == loaded_image.zeropoint, "Loaded image should have same zeropoint" + + +def test_image_wcs_roundtrip(): + # Minimal input + I = ap.Image( + data=torch.zeros((21, 21)), + zeropoint=22.5, + crpix=(10, 10), + crtan=(1.0, -10.0), + crval=(160.0, 45.0), + CD=0.05 + * np.array( + [[np.cos(np.pi / 4), -np.sin(np.pi / 4)], [np.sin(np.pi / 4), np.cos(np.pi / 4)]] + ), + ) + + assert torch.allclose( + torch.stack(I.world_to_plane(*I.plane_to_world(*I.center))), + I.center, + ), "WCS world/plane roundtrip should return input value" + assert torch.allclose( + torch.stack(I.pixel_to_plane(*I.plane_to_pixel(*I.center))), + I.center, + ), "WCS pixel/plane roundtrip should return input value" + assert torch.allclose( + torch.stack(I.world_to_pixel(*I.pixel_to_world(*torch.zeros_like(I.center)))), + torch.zeros_like(I.center), + atol=1e-6, + ), "WCS world/pixel roundtrip should return input value" + + +def test_target_image_variance(): + new_image = ap.TargetImage( + data=torch.ones((16, 32)), + variance=torch.ones((16, 32)), + pixelscale=1.0, + zeropoint=1.0, + ) + + assert new_image.has_variance, "target image should store variance" + + reduced_image = new_image.reduce(2) + assert reduced_image.variance[0][0] == 4, "reduced image should sum sub pixels" + + new_image.variance = None + assert not new_image.has_variance, "target image update to no variance" + + +def test_target_image_mask(): + new_image = ap.TargetImage( + data=torch.ones((16, 32)), + mask=torch.arange(16 * 32).reshape((16, 32)) % 4 == 0, + pixelscale=1.0, + zeropoint=1.0, + ) + assert new_image.has_mask, "target image should store mask" + + reduced_image = new_image.reduce(2) + assert reduced_image.mask[0][0] == 1, "reduced image should mask appropriately" + assert reduced_image.mask[1][0] == 0, "reduced image should mask appropriately" + + new_image.mask = None + assert not new_image.has_mask, "target image update to no mask" + + data = torch.ones((16, 32)) + data[1, 1] = torch.nan + data[5, 5] = torch.nan + + new_image = ap.TargetImage( + data=data, + pixelscale=1.0, + zeropoint=1.0, + ) + assert new_image.has_mask, "target image with nans should create mask" + assert new_image.mask[1][1].item() == True, "nan should be masked" + assert new_image.mask[5][5].item() == True, "nan should be masked" + + +def test_target_image_psf(): + new_image = ap.TargetImage( + data=torch.ones((15, 33)), + psf=torch.ones((9, 9)), + pixelscale=1.0, + zeropoint=1.0, + ) + assert new_image.has_psf, "target image should store variance" + assert new_image.psf.psf_pad == 4, "psf border should be half psf size" + + reduced_image = new_image.reduce(3) + assert reduced_image.psf.data[0][0] == 9, "reduced image should sum sub pixels in psf" + + new_image.psf = None + assert not new_image.has_psf, "target image update to no variance" + + +def test_target_image_reduce(): + new_image = ap.TargetImage( + data=torch.ones((30, 36)), + psf=torch.ones((9, 9)), + variance="auto", + pixelscale=1.0, + zeropoint=1.0, + ) + smaller_image = new_image.reduce(3) + assert smaller_image.data[0][0] == 9, "reduction should sum flux" + assert tuple(smaller_image.data.shape) == (12, 10), "reduction should decrease image size" + + +def test_target_image_save_load(): + new_image = ap.TargetImage( + data=torch.ones((16, 32)), + variance=torch.ones((16, 32)), + mask=torch.zeros((16, 32)), + psf=torch.ones((9, 9)), + CD=[[1.0, 0.0], [0.0, 1.5]], + zeropoint=1.0, + ) + + new_image.save("Test_target_AstroPhot.fits") + + loaded_image = ap.TargetImage(filename="Test_target_AstroPhot.fits") + + assert torch.all( + new_image.data == loaded_image.data + ), "Loaded image should have same pixel values" + assert torch.all(new_image.mask == loaded_image.mask), "Loaded image should have same mask" + assert torch.all( + new_image.variance == loaded_image.variance + ), "Loaded image should have same variance" + assert torch.all( + new_image.psf.data == loaded_image.psf.data + ), "Loaded image should have same psf" + assert torch.allclose( + new_image.CD.value, loaded_image.CD.value + ), "Loaded image should have same pixel scale" + + +def test_target_image_auto_var(): + target = make_basic_sersic() + target.variance = "auto" + + +def test_target_image_errors(): + new_image = ap.TargetImage( + data=torch.ones((16, 32)), + pixelscale=1.0, + zeropoint=1.0, + ) + + # bad variance + with pytest.raises(ap.errors.SpecificationConflict): + new_image.variance = np.ones((5, 5)) + + # bad mask + with pytest.raises(ap.errors.SpecificationConflict): + new_image.mask = np.zeros((5, 5)) + + +def test_psf_image_copying(): + psf_image = ap.PSFImage( + data=torch.ones((15, 15)), + ) + + assert psf_image.psf_pad == 7, "psf image should have correct psf_pad" + psf_image.normalize() + assert np.allclose( + psf_image.data.detach().cpu().numpy(), 1 / 15**2 + ), "psf image should normalize to sum to 1" + + +def test_jacobian_add(): + new_image = ap.JacobianImage( + parameters=["a", "b", "c"], + data=torch.ones((16, 32, 3)), + ) + other_image = ap.JacobianImage( + parameters=["b", "d"], + data=5 * torch.ones((4, 4, 2)), + ) + + new_image += other_image + + assert tuple(new_image.data.shape) == ( + 32, + 16, + 3, + ), "Jacobian addition should manage parameter identities" + assert tuple(new_image.flatten("data").shape) == ( + 512, + 3, + ), "Jacobian should flatten to Npix*Nparams tensor" + assert new_image.data[0, 0, 0].item() == 1, "Jacobian addition should not change original data" + assert new_image.data[0, 0, 1].item() == 6, " Jacobian addition should add correctly" diff --git a/tests/test_image_header.py b/tests/test_image_header.py deleted file mode 100644 index 55e7357f..00000000 --- a/tests/test_image_header.py +++ /dev/null @@ -1,144 +0,0 @@ -import unittest -import astrophot as ap -import torch - -from utils import get_astropy_wcs - -###################################################################### -# Image_Header Objects -###################################################################### - - -class TestImageHeader(unittest.TestCase): - def test_image_header_creation(self): - - # Minimal input - H = ap.image.Image_Header( - data_shape=(20, 20), - zeropoint=22.5, - pixelscale=0.2, - ) - - self.assertTrue(torch.all(H.origin == 0), "Origin should be assumed zero if not given") - - # Center - H = ap.image.Image_Header( - data_shape=(20, 20), - pixelscale=0.2, - center=(10, 10), - ) - - self.assertTrue( - torch.all(H.origin == 8), - "Center provided, origin should be adjusted accordingly", - ) - - # Origin - H = ap.image.Image_Header( - data_shape=(20, 20), - pixelscale=0.2, - origin=(10, 10), - ) - - self.assertTrue(torch.all(H.origin == 10), "Origin provided, origin should be as given") - - # Center radec - H = ap.image.Image_Header( - data_shape=(20, 20), - pixelscale=0.2, - center_radec=(10, 10), - ) - - self.assertTrue( - torch.allclose(H.plane_to_world(H.center), torch.ones_like(H.center) * 10), - "Center_radec provided, center should be as given in world coordinates", - ) - - # Origin radec - H = ap.image.Image_Header( - data_shape=(20, 20), - pixelscale=0.2, - origin_radec=(10, 10), - ) - - self.assertTrue( - torch.allclose(H.plane_to_world(H.origin), torch.ones_like(H.center) * 10), - "Origin_radec provided, origin should be as given in world coordinates", - ) - - # Astropy WCS - wcs = get_astropy_wcs() - H = ap.image.Image_Header( - data_shape=(180, 180), - wcs=wcs, - ) - - sky_coord = wcs.pixel_to_world(*wcs.wcs.crpix) - wcs_world = torch.tensor((sky_coord.ra.deg, sky_coord.dec.deg)) - self.assertTrue( - torch.allclose( - torch.tensor( - wcs.wcs.crpix, - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - H.world_to_pixel(wcs_world), - ), - "Astropy WCS initialization should map crval crpix coordinates", - ) - - def test_image_header_wcs_roundtrip(self): - - wcs = get_astropy_wcs() - # Minimal input - H = ap.image.Image_Header( - data_shape=(20, 20), - zeropoint=22.5, - wcs=wcs, - ) - - self.assertTrue( - torch.allclose( - H.world_to_plane(H.plane_to_world(torch.zeros_like(H.window.reference_radec))), - torch.zeros_like(H.window.reference_radec), - ), - "WCS world/plane roundtrip should return input value", - ) - self.assertTrue( - torch.allclose( - H.pixel_to_plane(H.plane_to_pixel(torch.zeros_like(H.window.reference_radec))), - torch.zeros_like(H.window.reference_radec), - ), - "WCS pixel/plane roundtrip should return input value", - ) - self.assertTrue( - torch.allclose( - H.world_to_pixel(H.pixel_to_world(torch.zeros_like(H.window.reference_radec))), - torch.zeros_like(H.window.reference_radec), - atol=1e-6, - ), - "WCS world/pixel roundtrip should return input value", - ) - - self.assertTrue( - torch.allclose( - H.pixel_to_plane_delta( - H.plane_to_pixel_delta(torch.ones_like(H.window.reference_radec)) - ), - torch.ones_like(H.window.reference_radec), - ), - "WCS pixel/plane delta roundtrip should return input value", - ) - - def test_iamge_header_repr(self): - - wcs = get_astropy_wcs() - # Minimal input - H = ap.image.Image_Header( - data_shape=(20, 20), - zeropoint=22.5, - wcs=wcs, - ) - - S = str(H) - R = repr(H) diff --git a/tests/test_image_list.py b/tests/test_image_list.py index b4f2bcd0..9fd63f6f 100644 --- a/tests/test_image_list.py +++ b/tests/test_image_list.py @@ -1,468 +1,176 @@ -import unittest import astrophot as ap +import numpy as np import torch +import pytest ###################################################################### # Image List Object ###################################################################### -class TestImageList(unittest.TestCase): - def test_image_creation(self): - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - metadata={"note": "test image 1"}, - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - metadata={"note": "test image 2"}, - ) - - test_image = ap.image.Image_List((base_image1, base_image2)) - - for image, original_image in zip(test_image, (base_image1, base_image2)): - self.assertEqual( - image.pixel_length, - original_image.pixel_length, - "image should track pixelscale", - ) - self.assertEqual( - image.zeropoint, - original_image.zeropoint, - "image should track zeropoint", - ) - self.assertEqual(image.origin[0], original_image.origin[0], "image should track origin") - self.assertEqual(image.origin[1], original_image.origin[1], "image should track origin") - self.assertEqual( - image.metadata["note"], - original_image.metadata["note"], - "image should track note", - ) - - slicer = ap.image.Window_List( - ( - ap.image.Window(origin=(3, 2), pixel_shape=(4, 5)), - ap.image.Window(origin=(3, 2), pixel_shape=(4, 5)), - ) - ) - sliced_image = test_image[slicer] - - self.assertEqual(sliced_image[0].origin[0], 3, "image should track origin") - self.assertEqual(sliced_image[0].origin[1], 2, "image should track origin") - self.assertEqual(sliced_image[1].origin[0], 3, "image should track origin") - self.assertEqual(sliced_image[1].origin[1], 2, "image should track origin") - self.assertEqual(base_image1.origin[0], 0, "subimage should not change image origin") - self.assertEqual(base_image1.origin[1], 0, "subimage should not change image origin") - - def test_copy(self): - - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - ) - - test_image = ap.image.Image_List((base_image1, base_image2)) - - copy_image = test_image.copy() - for ti, ci in zip(test_image, copy_image): - self.assertEqual( - ti.pixel_length, - ci.pixel_length, - "copied image should have same pixelscale", - ) - self.assertEqual(ti.zeropoint, ci.zeropoint, "copied image should have same zeropoint") - self.assertEqual(ti.window, ci.window, "copied image should have same window") - preval = ti.data[0][0].item() - ci += 1 - self.assertEqual( - ti.data[0][0], - preval, - "copied image should not share data with original", - ) - - blank_copy_image = test_image.blank_copy() - for ti, ci in zip(test_image, blank_copy_image): - self.assertEqual( - ti.pixel_length, - ci.pixel_length, - "copied image should have same pixelscale", - ) - self.assertEqual(ti.zeropoint, ci.zeropoint, "copied image should have same zeropoint") - self.assertEqual(ti.window, ci.window, "copied image should have same window") - preval = ti.data[0][0].item() - ci += 1 - self.assertEqual( - ti.data[0][0], - preval, - "copied image should not share data with original", - ) - - def test_image_arithmetic(self): - - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - ) - test_image = ap.image.Image_List((base_image1, base_image2)) - - arr3 = torch.ones((10, 15)) - base_image3 = ap.image.Image( - data=arr3, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.ones(2), - ) - arr4 = torch.zeros((15, 10)) - base_image4 = ap.image.Image( - data=arr4, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.zeros(2), - ) - second_image = ap.image.Image_List((base_image3, base_image4)) - - # Test iadd - test_image += second_image - - self.assertEqual( - test_image[0].data[0][0], 0, "image addition should only update its region" - ) - self.assertEqual(test_image[0].data[3][3], 1, "image addition should update its region") - self.assertEqual(test_image[1].data[0][0], 1, "image addition should update its region") - self.assertEqual(test_image[1].data[1][1], 1, "image addition should update its region") - - # Test iadd - test_image -= second_image - - self.assertEqual( - test_image[0].data[0][0], 0, "image addition should only update its region" - ) - self.assertEqual(test_image[0].data[3][3], 0, "image addition should update its region") - self.assertEqual(test_image[1].data[0][0], 1, "image addition should update its region") - self.assertEqual(test_image[1].data[1][1], 1, "image addition should update its region") - - def test_image_list_display(self): - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - ) - test_image = ap.image.Image_List((base_image1, base_image2)) - - self.assertIsInstance(str(test_image), str, "String representation should be a string!") - self.assertIsInstance(repr(test_image), str, "Repr should be a string!") - - def test_image_list_windowset(self): - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - note="test image 1", - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - note="test image 2", - ) - test_image = ap.image.Image_List((base_image1, base_image2)) - arr3 = torch.ones((10, 15)) - base_image3 = ap.image.Image( - data=arr3, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.ones(2), - note="test image 3", - ) - arr4 = torch.zeros((15, 10)) - base_image4 = ap.image.Image( - data=arr4, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.zeros(2), - note="test image 4", - ) - second_image = ap.image.Image_List((base_image3, base_image4), window=test_image.window) - - def test_image_list_errors(self): - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - ) - test_image = ap.image.Image_List((base_image1, base_image2)) - # Bad ra dec reference point - bad_base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - reference_radec=torch.ones(2), - ) - with self.assertRaises(ap.errors.ConflicingWCS): - test_image = ap.image.Image_List((base_image1, bad_base_image2)) - - # Bad tangent plane x y reference point - bad_base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - reference_planexy=torch.ones(2), - ) - with self.assertRaises(ap.errors.ConflicingWCS): - test_image = ap.image.Image_List((base_image1, bad_base_image2)) - - # Bad WCS projection - bad_base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - projection="orthographic", - ) - with self.assertRaises(ap.errors.ConflicingWCS): - test_image = ap.image.Image_List((base_image1, bad_base_image2)) - - -class TestModelImageList(unittest.TestCase): - def test_model_image_list_creation(self): - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Model_Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Model_Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - ) - - test_image = ap.image.Model_Image_List((base_image1, base_image2)) - - save_image = test_image.copy() - second_image = test_image.copy() - - second_image += (2, 2) - second_image -= (1, 1) - - test_image += second_image - - test_image -= second_image - - self.assertTrue( - torch.all(test_image[0].data == save_image[0].data), - "adding then subtracting should give the same image", - ) - self.assertTrue( - torch.all(test_image[1].data == save_image[1].data), - "adding then subtracting should give the same image", - ) - - print(test_image.data) - test_image.clear_image() - print(test_image.data) - test_image.replace(second_image) - print(test_image.data) - - test_image -= (1, 1) - print(test_image.data) - - self.assertTrue( - torch.all(test_image[0].data == save_image[0].data), - "adding then subtracting should give the same image", - ) - self.assertTrue( - torch.all(test_image[1].data == save_image[1].data), - "adding then subtracting should give the same image", - ) - - self.assertIsNone( - test_image.target_identity, - "Targets have not been assigned so target identity should be None", - ) - - def test_errors(self): - - # Model_Image_List with non Model_Image object - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Model_Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Target_Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - ) - - with self.assertRaises(ap.errors.InvalidImage): - test_image = ap.image.Model_Image_List((base_image1, base_image2)) - - -class TestTargetImageList(unittest.TestCase): - def test_target_image_list_creation(self): - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Target_Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - variance=torch.ones_like(arr1), - mask=torch.zeros_like(arr1), - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Target_Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - variance=torch.ones_like(arr2), - mask=torch.zeros_like(arr2), - ) - - test_image = ap.image.Target_Image_List((base_image1, base_image2)) - - save_image = test_image.copy() - second_image = test_image.copy() - - second_image += (2, 2) - second_image -= (1, 1) - - test_image += second_image - - test_image -= second_image - - self.assertTrue( - torch.all(test_image[0].data == save_image[0].data), - "adding then subtracting should give the same image", - ) - self.assertTrue( - torch.all(test_image[1].data == save_image[1].data), - "adding then subtracting should give the same image", - ) - - test_image += (1, 1) - test_image -= (1, 1) - - self.assertTrue( - torch.all(test_image[0].data == save_image[0].data), - "adding then subtracting should give the same image", - ) - self.assertTrue( - torch.all(test_image[1].data == save_image[1].data), - "adding then subtracting should give the same image", - ) - - def test_targetlist_errors(self): - arr1 = torch.zeros((10, 15)) - base_image1 = ap.image.Target_Image( - data=arr1, - pixelscale=1.0, - zeropoint=1.0, - origin=torch.zeros(2), - variance=torch.ones_like(arr1), - mask=torch.zeros_like(arr1), - ) - arr2 = torch.ones((15, 10)) - base_image2 = ap.image.Image( - data=arr2, - pixelscale=0.5, - zeropoint=2.0, - origin=torch.ones(2), - ) - with self.assertRaises(ap.errors.InvalidImage): - test_image = ap.image.Target_Image_List((base_image1, base_image2)) - - -class TestJacobianImageList(unittest.TestCase): - def test_jacobian_image_list_creation(self): - arr1 = torch.zeros((10, 15, 3)) - base_image1 = ap.image.Jacobian_Image( - data=arr1, - parameters=["a", "b", "c"], - target_identity="target1", - pixelscale=1.0, - zeropoint=1.0, - window=ap.image.Window(origin=torch.zeros(2) + 0.1, pixel_shape=torch.tensor((15, 10))), - ) - arr2 = torch.ones((15, 10, 3)) - base_image2 = ap.image.Jacobian_Image( - data=arr2, - parameters=["a", "b", "c"], - target_identity="target2", - pixelscale=0.5, - zeropoint=2.0, - window=ap.image.Window(origin=torch.zeros(2) + 0.2, pixel_shape=torch.tensor((10, 15))), - ) - - test_image = ap.image.Jacobian_Image_List((base_image1, base_image2)) - - second_image = test_image.copy() - - test_image += second_image - - self.assertEqual( - test_image.flatten("data").shape, - (300, 3), - "flattened jacobian should include all pixels and merge parameters", - ) - - -if __name__ == "__main__": - unittest.main() +def test_image_creation(): + arr1 = torch.zeros((10, 15)) + base_image1 = ap.Image(data=arr1, pixelscale=1.0, zeropoint=1.0, name="image1") + arr2 = torch.ones((15, 10)) + base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0, name="image2") + + test_image = ap.ImageList((base_image1, base_image2)) + + slicer = ap.WindowList( + (ap.Window((3, 12, 5, 8), base_image1), ap.Window((4, 8, 3, 13), base_image2)) + ) + sliced_image = test_image[slicer] + print(sliced_image[0].shape, sliced_image[1].shape) + assert sliced_image[0].shape == (9, 3), "image slice incorrect shape" + assert sliced_image[1].shape == (4, 10), "image slice incorrect shape" + assert np.all(sliced_image[0].crpix == np.array([-3, -5])), "image should track origin" + assert np.all(sliced_image[1].crpix == np.array([-4, -3])), "image should track origin" + + +def test_copy(): + arr1 = torch.zeros((10, 15)) + 2 + base_image1 = ap.Image(data=arr1, pixelscale=1.0, zeropoint=1.0, name="image1") + arr2 = torch.ones((15, 10)) + base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0, name="image2") + + test_image = ap.image.ImageList((base_image1, base_image2)) + + copy_image = test_image.copy() + copy_image.images[0] += 5 + copy_image.images[1] += 5 + + for ti, ci in zip(test_image, copy_image): + assert ti.pixelscale == ci.pixelscale, "copied image should have same pixelscale" + assert ti.zeropoint == ci.zeropoint, "copied image should have same zeropoint" + assert torch.all(ti.data != ci.data), "copied image should not modify original data" + + blank_copy_image = test_image.blank_copy() + for ti, ci in zip(test_image, blank_copy_image): + assert ti.pixelscale == ci.pixelscale, "copied image should have same pixelscale" + assert ti.zeropoint == ci.zeropoint, "copied image should have same zeropoint" + + +def test_image_arithmetic(): + arr1 = torch.zeros((10, 15)) + base_image1 = ap.Image(data=arr1, pixelscale=1.0, zeropoint=1.0, name="image1") + arr2 = torch.ones((15, 10)) + base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0, name="image2") + test_image = ap.image.ImageList((base_image1, base_image2)) + + base_image3 = base_image1.copy() + base_image3 += 1 + base_image4 = base_image2.copy() + base_image4 -= 2 + second_image = ap.image.ImageList((base_image3, base_image4)) + + # Test iadd + test_image += second_image + + assert torch.allclose( + test_image[0].data, torch.ones_like(base_image1.data) + ), "image addition should update its region" + assert torch.allclose( + base_image1.data, torch.ones_like(base_image1.data) + ), "image addition should update its region" + assert torch.allclose( + test_image[1].data, torch.zeros_like(base_image2.data) + ), "image addition should update its region" + assert torch.allclose( + base_image2.data, torch.zeros_like(base_image2.data) + ), "image addition should update its region" + + # Test isub + test_image -= second_image + + assert torch.allclose( + test_image[0].data, torch.zeros_like(base_image1.data) + ), "image addition should update its region" + assert torch.allclose( + base_image1.data, torch.zeros_like(base_image1.data) + ), "image addition should update its region" + assert torch.allclose( + test_image[1].data, torch.ones_like(base_image2.data) + ), "image addition should update its region" + assert torch.allclose( + base_image2.data, torch.ones_like(base_image2.data) + ), "image addition should update its region" + + +def test_model_image_list_error(): + arr1 = torch.zeros((10, 15)) + base_image1 = ap.ModelImage(data=arr1, pixelscale=1.0, zeropoint=1.0) + arr2 = torch.ones((15, 10)) + base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0) + + with pytest.raises(ap.errors.InvalidImage): + ap.image.ModelImageList((base_image1, base_image2)) + + +def test_target_image_list_creation(): + arr1 = torch.zeros((10, 15)) + base_image1 = ap.TargetImage( + data=arr1, + pixelscale=1.0, + zeropoint=1.0, + variance=torch.ones_like(arr1), + mask=torch.zeros_like(arr1), + name="image1", + ) + arr2 = torch.ones((15, 10)) + base_image2 = ap.TargetImage( + data=arr2, + pixelscale=0.5, + zeropoint=2.0, + variance=torch.ones_like(arr2), + mask=torch.zeros_like(arr2), + name="image2", + ) + + test_image = ap.TargetImageList((base_image1, base_image2)) + + save_image = test_image.copy() + second_image = test_image.copy() + + second_image[0].data += 1 + second_image[1].data += 1 + + test_image += second_image + test_image -= second_image + + assert torch.all( + test_image[0].data == save_image[0].data + ), "adding then subtracting should give the same image" + assert torch.all( + test_image[1].data == save_image[1].data + ), "adding then subtracting should give the same image" + + +def test_targetlist_errors(): + arr1 = torch.zeros((10, 15)) + base_image1 = ap.TargetImage( + data=arr1, + pixelscale=1.0, + zeropoint=1.0, + variance=torch.ones_like(arr1), + mask=torch.zeros_like(arr1), + ) + arr2 = torch.ones((15, 10)) + base_image2 = ap.Image( + data=arr2, + pixelscale=0.5, + zeropoint=2.0, + ) + with pytest.raises(ap.errors.InvalidImage): + ap.image.TargetImageList((base_image1, base_image2)) + + +def test_jacobian_image_list_error(): + arr1 = torch.zeros((10, 15, 3)) + base_image1 = ap.JacobianImage( + parameters=["a", "1", "zz"], data=arr1, pixelscale=1.0, zeropoint=1.0 + ) + arr2 = torch.ones((15, 10)) + base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0) + + with pytest.raises(ap.errors.InvalidImage): + ap.image.JacobianImageList((base_image1, base_image2)) diff --git a/tests/test_model.py b/tests/test_model.py index 524f8705..89c9b333 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -3,6 +3,7 @@ import torch import numpy as np from utils import make_basic_sersic, make_basic_gaussian_psf +import pytest # torch.autograd.set_detect_anomaly(True) ###################################################################### @@ -10,286 +11,122 @@ ###################################################################### -class TestModel(unittest.TestCase): - def test_AstroPhot_Model(self): - - model = ap.models.AstroPhot_Model(name="test model") - - self.assertIsNone(model.target, "model should not have a target at this point") - - target = ap.image.Target_Image(data=torch.zeros((16, 32)), pixelscale=1.0) - - model.target = target - - model.window = target.window - - model.locked = True - model.locked = False - - state = model.get_state() - - def test_initialize_does_not_recurse(self): - "Test case for error where missing parameter name triggered print that triggered missing parameter name ..." - target = make_basic_sersic() - model = ap.models.AstroPhot_Model( +def test_model_sampling_modes(): + + target = make_basic_sersic(90, 100) + model = ap.Model( + name="test sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=60 * np.pi / 180, + q=0.5, + n=2, + Re=5, + logIe=1, + target=target, + ) + model() + model.sampling_mode = "midpoint" + model() + model.sampling_mode = "simpsons" + model() + model.sampling_mode = "quad:3" + model() + model.integrate_mode = "none" + model() + model.integrate_mode = "should raise" + with pytest.raises(ap.errors.SpecificationConflict): + model() + model.integrate_mode = "none" + model.sampling_mode = "should raise" + with pytest.raises(ap.errors.SpecificationConflict): + model() + model.sampling_mode = "midpoint" + model.integrate_mode = "none" + + # test PSF modes + model.psf = np.array([[0.05, 0.1, 0.05], [0.1, 0.4, 0.1], [0.05, 0.1, 0.05]]) + model.psf_convolve = True + model() + + +def test_model_errors(): + + # Target that is not a target image + arr = torch.zeros((10, 15)) + target = ap.image.Image(data=arr, pixelscale=1.0, zeropoint=1.0) + + with pytest.raises(ap.errors.InvalidTarget): + model = ap.Model( name="test model", model_type="sersic galaxy model", target=target, ) - # Define a function that accesses a parameter that doesn't exist - def calc(params): - return params["A"].value - - model["center"].value = calc - - with self.assertRaises(KeyError) as context: - model.initialize() - - def test_basic_model_methods(self): - - target = make_basic_sersic() - model = ap.models.AstroPhot_Model( - name="test sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - rep = model.parameters.vector_representation() - nat = model.parameters.vector_values() - self.assertTrue( - torch.all(torch.isclose(rep, model.parameters.vector_transform_val_to_rep(nat))), - "transform should map between parameter natural and representation", - ) - self.assertTrue( - torch.all(torch.isclose(nat, model.parameters.vector_transform_rep_to_val(rep))), - "transform should map between parameter representation and natural", - ) - - def test_model_sampling_modes(self): - - target = make_basic_sersic(100, 100) - model = ap.models.AstroPhot_Model( - name="test sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - res = model() - model.sampling_mode = "trapezoid" - res = model() - model.sampling_mode = "simpsons" - res = model() - model.sampling_mode = "quad:3" - res = model() - model.integrate_mode = "none" - res = model() - model.integrate_mode = "should raise" - self.assertRaises(ap.errors.SpecificationConflict, model) - model.integrate_mode = "none" - model.sampling_mode = "should raise" - self.assertRaises(ap.errors.SpecificationConflict, model) - model.sampling_mode = "midpoint" - - # test PSF modes - model.psf = np.array([[0.05, 0.1, 0.05], [0.1, 0.4, 0.1], [0.05, 0.1, 0.05]]) - model.integrate_mode = "none" - model.psf_mode = "full" - model.psf_convolve_mode = "direct" - res = model() - model.psf_convolve_mode = "fft" - res = model() - - def test_model_creation(self): - np.random.seed(12345) - shape = (10, 15) - tar = ap.image.Target_Image( - data=np.random.normal(loc=0, scale=1.4, size=shape), - pixelscale=0.8, - variance=np.ones(shape) * (1.4**2), - psf=np.array([[0.05, 0.1, 0.05], [0.1, 0.4, 0.1], [0.05, 0.1, 0.05]]), - ) - - mod = ap.models.Component_Model( - name="base model", - target=tar, - parameters={"center": {"value": [5, 5], "locked": True}}, - ) - - mod.initialize() - - self.assertFalse(mod.locked, "default model should not be locked") - - self.assertTrue(torch.all(mod().data == 0), "Component_Model model_image should be zeros") - - def test_mask(self): - - target = make_basic_sersic() - mask = torch.zeros_like(target.data) - mask[10, 13] = 1 - model = ap.models.AstroPhot_Model( - name="test sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, + # model that doesn't exist + target = make_basic_sersic() + with pytest.raises(ap.errors.UnrecognizedModel): + model = ap.Model( + name="test model", + model_type="sersic gaaxy model", target=target, - mask=mask, ) - sample = model() - self.assertEqual(sample.data[10, 13].item(), 0.0, "masked values should be zero") - self.assertNotEqual(sample.data[11, 12].item(), 0.0, "unmasked values should NOT be zero") - - def test_model_errors(self): - - # Invalid name - self.assertRaises(ap.errors.NameNotAllowed, ap.models.AstroPhot_Model, name="my|model") - - # Target that is not a target image - arr = torch.zeros((10, 15)) - target = ap.image.Image(data=arr, pixelscale=1.0, zeropoint=1.0, origin=torch.zeros(2)) - - with self.assertRaises(ap.errors.InvalidTarget): - model = ap.models.AstroPhot_Model( - name="test model", - model_type="sersic galaxy model", - target=target, - ) - # model that doesn't exist - target = make_basic_sersic() - with self.assertRaises(ap.errors.UnrecognizedModel): - model = ap.models.AstroPhot_Model( - name="test model", - model_type="sersic gaaxy model", - target=target, - ) +def test_all_model_sample(): - # invalid window - with self.assertRaises(ap.errors.InvalidWindow): - model = ap.models.AstroPhot_Model( - name="test model", - model_type="sersic galaxy model", - target=target, - window=(1, 2, 3), - ) - - -class TestAllModelBasics(unittest.TestCase): - def test_all_model_sample(self): - - target = make_basic_sersic() - for model_type in ap.models.Component_Model.List_Model_Names(usable=True): - print(model_type) - MODEL = ap.models.AstroPhot_Model( - name="test model", - model_type=model_type, - target=target, - ) - MODEL.initialize() - for P in MODEL.parameter_order: - self.assertIsNotNone( - MODEL[P].value, - f"Model type {model_type} parameter {P} should not be None after initialization", - ) - img = MODEL() - self.assertTrue( - torch.all(torch.isfinite(img.data)), - "Model should evaluate a real number for the full image", - ) - self.assertIsInstance(str(MODEL), str, "String representation should return string") - self.assertIsInstance(repr(MODEL), str, "Repr should return string") - - -class TestSersic(unittest.TestCase): - def test_sersic_creation(self): - np.random.seed(12345) - N = 50 - Width = 20 - shape = (N + 10, N) - true_params = [2, 5, 10, -3, 5, 0.7, np.pi / 4] - IXX, IYY = np.meshgrid( - np.linspace(-Width, Width, shape[1]), np.linspace(-Width, Width, shape[0]) - ) - QPAXX, QPAYY = ap.utils.conversions.coordinates.Axis_Ratio_Cartesian_np( - true_params[5], IXX - true_params[3], IYY - true_params[4], true_params[6] - ) - Z0 = ap.utils.parametric_profiles.sersic_np( - np.sqrt(QPAXX**2 + QPAYY**2), - true_params[0], - true_params[1], - true_params[2], - ) + np.random.normal(loc=0, scale=0.1, size=shape) - tar = ap.image.Target_Image( - data=Z0, - pixelscale=0.8, - variance=np.ones(Z0.shape) * (0.1**2), - ) - - mod = ap.models.Sersic_Galaxy( - name="sersic model", - target=tar, - parameters={"center": [-3.2 + N / 2, 5.1 + (N + 10) / 2]}, - ) - - self.assertFalse(mod.locked, "default model should not be locked") - - mod.initialize() - - def test_sersic_save_load(self): - - target = make_basic_sersic() - psf = make_basic_gaussian_psf() - model = ap.models.AstroPhot_Model( - name="test sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - psf=psf, - psf_mode="full", + target = make_basic_sersic() + for model_type in ap.models.ComponentModel.List_Models(usable=True, types=True): + print(model_type) + MODEL = ap.Model( + name="test model", + model_type=model_type, target=target, ) - - model.initialize() - model.save("test_AstroPhot_sersic.yaml") - model2 = ap.models.AstroPhot_Model( - name="load model", - filename="test_AstroPhot_sersic.yaml", - ) - - for P in model.parameter_order: - self.assertAlmostEqual( - model[P].value.detach().cpu().tolist(), - model2[P].value.detach().cpu().tolist(), - msg="loaded model should have same parameters", - ) - - -if __name__ == "__main__": - unittest.main() + MODEL.initialize() + for P in MODEL.dynamic_params: + assert ( + P.value is not None + ), f"Model type {model_type} parameter {P.name} should not be None after initialization" + img = MODEL() + assert torch.all( + torch.isfinite(img.data) + ), "Model should evaluate a real number for the full image" + + +def test_sersic_save_load(self): + + target = make_basic_sersic() + model = ap.Model( + name="test sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=60 * np.pi / 180, + q=0.5, + n=2, + Re=5, + logIe=1, + target=target, + ) + + model.initialize() + model.save_state("test_AstroPhot_sersic.hdf5", appendable=True) + model.center = [30, 30] + model.PA = 30 * np.pi / 180 + model.q = 0.8 + model.n = 3 + model.Re = 10 + model.logIe = 2 + target.crtan = [1.0, 2.0] + model.append_state("test_AstroPhot_sersic.hdf5") + model.load_state("test_AstroPhot_sersic.hdf5", index=0) + + assert model.center.value[0].item() == 20, "Model center should be loaded correctly" + assert model.center.value[1].item() == 20, "Model center should be loaded correctly" + assert model.PA.value.item() == 60 * np.pi / 180, "Model PA should be loaded correctly" + assert model.q.value.item() == 0.5, "Model q should be loaded correctly" + assert model.n.value.item() == 2, "Model n should be loaded correctly" + assert model.Re.value.item() == 5, "Model Re should be loaded correctly" + assert model.logIe.value.item() == 1, "Model logIe should be loaded correctly" + assert model.target.crtan[0] == 0.0, "Model target crtan should be loaded correctly" + assert model.target.crtan[1] == 0.0, "Model target crtan should be loaded correctly" diff --git a/tests/test_param.py b/tests/test_param.py new file mode 100644 index 00000000..aa6885a6 --- /dev/null +++ b/tests/test_param.py @@ -0,0 +1,32 @@ +from astrophot.param import Param +import torch + + +def test_param(): + + a = Param("a", value=1.0, uncertainty=0.1, valid=(0, 2), prof=1.0) + assert a.is_valid(1.5), "value should be valid" + assert isinstance(a.uncertainty, torch.Tensor), "uncertainty should be a tensor" + assert isinstance(a.prof, torch.Tensor), "prof should be a tensor" + assert a.initialized, "parameter should be marked as initialized" + assert a.soft_valid(a.value) == a.value, "soft valid should return the value if not near limits" + assert ( + a.soft_valid(-1 * torch.ones_like(a.value)) > a.valid[0] + ), "soft valid should push values inside the limits" + assert ( + a.soft_valid(3 * torch.ones_like(a.value)) < a.valid[1] + ), "soft valid should push values inside the limits" + + b = Param("b", value=[2.0, 3.0], uncertainty=[0.1, 0.1], valid=(1, None)) + assert not b.is_valid(0.5), "value should not be valid" + assert b.is_valid(10.5), "value should be valid" + assert torch.all( + b.soft_valid(-1 * torch.ones_like(b.value)) > b.valid[0] + ), "soft valid should push values inside the limits" + assert b.prof is None + + c = Param("c", value=lambda P: P.a.value, valid=(None, 4.0)) + c.link(a) + assert c.initialized, "pointer should be marked as initialized" + assert c.is_valid(0.5), "value should be valid" + assert c.uncertainty is None diff --git a/tests/test_parameter.py b/tests/test_parameter.py deleted file mode 100644 index bfa9b4cd..00000000 --- a/tests/test_parameter.py +++ /dev/null @@ -1,570 +0,0 @@ -import unittest -from astrophot.param import ( - Node as BaseNode, - Parameter_Node, - Param_Mask, - Param_Unlock, -) -import astrophot as ap -import torch -import numpy as np - - -class Node(BaseNode): - """ - Dummy class for testing purposes - """ - - def value(self): - return None - - -class TestNode(unittest.TestCase): - - def test_node_init(self): - node1 = Node("node1") - node2 = Node("node2", locked=True) - - # Check for bad naming - with self.assertRaises(ValueError): - node_bad = Node("node:bad") - - def test_node_link(self): - node1 = Node("node1") - node2 = Node("node2") - node3 = Node("node3", locked=True) - - node1.link(node2, node3) - - self.assertTrue(node1.branch, "node1 is a branch") - self.assertFalse(node3.branch, "node1 is not a branch") - self.assertIs(node1["node2"], node2, "node getitem should fetch correct node") - - for Na, Nb in zip(node1.flat().values(), (node2, node3)): - self.assertIs(Na, Nb, "node flat should produce correct order") - - node4 = Node("node4") - - node2.link(node4) - - for Na, Nb in zip(node1.flat(include_locked=False).values(), (node4,)): - self.assertIs(Na, Nb, "node flat should produce correct order") - - # Check for cycle in DAG - with self.assertRaises(ap.errors.InvalidParameter): - node4.link(node1) - - node1.dump() - - self.assertEqual(len(node1.nodes), 0, "dump should clear all nodes") - - def test_node_access(self): - node1 = Node("node1") - node2 = Node("node2") - node3 = Node("node3", locked=True) - - node1.link(node2, node3) - node4 = Node("node4") - - node2.link(node4) - - self.assertIs(node1["node2:node4"], node4, "node getitem should fetch correct node") - self.assertEqual( - node1["node1"], - node1, - "node should get itself when getter called with its name", - ) - - # Check that error is raised when requesting non existent node - with self.assertRaises(KeyError): - badnode = node1[1.2] - - def test_state(self): - - node1 = Node("node1") - node2 = Node("node2") - node3 = Node("node3", locked=True) - - node1.link(node2, node3) - - state = node1.get_state() - - S = str(node1) - R = repr(node1) - - -class TestParameter(unittest.TestCase): - @torch.no_grad() - def test_parameter_setting(self): - base_param = Parameter_Node("base param") - base_param.value = 1.0 - self.assertEqual(base_param.value, 1, msg="Value should be set to 1") - - base_param.value = 2.0 - self.assertEqual(base_param.value, 2, msg="Value should update to 2") - - base_param.value += 2.0 - self.assertEqual(base_param.value, 4, msg="Value should update to 4") - - # Test a locked parameter that it does not change - locked_param = Parameter_Node("locked param", value=1.0, locked=True) - locked_param.value = 2.0 - self.assertEqual(locked_param.value, 1, msg="Locked value should remain at 1") - - locked_param.value = 2.0 - self.assertEqual(locked_param.value, 1, msg="Locked value should remain at 1") - - def test_parameter_limits(self): - - # Lower limit parameter - lowlim_param = Parameter_Node("lowlim param", limits=(1, None)) - lowlim_param.value = 100.0 - self.assertEqual( - lowlim_param.value, - 100, - msg="lower limit variable should not have upper limit", - ) - with self.assertRaises(ap.errors.InvalidParameter): - lowlim_param.value = -100.0 - - # Upper limit parameter - uplim_param = Parameter_Node("uplim param", limits=(None, 1)) - uplim_param.value = -100.0 - self.assertEqual( - uplim_param.value, - -100, - msg="upper limit variable should not have lower limit", - ) - with self.assertRaises(ap.errors.InvalidParameter): - uplim_param.value = 100.0 - - # Range limit parameter - range_param = Parameter_Node("range param", limits=(-1, 1)) - with self.assertRaises(ap.errors.InvalidParameter): - range_param.value = 100.0 - with self.assertRaises(ap.errors.InvalidParameter): - range_param.value = -100.0 - - # Cyclic Range limit parameter - cyrange_param = Parameter_Node("cyrange param", limits=(-1, 1), cyclic=True) - cyrange_param.value = 2.0 - self.assertEqual( - cyrange_param.value, - 0, - msg="cyclic variable should loop in range (upper)", - ) - cyrange_param.value = -2.0 - self.assertEqual( - cyrange_param.value, - 0, - msg="cyclic variable should loop in range (lower)", - ) - - def test_parameter_array(self): - - param_array1 = Parameter_Node("array1", value=list(float(3 + i) for i in range(5))) - param_array2 = Parameter_Node("array2", value=list(float(i) for i in range(5))) - - param_array2.value = list(float(3) for i in range(5)) - self.assertTrue( - torch.all(param_array2.value == 3), - msg="parameter array value should be updated", - ) - - self.assertEqual(len(param_array2), 5, "parameter array should have length attribute") - - def test_parameter_gradients(self): - V = torch.ones(3) - V.requires_grad = True - params = Parameter_Node("input params", value=V) - X = torch.sum(params.value * 3) - X.backward() - self.assertTrue(torch.all(V.grad == 3), "Parameters should track gradient") - - def test_parameter_state(self): - - P = Parameter_Node( - "state", value=1.0, uncertainty=0.5, limits=(-2, 2), locked=True, prof=1.0 - ) - - P2 = Parameter_Node("v2") - P2.set_state(P.get_state()) - - self.assertEqual(P.value, P2.value, "state should preserve value") - self.assertEqual(P.uncertainty, P2.uncertainty, "state should preserve uncertainty") - self.assertEqual(P.prof, P2.prof, "state should preserve prof") - self.assertEqual(P.locked, P2.locked, "state should preserve locked") - self.assertEqual( - P.limits[0].tolist(), P2.limits[0].tolist(), "state should preserve limits" - ) - self.assertEqual( - P.limits[1].tolist(), P2.limits[1].tolist(), "state should preserve limits" - ) - - S = str(P) - - def test_parameter_value(self): - - P1 = Parameter_Node( - "test1", value=0.5, uncertainty=0.5, limits=(-1, 1), locked=False, prof=1.0 - ) - - P2 = Parameter_Node("test2", value=P1) - - P3 = Parameter_Node("test3", value=lambda P: P["test1"].value ** 2, link=(P1,)) - - self.assertEqual(P1.value.item(), 0.5, "Parameter should store value") - self.assertEqual(P2.value.item(), 0.5, "Pointing parameter should fetch value") - self.assertEqual(P3.value.item(), 0.25, "Function parameter should compute value") - - self.assertEqual(P2.shape, P1.shape, "reference node should map shape") - self.assertEqual(P3.shape, P1.shape, "reference node should map shape") - - -class TestParamContext(unittest.TestCase): - def test_unlock(self): - locked_param = Parameter_Node("locked param", value=1.0, locked=True) - locked_param.value = 2.0 - self.assertEqual( - locked_param.value.item(), - 1.0, - "locked parameter should not be updated out of context", - ) - with Param_Unlock(locked_param): - locked_param.value = 2.0 - self.assertEqual( - locked_param.value.item(), - 2.0, - "locked parameter should be updated in context", - ) - with Param_Unlock(): - locked_param.value = 3.0 - self.assertEqual( - locked_param.value.item(), - 3.0, - "locked parameter should be updated in global unlock context", - ) - - -class TestParameterVector(unittest.TestCase): - def test_param_vector_creation(self): - - P1 = Parameter_Node( - "test1", value=0.5, uncertainty=0.5, limits=(-1, 1), locked=False, prof=1.0 - ) - P2 = Parameter_Node("test2", value=2.0, uncertainty=5.0, locked=False) - P3 = Parameter_Node("test3", value=[4.0, 5.0], uncertainty=[5.0, 5.0], locked=False) - P4 = Parameter_Node("test4", value=P2) - P5 = Parameter_Node("test5", value=lambda P: P["test1"].value ** 2, link=(P1,)) - PG = Parameter_Node("testgroup", link=(P1, P2, P3, P4, P5)) - - self.assertTrue( - torch.all( - PG.vector_values() - == torch.tensor([0.5, 2.0, 4.0, 5.0], dtype=P1.value.dtype, device=P1.value.device) - ), - "Vector store all leaf node values", - ) - self.assertEqual(PG.mask.numel(), 4, "Vector should take all/only leaf node masks") - self.assertEqual( - PG.vector_identities().size, - 4, - "Vector should take all/only leaf node identities", - ) - self.assertEqual(PG.identities.size, 4, "Vector should take all/only leaf node identities") - self.assertEqual(PG.names.size, 4, "Vector should take all/only leaf node names") - self.assertEqual(PG.vector_names().size, 4, "Vector should take all/only leaf node names") - - PG.value = [1.0, 2.0, 3.0, 4.0] - self.assertTrue( - torch.all( - PG.vector_values() - == torch.tensor([1.0, 2.0, 3.0, 4.0], dtype=P1.value.dtype, device=P1.value.device) - ), - "Vector store all leaf node values", - ) - - def test_vector_masking(self): - - P1 = Parameter_Node( - "test1", value=0.5, uncertainty=0.3, limits=(-1, 1), locked=False, prof=1.0 - ) - P2 = Parameter_Node("test2", value=2.0, uncertainty=1.0, locked=False) - P3 = Parameter_Node("test3", value=[4.0, 5.0], uncertainty=[5.0, 3.0], locked=False) - P4 = Parameter_Node("test4", value=P2) - P5 = Parameter_Node("test5", value=lambda P: P["test1"].value ** 2, link=(P1,)) - PG = Parameter_Node("testgroup", link=(P1, P2, P3, P4, P5)) - - mask = torch.tensor([1, 0, 0, 1], dtype=torch.bool, device=P1.value.device) - - with Param_Mask(PG, mask): - self.assertTrue( - torch.all( - PG.vector_values() - == torch.tensor([0.5, 5.0], dtype=P1.value.dtype, device=P1.value.device) - ), - "Vector store all leaf node values", - ) - self.assertTrue( - torch.all( - PG.vector_uncertainty() - == torch.tensor([0.3, 3.0], dtype=P1.value.dtype, device=P1.value.device) - ), - "Vector store all leaf node uncertainty", - ) - self.assertEqual( - PG.vector_mask().numel(), - 4, - "Vector should take all/only leaf node masks", - ) - self.assertEqual( - PG.vector_identities().size, - 2, - "Vector should take all/only leaf node identities", - ) - - # Nested masking - new_mask = torch.tensor([1, 0], dtype=torch.bool, device=P1.value.device) - with Param_Mask(PG, new_mask): - self.assertTrue( - torch.all( - PG.vector_values() - == torch.tensor([0.5], dtype=P1.value.dtype, device=P1.value.device) - ), - "Vector store all leaf node values", - ) - self.assertTrue( - torch.all( - PG.vector_uncertainty() - == torch.tensor([0.3], dtype=P1.value.dtype, device=P1.value.device) - ), - "Vector store all leaf node uncertainty", - ) - self.assertEqual( - PG.vector_mask().numel(), - 4, - "Vector should take all/only leaf node masks", - ) - self.assertEqual( - PG.vector_identities().size, - 1, - "Vector should take all/only leaf node identities", - ) - - self.assertTrue( - torch.all( - PG.vector_values() - == torch.tensor([0.5, 5.0], dtype=P1.value.dtype, device=P1.value.device) - ), - "Vector store all leaf node values", - ) - self.assertTrue( - torch.all( - PG.vector_uncertainty() - == torch.tensor([0.3, 3.0], dtype=P1.value.dtype, device=P1.value.device) - ), - "Vector store all leaf node uncertainty", - ) - self.assertEqual( - PG.vector_mask().numel(), - 4, - "Vector should take all/only leaf node masks", - ) - self.assertEqual( - PG.vector_identities().size, - 2, - "Vector should take all/only leaf node identities", - ) - - def test_vector_representation(self): - - P1 = Parameter_Node( - "test1", value=0.5, uncertainty=0.3, limits=(-1, 1), locked=False, prof=1.0 - ) - P2 = Parameter_Node("test2", value=2.0, uncertainty=1.0, locked=False) - P3 = Parameter_Node( - "test3", - value=[4.0, 5.0], - uncertainty=[5.0, 3.0], - limits=(1.0, None), - locked=False, - ) - P4 = Parameter_Node("test4", value=P2) - P5 = Parameter_Node("test5", value=lambda P: P["test1"].value ** 2, link=(P1,)) - P6 = Parameter_Node( - "test6", - value=((5, 6), (7, 8)), - uncertainty=0.1 * np.zeros((2, 2)), - limits=(None, 10.0), - ) - PG = Parameter_Node("testgroup", link=(P1, P2, P3, P4, P5, P6)) - - mask = torch.tensor([1, 1, 0, 1, 0, 1, 0, 1], dtype=torch.bool, device=P1.value.device) - - self.assertEqual( - len(PG.vector_representation()), - 8, - "representation should collect all values", - ) - with Param_Mask(PG, mask): - # round trip - vec = PG.vector_values().clone() - rep = PG.vector_representation() - PG.vector_set_representation(rep) - self.assertTrue( - torch.all(vec == PG.vector_values()), - "representation should be reversible", - ) - self.assertEqual(PG.vector_values().numel(), 5, "masked values shouldn't be shown") - - def test_printing(self): - - def node_func_sqr(P): - return P["test1"].value ** 2 - - P1 = Parameter_Node( - "test1", value=0.5, uncertainty=0.3, limits=(-1, 1), locked=False, prof=1.0 - ) - P2 = Parameter_Node("test2", value=2.0, uncertainty=1.0, locked=False) - P3 = Parameter_Node( - "test3", - value=[4.0, 5.0], - uncertainty=[5.0, 3.0], - limits=((0.0, 1.0), None), - locked=False, - ) - P4 = Parameter_Node("test4", value=P2) - P5 = Parameter_Node("test5", value=node_func_sqr, link=(P1,)) - P6 = Parameter_Node( - "test6", - value=((5, 6), (7, 8)), - uncertainty=0.1 * np.zeros((2, 2)), - limits=(None, 10 * np.ones((2, 2))), - ) - PG = Parameter_Node("testgroup", link=(P1, P2, P3, P4, P5, P6)) - - self.assertEqual( - str(PG), - """testgroup: -test1: 0.5 +- 0.3 [none], limits: (-1.0, 1.0) -test2: 2.0 +- 1.0 [none] -test3: [4.0, 5.0] +- [5.0, 3.0] [none], limits: ([0.0, 1.0], None) -test6: [[5.0, 6.0], [7.0, 8.0]] +- [[0.0, 0.0], [0.0, 0.0]] [none], limits: (None, [[10.0, 10.0], [10.0, 10.0]])""", - "String representation should return specific string", - ) - - ref_string = """testgroup (id-140071931416000, branch node): - test1 (id-140071931414752): 0.5 +- 0.3 [none], limits: (-1.0, 1.0) - test2 (id-140071931415376): 2.0 +- 1.0 [none] - test3 (id-140071931415472): [4.0, 5.0] +- [5.0, 3.0] [none], limits: ([0.0, 1.0], None) - test4 (id-140071931414272) points to: test2 (id-140071931415376): 2.0 +- 1.0 [none] - test5 (id-140071931414992, function node, node_func_sqr): - test1 (id-140071931414752): 0.5 +- 0.3 [none], limits: (-1.0, 1.0) - test6 (id-140071931415616): [[5.0, 6.0], [7.0, 8.0]] +- [[0.0, 0.0], [0.0, 0.0]] [none], limits: (None, [[10.0, 10.0], [10.0, 10.0]])""" - # Remove ids since they change every time - while "(id-" in ref_string: - start = ref_string.find("(id-") - end = ref_string.find(")", start) + 1 - ref_string = ref_string[:start] + ref_string[end:] - - repr_string = repr(PG) - # Remove ids since they change every time - count = 0 - while "(id-" in repr_string: - start = repr_string.find("(id-") - end = repr_string.find(")", start) + 1 - repr_string = repr_string[:start] + repr_string[end:] - count += 1 - if count > 100: - raise RuntimeError("infinite loop! Something very wrong with parameter repr") - self.assertEqual(repr_string, ref_string, "Repr should return specific string") - - def test_empty_vector(self): - def node_func_sqr(P): - return P["test1"].value ** 2 - - P1 = Parameter_Node( - "test1", value=0.5, uncertainty=0.3, limits=(-1, 1), locked=True, prof=1.0 - ) - P2 = Parameter_Node("test2", value=2.0, uncertainty=1.0, locked=True) - P3 = Parameter_Node( - "test3", - value=[4.0, 5.0], - uncertainty=[5.0, 3.0], - limits=((0.0, 1.0), None), - locked=True, - ) - P4 = Parameter_Node("test4", value=P2) - P5 = Parameter_Node("test5", value=node_func_sqr, link=(P1,)) - P6 = Parameter_Node( - "test6", - value=((5, 6), (7, 8)), - uncertainty=0.1 * np.zeros((2, 2)), - limits=(None, 10 * np.ones((2, 2))), - locked=True, - ) - PG = Parameter_Node("testgroup", link=(P1, P2, P3, P4, P5, P6)) - - self.assertEqual(PG.names.shape, (0,), "all locked parameter should have empty names") - self.assertEqual( - PG.identities.shape, - (0,), - "all locked parameter should have empty identities", - ) - self.assertEqual( - PG.vector_names().shape, - (0,), - "all locked parameter should have empty names", - ) - self.assertEqual( - PG.vector_identities().shape, - (0,), - "all locked parameter should have empty identities", - ) - - self.assertEqual( - PG.vector_values().shape, - (0,), - "all locked parameter should have empty values", - ) - self.assertEqual( - PG.vector_uncertainty().shape, - (0,), - "all locked parameter should have empty uncertainty", - ) - self.assertEqual( - PG.vector_mask().shape, (0,), "all locked parameter should have empty mask" - ) - self.assertEqual( - PG.vector_representation().shape, - (0,), - "all locked parameter should have empty representation", - ) - - def test_none_uncertainty(self): - - P1 = Parameter_Node( - "test1", value=0.5, uncertainty=0.3, limits=(-1, 1), locked=False, prof=1.0 - ) - P2 = Parameter_Node("test2", value=2.0, locked=True) - P3 = Parameter_Node("test3", value=[4.0, 5.0], limits=((0.0, 1.0), None), locked=False) - P4 = Parameter_Node("test4", link=(P1, P2, P3)) - - self.assertEqual( - tuple(P4.vector_uncertainty().detach().cpu().tolist()), - (0.3, 1.0, 1.0), - "None uncertainty should be filled with ones", - ) - - P3.uncertainty = None - P4.vector_set_uncertainty((0.1, 0.1, 0.1)) - - self.assertEqual( - tuple(P4.vector_uncertainty().detach().cpu().tolist()), - (0.1, 0.1, 0.1), - "None uncertainty should be filled using vector_set_uncertainty", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_plots.py b/tests/test_plots.py index 0c910084..46a904e4 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -1,202 +1,165 @@ -import unittest - import numpy as np import matplotlib.pyplot as plt import astrophot as ap from utils import make_basic_sersic, make_basic_gaussian_psf +import pytest -class TestPlots(unittest.TestCase): - """ - Can't test visuals, so this only tests that the code runs - """ - - def test_target_image(self): - target = make_basic_sersic() +""" +Can't test visuals, so this only tests that the code runs +""" - try: - fig, ax = plt.subplots() - except Exception: - print("skipping test_target_image because matplotlib is not installed properly") - return - ap.plots.target_image(fig, ax, target) - plt.close() +def test_target_image(): + target = make_basic_sersic() + try: + fig, ax = plt.subplots() + except Exception: + pytest.skip("skipping test_target_image because matplotlib is not installed properly") + ap.plots.target_image(fig, ax, target) + plt.close() - def test_psf_image(self): - target = make_basic_gaussian_psf() +def test_psf_image(): + target = make_basic_gaussian_psf() + try: + fig, ax = plt.subplots() + except Exception: + pytest.skip("skipping test_target_image because matplotlib is not installed properly") + ap.plots.psf_image(fig, ax, target) + plt.close() + + +def test_target_image_list(): + target1 = make_basic_sersic(name="target1") + target2 = make_basic_sersic(name="target2") + target = ap.TargetImageList([target1, target2]) + try: + fig, ax = plt.subplots(2) + except Exception: + pytest.skip("skipping test_target_image_list because matplotlib is not installed properly") + ap.plots.target_image(fig, ax, target) + plt.close() + + +def test_model_image(): + target = make_basic_sersic() + new_model = ap.Model( + name="constrained sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=60 * np.pi / 180, + q=0.5, + n=2, + Re=5, + Ie=1, + target=target, + ) + new_model.initialize() + try: + fig, ax = plt.subplots() + except Exception: + pytest.skip("skipping test because matplotlib is not installed properly") + ap.plots.model_image(fig, ax, new_model) + plt.close() + + +def test_residual_image(): + target = make_basic_sersic() + new_model = ap.Model( + name="constrained sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=60 * np.pi / 180, + q=0.5, + n=2, + Re=5, + logIe=1, + target=target, + ) + new_model.initialize() + try: fig, ax = plt.subplots() + except Exception: + pytest.skip("skipping test because matplotlib is not installed properly") + ap.plots.residual_image(fig, ax, new_model) + plt.close() + + +def test_model_windows(): + target = make_basic_sersic() + new_model = ap.Model( + name="constrained sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=60 * np.pi / 180, + q=0.5, + n=2, + Re=5, + Ie=1, + window=(10, 10, 30, 30), + target=target, + ) + new_model.initialize() + try: + fig, ax = plt.subplots() + except Exception: + pytest.skip("skipping test because matplotlib is not installed properly") + ap.plots.model_window(fig, ax, new_model) + plt.close() + - ap.plots.psf_image(fig, ax, target) - plt.close() - - def test_target_image_list(self): - target1 = make_basic_sersic() - target2 = make_basic_sersic() - target = ap.image.Target_Image_List([target1, target2]) - - try: - fig, ax = plt.subplots(2) - except Exception: - print("skipping test_target_image_list because matplotlib is not installed properly") - return - - ap.plots.target_image(fig, ax, target) - plt.close() - - def test_model_image(self): - target = make_basic_sersic() - - new_model = ap.models.AstroPhot_Model( - name="constrained sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - new_model.initialize() - - try: - fig, ax = plt.subplots() - except Exception: - print("skipping test because matplotlib is not installed properly") - return - - ap.plots.model_image(fig, ax, new_model) - - plt.close() - - def test_residual_image(self): - target = make_basic_sersic() - - new_model = ap.models.AstroPhot_Model( - name="constrained sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - new_model.initialize() - - try: - fig, ax = plt.subplots() - except Exception: - print("skipping test because matplotlib is not installed properly") - return - - ap.plots.residual_image(fig, ax, new_model) - - plt.close() - - def test_model_windows(self): - - target = make_basic_sersic() - - new_model = ap.models.AstroPhot_Model( - name="constrained sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - new_model.initialize() - - try: - fig, ax = plt.subplots() - except Exception: - print("skipping test because matplotlib is not installed properly") - return - - ap.plots.model_window(fig, ax, new_model) - - plt.close() - - def test_covariance_matrix(self): - covariance_matrix = np.array([[1, 0.5], [0.5, 1]]) - mean = np.array([0, 0]) - - try: - fig, ax = plt.subplots() - except Exception: - print("skipping test because matplotlib is not installed properly") - return - - fig, ax = ap.plots.covariance_matrix(covariance_matrix, mean, labels=["x", "y"]) - - plt.close() - - def test_radial_profile(self): - target = make_basic_sersic() - - new_model = ap.models.AstroPhot_Model( - name="constrained sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - new_model.initialize() - - try: - fig, ax = plt.subplots() - except Exception: - print("skipping test because matplotlib is not installed properly") - return - - ap.plots.radial_light_profile(fig, ax, new_model) - - plt.close() - - def test_radial_median_profile(self): - target = make_basic_sersic() - - new_model = ap.models.AstroPhot_Model( - name="constrained sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - new_model.initialize() - - try: - fig, ax = plt.subplots() - except Exception: - print("skipping test because matplotlib is not installed properly") - return - - ap.plots.radial_median_profile(fig, ax, new_model) - - plt.close() +def test_covariance_matrix(): + covariance_matrix = np.array([[1, 0.5], [0.5, 1]]) + mean = np.array([0, 0]) + try: + fig, ax = plt.subplots() + except Exception: + pytest.skip("skipping test because matplotlib is not installed properly") + fig, ax = ap.plots.covariance_matrix(covariance_matrix, mean, labels=["x", "y"]) + plt.close() + + +def test_radial_profile(): + target = make_basic_sersic() + new_model = ap.Model( + name="constrained sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=60 * np.pi / 180, + q=0.5, + n=2, + Re=5, + logIe=1, + target=target, + ) + new_model.initialize() + try: + fig, ax = plt.subplots() + except Exception: + pytest.skip("skipping test because matplotlib is not installed properly") + ap.plots.radial_light_profile(fig, ax, new_model) + plt.close() + + +def test_radial_median_profile(): + target = make_basic_sersic() + new_model = ap.Model( + name="constrained sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=60 * np.pi / 180, + q=0.5, + n=2, + Re=5, + logIe=1, + target=target, + ) + new_model.initialize() + try: + fig, ax = plt.subplots() + except Exception: + pytest.skip("skipping test because matplotlib is not installed properly") + ap.plots.radial_median_profile(fig, ax, new_model) + plt.close() diff --git a/tests/utils.py b/tests/utils.py index 72109c94..bd252427 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -37,33 +37,33 @@ def make_basic_sersic( Re=7.1, Ie=0, rand=12345, + **kwargs, ): np.random.seed(rand) mask = np.zeros((N, M), dtype=bool) mask[0][0] = True - target = ap.image.Target_Image( + target = ap.TargetImage( data=np.zeros((N, M)), pixelscale=pixelscale, psf=ap.utils.initialize.gaussian_psf(2 / pixelscale, 11, pixelscale), mask=mask, + **kwargs, ) - MODEL = ap.models.Sersic_Galaxy( + MODEL = ap.models.SersicGalaxy( name="basic sersic model", target=target, - parameters={ - "center": [x, y], - "PA": PA, - "q": q, - "n": n, - "Re": Re, - "Ie": Ie, - }, + center=[x, y], + PA=PA, + q=q, + n=n, + Re=Re, + Ie=Ie, sampling_mode="quad:5", ) - img = MODEL().data.detach().cpu().numpy() + img = MODEL().data.T.detach().cpu().numpy() target.data = ( img + np.random.normal(scale=0.1, size=img.shape) @@ -127,9 +127,10 @@ def make_basic_gaussian_psf( psf = ap.utils.initialize.gaussian_psf(sigma / pixelscale, N, pixelscale) psf += np.random.normal(scale=psf / 2) psf[psf < 0] = 0 - target = ap.image.PSF_Image( - data=psf / np.sum(psf), + target = ap.PSFImage( + data=psf, pixelscale=pixelscale, ) + target.normalize() return target From 110bc73ed227ccb690682e4400b5846d20c454fc Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 12 Jul 2025 10:58:21 -0400 Subject: [PATCH 055/191] add gaussian ellipsoid --- astrophot/image/image_object.py | 8 +- astrophot/image/psf_image.py | 2 +- astrophot/image/target_image.py | 2 +- astrophot/models/__init__.py | 2 + astrophot/models/_shared_methods.py | 2 +- astrophot/models/base.py | 2 + astrophot/models/func/__init__.py | 2 + astrophot/models/func/gaussian_ellipsoid.py | 24 +++++ astrophot/models/gaussian_ellipsoid.py | 108 ++++++++++++++++++++ astrophot/models/model_object.py | 4 + astrophot/plots/image.py | 26 ++--- astrophot/utils/interpolate.py | 1 + docs/source/tutorials/ModelZoo.ipynb | 66 +++++++++++- tests/test_model.py | 41 ++++---- 14 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 astrophot/models/func/gaussian_ellipsoid.py create mode 100644 astrophot/models/gaussian_ellipsoid.py diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index cebfc274..892e593f 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -323,7 +323,13 @@ def crop(self, pixels, **kwargs): crop - (int, int): crop each dimension by the number of pixels given. new shape (N - 2*crop[1], M - 2*crop[0]) crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) """ - if len(pixels) == 1: # same crop in all dimension + if isinstance(pixels, int): + data = self.data[ + pixels : self.data.shape[0] - pixels, + pixels : self.data.shape[1] - pixels, + ] + crpix = self.crpix - pixels + elif len(pixels) == 1: # same crop in all dimension crop = pixels if isinstance(pixels, int) else pixels[0] data = self.data[ crop : self.data.shape[0] - crop, diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 46725be6..550df982 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -44,7 +44,7 @@ def normalize(self): @property def psf_pad(self): - return np.max(self.data.shape) // 2 + return max(self.data.shape) // 2 def jacobian_image( self, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 3c1fc51d..876ead8b 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -229,7 +229,7 @@ def model_image(self, upsample=1, pad=0, **kwargs): def psf_image(self, data, upscale=1, **kwargs): kwargs = { - "_data": data, + "data": data, "CD": self.CD.value / upscale, "identity": self.identity, "name": self.name + "_psf", diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 627ff069..016319d4 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -26,6 +26,7 @@ # Special galaxy types from .edgeon import EdgeonModel, EdgeonSech, EdgeonIsothermal from .multi_gaussian_expansion import MultiGaussianExpansion +from .gaussian_ellipsoid import GaussianEllipsoid # Standard models based on a core radial profile from .sersic import ( @@ -127,6 +128,7 @@ "EdgeonSech", "EdgeonIsothermal", "MultiGaussianExpansion", + "GaussianEllipsoid", "FourierEllipseGalaxy", "SersicGalaxy", "SersicPSF", diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 56dff9a7..42a0c658 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -26,7 +26,7 @@ def _sample_image( # Get the radius of each pixel relative to object center x, y = transform(*image.coordinate_center_meshgrid(), params=()) - R = radius(x, y).detach().cpu().numpy().flatten() + R = radius(x, y, params=()).detach().cpu().numpy().flatten() if angle_range is not None: T = angle(x, y).detach().cpu().numpy().flatten() CHOOSE = ((T % (2 * np.pi)) > angle_range[0]) & ((T % (2 * np.pi)) < angle_range[1]) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 29d90bea..b83638de 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -302,9 +302,11 @@ def List_Models(cls, usable: Optional[bool] = None, types: bool = False) -> set: result.add(model) return result + @forward def radius_metric(self, x, y): return (x**2 + y**2 + self.softening**2).sqrt() + @forward def angular_metric(self, x, y): return torch.atan2(y, x) diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 562905ad..26dd086e 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -22,6 +22,7 @@ from .modified_ferrer import modified_ferrer from .empirical_king import empirical_king from .gaussian import gaussian +from .gaussian_ellipsoid import euler_rotation_matrix from .exponential import exponential from .nuker import nuker from .spline import spline @@ -46,6 +47,7 @@ "modified_ferrer", "empirical_king", "gaussian", + "euler_rotation_matrix", "exponential", "nuker", "spline", diff --git a/astrophot/models/func/gaussian_ellipsoid.py b/astrophot/models/func/gaussian_ellipsoid.py new file mode 100644 index 00000000..c70fd464 --- /dev/null +++ b/astrophot/models/func/gaussian_ellipsoid.py @@ -0,0 +1,24 @@ +import torch + + +def euler_rotation_matrix(alpha, beta, gamma): + """Compute the rotation matrix from Euler angles. + + See the Z_alpha X_beta Z_gamma convention for the order of rotations here: + https://en.wikipedia.org/wiki/Euler_angles + """ + ca = torch.cos(alpha) + sa = torch.sin(alpha) + cb = torch.cos(beta) + sb = torch.sin(beta) + cg = torch.cos(gamma) + sg = torch.sin(gamma) + R = torch.stack( + ( + torch.stack((ca * cg - cb * sa * sg, -ca * sg - cb * cg * sa, sb * sa)), + torch.stack((cg * sa + ca * cb * sg, ca * cb * cg - sa * sg, -ca * sb)), + torch.stack((sb * cg, sb * cg, cb)), + ), + dim=-1, + ) + return R diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py new file mode 100644 index 00000000..ab9deb0f --- /dev/null +++ b/astrophot/models/gaussian_ellipsoid.py @@ -0,0 +1,108 @@ +import torch +import numpy as np + +from .model_object import ComponentModel +from ..utils.decorators import ignore_numpy_warnings +from . import func +from ..param import forward + +__all__ = ["GaussianEllipsoid"] + + +class GaussianEllipsoid(ComponentModel): + """Model that represents a galaxy as a 3D Gaussian ellipsoid. + + The model is triaxial, meaning it has three different standard deviations + along the three axes. The orientation of the ellipsoid is defined by Euler + angles. + + If all three Euler angles are set to zero, the ellipsoid is aligned with the + image axes meaning sigma_a gives the std along the x axis of the tangent + plane, sigma_b gives the std along the y axis of the tangent plane, and + sigma_z gives the std into the tangent plane. We use the ZXZ convention for + the Euler angles. This means that for a disk galaxy, one can naturally + consider sigma_c as the disk thickness and sigma_a=sigma_b as the disk + radius; setting the Euler angles to zero would leave the disk face-on in the + x-y tangent plane. + + Note: + the model is highly degenerate, meaning that it is not possible to + uniquely determine the parameters from the data. The model is useful if + one already has a 3D model of the galaxy in mind and wants to produce + mock data. Alternately, if one applies some constraints on the + parameters, such as sigma_a = sigma_b and alpha=0, then the model will + be better determined. In that case, beta is related to the inclination + of the disk and gamma is related to the position angle of the disk. The + initialization for this model assumes exactly this interpretation with a + disk thickness of sigma_c = 0.2 *sigma_a. + + """ + + _model_type = "gaussianellipsoid" + _parameter_specs = { + "sigma_a": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "sigma_b": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "sigma_c": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "alpha": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "shape": ()}, + "beta": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "shape": ()}, + "gamma": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "shape": ()}, + "flux": {"units": "flux", "shape": ()}, + } + usable = True + + def initialize(self): + super().initialize() + + if any(self[key].initialized for key in GaussianEllipsoid._parameter_specs): + return + + self.sigma_b = self.sigma_a + self.sigma_c = lambda p: 0.2 * p.sigma_a.value + self.sigma_c.link(self.sigma_a) + self.alpha = 0.0 + + target_area = self.target[self.window] + dat = target_area.data.detach().cpu().numpy().copy() + if target_area.has_mask: + mask = target_area.mask.detach().cpu().numpy() + dat[mask] = np.median(dat[~mask]) + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) + edge_average = np.nanmedian(edge) + dat -= edge_average + x, y = target_area.coordinate_center_meshgrid() + x = (x - self.center.value[0]).detach().cpu().numpy() + y = (y - self.center.value[1]).detach().cpu().numpy() + mu20 = np.median(dat * np.abs(x)) + mu02 = np.median(dat * np.abs(y)) + mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) + M = np.array([[mu20, mu11], [mu11, mu02]]) + if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): + PA = np.pi / 2 + l = (0.7, 1.0) + else: + PA = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi + l = np.sort(np.linalg.eigvals(M)) + q = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) + self.beta.dynamic_value = np.arccos(q) + self.gamma.dynamic_value = PA + self.flux.dynamic_value = np.sum(dat) + + @forward + def total_flux(self, flux): + """Total flux of the Gaussian ellipsoid.""" + return flux + + @forward + def brightness(self, x, y, sigma_a, sigma_b, sigma_c, alpha, beta, gamma, flux): + """Brightness of the Gaussian ellipsoid.""" + D = torch.diag(torch.stack((sigma_a, sigma_b, sigma_c)) ** 2) + R = func.euler_rotation_matrix(alpha, beta, gamma) + Sigma = R @ D @ R.T + Sigma2D = Sigma[:2, :2] + inv_Sigma = torch.linalg.inv(Sigma2D) + v = torch.stack(self.transform_coordinates(x, y), dim=0).reshape(2, -1) + return ( + flux + * torch.exp(-0.5 * (v * (inv_Sigma @ v)).sum(dim=0)) + / (2 * np.pi * torch.linalg.det(Sigma2D).sqrt()) + ).reshape(x.shape) diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index feacad76..ecc222ba 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -71,6 +71,10 @@ def psf(self): @psf.setter def psf(self, val): + try: + del self._psf # Remove old PSF if it exists + except AttributeError: + pass if val is None: self._psf = None elif isinstance(val, PSFImage): diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 1f935e45..8a4b0787 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -231,15 +231,10 @@ def model_image( X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() sample_image = sample_image.data.detach().cpu().numpy() - print("sample_image shape", sample_image.shape) + # Default kwargs for image - vmin = kwargs.pop("vmin", None) - vmax = kwargs.pop("vmax", None) kwargs = { "cmap": cmap_grad, - "norm": matplotlib.colors.LogNorm( - vmin=vmin, vmax=vmax - ), # "norm": ImageNormalize(stretch=LogStretch(), clip=False), **kwargs, } @@ -252,8 +247,16 @@ def model_image( # If zeropoint is available, convert to surface brightness units if target.zeropoint is not None and magunits: sample_image = flux_to_sb(sample_image, target.pixel_area.item(), target.zeropoint.item()) - del kwargs["norm"] kwargs["cmap"] = kwargs["cmap"].reversed() + else: + vmin = kwargs.pop("vmin", None) + vmax = kwargs.pop("vmax", None) + kwargs = { + "norm": matplotlib.colors.LogNorm( + vmin=vmin, vmax=vmax + ), # "norm": ImageNormalize(stretch=LogStretch(), clip=False), + **kwargs, + } # Apply the mask if available if target_mask and target.has_mask: @@ -357,16 +360,7 @@ def residual_image( X, Y = sample_image.coordinate_corner_meshgrid() X = X.detach().cpu().numpy() Y = Y.detach().cpu().numpy() - print("target crpix", target.crpix, "sample crpix", sample_image.crpix) residuals = (target - sample_image).data - print( - "residuals shape", - residuals.shape, - "target shape", - target.data.shape, - "sample shape", - sample_image.data.shape, - ) if normalize_residuals is True: residuals = residuals / torch.sqrt(target.variance) diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index d95af539..22549333 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -1,4 +1,5 @@ import torch +import numpy as np def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index 53b2762e..1780a8ce 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -21,13 +21,15 @@ "source": [ "%load_ext autoreload\n", "%autoreload 2\n", + "%matplotlib inline\n", "\n", "import astrophot as ap\n", "import numpy as np\n", "import torch\n", "import matplotlib.pyplot as plt\n", + "import matplotlib.animation as animation\n", + "from IPython.display import HTML\n", "\n", - "%matplotlib inline\n", "basic_target = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1, zeropoint=20)" ] }, @@ -767,6 +769,68 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Gaussian Ellipsoid\n", + "\n", + "This model is an intrinsically 3D gaussian ellipsoid shape, which is projected to 2D for imaging. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "M = ap.models.Model(\n", + " model_type=\"gaussianellipsoid model\",\n", + " center=[50, 50],\n", + " sigma_a=20.0,\n", + " sigma_b=20.0,\n", + " sigma_c=2.0, # disk thickness\n", + " alpha=0.0, # disk spin\n", + " beta=np.arccos(0.6), # disk inclination\n", + " gamma=30 * np.pi / 180, # disk position angle\n", + " flux=10.0,\n", + " target=basic_target,\n", + ")\n", + "M.initialize()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "beta = np.linspace(0, np.pi, 50)\n", + "M.beta = beta[0]\n", + "fig, ax = plt.subplots(1, 1, figsize=(6, 6))\n", + "ap.plots.model_image(fig, ax, M, showcbar=False)\n", + "\n", + "\n", + "def update(frame):\n", + " M.beta = beta[frame]\n", + " ax.clear()\n", + " ap.plots.model_image(fig, ax, M, showcbar=False, vmin=24, vmax=30)\n", + " ax.set_title(f\"{M.name} beta = {beta[frame]:.2f} rad\")\n", + " return ax\n", + "\n", + "\n", + "ani = animation.FuncAnimation(fig, update, frames=50, interval=60)\n", + "plt.close()\n", + "# Save animation as gif\n", + "# ani.save(\"microlensing_animation.gif\", writer='pillow', fps=16) # Adjust 'fps' for the speed\n", + "# Or display the animation inline\n", + "HTML(ani.to_jshtml())" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/tests/test_model.py b/tests/test_model.py index 89c9b333..17bcbc6e 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -73,28 +73,29 @@ def test_model_errors(): ) -def test_all_model_sample(): +@pytest.mark.parametrize( + "model_type", ap.models.ComponentModel.List_Models(usable=True, types=True) +) +def test_all_model_sample(model_type): target = make_basic_sersic() - for model_type in ap.models.ComponentModel.List_Models(usable=True, types=True): - print(model_type) - MODEL = ap.Model( - name="test model", - model_type=model_type, - target=target, - ) - MODEL.initialize() - for P in MODEL.dynamic_params: - assert ( - P.value is not None - ), f"Model type {model_type} parameter {P.name} should not be None after initialization" - img = MODEL() - assert torch.all( - torch.isfinite(img.data) - ), "Model should evaluate a real number for the full image" + MODEL = ap.Model( + name="test model", + model_type=model_type, + target=target, + ) + MODEL.initialize() + for P in MODEL.dynamic_params: + assert ( + P.value is not None + ), f"Model type {model_type} parameter {P.name} should not be None after initialization" + img = MODEL() + assert torch.all( + torch.isfinite(img.data) + ), "Model should evaluate a real number for the full image" -def test_sersic_save_load(self): +def test_sersic_save_load(): target = make_basic_sersic() model = ap.Model( @@ -128,5 +129,5 @@ def test_sersic_save_load(self): assert model.n.value.item() == 2, "Model n should be loaded correctly" assert model.Re.value.item() == 5, "Model Re should be loaded correctly" assert model.logIe.value.item() == 1, "Model logIe should be loaded correctly" - assert model.target.crtan[0] == 0.0, "Model target crtan should be loaded correctly" - assert model.target.crtan[1] == 0.0, "Model target crtan should be loaded correctly" + assert model.target.crtan.value[0] == 0.0, "Model target crtan should be loaded correctly" + assert model.target.crtan.value[1] == 0.0, "Model target crtan should be loaded correctly" From d859480c2f81b5855d8e5451eeb0ca01f677ee50 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 12 Jul 2025 16:32:16 -0400 Subject: [PATCH 056/191] getting model test to run --- astrophot/models/_shared_methods.py | 14 ++++++++--- astrophot/models/edgeon.py | 4 +-- astrophot/models/empirical_king.py | 8 +++--- astrophot/models/func/modified_ferrer.py | 9 ++++++- astrophot/models/gaussian.py | 6 ++--- astrophot/models/gaussian_ellipsoid.py | 13 ++++++++-- astrophot/models/mixins/exponential.py | 2 +- astrophot/models/mixins/modified_ferrer.py | 5 ++-- astrophot/models/mixins/spline.py | 26 ++++++++++++++------ astrophot/models/mixins/transform.py | 6 ++--- astrophot/models/modified_ferrer.py | 8 +++--- astrophot/models/moffat.py | 6 ++--- astrophot/models/multi_gaussian_expansion.py | 4 +-- astrophot/models/nuker.py | 6 ++--- astrophot/models/spline.py | 6 ++--- astrophot/utils/interpolate.py | 2 +- astrophot/utils/parametric_profiles.py | 25 +++++++++++++++++++ tests/test_model.py | 18 ++++++++++++-- tests/utils.py | 12 ++++----- 19 files changed, 127 insertions(+), 53 deletions(-) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 42a0c658..18cb3016 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -14,6 +14,7 @@ def _sample_image( angle=None, rad_bins=None, angle_range=None, + cycle=2 * np.pi, ): dat = image.data.detach().cpu().numpy().copy() # Fill masked pixels @@ -25,11 +26,12 @@ def _sample_image( dat -= np.median(edge) # Get the radius of each pixel relative to object center x, y = transform(*image.coordinate_center_meshgrid(), params=()) - R = radius(x, y, params=()).detach().cpu().numpy().flatten() + if angle_range is not None: - T = angle(x, y).detach().cpu().numpy().flatten() - CHOOSE = ((T % (2 * np.pi)) > angle_range[0]) & ((T % (2 * np.pi)) < angle_range[1]) + T = angle(x, y, params=()).detach().cpu().numpy().flatten() + T = (T - angle_range[0]) % cycle + CHOOSE = T < (angle_range[1] - angle_range[0]) R = R[CHOOSE] dat = dat.flatten()[CHOOSE] raveldat = dat.ravel() @@ -88,12 +90,15 @@ def parametric_initialize(model, target, prof_func, params, x0_func): for i, param in enumerate(params): x0[i] = x0[i] if not model[param].initialized else model[param].npvalue + print(prof_func(R, *x0)) + def optim(x, r, f, u): - residual = ((f - np.log10(prof_func(r, *x))) / u) ** 2 + residual = ((f - np.nan_to_num(np.log10(prof_func(r, *x)), nan=np.min(f))) / u) ** 2 N = np.argsort(residual) return np.mean(residual[N][:-2]) res = minimize(optim, x0=x0, args=(R, I, S), method="Nelder-Mead") + print(res) if res.success: x0 = res.x elif AP_config.ap_verbose >= 2: @@ -136,6 +141,7 @@ def parametric_segment_initialize( model.radius_metric, angle=model.angular_metric, angle_range=angle_range, + cycle=cycle, ) x0 = list(x0_func(model, R, I)) diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index f6a54cb1..471a7d2f 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -82,7 +82,7 @@ def initialize(self): ] self.I0.dynamic_value = torch.mean(chunk) / self.target.pixel_area if not self.hs.initialized: - self.hs.value = torch.max(self.window.shape) * target_area.pixelscale * 0.1 + self.hs.value = max(self.window.shape) * target_area.pixelscale * 0.1 @forward def brightness(self, x, y, I0, hs): @@ -106,7 +106,7 @@ def initialize(self): super().initialize() if self.rs.initialized: return - self.rs.value = torch.max(self.window.shape) * self.target.pixelscale * 0.4 + self.rs.value = max(self.window.shape) * self.target.pixelscale * 0.4 @forward def radial_model(self, R, rs): diff --git a/astrophot/models/empirical_king.py b/astrophot/models/empirical_king.py index 8d71d348..e6b5a4f7 100644 --- a/astrophot/models/empirical_king.py +++ b/astrophot/models/empirical_king.py @@ -31,15 +31,17 @@ class EmpiricalKingPSF(EmpiricalKingMixin, RadialMixin, PSFModel): usable = True -class EmpiricalKingSuperEllipse(EmpiricalKingMixin, SuperEllipseMixin, GalaxyModel): +class EmpiricalKingSuperEllipse(EmpiricalKingMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True -class EmpiricalKingFourierEllipse(EmpiricalKingMixin, FourierEllipseMixin, GalaxyModel): +class EmpiricalKingFourierEllipse( + EmpiricalKingMixin, FourierEllipseMixin, RadialMixin, GalaxyModel +): usable = True -class EmpiricalKingWarp(EmpiricalKingMixin, WarpMixin, GalaxyModel): +class EmpiricalKingWarp(EmpiricalKingMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True diff --git a/astrophot/models/func/modified_ferrer.py b/astrophot/models/func/modified_ferrer.py index c4ca6b4b..41867410 100644 --- a/astrophot/models/func/modified_ferrer.py +++ b/astrophot/models/func/modified_ferrer.py @@ -1,3 +1,6 @@ +import torch + + def modified_ferrer(R, rout, alpha, beta, I0): """ Modified Ferrer profile. @@ -20,4 +23,8 @@ def modified_ferrer(R, rout, alpha, beta, I0): array_like The modified Ferrer profile evaluated at R. """ - return I0 * ((1 - (R / rout) ** (2 - beta)) ** alpha) * (R < rout) + return torch.where( + R < rout, + I0 * ((1 - (torch.clamp(R, 0, rout) / rout) ** (2 - beta)) ** alpha), + torch.zeros_like(R), + ) diff --git a/astrophot/models/gaussian.py b/astrophot/models/gaussian.py index c35f3b69..39f5ec73 100644 --- a/astrophot/models/gaussian.py +++ b/astrophot/models/gaussian.py @@ -47,15 +47,15 @@ class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): usable = True -class GaussianSuperEllipse(GaussianMixin, SuperEllipseMixin, GalaxyModel): +class GaussianSuperEllipse(GaussianMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True -class GaussianFourierEllipse(GaussianMixin, FourierEllipseMixin, GalaxyModel): +class GaussianFourierEllipse(GaussianMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True -class GaussianWarp(GaussianMixin, WarpMixin, GalaxyModel): +class GaussianWarp(GaussianMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index ab9deb0f..a4e14e20 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -50,6 +50,8 @@ class GaussianEllipsoid(ComponentModel): } usable = True + @torch.no_grad() + @ignore_numpy_warnings def initialize(self): super().initialize() @@ -70,8 +72,15 @@ def initialize(self): edge_average = np.nanmedian(edge) dat -= edge_average x, y = target_area.coordinate_center_meshgrid() - x = (x - self.center.value[0]).detach().cpu().numpy() - y = (y - self.center.value[1]).detach().cpu().numpy() + center = self.center.value + x = x - center[0] + y = y - center[1] + r = self.radius_metric(x, y, params=()).detach().cpu().numpy() + self.sigma_a.dynamic_value = np.sqrt(np.sum((r * dat) ** 2) / np.sum(r**2)) + + x = x.detach().cpu().numpy() + y = y.detach().cpu().numpy() + mu20 = np.median(dat * np.abs(x)) mu02 = np.median(dat * np.abs(y)) mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index c1ca4350..7505eb11 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -78,7 +78,7 @@ class iExponentialMixin: """ _model_type = "exponential" - parameter_specs = { + _parameter_specs = { "Re": {"units": "arcsec", "valid": (0, None)}, "Ie": {"units": "flux/arcsec^2"}, } diff --git a/astrophot/models/mixins/modified_ferrer.py b/astrophot/models/mixins/modified_ferrer.py index 37996385..34114001 100644 --- a/astrophot/models/mixins/modified_ferrer.py +++ b/astrophot/models/mixins/modified_ferrer.py @@ -2,6 +2,7 @@ from ...param import forward from ...utils.decorators import ignore_numpy_warnings +from ...utils.parametric_profiles import modified_ferrer_np from .._shared_methods import parametric_initialize, parametric_segment_initialize from .. import func @@ -40,7 +41,7 @@ def initialize(self): parametric_initialize( self, self.target[self.window], - lambda r, *x: func.modified_ferrer(r, x[0], x[1], x[2], 10 ** x[3]), + lambda r, *x: modified_ferrer_np(r, x[0], x[1], x[2], 10 ** x[3]), ("rout", "alpha", "beta", "logI0"), x0_func, ) @@ -80,7 +81,7 @@ def initialize(self): parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: func.modified_ferrer(r, x[0], x[1], x[2], 10 ** x[3]), + prof_func=lambda r, *x: modified_ferrer_np(r, x[0], x[1], x[2], 10 ** x[3]), params=("rout", "alpha", "beta", "logI0"), x0_func=x0_func, segments=self.segments, diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 674a552b..3e210964 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -25,8 +25,12 @@ class SplineMixin: def initialize(self): super().initialize() - if self.I_R.value is not None: - return + try: + if self.logI_R.initialized: + return + except AttributeError: + if self.I_R.initialized: + return target_area = self.target[self.window] # Create the I_R profile radii if needed @@ -42,9 +46,9 @@ def initialize(self): self.radius_metric, rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], ) - if hasattr(self, "logI_R"): + try: self.logI_R.dynamic_value = I - else: + except AttributeError: self.I_R.dynamic_value = 10**I @forward @@ -69,18 +73,23 @@ class iSplineMixin: def initialize(self): super().initialize() - if self.I_R.value is not None: - return + try: + if self.logI_R.initialized: + return + except AttributeError: + if self.I_R.initialized: + return target_area = self.target[self.window] # Create the I_R profile radii if needed if self.I_R.prof is None: prof = default_prof(self.window.shape, target_area.pixelscale, 2, 0.2) - self.I_R.prof = [prof] * self.segments + prof = np.stack([prof] * self.segments) + self.I_R.prof = prof else: prof = self.I_R.prof - value = np.zeros((self.segments, len(prof))) + value = np.zeros(prof.shape) cycle = np.pi if self.symmetric else 2 * np.pi w = cycle / self.segments v = w * np.arange(self.segments) @@ -93,6 +102,7 @@ def initialize(self): angle=self.angular_metric, rad_bins=[0] + list((prof[s][:-1] + prof[s][1:]) / 2) + [prof[s][-1] * 100], angle_range=angle_range, + cycle=cycle, ) value[s] = I diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 30b74114..9b49d81a 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -49,7 +49,7 @@ def initialize(self): l = (0.7, 1.0) else: l = np.sort(np.linalg.eigvals(M)) - self.q.dynamic_value = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) + self.q.dynamic_value = np.clip(np.sqrt(np.abs(l[0] / l[1])), 0.1, 0.9) @forward def transform_coordinates(self, x, y, PA, q): @@ -83,7 +83,7 @@ class SuperEllipseMixin: _model_type = "superellipse" _parameter_specs = { - "C": {"units": "none", "value": 2.0, "valid": (0, None)}, + "C": {"units": "none", "dynamic_value": 2.0, "valid": (0, None)}, } @forward @@ -246,7 +246,7 @@ def __init__(self, *args, outer_truncation=True, **kwargs): @ignore_numpy_warnings def initialize(self): super().initialize() - if not self.Rt.initialize: + if not self.Rt.initialized: prof = default_prof(self.window.shape, self.target.pixelscale, 2, 0.2) self.Rt.dynamic_value = prof[len(prof) // 2] if not self.sharpness.initialized: diff --git a/astrophot/models/modified_ferrer.py b/astrophot/models/modified_ferrer.py index 8d77d175..a98ed107 100644 --- a/astrophot/models/modified_ferrer.py +++ b/astrophot/models/modified_ferrer.py @@ -31,15 +31,17 @@ class ModifiedFerrerPSF(ModifiedFerrerMixin, RadialMixin, PSFModel): usable = True -class ModifiedFerrerSuperEllipse(ModifiedFerrerMixin, SuperEllipseMixin, GalaxyModel): +class ModifiedFerrerSuperEllipse(ModifiedFerrerMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True -class ModifiedFerrerFourierEllipse(ModifiedFerrerMixin, FourierEllipseMixin, GalaxyModel): +class ModifiedFerrerFourierEllipse( + ModifiedFerrerMixin, FourierEllipseMixin, RadialMixin, GalaxyModel +): usable = True -class ModifiedFerrerWarp(ModifiedFerrerMixin, WarpMixin, GalaxyModel): +class ModifiedFerrerWarp(ModifiedFerrerMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py index 5887db17..14f0a0d8 100644 --- a/astrophot/models/moffat.py +++ b/astrophot/models/moffat.py @@ -90,15 +90,15 @@ def total_flux(self, n, Rd, I0, q): return moffat_I0_to_flux(I0, n, Rd, q) -class MoffatSuperEllipse(MoffatMixin, SuperEllipseMixin, GalaxyModel): +class MoffatSuperEllipse(MoffatMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True -class MoffatFourierEllipse(MoffatMixin, FourierEllipseMixin, GalaxyModel): +class MoffatFourierEllipse(MoffatMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True -class MoffatWarp(MoffatMixin, WarpMixin, GalaxyModel): +class MoffatWarp(MoffatMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index a9436a95..a52a1740 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -41,9 +41,7 @@ def __init__(self, *args, n_components=None, **kwargs): self.n_components = self[key].value.shape[0] break else: - raise ValueError( - f"n_components must be specified when initial values is not defined." - ) + self.n_components = 1 else: self.n_components = int(n_components) diff --git a/astrophot/models/nuker.py b/astrophot/models/nuker.py index 12a244b8..884a7cbf 100644 --- a/astrophot/models/nuker.py +++ b/astrophot/models/nuker.py @@ -51,15 +51,15 @@ class NukerPSF(NukerMixin, RadialMixin, PSFModel): usable = True -class NukerSuperEllipse(NukerMixin, SuperEllipseMixin, GalaxyModel): +class NukerSuperEllipse(NukerMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True -class NukerFourierEllipse(NukerMixin, FourierEllipseMixin, GalaxyModel): +class NukerFourierEllipse(NukerMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True -class NukerWarp(NukerMixin, WarpMixin, GalaxyModel): +class NukerWarp(NukerMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True diff --git a/astrophot/models/spline.py b/astrophot/models/spline.py index bbdc1d33..db2d9411 100644 --- a/astrophot/models/spline.py +++ b/astrophot/models/spline.py @@ -46,15 +46,15 @@ class SplinePSF(SplineMixin, RadialMixin, PSFModel): usable = True -class SplineSuperEllipse(SplineMixin, SuperEllipseMixin, GalaxyModel): +class SplineSuperEllipse(SplineMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True -class SplineFourierEllipse(SplineMixin, FourierEllipseMixin, GalaxyModel): +class SplineFourierEllipse(SplineMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True -class SplineWarp(SplineMixin, WarpMixin, GalaxyModel): +class SplineWarp(SplineMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index 22549333..1bbb5862 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -6,7 +6,7 @@ def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): prof = [0, min_pixels * pixelscale] while prof[-1] < (np.max(shape) * pixelscale / 2): prof.append(prof[-1] + max(min_pixels * pixelscale, prof[-1] * scale)) - return prof + return np.array(prof) def interp1d_torch(x_in, y_in, x_out): diff --git a/astrophot/utils/parametric_profiles.py b/astrophot/utils/parametric_profiles.py index 5593b904..dbda9173 100644 --- a/astrophot/utils/parametric_profiles.py +++ b/astrophot/utils/parametric_profiles.py @@ -75,3 +75,28 @@ def nuker_np(R, Rb, Ib, alpha, beta, gamma): * ((R / Rb) ** (-gamma)) * ((1 + (R / Rb) ** alpha) ** ((gamma - beta) / alpha)) ) + + +def modified_ferrer_np(R, rout, alpha, beta, I0): + """ + Modified Ferrer profile. + + Parameters + ---------- + R : array_like + Radial distance from the center. + rout : float + Outer radius of the profile. + alpha : float + Power-law index. + beta : float + Exponent for the modified Ferrer function. + I0 : float + Central intensity. + + Returns + ------- + array_like + The modified Ferrer profile evaluated at R. + """ + return (R < rout) * I0 * ((1 - (np.clip(R, 0, rout) / rout) ** (2 - beta)) ** alpha) diff --git a/tests/test_model.py b/tests/test_model.py index 17bcbc6e..64726238 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -57,7 +57,7 @@ def test_model_errors(): target = ap.image.Image(data=arr, pixelscale=1.0, zeropoint=1.0) with pytest.raises(ap.errors.InvalidTarget): - model = ap.Model( + ap.Model( name="test model", model_type="sersic galaxy model", target=target, @@ -66,7 +66,7 @@ def test_model_errors(): # model that doesn't exist target = make_basic_sersic() with pytest.raises(ap.errors.UnrecognizedModel): - model = ap.Model( + ap.Model( name="test model", model_type="sersic gaaxy model", target=target, @@ -90,9 +90,23 @@ def test_all_model_sample(model_type): P.value is not None ), f"Model type {model_type} parameter {P.name} should not be None after initialization" img = MODEL() + import matplotlib.pyplot as plt + + print(MODEL) + fig, ax = plt.subplots(1, 2) + ap.plots.model_image(fig, ax[0], MODEL) + ap.plots.residual_image(fig, ax[1], MODEL) + plt.savefig(f"test_{model_type}_sample.png") + plt.close() assert torch.all( torch.isfinite(img.data) ), "Model should evaluate a real number for the full image" + res = ap.fit.LM(MODEL, max_iter=10).fit() + print(res.message) + assert res.loss_history[0] > res.loss_history[-1], ( + f"Model {model_type} should fit to the target image, but did not. " + f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" + ) def test_sersic_save_load(): diff --git a/tests/utils.py b/tests/utils.py index bd252427..8bcbef23 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -29,13 +29,13 @@ def make_basic_sersic( N=50, M=50, pixelscale=0.8, - x=24.5, - y=25.4, + x=20.5, + y=21.4, PA=45 * np.pi / 180, - q=0.6, - n=2, - Re=7.1, - Ie=0, + q=0.7, + n=1.5, + Re=15.1, + Ie=10.0, rand=12345, **kwargs, ): From 4abf746e8ca66b517361d676832385ee3da23452 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 13 Jul 2025 09:18:34 -0400 Subject: [PATCH 057/191] modified ferrer to ferrer and empirical king to king --- astrophot/models/__init__.py | 60 +++++++++---------- astrophot/models/empirical_king.py | 53 ---------------- astrophot/models/ferrer.py | 51 ++++++++++++++++ astrophot/models/func/__init__.py | 8 +-- .../func/{modified_ferrer.py => ferrer.py} | 2 +- .../func/{empirical_king.py => king.py} | 2 +- astrophot/models/king.py | 51 ++++++++++++++++ astrophot/models/mixins/__init__.py | 12 ++-- .../mixins/{modified_ferrer.py => ferrer.py} | 18 +++--- .../mixins/{empirical_king.py => king.py} | 16 ++--- astrophot/models/modified_ferrer.py | 53 ---------------- docs/source/tutorials/ModelZoo.ipynb | 8 ++- 12 files changed, 166 insertions(+), 168 deletions(-) delete mode 100644 astrophot/models/empirical_king.py create mode 100644 astrophot/models/ferrer.py rename astrophot/models/func/{modified_ferrer.py => ferrer.py} (92%) rename astrophot/models/func/{empirical_king.py => king.py} (93%) create mode 100644 astrophot/models/king.py rename astrophot/models/mixins/{modified_ferrer.py => ferrer.py} (82%) rename astrophot/models/mixins/{empirical_king.py => king.py} (85%) delete mode 100644 astrophot/models/modified_ferrer.py diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 016319d4..cfa57b77 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -66,23 +66,23 @@ MoffatWarp, MoffatSuperEllipse, ) -from .modified_ferrer import ( - ModifiedFerrerGalaxy, - ModifiedFerrerPSF, - ModifiedFerrerSuperEllipse, - ModifiedFerrerFourierEllipse, - ModifiedFerrerWarp, - ModifiedFerrerRay, - ModifiedFerrerWedge, +from .ferrer import ( + FerrerGalaxy, + FerrerPSF, + FerrerSuperEllipse, + FerrerFourierEllipse, + FerrerWarp, + FerrerRay, + FerrerWedge, ) -from .empirical_king import ( - EmpiricalKingGalaxy, - EmpiricalKingPSF, - EmpiricalKingSuperEllipse, - EmpiricalKingFourierEllipse, - EmpiricalKingWarp, - EmpiricalKingRay, - EmpiricalKingWedge, +from .king import ( + KingGalaxy, + KingPSF, + KingSuperEllipse, + KingFourierEllipse, + KingWarp, + KingRay, + KingWedge, ) from .nuker import ( NukerGalaxy, @@ -159,20 +159,20 @@ "MoffatWedge", "MoffatWarp", "MoffatSuperEllipse", - "ModifiedFerrerGalaxy", - "ModifiedFerrerPSF", - "ModifiedFerrerSuperEllipse", - "ModifiedFerrerFourierEllipse", - "ModifiedFerrerWarp", - "ModifiedFerrerRay", - "ModifiedFerrerWedge", - "EmpiricalKingGalaxy", - "EmpiricalKingPSF", - "EmpiricalKingSuperEllipse", - "EmpiricalKingFourierEllipse", - "EmpiricalKingWarp", - "EmpiricalKingRay", - "EmpiricalKingWedge", + "FerrerGalaxy", + "FerrerPSF", + "FerrerSuperEllipse", + "FerrerFourierEllipse", + "FerrerWarp", + "FerrerRay", + "FerrerWedge", + "KingGalaxy", + "KingPSF", + "KingSuperEllipse", + "KingFourierEllipse", + "KingWarp", + "KingRay", + "KingWedge", "NukerGalaxy", "NukerPSF", "NukerFourierEllipse", diff --git a/astrophot/models/empirical_king.py b/astrophot/models/empirical_king.py deleted file mode 100644 index e6b5a4f7..00000000 --- a/astrophot/models/empirical_king.py +++ /dev/null @@ -1,53 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from .mixins import ( - EmpiricalKingMixin, - RadialMixin, - WedgeMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, - iEmpiricalKingMixin, -) - -__all__ = ( - "EmpiricalKingGalaxy", - "EmpiricalKingPSF", - "EmpiricalKingSuperEllipse", - "EmpiricalKingFourierEllipse", - "EmpiricalKingWarp", - "EmpiricalKingRay", - "EmpiricalKingWedge", -) - - -class EmpiricalKingGalaxy(EmpiricalKingMixin, RadialMixin, GalaxyModel): - usable = True - - -class EmpiricalKingPSF(EmpiricalKingMixin, RadialMixin, PSFModel): - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} - usable = True - - -class EmpiricalKingSuperEllipse(EmpiricalKingMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -class EmpiricalKingFourierEllipse( - EmpiricalKingMixin, FourierEllipseMixin, RadialMixin, GalaxyModel -): - usable = True - - -class EmpiricalKingWarp(EmpiricalKingMixin, WarpMixin, RadialMixin, GalaxyModel): - usable = True - - -class EmpiricalKingRay(iEmpiricalKingMixin, RayMixin, GalaxyModel): - usable = True - - -class EmpiricalKingWedge(iEmpiricalKingMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/models/ferrer.py b/astrophot/models/ferrer.py new file mode 100644 index 00000000..a6e1c573 --- /dev/null +++ b/astrophot/models/ferrer.py @@ -0,0 +1,51 @@ +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel +from .mixins import ( + FerrerMixin, + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + iFerrerMixin, +) + +__all__ = ( + "FerrerGalaxy", + "FerrerPSF", + "FerrerSuperEllipse", + "FerrerFourierEllipse", + "FerrerWarp", + "FerrerRay", + "FerrerWedge", +) + + +class FerrerGalaxy(FerrerMixin, RadialMixin, GalaxyModel): + usable = True + + +class FerrerPSF(FerrerMixin, RadialMixin, PSFModel): + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + usable = True + + +class FerrerSuperEllipse(FerrerMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): + usable = True + + +class FerrerFourierEllipse(FerrerMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): + usable = True + + +class FerrerWarp(FerrerMixin, WarpMixin, RadialMixin, GalaxyModel): + usable = True + + +class FerrerRay(iFerrerMixin, RayMixin, GalaxyModel): + usable = True + + +class FerrerWedge(iFerrerMixin, WedgeMixin, GalaxyModel): + usable = True diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 26dd086e..574d89de 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -19,8 +19,8 @@ ) from .sersic import sersic, sersic_n_to_b from .moffat import moffat -from .modified_ferrer import modified_ferrer -from .empirical_king import empirical_king +from .ferrer import ferrer +from .king import king from .gaussian import gaussian from .gaussian_ellipsoid import euler_rotation_matrix from .exponential import exponential @@ -44,8 +44,8 @@ "sersic", "sersic_n_to_b", "moffat", - "modified_ferrer", - "empirical_king", + "ferrer", + "king", "gaussian", "euler_rotation_matrix", "exponential", diff --git a/astrophot/models/func/modified_ferrer.py b/astrophot/models/func/ferrer.py similarity index 92% rename from astrophot/models/func/modified_ferrer.py rename to astrophot/models/func/ferrer.py index 41867410..53f40988 100644 --- a/astrophot/models/func/modified_ferrer.py +++ b/astrophot/models/func/ferrer.py @@ -1,7 +1,7 @@ import torch -def modified_ferrer(R, rout, alpha, beta, I0): +def ferrer(R, rout, alpha, beta, I0): """ Modified Ferrer profile. diff --git a/astrophot/models/func/empirical_king.py b/astrophot/models/func/king.py similarity index 93% rename from astrophot/models/func/empirical_king.py rename to astrophot/models/func/king.py index 542ccd16..6e0b8483 100644 --- a/astrophot/models/func/empirical_king.py +++ b/astrophot/models/func/king.py @@ -1,4 +1,4 @@ -def empirical_king(R, Rc, Rt, alpha, I0): +def king(R, Rc, Rt, alpha, I0): """ Empirical King profile. diff --git a/astrophot/models/king.py b/astrophot/models/king.py new file mode 100644 index 00000000..21287ad1 --- /dev/null +++ b/astrophot/models/king.py @@ -0,0 +1,51 @@ +from .galaxy_model_object import GalaxyModel +from .psf_model_object import PSFModel +from .mixins import ( + KingMixin, + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + iKingMixin, +) + +__all__ = ( + "KingGalaxy", + "KingPSF", + "KingSuperEllipse", + "KingFourierEllipse", + "KingWarp", + "KingRay", + "KingWedge", +) + + +class KingGalaxy(KingMixin, RadialMixin, GalaxyModel): + usable = True + + +class KingPSF(KingMixin, RadialMixin, PSFModel): + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + usable = True + + +class KingSuperEllipse(KingMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): + usable = True + + +class KingFourierEllipse(KingMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): + usable = True + + +class KingWarp(KingMixin, WarpMixin, RadialMixin, GalaxyModel): + usable = True + + +class KingRay(iKingMixin, RayMixin, GalaxyModel): + usable = True + + +class KingWedge(iKingMixin, WedgeMixin, GalaxyModel): + usable = True diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index 75f21d8a..884033d5 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -9,8 +9,8 @@ from .sersic import SersicMixin, iSersicMixin from .exponential import ExponentialMixin, iExponentialMixin from .moffat import MoffatMixin, iMoffatMixin -from .modified_ferrer import ModifiedFerrerMixin, iModifiedFerrerMixin -from .empirical_king import EmpiricalKingMixin, iEmpiricalKingMixin +from .ferrer import FerrerMixin, iFerrerMixin +from .king import KingMixin, iKingMixin from .gaussian import GaussianMixin, iGaussianMixin from .nuker import NukerMixin, iNukerMixin from .spline import SplineMixin, iSplineMixin @@ -31,10 +31,10 @@ "iExponentialMixin", "MoffatMixin", "iMoffatMixin", - "ModifiedFerrerMixin", - "iModifiedFerrerMixin", - "EmpiricalKingMixin", - "iEmpiricalKingMixin", + "FerrerMixin", + "iFerrerMixin", + "KingMixin", + "iKingMixin", "GaussianMixin", "iGaussianMixin", "NukerMixin", diff --git a/astrophot/models/mixins/modified_ferrer.py b/astrophot/models/mixins/ferrer.py similarity index 82% rename from astrophot/models/mixins/modified_ferrer.py rename to astrophot/models/mixins/ferrer.py index 34114001..a1c65327 100644 --- a/astrophot/models/mixins/modified_ferrer.py +++ b/astrophot/models/mixins/ferrer.py @@ -2,7 +2,7 @@ from ...param import forward from ...utils.decorators import ignore_numpy_warnings -from ...utils.parametric_profiles import modified_ferrer_np +from ...utils.parametric_profiles import ferrer_np from .._shared_methods import parametric_initialize, parametric_segment_initialize from .. import func @@ -11,9 +11,9 @@ def x0_func(model_params, R, F): return R[5], 1, 1, F[0] -class ModifiedFerrerMixin: +class FerrerMixin: - _model_type = "modifiedferrer" + _model_type = "ferrer" _parameter_specs = { "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, @@ -41,19 +41,19 @@ def initialize(self): parametric_initialize( self, self.target[self.window], - lambda r, *x: modified_ferrer_np(r, x[0], x[1], x[2], 10 ** x[3]), + lambda r, *x: ferrer_np(r, x[0], x[1], x[2], 10 ** x[3]), ("rout", "alpha", "beta", "logI0"), x0_func, ) @forward def radial_model(self, R, rout, alpha, beta, I0): - return func.modified_ferrer(R, rout, alpha, beta, I0) + return func.ferrer(R, rout, alpha, beta, I0) -class iModifiedFerrerMixin: +class iFerrerMixin: - _model_type = "modifiedferrer" + _model_type = "ferrer" _parameter_specs = { "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, @@ -81,7 +81,7 @@ def initialize(self): parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: modified_ferrer_np(r, x[0], x[1], x[2], 10 ** x[3]), + prof_func=lambda r, *x: ferrer_np(r, x[0], x[1], x[2], 10 ** x[3]), params=("rout", "alpha", "beta", "logI0"), x0_func=x0_func, segments=self.segments, @@ -89,4 +89,4 @@ def initialize(self): @forward def iradial_model(self, i, R, rout, alpha, beta, I0): - return func.modified_ferrer(R, rout[i], alpha[i], beta[i], I0[i]) + return func.ferrer(R, rout[i], alpha[i], beta[i], I0[i]) diff --git a/astrophot/models/mixins/empirical_king.py b/astrophot/models/mixins/king.py similarity index 85% rename from astrophot/models/mixins/empirical_king.py rename to astrophot/models/mixins/king.py index 398c3f78..3c9ec713 100644 --- a/astrophot/models/mixins/empirical_king.py +++ b/astrophot/models/mixins/king.py @@ -10,9 +10,9 @@ def x0_func(model_params, R, F): return R[2], R[5], 2, F[0] -class EmpiricalKingMixin: +class KingMixin: - _model_type = "empiricalking" + _model_type = "king" _parameter_specs = { "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, @@ -40,19 +40,19 @@ def initialize(self): parametric_initialize( self, self.target[self.window], - lambda r, *x: func.empirical_king(r, x[0], x[1], x[2], 10 ** x[3]), + lambda r, *x: func.king(r, x[0], x[1], x[2], 10 ** x[3]), ("Rc", "Rt", "alpha", "logI0"), x0_func, ) @forward def radial_model(self, R, Rc, Rt, alpha, I0): - return func.empirical_king(R, Rc, Rt, alpha, I0) + return func.king(R, Rc, Rt, alpha, I0) -class iEmpiricalKingMixin: +class iKingMixin: - _model_type = "empiricalking" + _model_type = "king" _parameter_specs = { "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, @@ -80,7 +80,7 @@ def initialize(self): parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: func.empirical_king(r, x[0], x[1], x[2], 10 ** x[3]), + prof_func=lambda r, *x: func.king(r, x[0], x[1], x[2], 10 ** x[3]), params=("Rc", "Rt", "alpha", "logI0"), x0_func=x0_func, segments=self.segments, @@ -88,4 +88,4 @@ def initialize(self): @forward def iradial_model(self, i, R, Rc, Rt, alpha, I0): - return func.empirical_king(R, Rc[i], Rt[i], alpha[i], I0[i]) + return func.king(R, Rc[i], Rt[i], alpha[i], I0[i]) diff --git a/astrophot/models/modified_ferrer.py b/astrophot/models/modified_ferrer.py deleted file mode 100644 index a98ed107..00000000 --- a/astrophot/models/modified_ferrer.py +++ /dev/null @@ -1,53 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from .mixins import ( - ModifiedFerrerMixin, - RadialMixin, - WedgeMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, - iModifiedFerrerMixin, -) - -__all__ = ( - "ModifiedFerrerGalaxy", - "ModifiedFerrerPSF", - "ModifiedFerrerSuperEllipse", - "ModifiedFerrerFourierEllipse", - "ModifiedFerrerWarp", - "ModifiedFerrerRay", - "ModifiedFerrerWedge", -) - - -class ModifiedFerrerGalaxy(ModifiedFerrerMixin, RadialMixin, GalaxyModel): - usable = True - - -class ModifiedFerrerPSF(ModifiedFerrerMixin, RadialMixin, PSFModel): - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} - usable = True - - -class ModifiedFerrerSuperEllipse(ModifiedFerrerMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -class ModifiedFerrerFourierEllipse( - ModifiedFerrerMixin, FourierEllipseMixin, RadialMixin, GalaxyModel -): - usable = True - - -class ModifiedFerrerWarp(ModifiedFerrerMixin, WarpMixin, RadialMixin, GalaxyModel): - usable = True - - -class ModifiedFerrerRay(iModifiedFerrerMixin, RayMixin, GalaxyModel): - usable = True - - -class ModifiedFerrerWedge(iModifiedFerrerMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index 1780a8ce..c1c38015 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -94,7 +94,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Bilinear Sky Model" + "### Bilinear Sky Model\n", + "\n", + "This allows for a complex sky model which can vary arbitrarily as a function of position. Here we plot a sky that is just noise, but one would typically make it smoothly varying. The noise sky makes the nature of bilinear interpolation very clear, large flux changes can create sharp edges in the reconstruction." ] }, { @@ -787,8 +789,8 @@ "M = ap.models.Model(\n", " model_type=\"gaussianellipsoid model\",\n", " center=[50, 50],\n", - " sigma_a=20.0,\n", - " sigma_b=20.0,\n", + " sigma_a=20.0, # disk radius\n", + " sigma_b=20.0, # also disk radius\n", " sigma_c=2.0, # disk thickness\n", " alpha=0.0, # disk spin\n", " beta=np.arccos(0.6), # disk inclination\n", From 198af2f8b211c456e12513ad53cd093efd8bbed6 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 13 Jul 2025 15:32:55 -0400 Subject: [PATCH 058/191] test models now runs --- astrophot/models/_shared_methods.py | 4 +--- astrophot/models/func/king.py | 7 +++++- astrophot/models/mixins/king.py | 24 +++++++++++++-------- astrophot/utils/parametric_profiles.py | 29 ++++++++++++++++++++++++- docs/source/tutorials/ModelZoo.ipynb | 12 ++++++----- tests/test_model.py | 30 +++++++++++++++----------- 6 files changed, 74 insertions(+), 32 deletions(-) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 18cb3016..40b5c4fc 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -90,15 +90,13 @@ def parametric_initialize(model, target, prof_func, params, x0_func): for i, param in enumerate(params): x0[i] = x0[i] if not model[param].initialized else model[param].npvalue - print(prof_func(R, *x0)) - def optim(x, r, f, u): residual = ((f - np.nan_to_num(np.log10(prof_func(r, *x)), nan=np.min(f))) / u) ** 2 N = np.argsort(residual) return np.mean(residual[N][:-2]) res = minimize(optim, x0=x0, args=(R, I, S), method="Nelder-Mead") - print(res) + if res.success: x0 = res.x elif AP_config.ap_verbose >= 2: diff --git a/astrophot/models/func/king.py b/astrophot/models/func/king.py index 6e0b8483..b498dc46 100644 --- a/astrophot/models/func/king.py +++ b/astrophot/models/func/king.py @@ -1,3 +1,6 @@ +import torch + + def king(R, Rc, Rt, alpha, I0): """ Empirical King profile. @@ -22,4 +25,6 @@ def king(R, Rc, Rt, alpha, I0): """ beta = 1 / (1 + (Rt / Rc) ** 2) ** (1 / alpha) gamma = 1 / (1 + (R / Rc) ** 2) ** (1 / alpha) - return I0 * (R < Rt) * ((gamma - beta) / (1 - beta)) ** alpha + return torch.where( + R < Rt, I0 * ((torch.clamp(gamma, 0, 1) - beta) / (1 - beta)) ** alpha, torch.zeros_like(R) + ) diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index 3c9ec713..441df275 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -1,7 +1,9 @@ import torch +import numpy as np from ...param import forward from ...utils.decorators import ignore_numpy_warnings +from ...utils.parametric_profiles import king_np from .._shared_methods import parametric_initialize, parametric_segment_initialize from .. import func @@ -37,11 +39,14 @@ def initialize(self): if not hasattr(self, "logI0"): return + if not self.alpha.initialized: + self.alpha.dynamic_value = 2.0 + parametric_initialize( self, self.target[self.window], - lambda r, *x: func.king(r, x[0], x[1], x[2], 10 ** x[3]), - ("Rc", "Rt", "alpha", "logI0"), + lambda r, *x: king_np(r, x[0], x[1], 2.0, 10 ** x[2]), + ("Rc", "Rt", "logI0"), x0_func, ) @@ -54,15 +59,14 @@ class iKingMixin: _model_type = "king" _parameter_specs = { - "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, - "I0": {"units": "flux/arcsec^2", "shape": ()}, + "Rc": {"units": "arcsec", "valid": (0.0, None)}, + "Rt": {"units": "arcsec", "valid": (0.0, None)}, + "alpha": {"units": "unitless", "valid": (0, 10)}, + "I0": {"units": "flux/arcsec^2"}, } _overload_parameter_specs = { "logI0": { "units": "log10(flux/arcsec^2)", - "shape": (), "overloads": "I0", "overload_function": lambda p: 10**p.logI0.value, } @@ -77,11 +81,13 @@ def initialize(self): if not hasattr(self, "logI0"): return + if not self.alpha.initialized: + self.alpha.dynamic_value = 2.0 * np.ones(self.segments) parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: func.king(r, x[0], x[1], x[2], 10 ** x[3]), - params=("Rc", "Rt", "alpha", "logI0"), + prof_func=lambda r, *x: king_np(r, x[0], x[1], 2.0, 10 ** x[2]), + params=("Rc", "Rt", "logI0"), x0_func=x0_func, segments=self.segments, ) diff --git a/astrophot/utils/parametric_profiles.py b/astrophot/utils/parametric_profiles.py index dbda9173..433fb68c 100644 --- a/astrophot/utils/parametric_profiles.py +++ b/astrophot/utils/parametric_profiles.py @@ -77,7 +77,7 @@ def nuker_np(R, Rb, Ib, alpha, beta, gamma): ) -def modified_ferrer_np(R, rout, alpha, beta, I0): +def ferrer_np(R, rout, alpha, beta, I0): """ Modified Ferrer profile. @@ -100,3 +100,30 @@ def modified_ferrer_np(R, rout, alpha, beta, I0): The modified Ferrer profile evaluated at R. """ return (R < rout) * I0 * ((1 - (np.clip(R, 0, rout) / rout) ** (2 - beta)) ** alpha) + + +def king_np(R, Rc, Rt, alpha, I0): + """ + Empirical King profile. + + Parameters + ---------- + R : array_like + The radial distance from the center. + Rc : float + The core radius of the profile. + Rt : float + The truncation radius of the profile. + alpha : float + The power-law index of the profile. + I0 : float + The central intensity of the profile. + + Returns + ------- + array_like + The intensity at each radial distance. + """ + beta = 1 / (1 + (Rt / Rc) ** 2) ** (1 / alpha) + gamma = 1 / (1 + (R / Rc) ** 2) ** (1 / alpha) + return (R < Rt) * I0 * ((np.clip(gamma, 0, 1) - beta) / (1 - beta)) ** alpha diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index c1c38015..4255e6bf 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -635,7 +635,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Modified Ferrer Model" + "### Ferrer Model" ] }, { @@ -645,7 +645,7 @@ "outputs": [], "source": [ "M = ap.models.Model(\n", - " model_type=\"modifiedferrer galaxy model\",\n", + " model_type=\"ferrer galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", @@ -668,7 +668,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Empirical King Model" + "### King Model\n", + "\n", + "This is the Empirical King model with the extra free parameter $\\alpha$" ] }, { @@ -678,13 +680,13 @@ "outputs": [], "source": [ "M = ap.models.Model(\n", - " model_type=\"empiricalking galaxy model\",\n", + " model_type=\"king galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", " Rc=10.0,\n", " Rt=40.0,\n", - " alpha=1.0,\n", + " alpha=2.01,\n", " logI0=1.0,\n", " target=basic_target,\n", ")\n", diff --git a/tests/test_model.py b/tests/test_model.py index 64726238..466f4136 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -90,23 +90,27 @@ def test_all_model_sample(model_type): P.value is not None ), f"Model type {model_type} parameter {P.name} should not be None after initialization" img = MODEL() - import matplotlib.pyplot as plt - - print(MODEL) - fig, ax = plt.subplots(1, 2) - ap.plots.model_image(fig, ax[0], MODEL) - ap.plots.residual_image(fig, ax[1], MODEL) - plt.savefig(f"test_{model_type}_sample.png") - plt.close() assert torch.all( torch.isfinite(img.data) ), "Model should evaluate a real number for the full image" res = ap.fit.LM(MODEL, max_iter=10).fit() - print(res.message) - assert res.loss_history[0] > res.loss_history[-1], ( - f"Model {model_type} should fit to the target image, but did not. " - f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" - ) + + if "sky" in model_type or model_type in [ + "spline ray galaxy model", + "exponential warp galaxy model", + "spline wedge galaxy model", + ]: # sky has little freedom to fit + assert res.loss_history[0] > res.loss_history[-1], ( + f"Model {model_type} should fit to the target image, but did not. " + f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" + ) + else: + print(res.message) + print(res.loss_history) + assert res.loss_history[0] > (2 * res.loss_history[-1]), ( + f"Model {model_type} should fit to the target image, but did not. " + f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" + ) def test_sersic_save_load(): From 1946aaba94ea8ec31bae5384ac72cfedb753e375 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 15 Jul 2025 14:13:32 -0400 Subject: [PATCH 059/191] Use valid for intensity rather than overlaod param --- astrophot/models/_shared_methods.py | 3 + astrophot/models/base.py | 21 - astrophot/models/mixins/exponential.py | 40 +- astrophot/models/mixins/ferrer.py | 38 +- astrophot/models/mixins/gaussian.py | 36 +- astrophot/models/mixins/king.py | 37 +- astrophot/models/mixins/moffat.py | 39 +- astrophot/models/mixins/nuker.py | 38 +- astrophot/models/mixins/sample.py | 4 +- astrophot/models/mixins/sersic.py | 40 +- astrophot/models/mixins/spline.py | 44 +- astrophot/models/mixins/transform.py | 2 +- astrophot/models/point_source.py | 15 +- docs/source/tutorials/GettingStarted.ipynb | 2 + docs/source/tutorials/JointModels.ipynb | 44 +- tests/test_fit.py | 854 +++++++-------------- tests/test_model.py | 35 +- tests/utils.py | 2 +- 18 files changed, 422 insertions(+), 872 deletions(-) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 40b5c4fc..ce18eb6d 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -72,6 +72,8 @@ def _sample_image( N = np.isfinite(S) if not np.all(N): S[~N] = np.abs(np.interp(R[~N], R[N], S[N])) + Sm = np.median(S) + S[S < Sm] = Sm # remove very small uncertainties return R, I, S @@ -107,6 +109,7 @@ def optim(x, r, f, u): for param, x0x in zip(params, x0): if not model[param].initialized: if not model[param].is_valid(x0x): + print("soft valid", param, x0x) x0x = model[param].soft_valid( torch.tensor(x0x, dtype=AP_config.ap_dtype, device=AP_config.ap_device) ) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index b83638de..209a6c87 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -125,17 +125,6 @@ def __init__(self, *, name=None, target=None, window=None, mask=None, filename=N key, **parameter_specs[key], dtype=AP_config.ap_dtype, device=AP_config.ap_device ) setattr(self, key, param) - overload_specs = self.build_parameter_specs(kwargs, self.overload_parameter_specs) - for key in overload_specs: - overload = overload_specs[key].pop("overloads") - if self[overload].value is not None: - continue - self[overload].value = overload_specs[key].pop("overload_function") - param = Param( - key, **overload_specs[key], dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - setattr(self, key, param) - self[overload].link(key, self[key]) self.saveattrs.update(self.options) self.saveattrs.add("window.extent") @@ -176,16 +165,6 @@ def parameter_specs(cls) -> dict: specs.update(getattr(subcls, "_parameter_specs", {})) return specs - @classproperty - def overload_parameter_specs(cls) -> dict: - """Collects all parameter specifications from the class hierarchy.""" - specs = {} - for subcls in reversed(cls.mro()): - if subcls is object: - continue - specs.update(getattr(subcls, "_overload_parameter_specs", {})) - return specs - def build_parameter_specs(self, kwargs, parameter_specs) -> dict: parameter_specs = deepcopy(parameter_specs) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 7505eb11..2f05057e 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], F[4] + return R[4], 10 ** F[4] class ExponentialMixin: @@ -28,16 +28,8 @@ class ExponentialMixin: _model_type = "exponential" _parameter_specs = { - "Re": {"units": "arcsec", "valid": (0, None)}, - "Ie": {"units": "flux/arcsec^2"}, - } - _overload_parameter_specs = { - "logIe": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "Ie", - "overload_function": lambda p: 10**p.logIe.value, - } + "Re": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, } @torch.no_grad() @@ -45,15 +37,11 @@ class ExponentialMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logIe"): - return - parametric_initialize( self, self.target[self.window], - lambda r, *x: exponential_np(r, x[0], 10 ** x[1]), - ("Re", "logIe"), + exponential_np, + ("Re", "Ie"), _x0_func, ) @@ -80,15 +68,7 @@ class iExponentialMixin: _model_type = "exponential" _parameter_specs = { "Re": {"units": "arcsec", "valid": (0, None)}, - "Ie": {"units": "flux/arcsec^2"}, - } - _overload_parameter_specs = { - "logIe": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "Ie", - "overload_function": lambda p: 10**p.logIe.value, - } + "Ie": {"units": "flux/arcsec^2", "valid": (0, None)}, } @torch.no_grad() @@ -96,15 +76,11 @@ class iExponentialMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logIe"): - return - parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: exponential_np(r, x[0], 10 ** x[1]), - params=("Re", "logIe"), + prof_func=exponential_np, + params=("Re", "Ie"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/ferrer.py b/astrophot/models/mixins/ferrer.py index a1c65327..4d378889 100644 --- a/astrophot/models/mixins/ferrer.py +++ b/astrophot/models/mixins/ferrer.py @@ -8,7 +8,7 @@ def x0_func(model_params, R, F): - return R[5], 1, 1, F[0] + return R[5], 1, 1, 10 ** F[0] class FerrerMixin: @@ -18,15 +18,7 @@ class FerrerMixin: "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, - "I0": {"units": "flux/arcsec^2", "shape": ()}, - } - _overload_parameter_specs = { - "logI0": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "I0", - "overload_function": lambda p: 10**p.logI0.value, - } + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, } @torch.no_grad() @@ -34,15 +26,11 @@ class FerrerMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logI0"): - return - parametric_initialize( self, self.target[self.window], - lambda r, *x: ferrer_np(r, x[0], x[1], x[2], 10 ** x[3]), - ("rout", "alpha", "beta", "logI0"), + ferrer_np, + ("rout", "alpha", "beta", "I0"), x0_func, ) @@ -58,15 +46,7 @@ class iFerrerMixin: "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, - "I0": {"units": "flux/arcsec^2", "shape": ()}, - } - _overload_parameter_specs = { - "logI0": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "I0", - "overload_function": lambda p: 10**p.logI0.value, - } + "I0": {"units": "flux/arcsec^2", "valid": (0.0, None), "shape": ()}, } @torch.no_grad() @@ -74,15 +54,11 @@ class iFerrerMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logI0"): - return - parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: ferrer_np(r, x[0], x[1], x[2], 10 ** x[3]), - params=("rout", "alpha", "beta", "logI0"), + prof_func=ferrer_np, + params=("rout", "alpha", "beta", "I0"), x0_func=x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index b02b6f80..12298f43 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -16,15 +16,7 @@ class GaussianMixin: _model_type = "gaussian" _parameter_specs = { "sigma": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "flux": {"units": "flux", "shape": ()}, - } - _overload_parameter_specs = { - "logflux": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "flux", - "overload_function": lambda p: 10**p.logflux.value, - } + "flux": {"units": "flux", "valid": (0, None), "shape": ()}, } @torch.no_grad() @@ -32,15 +24,11 @@ class GaussianMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logflux"): - return - parametric_initialize( self, self.target[self.window], - lambda r, *x: gaussian_np(r, x[0], 10 ** x[1]), - ("sigma", "logflux"), + gaussian_np, + ("sigma", "flux"), _x0_func, ) @@ -54,15 +42,7 @@ class iGaussianMixin: _model_type = "gaussian" _parameter_specs = { "sigma": {"units": "arcsec", "valid": (0, None)}, - "flux": {"units": "flux"}, - } - _overload_parameter_specs = { - "logflux": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "flux", - "overload_function": lambda p: 10**p.logflux.value, - } + "flux": {"units": "flux", "valid": (0, None)}, } @torch.no_grad() @@ -70,15 +50,11 @@ class iGaussianMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logflux"): - return - parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: gaussian_np(r, x[0], 10 ** x[1]), - params=("sigma", "logflux"), + prof_func=gaussian_np, + params=("sigma", "flux"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index 441df275..e6cc5c9a 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -9,7 +9,7 @@ def x0_func(model_params, R, F): - return R[2], R[5], 2, F[0] + return R[2], R[5], 2, 10 ** F[0] class KingMixin: @@ -19,15 +19,7 @@ class KingMixin: "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, - "I0": {"units": "flux/arcsec^2", "shape": ()}, - } - _overload_parameter_specs = { - "logI0": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "I0", - "overload_function": lambda p: 10**p.logI0.value, - } + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, } @torch.no_grad() @@ -35,18 +27,14 @@ class KingMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logI0"): - return - if not self.alpha.initialized: self.alpha.dynamic_value = 2.0 parametric_initialize( self, self.target[self.window], - lambda r, *x: king_np(r, x[0], x[1], 2.0, 10 ** x[2]), - ("Rc", "Rt", "logI0"), + lambda r, *x: king_np(r, x[0], x[1], 2.0, x[2]), + ("Rc", "Rt", "I0"), x0_func, ) @@ -62,14 +50,7 @@ class iKingMixin: "Rc": {"units": "arcsec", "valid": (0.0, None)}, "Rt": {"units": "arcsec", "valid": (0.0, None)}, "alpha": {"units": "unitless", "valid": (0, 10)}, - "I0": {"units": "flux/arcsec^2"}, - } - _overload_parameter_specs = { - "logI0": { - "units": "log10(flux/arcsec^2)", - "overloads": "I0", - "overload_function": lambda p: 10**p.logI0.value, - } + "I0": {"units": "flux/arcsec^2", "valid": (0, None)}, } @torch.no_grad() @@ -77,17 +58,13 @@ class iKingMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logI0"): - return - if not self.alpha.initialized: self.alpha.dynamic_value = 2.0 * np.ones(self.segments) parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: king_np(r, x[0], x[1], 2.0, 10 ** x[2]), - params=("Rc", "Rt", "logI0"), + prof_func=lambda r, *x: king_np(r, x[0], x[1], 2.0, x[2]), + params=("Rc", "Rt", "I0"), x0_func=x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 6ab54d80..4be4ddf9 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -1,4 +1,5 @@ import torch +import numpy as np from ...param import forward from ...utils.decorators import ignore_numpy_warnings @@ -8,7 +9,7 @@ def _x0_func(model_params, R, F): - return 2.0, R[4], F[0] + return 2.0, R[4], 10 ** F[0] class MoffatMixin: @@ -17,15 +18,7 @@ class MoffatMixin: _parameter_specs = { "n": {"units": "none", "valid": (0.1, 10), "shape": ()}, "Rd": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "I0": {"units": "flux/arcsec^2", "shape": ()}, - } - _overload_parameter_specs = { - "logI0": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "I0", - "overload_function": lambda p: 10**p.logI0.value, - } + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, } @torch.no_grad() @@ -33,15 +26,11 @@ class MoffatMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logI0"): - return - parametric_initialize( self, self.target[self.window], - lambda r, *x: moffat_np(r, x[0], x[1], 10 ** x[2]), - ("n", "Rd", "logI0"), + moffat_np, + ("n", "Rd", "I0"), _x0_func, ) @@ -56,15 +45,7 @@ class iMoffatMixin: _parameter_specs = { "n": {"units": "none", "valid": (0.1, 10)}, "Rd": {"units": "arcsec", "valid": (0, None)}, - "I0": {"units": "flux/arcsec^2"}, - } - _overload_parameter_specs = { - "logI0": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "I0", - "overload_function": lambda p: 10**p.logI0.value, - } + "I0": {"units": "flux/arcsec^2", "valid": (0, None)}, } @torch.no_grad() @@ -72,15 +53,11 @@ class iMoffatMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logI0"): - return - parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: moffat_np(r, x[0], x[1], 10 ** x[2]), - params=("n", "Rd", "logI0"), + prof_func=moffat_np, + params=("n", "Rd", "I0"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 51d89dfc..611127f8 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], F[4], 1.0, 2.0, 0.5 + return R[4], 10 ** F[4], 1.0, 2.0, 0.5 class NukerMixin: @@ -16,34 +16,22 @@ class NukerMixin: _model_type = "nuker" _parameter_specs = { "Rb": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "Ib": {"units": "flux/arcsec^2", "shape": ()}, + "Ib": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, "alpha": {"units": "none", "valid": (0, None), "shape": ()}, "beta": {"units": "none", "valid": (0, None), "shape": ()}, "gamma": {"units": "none", "shape": ()}, } - _overload_parameter_specs = { - "logIb": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "Ib", - "overload_function": lambda p: 10**p.logIb.value, - } - } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logIb"): - return - parametric_initialize( self, self.target[self.window], - lambda r, *x: nuker_np(r, x[0], 10 ** x[1], x[2], x[3], x[4]), - ("Rb", "logIb", "alpha", "beta", "gamma"), + nuker_np, + ("Rb", "Ib", "alpha", "beta", "gamma"), _x0_func, ) @@ -57,34 +45,22 @@ class iNukerMixin: _model_type = "nuker" _parameter_specs = { "Rb": {"units": "arcsec", "valid": (0, None)}, - "Ib": {"units": "flux/arcsec^2"}, + "Ib": {"units": "flux/arcsec^2", "valid": (0, None)}, "alpha": {"units": "none", "valid": (0, None)}, "beta": {"units": "none", "valid": (0, None)}, "gamma": {"units": "none"}, } - _overload_parameter_specs = { - "logIb": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "Ib", - "overload_function": lambda p: 10**p.logIb.value, - } - } @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logIb"): - return - parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: nuker_np(r, x[0], 10 ** x[1], x[2], x[3], x[4]), - params=("Rb", "logIb", "alpha", "beta", "gamma"), + prof_func=nuker_np, + params=("Rb", "Ib", "alpha", "beta", "gamma"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 6d9e0ce5..0bfce9c8 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -144,14 +144,14 @@ def jacobian( n_pixels = np.prod(window.shape) if n_pixels > self.jacobian_maxpixels: for chunk in window.chunk(self.jacobian_maxpixels): - self.jacobian(window=chunk, pass_jacobian=jac_img, params=params) + jac_img = self.jacobian(window=chunk, pass_jacobian=jac_img, params=params) return jac_img identities = self.build_params_array_identities() target = self.target[window] if len(params) > self.jacobian_maxparams: # handle large number of parameters chunksize = len(params) // self.jacobian_maxparams + 1 - for i in range(chunksize, len(params), chunksize): + for i in range(0, len(params), chunksize): params_pre = params[:i] params_chunk = params[i : i + chunksize] params_post = params[i + chunksize :] diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index 78d9d234..a9e628b7 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -8,7 +8,7 @@ def _x0_func(model, R, F): - return 2.0, R[4], F[4] + return 2.0, R[4], 10 ** F[4] class SersicMixin: @@ -27,15 +27,7 @@ class SersicMixin: _parameter_specs = { "n": {"units": "none", "valid": (0.36, 8), "shape": ()}, "Re": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "Ie": {"units": "flux/arcsec^2", "shape": ()}, - } - _overload_parameter_specs = { - "logIe": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "Ie", - "overload_function": lambda p: 10**p.logIe.value, - } + "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, } @torch.no_grad() @@ -43,16 +35,8 @@ class SersicMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logIe"): - return - parametric_initialize( - self, - self.target[self.window], - lambda r, *x: sersic_np(r, x[0], x[1], 10 ** x[2]), - ("n", "Re", "logIe"), - _x0_func, + self, self.target[self.window], sersic_np, ("n", "Re", "Ie"), _x0_func ) @forward @@ -76,15 +60,7 @@ class iSersicMixin: _parameter_specs = { "n": {"units": "none", "valid": (0.36, 8)}, "Re": {"units": "arcsec", "valid": (0, None)}, - "Ie": {"units": "flux/arcsec^2"}, - } - _overload_parameter_specs = { - "logIe": { - "units": "log10(flux/arcsec^2)", - "shape": (), - "overloads": "Ie", - "overload_function": lambda p: 10**p.logIe.value, - } + "Ie": {"units": "flux/arcsec^2", "valid": (0, None)}, } @torch.no_grad() @@ -92,15 +68,11 @@ class iSersicMixin: def initialize(self): super().initialize() - # Only auto initialize for standard parametrization - if not hasattr(self, "logIe"): - return - parametric_segment_initialize( model=self, target=self.target[self.window], - prof_func=lambda r, *x: sersic_np(r, x[0], x[1], 10 ** x[2]), - params=("n", "Re", "logIe"), + prof_func=sersic_np, + params=("n", "Re", "Ie"), x0_func=_x0_func, segments=self.segments, ) diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 3e210964..62e04ff9 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -11,26 +11,15 @@ class SplineMixin: _model_type = "spline" - _parameter_specs = {"I_R": {"units": "flux/arcsec^2"}} - _overload_parameter_specs = { - "logI_R": { - "units": "log10(flux/arcsec^2)", - "overloads": "I_R", - "overload_function": lambda p: 10**p.logI_R.value, - } - } + _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None)}} @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() - try: - if self.logI_R.initialized: - return - except AttributeError: - if self.I_R.initialized: - return + if self.I_R.initialized: + return target_area = self.target[self.window] # Create the I_R profile radii if needed @@ -46,10 +35,7 @@ def initialize(self): self.radius_metric, rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], ) - try: - self.logI_R.dynamic_value = I - except AttributeError: - self.I_R.dynamic_value = 10**I + self.I_R.dynamic_value = 10**I @forward def radial_model(self, R, I_R): @@ -59,26 +45,15 @@ def radial_model(self, R, I_R): class iSplineMixin: _model_type = "spline" - _parameter_specs = {"I_R": {"units": "flux/arcsec^2"}} - _overload_parameter_specs = { - "logI_R": { - "units": "log10(flux/arcsec^2)", - "overloads": "I_R", - "overload_function": lambda p: 10**p.logI_R.value, - } - } + _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None)}} @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() - try: - if self.logI_R.initialized: - return - except AttributeError: - if self.I_R.initialized: - return + if self.I_R.initialized: + return target_area = self.target[self.window] # Create the I_R profile radii if needed @@ -106,10 +81,7 @@ def initialize(self): ) value[s] = I - if hasattr(self, "logI_R"): - self.logI_R.dynamic_value = value - else: - self.I_R.dynamic_value = 10**value + self.I_R.dynamic_value = 10**value @forward def iradial_model(self, i, R, I_R): diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 9b49d81a..a3d6ca73 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -11,7 +11,7 @@ class InclinedMixin: _parameter_specs = { - "q": {"units": "b/a", "valid": (0, 1), "shape": ()}, + "q": {"units": "b/a", "valid": (0.01, 1), "shape": ()}, "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "shape": ()}, } diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 0a621aaa..46caaec3 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -10,7 +10,6 @@ from ..image import Window, PSFImage from ..errors import SpecificationConflict from ..param import forward -from . import func __all__ = ("PointSource",) @@ -26,15 +25,7 @@ class PointSource(ComponentModel): _model_type = "point" _parameter_specs = { - "flux": {"units": "flux", "shape": ()}, - } - _overload_parameter_specs = { - "logflux": { - "units": "log10(flux)", - "shape": (), - "overloads": "flux", - "overload_function": lambda p: 10**p.logflux.value, - } + "flux": {"units": "flux", "valid": (0, None), "shape": ()}, } usable = True @@ -50,13 +41,13 @@ def __init__(self, *args, **kwargs): def initialize(self): super().initialize() - if not hasattr(self, "logflux") or self.logflux.initialized: + if self.flux.initialized: return target_area = self.target[self.window] dat = target_area.data.detach().cpu().numpy().copy() edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) - self.logflux.dynamic_value = np.log10(np.abs(np.sum(dat - edge_average))) + self.flux.dynamic_value = np.abs(np.sum(dat - edge_average)) # Psf convolution should be on by default since this is a delta function @property diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 237e0e10..d98b2a9c 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -122,6 +122,8 @@ " name=\"model with target\",\n", " model_type=\"sersic galaxy model\", # feel free to swap out sersic with other profile types\n", " target=target, # now the model knows what its trying to match\n", + " # jacobian_maxpixels=200**2,\n", + " # integrate_mode=\"none\", # this tells the model how to compute the model image, \"none\" is fast but not very accurate, \"integrate\" is slow but accurate\n", ")\n", "\n", "# Instead of giving initial values for all the parameters, it is possible to simply call \"initialize\" and AstroPhot\n", diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 18846626..0a6b2c44 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -102,7 +102,7 @@ " name=\"rband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_r,\n", - " psf_mode=\"full\",\n", + " psf_convolve=True,\n", ")\n", "\n", "model_W1 = ap.models.Model(\n", @@ -111,7 +111,7 @@ " target=target_W1,\n", " center=[0, 0],\n", " PA=-2.3,\n", - " psf_mode=\"full\",\n", + " psf_convolve=True,\n", ")\n", "\n", "model_NUV = ap.models.Model(\n", @@ -120,7 +120,7 @@ " target=target_NUV,\n", " center=[0, 0],\n", " PA=-2.3,\n", - " psf_mode=\"full\",\n", + " psf_convolve=True,\n", ")\n", "\n", "# At this point we would just be fitting three separate models at the same time, not very interesting. Next\n", @@ -149,11 +149,15 @@ "\n", "model_full.initialize()\n", "print(model_full)\n", - "fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6))\n", - "ap.plots.model_image(fig1, ax1, model_full)\n", - "ax1[0].set_title(\"r-band model image\")\n", - "ax1[1].set_title(\"W1-band model image\")\n", - "ax1[2].set_title(\"NUV-band model image\")\n", + "ig1, ax1 = plt.subplots(2, 3, figsize=(18, 12))\n", + "ap.plots.model_image(fig1, ax1[0], model_full)\n", + "ax1[0][0].set_title(\"r-band model image\")\n", + "ax1[0][1].set_title(\"W1-band model image\")\n", + "ax1[0][2].set_title(\"NUV-band model image\")\n", + "ap.plots.residual_image(fig1, ax1[1], model_full, normalize_residuals=True)\n", + "ax1[1][0].set_title(\"r-band residual image\")\n", + "ax1[1][1].set_title(\"W1-band residual image\")\n", + "ax1[1][2].set_title(\"NUV-band residual image\")\n", "plt.show()\n", "model_full.graphviz()" ] @@ -169,15 +173,6 @@ "print(model_full)" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "print(model_full.models[0].center.value)" - ] - }, { "cell_type": "code", "execution_count": null, @@ -322,7 +317,7 @@ " model_type=\"sersic galaxy model\", # we could use spline models for the r-band since it is well resolved\n", " target=target_r,\n", " window=rwindows[window],\n", - " psf_mode=\"full\",\n", + " psf_convolve=True,\n", " center=torch.stack(target_r.pixel_to_plane(*torch.tensor(centers[window]))),\n", " PA=target_r.pixel_angle_to_plane_angle(torch.tensor(PAs[window])),\n", " q=qs[window],\n", @@ -334,7 +329,7 @@ " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", " window=w1windows[window],\n", - " psf_mode=\"full\",\n", + " psf_convolve=True,\n", " )\n", " )\n", " sub_list.append(\n", @@ -343,7 +338,7 @@ " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", " window=nuvwindows[window],\n", - " psf_mode=\"full\",\n", + " psf_convolve=True,\n", " )\n", " )\n", " # ensure equality constraints\n", @@ -450,6 +445,15 @@ "It is possible to get quite creative with joint models as they allow one to fix selective features of a model over a wide range of data. If you have a situation which may benefit from joint modelling but are having a hard time determining how to format everything, please do contact us!" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(MODEL)" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/tests/test_fit.py b/tests/test_fit.py index e16dbced..649a26b6 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -1,569 +1,307 @@ -import unittest - import torch import numpy as np import astrophot as ap from utils import make_basic_sersic +import pytest ###################################################################### # Fit Objects ###################################################################### -class TestComponentModelFits(unittest.TestCase): - def test_sersic_fit_grad(self): - """ - Simply test that the gradient optimizer changes the parameters - """ - np.random.seed(12345) - N = 50 - Width = 20 - shape = (N + 10, N) - true_params = [2, 5, 10, -3, 5, 0.7, np.pi / 4] - IXX, IYY = np.meshgrid( - np.linspace(-Width, Width, shape[1]), np.linspace(-Width, Width, shape[0]) - ) - QPAXX, QPAYY = ap.utils.conversions.coordinates.Axis_Ratio_Cartesian_np( - true_params[5], IXX - true_params[3], IYY - true_params[4], true_params[6] - ) - Z0 = ap.utils.parametric_profiles.sersic_np( - np.sqrt(QPAXX**2 + QPAYY**2), - true_params[0], - true_params[1], - true_params[2], - ) + np.random.normal(loc=0, scale=0.1, size=shape) - tar = ap.image.Target_Image( - data=Z0, - pixelscale=0.8, - variance=np.ones(Z0.shape) * (0.1**2), - ) - - mod = ap.models.Sersic_Galaxy( - name="sersic model", - target=tar, - parameters={ - "center": [-3.2 + N / 2, 5.1 + (N + 10) / 2], - "q": 0.6, - "PA": np.pi / 4, - "n": 2, - "Re": 5, - "Ie": 10, - }, - ) - - self.assertFalse(mod.locked, "default model should not be locked") - - mod.initialize() - - mod_initparams = {} - for p in mod.parameters: - mod_initparams[p.name] = np.copy(p.vector_representation().detach().cpu().numpy()) - - res = ap.fit.Grad(model=mod, max_iter=10).fit() - - for p in mod.parameters: - self.assertFalse( - np.any(p.vector_representation().detach().cpu().numpy() == mod_initparams[p.name]), - f"parameter {p.name} should update with optimization", - ) - - def test_sersic_fit_lm(self): - """ - Test sersic fitting with entirely independent sersic sampling at 10x resolution. - """ - N = 50 - pixelscale = 0.8 - shape = (N + 10, N) - true_params = { - "center": [ - shape[0] * pixelscale / 2 - 3.35, - shape[1] * pixelscale / 2 + 5.35, - ], - "n": 1, - "Re": 20, - "Ie": 0.0, - "q": 0.7, - "PA": np.pi / 4, - } - tar = make_basic_sersic( - N=shape[0], - M=shape[1], - pixelscale=pixelscale, - x=true_params["center"][0], - y=true_params["center"][1], - n=true_params["n"], - Re=true_params["Re"], - Ie=true_params["Ie"], - q=true_params["q"], - PA=true_params["PA"], - ) - mod = ap.models.AstroPhot_Model( - name="sersic model", - model_type="sersic galaxy model", - target=tar, - sampling_mode="simpsons", - ) - - mod.initialize() - ap.AP_config.set_logging_output(stdout=True, filename="AstroPhot.log") - res = ap.fit.LM(model=mod, verbose=2).fit() - res.update_uncertainty() - - self.assertAlmostEqual( - mod["center"].value[0].item() / true_params["center"][0], - 1, - 2, - "LM should accurately recover parameters in simple cases", - ) - self.assertAlmostEqual( - mod["center"].value[1].item() / true_params["center"][1], - 1, - 2, - "LM should accurately recover parameters in simple cases", - ) - self.assertAlmostEqual( - mod["n"].value.item(), - true_params["n"], - 1, - msg="LM should accurately recover parameters in simple cases", - ) - self.assertAlmostEqual( - (mod["Re"].value.item()) / true_params["Re"], - 1, - delta=1, - msg="LM should accurately recover parameters in simple cases", - ) - self.assertAlmostEqual( - mod["Ie"].value.item(), - true_params["Ie"], - 1, - "LM should accurately recover parameters in simple cases", - ) - self.assertAlmostEqual( - mod["PA"].value.item() / true_params["PA"], - 1, - delta=0.5, - msg="LM should accurately recover parameters in simple cases", - ) - self.assertAlmostEqual( - mod["q"].value.item(), - true_params["q"], - 1, - "LM should accurately recover parameters in simple cases", - ) - cov = res.covariance_matrix - - -class TestGroupModelFits(unittest.TestCase): - def test_groupmodel_fit(self): - """ - Simply test that fitting a group model changes the parameter values - """ - np.random.seed(12345) - N = 50 - Width = 20 - shape = (N + 10, N) - true_params1 = [2, 4, 10, -3, 5, 0.7, np.pi / 4] - true_params2 = [1.2, 6, 8, 2, -3, 0.5, -np.pi / 4] - IXX, IYY = np.meshgrid( - np.linspace(-Width, Width, shape[1]), np.linspace(-Width, Width, shape[0]) - ) - QPAXX, QPAYY = ap.utils.conversions.coordinates.Axis_Ratio_Cartesian_np( - true_params1[5], - IXX - true_params1[3], - IYY - true_params1[4], - true_params1[6], - ) - Z0 = ap.utils.parametric_profiles.sersic_np( - np.sqrt(QPAXX**2 + QPAYY**2), - true_params1[0], - true_params1[1], - true_params1[2], - ) - QPAXX, QPAYY = ap.utils.conversions.coordinates.Axis_Ratio_Cartesian_np( - true_params2[5], - IXX - true_params2[3], - IYY - true_params2[4], - true_params2[6], - ) - Z0 += ap.utils.parametric_profiles.sersic_np( - np.sqrt(QPAXX**2 + QPAYY**2), - true_params2[0], - true_params2[1], - true_params2[2], - ) - Z0 += np.random.normal(loc=0, scale=0.1, size=shape) - tar = ap.image.Target_Image( - data=Z0, - pixelscale=0.8, - variance=np.ones(Z0.shape) * (0.1**2), - ) - - mod1 = ap.models.Sersic_Galaxy( - name="sersic model 1", - target=tar, - parameters={"center": {"value": [-3.2 + N / 2, 5.1 + (N + 10) / 2]}}, - ) - mod2 = ap.models.Sersic_Galaxy( - name="sersic model 2", - target=tar, - parameters={"center": {"value": [2.1 + N / 2, -3.1 + (N + 10) / 2]}}, - ) - - smod = ap.models.Group_Model(name="group model", models=[mod1, mod2], target=tar) - - self.assertFalse(smod.locked, "default model should not be locked") - - smod.initialize() - - mod1_initparams = {} - for p in mod1.parameters: - mod1_initparams[p.name] = np.copy(p.vector_representation().detach().cpu().numpy()) - mod2_initparams = {} - for p in mod2.parameters: - mod2_initparams[p.name] = np.copy(p.vector_representation().detach().cpu().numpy()) - - res = ap.fit.Grad(model=smod, max_iter=10).fit() - - for p in mod1.parameters: - self.assertFalse( - np.any(p.vector_representation().detach().cpu().numpy() == mod1_initparams[p.name]), - f"mod1 parameter {p.name} should update with optimization", - ) - for p in mod2.parameters: - self.assertFalse( - np.any(p.vector_representation().detach().cpu().numpy() == mod2_initparams[p.name]), - f"mod2 parameter {p.name} should update with optimization", - ) - - -class TestLM(unittest.TestCase): - def test_lm_creation(self): - target = make_basic_sersic() - new_model = ap.models.AstroPhot_Model( - name="test sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - - LM = ap.fit.LM(new_model, max_iter=10) - - LM.fit() - - def test_chunk_parameter_jacobian(self): - target = make_basic_sersic() - new_model = ap.models.AstroPhot_Model( - name="test sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - jacobian_chunksize=3, - ) - - LM = ap.fit.LM(new_model, max_iter=10) - - LM.fit() - - def test_chunk_image_jacobian(self): - target = make_basic_sersic() - new_model = ap.models.AstroPhot_Model( - name="test sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - image_chunksize=15, - ) - - LM = ap.fit.LM(new_model, max_iter=10) - - LM.fit() - - def test_group_fit_step(self): - np.random.seed(123456) - tar = make_basic_sersic(N=51, M=51) - mod1 = ap.models.Sersic_Galaxy( - name="base model 1", - target=tar, - window=[[0, 25], [0, 25]], - parameters={ - "center": [5, 5], - "PA": 0, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - ) - mod2 = ap.models.Sersic_Galaxy( - name="base model 2", - target=tar, - window=[[25, 51], [25, 51]], - parameters={ - "center": [5, 5], - "PA": 0, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - ) - - smod = ap.models.AstroPhot_Model( - name="group model", - model_type="group model", - models=[mod1, mod2], - target=tar, - ) - vec_init = smod.parameters.vector_values().detach().clone() - LM = ap.fit.LM(smod, max_iter=1).fit() - vec_final = smod.parameters.vector_values().detach().clone() - self.assertFalse( - torch.all(vec_init == vec_final), - "LM should update parameters in LM step", - ) - - -class TestMiniFit(unittest.TestCase): - def test_minifit(self): - target = make_basic_sersic() - new_model = ap.models.AstroPhot_Model( - name="test sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - - MF = ap.fit.MiniFit( - new_model, downsample_factor=2, method_quargs={"max_iter": 10}, verbose=1 - ) - - MF.fit() - - -class TestIter(unittest.TestCase): - def test_iter_basic(self): - target = make_basic_sersic() - model_list = [] - model_list.append( - ap.models.AstroPhot_Model( - name="basic sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - ) - model_list.append( - ap.models.AstroPhot_Model( - name="basic sky", - model_type="flat sky model", - parameters={"F": -1}, - target=target, - ) - ) - - MODEL = ap.models.AstroPhot_Model( - name="model", - model_type="group model", - target=target, - models=model_list, - ) - - MODEL.initialize() - - res = ap.fit.Iter(MODEL, method=ap.fit.LM) - - res.fit() - - -class TestIterLM(unittest.TestCase): - def test_iter_basic(self): - target = make_basic_sersic() - model_list = [] - model_list.append( - ap.models.AstroPhot_Model( - name="basic sersic", - model_type="sersic galaxy model", - parameters={ - "center": [20, 20], - "PA": 60 * np.pi / 180, - "q": 0.5, - "n": 2, - "Re": 5, - "Ie": 1, - }, - target=target, - ) - ) - model_list.append( - ap.models.AstroPhot_Model( - name="basic sky", - model_type="flat sky model", - parameters={"F": -1}, - target=target, - ) - ) - - MODEL = ap.models.AstroPhot_Model( - name="model", - model_type="group model", - target=target, - models=model_list, - ) - - MODEL.initialize() - - res = ap.fit.Iter_LM(MODEL) - - res.fit() - - -class TestHMC(unittest.TestCase): - def test_hmc_sample(self): - np.random.seed(12345) - N = 50 - pixelscale = 0.8 - true_params = { - "n": 2, - "Re": 10, - "Ie": 1, - "center": [-3.3, 5.3], - "q": 0.7, - "PA": np.pi / 4, - } - target = ap.image.Target_Image( - data=np.zeros((N, N)), - pixelscale=pixelscale, - ) - - MODEL = ap.models.Sersic_Galaxy( - name="sersic model", - target=target, - parameters=true_params, - ) - img = MODEL().data.detach().cpu().numpy() - target.data = torch.Tensor( - img - + np.random.normal(scale=0.1, size=img.shape) - + np.random.normal(scale=np.sqrt(img) / 10) - ) - target.variance = torch.Tensor(0.1**2 + img / 100) - - HMC = ap.fit.HMC(MODEL, epsilon=1e-5, max_iter=5, warmup=2) - HMC.fit() - - -class TestNUTS(unittest.TestCase): - def test_nuts_sample(self): - np.random.seed(12345) - N = 50 - pixelscale = 0.8 - true_params = { - "n": 2, - "Re": 10, - "Ie": 1, - "center": [-3.3, 5.3], - "q": 0.7, - "PA": np.pi / 4, - } - target = ap.image.Target_Image( - data=np.zeros((N, N)), - pixelscale=pixelscale, - ) - - MODEL = ap.models.Sersic_Galaxy( - name="sersic model", - target=target, - parameters=true_params, - ) - img = MODEL().data.detach().cpu().numpy() - target.data = torch.Tensor( - img - + np.random.normal(scale=0.1, size=img.shape) - + np.random.normal(scale=np.sqrt(img) / 10) - ) - target.variance = torch.Tensor(0.1**2 + img / 100) - - NUTS = ap.fit.NUTS(MODEL, max_iter=5, warmup=2) - NUTS.fit() - - -class TestMHMCMC(unittest.TestCase): - def test_singlesersic(self): - np.random.seed(12345) - N = 50 - pixelscale = 0.8 - true_params = { - "n": 2, - "Re": 10, - "Ie": 1, - "center": [-3.3, 5.3], - "q": 0.7, - "PA": np.pi / 4, - } - target = ap.image.Target_Image( - data=np.zeros((N, N)), - pixelscale=pixelscale, - ) - - MODEL = ap.models.Sersic_Galaxy( - name="sersic model", - target=target, - parameters=true_params, - ) - img = MODEL().data.detach().cpu().numpy() - target.data = torch.Tensor( - img - + np.random.normal(scale=0.1, size=img.shape) - + np.random.normal(scale=np.sqrt(img) / 10) - ) - target.variance = torch.Tensor(0.1**2 + img / 100) - - MHMCMC = ap.fit.MHMCMC(MODEL, epsilon=1e-4, max_iter=100) - MHMCMC.fit() - - self.assertGreater( - MHMCMC.acceptance, - 0.1, - "MHMCMC should have nonzero acceptance for simple fits", - ) - - -if __name__ == "__main__": - unittest.main() +@pytest.mark.parametrize("center", [[20, 20], [25.1, 17.324567]]) +@pytest.mark.parametrize("PA", [0, 60 * np.pi / 180]) +@pytest.mark.parametrize("q", [0.2, 0.8]) +@pytest.mark.parametrize("n", [1, 4]) +@pytest.mark.parametrize("Re", [10, 25.1]) +def test_chunk_jacobian(center, PA, q, n, Re): + target = make_basic_sersic() + model = ap.Model( + name="test sersic", + model_type="sersic galaxy model", + center=center, + PA=PA, + q=q, + n=n, + Re=Re, + Ie=10.0, + target=target, + integrate_mode="none", + ) + + Jtrue = model.jacobian() + + model.jacobian_maxparams = 3 + + Jchunked = model.jacobian() + assert torch.allclose( + Jtrue.data, Jchunked.data + ), "Param chunked Jacobian should match full Jacobian" + + model.jacobian_maxparams = 10 + model.jacobian_maxpixels = 20**2 + + Jchunked = model.jacobian() + + assert torch.allclose( + Jtrue.data, Jchunked.data + ), "Pixel chunked Jacobian should match full Jacobian" + + +# def test_lm(): +# target = make_basic_sersic() +# new_model = ap.Model( +# name="test sersic", +# model_type="sersic galaxy model", +# center=[20, 20], +# PA=60 * np.pi / 180, +# q=0.5, +# n=2, +# Re=5, +# Ie=10, +# target=target, +# ) + +# res = ap.fit.LM(new_model).fit() +# print(res.loss_history) +# raise Exception() + +# assert res.message == "success", "LM should converge successfully" + + +# def test_chunk_parameter_jacobian(): +# target = make_basic_sersic() +# new_model = ap.Model( +# name="test sersic", +# model_type="sersic galaxy model", +# center=[20, 20], +# PA=60 * np.pi / 180, +# q=0.5, +# n=2, +# Re=5, +# Ie=10, +# target=target, +# jacobian_maxparams=3, +# ) + +# res = ap.fit.LM(new_model).fit() +# print(res.loss_history) +# raise Exception() +# assert res.message == "success", "LM should converge successfully" + + +# def test_chunk_image_jacobian(): +# target = make_basic_sersic() +# new_model = ap.Model( +# name="test sersic", +# model_type="sersic galaxy model", +# center=[20, 20], +# PA=60 * np.pi / 180, +# q=0.5, +# n=2, +# Re=5, +# Ie=1, +# target=target, +# jacobian_maxpixels=20**2, +# ) + +# res = ap.fit.LM(new_model).fit() +# print(res.loss_history) +# raise Exception() +# assert res.message == "success", "LM should converge successfully" + + +# class TestIter(unittest.TestCase): +# def test_iter_basic(self): +# target = make_basic_sersic() +# model_list = [] +# model_list.append( +# ap.models.AstroPhot_Model( +# name="basic sersic", +# model_type="sersic galaxy model", +# parameters={ +# "center": [20, 20], +# "PA": 60 * np.pi / 180, +# "q": 0.5, +# "n": 2, +# "Re": 5, +# "Ie": 1, +# }, +# target=target, +# ) +# ) +# model_list.append( +# ap.models.AstroPhot_Model( +# name="basic sky", +# model_type="flat sky model", +# parameters={"F": -1}, +# target=target, +# ) +# ) + +# MODEL = ap.models.AstroPhot_Model( +# name="model", +# model_type="group model", +# target=target, +# models=model_list, +# ) + +# MODEL.initialize() + +# res = ap.fit.Iter(MODEL, method=ap.fit.LM) + +# res.fit() + + +# class TestIterLM(unittest.TestCase): +# def test_iter_basic(self): +# target = make_basic_sersic() +# model_list = [] +# model_list.append( +# ap.models.AstroPhot_Model( +# name="basic sersic", +# model_type="sersic galaxy model", +# parameters={ +# "center": [20, 20], +# "PA": 60 * np.pi / 180, +# "q": 0.5, +# "n": 2, +# "Re": 5, +# "Ie": 1, +# }, +# target=target, +# ) +# ) +# model_list.append( +# ap.models.AstroPhot_Model( +# name="basic sky", +# model_type="flat sky model", +# parameters={"F": -1}, +# target=target, +# ) +# ) + +# MODEL = ap.models.AstroPhot_Model( +# name="model", +# model_type="group model", +# target=target, +# models=model_list, +# ) + +# MODEL.initialize() + +# res = ap.fit.Iter_LM(MODEL) + +# res.fit() + + +# class TestHMC(unittest.TestCase): +# def test_hmc_sample(self): +# np.random.seed(12345) +# N = 50 +# pixelscale = 0.8 +# true_params = { +# "n": 2, +# "Re": 10, +# "Ie": 1, +# "center": [-3.3, 5.3], +# "q": 0.7, +# "PA": np.pi / 4, +# } +# target = ap.image.Target_Image( +# data=np.zeros((N, N)), +# pixelscale=pixelscale, +# ) + +# MODEL = ap.models.Sersic_Galaxy( +# name="sersic model", +# target=target, +# parameters=true_params, +# ) +# img = MODEL().data.detach().cpu().numpy() +# target.data = torch.Tensor( +# img +# + np.random.normal(scale=0.1, size=img.shape) +# + np.random.normal(scale=np.sqrt(img) / 10) +# ) +# target.variance = torch.Tensor(0.1**2 + img / 100) + +# HMC = ap.fit.HMC(MODEL, epsilon=1e-5, max_iter=5, warmup=2) +# HMC.fit() + + +# class TestNUTS(unittest.TestCase): +# def test_nuts_sample(self): +# np.random.seed(12345) +# N = 50 +# pixelscale = 0.8 +# true_params = { +# "n": 2, +# "Re": 10, +# "Ie": 1, +# "center": [-3.3, 5.3], +# "q": 0.7, +# "PA": np.pi / 4, +# } +# target = ap.image.Target_Image( +# data=np.zeros((N, N)), +# pixelscale=pixelscale, +# ) + +# MODEL = ap.models.Sersic_Galaxy( +# name="sersic model", +# target=target, +# parameters=true_params, +# ) +# img = MODEL().data.detach().cpu().numpy() +# target.data = torch.Tensor( +# img +# + np.random.normal(scale=0.1, size=img.shape) +# + np.random.normal(scale=np.sqrt(img) / 10) +# ) +# target.variance = torch.Tensor(0.1**2 + img / 100) + +# NUTS = ap.fit.NUTS(MODEL, max_iter=5, warmup=2) +# NUTS.fit() + + +# class TestMHMCMC(unittest.TestCase): +# def test_singlesersic(self): +# np.random.seed(12345) +# N = 50 +# pixelscale = 0.8 +# true_params = { +# "n": 2, +# "Re": 10, +# "Ie": 1, +# "center": [-3.3, 5.3], +# "q": 0.7, +# "PA": np.pi / 4, +# } +# target = ap.image.Target_Image( +# data=np.zeros((N, N)), +# pixelscale=pixelscale, +# ) + +# MODEL = ap.models.Sersic_Galaxy( +# name="sersic model", +# target=target, +# parameters=true_params, +# ) +# img = MODEL().data.detach().cpu().numpy() +# target.data = torch.Tensor( +# img +# + np.random.normal(scale=0.1, size=img.shape) +# + np.random.normal(scale=np.sqrt(img) / 10) +# ) +# target.variance = torch.Tensor(0.1**2 + img / 100) + +# MHMCMC = ap.fit.MHMCMC(MODEL, epsilon=1e-4, max_iter=100) +# MHMCMC.fit() + +# self.assertGreater( +# MHMCMC.acceptance, +# 0.1, +# "MHMCMC should have nonzero acceptance for simple fits", +# ) diff --git a/tests/test_model.py b/tests/test_model.py index 466f4136..dfde4ed5 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -105,8 +105,6 @@ def test_all_model_sample(model_type): f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" ) else: - print(res.message) - print(res.loss_history) assert res.loss_history[0] > (2 * res.loss_history[-1]), ( f"Model {model_type} should fit to the target image, but did not. " f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" @@ -149,3 +147,36 @@ def test_sersic_save_load(): assert model.logIe.value.item() == 1, "Model logIe should be loaded correctly" assert model.target.crtan.value[0] == 0.0, "Model target crtan should be loaded correctly" assert model.target.crtan.value[1] == 0.0, "Model target crtan should be loaded correctly" + + +@pytest.mark.parametrize("center", [[20, 20], [25.1, 17.324567]]) +@pytest.mark.parametrize("PA", [0, 60 * np.pi / 180]) +@pytest.mark.parametrize("q", [0.2, 0.8]) +@pytest.mark.parametrize("n", [1, 4]) +@pytest.mark.parametrize("Re", [10, 25.1]) +def test_chunk_sample(center, PA, q, n, Re): + target = make_basic_sersic() + model = ap.Model( + name="test sersic", + model_type="sersic galaxy model", + center=center, + PA=PA, + q=q, + n=n, + Re=Re, + Ie=10.0, + target=target, + integrate_mode="none", + ) + + full_img = model.sample() + + chunk_img = target.model_image() + + for chunk in model.window.chunk(20**2): + sample = model.sample(window=chunk) + chunk_img += sample + + assert torch.allclose( + full_img.data, chunk_img.data + ), "Chunked sample should match full sample within tolerance" diff --git a/tests/utils.py b/tests/utils.py index 8bcbef23..22bd3d6a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -26,7 +26,7 @@ def get_astropy_wcs(): def make_basic_sersic( - N=50, + N=52, M=50, pixelscale=0.8, x=20.5, From ead3c206d2501789add4af299d1c5c95d4af80fb Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 17 Jul 2025 19:34:10 -0400 Subject: [PATCH 060/191] basis model online with zernike --- astrophot/image/func/__init__.py | 2 + astrophot/image/func/image.py | 9 ++ astrophot/models/__init__.py | 6 +- astrophot/models/airy.py | 2 +- astrophot/models/basis.py | 100 ++++++++++++ astrophot/models/bilinear_sky.py | 47 +++--- astrophot/models/eigen.py | 82 ---------- astrophot/models/func/__init__.py | 3 + astrophot/models/func/zernike.py | 38 +++++ astrophot/models/zernike.py | 120 --------------- astrophot/plots/image.py | 6 +- astrophot/plots/profile.py | 2 +- astrophot/utils/initialize/PA.py | 13 ++ astrophot/utils/initialize/__init__.py | 2 + docs/source/tutorials/JointModels.ipynb | 9 -- docs/source/tutorials/ModelZoo.ipynb | 194 ++++++++++-------------- tests/test_plots.py | 6 +- tests/test_psfmodel.py | 111 ++++++-------- tests/utils.py | 5 +- 19 files changed, 321 insertions(+), 436 deletions(-) create mode 100644 astrophot/models/basis.py delete mode 100644 astrophot/models/eigen.py create mode 100644 astrophot/models/func/zernike.py delete mode 100644 astrophot/models/zernike.py create mode 100644 astrophot/utils/initialize/PA.py diff --git a/astrophot/image/func/__init__.py b/astrophot/image/func/__init__.py index c00031dd..ae7c920e 100644 --- a/astrophot/image/func/__init__.py +++ b/astrophot/image/func/__init__.py @@ -3,6 +3,7 @@ pixel_corner_meshgrid, pixel_simpsons_meshgrid, pixel_quad_meshgrid, + rotate, ) from .wcs import ( world_to_plane_gnomonic, @@ -18,6 +19,7 @@ "pixel_corner_meshgrid", "pixel_simpsons_meshgrid", "pixel_quad_meshgrid", + "rotate", "world_to_plane_gnomonic", "plane_to_world_gnomonic", "pixel_to_plane_linear", diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index 4ab1af99..7e1815f8 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -27,3 +27,12 @@ def pixel_quad_meshgrid(shape, dtype, device, order=3): i = torch.repeat_interleave(i[..., None], order**2, -1) + di.flatten() j = torch.repeat_interleave(j[..., None], order**2, -1) + dj.flatten() return i, j, w.flatten() + + +def rotate(theta, x, y): + """ + Applies a rotation matrix to the X,Y coordinates + """ + s = theta.sin() + c = theta.cos() + return c * x - s * y, s * x + c * y diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index cfa57b77..059e85d5 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -13,9 +13,8 @@ from .point_source import PointSource # subtypes of PSFModel -from .eigen import EigenPSF +from .basis import PixelBasisPSF from .airy import AiryPSF -from .zernike import ZernikePSF from .pixelated_psf import PixelatedPSF # Subtypes of SkyModel @@ -117,9 +116,8 @@ "SuperEllipseGalaxy", "WedgeGalaxy", "WarpGalaxy", - "EigenPSF", + "PixelBasisPSF", "AiryPSF", - "ZernikePSF", "PixelatedPSF", "FlatSky", "PlaneSky", diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index 7637ca29..403de922 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -60,7 +60,7 @@ def initialize(self): ] self.I0.dynamic_value = torch.mean(mid_chunk) / self.target.pixel_area if not self.aRL.initialized: - self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixelscale + self.aRL.dynamic_value = (5.0 / 8.0) * 2 * self.target.pixelscale @forward def radial_model(self, R, I0, aRL): diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py new file mode 100644 index 00000000..81aeeb1f --- /dev/null +++ b/astrophot/models/basis.py @@ -0,0 +1,100 @@ +import torch +import numpy as np + +from .psf_model_object import PSFModel +from ..utils.decorators import ignore_numpy_warnings +from ..utils.interpolate import interp2d +from .. import AP_config +from ..errors import SpecificationConflict +from ..param import forward +from . import func +from ..utils.initialize import polar_decomposition + +__all__ = ["BasisPSF"] + + +class PixelBasisPSF(PSFModel): + """point source model which uses multiple images as a basis for the + PSF as its representation for point sources. Using bilinear interpolation it + will shift the PSF within a pixel to accurately represent the center + location of a point source. There is no functional form for this object type + as any image can be supplied. Bilinear interpolation is very fast and + accurate for smooth models, so it is possible to do the expensive + interpolation before optimization and save time. + """ + + _model_type = "basis" + _parameter_specs = { + "weights": {"units": "flux"}, + "PA": {"units": "radians", "shape": ()}, + "scale": {"units": "arcsec/grid-unit", "shape": ()}, + } + usable = True + + def __init__(self, *args, basis="zernike:3", **kwargs): + """Initialize the PixelBasisPSF model with a basis set of images.""" + super().__init__(*args, **kwargs) + self.basis = basis + + @property + def basis(self): + """The basis set of images used to form the eigen point source.""" + return self._basis + + @basis.setter + def basis(self, value): + """Set the basis set of images. If value is None, the basis is initialized to an empty tensor.""" + if value is None: + raise SpecificationConflict( + "PixelBasisPSF requires a basis set of images to be provided." + ) + elif isinstance(value, str) and value.startswith("zernike:"): + self._basis = value + else: + # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates + self._basis = torch.transpose( + torch.as_tensor(value, dtype=AP_config.ap_dtype, device=AP_config.ap_device), 1, 2 + ) + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + target_area = self.target[self.window] + if not self.PA.initialized: + R, _ = polar_decomposition(self.target.CD.value.detach().cpu().numpy()) + self.PA.value = np.arccos(np.abs(R[0, 0])) + if not self.scale.initialized: + self.scale.value = self.target.pixelscale.item() + if isinstance(self.basis, str) and self.basis.startswith("zernike:"): + order = int(self.basis.split(":")[1]) + nm = func.zernike_n_m_list(order) + N = int( + target_area.data.shape[0] * self.target.pixelscale.item() / self.scale.value.item() + ) + X, Y = np.meshgrid( + np.linspace(-1, 1, N) * (N - 1) / N, + np.linspace(-1, 1, N) * (N - 1) / N, + indexing="ij", + ) + R = np.sqrt(X**2 + Y**2) + Phi = np.arctan2(Y, X) + basis = [] + for n, m in nm: + basis.append(func.zernike_n_m_modes(R, Phi, n, m)) + self.basis = np.stack(basis, axis=0) + + if not self.weights.initialized: + self.weights.dynamic_value = 1 / np.arange(len(self.basis)) + + @forward + def transform_coordinates(self, x, y, PA, scale): + x, y = super().transform_coordinates(x, y) + i, j = func.rotate(-PA, x, y) + pixel_center = (self.basis.shape[1] - 1) / 2, (self.basis.shape[2] - 1) / 2 + return i / scale + pixel_center[0], j / scale + pixel_center[1] + + @forward + def brightness(self, x, y, weights): + x, y = self.transform_coordinates(x, y) + return torch.sum(torch.vmap(lambda w, b: w * interp2d(b, y, x))(weights, self.basis), dim=0) diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py index c428c866..a65aabe2 100644 --- a/astrophot/models/bilinear_sky.py +++ b/astrophot/models/bilinear_sky.py @@ -5,7 +5,8 @@ from ..utils.decorators import ignore_numpy_warnings from ..utils.interpolate import interp2d from ..param import forward -from .. import AP_config +from . import func +from ..utils.initialize import polar_decomposition __all__ = ["BilinearSky"] @@ -21,6 +22,8 @@ class BilinearSky(SkyModel): _model_type = "bilinear" _parameter_specs = { "I": {"units": "flux/arcsec^2"}, + "PA": {"units": "radians", "shape": ()}, + "scale": {"units": "arcsec/grid-unit", "shape": ()}, } sampling_mode = "midpoint" usable = True @@ -37,7 +40,16 @@ def initialize(self): if self.I.initialized: self.nodes = tuple(self.I.value.shape) - self.update_transform() + + if not self.PA.initialized: + R, _ = polar_decomposition(self.target.CD.value.detach().cpu().numpy()) + self.PA.value = np.arccos(np.abs(R[0, 0])) + if not self.scale.initialized: + self.scale.value = ( + self.target.pixelscale.item() * self.target.data.shape[0] / self.nodes[0] + ) + + if self.I.initialized: return target_dat = self.target[self.window] @@ -57,36 +69,15 @@ def initialize(self): ) / self.target.pixel_area.item() ) - self.update_transform() - - def update_transform(self): - target_dat = self.target[self.window] - P = torch.stack(list(torch.stack(c) for c in target_dat.corners())) - centroid = P.mean(dim=0) - dP = P - centroid - evec = torch.linalg.eig(dP.T @ dP / 4)[1].real.to( - dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - if torch.dot(evec[0], P[3] - P[0]).abs() < torch.dot(evec[1], P[3] - P[0]).abs(): - evec = evec.flip(0) - evec[0] = evec[0] * self.nodes[0] / torch.linalg.norm(P[3] - P[0]) - evec[1] = evec[1] * self.nodes[1] / torch.linalg.norm(P[1] - P[0]) - self.evec = evec - self.shift = torch.tensor( - [(self.nodes[0] - 1) / 2, (self.nodes[1] - 1) / 2], - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, - ) @forward - def transform_coordinates(self, x, y): + def transform_coordinates(self, x, y, I, PA, scale): x, y = super().transform_coordinates(x, y) - xy = torch.stack((x, y), dim=-1) - xy = xy @ self.evec - xy = xy + self.shift - return xy[..., 0], xy[..., 1] + i, j = func.rotate(-PA, x, y) + pixel_center = (I.shape[0] - 1) / 2, (I.shape[1] - 1) / 2 + return i / scale + pixel_center[0], j / scale + pixel_center[1] @forward def brightness(self, x, y, I): x, y = self.transform_coordinates(x, y) - return interp2d(I, x, y) + return interp2d(I, y, x) diff --git a/astrophot/models/eigen.py b/astrophot/models/eigen.py deleted file mode 100644 index 2db23053..00000000 --- a/astrophot/models/eigen.py +++ /dev/null @@ -1,82 +0,0 @@ -import torch -import numpy as np - -from .psf_model_object import PSFModel -from ..utils.decorators import ignore_numpy_warnings -from ..utils.interpolate import interp2d -from .. import AP_config -from ..errors import SpecificationConflict -from ..param import forward - -__all__ = ["EigenPSF"] - - -class EigenPSF(PSFModel): - """point source model which uses multiple images as a basis for the - PSF as its representation for point sources. Using bilinear - interpolation it will shift the PSF within a pixel to accurately - represent the center location of a point source. There is no - functional form for this object type as any image can be - supplied. Note that as an argument to the model at construction - one can provide "psf" as an AstroPhot PSF_Image object. Since only - bilinear interpolation is performed, it is recommended to provide - the PSF at a higher resolution than the image if it is near the - nyquist sampling limit. Bilinear interpolation is very fast and - accurate for smooth models, so this way it is possible to do the - expensive interpolation before optimization and save time. Note - that if you do this you must provide the PSF as a PSF_Image object - with the correct pixelscale (essentially just divide the - pixelscale by the upsampling factor you used). - - Args: - eigen_basis (tensor): This is the basis set of images used to form the eigen point source, it should be a tensor with shape (N x W x H) where N is the number of eigen images, and W/H are the dimensions of the image. - eigen_pixelscale (float): This is the pixelscale associated with the eigen basis images. - - Parameters: - flux: the total flux of the point source model, represented as the log of the total flux. - weights: the relative amplitude of the Eigen basis modes. - - """ - - _model_type = "eigen" - _parameter_specs = { - "flux": {"units": "flux/arcsec^2", "value": 1.0}, - "weights": {"units": "unitless"}, - } - usable = True - - def __init__(self, *args, eigen_basis=None, **kwargs): - super().__init__(*args, **kwargs) - if eigen_basis is None: - raise SpecificationConflict( - "EigenPSF model requires 'eigen_basis' argument to be provided." - ) - self.eigen_basis = torch.as_tensor( - eigen_basis, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) - - @torch.no_grad() - @ignore_numpy_warnings - def initialize(self): - super().initialize() - target_area = self.target[self.window] - if not self.flux.initialized: - self.flux.dynamic_value = ( - torch.abs(torch.sum(target_area.data)) / target_area.pixel_area - ) - if not self.weights.initialized: - self.weights.dynamic_value = 1 / np.arange(len(self.eigen_basis)) - - @forward - def brightness(self, x, y, flux, weights): - x, y = self.transform_coordinates(x, y) - - psf = torch.sum( - self.eigen_basis * (weights / torch.linalg.norm(weights)).unsqueeze(1).unsqueeze(2), - axis=0, - ) - - pX, pY = self.target.plane_to_pixel(x, y) - result = interp2d(psf, pX, pY) - - return result * flux diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 574d89de..d7896bb5 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -27,6 +27,7 @@ from .nuker import nuker from .spline import spline from .transform import rotate +from .zernike import zernike_n_m_list, zernike_n_m_modes __all__ = ( "all_subclasses", @@ -55,4 +56,6 @@ "recursive_quad_integrate", "upsample", "rotate", + "zernike_n_m_list", + "zernike_n_m_modes", ) diff --git a/astrophot/models/func/zernike.py b/astrophot/models/func/zernike.py new file mode 100644 index 00000000..a3eb8ea3 --- /dev/null +++ b/astrophot/models/func/zernike.py @@ -0,0 +1,38 @@ +from functools import lru_cache +from scipy.special import binom +import numpy as np + + +@lru_cache(maxsize=1024) +def coefficients(n, m): + C = [] + for k in range(int((n - abs(m)) / 2) + 1): + C.append( + ( + k, + (-1) ** k * binom(n - k, k) * binom(n - 2 * k, (n - abs(m)) / 2 - k), + ) + ) + return C + + +def zernike_n_m_list(n): + nm = [] + for n_i in range(n + 1): + for m_i in range(-n_i, n_i + 1, 2): + nm.append((n_i, m_i)) + return nm + + +def zernike_n_m_modes(rho, phi, n, m): + Z = np.zeros_like(rho) + for k, c in coefficients(n, m): + R = rho ** (n - 2 * k) + T = 1.0 + if m < 0: + T = np.sin(abs(m) * phi) + elif m > 0: + T = np.cos(m * phi) + + Z = Z + c * R * T + return Z * (rho <= 1).astype(np.float64) diff --git a/astrophot/models/zernike.py b/astrophot/models/zernike.py deleted file mode 100644 index ae646d4f..00000000 --- a/astrophot/models/zernike.py +++ /dev/null @@ -1,120 +0,0 @@ -from functools import lru_cache - -import torch -from scipy.special import binom - -from ..utils.decorators import ignore_numpy_warnings -from .psf_model_object import PSFModel -from ..errors import SpecificationConflict -from ..param import forward - -__all__ = ("ZernikePSF",) - - -class ZernikePSF(PSFModel): - - _model_type = "zernike" - _parameter_specs = {"Anm": {"units": "flux/arcsec^2"}} - usable = True - - def __init__(self, *args, order_n=2, r_scale=None, **kwargs): - super().__init__(*args, **kwargs) - - self.order_n = int(order_n) - self.r_scale = r_scale - self.nm_list = self.iter_nm(self.order_n) - - @torch.no_grad() - @ignore_numpy_warnings - def initialize(self): - super().initialize() - - # List the coefficients to use - self.nm_list = self.iter_nm(self.order_n) - # Set the scale radius for the Zernike area - if self.r_scale is None: - self.r_scale = max(self.window.shape) / 2 - - # Check if user has already set the coefficients - if self.Anm.initialized: - if len(self.nm_list) != len(self.Anm.value): - raise SpecificationConflict( - f"nm_list length ({len(self.nm_list)}) must match coefficients ({len(self.Anm.value)})" - ) - return - - # Set the default coefficients to zeros - self.Anm.dynamic_value = torch.zeros(len(self.nm_list)) - if self.nm_list[0] == (0, 0): - self.Anm.value[0] = torch.median(self.target[self.window].data) / self.target.pixel_area - - def iter_nm(self, n): - nm = [] - for n_i in range(n + 1): - for m_i in range(-n_i, n_i + 1, 2): - nm.append((n_i, m_i)) - return nm - - @staticmethod - @lru_cache(maxsize=1024) - def coefficients(n, m): - C = [] - for k in range(int((n - abs(m)) / 2) + 1): - C.append( - ( - k, - (-1) ** k * binom(n - k, k) * binom(n - 2 * k, (n - abs(m)) / 2 - k), - ) - ) - return C - - def Z_n_m(self, rho, phi, n, m, efficient=True): - Z = torch.zeros_like(rho) - if efficient: - T_cache = {0: None} - R_cache = {} - for k, c in self.coefficients(n, m): - if efficient: - if (n - 2 * k) not in R_cache: - R_cache[n - 2 * k] = rho ** (n - 2 * k) - R = R_cache[n - 2 * k] - if m not in T_cache: - if m < 0: - T_cache[m] = torch.sin(abs(m) * phi) - elif m > 0: - T_cache[m] = torch.cos(m * phi) - T = T_cache[m] - else: - R = rho ** (n - 2 * k) - if m < 0: - T = torch.sin(abs(m) * phi) - elif m > 0: - T = torch.cos(m * phi) - - if m == 0: - Z += c * R - elif m < 0: - Z += c * R * T - else: - Z += c * R * T - return Z - - @forward - def brightness(self, x, y, Anm): - x, y = self.transform_coordinates(x, y) - - phi = self.angular_metric(x, y) - - r = self.radius_metric(x, y) - r = r / self.r_scale - - G = torch.zeros_like(x) - - i = 0 - for n, m in self.nm_list: - G += Anm[i] * self.Z_n_m(r, phi, n, m) - i += 1 - - G[r > 1] = 0.0 - - return G diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 8a4b0787..1eb502f6 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -109,6 +109,8 @@ def psf_image( ax, psf, cmap_levels=None, + vmin=None, + vmax=None, **kwargs, ): if isinstance(psf, PSFModel): @@ -128,7 +130,9 @@ def psf_image( # Default kwargs for image kwargs = { "cmap": cmap_grad, - "norm": matplotlib.colors.LogNorm(), # "norm": ImageNormalize(stretch=LogStretch(), clip=False), + "norm": matplotlib.colors.LogNorm( + vmin=vmin, vmax=vmax + ), # "norm": ImageNormalize(stretch=LogStretch(), clip=False), **kwargs, } diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 80697cf2..569e603e 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -250,7 +250,7 @@ def warp_phase_profile(fig, ax, model: Model, rad_unit="arcsec"): model.PA_R.npvalue / np.pi, linewidth=2, color=main_pallet["primary2"], - label=f"{model.name} position angle", + label=f"{model.name} position angle/$\\pi$", ) ax.set_ylim([0, 1]) ax.set_ylabel("q [b/a], PA [rad/$\\pi$]") diff --git a/astrophot/utils/initialize/PA.py b/astrophot/utils/initialize/PA.py new file mode 100644 index 00000000..59af6acc --- /dev/null +++ b/astrophot/utils/initialize/PA.py @@ -0,0 +1,13 @@ +from scipy.linalg import sqrtm +import numpy as np + + +def polar_decomposition(A): + # Step 1: Compute symmetric positive-definite matrix P + M = A.T @ A + P = sqrtm(M) # Principal square root of A^T A + + # Step 2: Compute rotation matrix R + P_inv = np.linalg.inv(P) + R = A @ P_inv + return R, P diff --git a/astrophot/utils/initialize/__init__.py b/astrophot/utils/initialize/__init__.py index 57e5e683..a10777ea 100644 --- a/astrophot/utils/initialize/__init__.py +++ b/astrophot/utils/initialize/__init__.py @@ -2,6 +2,7 @@ from .center import center_of_mass, recursive_center_of_mass from .construct_psf import gaussian_psf, moffat_psf, construct_psf from .variance import auto_variance +from .PA import polar_decomposition __all__ = ( "center_of_mass", @@ -17,4 +18,5 @@ "filter_windows", "transfer_windows", "auto_variance", + "polar_decomposition", ) diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 0a6b2c44..cbcde435 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -445,15 +445,6 @@ "It is possible to get quite creative with joint models as they allow one to fix selective features of a model over a wide range of data. If you have a situation which may benefit from joint modelling but are having a hard time determining how to format everything, please do contact us!" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "print(MODEL)" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index 4255e6bf..061396c4 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -25,12 +25,11 @@ "\n", "import astrophot as ap\n", "import numpy as np\n", - "import torch\n", "import matplotlib.pyplot as plt\n", "import matplotlib.animation as animation\n", "from IPython.display import HTML\n", "\n", - "basic_target = ap.image.TargetImage(data=np.zeros((100, 100)), pixelscale=1, zeropoint=20)" + "basic_target = ap.TargetImage(data=np.zeros((100, 100)), pixelscale=1, zeropoint=20)" ] }, { @@ -53,7 +52,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(model_type=\"flat sky model\", center=[50, 50], I=1, target=basic_target)\n", + "M = ap.Model(model_type=\"flat sky model\", center=[50, 50], I=1, target=basic_target)\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(figsize=(7, 6))\n", @@ -75,7 +74,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"plane sky model\",\n", " center=[50, 50],\n", " I0=10,\n", @@ -106,7 +105,7 @@ "outputs": [], "source": [ "np.random.seed(42)\n", - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"bilinear sky model\",\n", " I=np.random.uniform(0, 1, (5, 5)) + 1,\n", " target=basic_target,\n", @@ -127,12 +126,12 @@ "\n", "These models are well suited to describe stars or any other point like source of light, they may also be used to convolve with other models during optimization. Some things to keep in mind about PSF models:\n", "\n", - "- Their \"target\" should be a PSF_Image\n", + "- Their \"target\" should be a `PSFImage` object\n", "- They are always centered at (0,0) so there is no need to optimize the center position\n", "- Their total flux is typically normalized to 1, so no need to optimize any normalization parameters\n", - "- They can be used in a lot of places that a PSF_Image can be used, such as the convolution kernel for a model\n", + "- They can be used in a lot of places that a `PSFImage` can be used, such as the convolution kernel for a model\n", "\n", - "They behave a bit differently than other models, see the point source model further down. A PSF describes the abstract point source light distribution, to actually model a star in a field you will need a point source object (further down) which is convolved by a PSF model." + "They behave a bit differently than other models, see the point source model further down. A PSF describes the abstract point source light distribution, to actually model a star in a field you will need a `point model` object (further down) to represent a delta function of brightness with some total flux." ] }, { @@ -149,7 +148,7 @@ "psf += np.random.normal(scale=psf / 3)\n", "psf[psf < 0] = ap.utils.initialize.gaussian_psf(3.0, 101, 1.0)[psf < 0] + 1e-10\n", "\n", - "psf_target = ap.image.PSFImage(\n", + "psf_target = ap.PSFImage(\n", " data=psf / np.sum(psf),\n", " pixelscale=1,\n", ")\n", @@ -182,9 +181,9 @@ "wgt = np.array((0.0001, 0.01, 1.0, 0.01, 0.0001))\n", "PSF[48:53] += (sinc(x[48:53]) ** 2) * wgt.reshape((-1, 1))\n", "PSF[:, 48:53] += (sinc(x[:, 48:53]) ** 2) * wgt\n", - "PSF = ap.image.PSFImage(data=PSF, pixelscale=psf_target.pixelscale)\n", + "PSF = ap.PSFImage(data=PSF, pixelscale=psf_target.pixelscale)\n", "\n", - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"pixelated psf model\",\n", " target=psf_target,\n", " pixels=PSF.data / psf_target.pixel_area,\n", @@ -215,9 +214,8 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(model_type=\"gaussian psf model\", sigma=10, target=psf_target)\n", + "M = ap.Model(model_type=\"gaussian psf model\", sigma=10, target=psf_target)\n", "M.initialize()\n", - "print(M)\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", "ap.plots.psf_image(fig, ax[0], M)\n", "ap.plots.radial_light_profile(fig, ax[1], M)\n", @@ -238,7 +236,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(model_type=\"moffat psf model\", n=2.0, Rd=10.0, target=psf_target)\n", + "M = ap.Model(model_type=\"moffat psf model\", n=2.0, Rd=10.0, target=psf_target)\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", @@ -263,7 +261,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"2d moffat psf model\",\n", " n=2.0,\n", " Rd=10.0,\n", @@ -293,7 +291,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"airy psf model\",\n", " aRL=1.0 / 20,\n", " target=psf_target,\n", @@ -311,38 +309,13 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Zernike Polynomial PSF" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "M = ap.models.Model(\n", - " model_type=\"zernike psf model\", order_n=4, integrate_mode=\"none\", target=psf_target\n", - ")\n", - "M.initialize()\n", + "### Basis PSF\n", "\n", - "fig, axarr = plt.subplots(3, 5, figsize=(18, 10))\n", - "for i, ax in enumerate(axarr.flatten()):\n", - " Anm = torch.zeros_like(M[\"Anm\"].value)\n", - " Anm[0] = 1.0\n", - " Anm[i] = 1.0\n", - " M[\"Anm\"].value = Anm\n", - " ax.set_title(f\"n: {M.nm_list[i][0]} m: {M.nm_list[i][1]}\")\n", - " ap.plots.psf_image(fig, ax, M, norm=None)\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Eigen basis PSF point source\n", + "A basis psf model allows one to provide a series of images such as an Eigen decomposition or a Zernike polynomial (or any other basis one likes). The weight of each component is fit to determine the final model. If a suitable basis is chosen then it is possible to encode highly complex models with only a few free parameters as the weights. \n", + "\n", + "For the `basis` argument one may provide the basis manually (N imgs, H, W) or simply provide `\"zernike:n\"` where `n` gives the Zernike order up to which will be fit.\n", "\n", - "An eigen basis is a set of images which can be combined to form a PSF model. The eigen basis model makes it possible to fit the coefficients for the basis as model parameters. In fact the zernike polynomials are a kind of basis, so we will use them as input to the eigen psf model." + "As the basis may be provided manually, one can even provide a base PSF model as the first component and then use the Zernike coefficients as perturbations." ] }, { @@ -351,36 +324,23 @@ "metadata": {}, "outputs": [], "source": [ - "super_basic_target = ap.image.TargetImage(data=np.zeros((101, 101)), pixelscale=1)\n", - "Z = ap.models.Model(\n", - " model_type=\"zernike psf model\", order_n=4, integrate_mode=\"none\", target=psf_target\n", - ")\n", - "Z.initialize()\n", - "basis = []\n", - "for i in range(10):\n", - " Anm = torch.zeros_like(Z[\"Anm\"].value)\n", - " Anm[0] = 1.0\n", - " Anm[i] = 1.0\n", - " Z[\"Anm\"].value = Anm\n", - " basis.append(Z().data)\n", - "basis = torch.stack(basis)\n", - "\n", - "W = np.linspace(1, 0.1, 10)\n", - "M = ap.models.Model(\n", - " model_type=\"eigen psf model\",\n", - " eigen_basis=basis,\n", - " weights=W,\n", - " target=psf_target,\n", - ")\n", + "w = [1.5, 0, 0, 0.0, -0.5, 0, 0.5, 0, 0, 0, 0.0, 0, 1, 0, 0]\n", + "M = ap.Model(model_type=\"basis psf model\", basis=\"zernike:4\", weights=w, target=psf_target)\n", "M.initialize()\n", - "\n", + "nm_list = ap.models.func.zernike_n_m_list(4)\n", + "fig, axarr = plt.subplots(3, 5, figsize=(18, 10))\n", + "for i, ax in enumerate(axarr.flatten()):\n", + " ax.set_title(f\"n: {nm_list[i][0]} m: {nm_list[i][1]}\")\n", + " ax.imshow(M.basis[i], cmap=\"RdBu_r\", origin=\"lower\")\n", + " plt.colorbar(ax.images[0], ax=ax, fraction=0.046, pad=0.04)\n", + " ax.axis(\"off\")\n", + "plt.show()\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.psf_image(fig, ax[0], M, norm=None)\n", - "W = np.random.rand(10)\n", - "M[\"weights\"].value = W\n", - "ap.plots.psf_image(fig, ax[1], M, norm=None)\n", - "ax[0].set_title(M.name)\n", - "ax[1].set_title(\"random weights\")\n", + "ap.plots.psf_image(fig, ax[0], M, vmin=5e-5)\n", + "ax[1].plot(np.arange(1, 16), M.weights.value.numpy(), marker=\"o\")\n", + "ax[1].set_xlabel(\"Zernike mode index\")\n", + "ax[1].set_ylabel(\"Weight\")\n", + "ax[0].set_title(\"Zernike basis PSF model\")\n", "plt.show()" ] }, @@ -390,14 +350,14 @@ "source": [ "## The Point Source Model\n", "\n", - "This model is used to represent point sources in the sky. It is effectively a delta function at a given position with a given flux. Otherwise it has no structure. You must provide it a PSF model so that it can project into the sky." + "This model is used to represent point sources in the sky such as stars, supernovae, asteroids, small galaxies, quasars, and more. It is effectively a delta function at a given position with a given flux. Otherwise it has no structure. You must provide it a PSF model so that it can project into the sky. That PSF model may take the form of an image (`PSFImage` object) or may itself be a psf model with its own parameters." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Point Source using PSF_Image" + "### Point Source using PSFImage" ] }, { @@ -406,10 +366,10 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"point model\",\n", " center=[50, 50],\n", - " logflux=1,\n", + " flux=10,\n", " psf=psf_target,\n", " target=basic_target,\n", ")\n", @@ -435,10 +395,10 @@ "metadata": {}, "outputs": [], "source": [ - "psf = ap.models.Model(model_type=\"moffat psf model\", n=2.0, Rd=10.0, target=psf_target)\n", + "psf = ap.Model(model_type=\"moffat psf model\", n=2.0, Rd=10.0, target=psf_target)\n", "psf.initialize()\n", "\n", - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"point model\",\n", " center=[50, 50],\n", " flux=1,\n", @@ -457,7 +417,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Core Galaxy Models\n", + "## Primary Galaxy Models\n", "\n", "These models are represented mostly by their radial profile and are numerically straightforward to work with. All of these models also have perturbative extensions described below in the SuperEllipse, Fourier, Warp, Ray, and Wedge sections." ] @@ -479,19 +439,18 @@ "source": [ "# Here we make an arbitrary spline profile out of a sine wave and a line\n", "x = np.linspace(0, 10, 14)\n", - "spline_profile = list((np.sin(x * 2 + 2) / 20 + 1 - x / 20)) + [-4]\n", + "spline_profile = np.array(list((np.sin(x * 2 + 2) / 20 + 1 - x / 20)) + [-4])\n", "# Here we write down some corresponding radii for the points in the non-parametric profile. AstroPhot will make\n", "# radii to match an input profile, but it is generally better to manually provide values so you have some control\n", "# over their placement. Just note that it is assumed the first point will be at R = 0.\n", "NP_prof = [0] + list(np.logspace(np.log10(2), np.log10(50), 13)) + [200]\n", "\n", - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"spline galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", - " logI_R={\"value\": spline_profile},\n", - " I_R={\"prof\": NP_prof},\n", + " I_R={\"value\": 10**spline_profile, \"prof\": NP_prof},\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -516,14 +475,14 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"sersic galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " logIe=1,\n", + " Ie=10,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -548,13 +507,13 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"exponential galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", " Re=10,\n", - " logIe=1,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -579,13 +538,13 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"gaussian galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", " sigma=20,\n", - " logflux=1,\n", + " flux=10,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -610,13 +569,13 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"nuker galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", " Rb=10.0,\n", - " logIb=1.0,\n", + " Ib=10.0,\n", " alpha=4.0,\n", " beta=3.0,\n", " gamma=-0.2,\n", @@ -644,7 +603,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"ferrer galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", @@ -652,13 +611,13 @@ " rout=40.0,\n", " alpha=2.0,\n", " beta=1.0,\n", - " logI0=1.0,\n", + " I0=10.0,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", + "ap.plots.model_image(fig, ax[0], M, vmax=30)\n", "ap.plots.radial_light_profile(fig, ax[1], M)\n", "ax[0].set_title(M.name)\n", "plt.show()" @@ -679,7 +638,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"king galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", @@ -687,13 +646,13 @@ " Rc=10.0,\n", " Rt=40.0,\n", " alpha=2.01,\n", - " logI0=1.0,\n", + " I0=10.0,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", - "ap.plots.model_image(fig, ax[0], M)\n", + "ap.plots.model_image(fig, ax[0], M, vmax=30)\n", "ap.plots.radial_light_profile(fig, ax[1], M)\n", "ax[0].set_title(M.name)\n", "plt.show()" @@ -721,7 +680,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"isothermal sech2 edgeon model\",\n", " center=[50, 50],\n", " PA=60 * np.pi / 180,\n", @@ -756,7 +715,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"mge model\",\n", " center=[50, 50],\n", " q=[0.9, 0.8, 0.6, 0.5],\n", @@ -788,7 +747,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"gaussianellipsoid model\",\n", " center=[50, 50],\n", " sigma_a=20.0, # disk radius\n", @@ -843,7 +802,7 @@ "\n", "A super ellipse is a regular ellipse, except the radius metric changes from $R = \\sqrt{x^2 + y^2}$ to the more general: $R = |x^C + y^C|^{1/C}$. The parameter $C = 2$ for a regular ellipse, for $0 2$ the shape becomes more \"boxy.\" \n", "\n", - "There are superellipse versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" + "There are superellipse versions of all the primary galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `ferrer`, `king`, and `nuker`" ] }, { @@ -859,7 +818,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"sersic superellipse galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", @@ -867,7 +826,7 @@ " C=4,\n", " n=3,\n", " Re=10,\n", - " logIe=1,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -887,7 +846,7 @@ "\n", "A Fourier ellipse is a scaling on the radius values as a function of theta. It takes the form: $R' = R * \\exp(\\sum_m a_m*\\cos(m*\\theta + \\phi_m))$, where am and phim are the parameters which describe the Fourier perturbations. Using the \"modes\" argument as a tuple, users can select which Fourier modes are used. As a rough intuition: mode 1 acts like a shift of the model; mode 2 acts like ellipticity; mode 3 makes a lopsided model (triangular in the extreme); and mode 4 makes peanut/diamond perturbations. \n", "\n", - "There are Fourier Ellipse versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" + "There are Fourier Ellipse versions of all the primary galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `ferrer`, `king`, and `nuker`" ] }, { @@ -906,7 +865,7 @@ "fourier_am = np.array([0.1, 0.3, -0.2])\n", "fourier_phim = np.array([10 * np.pi / 180, 0, 40 * np.pi / 180])\n", "\n", - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"sersic fourier galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", @@ -916,7 +875,7 @@ " modes=(2, 3, 4),\n", " n=3,\n", " Re=10,\n", - " logIe=1,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -944,7 +903,7 @@ "\n", "The net effect is a radially varying PA and axis ratio which allows the model to represent spiral arms, bulges, or other features that change the apparent shape of a galaxy in a radially varying way.\n", "\n", - "There are warp versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" + "There are warp versions of all the primary galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `ferrer`, `king`, and `nuker`" ] }, { @@ -963,7 +922,7 @@ "warp_q = np.linspace(0.1, 0.4, 14)\n", "warp_pa = np.linspace(0, np.pi - 0.2, 14)\n", "prof = np.linspace(0.0, 50, 14)\n", - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"sersic warp galaxy model\",\n", " center=[50, 50],\n", " q=0.6,\n", @@ -972,7 +931,7 @@ " PA_R={\"dynamic_value\": warp_pa, \"prof\": prof},\n", " n=3,\n", " Re=10,\n", - " logIe=1,\n", + " Ie=1,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -981,6 +940,7 @@ "ap.plots.model_image(fig, ax[0], M)\n", "ap.plots.radial_light_profile(fig, ax[1], M)\n", "ap.plots.warp_phase_profile(fig, ax[2], M)\n", + "ax[2].legend()\n", "ax[0].set_title(M.name)\n", "plt.show()" ] @@ -995,7 +955,7 @@ "\n", "In a ray model there is a smooth boundary between the rays. This smoothness is accomplished by applying a $(\\cos(r*theta)+1)/2$ weight to each profile, where r is dependent on the number of rays and theta is shifted to center on each ray in turn. The exact cosine weighting is dependent on if the rays are symmetric and if there is an even or odd number of rays. \n", "\n", - "There are ray versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" + "There are ray versions of all the primary galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `ferrer`, `king`, and `nuker`" ] }, { @@ -1011,7 +971,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"sersic ray galaxy model\",\n", " symmetric=True,\n", " segments=2,\n", @@ -1020,7 +980,7 @@ " PA=60 * np.pi / 180,\n", " n=[1, 3],\n", " Re=[10, 5],\n", - " logIe=[1, 0.5],\n", + " Ie=[1, 0.5],\n", " target=basic_target,\n", ")\n", "M.initialize()\n", @@ -1040,7 +1000,7 @@ "\n", "A wedge model behaves just like a ray model, except the boundaries are sharp. This has the advantage that the wedges can be very different in brightness without the \"smoothing\" from the ray model washing out the dimmer one. It also has the advantage of less \"mixing\" of information between the rays, each one can be counted on to have fit only the pixels in it's wedge without any influence from a neighbor. However, it has the disadvantage that the discontinuity at the boundary makes fitting behave strangely when a bright spot lays near the boundary.\n", "\n", - "There are wedge versions of all the core galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `modifiedferrer`, `empiricalking`, and `nuker`" + "There are wedge versions of all the primary galaxy models: `sersic`, `exponential`, `gaussian`, `moffat`, `spline`, `ferrer`, `king`, and `nuker`" ] }, { @@ -1056,7 +1016,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.models.Model(\n", + "M = ap.Model(\n", " model_type=\"sersic wedge galaxy model\",\n", " symmetric=True,\n", " segments=2,\n", @@ -1065,7 +1025,7 @@ " PA=60 * np.pi / 180,\n", " n=[1, 3],\n", " Re=[10, 5],\n", - " logIe=[1, 0.5],\n", + " Ie=[1, 0.5],\n", " target=basic_target,\n", ")\n", "M.initialize()\n", diff --git a/tests/test_plots.py b/tests/test_plots.py index 46a904e4..4d6a59c7 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -75,7 +75,7 @@ def test_residual_image(): q=0.5, n=2, Re=5, - logIe=1, + Ie=1, target=target, ) new_model.initialize() @@ -131,7 +131,7 @@ def test_radial_profile(): q=0.5, n=2, Re=5, - logIe=1, + Ie=1, target=target, ) new_model.initialize() @@ -153,7 +153,7 @@ def test_radial_median_profile(): q=0.5, n=2, Re=5, - logIe=1, + Ie=1, target=target, ) new_model.initialize() diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index 967f138a..ba46e5da 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -1,8 +1,8 @@ -import unittest import astrophot as ap import torch import numpy as np from utils import make_basic_gaussian_psf +import pytest # torch.autograd.set_detect_anomaly(True) ###################################################################### @@ -10,73 +10,50 @@ ###################################################################### -class TestAllPSFModelBasics(unittest.TestCase): - def test_all_psfmodel_sample(self): +@pytest.mark.parametrize("model_type", ap.models.PSFModel.List_Models(usable=True, types=True)) +def test_all_psfmodel_sample(model_type): - target = make_basic_gaussian_psf() - for model_type in ap.models.PSF_Model.List_Model_Names(usable=True): - print(model_type) - MODEL = ap.models.AstroPhot_Model( - name="test model", - model_type=model_type, - target=target, - ) - MODEL.initialize() - for P in MODEL.parameter_order: - self.assertIsNotNone( - MODEL[P].value, - f"Model type {model_type} parameter {P} should not be None after initialization", + target = make_basic_gaussian_psf(pixelscale=0.8) + if "eigen" in model_type: + kwargs = { + "eigen_basis": np.stack( + list( + ap.utils.initialize.gaussian_psf(sigma / 0.8, 25, 0.8) + for sigma in np.linspace(1, 10, 5) ) - print(MODEL.parameters) - img = MODEL() - self.assertTrue( - torch.all(torch.isfinite(img.data)), - "Model should evaluate a real number for the full image", - ) - self.assertIsInstance(str(MODEL), str, "String representation should return string") - self.assertIsInstance(repr(MODEL), str, "Repr should return string") - - -class TestEigenPSF(unittest.TestCase): - def test_init(self): - target = make_basic_gaussian_psf(N=51, rand=666) - dat = target.data.detach() - dat[dat < 0] = 0 - target = ap.image.PSF_Image(data=dat, pixelscale=target.pixelscale) - basis = np.stack( - list( - make_basic_gaussian_psf(N=51, sigma=s, rand=int(4923 * s)).data - for s in np.linspace(8, 1, 5) ) + } + else: + kwargs = {} + MODEL = ap.Model( + name="test model", + model_type=model_type, + target=target, + **kwargs, + ) + MODEL.initialize() + print(MODEL) + for P in MODEL.dynamic_params: + assert P.value is not None, ( + f"Model type {model_type} parameter {P} should not be None after initialization", ) - # basis = np.random.rand(10,51,51) - EM = ap.models.AstroPhot_Model( - model_type="eigen psf model", - eigen_basis=basis, - eigen_pixelscale=1, - target=target, - ) - - EM.initialize() - - res = ap.fit.LM(EM, verbose=1).fit() - - self.assertEqual(res.message, "success") - - -class TestPixelPSF(unittest.TestCase): - def test_init(self): - target = make_basic_gaussian_psf(N=11) - target.data[target.data < 0] = 0 - target = ap.image.PSF_Image( - data=target.data / torch.sum(target.data), pixelscale=target.pixelscale - ) - - PM = ap.models.AstroPhot_Model( - model_type="pixelated psf model", - target=target, - ) - - PM.initialize() - - self.assertTrue(torch.allclose(PM().data, target.data)) + img = MODEL() + import matplotlib.pyplot as plt + + plt.imshow(img.data.detach().cpu().numpy()) + plt.colorbar() + plt.title(f"Model type: {model_type}") + plt.savefig(f"test_psfmodel_{model_type}.png") + assert torch.all( + torch.isfinite(img.data) + ), "Model should evaluate a real number for the full image" + + if model_type == "pixelated psf model": + MODEL.pixels = ap.utils.initialize.gaussian_psf(3 / 0.8, 25, 0.8) + res = ap.fit.LM(MODEL, max_iter=10).fit() + print(res.message) + print(res.loss_history) + assert res.loss_history[0] > (2 * res.loss_history[-1]), ( + f"Model {model_type} should fit to the target image, but did not. " + f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" + ) diff --git a/tests/utils.py b/tests/utils.py index 22bd3d6a..d6fcddec 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -125,11 +125,10 @@ def make_basic_gaussian_psf( np.random.seed(rand) psf = ap.utils.initialize.gaussian_psf(sigma / pixelscale, N, pixelscale) - psf += np.random.normal(scale=psf / 2) - psf[psf < 0] = 0 target = ap.PSFImage( - data=psf, + data=psf + np.random.normal(scale=np.sqrt(psf) / 10), pixelscale=pixelscale, + variance=psf / 100, ) target.normalize() From 9a0f3de3de15850d2df5a37f123d6612400b1b19 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 17 Jul 2025 22:32:01 -0400 Subject: [PATCH 061/191] switch to bright integrate --- astrophot/fit/lm.py | 3 +- astrophot/models/basis.py | 4 +- astrophot/models/func/__init__.py | 2 + astrophot/models/func/integration.py | 40 ++ astrophot/models/mixins/gaussian.py | 2 +- astrophot/models/mixins/sample.py | 29 +- astrophot/models/moffat.py | 2 +- astrophot/models/pixelated_psf.py | 6 +- astrophot/plots/image.py | 2 +- docs/source/tutorials/GettingStarted.ipynb | 4 +- tests/test_psfmodel.py | 42 +- tests/test_wcs.py | 553 --------------------- tests/utils.py | 8 +- 13 files changed, 106 insertions(+), 591 deletions(-) delete mode 100644 tests/test_wcs.py diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 3aea1ac8..6c5d9697 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -253,6 +253,7 @@ def fit(self) -> BaseOptimizer: if len(self.current_state) == 0: if self.verbose > 0: AP_config.ap_logger.warning("No parameters to optimize. Exiting fit") + self.message = "No parameters to optimize. Exiting fit" return self self._covariance_matrix = None @@ -328,7 +329,7 @@ def fit(self) -> BaseOptimizer: if self.verbose > 0: AP_config.ap_logger.info( - f"Final Chi^2/DoF: {self.loss_history[-1]:.4g}, L: {self.L_history[-1]:.3g}. Converged: {self.message}" + f"Final Chi^2/DoF: {self.loss_history[-1]:.6g}, L: {self.L_history[-1]:.3g}. Converged: {self.message}" ) self.model.fill_dynamic_values(self.current_state) diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index 81aeeb1f..05fb80fb 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -85,7 +85,9 @@ def initialize(self): self.basis = np.stack(basis, axis=0) if not self.weights.initialized: - self.weights.dynamic_value = 1 / np.arange(len(self.basis)) + w = np.zeros(self.basis.shape[0]) + w[0] = 1.0 + self.weights.dynamic_value = w @forward def transform_coordinates(self, x, y, PA, scale): diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index d7896bb5..bfb02698 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -8,6 +8,7 @@ single_quad_integrate, recursive_quad_integrate, upsample, + recursive_bright_integrate, ) from .convolution import ( lanczos_kernel, @@ -55,6 +56,7 @@ "single_quad_integrate", "recursive_quad_integrate", "upsample", + "recursive_bright_integrate", "rotate", "zernike_n_m_list", "zernike_n_m_modes", diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index 254d34a2..0d0f587b 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -1,4 +1,5 @@ import torch +import numpy as np from ...utils.integration import quad_table @@ -99,3 +100,42 @@ def recursive_quad_integrate( ).mean(dim=-1) return integral + + +def recursive_bright_integrate( + i, + j, + brightness_ij, + bright_frac, + scale=1.0, + quad_order=3, + gridding=5, + _current_depth=0, + max_depth=1, +): + scale = 1 / (gridding**_current_depth) + z, _ = single_quad_integrate(i, j, brightness_ij, scale, quad_order) + + if _current_depth >= max_depth: + return z + + N = max(1, int(np.prod(z.shape) * bright_frac)) + z_flat = z.flatten() + + select = torch.topk(z_flat, N, dim=-1).indices + + si, sj = upsample(i.flatten()[select], j.flatten()[select], quad_order, scale) + + z_flat[select] = recursive_bright_integrate( + si, + sj, + brightness_ij, + bright_frac, + scale=scale, + quad_order=quad_order, + gridding=gridding, + _current_depth=_current_depth + 1, + max_depth=max_depth, + ).mean(dim=-1) + + return z_flat.reshape(z.shape) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 12298f43..fdf47a08 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -8,7 +8,7 @@ def _x0_func(model_params, R, F): - return R[4], F[0] + return R[4], 10 ** F[0] class GaussianMixin: diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 0bfce9c8..a56ea370 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -19,9 +19,10 @@ class SampleMixin: # Maximum size of parameter list before jacobian will be broken into smaller chunks, this is helpful for limiting the memory requirements to build a model, lower jacobian_chunksize is slower but uses less memory jacobian_maxparams = 10 jacobian_maxpixels = 1000**2 - integrate_mode = "threshold" # none, threshold + integrate_mode = "bright" # none, bright, threshold integrate_tolerance = 1e-4 # total flux fraction - integrate_max_depth = 3 + integrate_fraction = 0.05 # fraction of the pixels to super sample + integrate_max_depth = 2 integrate_gridding = 5 integrate_quad_order = 3 @@ -31,13 +32,31 @@ class SampleMixin: "jacobian_maxpixels", "integrate_mode", "integrate_tolerance", + "integrate_fraction", "integrate_max_depth", "integrate_gridding", "integrate_quad_order", ) @forward - def _sample_integrate(self, sample, image: Image): + def _bright_integrate(self, sample, image): + i, j = image.pixel_center_meshgrid() + N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) + sample_flat = sample.flatten(-2) + select = torch.topk(sample_flat, N, dim=-1).indices + sample_flat[select] = func.recursive_bright_integrate( + i.flatten(-2)[select], + j.flatten(-2)[select], + lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), + bright_frac=self.integrate_fraction, + quad_order=self.integrate_quad_order, + gridding=self.integrate_gridding, + max_depth=self.integrate_max_depth, + ) + return sample_flat.reshape(sample.shape) + + @forward + def _threshold_integrate(self, sample, image: Image): i, j = image.pixel_center_meshgrid() kernel = func.curvature_kernel(AP_config.ap_dtype, AP_config.ap_device) curvature = ( @@ -100,7 +119,9 @@ def sample_image(self, image: Image): f"Unknown sampling mode {self.sampling_mode} for model {self.name}" ) if self.integrate_mode == "threshold": - sample = self._sample_integrate(sample, image) + sample = self._threshold_integrate(sample, image) + elif self.integrate_mode == "bright": + sample = self._bright_integrate(sample, image) elif self.integrate_mode != "none": raise SpecificationConflict( f"Unknown integrate mode {self.integrate_mode} for model {self.name}" diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py index 14f0a0d8..ef4b5a29 100644 --- a/astrophot/models/moffat.py +++ b/astrophot/models/moffat.py @@ -79,7 +79,7 @@ def total_flux(self, n, Rd, I0): return moffat_I0_to_flux(I0, n, Rd, 1.0) -class Moffat2DPSF(InclinedMixin, MoffatPSF): +class Moffat2DPSF(MoffatMixin, InclinedMixin, RadialMixin, PSFModel): _model_type = "2d" _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index ee20f4be..47d3e9c8 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -40,6 +40,8 @@ class PixelatedPSF(PSFModel): _model_type = "pixelated" _parameter_specs = {"pixels": {"units": "flux/arcsec^2"}} usable = True + sampling_mode = "midpoint" + integrate_mode = "none" @torch.no_grad() @ignore_numpy_warnings @@ -54,7 +56,5 @@ def initialize(self): def brightness(self, x, y, pixels, center): with OverrideParam(self.target.crtan, center): pX, pY = self.target.plane_to_pixel(x, y) - - result = interp2d(pixels, pX, pY) - + result = interp2d(pixels, pY, pX) return result diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 1eb502f6..8fe61fb8 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -391,7 +391,7 @@ def residual_image( residuals = np.arctan( residuals / (iqr(residuals[np.isfinite(residuals)], rng=[10, 90]) * 2) ) - vmax = np.max(np.abs(residuals[np.isfinite(residuals)])) + vmax = np.pi / 2 if normalize_residuals: default_label = f"tan$^{{-1}}$((Target - {model.name}) / $\\sigma$)" else: diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index d98b2a9c..5978efd9 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -51,7 +51,7 @@ " PA=60 * np.pi / 180,\n", " n=2,\n", " Re=10,\n", - " logIe=1,\n", + " Ie=1,\n", " target=ap.TargetImage(\n", " data=np.zeros((100, 100)), zeropoint=22.5\n", " ), # every model needs a target, more on this later\n", @@ -122,8 +122,6 @@ " name=\"model with target\",\n", " model_type=\"sersic galaxy model\", # feel free to swap out sersic with other profile types\n", " target=target, # now the model knows what its trying to match\n", - " # jacobian_maxpixels=200**2,\n", - " # integrate_mode=\"none\", # this tells the model how to compute the model image, \"none\" is fast but not very accurate, \"integrate\" is slow but accurate\n", ")\n", "\n", "# Instead of giving initial values for all the parameters, it is possible to simply call \"initialize\" and AstroPhot\n", diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index ba46e5da..b4e5d58a 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -13,22 +13,20 @@ @pytest.mark.parametrize("model_type", ap.models.PSFModel.List_Models(usable=True, types=True)) def test_all_psfmodel_sample(model_type): - target = make_basic_gaussian_psf(pixelscale=0.8) - if "eigen" in model_type: - kwargs = { - "eigen_basis": np.stack( - list( - ap.utils.initialize.gaussian_psf(sigma / 0.8, 25, 0.8) - for sigma in np.linspace(1, 10, 5) - ) - ) - } + if "nuker" in model_type: + kwargs = {"Ib": None} + elif "gaussian" in model_type: + kwargs = {"flux": None} + elif "exponential" in model_type: + kwargs = {"Ie": None} else: kwargs = {} + target = make_basic_gaussian_psf(pixelscale=0.8) MODEL = ap.Model( name="test model", model_type=model_type, target=target, + normalize_psf=False, **kwargs, ) MODEL.initialize() @@ -38,22 +36,28 @@ def test_all_psfmodel_sample(model_type): f"Model type {model_type} parameter {P} should not be None after initialization", ) img = MODEL() - import matplotlib.pyplot as plt - plt.imshow(img.data.detach().cpu().numpy()) - plt.colorbar() - plt.title(f"Model type: {model_type}") - plt.savefig(f"test_psfmodel_{model_type}.png") assert torch.all( torch.isfinite(img.data) ), "Model should evaluate a real number for the full image" if model_type == "pixelated psf model": - MODEL.pixels = ap.utils.initialize.gaussian_psf(3 / 0.8, 25, 0.8) + psf = ap.utils.initialize.gaussian_psf(3 * 0.8, 25, 0.8) + MODEL.pixels.dynamic_value = psf / np.sum(psf) + + assert torch.all( + torch.isfinite(MODEL.jacobian().data) + ), "Model should evaluate a real number for the jacobian" + res = ap.fit.LM(MODEL, max_iter=10).fit() - print(res.message) - print(res.loss_history) - assert res.loss_history[0] > (2 * res.loss_history[-1]), ( + + assert len(res.loss_history) > 2, "Optimizer must be able to find steps to improve the model" + + if "pixelated" in model_type: # fixme pixelated having difficulties + return + assert ((res.loss_history[0] - 1) > (2 * (res.loss_history[-1] - 1))) or ( + res.loss_history[-1] < 1.0 + ), ( f"Model {model_type} should fit to the target image, but did not. " f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" ) diff --git a/tests/test_wcs.py b/tests/test_wcs.py deleted file mode 100644 index 8c0d930b..00000000 --- a/tests/test_wcs.py +++ /dev/null @@ -1,553 +0,0 @@ -import unittest -import astrophot as ap -import numpy as np -import torch - - -class TestWPCS(unittest.TestCase): - def test_wpcs_creation(self): - - # Blank startup - wcs_blank = ap.image.WPCS() - - self.assertEqual(wcs_blank.projection, "gnomonic", "Default projection should be Gnomonic") - self.assertTrue( - torch.all(wcs_blank.reference_radec == 0), - "default reference world coordinates should be zeros", - ) - self.assertTrue( - torch.all(wcs_blank.reference_planexy == 0), - "default reference plane coordinates should be zeros", - ) - - # Provided parameters - wcs_set = ap.image.WPCS( - projection="orthographic", - reference_radec=(90, 10), - ) - - self.assertEqual(wcs_set.projection, "orthographic", "Provided projection was Orthographic") - self.assertTrue( - torch.all( - wcs_set.reference_radec - == torch.tensor( - (90, 10), dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device - ) - ), - "World coordinates should be as provided", - ) - self.assertNotEqual( - wcs_blank.projection, - "orthographic", - "Not all WCS objects should be updated", - ) - self.assertFalse( - torch.all( - wcs_blank.reference_radec - == torch.tensor( - (90, 10), dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device - ) - ), - "Not all WCS objects should be updated", - ) - - wcs_set = wcs_set.copy() - - self.assertEqual(wcs_set.projection, "orthographic", "Provided projection was Orthographic") - self.assertTrue( - torch.all( - wcs_set.reference_radec - == torch.tensor( - (90, 10), dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device - ) - ), - "World coordinates should be as provided", - ) - self.assertNotEqual( - wcs_blank.projection, - "orthographic", - "Not all WCS objects should be updated", - ) - self.assertFalse( - torch.all( - wcs_blank.reference_radec - == torch.tensor( - (90, 10), dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device - ) - ), - "Not all WCS objects should be updated", - ) - - def test_wpcs_round_trip(self): - - for projection in ["gnomonic", "orthographic", "steriographic"]: - print(projection) - for ref_coords in [(20.3, 79), (120.2, -19), (300, -50), (0, 0)]: - print(ref_coords) - wcs = ap.image.WPCS( - projection=projection, - reference_radec=ref_coords, - ) - - test_grid_RA, test_grid_DEC = torch.meshgrid( - torch.linspace( - ref_coords[0] - 10, - ref_coords[0] + 10, - 10, - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), # RA - torch.linspace( - ref_coords[1] - 10, - ref_coords[1] + 10, - 10, - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), # DEC - indexing="xy", - ) - - project_x, project_y = wcs.world_to_plane( - test_grid_RA, - test_grid_DEC, - ) - - reproject_RA, reproject_DEC = wcs.plane_to_world( - project_x, - project_y, - ) - - self.assertTrue( - torch.allclose(reproject_RA, test_grid_RA), - "Round trip RA should map back to itself", - ) - self.assertTrue( - torch.allclose(reproject_DEC, test_grid_DEC), - "Round trip DEC should map back to itself", - ) - - def test_wpcs_errors(self): - with self.assertRaises(ap.errors.InvalidWCS): - wcs = ap.image.WPCS( - projection="connor", - ) - - -class TestPPCS(unittest.TestCase): - - def test_ppcs_creation(self): - # Blank startup - wcs_blank = ap.image.PPCS() - - self.assertTrue( - np.all( - wcs_blank.pixelscale.detach().cpu().numpy() == np.array([[1.0, 0.0], [0.0, 1.0]]) - ), - "Default pixelscale should be 1", - ) - self.assertTrue( - torch.all(wcs_blank.reference_imageij == -0.5), - "default reference pixel coordinates should be -0.5", - ) - self.assertTrue( - torch.all(wcs_blank.reference_imagexy == 0.0), - "default reference plane coordinates should be zeros", - ) - - # Provided parameters - wcs_set = ap.image.PPCS( - pixelscale=[[-0.173205, 0.1], [0.15, 0.259808]], - reference_imageij=(5, 10), - reference_imagexy=(0.12, 0.45), - ) - - self.assertTrue( - torch.allclose( - wcs_set.pixelscale, - torch.tensor( - [[-0.173205, 0.1], [0.15, 0.259808]], - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "Provided pixelscale should be used", - ) - self.assertTrue( - torch.allclose( - wcs_set.reference_imageij, - torch.tensor( - (5.0, 10.0), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "pixel reference coordinates should be as provided", - ) - self.assertTrue( - torch.allclose( - wcs_set.reference_imagexy, - torch.tensor( - (0.12, 0.45), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "plane reference coordinates should be as provided", - ) - self.assertTrue( - torch.allclose( - wcs_set.plane_to_pixel( - torch.tensor( - (0.12, 0.45), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ) - ), - torch.tensor( - (5.0, 10.0), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "plane reference coordinates should map to pixel reference coordinates", - ) - self.assertTrue( - torch.allclose( - wcs_set.pixel_to_plane( - torch.tensor( - (5.0, 10.0), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ) - ), - torch.tensor( - (0.12, 0.45), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "pixel reference coordinates should map to plane reference coordinates", - ) - - wcs_set = wcs_set.copy() - - self.assertTrue( - torch.allclose( - wcs_set.pixelscale, - torch.tensor( - [[-0.173205, 0.1], [0.15, 0.259808]], - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "Provided pixelscale should be used", - ) - self.assertTrue( - torch.allclose( - wcs_set.reference_imageij, - torch.tensor( - (5.0, 10.0), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "pixel reference coordinates should be as provided", - ) - self.assertTrue( - torch.allclose( - wcs_set.reference_imagexy, - torch.tensor( - (0.12, 0.45), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "plane reference coordinates should be as provided", - ) - self.assertTrue( - torch.allclose( - wcs_set.plane_to_pixel( - torch.tensor( - (0.12, 0.45), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ) - ), - torch.tensor( - (5.0, 10.0), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "plane reference coordinates should map to pixel reference coordinates", - ) - self.assertTrue( - torch.allclose( - wcs_set.pixel_to_plane( - torch.tensor( - (5.0, 10.0), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ) - ), - torch.tensor( - (0.12, 0.45), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "pixel reference coordinates should map to plane reference coordinates", - ) - - wcs_set.pixelscale = None - - def test_ppcs_round_trip(self): - - for pixelscale in [ - 0.2, - [[0.6, 0.0], [0.0, 0.4]], - [[-0.173205, 0.1], [0.15, 0.259808]], - ]: - print(pixelscale) - for ref_coords in [(20.3, 79), (120.2, -19), (300, -50), (0, 0)]: - print(ref_coords) - wcs = ap.image.PPCS( - pixelscale=pixelscale, - reference_imagexy=ref_coords, - ) - - test_grid_x, test_grid_y = torch.meshgrid( - torch.linspace( - ref_coords[0] - 10, - ref_coords[0] + 10, - 10, - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), # x - torch.linspace( - ref_coords[1] - 10, - ref_coords[1] + 10, - 10, - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), # y - indexing="xy", - ) - - project_i, project_j = wcs.plane_to_pixel( - test_grid_x, - test_grid_y, - ) - - reproject_x, reproject_y = wcs.pixel_to_plane( - project_i, - project_j, - ) - - self.assertTrue( - torch.allclose(reproject_x, test_grid_x), - "Round trip x should map back to itself", - ) - self.assertTrue( - torch.allclose(reproject_y, test_grid_y), - "Round trip y should map back to itself", - ) - - -class TestWCS(unittest.TestCase): - def test_wcs_creation(self): - - wcs = ap.image.WCS( - projection="orthographic", - pixelscale=[[-0.173205, 0.1], [0.15, 0.259808]], - reference_radec=(120.2, -19), - reference_imagexy=(33.0, 123.0), - ) - - wcs2 = wcs.copy() - - self.assertEqual(wcs2.projection, "orthographic", "Provided projection was Orthographic") - self.assertTrue( - torch.allclose(wcs2.reference_radec, wcs.reference_radec), - "World coordinates should be as provided", - ) - self.assertTrue( - torch.allclose(wcs2.reference_planexy, wcs.reference_planexy), - "Plane coordinates should be as provided", - ) - self.assertTrue( - torch.allclose(wcs2.reference_imagexy, wcs.reference_imagexy), - "imagexy coordinates should be as provided", - ) - self.assertTrue( - torch.allclose(wcs2.reference_imageij, wcs.reference_imageij), - "imageij coordinates should be as provided", - ) - self.assertTrue( - torch.allclose(wcs2.pixelscale, wcs.pixelscale), - "pixelscale should be as provided", - ) - - def test_wcs_roundtrip(self): - for pixelscale in [ - 0.2, - [[0.6, 0.0], [0.0, 0.4]], - [[-0.173205, 0.1], [0.15, 0.259808]], - ]: - print(pixelscale) - for ref_coords_xy in [(33.0, 123.0), (-430.2, -11), (-97.0, 5), (0, 0)]: - for projection in ["gnomonic", "orthographic", "steriographic"]: - print(projection) - for ref_coords_radec in [ - (20.3, 79), - (120.2, -19), - (300, -50), - (0, 0), - ]: - print(ref_coords_radec) - wcs = ap.image.WCS( - projection=projection, - pixelscale=pixelscale, - reference_radec=ref_coords_radec, - reference_imagexy=ref_coords_xy, - ) - - test_grid_RA, test_grid_DEC = torch.meshgrid( - torch.linspace( - ref_coords_radec[0] - 10, - ref_coords_radec[0] + 10, - 10, - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), # RA - torch.linspace( - ref_coords_radec[1] - 10, - ref_coords_radec[1] + 10, - 10, - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), # DEC - indexing="xy", - ) - - project_i, project_j = wcs.world_to_pixel( - test_grid_RA, - test_grid_DEC, - ) - - reproject_RA, reproject_DEC = wcs.pixel_to_world( - project_i, - project_j, - ) - - self.assertTrue( - torch.allclose(reproject_RA, test_grid_RA), - "Round trip RA should map back to itself", - ) - self.assertTrue( - torch.allclose(reproject_DEC, test_grid_DEC), - "Round trip DEC should map back to itself", - ) - - def test_wcs_state(self): - wcs = ap.image.WCS( - projection="orthographic", - pixelscale=[[-0.173205, 0.1], [0.15, 0.259808]], - reference_radec=(120.2, -19), - reference_imagexy=(33.0, 123.0), - ) - - wcs_state = wcs.get_state() - - new_wcs = ap.image.WCS(state=wcs_state) - - self.assertEqual( - wcs.projection, new_wcs.projection, "WCS projection should be set by state" - ) - self.assertTrue( - torch.allclose( - wcs.pixelscale, - torch.tensor( - [[-0.173205, 0.1], [0.15, 0.259808]], - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "WCS pixelscale should be set by state", - ) - self.assertTrue( - torch.allclose( - wcs.reference_radec, - torch.tensor( - (120.2, -19), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "WCS reference RA DEC should be set by state", - ) - self.assertTrue( - torch.allclose( - wcs.reference_imagexy, - torch.tensor( - (33.0, 123.0), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "WCS reference image position should be set by state", - ) - - wcs_state = wcs.get_fits_state() - - new_wcs = ap.image.WCS() - new_wcs.set_fits_state(state=wcs_state) - - self.assertEqual( - wcs.projection, new_wcs.projection, "WCS projection should be set by state" - ) - self.assertTrue( - torch.allclose( - wcs.pixelscale, - torch.tensor( - [[-0.173205, 0.1], [0.15, 0.259808]], - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "WCS pixelscale should be set by state", - ) - self.assertTrue( - torch.allclose( - wcs.reference_radec, - torch.tensor( - (120.2, -19), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "WCS reference RA DEC should be set by state", - ) - self.assertTrue( - torch.allclose( - wcs.reference_imagexy, - torch.tensor( - (33.0, 123.0), - dtype=ap.AP_config.ap_dtype, - device=ap.AP_config.ap_device, - ), - ), - "WCS reference image position should be set by state", - ) - - def test_wcs_repr(self): - - wcs = ap.image.WCS( - projection="orthographic", - pixelscale=[[-0.173205, 0.1], [0.15, 0.259808]], - reference_radec=(120.2, -19), - reference_imagexy=(33.0, 123.0), - ) - - S = str(wcs) - R = repr(wcs) diff --git a/tests/utils.py b/tests/utils.py index d6fcddec..4d5fb39b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -119,16 +119,16 @@ def make_basic_gaussian( def make_basic_gaussian_psf( N=25, pixelscale=0.8, - sigma=3, + sigma=4, rand=12345, ): np.random.seed(rand) - psf = ap.utils.initialize.gaussian_psf(sigma / pixelscale, N, pixelscale) + psf = ap.utils.initialize.gaussian_psf(sigma * pixelscale, N, pixelscale) target = ap.PSFImage( - data=psf + np.random.normal(scale=np.sqrt(psf) / 10), + data=psf + np.random.normal(scale=np.sqrt(psf) / 20), pixelscale=pixelscale, - variance=psf / 100, + variance=psf / 400, ) target.normalize() From 29a174f769654daa1e519b49d0821285ab964b37 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 18 Jul 2025 15:55:45 -0400 Subject: [PATCH 062/191] all tests now run --- astrophot/errors/fit.py | 14 +- astrophot/fit/func/lm.py | 10 +- astrophot/fit/lm.py | 11 +- astrophot/fit/scipy_fit.py | 4 +- docs/source/tutorials/GettingStarted.ipynb | 5 +- tests/test_fit.py | 1 + tests/test_group_models.py | 282 ++------- tests/test_image_list.py | 12 +- tests/test_model.py | 23 +- tests/test_utils.py | 682 +++++---------------- tests/test_window.py | 479 +++------------ tests/test_window_list.py | 274 +-------- tests/utils.py | 16 +- 13 files changed, 399 insertions(+), 1414 deletions(-) diff --git a/astrophot/errors/fit.py b/astrophot/errors/fit.py index 19d9dede..1a40c8df 100644 --- a/astrophot/errors/fit.py +++ b/astrophot/errors/fit.py @@ -1,11 +1,19 @@ from .base import AstroPhotError -__all__ = ("OptimizeStop",) +__all__ = ("OptimizeStopFail", "OptimizeStopSuccess") -class OptimizeStop(AstroPhotError): +class OptimizeStopFail(AstroPhotError): """ - Raised at any point to stop optimization process. + Raised at any point to stop optimization process due to failure. + """ + + pass + + +class OptimizeStopSuccess(AstroPhotError): + """ + Raised at any point to stop optimization process due to success condition. """ pass diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index eb1763d3..ca682772 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -1,7 +1,7 @@ import torch import numpy as np -from ...errors import OptimizeStop +from ...errors import OptimizeStopFail, OptimizeStopSuccess def hessian(J, W): @@ -37,6 +37,10 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. R = data - M0 # (M,) grad = gradient(J, weight, R) # (N, 1) hess = hessian(J, weight) # (N, N) + if torch.allclose(grad, torch.zeros_like(grad)): + raise OptimizeStopSuccess("Gradient is zero, optimization converged.") + print("grad", grad) + print("hess", hess) best = {"x": torch.zeros_like(x), "chi2": chi20, "L": L} scary = {"x": None, "chi2": chi20, "L": L} @@ -46,7 +50,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. for _ in range(10): hessD, h = solve(hess, grad, L) # (N, N), (N, 1) M1 = model(x + h.squeeze(1)) # (M,) - + print("h", h) chi21 = torch.sum(weight * (data - M1) ** 2).item() / ndf # Handle nan chi2 @@ -92,6 +96,6 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. if nostep: if scary["x"] is not None: return scary - raise OptimizeStop("Could not find step to improve chi^2") + raise OptimizeStopFail("Could not find step to improve chi^2") return best diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 6c5d9697..41853b5e 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -7,7 +7,7 @@ from .base import BaseOptimizer from .. import AP_config from . import func -from ..errors import OptimizeStop +from ..errors import OptimizeStopFail, OptimizeStopSuccess from ..param import ValidContext __all__ = ("LM",) @@ -204,7 +204,7 @@ def __init__( self.model.target[self.fit_window].flatten("data"), dtype=torch.bool ) if self.mask is not None and torch.sum(self.mask).item() == 0: - raise OptimizeStop("No data to fit. All pixels are masked") + raise OptimizeStopSuccess("No data to fit. All pixels are masked") # Initialize optimizer attributes self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] @@ -298,11 +298,16 @@ def fit(self) -> BaseOptimizer: Ldn=self.Ldn, ) self.current_state = res["x"].detach() - except OptimizeStop: + except OptimizeStopFail: if self.verbose > 0: AP_config.ap_logger.warning("Could not find step to improve Chi^2, stopping") self.message = self.message + "fail. Could not find step to improve Chi^2" break + except OptimizeStopSuccess as e: + if self.verbose > 0: + AP_config.ap_logger.info(f"Optimization converged successfully: {e}") + self.message = self.message + "success" + break self.L = np.clip(res["L"], 1e-9, 1e9) self.L_history.append(res["L"]) diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py index 0a036de8..bd0fe1ae 100644 --- a/astrophot/fit/scipy_fit.py +++ b/astrophot/fit/scipy_fit.py @@ -5,7 +5,7 @@ from .base import BaseOptimizer from .. import AP_config -from ..errors import OptimizeStop +from ..errors import OptimizeStopSuccess __all__ = ("ScipyFit",) @@ -52,7 +52,7 @@ def __init__( self.model.target[self.fit_window].flatten("data"), dtype=torch.bool ) if self.mask is not None and torch.sum(self.mask).item() == 0: - raise OptimizeStop("No data to fit. All pixels are masked") + raise OptimizeStopSuccess("No data to fit. All pixels are masked") # Initialize optimizer attributes self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 5978efd9..6924f1bd 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -15,6 +15,7 @@ "metadata": {}, "outputs": [], "source": [ + "%matplotlib inline\n", "%load_ext autoreload\n", "%autoreload 2\n", "\n", @@ -23,9 +24,7 @@ "import torch\n", "from astropy.io import fits\n", "from astropy.wcs import WCS\n", - "import matplotlib.pyplot as plt\n", - "\n", - "%matplotlib inline" + "import matplotlib.pyplot as plt" ] }, { diff --git a/tests/test_fit.py b/tests/test_fit.py index 649a26b6..e3b84a06 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -49,6 +49,7 @@ def test_chunk_jacobian(center, PA, q, n, Re): ), "Pixel chunked Jacobian should match full Jacobian" +# LM already tested extensively # def test_lm(): # target = make_basic_sersic() # new_model = ap.Model( diff --git a/tests/test_group_models.py b/tests/test_group_models.py index 9b3fdaff..72ee63f2 100644 --- a/tests/test_group_models.py +++ b/tests/test_group_models.py @@ -1,220 +1,74 @@ -import unittest import astrophot as ap import torch import numpy as np from utils import make_basic_sersic, make_basic_gaussian_psf -class TestGroup(unittest.TestCase): - def test_groupmodel_creation(self): - np.random.seed(12345) - shape = (10, 15) - tar = ap.image.Target_Image( - data=np.random.normal(loc=0, scale=1.4, size=shape), - pixelscale=0.8, - variance=np.ones(shape) * (1.4**2), - ) - - mod1 = ap.models.Component_Model( - name="base model 1", - target=tar, - parameters={"center": {"value": [5, 5], "locked": True}}, - ) - mod2 = ap.models.Component_Model( - name="base model 2", - target=tar, - parameters={"center": {"value": [5, 5], "locked": True}}, - ) - - smod = ap.models.AstroPhot_Model( - name="group model", - model_type="group model", - models=[mod1, mod2], - target=tar, - ) - - self.assertFalse(smod.locked, "default model state should not be locked") - - smod.initialize() - - self.assertTrue(torch.all(smod().data == 0), "model_image should be zeros") - - def test_jointmodel_creation(self): - np.random.seed(12345) - shape = (10, 15) - tar1 = ap.image.Target_Image( - data=np.random.normal(loc=0, scale=1.4, size=shape), - pixelscale=0.8, - variance=np.ones(shape) * (1.4**2), - ) - shape2 = (33, 42) - tar2 = ap.image.Target_Image( - data=np.random.normal(loc=0, scale=1.4, size=shape2), - pixelscale=0.3, - origin=(43.2, 78.01), - variance=np.ones(shape2) * (1.4**2), - ) - - tar = ap.image.Target_Image_List([tar1, tar2]) - - mod1 = ap.models.Flat_Sky( - name="base model 1", - target=tar1, - ) - mod2 = ap.models.Flat_Sky( - name="base model 2", - target=tar2, - ) - - smod = ap.models.AstroPhot_Model( - name="group model", - model_type="group model", - models=[mod1, mod2], - target=tar, - ) - - self.assertFalse(smod.locked, "default model state should not be locked") - - smod.initialize() - self.assertTrue( - torch.all(torch.isfinite(smod().flatten("data"))).item(), "model_image should be real" - ) - - fm = smod.fit_mask() - for fmi in fm: - self.assertTrue(torch.sum(fmi).item() == 0, "this fit_mask should not mask any pixels") - - def test_groupmodel_saveload(self): - np.random.seed(12345) - tar = make_basic_sersic(N=51, M=51) - - psf = ap.models.Moffat_PSF( - name="psf model 1", - target=make_basic_gaussian_psf(N=11), - parameters={ - "center": {"value": [5, 5], "locked": True}, - "n": 2.0, - "Rd": 3.0, - "I0": {"value": 0.0, "locked": True}, - }, - ) - - mod1 = ap.models.Sersic_Galaxy( - name="base model 1", - target=tar, - parameters={"center": {"value": [5, 5], "locked": False}}, - psf=psf, - psf_mode="full", - ) - mod2 = ap.models.Sersic_Galaxy( - name="base model 2", - target=tar, - parameters={"center": {"value": [5, 5], "locked": False}}, - ) - - smod = ap.models.AstroPhot_Model( - name="group model", - model_type="group model", - models=[mod1, mod2], - target=tar, - ) - - self.assertFalse(smod.locked, "default model state should not be locked") - - smod.initialize() - - self.assertTrue(torch.all(torch.isfinite(smod().data)), "model_image should be real values") - - smod.save("test_save_group_model.yaml") - - newmod = ap.models.AstroPhot_Model( - name="group model", - filename="test_save_group_model.yaml", - ) - self.assertEqual(len(smod.models), len(newmod.models), "Group model should load sub models") - - self.assertEqual(newmod.parameters.size, 16, "Group model size should sum all parameters") - - self.assertTrue( - torch.all(newmod.parameters.vector_values() == smod.parameters.vector_values()), - "Save/load should extract all parameters", - ) - - -class TestPSFGroup(unittest.TestCase): - def test_psfgroupmodel_creation(self): - tar = make_basic_gaussian_psf() - - mod1 = ap.models.AstroPhot_Model( - name="base model 1", - model_type="moffat psf model", - target=tar, - ) - - mod2 = ap.models.AstroPhot_Model( - name="base model 2", - model_type="moffat psf model", - target=tar, - ) - - smod = ap.models.AstroPhot_Model( - name="group model", - model_type="psf group model", - models=[mod1, mod2], - target=tar, - ) - - smod.initialize() - - self.assertTrue( - torch.all(smod().data >= 0), - "PSF group sample should be greater than or equal to zero", - ) - - def test_psfgroupmodel_saveload(self): - np.random.seed(12345) - tar = make_basic_gaussian_psf() - - psf1 = ap.models.Moffat_PSF( - name="psf model 1", - target=tar, - parameters={ - "n": 2.0, - "Rd": 3.0, - }, - ) - - psf2 = ap.models.Sersic_PSF( - name="psf model 2", - target=tar, - parameters={ - "n": 2.0, - "Re": 3.0, - }, - ) - - smod = ap.models.AstroPhot_Model( - name="group model", - model_type="psf group model", - models=[psf1, psf2], - target=tar, - ) - - smod.initialize() - - self.assertTrue(torch.all(torch.isfinite(smod().data)), "psf_image should be real values") - - smod.save("test_save_psfgroup_model.yaml") - - newmod = ap.models.AstroPhot_Model( - name="group model", - filename="test_save_psfgroup_model.yaml", - ) - self.assertEqual(len(smod.models), len(newmod.models), "Group model should load sub models") - - self.assertEqual(newmod.parameters.size, 4, "Group model size should sum all parameters") - - self.assertTrue( - torch.all(newmod.parameters.vector_values() == smod.parameters.vector_values()), - "Save/load should extract all parameters", - ) +def test_jointmodel_creation(): + np.random.seed(12345) + shape = (10, 15) + tar1 = ap.TargetImage( + name="target1", + data=np.random.normal(loc=0, scale=1.4, size=shape), + pixelscale=0.8, + variance=np.ones(shape) * (1.4**2), + ) + shape2 = (33, 42) + tar2 = ap.TargetImage( + name="target2", + data=np.random.normal(loc=0, scale=1.4, size=shape2), + pixelscale=0.3, + variance=np.ones(shape2) * (1.4**2), + ) + + tar = ap.TargetImageList([tar1, tar2]) + + mod1 = ap.models.FlatSky( + name="base model 1", + target=tar1, + ) + mod2 = ap.models.FlatSky( + name="base model 2", + target=tar2, + ) + + smod = ap.Model( + name="group model", + model_type="group model", + models=[mod1, mod2], + target=tar, + ) + + smod.initialize() + assert torch.all(torch.isfinite(smod().flatten("data"))).item(), "model_image should be real" + + fm = smod.fit_mask() + for fmi in fm: + assert torch.sum(fmi).item() == 0, "this fit_mask should not mask any pixels" + + +def test_psfgroupmodel_creation(): + tar = make_basic_gaussian_psf() + + mod1 = ap.Model( + name="base model 1", + model_type="moffat psf model", + target=tar, + ) + + mod2 = ap.Model( + name="base model 2", + model_type="moffat psf model", + target=tar, + ) + + smod = ap.Model( + name="group model", + model_type="psf group model", + models=[mod1, mod2], + target=tar, + ) + + smod.initialize() + + assert torch.all(smod().data >= 0), "PSF group sample should be greater than or equal to zero" diff --git a/tests/test_image_list.py b/tests/test_image_list.py index 9fd63f6f..cbfdf158 100644 --- a/tests/test_image_list.py +++ b/tests/test_image_list.py @@ -33,7 +33,7 @@ def test_copy(): arr2 = torch.ones((15, 10)) base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0, name="image2") - test_image = ap.image.ImageList((base_image1, base_image2)) + test_image = ap.ImageList((base_image1, base_image2)) copy_image = test_image.copy() copy_image.images[0] += 5 @@ -55,13 +55,13 @@ def test_image_arithmetic(): base_image1 = ap.Image(data=arr1, pixelscale=1.0, zeropoint=1.0, name="image1") arr2 = torch.ones((15, 10)) base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0, name="image2") - test_image = ap.image.ImageList((base_image1, base_image2)) + test_image = ap.ImageList((base_image1, base_image2)) base_image3 = base_image1.copy() base_image3 += 1 base_image4 = base_image2.copy() base_image4 -= 2 - second_image = ap.image.ImageList((base_image3, base_image4)) + second_image = ap.ImageList((base_image3, base_image4)) # Test iadd test_image += second_image @@ -103,7 +103,7 @@ def test_model_image_list_error(): base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0) with pytest.raises(ap.errors.InvalidImage): - ap.image.ModelImageList((base_image1, base_image2)) + ap.ModelImageList((base_image1, base_image2)) def test_target_image_list_creation(): @@ -161,7 +161,7 @@ def test_targetlist_errors(): zeropoint=2.0, ) with pytest.raises(ap.errors.InvalidImage): - ap.image.TargetImageList((base_image1, base_image2)) + ap.TargetImageList((base_image1, base_image2)) def test_jacobian_image_list_error(): @@ -173,4 +173,4 @@ def test_jacobian_image_list_error(): base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0) with pytest.raises(ap.errors.InvalidImage): - ap.image.JacobianImageList((base_image1, base_image2)) + ap.JacobianImageList((base_image1, base_image2)) diff --git a/tests/test_model.py b/tests/test_model.py index dfde4ed5..ed046138 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -22,7 +22,7 @@ def test_model_sampling_modes(): q=0.5, n=2, Re=5, - logIe=1, + Ie=1, target=target, ) model() @@ -95,11 +95,16 @@ def test_all_model_sample(model_type): ), "Model should evaluate a real number for the full image" res = ap.fit.LM(MODEL, max_iter=10).fit() - if "sky" in model_type or model_type in [ - "spline ray galaxy model", - "exponential warp galaxy model", - "spline wedge galaxy model", - ]: # sky has little freedom to fit + if ( + "sky" in model_type + or "king" in model_type + or model_type + in [ + "spline ray galaxy model", + "exponential warp galaxy model", + "spline wedge galaxy model", + ] + ): # sky has little freedom to fit assert res.loss_history[0] > res.loss_history[-1], ( f"Model {model_type} should fit to the target image, but did not. " f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" @@ -122,7 +127,7 @@ def test_sersic_save_load(): q=0.5, n=2, Re=5, - logIe=1, + Ie=1, target=target, ) @@ -133,7 +138,7 @@ def test_sersic_save_load(): model.q = 0.8 model.n = 3 model.Re = 10 - model.logIe = 2 + model.Ie = 2 target.crtan = [1.0, 2.0] model.append_state("test_AstroPhot_sersic.hdf5") model.load_state("test_AstroPhot_sersic.hdf5", index=0) @@ -144,7 +149,7 @@ def test_sersic_save_load(): assert model.q.value.item() == 0.5, "Model q should be loaded correctly" assert model.n.value.item() == 2, "Model n should be loaded correctly" assert model.Re.value.item() == 5, "Model Re should be loaded correctly" - assert model.logIe.value.item() == 1, "Model logIe should be loaded correctly" + assert model.Ie.value.item() == 1, "Model Ie should be loaded correctly" assert model.target.crtan.value[0] == 0.0, "Model target crtan should be loaded correctly" assert model.target.crtan.value[1] == 0.0, "Model target crtan should be loaded correctly" diff --git a/tests/test_utils.py b/tests/test_utils.py index d9db8071..25d18b79 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,5 @@ -import unittest import numpy as np import torch -import h5py -from scipy.signal import fftconvolve from scipy.special import gamma import astrophot as ap from utils import make_basic_sersic, make_basic_gaussian @@ -12,514 +9,171 @@ ###################################################################### -class TestFFT(unittest.TestCase): - def test_fft(self): - - target = make_basic_sersic() - - convolved = ap.utils.operations.fft_convolve_torch( - target.data, - target.psf.data, - ) - scipy_convolve = fftconvolve( - target.data.detach().cpu().numpy(), - target.psf.data.detach().cpu().numpy(), - mode="same", - ) - self.assertLess( - torch.std(convolved), - torch.std(target.data), - "Convolved image should be smoothed", - ) - - self.assertTrue( - np.all(np.isclose(convolved.detach().cpu().numpy(), scipy_convolve)), - "Should reproduce scipy convolve", - ) - - def test_fft_multi(self): - - target = make_basic_sersic() - - convolved = ap.utils.operations.fft_convolve_multi_torch( - target.data, [target.psf.data, target.psf.data] - ) - self.assertLess( - torch.std(convolved), - torch.std(target.data), - "Convolved image should be smoothed", - ) - - -class TestOptimize(unittest.TestCase): - def test_chi2(self): - - # with variance - # with mask - mask = torch.zeros(10, dtype=torch.bool, device=ap.AP_config.ap_device) - mask[2] = 1 - chi2 = ap.utils.optimization.chi_squared( - torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - torch.zeros(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - mask=mask, - variance=2 * torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - ) - self.assertEqual(chi2, 4.5, "Chi squared calculation incorrect") - chi2_red = ap.utils.optimization.reduced_chi_squared( - torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - torch.zeros(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - params=3, - mask=mask, - variance=2 * torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - ) - self.assertEqual(chi2_red.item(), 0.75, "Chi squared calculation incorrect") - - # no mask - chi2 = ap.utils.optimization.chi_squared( - torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - torch.zeros(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - variance=2 * torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - ) - self.assertEqual(chi2, 5, "Chi squared calculation incorrect") - chi2_red = ap.utils.optimization.reduced_chi_squared( - torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - torch.zeros(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - params=3, - variance=2 * torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - ) - self.assertEqual(chi2_red.item(), 5 / 7, "Chi squared calculation incorrect") - - # no variance - # with mask - mask = torch.zeros(10, dtype=torch.bool, device=ap.AP_config.ap_device) - mask[2] = 1 - chi2 = ap.utils.optimization.chi_squared( - torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - torch.zeros(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - mask=mask, - ) - self.assertEqual(chi2.item(), 9, "Chi squared calculation incorrect") - chi2_red = ap.utils.optimization.reduced_chi_squared( - torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - torch.zeros(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - params=3, - mask=mask, - ) - self.assertEqual(chi2_red.item(), 1.5, "Chi squared calculation incorrect") - - # no mask - chi2 = ap.utils.optimization.chi_squared( - torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - torch.zeros(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - ) - self.assertEqual(chi2.item(), 10, "Chi squared calculation incorrect") - chi2_red = ap.utils.optimization.reduced_chi_squared( - torch.ones(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - torch.zeros(10, dtype=ap.AP_config.ap_dtype, device=ap.AP_config.ap_device), - params=3, - ) - self.assertEqual(chi2_red.item(), 10 / 7, "Chi squared calculation incorrect") - - -class TestPSF(unittest.TestCase): - def test_make_psf(self): - - target = make_basic_gaussian(x=10, y=10) - target += make_basic_gaussian(x=40, y=40, rand=54321) - - psf = ap.utils.initialize.construct_psf( - [[10, 10], [40, 40]], - target.data.detach().cpu().numpy(), - sky_est=0.0, - size=5, - ) - - self.assertTrue(np.all(np.isfinite(psf))) - - -class TestSegtoWindow(unittest.TestCase): - def test_segtowindow(self): - - segmap = np.zeros((100, 100), dtype=int) - - segmap[5:9, 20:30] = 1 - segmap[50:90, 17:35] = 2 - segmap[26:34, 80:85] = 3 - - centroids = ap.utils.initialize.centroids_from_segmentation_map(segmap, image=segmap) - - PAs = ap.utils.initialize.PA_from_segmentation_map( - segmap, - image=segmap, - centroids=centroids, - ) - qs = ap.utils.initialize.q_from_segmentation_map( - segmap, - image=segmap, - centroids=centroids, - ) - - windows = ap.utils.initialize.windows_from_segmentation_map(segmap) - - self.assertEqual(len(windows), 3, "should ignore zero index, but find all three windows") - self.assertEqual(len(centroids), 3, "should ignore zero index, but find all three windows") - self.assertEqual(len(PAs), 3, "should ignore zero index, but find all three windows") - self.assertEqual(len(qs), 3, "should ignore zero index, but find all three windows") - - self.assertEqual(windows[1], [[20, 29], [5, 8]], "Windows should be identified by index") - - # transfer windows - old_image = ap.image.Target_Image( - data=np.zeros((100, 100)), - pixelscale=1.0, - ) - new_image = ap.image.Target_Image( - data=np.zeros((100, 100)), - pixelscale=0.9, - origin=(0.1, 1.2), - ) - new_windows = ap.utils.initialize.transfer_windows(windows, old_image, new_image) - self.assertEqual( - windows.keys(), - new_windows.keys(), - "Transferred windows should have the same set of windows", - ) - - # scale windows - - new_windows = ap.utils.initialize.scale_windows( - windows, image_shape=(100, 100), expand_scale=2, expand_border=3 - ) - - self.assertEqual(new_windows[2], [[5, 45], [27, 100]], "Windows should scale appropriately") - - filtered_windows = ap.utils.initialize.filter_windows( - new_windows, min_size=10, max_size=80, min_area=30, max_area=1000 - ) - filtered_windows = ap.utils.initialize.filter_windows( - new_windows, min_flux=10, max_flux=1000, image=np.ones(segmap.shape) - ) - - self.assertEqual(len(filtered_windows), 2, "windows should have been filtered") - - # check original - self.assertEqual( - windows[3], [[80, 84], [26, 33]], "Original windows should not have changed" - ) - - -class TestConversions(unittest.TestCase): - def test_conversions_units(self): - - # flux to sb - self.assertEqual( - ap.utils.conversions.units.flux_to_sb(1.0, 1.0, 0.0), - 0, - "flux incorrectly converted to sb", - ) - - # sb to flux - self.assertEqual( - ap.utils.conversions.units.sb_to_flux(1.0, 1.0, 0.0), - (10 ** (-1 / 2.5)), - "sb incorrectly converted to flux", - ) - - # flux to mag no error - self.assertEqual( - ap.utils.conversions.units.flux_to_mag(1.0, 0.0), - 0, - "flux incorrectly converted to mag (no error)", - ) - - # flux to mag with error - self.assertEqual( - ap.utils.conversions.units.flux_to_mag(1.0, 0.0, fluxe=1.0), - (0.0, 2.5 / np.log(10)), - "flux incorrectly converted to mag (with error)", - ) - - # mag to flux no error: - self.assertEqual( - ap.utils.conversions.units.mag_to_flux(1.0, 0.0, mage=None), - (10 ** (-1 / 2.5)), - "mag incorrectly converted to flux (no error)", - ) - - # mag to flux with error: - [ - self.assertAlmostEqual( - ap.utils.conversions.units.mag_to_flux(1.0, 0.0, mage=1.0)[i], - (10 ** (-1.0 / 2.5), np.log(10) * (1.0 / 2.5) * 10 ** (-1.0 / 2.5))[i], - msg="mag incorrectly converted to flux (with error)", - ) - for i in range(1) - ] - - # magperarcsec2 to mag with area A defined - self.assertAlmostEqual( - ap.utils.conversions.units.magperarcsec2_to_mag(1.0, a=None, b=None, A=1.0), - (1.0 - 2.5 * np.log10(1.0)), - msg="mag/arcsec^2 incorrectly converted to mag (area A given, a and b not defined)", - ) - - # magperarcsec2 to mag with semi major and minor axes defined (a, and b) - self.assertAlmostEqual( - ap.utils.conversions.units.magperarcsec2_to_mag(1.0, a=1.0, b=1.0, A=None), - (1.0 - 2.5 * np.log10(np.pi)), - msg="mag/arcsec^2 incorrectly converted to mag (semi major/minor axes defined)", - ) - - # mag to magperarcsec2 with area A defined - self.assertAlmostEqual( - ap.utils.conversions.units.mag_to_magperarcsec2(1.0, a=None, b=None, A=1.0, R=None), - (1.0 + 2.5 * np.log10(1.0)), - msg="mag incorrectly converted to mag/arcsec^2 (area A given)", - ) - - # mag to magperarcsec2 with radius R given (assumes circular) - self.assertAlmostEqual( - ap.utils.conversions.units.mag_to_magperarcsec2(1.0, a=None, b=None, A=None, R=1.0), - (1.0 + 2.5 * np.log10(np.pi)), - msg="mag incorrectly converted to mag/arcsec^2 (radius R given)", - ) - - # mag to magperarcsec2 with semi major and minor axes defined (a, and b) - self.assertAlmostEqual( - ap.utils.conversions.units.mag_to_magperarcsec2(1.0, a=1.0, b=1.0, A=None, R=None), - (1.0 + 2.5 * np.log10(np.pi)), - msg="mag incorrectly converted to mag/arcsec^2 (area A given)", - ) - - # position angle PA to radians - self.assertAlmostEqual( - ap.utils.conversions.units.PA_shift_convention(1.0, unit="rad"), - ((1.0 - (np.pi / 2)) % np.pi), - msg="PA incorrectly converted to radians", - ) - - # position angle PA to degrees - self.assertAlmostEqual( - ap.utils.conversions.units.PA_shift_convention(1.0, unit="deg"), - ((1.0 - (180 / 2)) % 180), - msg="PA incorrectly converted to degrees", - ) - - def test_conversion_dict_to_hdf5(self): - - # convert string to hdf5 - self.assertEqual( - ap.utils.conversions.dict_to_hdf5.to_hdf5_has_None(l="test"), - (False), - "Failed to properly identify string object while converting to hdf5", - ) - - # convert __iter__ to hdf5 - self.assertEqual( - ap.utils.conversions.dict_to_hdf5.to_hdf5_has_None(l="__iter__"), - (False), - "Attempted to convert '__iter__' to hdf5 key", - ) - - # convert hdf5 file to dict - h = h5py.File("mytestfile.hdf5", "w") - dset = h.create_dataset("mydataset", (1,), dtype="i") - dset[...] = np.array([1.0]) - self.assertEqual( - ap.utils.conversions.dict_to_hdf5.hdf5_to_dict(h=h), - ({"mydataset": h["mydataset"]}), - "Failed to convert hdf5 file to dict", - ) - - # convert dict to hdf5 - target = make_basic_sersic().data.detach().cpu().numpy()[0] - d = {"sersic": target.tolist()} - ap.utils.conversions.dict_to_hdf5.dict_to_hdf5(h=h5py.File("mytestfile2.hdf5", "w"), D=d) - self.assertEqual( - (list(h5py.File("mytestfile2.hdf5", "r"))), - (list(d)), - "Failed to convert dict of strings to hdf5", - ) - - def test_conversion_functions(self): - - sersic_n = ap.utils.conversions.functions.sersic_n_to_b(1.0) - # sersic I0 to flux - numpy - self.assertAlmostEqual( - ap.utils.conversions.functions.sersic_I0_to_flux_np(1.0, 1.0, 1.0, 1.0), - (2 * np.pi * gamma(2)), - msg="Error converting sersic central intensity to flux (np)", - ) - - # sersic flux to I0 - numpy - self.assertAlmostEqual( - ap.utils.conversions.functions.sersic_flux_to_I0_np(1.0, 1.0, 1.0, 1.0), - (1.0 / (2 * np.pi * gamma(2))), - msg="Error converting sersic flux to central intensity (np)", - ) - - # sersic Ie to flux - numpy - self.assertAlmostEqual( - ap.utils.conversions.functions.sersic_Ie_to_flux_np(1.0, 1.0, 1.0, 1.0), - (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2)), - msg="Error converting sersic effective intensity to flux (np)", - ) - - # sersic flux to Ie - numpy - self.assertAlmostEqual( - ap.utils.conversions.functions.sersic_flux_to_Ie_np(1.0, 1.0, 1.0, 1.0), - (1 / (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2))), - msg="Error converting sersic flux to effective intensity (np)", - ) - - # inverse sersic - numpy - self.assertAlmostEqual( - ap.utils.conversions.functions.sersic_inv_np(1.0, 1.0, 1.0, 1.0), - (1.0 - (1.0 / sersic_n) * np.log(1.0)), - msg="Error computing inverse sersic function (np)", - ) - - # sersic I0 to flux - torch - tv = torch.tensor([[1.0]], dtype=torch.float64) - self.assertEqual( - torch.round( - ap.utils.conversions.functions.sersic_I0_to_flux_np(tv, tv, tv, tv), - decimals=7, - ), - torch.round(torch.tensor([[2 * np.pi * gamma(2)]]), decimals=7), - msg="Error converting sersic central intensity to flux (torch)", - ) - - # sersic flux to I0 - torch - self.assertEqual( - torch.round( - ap.utils.conversions.functions.sersic_flux_to_I0_np(tv, tv, tv, tv), - decimals=7, - ), - torch.round(torch.tensor([[1.0 / (2 * np.pi * gamma(2))]]), decimals=7), - msg="Error converting sersic flux to central intensity (torch)", - ) - - # sersic Ie to flux - torch - self.assertEqual( - torch.round( - ap.utils.conversions.functions.sersic_Ie_to_flux_np(tv, tv, tv, tv), - decimals=7, - ), - torch.round( - torch.tensor([[2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2)]]), - decimals=7, - ), - msg="Error converting sersic effective intensity to flux (torch)", - ) - - # sersic flux to Ie - torch - self.assertEqual( - torch.round( - ap.utils.conversions.functions.sersic_flux_to_Ie_np(tv, tv, tv, tv), - decimals=7, - ), - torch.round( - torch.tensor([[1 / (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2))]]), - decimals=7, - ), - msg="Error converting sersic flux to effective intensity (torch)", - ) - - # inverse sersic - torch - self.assertEqual( - torch.round(ap.utils.conversions.functions.sersic_inv_np(tv, tv, tv, tv), decimals=7), - torch.round(torch.tensor([[1.0 - (1.0 / sersic_n) * np.log(1.0)]]), decimals=7), - msg="Error computing inverse sersic function (torch)", - ) - - def test_general_derivative(self): - - res = ap.utils.conversions.functions.general_uncertainty_prop( - tuple(torch.tensor(a) for a in (1.0, 1.0, 1.0, 0.5)), - tuple(torch.tensor(a) for a in (0.1, 0.1, 0.1, 0.1)), - ap.utils.conversions.functions.sersic_Ie_to_flux_torch, - ) - - self.assertAlmostEqual( - res.detach().cpu().numpy(), - 1.8105, - 3, - "General uncertianty prop should compute uncertainty", - ) - - -class TestInterpolate(unittest.TestCase): - def test_interpolate_functions(self): - - # Lanczos kernel interpolation on the center point of a gaussian (10., 10.) - model = make_basic_gaussian(x=10.0, y=10.0).data.detach().cpu().numpy() - lanczos_interp = ap.utils.interpolate.point_Lanczos(model, 10.0, 10.0, scale=0.8) - self.assertTrue(np.all(np.isfinite(model)), msg="gaussian model returning nonfinite values") - self.assertLess(lanczos_interp, 1.0, msg="Lanczos interpolation greater than total flux") - self.assertTrue( - np.isfinite(lanczos_interp), - msg="Lanczos interpolate returning nonfinite values", - ) - - -class TestAngleOperations(unittest.TestCase): - def test_angle_operation_functions(self): - - test_angles = np.array([np.pi, 2 * np.pi, 3 * np.pi, 4 * np.pi]) - # angle median - self.assertAlmostEqual( - ap.utils.angle_operations.Angle_Median(test_angles), - -np.pi / 2, - msg="incorrectly calculating median of list of angles", - ) - - # angle scatter (iqr) - self.assertAlmostEqual( - ap.utils.angle_operations.Angle_Scatter(test_angles), - np.pi, - msg="incorrectly calculating iqr of list of angles", - ) - - def test_angle_com(self): - pixelscale = 0.8 - tar = make_basic_sersic( - N=50, - M=50, - pixelscale=pixelscale, - x=24.5 * pixelscale, - y=24.5 * pixelscale, - PA=115 * np.pi / 180, - ) - - res = ap.utils.angle_operations.Angle_COM_PA(tar.data.detach().cpu().numpy()) - - self.assertAlmostEqual(res + np.pi / 2, 115 * np.pi / 180, delta=0.1) - - -class TestIsophote(unittest.TestCase): - def test_ellipse(self): - rs = ap.utils.isophote.ellipse.Rscale_Fmodes(1.0, [1, 2], [1, 2], [1, 2]) - - self.assertTrue(np.isfinite(rs), "Rscale_Fmodes should return finite values") - - rs = ap.utils.isophote.ellipse.parametric_Fmodes( - np.linspace(0, np.pi / 2, 10), [1, 2], [1, 2], [1, 2] - ) - - self.assertTrue(np.all(np.isfinite(rs)), "parametric_Fmodes should return finite values") - - for C in np.linspace(1, 3, 5): - rs = ap.utils.isophote.ellipse.Rscale_SuperEllipse(1.0, 0.8, C) - self.assertTrue(np.isfinite(rs), "Rscale_SuperEllipse should return finite values") - - rs = ap.utils.isophote.ellipse.parametric_SuperEllipse( - np.linspace(0, np.pi / 2, 10), 0.8, C - ) - self.assertTrue( - np.all(np.isfinite(rs)), "parametric_SuperEllipse should return finite values" - ) - - -if __name__ == "__main__": - unittest.main() +def test_make_psf(): + + target = make_basic_gaussian(x=10, y=10) + target += make_basic_gaussian(x=40, y=40, rand=54321) + + assert np.all( + np.isfinite(target.data.detach().cpu().numpy()) + ), "Target image should be finite after creation" + + +def test_conversions_units(): + + # flux to sb + # flux to sb + assert ( + ap.utils.conversions.units.flux_to_sb(1.0, 1.0, 0.0) == 0 + ), "flux incorrectly converted to sb" + + # sb to flux + assert ap.utils.conversions.units.sb_to_flux(1.0, 1.0, 0.0) == ( + 10 ** (-1 / 2.5) + ), "sb incorrectly converted to flux" + + # flux to mag no error + assert ( + ap.utils.conversions.units.flux_to_mag(1.0, 0.0) == 0 + ), "flux incorrectly converted to mag (no error)" + + # flux to mag with error + assert ap.utils.conversions.units.flux_to_mag(1.0, 0.0, fluxe=1.0) == ( + 0.0, + 2.5 / np.log(10), + ), "flux incorrectly converted to mag (with error)" + + # mag to flux no error: + assert ap.utils.conversions.units.mag_to_flux(1.0, 0.0, mage=None) == ( + 10 ** (-1 / 2.5) + ), "mag incorrectly converted to flux (no error)" + + # mag to flux with error: + for i in range(1): + assert np.isclose( + ap.utils.conversions.units.mag_to_flux(1.0, 0.0, mage=1.0)[i], + (10 ** (-1.0 / 2.5), np.log(10) * (1.0 / 2.5) * 10 ** (-1.0 / 2.5))[i], + ), "mag incorrectly converted to flux (with error)" + + # magperarcsec2 to mag with area A defined + assert np.isclose( + ap.utils.conversions.units.magperarcsec2_to_mag(1.0, a=None, b=None, A=1.0), + (1.0 - 2.5 * np.log10(1.0)), + ), "mag/arcsec^2 incorrectly converted to mag (area A given, a and b not defined)" + + # magperarcsec2 to mag with semi major and minor axes defined (a, and b) + assert np.isclose( + ap.utils.conversions.units.magperarcsec2_to_mag(1.0, a=1.0, b=1.0, A=None), + (1.0 - 2.5 * np.log10(np.pi)), + ), "mag/arcsec^2 incorrectly converted to mag (semi major/minor axes defined)" + + # mag to magperarcsec2 with area A defined + assert np.isclose( + ap.utils.conversions.units.mag_to_magperarcsec2(1.0, a=None, b=None, A=1.0, R=None), + (1.0 + 2.5 * np.log10(1.0)), + ), "mag incorrectly converted to mag/arcsec^2 (area A given)" + + # mag to magperarcsec2 with radius R given (assumes circular) + assert np.isclose( + ap.utils.conversions.units.mag_to_magperarcsec2(1.0, a=None, b=None, A=None, R=1.0), + (1.0 + 2.5 * np.log10(np.pi)), + ), "mag incorrectly converted to mag/arcsec^2 (radius R given)" + + # mag to magperarcsec2 with semi major and minor axes defined (a, and b) + assert np.isclose( + ap.utils.conversions.units.mag_to_magperarcsec2(1.0, a=1.0, b=1.0, A=None, R=None), + (1.0 + 2.5 * np.log10(np.pi)), + ), "mag incorrectly converted to mag/arcsec^2 (area A given)" + + # position angle PA to radians + assert np.isclose( + ap.utils.conversions.units.PA_shift_convention(1.0, unit="rad"), + ((1.0 - (np.pi / 2)) % np.pi), + ), "PA incorrectly converted to radians" + + # position angle PA to degrees + assert np.isclose( + ap.utils.conversions.units.PA_shift_convention(1.0, unit="deg"), ((1.0 - (180 / 2)) % 180) + ), "PA incorrectly converted to degrees" + + +def test_conversion_functions(): + + sersic_n = ap.utils.conversions.functions.sersic_n_to_b(1.0) + # sersic I0 to flux - numpy + assert np.isclose( + ap.utils.conversions.functions.sersic_I0_to_flux_np(1.0, 1.0, 1.0, 1.0), + (2 * np.pi * gamma(2)), + ), "Error converting sersic central intensity to flux (np)" + # sersic flux to I0 - numpy + assert np.isclose( + ap.utils.conversions.functions.sersic_flux_to_I0_np(1.0, 1.0, 1.0, 1.0), + (1.0 / (2 * np.pi * gamma(2))), + ), "Error converting sersic flux to central intensity (np)" + + # sersic Ie to flux - numpy + assert np.isclose( + ap.utils.conversions.functions.sersic_Ie_to_flux_np(1.0, 1.0, 1.0, 1.0), + (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2)), + ), "Error converting sersic effective intensity to flux (np)" + + # sersic flux to Ie - numpy + assert np.isclose( + ap.utils.conversions.functions.sersic_flux_to_Ie_np(1.0, 1.0, 1.0, 1.0), + (1 / (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2))), + ), "Error converting sersic flux to effective intensity (np)" + + # inverse sersic - numpy + assert np.isclose( + ap.utils.conversions.functions.sersic_inv_np(1.0, 1.0, 1.0, 1.0), + (1.0 - (1.0 / sersic_n) * np.log(1.0)), + ), "Error computing inverse sersic function (np)" + + # sersic I0 to flux - torch + tv = torch.tensor([[1.0]], dtype=torch.float64) + assert torch.allclose( + torch.round( + ap.utils.conversions.functions.sersic_I0_to_flux_np(tv, tv, tv, tv), + decimals=7, + ), + torch.round(torch.tensor([[2 * np.pi * gamma(2)]]), decimals=7), + ), "Error converting sersic central intensity to flux (torch)" + + # sersic flux to I0 - torch + assert torch.allclose( + torch.round( + ap.utils.conversions.functions.sersic_flux_to_I0_np(tv, tv, tv, tv), + decimals=7, + ), + torch.round(torch.tensor([[1.0 / (2 * np.pi * gamma(2))]]), decimals=7), + ), "Error converting sersic flux to central intensity (torch)" + + # sersic Ie to flux - torch + assert torch.allclose( + torch.round( + ap.utils.conversions.functions.sersic_Ie_to_flux_np(tv, tv, tv, tv), + decimals=7, + ), + torch.round( + torch.tensor([[2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2)]]), + decimals=7, + ), + ), "Error converting sersic effective intensity to flux (torch)" + + # sersic flux to Ie - torch + assert torch.allclose( + torch.round( + ap.utils.conversions.functions.sersic_flux_to_Ie_np(tv, tv, tv, tv), + decimals=7, + ), + torch.round( + torch.tensor([[1 / (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2))]]), + decimals=7, + ), + ), "Error converting sersic flux to effective intensity (torch)" + + # inverse sersic - torch + assert torch.allclose( + torch.round(ap.utils.conversions.functions.sersic_inv_np(tv, tv, tv, tv), decimals=7), + torch.round(torch.tensor([[1.0 - (1.0 / sersic_n) * np.log(1.0)]]), decimals=7), + ), "Error computing inverse sersic function (torch)" diff --git a/tests/test_window.py b/tests/test_window.py index 3e51f079..98c7a679 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1,412 +1,75 @@ -import unittest import astrophot as ap import numpy as np -import torch -class TestWindow(unittest.TestCase): - def test_window_creation(self): - - window1 = ap.image.Window(origin=(0, 6), pixel_shape=(100, 110)) - - window1.to(dtype=torch.float64, device="cpu") - - self.assertEqual(window1.origin[0], 0, "Window should store origin") - self.assertEqual(window1.origin[1], 6, "Window should store origin") - self.assertEqual(window1.shape[0], 100, "Window should store shape") - self.assertEqual(window1.shape[1], 110, "Window should store shape") - self.assertEqual(window1.center[0], 50.0, "Window should determine center") - self.assertEqual(window1.center[1], 61.0, "Window should determine center") - - self.assertRaises(Exception, ap.image.Window) - - x = str(window1) - x = repr(window1) - - wcs = window1.get_astropywcs() - - def test_window_crop(self): - - window1 = ap.image.Window(origin=(0, 6), pixel_shape=(100, 110)) - - window1.crop_to_pixel([[10, 90], [15, 105]]) - self.assertTrue( - np.all(window1.origin.detach().cpu().numpy() == np.array([10.0, 21])), - "crop pixels should move origin", - ) - self.assertTrue( - np.all(window1.pixel_shape.detach().cpu().numpy() == np.array([80, 90])), - "crop pixels should change shape", - ) - - window2 = ap.image.Window(origin=(0, 6), pixel_shape=(100, 110)) - window2.crop_pixel((5,)) - self.assertTrue( - np.all(window2.origin.detach().cpu().numpy() == np.array([5.0, 11.0])), - "crop pixels should move origin", - ) - self.assertTrue( - np.all(window2.pixel_shape.detach().cpu().numpy() == np.array([90, 100])), - "crop pixels should change shape", - ) - window2.pad_pixel((5,)) - - window2 = ap.image.Window(origin=(0, 6), pixel_shape=(100, 110)) - window2.crop_pixel((5, 6)) - self.assertTrue( - np.all(window2.origin.detach().cpu().numpy() == np.array([5.0, 12.0])), - "crop pixels should move origin", - ) - self.assertTrue( - np.all(window2.pixel_shape.detach().cpu().numpy() == np.array([90, 98])), - "crop pixels should change shape", - ) - window2.pad_pixel((5, 6)) - - window2 = ap.image.Window(origin=(0, 6), pixel_shape=(100, 110)) - window2.crop_pixel((5, 6, 7, 8)) - self.assertTrue( - np.all(window2.origin.detach().cpu().numpy() == np.array([5.0, 12.0])), - "crop pixels should move origin", - ) - self.assertTrue( - np.all(window2.pixel_shape.detach().cpu().numpy() == np.array([88, 96])), - "crop pixels should change shape", - ) - window2.pad_pixel((5, 6, 7, 8)) - - self.assertTrue( - np.all(window2.origin.detach().cpu().numpy() == np.array([0.0, 6.0])), - "pad pixels should move origin", - ) - self.assertTrue( - np.all(window2.pixel_shape.detach().cpu().numpy() == np.array([100, 110])), - "pad pixels should change shape", - ) - - def test_window_get_indices(self): - - window1 = ap.image.Window(origin=(0, 6), pixel_shape=(100, 110)) - xstep, ystep = np.meshgrid(range(100), range(110), indexing="xy") - zstep = xstep + ystep - window2 = ap.image.Window(origin=(15, 15), pixel_shape=(30, 200)) - - zsliced = zstep[window1.get_self_indices(window2)] - self.assertTrue( - np.all(zsliced == zstep[9:110, 15:45]), - "window slices should get correct part of image", - ) - zsliced = zstep[window2.get_other_indices(window1)] - self.assertTrue( - np.all(zsliced == zstep[9:110, 15:45]), - "window slices should get correct part of image", - ) - - def test_window_arithmetic(self): - - windowbig = ap.image.Window(origin=(0, 0), pixel_shape=(100, 110)) - windowsmall = ap.image.Window(origin=(40, 40), pixel_shape=(20, 30)) - - # Logical or, size - ###################################################################### - big_or_small = windowbig | windowsmall - self.assertEqual( - big_or_small.origin[0], - 0, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_small.shape[0], - 100, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowbig.origin[0], - 0, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowsmall.origin[0], - 40, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowsmall.shape[0], - 20, - "logical or of images should not affect initial images", - ) - - # Logical and, size - ###################################################################### - big_and_small = windowbig & windowsmall - self.assertEqual( - big_and_small.origin[0], - 40, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_small.shape[0], - 20, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_small.shape[1], - 30, - "logical and of images should take overlap region", - ) - self.assertEqual( - windowbig.origin[0], - 0, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowsmall.origin[0], - 40, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowsmall.shape[0], - 20, - "logical and of images should not affect initial images", - ) - - # Logical or, offset - ###################################################################### - windowoffset = ap.image.Window(origin=(40, -20), pixel_shape=(100, 90)) - big_or_offset = windowbig | windowoffset - self.assertEqual( - big_or_offset.origin[0], - 0, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_offset.origin[1], - -20, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_offset.shape[0], - 140, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_offset.shape[1], - 130, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowbig.origin[0], - 0, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowoffset.origin[0], - 40, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowoffset.shape[0], - 100, - "logical or of images should not affect initial images", - ) - - # Logical and, offset - ###################################################################### - big_and_offset = windowbig & windowoffset - self.assertEqual( - big_and_offset.origin[0], - 40, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_offset.origin[1], - 0, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_offset.shape[0], - 60, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_offset.shape[1], - 70, - "logical and of images should take overlap region", - ) - self.assertEqual( - windowbig.origin[0], - 0, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowoffset.origin[0], - 40, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowoffset.shape[0], - 100, - "logical and of images should not affect initial images", - ) - - # Logical ior, size - ###################################################################### - windowbig |= windowsmall - self.assertEqual( - windowbig.origin[0], - 0, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowsmall.origin[0], - 40, - "logical or of images should not affect input image", - ) - self.assertEqual( - windowsmall.shape[0], - 20, - "logical or of images should not affect input image", - ) - - # Logical ior, offset - ###################################################################### - windowbig |= windowoffset - self.assertEqual( - windowbig.origin[0], - 0, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowbig.origin[1], - -20, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowbig.shape[0], - 140, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowbig.shape[1], - 130, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowoffset.origin[0], - 40, - "logical or of images should not affect input image", - ) - self.assertEqual( - windowoffset.shape[0], - 100, - "logical or of images should not affect input image", - ) - - # Logical iand, offset - ###################################################################### - windowbig = ap.image.Window(origin=(0, 0), pixel_shape=(100, 110)) - windowbig &= windowoffset - self.assertEqual( - windowbig.origin[0], 40, "logical and of images should take overlap region" - ) - self.assertEqual(windowbig.origin[1], 0, "logical and of images should take overlap region") - self.assertEqual(windowbig.shape[0], 60, "logical and of images should take overlap region") - self.assertEqual(windowbig.shape[1], 70, "logical and of images should take overlap region") - self.assertEqual( - windowoffset.origin[0], - 40, - "logical and of images should not affect input image", - ) - self.assertEqual( - windowoffset.shape[0], - 100, - "logical and of images should not affect input image", - ) - - windowbig &= windowsmall - - self.assertEqual( - windowbig, - windowsmall, - "logical and of images should take overlap region, equality should be internally determined", - ) - - def test_window_state(self): - window_init = ap.image.Window( - origin=[1.0, 2.0], - pixel_shape=[10, 15], - pixelscale=1, - projection="orthographic", - reference_radec=(0, 0), - ) - window = ap.image.Window(state=window_init.get_state()) - self.assertEqual(window.origin[0].item(), 1.0, "Window initialization should read state") - self.assertEqual(window.shape[0].item(), 10.0, "Window initialization should read state") - self.assertEqual( - window.pixelscale[0][0].item(), - 1.0, - "Window initialization should read state", - ) - - state = window.get_state() - self.assertEqual( - state["reference_imagexy"][1], 2.0, "Window get state should collect values" - ) - self.assertEqual(state["pixel_shape"][1], 15.0, "Window get state should collect values") - self.assertEqual(state["pixelscale"][1][0], 0.0, "Window get state should collect values") - self.assertEqual( - state["projection"], - "orthographic", - "Window get state should collect values", - ) - self.assertEqual( - tuple(state["reference_radec"]), - (0.0, 0.0), - "Window get state should collect values", - ) - - def test_window_logic(self): - - window1 = ap.image.Window(origin=[0.0, 1.0], pixel_shape=[10.0, 11.0]) - window2 = ap.image.Window(origin=[0.0, 1.0], pixel_shape=[10.0, 11.0]) - window3 = ap.image.Window(origin=[-0.6, 0.4], pixel_shape=[15.0, 18.0]) - - self.assertEqual(window1, window2, "same origin, shape windows should evaluate equal") - self.assertNotEqual(window1, window3, "Different windows should not evaluate equal") - - def test_window_errors(self): - - # Initialize with conflicting information - with self.assertRaises(ap.errors.SpecificationConflict): - window = ap.image.Window( - origin=[0.0, 1.0], origin_radec=[5.0, 6.0], pixel_shape=[10.0, 11.0] - ) - - -if __name__ == "__main__": - unittest.main() +def test_window_creation(): + + image = ap.Image( + data=np.zeros((100, 110)), + pixelscale=0.3, + zeropoint=1.0, + name="test_image", + ) + window = ap.Window((2, 107, 3, 97), image) + + assert np.all(window.crpix == image.crpix), "Window should inherit crpix from image" + assert window.identity == image.identity, "Window should inherit identity from image" + assert window.shape == (105, 94), "Window should have correct shape" + assert window.extent == (2, 107, 3, 97), "Window should have correct extent" + assert str(window) == "Window(2, 107, 3, 97)", "String representation should match" + + +def test_window_chunk(): + + image = ap.Image( + data=np.zeros((100, 110)), + pixelscale=0.3, + zeropoint=1.0, + name="test_image", + ) + window1 = ap.Window((2, 107, 3, 97), image) + + subwindows = window1.chunk(10**2) + reconstitute = subwindows[0] + for subwindow in subwindows: + reconstitute |= subwindow + assert ( + reconstitute.i_low == window1.i_low + ), "chunked windows should reconstitute to original window" + assert ( + reconstitute.i_high == window1.i_high + ), "chunked windows should reconstitute to original window" + assert ( + reconstitute.j_low == window1.j_low + ), "chunked windows should reconstitute to original window" + assert ( + reconstitute.j_high == window1.j_high + ), "chunked windows should reconstitute to original window" + + +def test_window_arithmetic(): + + image = ap.Image( + data=np.zeros((100, 110)), + pixelscale=0.3, + zeropoint=1.0, + name="test_image", + ) + windowbig = ap.Window((2, 107, 3, 97), image) + windowsmall = ap.Window((20, 45, 30, 90), image) + + # Logical or, size + ###################################################################### + big_or_small = windowbig | windowsmall + assert big_or_small.i_low == 2, "logical or of images should take largest bounding box" + assert big_or_small.i_high == 107, "logical or of images should take largest bounding box" + assert big_or_small.j_low == 3, "logical or of images should take largest bounding box" + assert big_or_small.j_high == 97, "logical or of images should take largest bounding box" + + # Logical and, size + ###################################################################### + big_and_small = windowbig & windowsmall + assert big_and_small.i_low == 20, "logical and of images should take overlap region" + assert big_and_small.i_high == 45, "logical and of images should take overlap region" + assert big_and_small.j_low == 30, "logical and of images should take overlap region" + assert big_and_small.j_high == 90, "logical and of images should take overlap region" diff --git a/tests/test_window_list.py b/tests/test_window_list.py index c1b88d98..d00b928f 100644 --- a/tests/test_window_list.py +++ b/tests/test_window_list.py @@ -9,243 +9,37 @@ ###################################################################### -class TestWindowList(unittest.TestCase): - def test_windowlist_creation(self): - - window1 = ap.image.Window(origin=(0, 6), pixel_shape=(100, 110)) - window2 = ap.image.Window(origin=(0, 6), pixel_shape=(100, 110)) - windowlist = ap.image.Window_List([window1, window2]) - - windowlist.to(dtype=torch.float64, device="cpu") - - # under review - self.assertEqual(windowlist.origin[0][0], 0, "Window list should capture origin") - self.assertEqual(windowlist.origin[1][1], 6, "Window list should capture origin") - self.assertEqual(windowlist.shape[0][0], 100, "Window list should capture shape") - self.assertEqual(windowlist.shape[1][1], 110, "Window list should capture shape") - self.assertEqual(windowlist.center[1][0], 50.0, "Window should determine center") - self.assertEqual(windowlist.center[0][1], 61.0, "Window should determine center") - - x = str(windowlist) - x = repr(windowlist) - - def test_window_arithmetic(self): - - windowbig = ap.image.Window(origin=(0, 0), pixel_shape=(100, 110)) - windowsmall = ap.image.Window(origin=(40, 40), pixel_shape=(20, 30)) - windowlistbs = ap.image.Window_List([windowbig, windowsmall]) - windowlistbb = ap.image.Window_List([windowbig, windowbig]) - windowlistsb = ap.image.Window_List([windowsmall, windowbig]) - - # Logical or, size - ###################################################################### - big_or_small = windowlistbs | windowlistsb - - self.assertEqual( - big_or_small.origin[0][0], - 0.0, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_small.origin[1][0], - 0.0, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_small.shape[0][0], - 100, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowbig.origin[0], - 0, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowsmall.origin[0], - 40, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowsmall.shape[0], - 20, - "logical or of images should not affect initial images", - ) - - # Logical and, size - ###################################################################### - big_and_small = windowlistbs & windowlistsb - self.assertEqual( - big_and_small.origin[0][0], - 40, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_small.shape[0][0], - 20, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_small.shape[0][1], - 30, - "logical and of images should take overlap region", - ) - self.assertEqual( - windowbig.origin[0], - 0, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowsmall.origin[0], - 40, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowsmall.shape[0], - 20, - "logical and of images should not affect initial images", - ) - - # Logical or, offset - ###################################################################### - windowoffset = ap.image.Window(origin=(40, -20), pixel_shape=(100, 90)) - windowlistoffset = ap.image.Window_List([windowoffset, windowoffset]) - big_or_offset = windowlistbb | windowlistoffset - self.assertEqual( - big_or_offset.origin[0][0], - 0, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_offset.origin[1][1], - -20, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_offset.shape[0][0], - 140, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - big_or_offset.shape[1][1], - 130, - "logical or of images should take largest bounding box", - ) - self.assertEqual( - windowbig.origin[0], - 0, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowoffset.origin[0], - 40, - "logical or of images should not affect initial images", - ) - self.assertEqual( - windowoffset.shape[0], - 100, - "logical or of images should not affect initial images", - ) - - # Logical and, offset - ###################################################################### - big_and_offset = windowlistbb & windowlistoffset - self.assertEqual( - big_and_offset.origin[0][0], - 40, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_offset.origin[0][1], - 0, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_offset.shape[0][0], - 60, - "logical and of images should take overlap region", - ) - self.assertEqual( - big_and_offset.shape[0][1], - 70, - "logical and of images should take overlap region", - ) - self.assertEqual( - windowbig.origin[0], - 0, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowbig.shape[0], - 100, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowoffset.origin[0], - 40, - "logical and of images should not affect initial images", - ) - self.assertEqual( - windowoffset.shape[0], - 100, - "logical and of images should not affect initial images", - ) - - def test_windowlist_logic(self): - - window1 = ap.image.Window(origin=[0.0, 1.0], pixel_shape=[10.2, 11.8]) - window2 = ap.image.Window(origin=[0.0, 1.0], pixel_shape=[10.2, 11.8]) - window3 = ap.image.Window(origin=[-0.6, 0.4], pixel_shape=[15.2, 18.0]) - windowlist1 = ap.image.Window_List([window1, window1.copy()]) - windowlist2 = ap.image.Window_List([window2, window2.copy()]) - windowlist3 = ap.image.Window_List([window3, window3.copy()]) - - self.assertEqual( - windowlist1, windowlist2, "same origin, shape windows should evaluate equal" - ) - self.assertNotEqual(windowlist1, windowlist3, "Different windows should not evaluate equal") - - def test_image_list_errors(self): - window1 = ap.image.Window(origin=[0.0, 1.0], pixel_shape=[10.2, 11.8]) - window2 = ap.image.Window(origin=[0.0, 1.0], pixel_shape=[10.2, 11.8]) - windowlist1 = ap.image.Window_List([window1, window2]) - - # Bad ra dec reference point - window2 = ap.image.Window( - origin=[0.0, 1.0], reference_radec=np.ones(2), pixel_shape=[10.2, 11.8] - ) - with self.assertRaises(ap.errors.ConflicingWCS): - test_image = ap.image.Window_List((window1, window2)) - - # Bad tangent plane x y reference point - window2 = ap.image.Window( - origin=[0.0, 1.0], reference_planexy=np.ones(2), pixel_shape=[10.2, 11.8] - ) - with self.assertRaises(ap.errors.ConflicingWCS): - test_image = ap.image.Window_List((window1, window2)) - - # Bad WCS projection - window2 = ap.image.Window( - origin=[0.0, 1.0], projection="orthographic", pixel_shape=[10.2, 11.8] - ) - with self.assertRaises(ap.errors.ConflicingWCS): - test_image = ap.image.Window_List((window1, window2)) - - -if __name__ == "__main__": - unittest.main() +def test_windowlist_creation(): + + image1 = ap.Image( + data=np.zeros((10, 15)), + pixelscale=1.0, + zeropoint=1.0, + name="image1", + ) + image2 = ap.Image( + data=np.ones((15, 10)), + pixelscale=0.5, + zeropoint=2.0, + name="image2", + ) + window1 = ap.Window([4, 13, 5, 9], image1) + window2 = ap.Window([0, 7, 1, 8], image2) + windowlist = ap.WindowList([window1, window2]) + + window3 = ap.Window([3, 12, 5, 8], image1) + assert windowlist.index(window3) == 0, "WindowList should find window by index" + assert len(windowlist) == 2, "WindowList should have two windows" + + window21 = ap.Window([5, 10, 6, 9], image1) + window22 = ap.Window([0, 9, 0, 8], image2) + windowlist2 = ap.WindowList([window21, window22]) + + windowlist_and = windowlist & windowlist2 + assert len(windowlist_and) == 2, "WindowList should have two windows after intersection" + assert windowlist_and[0].image is image1, "First window should be from image1" + assert windowlist_and[1].image is image2, "Second window should be from image2" + assert windowlist_and[0].i_low == 5, "First window should have i_low of 5" + assert windowlist_and[0].i_high == 10, "First window should have i_high of 10" + assert windowlist_and[0].j_low == 6, "First window should have j_low of 6" + assert windowlist_and[0].j_high == 9, "First window should have j_high of 9" diff --git a/tests/utils.py b/tests/utils.py index 4d5fb39b..22253db0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -87,22 +87,20 @@ def make_basic_gaussian( ): np.random.seed(rand) - target = ap.image.Target_Image( + target = ap.TargetImage( data=np.zeros((N, M)), pixelscale=pixelscale, psf=ap.utils.initialize.gaussian_psf(2 / pixelscale, 11, pixelscale), ) - MODEL = ap.models.Gaussian_Galaxy( + MODEL = ap.models.GaussianGalaxy( name="basic gaussian source", target=target, - parameters={ - "center": [x, y], - "sigma": sigma, - "flux": flux, - "PA": {"value": 0.0, "locked": True}, - "q": {"value": 0.99, "locked": True}, - }, + center=[x, y], + sigma=sigma, + flux=flux, + PA=0.0, + q=0.99, ) img = MODEL().data.detach().cpu().numpy() From c07de9f19f077f3260f2d80fc8a78896d3256a64 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 18 Jul 2025 16:17:29 -0400 Subject: [PATCH 063/191] fix profile test --- astrophot/plots/profile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index a6654dd4..28fa5101 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -108,7 +108,6 @@ def radial_median_profile( while Rbins[-1] < Rlast_phys: Rbins.append(Rbins[-1] + max(2 * model.target.pixelscale.item(), Rbins[-1] * 0.1)) Rbins = np.array(Rbins) - Rbins = Rbins * model.target.pixel_length.item() # back to physical units with torch.no_grad(): image = model.target[model.window] From 97d602554bdc6292063b99a973fede1fd5fbc913 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 19 Jul 2025 23:38:51 -0400 Subject: [PATCH 064/191] build docstrings for primary models --- astrophot/__init__.py | 4 + astrophot/fit/func/lm.py | 6 +- astrophot/fit/lm.py | 4 +- astrophot/models/__init__.py | 53 ++++ astrophot/models/base.py | 85 ++---- astrophot/models/exponential.py | 25 +- astrophot/models/ferrer.py | 9 + astrophot/models/galaxy_model_object.py | 21 +- astrophot/models/gaussian.py | 24 +- astrophot/models/king.py | 9 + astrophot/models/mixins/brightness.py | 67 +++-- astrophot/models/mixins/exponential.py | 29 +- astrophot/models/mixins/ferrer.py | 39 +++ astrophot/models/mixins/gaussian.py | 32 +++ astrophot/models/mixins/king.py | 39 +++ astrophot/models/mixins/moffat.py | 33 +++ astrophot/models/mixins/nuker.py | 39 +++ astrophot/models/mixins/sample.py | 23 ++ astrophot/models/mixins/sersic.py | 31 ++- astrophot/models/mixins/spline.py | 24 ++ astrophot/models/mixins/transform.py | 174 ++++++------ astrophot/models/model_object.py | 39 +-- astrophot/models/moffat.py | 44 +--- astrophot/models/nuker.py | 29 +- astrophot/models/sersic.py | 20 +- astrophot/models/spline.py | 25 +- astrophot/param/module.py | 10 + .../utils/initialize/segmentation_map.py | 93 ++++--- docs/source/tutorials/AdvancedPSFModels.ipynb | 39 +-- docs/source/tutorials/BasicPSFModels.ipynb | 34 +-- docs/source/tutorials/ConstrainedModels.ipynb | 131 +++++----- docs/source/tutorials/CustomModels.ipynb | 247 ++++++++++-------- docs/source/tutorials/GettingStarted.ipynb | 50 ++-- docs/source/tutorials/GroupModels.ipynb | 29 +- docs/source/tutorials/JointModels.ipynb | 65 ++--- 35 files changed, 932 insertions(+), 693 deletions(-) diff --git a/astrophot/__init__.py b/astrophot/__init__.py index c36afa98..e3df93c7 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -2,6 +2,7 @@ import requests import torch from . import models, plots, utils, fit, AP_config +from .param import forward, Param, Module from .image import ( Image, @@ -155,6 +156,9 @@ def run_from_terminal() -> None: "plots", "utils", "fit", + "forward", + "Param", + "Module", "AP_config", "run_from_terminal", "__version__", diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index ca682772..fbefb472 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -39,8 +39,6 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. hess = hessian(J, weight) # (N, N) if torch.allclose(grad, torch.zeros_like(grad)): raise OptimizeStopSuccess("Gradient is zero, optimization converged.") - print("grad", grad) - print("hess", hess) best = {"x": torch.zeros_like(x), "chi2": chi20, "L": L} scary = {"x": None, "chi2": chi20, "L": L} @@ -50,7 +48,6 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. for _ in range(10): hessD, h = solve(hess, grad, L) # (N, N), (N, 1) M1 = model(x + h.squeeze(1)) # (M,) - print("h", h) chi21 = torch.sum(weight * (data - M1) ** 2).item() / ndf # Handle nan chi2 @@ -64,6 +61,9 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. if chi21 < scary["chi2"]: scary = {"x": x + h.squeeze(1), "chi2": chi21, "L": L} + # if torch.allclose(h, torch.zeros_like(h)): + # raise OptimizeStopSuccess("Step with zero length means optimization complete.") + # actual chi2 improvement vs expected from linearization rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() # Avoid highly non-linear regions diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 41853b5e..312c884d 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -240,7 +240,7 @@ def chi2_ndf(self): return torch.sum(self.W * (self.Y - self.forward(self.current_state)) ** 2) / self.ndf @torch.no_grad() - def fit(self) -> BaseOptimizer: + def fit(self, update_uncertainty=True) -> BaseOptimizer: """This performs the fitting operation. It iterates the LM step function until convergence is reached. Includes a message after fitting to indicate how the fitting exited. Typically if @@ -338,6 +338,8 @@ def fit(self) -> BaseOptimizer: ) self.model.fill_dynamic_values(self.current_state) + if update_uncertainty: + self.update_uncertainty() return self diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 059e85d5..00d58d37 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -102,6 +102,34 @@ SplineWedge, ) +from .mixins import ( + RadialMixin, + WedgeMixin, + RayMixin, + ExponentialMixin, + iExponentialMixin, + FerrerMixin, + iFerrerMixin, + GaussianMixin, + iGaussianMixin, + KingMixin, + iKingMixin, + MoffatMixin, + iMoffatMixin, + NukerMixin, + iNukerMixin, + SersicMixin, + iSersicMixin, + SplineMixin, + iSplineMixin, + SampleMixin, + InclinedMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + TruncationMixin, +) + __all__ = ( "Model", @@ -185,4 +213,29 @@ "SplineSuperEllipse", "SplineRay", "SplineWedge", + "RadialMixin", + "WedgeMixin", + "RayMixin", + "ExponentialMixin", + "iExponentialMixin", + "FerrerMixin", + "iFerrerMixin", + "GaussianMixin", + "iGaussianMixin", + "KingMixin", + "iKingMixin", + "MoffatMixin", + "iMoffatMixin", + "NukerMixin", + "iNukerMixin", + "SersicMixin", + "iSersicMixin", + "SplineMixin", + "iSplineMixin", + "SampleMixin", + "InclinedMixin", + "SuperEllipseMixin", + "FourierEllipseMixin", + "WarpMixin", + "TruncationMixin", ) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 209a6c87..0333586b 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -2,6 +2,7 @@ from copy import deepcopy import torch +import numpy as np from ..param import Module, forward, Param from ..utils.decorators import classproperty @@ -15,71 +16,7 @@ ###################################################################### class Model(Module): - """Core class for all AstroPhot models and model like objects. This - class defines the signatures to interact with AstroPhot models - both for users and internal functions. - - Basic usage: - - .. code-block:: python - - import astrophot as ap - - # Create a model object - model = ap.models.AstroPhot_Model( - name="unique name", - model_type="choose a model type", - target="Target_Image object", - window="[[xmin, xmax],[ymin,ymax]]", # , - parameters="dict of parameter specifications if desired", - ) - - # Initialize parameters that weren't set on creation - model.initialize() - - # Fit model to target - result = ap.fit.lm(model, verbose=1).fit() - - # Plot the model - fig, ax = plt.subplots() - ap.plots.model_image(fig, ax, model) - plt.show() - - # Sample the model - img = model() - pixels = img.data - - AstroPhot models are one of the main ways that one interacts with - the code, either by setting model parameters or passing models to - other objects, one can perform a huge variety of fitting - tasks. The subclass `Component_Model` should be thought of as the - basic unit when constructing a model of an image while a - `Group_Model` is a composite structure that may represent a - complex object, a region of an image, or even a model spanning - many images. Constructing the `Component_Model`s is where most - work goes, these store the actual parameters that will be - optimized. It is important to remember that a `Component_Model` - only ever applies to a single image and a single component (star, - galaxy, or even sub-component of one of those) in that image. - - A complex representation is made by stacking many - `Component_Model`s together, in total this may result in a very - large number of parameters. Trying to find starting values for all - of these parameters can be tedious and error prone, so instead all - built-in AstroPhot models can self initialize and find reasonable - starting parameters for most situations. Even still one may find - that for extremely complex fits, it is more stable to first run an - iterative fitter before global optimization to start the models in - better initial positions. - - Args: - name (Optional[str]): every AstroPhot model should have a unique name - model_type (str): a model type string can determine which kind of AstroPhot model is instantiated. - target (Optional[Target_Image]): A Target_Image object which stores information about the image which the model is trying to fit. - filename (Optional[str]): name of a file to load AstroPhot parameters, window, and name. The model will still need to be told its target, device, and other information - window (Optional[Union[Window, tuple]]): A window on the target image in which the model will be optimized and evaluated. If not provided, the model will assume a window equal to the target it is fitting. The window may be formatted as (i_low, i_high, j_low, j_high) or as ((i_low, j_low), (i_high, j_high)). - - """ + """Base class for all AstroPhot models.""" _model_type = "model" _parameter_specs = {} @@ -230,11 +167,27 @@ def poisson_log_likelihood( return -nll - @forward def total_flux(self, window=None) -> torch.Tensor: F = self(window=window) return torch.sum(F.data) + def total_flux_uncertainty(self, window=None) -> torch.Tensor: + jac = self.jacobian(window=window).flatten("data") + dF = torch.sum(jac, dim=0) # VJP for sum(total_flux) + current_uncertainty = self.build_params_array_uncertainty() + return torch.sqrt(torch.sum((dF * current_uncertainty) ** 2)) + + def total_magnitude(self, window=None) -> torch.Tensor: + """Compute the total magnitude of the model in the given window.""" + F = self.total_flux(window=window) + return -2.5 * torch.log10(F) + self.target.zeropoint + + def total_magnitude_uncertainty(self, window=None) -> torch.Tensor: + """Compute the uncertainty in the total magnitude of the model in the given window.""" + F = self.total_flux(window=window) + dF = self.total_flux_uncertainty(window=window) + return 2.5 * (dF / F) / np.log(10) + @property def window(self) -> Optional[Window]: """The window defines a region on the sky in which this model will be diff --git a/astrophot/models/exponential.py b/astrophot/models/exponential.py index 33f73f43..237d79d3 100644 --- a/astrophot/models/exponential.py +++ b/astrophot/models/exponential.py @@ -1,5 +1,5 @@ from .galaxy_model_object import GalaxyModel - +from ..utils.decorators import combine_docstrings from .psf_model_object import PSFModel from .mixins import ( ExponentialMixin, @@ -23,46 +23,37 @@ ] +@combine_docstrings class ExponentialGalaxy(ExponentialMixin, RadialMixin, GalaxyModel): - """basic galaxy model with a exponential profile for the radial light - profile. The light profile is defined as: - - I(R) = Ie * exp(-b1(R/Re - 1)) - - where I(R) is the brightness as a function of semi-major axis, Ie - is the brightness at the half light radius, b1 is a constant not - involved in the fit, R is the semi-major axis, and Re is the - effective radius. - - Parameters: - Ie: Brightness at half light radius, represented as the log of the brightness divided by pixelscale squared. This is proportional to a surface brightness - Re: half light radius, represented in arcsec. This parameter cannot go below zero. - - """ - usable = True +@combine_docstrings class ExponentialPSF(ExponentialMixin, RadialMixin, PSFModel): _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0}} usable = True +@combine_docstrings class ExponentialSuperEllipse(ExponentialMixin, RadialMixin, SuperEllipseMixin, GalaxyModel): usable = True +@combine_docstrings class ExponentialFourierEllipse(ExponentialMixin, RadialMixin, FourierEllipseMixin, GalaxyModel): usable = True +@combine_docstrings class ExponentialWarp(ExponentialMixin, RadialMixin, WarpMixin, GalaxyModel): usable = True +@combine_docstrings class ExponentialRay(iExponentialMixin, RayMixin, GalaxyModel): usable = True +@combine_docstrings class ExponentialWedge(iExponentialMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/models/ferrer.py b/astrophot/models/ferrer.py index a6e1c573..b59f5c18 100644 --- a/astrophot/models/ferrer.py +++ b/astrophot/models/ferrer.py @@ -10,6 +10,8 @@ WarpMixin, iFerrerMixin, ) +from ..utils.decorators import combine_docstrings + __all__ = ( "FerrerGalaxy", @@ -22,30 +24,37 @@ ) +@combine_docstrings class FerrerGalaxy(FerrerMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class FerrerPSF(FerrerMixin, RadialMixin, PSFModel): _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} usable = True +@combine_docstrings class FerrerSuperEllipse(FerrerMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class FerrerFourierEllipse(FerrerMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class FerrerWarp(FerrerMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class FerrerRay(iFerrerMixin, RayMixin, GalaxyModel): usable = True +@combine_docstrings class FerrerWedge(iFerrerMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index fb07831b..6b708963 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -6,26 +6,7 @@ class GalaxyModel(InclinedMixin, ComponentModel): - """General galaxy model to be subclassed for any specific - representation. Defines a galaxy as an object with a position - angle and axis ratio, or effectively a tilted disk. Most - subclassing models should simply define a radial model or update - to the coordinate transform. The definition of the position angle and axis ratio used here is simply a scaling along the minor axis. The transformation can be written as: - - X, Y = meshgrid(image) - X', Y' = Rot(theta, X, Y) - Y'' = Y' / q - - where X Y are the coordinates of an image, X' Y' are the rotated - coordinates, Rot is a rotation matrix by angle theta applied to the - initial X Y coordinates, Y'' is the scaled semi-minor axis, and q - is the axis ratio. - - Parameters: - q: axis ratio to scale minor axis from the ratio of the minor/major axis b/a, this parameter is unitless, it is restricted to the range (0,1) - PA: position angle of the smei-major axis relative to the image positive x-axis in radians, it is a cyclic parameter in the range [0,pi) - - """ + """Intended to represent a galaxy or extended component in an image.""" _model_type = "galaxy" usable = False diff --git a/astrophot/models/gaussian.py b/astrophot/models/gaussian.py index 39f5ec73..1dcdcb08 100644 --- a/astrophot/models/gaussian.py +++ b/astrophot/models/gaussian.py @@ -11,6 +11,8 @@ WarpMixin, iGaussianMixin, ) +from ..utils.decorators import combine_docstrings + __all__ = [ "GaussianGalaxy", @@ -23,45 +25,37 @@ ] +@combine_docstrings class GaussianGalaxy(GaussianMixin, RadialMixin, GalaxyModel): - """Basic galaxy model with Gaussian as the radial light profile. The - gaussian radial profile is defined as: - - I(R) = F * exp(-0.5 R^2/S^2) / sqrt(2pi*S^2) - - where I(R) is the prightness as a function of semi-major axis - length, F is the total flux in the model, R is the semi-major - axis, and S is the standard deviation. - - Parameters: - sigma: standard deviation of the gaussian profile, must be a positive value - flux: the total flux in the gaussian model, represented as the log of the total - - """ - usable = True +@combine_docstrings class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): _parameter_specs = {"flux": {"units": "flux", "value": 1.0}} usable = True +@combine_docstrings class GaussianSuperEllipse(GaussianMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class GaussianFourierEllipse(GaussianMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class GaussianWarp(GaussianMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class GaussianRay(iGaussianMixin, RayMixin, GalaxyModel): usable = True +@combine_docstrings class GaussianWedge(iGaussianMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/models/king.py b/astrophot/models/king.py index 21287ad1..f3f4149c 100644 --- a/astrophot/models/king.py +++ b/astrophot/models/king.py @@ -10,6 +10,8 @@ WarpMixin, iKingMixin, ) +from ..utils.decorators import combine_docstrings + __all__ = ( "KingGalaxy", @@ -22,30 +24,37 @@ ) +@combine_docstrings class KingGalaxy(KingMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class KingPSF(KingMixin, RadialMixin, PSFModel): _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} usable = True +@combine_docstrings class KingSuperEllipse(KingMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class KingFourierEllipse(KingMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class KingWarp(KingMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class KingRay(iKingMixin, RayMixin, GalaxyModel): usable = True +@combine_docstrings class KingWedge(iKingMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index 8154b21d..154493c5 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -5,6 +5,21 @@ class RadialMixin: + """This model defines its `brightness(x,y)` function using a radial model. + Thus the brightness is instead defined as`radial_model(R)` + + More specifically the function is: + + $$x, y = {\\rm transform\\_coordinates}(x, y)$$ + $$R = {\\rm radius\\_metric}(x, y)$$ + $$I(x, y) = {\\rm radial\\_model}(R)$$ + + The `transform_coordinates` function depends on the model. In its simplest + form it simply subtracts the center of the model to re-center the coordinates. + + The `radius_metric` function is also model dependent, in its simplest form + this is just $R = \\sqrt{x^2 + y^2}$. + """ @forward def brightness(self, x, y): @@ -16,19 +31,17 @@ def brightness(self, x, y): class WedgeMixin: - """Variant of the ray model where no smooth transition is performed - between regions as a function of theta, instead there is a sharp - trnasition boundary. This may be desirable as it cleanly - separates where the pixel information is going. Due to the sharp - transition though, it may cause unusual behaviour when fitting. If - problems occur, try fitting a ray model first then fix the center, - PA, and q and then fit the wedge model. Essentially this breaks - down the structure fitting and the light profile fitting into two - steps. The wedge model, like the ray model, defines no extra - parameters, however a new option can be supplied on instantiation - of the wedge model which is "wedges" or the number of wedges in - the model. + """Defines a model with multiple profiles that form wedges projected from the center. + + model which defines multiple radial models separately along some number of + wedges projected from the center. These wedges have sharp transitions along boundary angles theta. + Options: + symmetric: If True, the model will have symmetry for rotations of pi radians + and each ray will appear twice on the sky on opposite sides of the model. + If False, each ray is independent. + segments: The number of segments to divide the model into. This controls + how many rays are used in the model. The default is 2 """ _model_type = "wedge" @@ -56,25 +69,25 @@ def brightness(self, x, y): class RayMixin: - """Variant of a galaxy model which defines multiple radial models - seprarately along some number of rays projected from the galaxy - center. These rays smoothly transition from one to another along - angles theta. The ray transition uses a cosine smoothing function - which depends on the number of rays, for example with two rays the + """Defines a model with multiple profiles along rays projected from the center. + + model which defines multiple radial models separately along some number of + rays projected from the center. These rays smoothly transition from one to + another along angles theta. The ray transition uses a cosine smoothing + function which depends on the number of rays, for example with two rays the brightness would be: - I(R,theta) = I1(R)*cos(theta % pi) + I2(R)*cos((theta + pi/2) % pi) + $$I(R,theta) = I_1(R)*\\cos(\\theta \\% \\pi) + I_2(R)*\\cos((theta + \\pi/2) \\% \\pi)$$ - Where I(R,theta) is the brightness function in polar coordinates, - R is the semi-major axis, theta is the polar angle (defined after - galaxy axis ratio is applied), I1(R) is the first brightness - profile, % is the modulo operator, and I2 is the second brightness - profile. The ray model defines no extra parameters, though now - every model parameter related to the brightness profile gains an - extra dimension for the ray number. Also a new input can be given - when instantiating the ray model: "rays" which is an integer for - the number of rays. + For `theta = 0` the brightness comes entirely from `I_1` while for `theta = pi/2` + the brightness comes entirely from `I_2`. + Options: + symmetric: If True, the model will have symmetry for rotations of pi radians + and each ray will appear twice on the sky on opposite sides of the model. + If False, each ray is independent. + segments: The number of segments to divide the model into. This controls + how many rays are used in the model. The default is 2 """ _model_type = "ray" diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 2f05057e..36c1966b 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -12,14 +12,15 @@ def _x0_func(model_params, R, F): class ExponentialMixin: - """Mixin for models that use an exponential profile for the radial light - profile. The functional form of the exponential profile is defined as: + """Exponential radial light profile. - I(R) = Ie * exp(- (R / Re)) + An exponential is a classical radial model used in many contexts. The + functional form of the exponential profile is defined as: - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness at the - effective radius, and Re is the effective radius. + $$I(R) = I_e * \\exp(- b_1(\\frac{R}{R_e} - 1))$$ + + Ie is the brightness at the effective radius, and Re is the effective + radius. `b_1` is a constant that ensures `Ie` is the brightness at `R_e`. Parameters: Re: effective radius in arcseconds @@ -51,14 +52,18 @@ def radial_model(self, R, Re, Ie): class iExponentialMixin: - """Mixin for models that use an exponential profile for the radial light - profile. The functional form of the exponential profile is defined as: + """Exponential radial light profile. + + An exponential is a classical radial model used in many contexts. The + functional form of the exponential profile is defined as: + + $$I(R) = I_e * \\exp(- b_1(\\frac{R}{R_e} - 1))$$ - I(R) = Ie * exp(- (R / Re)) + Ie is the brightness at the effective radius, and Re is the effective + radius. `b_1` is a constant that ensures `Ie` is the brightness at `R_e`. - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness at the - effective radius, and Re is the effective radius. + `Re` and `Ie` are batched by their first dimension, allowing for multiple + exponential profiles to be defined at once. Parameters: Re: effective radius in arcseconds diff --git a/astrophot/models/mixins/ferrer.py b/astrophot/models/mixins/ferrer.py index 4d378889..c8491d7f 100644 --- a/astrophot/models/mixins/ferrer.py +++ b/astrophot/models/mixins/ferrer.py @@ -12,6 +12,24 @@ def x0_func(model_params, R, F): class FerrerMixin: + """Modified Ferrer radial light profile (Binney & Tremaine 1987). + + This model has a relatively flat brightness core and then a truncation. It + is used in specialized circumstances such as fitting the bar of a galaxy. + The functional form of the Modified Ferrer profile is defined as: + + $$I(R) = I_0 \\left(1 - \\left(\\frac{R}{r_{\\rm out}}\\right)^{2-\\beta}\\right)^{\\alpha}$$ + + where `rout` is the outer truncation radius, `alpha` controls the steepness + of the truncation, `beta` controls the shape, and `I0` is the intensity at + the center of the profile. + + Parameters: + rout: Outer truncation radius in arcseconds. + alpha: Inner slope parameter. + beta: Outer slope parameter. + I0: Intensity at the center of the profile in flux/arcsec^2 + """ _model_type = "ferrer" _parameter_specs = { @@ -40,6 +58,27 @@ def radial_model(self, R, rout, alpha, beta, I0): class iFerrerMixin: + """Modified Ferrer radial light profile (Binney & Tremaine 1987). + + This model has a relatively flat brightness core and then a truncation. It + is used in specialized circumstances such as fitting the bar of a galaxy. + The functional form of the Modified Ferrer profile is defined as: + + $$I(R) = I_0 \\left(1 - \\left(\\frac{R}{r_{\\rm out}}\\right)^{2-\\beta}\\right)^{\\alpha}$$ + + where `rout` is the outer truncation radius, `alpha` controls the steepness + of the truncation, `beta` controls the shape, and `I0` is the intensity at + the center of the profile. + + `rout`, `alpha`, `beta`, and `I0` are batched by their first dimension, + allowing for multiple Ferrer profiles to be defined at once. + + Parameters: + rout: Outer truncation radius in arcseconds. + alpha: Inner slope parameter. + beta: Outer slope parameter. + I0: Intensity at the center of the profile in flux/arcsec^2 + """ _model_type = "ferrer" _parameter_specs = { diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index fdf47a08..2485f8fe 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -12,6 +12,20 @@ def _x0_func(model_params, R, F): class GaussianMixin: + """Gaussian radial light profile. + + The Gaussian profile is a simple and widely used model for extended objects. + The functional form of the Gaussian profile is defined as: + + $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \exp(-R^2 / (2 \sigma^2))$$ + + where `I_0` is the intensity at the center of the profile and `sigma` is the + standard deviation which controls the width of the profile. + + Parameters: + sigma: Standard deviation of the Gaussian profile in arcseconds. + flux: Total flux of the Gaussian profile. + """ _model_type = "gaussian" _parameter_specs = { @@ -38,6 +52,24 @@ def radial_model(self, R, sigma, flux): class iGaussianMixin: + """Gaussian radial light profile. + + The Gaussian profile is a simple and widely used model for extended objects. + The functional form of the Gaussian profile is defined as: + + $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \exp(-R^2 / (2 \sigma^2))$$ + + where `sigma` is the standard deviation which controls the width of the + profile and `flux` gives the total flux of the profile (assuming no + perturbations). + + `sigma` and `flux` are batched by their first dimension, allowing for + multiple Gaussian profiles to be defined at once. + + Parameters: + sigma: Standard deviation of the Gaussian profile in arcseconds. + flux: Total flux of the Gaussian profile. + """ _model_type = "gaussian" _parameter_specs = { diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index e6cc5c9a..de660c3d 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -13,6 +13,24 @@ def x0_func(model_params, R, F): class KingMixin: + """Empirical King radial light profile (Elson 1999). + + Often used for star clusters. By default the profile has `alpha = 2` but we + allow the parameter to vary freely for fitting. The functional form of the + Empirical King profile is defined as: + + $$I(R) = I_0\\left[\\frac{1}{(1 + (R/R_c)^2)^{1/\\alpha}} - \\frac{1}{(1 + (R_t/R_c)^2)^{1/\\alpha}}\\right]^{\\alpha}\\left[1 - \\frac{1}{(1 + (R_t/R_c)^2)^{1/\\alpha}}\\right]^{-\\alpha}$$ + + where `R_c` is the core radius, `R_t` is the truncation radius, and `I_0` is + the intensity at the center of the profile. `alpha` is the concentration + index which controls the shape of the profile. + + Parameters: + Rc: core radius + Rt: truncation radius + alpha: concentration index which controls the shape of the brightness profile + I0: intensity at the center of the profile + """ _model_type = "king" _parameter_specs = { @@ -44,6 +62,27 @@ def radial_model(self, R, Rc, Rt, alpha, I0): class iKingMixin: + """Empirical King radial light profile (Elson 1999). + + Often used for star clusters. By default the profile has `alpha = 2` but we + allow the parameter to vary freely for fitting. The functional form of the + Empirical King profile is defined as: + + $$I(R) = I_0\\left[\\frac{1}{(1 + (R/R_c)^2)^{1/\\alpha}} - \\frac{1}{(1 + (R_t/R_c)^2)^{1/\\alpha}}\\right]^{\\alpha}\\left[1 - \\frac{1}{(1 + (R_t/R_c)^2)^{1/\\alpha}}\\right]^{-\\alpha}$$ + + where `R_c` is the core radius, `R_t` is the truncation radius, and `I_0` is + the intensity at the center of the profile. `alpha` is the concentration + index which controls the shape of the profile. + + `Rc`, `Rt`, `alpha`, and `I0` are batched by their first dimension, allowing + for multiple King profiles to be defined at once. + + Parameters: + Rc: core radius + Rt: truncation radius + alpha: concentration index which controls the shape of the brightness profile + I0: intensity at the center of the profile + """ _model_type = "king" _parameter_specs = { diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 4be4ddf9..1e8c21aa 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -13,6 +13,21 @@ def _x0_func(model_params, R, F): class MoffatMixin: + """Moffat radial light profile (Moffat 1969). + + The moffat profile gives a good representation of the gneeral structure of + PSF functions for ground based data. It can also be used to fit extended + objects. The functional form of the Moffat profile is defined as: + + $$I(R) = \\frac{I_0}{(1 + (R/R_d)^2)^n}$$ + + n is the concentration index which controls the shape of the profile. + + Parameters: + n: Concentration index which controls the shape of the brightness profile + Rd: Scale length radius + I0: Intensity at the center of the profile + """ _model_type = "moffat" _parameter_specs = { @@ -40,6 +55,24 @@ def radial_model(self, R, n, Rd, I0): class iMoffatMixin: + """Moffat radial light profile (Moffat 1969). + + The moffat profile gives a good representation of the gneeral structure of + PSF functions for ground based data. It can also be used to fit extended + objects. The functional form of the Moffat profile is defined as: + + $$I(R) = \\frac{I_0}{(1 + (R/R_d)^2)^n}$$ + + n is the concentration index which controls the shape of the profile. + + `n`, `Rd`, and `I0` are batched by their first dimension, allowing for + multiple Moffat profiles to be defined at once. + + Parameters: + n: Concentration index which controls the shape of the brightness profile + Rd: Scale length radius + I0: Intensity at the center of the profile + """ _model_type = "moffat" _parameter_specs = { diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 611127f8..f138b15d 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -12,6 +12,24 @@ def _x0_func(model_params, R, F): class NukerMixin: + """Nuker radial light profile (Lauer et al. 1995). + + This is a classic profile used widely in galaxy modelling. The functional + form of the Nuker profile is defined as: + + $$I(R) = I_b2^{\\frac{\\beta - \\gamma}{\\alpha}}\\left(\\frac{R}{R_b}\\right)^{-\\gamma}\\left[1 + \\left(\\frac{R}{R_b}\\right)^{\\alpha}\\right]^{\\frac{\\gamma-\\beta}{\\alpha}}$$ + + It is effectively a double power law profile. $\\gamma$ gives the inner + slope, $\\beta$ gives the outer slope, $\\alpha$ is somewhat degenerate with + the other slopes. + + Parameters: + Rb: scale length radius + Ib: intensity at the scale length + alpha: sharpness of transition between power law slopes + beta: outer power law slope + gamma: inner power law slope + """ _model_type = "nuker" _parameter_specs = { @@ -41,6 +59,27 @@ def radial_model(self, R, Rb, Ib, alpha, beta, gamma): class iNukerMixin: + """Nuker radial light profile (Lauer et al. 1995). + + This is a classic profile used widely in galaxy modelling. The functional + form of the Nuker profile is defined as: + + $$I(R) = I_b2^{\\frac{\\beta - \\gamma}{\\alpha}}\\left(\\frac{R}{R_b}\\right)^{-\\gamma}\\left[1 + \\left(\\frac{R}{R_b}\\right)^{\\alpha}\\right]^{\\frac{\\gamma-\\beta}{\\alpha}}$$ + + It is effectively a double power law profile. $\\gamma$ gives the inner + slope, $\\beta$ gives the outer slope, $\\alpha$ is somewhat degenerate with + the other slopes. + + `Rb`, `Ib`, `alpha`, `beta`, and `gamma` are batched by their first + dimension, allowing for multiple Nuker profiles to be defined at once. + + Parameters: + Rb: scale length radius + Ib: intensity at the scale length + alpha: sharpness of transition between power law slopes + beta: outer power law slope + gamma: inner power law slope + """ _model_type = "nuker" _parameter_specs = { diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index a56ea370..72f4f3eb 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -13,6 +13,29 @@ class SampleMixin: + """ + options: + sampling_mode: The method used to sample the model in image pixels. Options are: + - auto: Automatically choose the sampling method based on the image size. + - midpoint: Use midpoint sampling, evaluate the brightness at the center of each pixel. + - simpsons: Use Simpson's rule for sampling integrating each pixel. + - quad:x: Use quadrature sampling with order x, where x is a positive integer to integrate each pixel. + jacobian_maxparams: The maximum number of parameters before the Jacobian will be broken into + smaller chunks. This is helpful for limiting the memory requirements to build a model. + jacobian_maxpixels: The maximum number of pixels before the Jacobian will be broken into + smaller chunks. This is helpful for limiting the memory requirements to build a model. + integrate_mode: The method used to select pixels to integrate further where the model varies significantly. Options are: + - none: No extra integration is performed (beyond the sampling_mode). + - bright: Select the brightest pixels for further integration. + - threshold: Select pixels which show signs of significant higher order derivatives. + integrate_tolerance: The tolerance for selecting a pixel in the integration method. This is the total flux fraction + that is integrated over the image. + integrate_fraction: The fraction of the pixels to super sample during integration. + integrate_max_depth: The maximum depth of the integration method. + integrate_gridding: The gridding used for the integration method to super-sample a pixel at each iteration. + integrate_quad_order: The order of the quadrature used for the integration method on the super sampled pixels. + """ + # Method for initial sampling of model sampling_mode = "auto" # auto (choose based on image size), midpoint, simpsons, quad:x (where x is a positive integer) diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index a9e628b7..a14b5393 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -12,15 +12,23 @@ def _x0_func(model, R, F): class SersicMixin: - """Sersic radial light profile. The functional form of the Sersic profile is defined as: + """Sersic radial light profile (Sersic 1963). - $$I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1))$$ + This is a classic profile used widely in galaxy modelling. It can be a good + starting point for many extended objects. The functional form of the Sersic + profile is defined as: + + $$I(R) = I_e * \\exp(- b_n((R/R_e)^(1/n) - 1))$$ + + It is a generalization of a gaussian, exponential, and de-Vaucouleurs + profile. The Sersic index `n` controls the shape of the profile, with `n=1` + being an exponential profile, `n=4` being a de-Vaucouleurs profile, and + `n=0.5` being a Gaussian profile. Parameters: n: Sersic index which controls the shape of the brightness profile Re: half light radius [arcsec] Ie: intensity at the half light radius [flux/arcsec^2] - """ _model_type = "sersic" @@ -45,15 +53,26 @@ def radial_model(self, R, n, Re, Ie): class iSersicMixin: - """Sersic radial light profile. The functional form of the Sersic profile is defined as: + """Sersic radial light profile (Sersic 1963). + + This is a classic profile used widely in galaxy modelling. It can be a good + starting point for many extended objects. The functional form of the Sersic + profile is defined as: - $$I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1))$$ + $$I(R) = I_e * \\exp(- b_n((R/R_e)^(1/n) - 1))$$ + + It is a generalization of a gaussian, exponential, and de-Vaucouleurs + profile. The Sersic index `n` controls the shape of the profile, with `n=1` + being an exponential profile, `n=4` being a de-Vaucouleurs profile, and + `n=0.5` being a Gaussian profile. + + `n`, `Re`, and `Ie` are batched by their first dimension, allowing for + multiple Sersic profiles to be defined at once. Parameters: n: Sersic index which controls the shape of the brightness profile Re: half light radius [arcsec] Ie: intensity at the half light radius [flux/arcsec^2] - """ _model_type = "sersic" diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 62e04ff9..5bd38ef6 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -9,6 +9,16 @@ class SplineMixin: + """Spline radial model for brightness. + + The `radial_model` function for this model is defined as a spline + interpolation from the parameter `I_R`. The `I_R` parameter is a tensor + that contains the radial profile of the brightness in units of + flux/arcsec^2. The radius of each node is determined from `I_R.prof`. + + Parameters: + I_R: Tensor of radial brightness values in units of flux/arcsec^2. + """ _model_type = "spline" _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None)}} @@ -43,6 +53,20 @@ def radial_model(self, R, I_R): class iSplineMixin: + """Batched spline radial model for brightness. + + The `radial_model` function for this model is defined as a spline + interpolation from the parameter `I_R`. The `I_R` parameter is a tensor that + contains the radial profile of the brightness in units of flux/arcsec^2. The + radius of each node is determined from `I_R.prof`. + + Both `I_R` and `I_R.prof` are batched by their first dimension, allowing for + multiple spline profiles to be defined at once. Each individual spline model + is then `I_R[i]` and `I_R.prof[i]` where `i` indexes the profiles. + + Parameters: + I_R: Tensor of radial brightness values in units of flux/arcsec^2. + """ _model_type = "spline" _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None)}} diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index a3d6ca73..37f614a0 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -9,6 +9,23 @@ class InclinedMixin: + """A model which defines a position angle and axis ratio. + + PA and q operate on the coordinates to transform the model. Given some x,y + the updated values are: + + $$x', y' = \\rm{rotate}(-PA + \\pi/2, x, y)$$ + $$y'' = y' / q$$ + + where x' and y'' are the final transformed coordinates. The pi/2 is included + such that the position angle is defined with 0 at north. The -PA is such + that the position angle increases to the East. Thus, the position angle is a + standard East of North definition assuming the WCS of the image is correct. + + Note that this means radii are defined with $R = \\sqrt{x^2 + + (\\frac{y}{q})^2}$ rather than the common alternative which is $R = + \\sqrt{qx^2 + \\frac{y^2}{q}}$ + """ _parameter_specs = { "q": {"units": "b/a", "valid": (0.01, 1), "shape": ()}, @@ -53,22 +70,20 @@ def initialize(self): @forward def transform_coordinates(self, x, y, PA, q): - """ - Transform coordinates based on the position angle and axis ratio. - """ x, y = super().transform_coordinates(x, y) x, y = func.rotate(-PA + np.pi / 2, x, y) return x, y / q class SuperEllipseMixin: - """Expanded galaxy model which includes a superellipse transformation - in its radius metric. This allows for the expression of "boxy" and - "disky" isophotes instead of pure ellipses. This is a common + """Generalizes the definition of radius and so modifies the evaluation of radial models. + + A superellipse transformation allows for the expression of "boxy" and + "disky" modifications to traditional elliptical isophotes. This is a common extension of the standard elliptical representation, especially for early-type galaxies. The functional form for this is: - R = (|X|^C + |Y|^C)^(1/C) + $$R = (|x|^C + |y|^C)^(1/C)$$ where R is the new distance metric, X Y are the coordinates, and C is the coefficient for the superellipse. C can take on any value @@ -92,44 +107,43 @@ def radius_metric(self, x, y, C): class FourierEllipseMixin: - """Expanded galaxy model which includes a Fourier transformation in - its radius metric. This allows for the expression of arbitrarily - complex isophotes instead of pure ellipses. This is a common - extension of the standard elliptical representation. The form of - the Fourier perturbations is: - - R' = R * exp(sum_m(a_m * cos(m * theta + phi_m))) - - where R' is the new radius value, R is the original ellipse - radius, a_m is the amplitude of the m'th Fourier mode, m is the - index of the Fourier mode, theta is the angle around the ellipse, - and phi_m is the phase of the m'th fourier mode. This - representation is somewhat different from other Fourier mode - implementations where instead of an expoenntial it is just 1 + - sum_m(...), we opt for this formulation as it is more numerically - stable. It cannot ever produce negative radii, but to first order - the two representation are the same as can be seen by a Taylor - expansion of exp(x) = 1 + x + O(x^2). - - One can create extremely complex shapes using different Fourier - modes, however usually it is only low order modes that are of - interest. For intuition, the first Fourier mode is roughly - equivalent to a lopsided galaxy, one side will be compressed and - the opposite side will be expanded. The second mode is almost - never used as it is nearly degenerate with ellipticity. The third - mode is an alternate kind of lopsidedness for a galaxy which makes - it somewhat triangular, meaning that it is wider on one side than - the other. The fourth mode is similar to a boxyness/diskyness - parameter which tends to make more pronounced peanut shapes since - it is more rounded than a superellipse representation. Modes - higher than 4 are only useful in very specialized situations. In - general one should consider carefully why the Fourier modes are - being used for the science case at hand. + """Sine wave perturbation of the elliptical radius metric. + + This allows for the expression of arbitrarily complex isophotes instead of + pure ellipses. This is a common extension of the standard elliptical + representation. The form of the Fourier perturbations is: + + $$R' = R * \\exp(\\sum_m(a_m * \\cos(m * \\theta + \\phi_m)))$$ + + where R' is the new radius value, R is the original radius (typically + computed as $\\sqrt{x^2+y^2}$), m is the index of the Fourier mode, a_m is + the amplitude of the m'th Fourier mode, theta is the angle around the + ellipse (typically $\\arctan(y/x)$), and phi_m is the phase of the m'th + fourier mode. + + One can create extremely complex shapes using different Fourier modes, + however usually it is only low order modes that are of interest. For + intuition, the first Fourier mode is roughly equivalent to a lopsided + galaxy, one side will be compressed and the opposite side will be expanded. + The second mode is almost never used as it is nearly degenerate with + ellipticity. The third mode is an alternate kind of lopsidedness for a + galaxy which makes it somewhat triangular, meaning that it is wider on one + side than the other. The fourth mode is similar to a boxyness/diskyness + parameter of a superelllipse which tends to make more pronounced peanut + shapes since it is more rounded than a superellipse representation. Modes + higher than 4 are only useful in very specialized situations. In general one + should consider carefully why the Fourier modes are being used for the + science case at hand. Parameters: - am: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. - phi_m: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) - + am: Tensor of amplitudes for the Fourier modes, indicates the strength + of each mode. + phim: Tensor of phases for the Fourier modes, adjusts the + orientation of the mode perturbation relative to the major axis. It + is cyclically defined in the range [0,2pi) + + Options: + modes: Tuple of integers indicating which Fourier modes to use. """ _model_type = "fourier" @@ -167,28 +181,26 @@ def initialize(self): class WarpMixin: - """Galaxy model which includes radially varrying PA and q - profiles. This works by warping the coordinates using the same - transform for a global PA/q except applied to each pixel - individually. In the limit that PA and q are a constant, this - recovers a basic galaxy model with global PA/q. However, a linear - PA profile will give a spiral appearance, variations of PA/q - profiles can create complex galaxy models. The form of the - coordinate transformation looks like: - - X, Y = meshgrid(image) - R = sqrt(X^2 + Y^2) - X', Y' = Rot(theta(R), X, Y) - Y'' = Y' / q(R) - - where the definitions are the same as for a regular galaxy model, - except now the theta is a function of radius R (before - transformation) and the axis ratio q is also a function of radius - (before the transformation). + """Warped model with varying PA and q as a function of radius. + + This works by warping the coordinates using the same transform for a global + PA, q except applied to each pixel individually based on its unwarped radius + value. In the limit that PA and q are a constant, this recovers a basic + model with global PA, q. However, a linear PA profile will give a spiral + appearance, variations of PA, q profiles can create complex galaxy models. + The form of the coordinate transformation for each pixel looks like: + + $$R = \\sqrt{x^2 + y^2}$$ + $$x', y' = \\rm{rotate}(-PA(R) + \\pi/2, x, y)$$ + $$y'' = y' / q(R)$$ + + Note that now PA and q are functions of radius R, which is computed from the + original coordinates X, Y. This is achieved by making PA and q a spline + profile. Parameters: - q(R): Tensor of axis ratio values for axis ratio spline - PA(R): Tensor of position angle values as input to the spline + q_R: Tensor of axis ratio values for axis ratio spline + PA_R: Tensor of position angle values as input to the spline """ @@ -218,23 +230,39 @@ def transform_coordinates(self, x, y, q_R, PA_R): R = self.radius_metric(x, y) PA = func.spline(R, self.PA_R.prof, PA_R, extend="const") q = func.spline(R, self.q_R.prof, q_R, extend="const") - x, y = func.rotate(PA, x, y) + x, y = func.rotate(-PA + np.pi / 2, x, y) return x, y / q class TruncationMixin: - """Mixin for models that include a truncation radius. This is used to - limit the radial extent of the model, effectively setting a maximum - radius beyond which the model's brightness is zero. + """Truncated model with radial brightness profile. + + This model will smoothly truncate the radial brightness profile at Rt. The + truncation is centered on Rt and thus two identical models with the same Rt + (and St) where one is inner truncated and the other is outer truncated will + reproduce nearly the same as a single un-truncated model. + + By default the St parameter is set fixed to 1.0, giving a relatively smooth + truncation. This can be set to a smaller value for sharper truncations or a + larger value for even more gradual truncation. It can be set dynamic to be + optimized in a model, though it is possible for this parameter to be + unstable if there isn't a clear truncation signal in the data. Parameters: - R_trunc: The truncation radius in arcseconds. + Rt: The truncation radius in arcseconds. + St: The steepness of the truncation profile, controlling how quickly + the brightness drops to zero at the truncation radius. + + Options: + outer_truncation: If True, the model will truncate the brightness beyond + the truncation radius. If False, the model will truncate the + brightness within the truncation radius. """ _model_type = "truncated" _parameter_specs = { "Rt": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "sharpness": {"units": "none", "valid": (0, None), "shape": ()}, + "St": {"units": "none", "valid": (0, None), "shape": (), "value": 1.0}, } _options = ("outer_truncation",) @@ -249,12 +277,10 @@ def initialize(self): if not self.Rt.initialized: prof = default_prof(self.window.shape, self.target.pixelscale, 2, 0.2) self.Rt.dynamic_value = prof[len(prof) // 2] - if not self.sharpness.initialized: - self.sharpness.dynamic_value = 1.0 @forward - def radial_model(self, R, Rt, sharpness): + def radial_model(self, R, Rt, St): I = super().radial_model(R) if self.outer_truncation: - return I * (1 - torch.tanh(sharpness * (R - Rt))) / 2 - return I * (torch.tanh(sharpness * (R - Rt)) + 1) / 2 + return I * (1 - torch.tanh(St * (R - Rt))) / 2 + return I * (torch.tanh(St * (R - Rt)) + 1) / 2 diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index ecc222ba..c0042c69 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -21,42 +21,21 @@ class ComponentModel(SampleMixin, Model): - """Component_Model(name, target, window, locked, **kwargs) - - Component_Model is a base class for models that represent single - objects or parametric forms. It provides the basis for subclassing - models and requires the definition of parameters, initialization, - and model evaluation functions. This class also handles - integration, PSF convolution, and computing the Jacobian matrix. - - Attributes: - parameter_specs (dict): Specifications for the model parameters. - _parameter_order (tuple): Fixed order of parameters. - psf_mode (str): Technique and scope for PSF convolution. - sampling_mode (str): Method for initial sampling of model. Can be one of midpoint, trapezoid, simpson. Default: midpoint - sampling_tolerance (float): accuracy to which each pixel should be evaluated. Default: 1e-2 - integrate_mode (str): Integration scope for the model. One of none, threshold, full where threshold will select which pixels to integrate while full (in development) will integrate all pixels. Default: threshold - integrate_max_depth (int): Maximum recursion depth when performing sub pixel integration. - integrate_gridding (int): Amount by which to subdivide pixels when doing recursive pixel integration. - integrate_quad_level (int): The initial quadrature level for sub pixel integration. Please always choose an odd number 3 or higher. - softening (float): Softening length used for numerical stability and integration stability to avoid discontinuities (near R=0). Effectively has units of arcsec. Default: 1e-5 - jacobian_chunksize (int): Maximum size of parameter list before jacobian will be broken into smaller chunks. - special_kwargs (list): Parameters which are treated specially by the model object and should not be updated directly. - usable (bool): Indicates if the model is usable. - - Methods: - initialize: Determine initial values for the center coordinates. - sample: Evaluate the model on the space covered by an image object. - jacobian: Compute the Jacobian matrix for this model. + """Component of a model for an object in an image. + + This is a single component of an image model. It has a position on the sky + determined by `center` and may or may not be convolved with a PSF to represent some data. + + Options: + psf_convolve: Whether to convolve the model with a PSF. (bool) """ _parameter_specs = {"center": {"units": "arcsec", "shape": (2,)}} - # Scope for PSF convolution - psf_convolve = False - _options = ("psf_convolve",) + psf_convolve: bool = False + usable = False def __init__(self, *args, psf=None, **kwargs): diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py index ef4b5a29..d0432e7b 100644 --- a/astrophot/models/moffat.py +++ b/astrophot/models/moffat.py @@ -14,6 +14,8 @@ WarpMixin, iMoffatMixin, ) +from ..utils.decorators import combine_docstrings + __all__ = ( "MoffatGalaxy", @@ -27,24 +29,8 @@ ) +@combine_docstrings class MoffatGalaxy(MoffatMixin, RadialMixin, GalaxyModel): - """basic galaxy model with a Moffat profile for the radial light - profile. The functional form of the Moffat profile is defined as: - - I(R) = I0 / (1 + (R/Rd)^2)^n - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, I0 is the central flux - density, Rd is the scale length for the profile, and n is the - concentration index which controls the shape of the profile. - - Parameters: - n: Concentration index which controls the shape of the brightness profile - I0: brightness at the center of the profile, represented as the log of the brightness divided by pixel scale squared. - Rd: scale length radius - - """ - usable = True @forward @@ -52,24 +38,8 @@ def total_flux(self, n, Rd, I0, q): return moffat_I0_to_flux(I0, n, Rd, q) +@combine_docstrings class MoffatPSF(MoffatMixin, RadialMixin, PSFModel): - """basic point source model with a Moffat profile for the radial light - profile. The functional form of the Moffat profile is defined as: - - I(R) = I0 / (1 + (R/Rd)^2)^n - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, I0 is the central flux - density, Rd is the scale length for the profile, and n is the - concentration index which controls the shape of the profile. - - Parameters: - n: Concentration index which controls the shape of the brightness profile - I0: brightness at the center of the profile, represented as the log of the brightness divided by pixel scale squared. - Rd: scale length radius - - """ - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} usable = True @@ -79,6 +49,7 @@ def total_flux(self, n, Rd, I0): return moffat_I0_to_flux(I0, n, Rd, 1.0) +@combine_docstrings class Moffat2DPSF(MoffatMixin, InclinedMixin, RadialMixin, PSFModel): _model_type = "2d" @@ -90,21 +61,26 @@ def total_flux(self, n, Rd, I0, q): return moffat_I0_to_flux(I0, n, Rd, q) +@combine_docstrings class MoffatSuperEllipse(MoffatMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class MoffatFourierEllipse(MoffatMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class MoffatWarp(MoffatMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class MoffatRay(iMoffatMixin, RayMixin, GalaxyModel): usable = True +@combine_docstrings class MoffatWedge(iMoffatMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/models/nuker.py b/astrophot/models/nuker.py index 884a7cbf..dfcbce71 100644 --- a/astrophot/models/nuker.py +++ b/astrophot/models/nuker.py @@ -10,6 +10,8 @@ FourierEllipseMixin, WarpMixin, ) +from ..utils.decorators import combine_docstrings + __all__ = [ "NukerGalaxy", @@ -22,50 +24,37 @@ ] +@combine_docstrings class NukerGalaxy(NukerMixin, RadialMixin, GalaxyModel): - """basic galaxy model with a Nuker profile for the radial light - profile. The functional form of the Nuker profile is defined as: - - I(R) = Ib * 2^((beta-gamma)/alpha) * (R / Rb)^(-gamma) * (1 + (R/Rb)^alpha)^((gamma - beta)/alpha) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ib is the flux density at - the scale radius Rb, Rb is the scale length for the profile, beta - is the outer power law slope, gamma is the iner power law slope, - and alpha is the sharpness of the transition. - - Parameters: - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope - - """ - usable = True +@combine_docstrings class NukerPSF(NukerMixin, RadialMixin, PSFModel): _parameter_specs = {"Ib": {"units": "flux/arcsec^2", "value": 1.0}} usable = True +@combine_docstrings class NukerSuperEllipse(NukerMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class NukerFourierEllipse(NukerMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class NukerWarp(NukerMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class NukerRay(iNukerMixin, RayMixin, GalaxyModel): usable = True +@combine_docstrings class NukerWedge(iNukerMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/models/sersic.py b/astrophot/models/sersic.py index 7f4545ee..7bd30fd4 100644 --- a/astrophot/models/sersic.py +++ b/astrophot/models/sersic.py @@ -32,7 +32,7 @@ class SersicGalaxy(SersicMixin, RadialMixin, GalaxyModel): usable = True @forward - def total_flux(self, Ie, n, Re, q): + def total_flux(self, Ie, n, Re, q, window=None): return sersic_Ie_to_flux_torch(Ie, n, Re, q) @@ -43,24 +43,6 @@ class TSersicGalaxy(TruncationMixin, SersicMixin, RadialMixin, GalaxyModel): @combine_docstrings class SersicPSF(SersicMixin, RadialMixin, PSFModel): - """basic point source model with a sersic profile for the radial light - profile. The functional form of the Sersic profile is defined as: - - I(R) = Ie * exp(- bn((R/Re)^(1/n) - 1)) - - where I(R) is the brightness profile as a function of semi-major - axis, R is the semi-major axis length, Ie is the brightness as the - half light radius, bn is a function of n and is not involved in - the fit, Re is the half light radius, and n is the sersic index - which controls the shape of the profile. - - Parameters: - n: Sersic index which controls the shape of the brightness profile - Ie: brightness at the half light radius, represented as the log of the brightness divided by pixel scale squared. - Re: half light radius - - """ - _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0}} usable = True diff --git a/astrophot/models/spline.py b/astrophot/models/spline.py index db2d9411..0a011e7d 100644 --- a/astrophot/models/spline.py +++ b/astrophot/models/spline.py @@ -10,6 +10,8 @@ FourierEllipseMixin, WarpMixin, ) +from ..utils.decorators import combine_docstrings + __all__ = [ "SplineGalaxy", @@ -22,45 +24,36 @@ ] +@combine_docstrings class SplineGalaxy(SplineMixin, RadialMixin, GalaxyModel): - """Basic galaxy model with a spline radial light profile. The - light profile is defined as a cubic spline interpolation of the - stored brightness values: - - I(R) = interp(R, profR, I) - - where I(R) is the brightness along the semi-major axis, interp is - a cubic spline function, R is the semi-major axis length, profR is - a list of radii for the spline, I is a corresponding list of - brightnesses at each profR value. - - Parameters: - I(R): Tensor of brighntess values, represented as the log of the brightness divided by pixelscale squared - - """ - usable = True +@combine_docstrings class SplinePSF(SplineMixin, RadialMixin, PSFModel): usable = True +@combine_docstrings class SplineSuperEllipse(SplineMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class SplineFourierEllipse(SplineMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class SplineWarp(SplineMixin, WarpMixin, RadialMixin, GalaxyModel): usable = True +@combine_docstrings class SplineRay(iSplineMixin, RayMixin, GalaxyModel): usable = True +@combine_docstrings class SplineWedge(iSplineMixin, WedgeMixin, GalaxyModel): usable = True diff --git a/astrophot/param/module.py b/astrophot/param/module.py index d97f0352..e009d66c 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -1,4 +1,5 @@ import numpy as np +import torch from math import prod from caskade import ( Module as CModule, @@ -18,6 +19,15 @@ def build_params_array_identities(self): identities.append(f"{id(param)}_{i}") return identities + def build_params_array_uncertainty(self): + uncertainties = [] + for param in self.dynamic_params: + if param.uncertainty is None: + uncertainties.append(torch.zeros_like(param.value.flatten())) + else: + uncertainties.append(param.uncertainty.flatten()) + return torch.cat(tuple(uncertainties), dim=-1) + def build_params_array_names(self): names = [] for param in self.dynamic_params: diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index dee180a9..526f3018 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -30,9 +30,9 @@ def _select_img(img, hduli): def centroids_from_segmentation_map( seg_map: Union[np.ndarray, str], - image: Union[np.ndarray, str], + image: "Image", + sky_level=None, hdul_index_seg: int = 0, - hdul_index_img: int = 0, skip_index: tuple = (0,), ): """identify centroid centers for all segments in a segmentation map @@ -54,8 +54,12 @@ def centroids_from_segmentation_map( """ seg_map = _select_img(seg_map, hdul_index_seg) - image = _select_img(image, hdul_index_img) + seg_map = seg_map.T + if sky_level is None: + sky_level = np.nanmedian(image.data) + + data = image.data.detach().cpu().numpy() - sky_level centroids = {} II, JJ = np.meshgrid(np.arange(seg_map.shape[0]), np.arange(seg_map.shape[1]), indexing="ij") @@ -64,46 +68,55 @@ def centroids_from_segmentation_map( if index is None or index in skip_index: continue N = seg_map == index - icentroid = np.sum(II[N] * image[N]) / np.sum(image[N]) - jcentroid = np.sum(JJ[N] * image[N]) / np.sum(image[N]) - centroids[index] = [icentroid, jcentroid] + icentroid = np.sum(II[N] * data[N]) / np.sum(data[N]) + jcentroid = np.sum(JJ[N] * data[N]) / np.sum(data[N]) + xcentroid, ycentroid = image.pixel_to_plane( + torch.tensor(icentroid, dtype=image.data.dtype, device=image.data.device), + torch.tensor(jcentroid, dtype=image.data.dtype, device=image.data.device), + params=(), + ) + centroids[index] = [xcentroid.item(), ycentroid.item()] return centroids def PA_from_segmentation_map( seg_map: Union[np.ndarray, str], - image: Union[np.ndarray, str], + image: "Image", centroids=None, sky_level=None, hdul_index_seg: int = 0, - hdul_index_img: int = 0, skip_index: tuple = (0,), softening=1e-3, ): seg_map = _select_img(seg_map, hdul_index_seg) - image = _select_img(image, hdul_index_img) + # reverse to match numpy indexing + seg_map = seg_map.T if sky_level is None: - sky_level = np.nanmedian(image) + sky_level = np.nanmedian(image.data) + + data = image.data.detach().cpu().numpy() - sky_level + if centroids is None: centroids = centroids_from_segmentation_map( seg_map=seg_map, image=image, skip_index=skip_index ) - II, JJ = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij") + x, y = image.coordinate_center_meshgrid() + x = x.detach().cpu().numpy() + y = y.detach().cpu().numpy() PAs = {} for index in np.unique(seg_map): if index is None or index in skip_index: continue N = seg_map == index - dat = image[N] - sky_level - ii = II[N] - centroids[index][0] - jj = JJ[N] - centroids[index][1] - mu20 = np.median(dat * np.abs(ii)) - mu02 = np.median(dat * np.abs(jj)) - mu11 = np.median(dat * ii * jj / np.sqrt(np.abs(ii * jj) + softening**2)) + xx = x[N] - centroids[index][0] + yy = y[N] - centroids[index][1] + mu20 = np.median(data[N] * np.abs(xx)) + mu02 = np.median(data[N] * np.abs(yy)) + mu11 = np.median(data[N] * xx * yy / np.sqrt(np.abs(xx * yy) + softening**2)) M = np.array([[mu20, mu11], [mu11, mu02]]) if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): PAs[index] = np.pi / 2 @@ -115,42 +128,47 @@ def PA_from_segmentation_map( def q_from_segmentation_map( seg_map: Union[np.ndarray, str], - image: Union[np.ndarray, str], + image: "Image", centroids=None, sky_level=None, hdul_index_seg: int = 0, - hdul_index_img: int = 0, skip_index: tuple = (0,), softening=1e-3, ): seg_map = _select_img(seg_map, hdul_index_seg) - image = _select_img(image, hdul_index_img) + + # reverse to match numpy indexing + seg_map = seg_map.T if sky_level is None: - sky_level = np.nanmedian(image) + sky_level = np.nanmedian(image.data) + + data = image.data.detach().cpu().numpy() - sky_level + if centroids is None: centroids = centroids_from_segmentation_map( seg_map=seg_map, image=image, skip_index=skip_index ) - II, JJ = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1]), indexing="ij") + x, y = image.coordinate_center_meshgrid() + x = x.detach().cpu().numpy() + y = y.detach().cpu().numpy() qs = {} for index in np.unique(seg_map): if index is None or index in skip_index: continue N = seg_map == index - dat = image[N] - sky_level - ii = II[N] - centroids[index][0] - jj = JJ[N] - centroids[index][1] - mu20 = np.median(dat * np.abs(ii)) - mu02 = np.median(dat * np.abs(jj)) - mu11 = np.median(dat * ii * jj / np.sqrt(np.abs(ii * jj) + softening**2)) + xx = x[N] - centroids[index][0] + yy = y[N] - centroids[index][1] + mu20 = np.median(data[N] * np.abs(xx)) + mu02 = np.median(data[N] * np.abs(yy)) + mu11 = np.median(data[N] * xx * yy / np.sqrt(np.abs(xx * yy) + softening**2)) M = np.array([[mu20, mu11], [mu11, mu02]]) if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): qs[index] = 0.7 else: - l = np.sort(np.linalg.eigvals(M)) + l = np.abs(np.sort(np.linalg.eigvals(M))) qs[index] = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) return qs @@ -181,6 +199,8 @@ def windows_from_segmentation_map(seg_map, hdul_index=0, skip_index=(0,)): else: raise ValueError(f"unrecognized file type, should be one of: fits, npy\n{seg_map}") + seg_map = seg_map.T + windows = {} for index in np.unique(seg_map): @@ -193,7 +213,7 @@ def windows_from_segmentation_map(seg_map, hdul_index=0, skip_index=(0,)): return windows -def scale_windows(windows, image_shape=None, expand_scale=1.0, expand_border=0.0): +def scale_windows(windows, image: "Image" = None, expand_scale=1.0, expand_border=0.0): new_windows = {} for index in list(windows.keys()): new_window = deepcopy(windows[index]) @@ -218,10 +238,13 @@ def scale_windows(windows, image_shape=None, expand_scale=1.0, expand_border=0.0 ], ] # Ensure the window does not exceed the borders of the image - if image_shape is not None: + if image is not None: new_window = [ [max(0, new_window[0][0]), max(0, new_window[0][1])], - [min(image_shape[0], new_window[1][0]), min(image_shape[1], new_window[1][1])], + [ + min(image.data.shape[0], new_window[1][0]), + min(image.data.shape[1], new_window[1][1]), + ], ] new_windows[index] = new_window return new_windows @@ -235,7 +258,7 @@ def filter_windows( max_area=None, min_flux=None, max_flux=None, - image=None, + image: "Image" = None, ): """ Filter a set of windows based on a set of criteria. @@ -283,7 +306,7 @@ def filter_windows( if min_flux is not None: if ( np.sum( - image[ + image.data[ windows[w][0][0] : windows[w][1][0], windows[w][0][1] : windows[w][1][1], ] @@ -294,7 +317,7 @@ def filter_windows( if max_flux is not None: if ( np.sum( - image[ + image.data[ windows[w][0][0] : windows[w][1][0], windows[w][0][1] : windows[w][1][1], ] diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index d86ae1c7..484bcb13 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -47,7 +47,7 @@ "variance = psf**2 / 100\n", "psf += np.random.normal(scale=np.sqrt(variance))\n", "\n", - "psf_target = ap.image.PSFImage(\n", + "psf_target = ap.PSFImage(\n", " data=psf,\n", " pixelscale=0.5,\n", " variance=variance,\n", @@ -70,7 +70,7 @@ "outputs": [], "source": [ "# Now we initialize on the image\n", - "psf_model = ap.models.Model(\n", + "psf_model = ap.Model(\n", " name=\"init psf\",\n", " model_type=\"moffat psf model\",\n", " target=psf_target,\n", @@ -116,12 +116,12 @@ "outputs": [], "source": [ "# Lets make some data that we need to fit\n", - "psf_target = ap.image.PSFImage(\n", + "psf_target = ap.PSFImage(\n", " data=np.zeros((51, 51)),\n", " pixelscale=1.0,\n", ")\n", "\n", - "true_psf_model = ap.models.Model(\n", + "true_psf_model = ap.Model(\n", " name=\"true psf\",\n", " model_type=\"moffat psf model\",\n", " target=psf_target,\n", @@ -130,13 +130,13 @@ ")\n", "true_psf = true_psf_model().data\n", "\n", - "target = ap.image.TargetImage(\n", + "target = ap.TargetImage(\n", " data=torch.zeros(100, 100),\n", " pixelscale=1.0,\n", " psf=true_psf,\n", ")\n", "\n", - "true_model = ap.models.Model(\n", + "true_model = ap.Model(\n", " name=\"true model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", @@ -146,14 +146,14 @@ " n=2,\n", " Re=25,\n", " Ie=10,\n", - " psf_mode=\"full\",\n", + " psf_convolve=True,\n", ")\n", "\n", "# use the true model to make some data\n", "sample = true_model()\n", "torch.manual_seed(61803398)\n", - "target.data = sample.data + torch.normal(torch.zeros_like(sample.data), 0.1)\n", - "target.variance = 0.01 * torch.ones_like(sample.data)\n", + "target._data = sample.data + torch.normal(torch.zeros_like(sample.data), 0.1)\n", + "target.variance = 0.01 * torch.ones_like(sample.data.T)\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", "ap.plots.model_image(fig, ax[0], true_model)\n", @@ -173,7 +173,7 @@ "# Now we will try and fit the data using just a plain sersic\n", "\n", "# Here we set up a sersic model for the galaxy\n", - "plain_galaxy_model = ap.models.Model(\n", + "plain_galaxy_model = ap.Model(\n", " name=\"galaxy model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", @@ -215,12 +215,12 @@ "# Now we will try and fit the data with a sersic model and a \"live\" psf\n", "\n", "# Here we create a target psf model which will determine the specs of our live psf model\n", - "psf_target = ap.image.PSFImage(\n", + "psf_target = ap.PSFImage(\n", " data=np.zeros((51, 51)),\n", " pixelscale=target.pixelscale,\n", ")\n", "\n", - "live_psf_model = ap.models.Model(\n", + "live_psf_model = ap.Model(\n", " name=\"psf\",\n", " model_type=\"moffat psf model\",\n", " target=psf_target,\n", @@ -229,17 +229,16 @@ ")\n", "\n", "# Here we set up a sersic model for the galaxy\n", - "live_galaxy_model = ap.models.Model(\n", + "live_galaxy_model = ap.Model(\n", " name=\"galaxy model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " psf_mode=\"full\",\n", + " psf_convolve=True,\n", " psf=live_psf_model, # Here we bind the PSF model to the galaxy model, this will add the psf_model parameters to the galaxy_model\n", ")\n", "live_galaxy_model.initialize()\n", "\n", - "result = ap.fit.LM(live_galaxy_model, verbose=3).fit()\n", - "result.update_uncertainty()" + "result = ap.fit.LM(live_galaxy_model, verbose=3).fit()" ] }, { @@ -249,8 +248,12 @@ "metadata": {}, "outputs": [], "source": [ - "print(f\"fitted n for moffat PSF: {live_psf_model.n.value.item()} we were hoping to get 2!\")\n", - "print(f\"fitted Rd for moffat PSF: {live_psf_model.Rd.value.item()} we were hoping to get 3!\")\n", + "print(\n", + " f\"fitted n for moffat PSF: {live_psf_model.n.value.item():.6f} +- {live_psf_model.n.uncertainty.item():.6f} we were hoping to get 2!\"\n", + ")\n", + "print(\n", + " f\"fitted Rd for moffat PSF: {live_psf_model.Rd.value.item():.6f} +- {live_psf_model.Rd.uncertainty.item():.6f} we were hoping to get 3!\"\n", + ")\n", "fig, ax = ap.plots.covariance_matrix(\n", " result.covariance_matrix.detach().cpu().numpy(),\n", " live_galaxy_model.build_params_array().detach().cpu().numpy(),\n", diff --git a/docs/source/tutorials/BasicPSFModels.ipynb b/docs/source/tutorials/BasicPSFModels.ipynb index 3274ebd3..2b328687 100644 --- a/docs/source/tutorials/BasicPSFModels.ipynb +++ b/docs/source/tutorials/BasicPSFModels.ipynb @@ -55,7 +55,7 @@ "psf += np.random.normal(scale=psf / 4)\n", "psf[psf < 0] = ap.utils.initialize.gaussian_psf(2.0, 101, 0.5)[psf < 0]\n", "\n", - "psf_target = ap.image.PSFImage(\n", + "psf_target = ap.PSFImage(\n", " data=psf,\n", " pixelscale=0.5,\n", ")\n", @@ -69,7 +69,7 @@ "plt.show()\n", "\n", "# Dummy target for sampling purposes\n", - "target = ap.image.TargetImage(data=np.zeros((300, 300)), pixelscale=0.5, psf=psf_target)" + "target = ap.TargetImage(data=np.zeros((300, 300)), pixelscale=0.5, psf=psf_target)" ] }, { @@ -89,7 +89,7 @@ "metadata": {}, "outputs": [], "source": [ - "pointsource = ap.models.Model(\n", + "pointsource = ap.Model(\n", " model_type=\"point model\",\n", " target=target,\n", " center=[75.25, 75.9],\n", @@ -129,7 +129,7 @@ "metadata": {}, "outputs": [], "source": [ - "model_nopsf = ap.models.Model(\n", + "model_nopsf = ap.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", " center=[75, 75],\n", @@ -137,11 +137,11 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " logIe=1,\n", - " psf_mode=\"none\", # no PSF convolution will be done\n", + " Ie=10,\n", + " psf_convolve=False, # no PSF convolution will be done\n", ")\n", "model_nopsf.initialize()\n", - "model_psf = ap.models.Model(\n", + "model_psf = ap.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", " center=[75, 75],\n", @@ -149,20 +149,20 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " logIe=1,\n", - " psf_mode=\"full\", # now the full window will be PSF convolved using the PSF from the target\n", + " Ie=10,\n", + " psf_convolve=True, # now the full window will be PSF convolved using the PSF from the target\n", ")\n", "model_psf.initialize()\n", "\n", "psf = psf.copy()\n", "psf[49:51] += 4 * np.mean(psf)\n", "psf[:, 49:51] += 4 * np.mean(psf)\n", - "psf_target_2 = ap.image.PSFImage(\n", + "psf_target_2 = ap.PSFImage(\n", " data=psf,\n", " pixelscale=0.5,\n", ")\n", "psf_target_2.normalize()\n", - "model_selfpsf = ap.models.Model(\n", + "model_selfpsf = ap.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", " center=[75, 75],\n", @@ -170,8 +170,8 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " logIe=1,\n", - " psf_mode=\"full\",\n", + " Ie=10,\n", + " psf_convolve=True,\n", " psf=psf_target_2, # Now this model has its own PSF, instead of using the target psf\n", ")\n", "model_selfpsf.initialize()\n", @@ -204,13 +204,13 @@ "metadata": {}, "outputs": [], "source": [ - "upsample_psf_target = ap.image.PSFImage(\n", + "upsample_psf_target = ap.PSFImage(\n", " data=ap.utils.initialize.gaussian_psf(2.0, 51, 0.25),\n", " pixelscale=0.25, # This PSF is at a higher resolution than the target\n", ")\n", "target.psf = upsample_psf_target\n", "\n", - "model_upsamplepsf = ap.models.Model(\n", + "model_upsamplepsf = ap.Model(\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", " center=[75, 75],\n", @@ -218,8 +218,8 @@ " PA=60 * np.pi / 180,\n", " n=3,\n", " Re=10,\n", - " logIe=1,\n", - " psf_mode=\"full\", # now the full window will be PSF convolved using the PSF from the target\n", + " Ie=10,\n", + " psf_convolve=True,\n", ")\n", "model_upsamplepsf.initialize()\n", "fig, ax = plt.subplots(1, 1, figsize=(5, 5))\n", diff --git a/docs/source/tutorials/ConstrainedModels.ipynb b/docs/source/tutorials/ConstrainedModels.ipynb index 67297a59..599df83e 100644 --- a/docs/source/tutorials/ConstrainedModels.ipynb +++ b/docs/source/tutorials/ConstrainedModels.ipynb @@ -29,7 +29,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Range limits\n", + "## Valid Range\n", "\n", "The simplest form of constraint on a parameter is to restrict its range to within some limit. This is done at creation of the variable and you simply indicate the endpoints (non-inclusive) of the limits." ] @@ -40,24 +40,25 @@ "metadata": {}, "outputs": [], "source": [ - "target = ap.image.Target_Image(data=np.zeros((100, 100)), center=[0, 0], pixelscale=1)\n", - "gal1 = ap.models.AstroPhot_Model(\n", + "target = ap.TargetImage(data=np.zeros((100, 100)), crpix=[49.5, 49.5], pixelscale=1)\n", + "gal1 = ap.Model(\n", " name=\"galaxy1\",\n", " model_type=\"sersic galaxy model\",\n", - " parameters={\n", - " \"center\": {\n", - " \"value\": [0, 0],\n", - " \"limits\": [[-10, -20], [10, 20]],\n", - " }, # here we set the limits, note it can be different for each value\n", + " # here we set the limits, note it can be different for each value of center.\n", + " # The valid range is a tuple with two elements, the lower limit and the\n", + " # upper limit, either can be None\n", + " center={\n", + " \"value\": [0, 0],\n", + " \"valid\": ([-10, -20], [10, 20]),\n", " },\n", + " # One sided limits can be used for example to ensure a value is positive\n", + " Re={\"valid\": (0, None)},\n", " target=target,\n", ")\n", "\n", - "# Now if we try to set a value outside the range we get an error\n", - "try:\n", - " gal1[\"center\"].value = [25, 25]\n", - "except ap.errors.InvalidParameter as e:\n", - " print(\"got an AssertionError with message: \", e)" + "# Now if we try to set a value outside the range we get a warning\n", + "gal1.center.value = [25, 25]\n", + "gal1.center.value = [0, 0] # set back to good value" ] }, { @@ -82,37 +83,44 @@ "metadata": {}, "outputs": [], "source": [ - "target = ap.image.Target_Image(data=np.zeros((100, 100)), center=[0, 0], pixelscale=1)\n", - "gal1 = ap.models.AstroPhot_Model(\n", + "gal1 = ap.Model(\n", " name=\"galaxy1\",\n", " model_type=\"sersic galaxy model\",\n", - " parameters={\"center\": [-25, -25], \"PA\": 0, \"q\": 0.9, \"n\": 2, \"Re\": 5, \"Ie\": 1.0},\n", + " center=[-25, -25],\n", + " PA=0,\n", + " q=0.9,\n", + " n=2,\n", + " Re=5,\n", + " Ie=1.0,\n", " target=target,\n", ")\n", - "gal2 = ap.models.AstroPhot_Model(\n", + "gal2 = ap.Model(\n", " name=\"galaxy2\",\n", " model_type=\"sersic galaxy model\",\n", - " parameters={\"center\": [25, 25], \"PA\": 0, \"q\": 0.9, \"Ie\": 1.0},\n", + " center=[25, 25],\n", + " PA=0,\n", + " q=0.9,\n", + " Ie=1.0,\n", " target=target,\n", ")\n", "\n", "# here we set the equality constraint, setting the values for gal2 equal to the parameters of gal1\n", - "gal2[\"n\"].value = gal1[\"n\"]\n", - "gal2[\"Re\"].value = gal1[\"Re\"]\n", + "gal2.n = gal1.n\n", + "gal2.Re = gal1.Re\n", "\n", "# we make a group model to use both star models together\n", - "gals = ap.models.AstroPhot_Model(\n", + "gals = ap.Model(\n", " name=\"gals\",\n", " model_type=\"group model\",\n", " models=[gal1, gal2],\n", " target=target,\n", ")\n", "\n", - "print(gals.parameters)\n", - "\n", "fig, ax = plt.subplots()\n", "ap.plots.model_image(fig, ax, gals)\n", - "plt.show()" + "plt.show()\n", + "\n", + "gals.graphviz()" ] }, { @@ -122,7 +130,7 @@ "outputs": [], "source": [ "# We can now change a parameter value and both models will change\n", - "gal1[\"n\"].value = 1\n", + "gal1.n.value = 1\n", "\n", "fig, ax = plt.subplots()\n", "ap.plots.model_image(fig, ax, gals)\n", @@ -146,7 +154,7 @@ "\n", "- A spatially varying PSF can be forced to obey some smoothing function such as a plane or spline\n", "- The SED of a multiband fit may be constrained to follow some pre-determined form\n", - "- An astrometry correction in multi-image fitting can be included for each image to ensure precise alignment\n", + "- A light curve model could be used to constrain the brightness in a multi-epoch analysis\n", "\n", "The possibilities with this kind of constraint capability are quite extensive. If you do something creative with these functional constraints please let us know!" ] @@ -158,67 +166,54 @@ "outputs": [], "source": [ "# Here we will demo a spatially varying PSF where the moffat \"n\" parameter changes across the image\n", - "target = ap.image.Target_Image(data=np.zeros((100, 100)), center=[0, 0], pixelscale=1)\n", + "target = ap.TargetImage(data=np.zeros((100, 100)), crpix=[49.5, 49.5], pixelscale=1)\n", + "\n", + "psf_target = ap.PSFImage(data=np.zeros((55, 55)), pixelscale=1)\n", + "\n", + "# We make parameters and a function to control the moffat n parameter\n", + "intercept = ap.Param(\"intercept\", 3)\n", + "slope = ap.Param(\"slope\", [1 / 50, -1 / 50])\n", + "\n", + "\n", + "def constrained_moffat_n(n_param):\n", + " return n_param.intercept.value + torch.sum(n_param.slope.value * n_param.center.value)\n", "\n", - "psf_target = ap.image.PSF_Image(data=np.zeros((25, 25)), pixelscale=1)\n", "\n", - "# First we make all the star objects\n", + "# Next we make all the star and PSF objects\n", "allstars = []\n", "allpsfs = []\n", "for x in [-30, 0, 30]:\n", " for y in [-30, 0, 30]:\n", - " allpsfs.append(\n", - " ap.models.AstroPhot_Model(\n", - " name=\"psf\",\n", - " model_type=\"moffat psf model\",\n", - " parameters={\"Rd\": 2},\n", - " target=psf_target,\n", - " )\n", + " psf = ap.Model(\n", + " name=\"psf\",\n", + " model_type=\"moffat psf model\",\n", + " Rd=2,\n", + " n={\"value\": constrained_moffat_n},\n", + " target=psf_target,\n", " )\n", + " if len(allstars) > 0:\n", + " psf.Rd = allstars[0].psf.Rd\n", " allstars.append(\n", - " ap.models.AstroPhot_Model(\n", + " ap.Model(\n", " name=f\"star {x} {y}\",\n", " model_type=\"point model\",\n", - " parameters={\"center\": [x, y], \"flux\": 1},\n", + " center=[x, y],\n", + " flux=1,\n", " target=target,\n", - " psf=allpsfs[-1],\n", + " psf=psf,\n", " )\n", " )\n", - " allpsfs[-1][\"n\"].link(\n", - " allstars[-1][\"center\"]\n", - " ) # see we need to link the center as well so that it can be used in the function\n", - "\n", - "# we link the Rd parameter for all the PSFs so that they are the same\n", - "for psf in allpsfs[1:]:\n", - " psf[\"Rd\"].value = allpsfs[0][\"Rd\"]\n", - "\n", - "# next we need the parameters for the spatially varying PSF plane\n", - "P_intercept = ap.param.Parameter_Node(\n", - " name=\"intercept\",\n", - " value=3,\n", - ")\n", - "P_slope = ap.param.Parameter_Node(\n", - " name=\"slope\",\n", - " value=[1 / 50, -1 / 50],\n", - ")\n", - "\n", - "\n", - "# next we define the function which takes the parameters as input and returns the value for n\n", - "def constrained_moffat_n(params):\n", - " return params[\"intercept\"].value + torch.sum(params[\"slope\"].value * params[\"center\"].value)\n", "\n", + " # see we need to link the center as well so that it can be used in the function\n", + " psf.n.link((intercept, slope, allstars[-1].center))\n", "\n", - "# finally we assign this parameter function to the \"n\" parameter for each moffat\n", - "for psf in allpsfs:\n", - " psf[\"n\"].value = constrained_moffat_n\n", - " psf[\"n\"].link(P_intercept)\n", - " psf[\"n\"].link(P_slope)\n", "\n", "# A group model holds all the stars together\n", - "MODEL = ap.models.AstroPhot_Model(\n", + "sky = ap.Model(name=\"sky\", model_type=\"flat sky model\", I=1e-5, target=target)\n", + "MODEL = ap.Model(\n", " name=\"spatial PSF\",\n", " model_type=\"group model\",\n", - " models=allstars,\n", + " models=[sky] + allstars,\n", " target=target,\n", ")\n", "\n", diff --git a/docs/source/tutorials/CustomModels.ipynb b/docs/source/tutorials/CustomModels.ipynb index f8d64c5f..bcb61285 100644 --- a/docs/source/tutorials/CustomModels.ipynb +++ b/docs/source/tutorials/CustomModels.ipynb @@ -6,13 +6,44 @@ "source": [ "# Custom model objects\n", "\n", - "Here we will go over some of the core functionality of AstroPhot models so that you can make your own custom models with arbitrary behavior. This is an advanced tutorial and likely not needed for most users. However, the flexibility of AstroPhot can be a real lifesaver for some niche applications! If you get stuck trying to make your own models, please contact Connor Stone (see GitHub), he can help you get the model working and maybe even help add it to the core AstroPhot model list!\n", + "Here we will go over some of the core functionality of AstroPhot models so that\n", + "you can make your own custom models with arbitrary behavior. This is an advanced\n", + "tutorial and likely not needed for most users. However, the flexibility of\n", + "AstroPhot can be a real lifesaver for some niche applications! If you get stuck\n", + "trying to make your own models, please contact Connor Stone (see GitHub), he can\n", + "help you get the model working and maybe even help add it to the core AstroPhot\n", + "model list!\n", "\n", "### AstroPhot model hierarchy\n", "\n", - "AstroPhot models are very much object oriented and inheritance driven. Every AstroPhot model inherits from `AstroPhot_Model` and so if you wish to make something truly original then this is where you would need to start. However, it is almost certain that is the wrong way to go. Further down the hierarchy is the `Component_Model` object, this is what you will likely use to construct a custom model as it represents a single \"unit\" in the astronomical image. Spline, Sersic, Exponential, Gaussian, PSF, Sky, etc. all of these inherit from `Component_Model` so likely that's what you will want. At its core, a `Component_Model` object defines a center location for the model, but it doesn't know anything else yet. At the same level as `Component_Model` is `Group_Model` which represents a collection of model objects (typically but not always `Component_Model` objects). A `Group_Model` is how you construct more complex models by composing several simpler models. It's unlikely you'll need to inherit from `Group_Model` so we won't discuss this any further (contact the developers if you're thinking about that). \n", + "AstroPhot models are very much object oriented and inheritance driven. Every\n", + "AstroPhot model inherits from `Model` and so if you wish to make something truly\n", + "original then this is where you would need to start. However, it is almost\n", + "certain that is the wrong way to go. Further down the hierarchy is the\n", + "`ComponentModel` object, this is what you will likely use to construct a custom\n", + "model as it represents a single \"unit\" in the astronomical image. Spline,\n", + "Sersic, Exponential, Gaussian, PSF, Sky, etc. all of these inherit from\n", + "`ComponentModel` so likely that's what you will want. At its core, a\n", + "`ComponentModel` object defines a center location for the model, but it doesn't\n", + "know anything else yet. At the same level as `ComponentModel` is `GroupModel`\n", + "which represents a collection of model objects (typically but not always\n", + "`ComponentModel` objects). A `GroupModel` is how you construct more complex\n", + "models by composing several simpler models. It's unlikely you'll need to inherit\n", + "from `GroupModel` so we won't discuss this any further (contact the developers\n", + "if you're thinking about that). \n", "\n", - "Inheriting from `Component_Model` are a few general classes which make it easier to build typical cases. There is the `Galaxy_Model` which adds a position angle and axis ratio to the model; also `Star_Model` which simply enforces no psf convolution on the object since that will be handled internally for anything star like; `Sky_Model` should be used for anything low resolution defined over the entire image, in this model psf convolution and integration are turned off since they shouldn't be needed. Based on these low level classes, you can \"jump in\" where it makes sense to define your model. Of course, you can take any AstroPhot model as a starting point and modify it to suit a given task, however we will not list all models here. See the documentation for a more complete list." + "Inheriting from `ComponentModel` are a few general classes which make it easier\n", + "to build typical cases. There is the `GalaxyModel` which adds a position angle\n", + "and axis ratio to the model; also `PointSource` which simply enforces some\n", + "restrictions that make more sense for a delta function model; `SkyModel` should\n", + "be used for anything low resolution defined over the entire image, in this model\n", + "psf convolution and sub-pixel integration are turned off since they shouldn't be\n", + "needed. Based on these low level classes, you can \"jump in\" where it makes sense\n", + "to define your model. If you are looking to define a sersic that has some\n", + "slightly different behaviour you may be able to take the `SersicGalaxy` class\n", + "and directly make your modification. Of course, you can take any AstroPhot model\n", + "as a starting point and modify it to suit a given task, however we will not list\n", + "all models here. See the documentation for a more complete list." ] }, { @@ -34,11 +65,7 @@ "import torch\n", "from astropy.io import fits\n", "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "\n", - "ap.AP_config.set_logging_output(\n", - " stdout=True, filename=None\n", - ") # see GettingStarted tutorial for what this does" + "import matplotlib.pyplot as plt" ] }, { @@ -47,38 +74,26 @@ "metadata": {}, "outputs": [], "source": [ - "class My_Sersic(ap.models.Galaxy_Model):\n", + "class My_Sersic(ap.models.RadialMixin, ap.models.GalaxyModel):\n", " \"\"\"Let's make a sersic model!\"\"\"\n", "\n", - " model_type = f\"mysersic {ap.models.Galaxy_Model.model_type}\" # here we give a name to the model, the convention is to lead with a new identifier then include the name of the inheritance model\n", - " parameter_specs = {\n", - " \"my_n\": {\n", - " \"limits\": (0.36, 8)\n", - " }, # our sersic index will have some default limits so it doesn't produce weird results\n", - " \"my_Re\": {\n", - " \"limits\": (0, None)\n", - " }, # our effective radius must be positive, otherwise it is fair game\n", - " \"my_Ie\": {}, # our effective surface density could be any real number\n", + " _model_type = \"mysersic\" # here we give a name to the model, since we inherit from GalaxyModel the full model_type will be \"mysersic galaxy model\"\n", + " _parameter_specs = {\n", + " # our sersic index will have some default limits so it doesn't produce\n", + " # weird results We also indicate the expected shapeof the parameter, in\n", + " # this case a scalar. This isn't necessary but it gives AstroPhot more\n", + " # information to work with. if e.g. you accidentaly provide multiple\n", + " # values, you'll now get an error rather than confusing behavior later.\n", + " \"my_n\": {\"valid\": (0.36, 8), \"shape\": ()},\n", + " \"my_Re\": {\"units\": \"arcsec\", \"valid\": (0, None), \"shape\": ()},\n", + " \"my_Ie\": {\"units\": \"flux/arcsec^2\"},\n", " }\n", - " _parameter_order = ap.models.Galaxy_Model._parameter_order + (\n", - " \"my_n\",\n", - " \"my_Re\",\n", - " \"my_Ie\",\n", - " ) # we have to tell AstroPhot what order to access these parameters, this is used in several underlying methods\n", "\n", - " def radial_model(\n", - " self, R, image=None, parameters=None\n", - " ): # by default a Galaxy_Model object will call radial_model to determine the flux at each pixel\n", - " bn = ap.utils.conversions.functions.sersic_n_to_b(\n", - " parameters[\"my_n\"].value\n", - " ) # AstroPhot has a number of useful util functions, though you are welcome to use your own\n", - " return (\n", - " parameters[\"my_Ie\"].value\n", - " * (image.pixel_area)\n", - " * torch.exp(\n", - " -bn * ((R / parameters[\"my_Re\"].value) ** (1.0 / parameters[\"my_n\"].value) - 1)\n", - " )\n", - " ) # this is simply the classic sersic profile. more details later." + " # a GalaxyModel object will determine the radius for each pixel then call radial_model to determine the brightness\n", + " @ap.forward\n", + " def radial_model(self, R, my_n, my_Re, my_Ie):\n", + " bn = ap.models.func.sersic_n_to_b(my_n)\n", + " return my_Ie * torch.exp(-bn * ((R / my_Re) ** (1.0 / my_n) - 1))" ] }, { @@ -99,15 +114,8 @@ ")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", - "# Create a target object with specified pixelscale and zeropoint\n", - "target = ap.image.Target_Image(\n", - " data=target_data,\n", - " pixelscale=0.262,\n", - " zeropoint=22.5,\n", - " variance=np.ones(target_data.shape) / 1e3,\n", - ")\n", + "target = ap.TargetImage(data=target_data, pixelscale=0.262, zeropoint=22.5, variance=\"auto\")\n", "\n", - "# The default AstroPhot target plotting method uses log scaling in bright areas and histogram scaling in faint areas\n", "fig, ax = plt.subplots(figsize=(8, 8))\n", "ap.plots.target_image(fig, ax, target)\n", "plt.show()" @@ -122,11 +130,10 @@ "my_model = My_Sersic( # notice we are now using the custom class\n", " name=\"wow I made a model\",\n", " target=target, # now the model knows what its trying to match\n", - " parameters={\n", - " \"my_n\": 1.0,\n", - " \"my_Re\": 50,\n", - " \"my_Ie\": 1.0,\n", - " }, # note we have to give initial values for our new parameters. We'll see what can be done for this later\n", + " # note we have to give initial values for our new parameters. AstroPhot doesn't know how to auto-initialize them because they are custom\n", + " my_n=1.0,\n", + " my_Re=50,\n", + " my_Ie=1.0,\n", ")\n", "\n", "# We gave it parameters for our new variables, but initialize will get starting values for everything else\n", @@ -167,11 +174,24 @@ "source": [ "Success! Our \"custom\" sersic model behaves exactly as expected. While going through the tutorial so far there may have been a few things that stood out to you. Lets discuss them now:\n", "\n", - "- What was \"sample_image\" in the radial_model function? This is an object for the image that we are currently sampling. You shouldn't need to do anything with it except get the pixelscale.\n", - "- what else is in \"ap.utils\"? Lots of stuff used in the background by AstroPhot. For now the organization of these is not very good and sometimes changes, so you may wish to just make your own functions for the time being.\n", - "- Why the weird way to access the parameters? The self\\[\"variable\"\\].value format was settled on for simplicity and generality. it's not perfect, but it works.\n", - "- Why is \"sample_image.pixel_area\" in the sersic evaluation? it is important for AstroPhot to know the size of the pixels it is evaluating, multiplying by this value will normalize the flux evaluation regardless of the pixel sizes.\n", - "- When making the model, why did we have to provide values for the parameters? Every model can define an \"initialize\" function which sets the values for its parameters. Since we didn't add that function to our custom class, it doesn't know how to set those variables. All the other variables can be auto-initialized though." + "- What is `ap.models.RadialMixin`? Think of \"Mixin's\" as power ups for classes,\n", + " this power up makes a `brightness` function which calls `radial_model` to\n", + " determine the flux density, that way you only need to define a radial function\n", + " rather than a more general `brightness(x,y)` 2D function.\n", + "- what else is in \"ap.models.func\"? Lots of stuff used in the background by\n", + " AstroPhot models. There is a similar `ap.image.func` for image specific\n", + " functions. You can use these, or write your own functions.\n", + "- How did the `radial_model` function accept the parameters I defined in\n", + " `_parameter_specs`? That's the work of `caskade` a powerful parameter\n", + " management tool.\n", + "- When making the model, why did we have to provide values for the parameters?\n", + " Every model can define an \"initialize\" function which sets the values for its\n", + " parameters. Since we didn't add that function to our custom class, it doesn't\n", + " know how to set those variables. All the other variables can be\n", + " auto-initialized though.\n", + "- Why is `radial_model` decorated with `@ap.forward`? This is part of the\n", + " `caskade` system, the `@ap.forward` here does a lot of heavily lifting\n", + " automatically to fill in values for `my_n`, `my_Re`, and `my_Ie`" ] }, { @@ -189,49 +209,34 @@ "metadata": {}, "outputs": [], "source": [ - "class My_Super_Sersic(\n", - " My_Sersic\n", - "): # note we're inheriting everything from the My_Sersic model since its not making any new parameters\n", - " model_type = \"super awesome sersic model\" # you can make the name anything you like, but the one above follows the normal convention\n", + "# note we're inheriting everything from the My_Sersic model since its not making any new parameters\n", + "class My_Super_Sersic(My_Sersic):\n", + " _model_type = \"super\" # the new name will be \"super mysersic galaxy model\"\n", "\n", - " def initialize(self, target=None, parameters=None):\n", - " if target is None: # good to just use the model target if none given\n", - " target = self.target\n", - " if parameters is None:\n", - " parameters = self.parameters\n", - " super().initialize(\n", - " target=target, parameters=parameters\n", - " ) # typically you want all the lower level parameters determined first\n", + " def initialize(self):\n", + " # typically you want all the lower level parameters determined first\n", + " super().initialize()\n", "\n", - " target_area = target[\n", - " self.window\n", - " ] # this gets the part of the image that the user actually wants us to analyze\n", + " # this gets the part of the image that the user actually wants us to analyze\n", + " target_area = target[self.window]\n", "\n", - " if self[\"my_n\"].value is None: # only do anything if the user didn't provide a value\n", - " with ap.param.Param_Unlock(parameters[\"my_n\"]):\n", - " parameters[\"my_n\"].value = (\n", - " 2.0 # make an initial value for my_n. Override locked since this is the beginning\n", - " )\n", - " parameters[\"my_n\"].uncertainty = (\n", - " 0.1 # make sure there is a starting point for the uncertainty too\n", - " )\n", + " # only initialize if the user didn't already provide a value\n", + " if not self.my_n.initialized:\n", + " # make an initial value for my_n. It's a \"dynamic_value\" so it can be optimized later\n", + " self.my_n.dynamic_value = 2.0\n", "\n", - " if (\n", - " self[\"my_Re\"].value is None\n", - " ): # same as my_n, though in general you should try to do something smart to get a good starting point\n", - " with ap.param.Param_Unlock(parameters[\"my_Re\"]):\n", - " parameters[\"my_Re\"].value = 20.0\n", - " parameters[\"my_Re\"].uncertainty = 0.1\n", + " if not self.my_Re.initialized:\n", + " self.my_Re.dynamic_value = 20.0\n", "\n", - " if self[\"my_Ie\"].value is None: # lets try to be a bit clever here\n", - " small_window = self.window.copy().crop_pixel(\n", - " (250,)\n", - " ) # This creates a window much smaller, but still centered on the same point\n", - " with ap.param.Param_Unlock(parameters[\"my_Ie\"]):\n", - " parameters[\"my_Ie\"].value = (\n", - " torch.median(target_area[small_window].data) / target_area.pixel_area\n", - " ) # this will be an average in the window, should at least get us within an order of magnitude\n", - " parameters[\"my_Ie\"].uncertainty = 0.1" + " # lets try to be a bit clever here. This will be an average in the\n", + " # window, should at least get us within an order of magnitude\n", + " if not self.my_Ie.initialized:\n", + " center = target_area.plane_to_pixel(*self.center.value)\n", + " i, j = int(center[0].item()), int(center[1].item())\n", + " self.my_Ie.dynamic_value = (\n", + " torch.median(target_area.data[i - 100 : i + 100, j - 100 : j + 100])\n", + " / target_area.pixel_area\n", + " )" ] }, { @@ -240,10 +245,11 @@ "metadata": {}, "outputs": [], "source": [ - "my_super_model = My_Super_Sersic( # notice we switched the custom class\n", + "my_super_model = ap.Model(\n", " name=\"goodness I made another one\",\n", + " model_type=\"super mysersic galaxy model\", # this is the type we defined above\n", " target=target,\n", - ") # no longer need to provide initial values!\n", + ")\n", "\n", "my_super_model.initialize()\n", "\n", @@ -290,7 +296,13 @@ "source": [ "## Models from scratch\n", "\n", - "By inheriting from `Galaxy_Model` we got to start with some methods already available. In this section we will see how to create a model essentially from scratch by inheriting from the `Component_Model` object. Below is an example model which uses a $\\frac{I_0}{R}$ model, this is a weird model but it will work. To demonstrate the basics for a `Component_Model` is actually simpler than a `Galaxy_Model` we really only need the `evaluate_model` function, it's what you do with that function where the complexity arises." + "By inheriting from `GalaxyModel` we got to start with some methods already\n", + "available. In this section we will see how to create a model essentially from\n", + "scratch by inheriting from the `ComponentModel` object. Below is an example\n", + "model which uses a $\\frac{I_0}{R}$ model, this is a weird model but it will\n", + "work. To demonstrate the basics for a `ComponentModel` is actually simpler than\n", + "a `GalaxyModel` we really only need the `brightness(x,y)` function, it's what\n", + "you do with that function where the complexity arises." ] }, { @@ -299,34 +311,35 @@ "metadata": {}, "outputs": [], "source": [ - "class My_InvR(ap.models.Component_Model):\n", - " model_type = \"InvR model\"\n", + "class My_InvR(ap.models.ComponentModel):\n", + " _model_type = \"InvR\"\n", "\n", - " parameter_specs = {\n", - " \"my_Rs\": {\"limits\": (0, None)}, # This will be the scale length\n", - " \"my_I0\": {}, # This will be the central brightness\n", + " _parameter_specs = {\n", + " # scale length\n", + " \"my_Rs\": {\"units\": \"arcsec\", \"valid\": (0, None)},\n", + " \"my_I0\": {\"units\": \"flux/arcsec^2\"}, # central brightness\n", " }\n", - " _parameter_order = ap.models.Component_Model._parameter_order + (\n", - " \"my_Rs\",\n", - " \"my_I0\",\n", - " ) # we have to tell AstroPhot what order to access these parameters, this is used in several underlying methods\n", "\n", - " epsilon = 1e-4 # this can be set with model.epsilon, but will not be fit during optimization\n", + " def __init__(self, *args, epsilon=1e-4, **kwargs):\n", + " super().__init__(*args, **kwargs)\n", + " self.epsilon = epsilon\n", "\n", - " def evaluate_model(self, X=None, Y=None, image=None, parameters=None):\n", - " if X is None or Y is None:\n", - " Coords = image.get_coordinate_meshgrid()\n", - " X, Y = Coords - parameters[\"center\"].value[..., None, None]\n", - " return parameters[\"my_I0\"].value * image.pixel_area / torch.sqrt(X**2 + Y**2 + self.epsilon)" + " @ap.forward\n", + " def brightness(self, x, y, my_Rs, my_I0):\n", + " x, y = self.transform_coordinates(\n", + " x, y\n", + " ) # basically just subtracts the center from the coordinates\n", + " R = torch.sqrt(x**2 + y**2 + self.epsilon) / my_Rs\n", + " return my_I0 / R" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "See now that we must define a `evaluate_model` method. This takes coordinates, an image object, and parameters and returns the model evaluated at the coordinates. No need to worry about integrating the model within a pixel, this will be handled internally, just evaluate the model at the center of each pixel. For most situations this is made easier with the `get_coordinate_meshgrid_torch` method that all AstroPhot `Target_Image` objects have. We also add a new value `epsilon` which is a core radius in arcsec. This parameter will not be fit, it is set as part of the model creation. You can now also provide epsilon when creating the model, or do nothing and the default value will be used.\n", + "See now that we must define a `brightness` method. This takes general tangent plane coordinates and returns the model evaluated at those coordinates. No need to worry about integrating the model within a pixel, this will be handled internally, just evaluate the model at exactly the coordinates requested. We also add a new value `epsilon` which is a core radius in arcsec and stops numerical divide by zero errors at the center. This parameter will not be fit, it is set as part of the model creation. You can now also provide epsilon when creating the model, or do nothing and the default value will be used.\n", "\n", - "From here you have complete freedom, it need only provide a value for each pixel in the given image. Just make sure that it accounts for pixel size (proportional to pixelscale^2). Also make sure to use only pytorch functions, since that way it is possible to run on GPU and propagate derivatives." + "From here you have complete freedom, make sure to use only pytorch functions, since that way it is possible to run on GPU and propagate derivatives." ] }, { @@ -335,16 +348,20 @@ "metadata": {}, "outputs": [], "source": [ - "simpletarget = ap.image.Target_Image(data=np.zeros([100, 100]), pixelscale=1)\n", - "newmodel = My_InvR(\n", + "simpletarget = ap.TargetImage(data=np.zeros([100, 100]), pixelscale=1)\n", + "newmodel = ap.Model(\n", " name=\"newmodel\",\n", + " model_type=\"InvR model\", # this is the type we defined above\n", " epsilon=1,\n", - " parameters={\"center\": [50, 50], \"my_Rs\": 10, \"my_I0\": 1.0},\n", + " center=[50, 50],\n", + " my_Rs=10,\n", + " my_I0=1.0,\n", " target=simpletarget,\n", ")\n", "\n", "fig, ax = plt.subplots(1, 1, figsize=(8, 7))\n", "ap.plots.model_image(fig, ax, newmodel)\n", + "ax.set_title(\"Observe parental-figure, no hands!\")\n", "plt.show()" ] }, diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 9acdd285..c52ced93 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -43,19 +43,22 @@ "outputs": [], "source": [ "model1 = ap.Model(\n", - " name=\"model1\", # every model must have a unique name\n", + " name=\"model1\",\n", " model_type=\"sersic galaxy model\", # this specifies the kind of model\n", - " center=[50, 50], # here we set initial values for each parameter\n", + " # here we set initial values for each parameter\n", + " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", " n=2,\n", " Re=10,\n", " Ie=1,\n", - " target=ap.TargetImage(\n", - " data=np.zeros((100, 100)), zeropoint=22.5\n", - " ), # every model needs a target, more on this later\n", + " # every model needs a target, more on this later\n", + " target=ap.TargetImage(data=np.zeros((100, 100)), zeropoint=22.5),\n", ")\n", - "model1.initialize() # before using the model it is good practice to call initialize so the model can get itself ready\n", + "\n", + "# models must/should be initialized before doing anything with them.\n", + "# This makes sure all the parameters and metadata are ready to go.\n", + "model1.initialize()\n", "\n", "# We can print the model's current state\n", "print(model1)" @@ -67,9 +70,9 @@ "metadata": {}, "outputs": [], "source": [ - "# AstroPhot has built in methods to plot relevant information. We didn't specify the region on the sky for\n", - "# this model to focus on, so we just made a 100x100 window. Unless you are very lucky this won't\n", - "# line up with what you're trying to fit, so next we'll see how to give the model a target.\n", + "# AstroPhot has built in methods to plot relevant information. This plots the model\n", + "# as projected into the \"target\" image. Thus it has the same pixelscale, orientation\n", + "# and (optionally) PSF as the model's target.\n", "fig, ax = plt.subplots(figsize=(8, 7))\n", "ap.plots.model_image(fig, ax, model1)\n", "plt.show()" @@ -94,14 +97,13 @@ "hdu = fits.open(\n", " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", ")\n", - "target_data = np.array(hdu[0].data, dtype=np.float64) # [:-50]\n", + "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", - "# Create a target object with specified pixelscale and zeropoint\n", "target = ap.TargetImage(\n", " data=target_data,\n", - " pixelscale=0.262, # Every target image needs to know it's pixelscale in arcsec/pixel\n", - " zeropoint=22.5, # optionally, you can give a zeropoint to tell AstroPhot what the pixel flux units are\n", - " variance=\"auto\", # Automatic variance estimate for testing and demo purposes, in real analysis use weight maps, counts, gain, etc to compute variance!\n", + " pixelscale=0.262,\n", + " zeropoint=22.5, # optionally, a zeropoint tells AstroPhot the pixel flux units\n", + " variance=\"auto\", # Automatic variance estimate for testing and demo purposes only! In real analysis use weight maps, counts, gain, etc to compute variance!\n", ")\n", "\n", "# The default AstroPhot target plotting method uses log scaling in bright areas and histogram scaling in faint areas\n", @@ -119,17 +121,19 @@ "# This model now has a target that it will attempt to match\n", "model2 = ap.Model(\n", " name=\"model with target\",\n", - " model_type=\"sersic galaxy model\", # feel free to swap out sersic with other profile types\n", - " target=target, # now the model knows what its trying to match\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", ")\n", "\n", - "# Instead of giving initial values for all the parameters, it is possible to simply call \"initialize\" and AstroPhot\n", - "# will try to guess initial values for every parameter assuming the galaxy is roughly centered. It is also possible\n", - "# to set just a few parameters and let AstroPhot try to figure out the rest. For example you could give it an initial\n", + "# Instead of giving initial values for all the parameters, it is possible to\n", + "# simply call \"initialize\" and AstroPhot will try to guess initial values for\n", + "# every parameter. It is also possible to set just a few parameters and let\n", + "# AstroPhot try to figure out the rest. For example you could give it an initial\n", "# Guess for the center and it will work from there.\n", "model2.initialize()\n", "\n", - "# Plotting the initial parameters and residuals, we see it gets the rough shape of the galaxy right, but still has some fitting to do\n", + "# Plotting the initial parameters and residuals, we see it gets the rough shape\n", + "# of the galaxy right, but still has some fitting to do\n", "fig4, ax4 = plt.subplots(1, 2, figsize=(16, 6))\n", "ap.plots.model_image(fig4, ax4[0], model2)\n", "ap.plots.residual_image(fig4, ax4[1], model2)\n", @@ -146,10 +150,8 @@ "result = ap.fit.LM(model2, verbose=1).fit()\n", "\n", "# See that we use ap.fit.LM, this is the Levenberg-Marquardt Chi^2 minimization method, it is the recommended technique\n", - "# for most least-squares problems. However, there are situations in which different optimizers may be more desirable\n", - "# so the ap.fit package includes a few options to pick from. The various fitting methods will be described in a\n", - "# different tutorial.\n", - "print(\"Fit message:\", result.message) # the fitter will return a message about its convergence" + "# for most least-squares problems. See the Fitting Methods tutorial for more on fitters!\n", + "print(\"Fit message:\", result.message) # the fitter will store a message about its convergence" ] }, { diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index 73ea3cf2..fc098cb7 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -23,10 +23,8 @@ "source": [ "import astrophot as ap\n", "import numpy as np\n", - "import torch\n", "from astropy.io import fits\n", - "import matplotlib.pyplot as plt\n", - "from scipy.stats import iqr" + "import matplotlib.pyplot as plt" ] }, { @@ -74,7 +72,7 @@ "outputs": [], "source": [ "pixelscale = 0.262\n", - "target = ap.image.TargetImage(\n", + "target = ap.TargetImage(\n", " data=target_data,\n", " pixelscale=pixelscale,\n", " zeropoint=22.5,\n", @@ -105,13 +103,11 @@ "# This will convert the segmentation map into boxes that enclose the identified pixels\n", "windows = ap.utils.initialize.windows_from_segmentation_map(segmap)\n", "# Next we scale up the windows so that AstroPhot can fit the faint parts of each object as well\n", - "windows = ap.utils.initialize.scale_windows(\n", - " windows, image_shape=target_data.shape, expand_scale=2, expand_border=10\n", - ")\n", + "windows = ap.utils.initialize.scale_windows(windows, image=target, expand_scale=2, expand_border=10)\n", "# Here we get some basic starting parameters for the galaxies (center, position angle, axis ratio)\n", - "centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, target_data)\n", - "PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, target_data, centers)\n", - "qs = ap.utils.initialize.q_from_segmentation_map(segmap, target_data, centers, PAs)" + "centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, target)\n", + "PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, target, centers)\n", + "qs = ap.utils.initialize.q_from_segmentation_map(segmap, target, centers)" ] }, { @@ -124,24 +120,25 @@ "seg_models = []\n", "for win in windows:\n", " seg_models.append(\n", - " ap.models.Model(\n", + " ap.Model(\n", " name=f\"object {win:02d}\",\n", " window=windows[win],\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " center=np.array(centers[win]) * pixelscale,\n", + " center=centers[win],\n", " PA=PAs[win],\n", " q=qs[win],\n", " )\n", " )\n", - "sky = ap.models.Model(\n", + "sky = ap.Model(\n", " name=f\"sky level\",\n", " model_type=\"flat sky model\",\n", " target=target,\n", + " I={\"valid\": (0, None)},\n", ")\n", "\n", "# We build the group model just like any other, except we pass a list of other models\n", - "groupmodel = ap.models.Model(\n", + "groupmodel = ap.Model(\n", " name=\"group\", models=[sky] + seg_models, target=target, model_type=\"group model\"\n", ")\n", "\n", @@ -177,7 +174,7 @@ "source": [ "# This is now a very complex model composed of 9 sub-models! In total 57 parameters!\n", "# Here we will limit it to 1 iteration so that it runs quickly. In general you should let it run to convergence\n", - "result = ap.fit.Iter(groupmodel, verbose=1, max_iter=1).fit()" + "result = ap.fit.Iter(groupmodel, verbose=1, max_iter=2).fit()" ] }, { @@ -188,7 +185,7 @@ "source": [ "# Now we can see what the fitting has produced\n", "fig10, ax10 = plt.subplots(1, 2, figsize=(16, 7))\n", - "ap.plots.model_image(fig10, ax10[0], groupmodel)\n", + "ap.plots.model_image(fig10, ax10[0], groupmodel, vmax=30)\n", "ap.plots.residual_image(fig10, ax10[1], groupmodel, normalize_residuals=True)\n", "plt.show()" ] diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index f4499d63..38d9a79a 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -6,7 +6,7 @@ "source": [ "# Joint Modelling\n", "\n", - "In this tutorial you will learn how to set up a joint modelling fit which encoporates the data from multiple images. These use `Group_Model` objects just like in the `GroupModels.ipynb` tutorial, the main difference being how the `Target_Image` object is constructed and that more care must be taken when assigning targets to models. \n", + "In this tutorial you will learn how to set up a joint modelling fit which encoporates the data from multiple images. These use `GroupModel` objects just like in the `GroupModels.ipynb` tutorial, the main difference being how the `TargetImage` object is constructed and that more care must be taken when assigning targets to models. \n", "\n", "It is, of course, more work to set up a fit across multiple target images. However, the tradeoff can be well worth it. Perhaps there is space-based data with high resolution, but groundbased data has better S/N. Or perhaps each band individually does not have enough signal for a confident fit, but all three together just might. Perhaps colour information is of paramount importance for a science goal, one would hope that both bands could be treated on equal footing but in a consistent way when extracting profile information. There are a number of reasons why one might wish to try and fit a multi image picture of a galaxy simultaneously. \n", "\n", @@ -20,7 +20,6 @@ "outputs": [], "source": [ "import astrophot as ap\n", - "import torch\n", "import matplotlib.pyplot as plt" ] }, @@ -40,7 +39,7 @@ "# science level analysis one should endeavor to get the best measure available for these.\n", "\n", "# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel and is 500 pixels across\n", - "target_r = ap.image.TargetImage(\n", + "target_r = ap.TargetImage(\n", " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=500&layer=ls-dr9&pixscale=0.262&bands=r\",\n", " zeropoint=22.5,\n", " variance=\"auto\", # auto variance gets it roughly right, use better estimate for science!\n", @@ -50,7 +49,7 @@ "\n", "\n", "# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel and is 52 pixels across\n", - "target_W1 = ap.image.TargetImage(\n", + "target_W1 = ap.TargetImage(\n", " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1\",\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", @@ -59,7 +58,7 @@ ")\n", "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel and is 90 pixels across\n", - "target_NUV = ap.image.TargetImage(\n", + "target_NUV = ap.TargetImage(\n", " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=90&layer=galex&pixscale=1.5&bands=n\",\n", " zeropoint=20.08,\n", " variance=\"auto\",\n", @@ -85,7 +84,7 @@ "source": [ "# The joint model will need a target to try and fit, but now that we have multiple images the \"target\" is\n", "# a Target_Image_List object which points to all three.\n", - "target_full = ap.image.TargetImageList((target_r, target_W1, target_NUV))\n", + "target_full = ap.TargetImageList((target_r, target_W1, target_NUV))\n", "# It doesn't really need any other information since everything is already available in the individual targets" ] }, @@ -98,14 +97,14 @@ "# To make things easy to start, lets just fit a sersic model to all three. In principle one can use arbitrary\n", "# group models designed for each band individually, but that would be unnecessarily complex for a tutorial\n", "\n", - "model_r = ap.models.Model(\n", + "model_r = ap.Model(\n", " name=\"rband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_r,\n", " psf_convolve=True,\n", ")\n", "\n", - "model_W1 = ap.models.Model(\n", + "model_W1 = ap.Model(\n", " name=\"W1band model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", @@ -114,7 +113,7 @@ " psf_convolve=True,\n", ")\n", "\n", - "model_NUV = ap.models.Model(\n", + "model_NUV = ap.Model(\n", " name=\"NUVband model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", @@ -129,7 +128,7 @@ "for p in [\"center\", \"q\", \"PA\", \"n\", \"Re\"]:\n", " model_W1[p].value = model_r[p]\n", " model_NUV[p].value = model_r[p]\n", - "# Now every model will have a unique Ie, but every other parameter is shared for all three" + "# Now every model will have a unique Ie, but every other parameter is shared" ] }, { @@ -140,7 +139,7 @@ "source": [ "# We can now make the joint model object\n", "\n", - "model_full = ap.models.Model(\n", + "model_full = ap.Model(\n", " name=\"LEDA 41136\",\n", " model_type=\"group model\",\n", " models=[model_r, model_W1, model_NUV],\n", @@ -148,17 +147,6 @@ ")\n", "\n", "model_full.initialize()\n", - "print(model_full)\n", - "ig1, ax1 = plt.subplots(2, 3, figsize=(18, 12))\n", - "ap.plots.model_image(fig1, ax1[0], model_full)\n", - "ax1[0][0].set_title(\"r-band model image\")\n", - "ax1[0][1].set_title(\"W1-band model image\")\n", - "ax1[0][2].set_title(\"NUV-band model image\")\n", - "ap.plots.residual_image(fig1, ax1[1], model_full, normalize_residuals=True)\n", - "ax1[1][0].set_title(\"r-band residual image\")\n", - "ax1[1][1].set_title(\"W1-band residual image\")\n", - "ax1[1][2].set_title(\"NUV-band residual image\")\n", - "plt.show()\n", "model_full.graphviz()" ] }, @@ -169,8 +157,7 @@ "outputs": [], "source": [ "result = ap.fit.LM(model_full, verbose=1).fit()\n", - "print(result.message)\n", - "print(model_full)" + "print(result.message)" ] }, { @@ -271,7 +258,7 @@ "#########################################\n", "from photutils.segmentation import detect_sources, deblend_sources\n", "\n", - "rdata = target_r.data.detach().cpu().numpy()\n", + "rdata = target_r.data.T.detach().cpu().numpy()\n", "initsegmap = detect_sources(rdata, threshold=0.01, npixels=10)\n", "segmap = deblend_sources(rdata, initsegmap, npixels=5).data\n", "fig8, ax8 = plt.subplots(figsize=(8, 8))\n", @@ -281,17 +268,15 @@ "rwindows = ap.utils.initialize.windows_from_segmentation_map(segmap)\n", "# Next we scale up the windows so that AstroPhot can fit the faint parts of each object as well\n", "rwindows = ap.utils.initialize.scale_windows(\n", - " rwindows, image_shape=rdata.shape, expand_scale=1.5, expand_border=10\n", + " rwindows, image=target_r, expand_scale=1.5, expand_border=10\n", ")\n", "w1windows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_W1)\n", - "w1windows = ap.utils.initialize.scale_windows(\n", - " w1windows, image_shape=w1img[0].data.shape, expand_border=1\n", - ")\n", + "w1windows = ap.utils.initialize.scale_windows(w1windows, image=target_W1, expand_border=1)\n", "nuvwindows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_NUV)\n", "# Here we get some basic starting parameters for the galaxies (center, position angle, axis ratio)\n", - "centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, rdata)\n", - "PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, rdata, centers)\n", - "qs = ap.utils.initialize.q_from_segmentation_map(segmap, rdata, centers)" + "centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, target_r)\n", + "PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, target_r, centers)\n", + "qs = ap.utils.initialize.q_from_segmentation_map(segmap, target_r, centers)" ] }, { @@ -315,19 +300,19 @@ " # create the submodels for this object\n", " sub_list = []\n", " sub_list.append(\n", - " ap.models.Model(\n", + " ap.Model(\n", " name=f\"rband model {i}\",\n", " model_type=\"sersic galaxy model\", # we could use spline models for the r-band since it is well resolved\n", " target=target_r,\n", " window=rwindows[window],\n", " psf_convolve=True,\n", - " center=torch.stack(target_r.pixel_to_plane(*torch.tensor(centers[window]))),\n", - " PA=target_r.pixel_angle_to_plane_angle(torch.tensor(PAs[window])),\n", + " center=centers[window],\n", + " PA=PAs[window],\n", " q=qs[window],\n", " )\n", " )\n", " sub_list.append(\n", - " ap.models.Model(\n", + " ap.Model(\n", " name=f\"W1band model {i}\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", @@ -336,7 +321,7 @@ " )\n", " )\n", " sub_list.append(\n", - " ap.models.Model(\n", + " ap.Model(\n", " name=f\"NUVband model {i}\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", @@ -352,7 +337,7 @@ "\n", " # Make the multiband model for this object\n", " model_list.append(\n", - " ap.models.Model(\n", + " ap.Model(\n", " name=f\"model {i}\",\n", " model_type=\"group model\",\n", " target=target_full,\n", @@ -360,7 +345,7 @@ " )\n", " )\n", "# Make the full model for this system of objects\n", - "MODEL = ap.models.Model(\n", + "MODEL = ap.Model(\n", " name=f\"full model\",\n", " model_type=\"group model\",\n", " target=target_full,\n", @@ -406,7 +391,7 @@ "ax1[0][0].set_title(\"r-band model image\")\n", "ax1[0][1].set_title(\"W1-band model image\")\n", "ax1[0][2].set_title(\"NUV-band model image\")\n", - "ap.plots.residual_image(fig, ax1[1], MODEL, normalize_residuals=True)\n", + "ap.plots.residual_image(fig1, ax1[1], MODEL, normalize_residuals=True)\n", "ax1[1][0].set_title(\"r-band residual image\")\n", "ax1[1][1].set_title(\"W1-band residual image\")\n", "ax1[1][2].set_title(\"NUV-band residual image\")\n", From b649b2bad5327e2ccc3180f977fe345c123f3b93 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 20 Jul 2025 11:32:57 -0400 Subject: [PATCH 065/191] add image alignment tutorial --- astrophot/fit/func/lm.py | 6 +- astrophot/image/base.py | 193 ------------ astrophot/models/base.py | 4 + docs/source/tutorials/ImageAlignment.ipynb | 341 +++++++++++++++++++++ docs/source/tutorials/index.rst | 1 + tests/test_model.py | 7 +- 6 files changed, 354 insertions(+), 198 deletions(-) delete mode 100644 astrophot/image/base.py create mode 100644 docs/source/tutorials/ImageAlignment.ipynb diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index fbefb472..30b39cb9 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -61,13 +61,13 @@ def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11. if chi21 < scary["chi2"]: scary = {"x": x + h.squeeze(1), "chi2": chi21, "L": L} - # if torch.allclose(h, torch.zeros_like(h)): - # raise OptimizeStopSuccess("Step with zero length means optimization complete.") + if torch.allclose(h, torch.zeros_like(h)) and L < 0.1: + raise OptimizeStopSuccess("Step with zero length means optimization complete.") # actual chi2 improvement vs expected from linearization rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() # Avoid highly non-linear regions - if rho < 0.1 or rho > 10: + if rho < 0.1 or rho > 2: L *= Lup if improving is True: break diff --git a/astrophot/image/base.py b/astrophot/image/base.py deleted file mode 100644 index 3342c79c..00000000 --- a/astrophot/image/base.py +++ /dev/null @@ -1,193 +0,0 @@ -from typing import Optional, Union - -import torch -import numpy as np - -from ..param import Module -from .. import AP_config -from .window import Window -from . import func - - -class BaseImage(Module): - - def __init__( - self, - *, - data: Optional[torch.Tensor] = None, - crpix: Union[torch.Tensor, tuple] = (0.0, 0.0), - identity: str = None, - name: Optional[str] = None, - ) -> None: - - super().__init__(name=name) - self.data = data # units: flux - self.crpix = crpix - - if identity is None: - self.identity = id(self) - else: - self.identity = identity - - @property - def data(self): - """The image data, which is a tensor of pixel values.""" - return self._data - - @data.setter - def data(self, value: Optional[torch.Tensor]): - """Set the image data. If value is None, the data is initialized to an empty tensor.""" - if value is None: - self._data = torch.empty((0, 0), dtype=AP_config.ap_dtype, device=AP_config.ap_device) - else: - # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates - self._data = torch.transpose( - torch.as_tensor(value, dtype=AP_config.ap_dtype, device=AP_config.ap_device), 0, 1 - ) - - @property - def crpix(self): - """The reference pixel coordinates in the image, which is used to convert from pixel coordinates to tangent plane coordinates.""" - return self._crpix - - @crpix.setter - def crpix(self, value: Union[torch.Tensor, tuple]): - self._crpix = np.asarray(value, dtype=np.float64) - - @property - def window(self): - return Window(window=((0, 0), self.data.shape[:2]), image=self) - - @property - def shape(self): - """The shape of the image data.""" - return self.data.shape - - def pixel_center_meshgrid(self): - """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" - return func.pixel_center_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) - - def pixel_corner_meshgrid(self): - """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" - return func.pixel_corner_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) - - def pixel_simpsons_meshgrid(self): - """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" - return func.pixel_simpsons_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) - - def pixel_quad_meshgrid(self, order=3): - """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" - return func.pixel_quad_meshgrid( - self.shape, AP_config.ap_dtype, AP_config.ap_device, order=order - ) - - def copy(self, **kwargs): - """Produce a copy of this image with all of the same properties. This - can be used when one wishes to make temporary modifications to - an image and then will want the original again. - - """ - kwargs = { - "data": torch.transpose(torch.clone(self.data.detach()), 0, 1), - "crpix": self.crpix, - "identity": self.identity, - "name": self.name, - **kwargs, - } - return self.__class__(**kwargs) - - def blank_copy(self, **kwargs): - """Produces a blank copy of the image which has the same properties - except that its data is now filled with zeros. - - """ - kwargs = { - "data": torch.transpose(torch.zeros_like(self.data), 0, 1), - "crpix": self.crpix, - "identity": self.identity, - "name": self.name, - **kwargs, - } - return self.__class__(**kwargs) - - def flatten(self, attribute: str = "data") -> torch.Tensor: - return getattr(self, attribute).flatten(end_dim=1) - - @torch.no_grad() - def get_indices(self, other: Window): - if other.image is self: - return slice(max(0, other.i_low), min(self.shape[0], other.i_high)), slice( - max(0, other.j_low), min(self.shape[1], other.j_high) - ) - shift = np.round(self.crpix - other.crpix).astype(int) - return slice( - min(max(0, other.i_low + shift[0]), self.shape[0]), - max(0, min(other.i_high + shift[0], self.shape[0])), - ), slice( - min(max(0, other.j_low + shift[1]), self.shape[1]), - max(0, min(other.j_high + shift[1], self.shape[1])), - ) - - @torch.no_grad() - def get_other_indices(self, other: Window): - if other.image == self: - shape = other.shape - return slice(max(0, -other.i_low), min(self.shape[0] - other.i_low, shape[0])), slice( - max(0, -other.j_low), min(self.shape[1] - other.j_low, shape[1]) - ) - raise ValueError() - - def get_window(self, other: Union[Window, "BaseImage"], indices=None, **kwargs): - """Get a new image object which is a window of this image - corresponding to the other image's window. This will return a - new image object with the same properties as this one, but with - the data cropped to the other image's window. - - """ - if indices is None: - indices = self.get_indices(other if isinstance(other, Window) else other.window) - new_img = self.copy( - data=self.data[indices], - crpix=self.crpix - np.array((indices[0].start, indices[1].start)), - **kwargs, - ) - return new_img - - def __sub__(self, other): - if isinstance(other, BaseImage): - new_img = self[other] - new_img.data = new_img.data - other[self].data - return new_img - else: - new_img = self.copy() - new_img.data = new_img.data - other - return new_img - - def __add__(self, other): - if isinstance(other, BaseImage): - new_img = self[other] - new_img.data = new_img.data + other[self].data - return new_img - else: - new_img = self.copy() - new_img.data = new_img.data + other - return new_img - - def __iadd__(self, other): - if isinstance(other, BaseImage): - self.data[self.get_indices(other.window)] += other.data[other.get_indices(self.window)] - else: - self.data = self.data + other - return self - - def __isub__(self, other): - if isinstance(other, BaseImage): - self.data[self.get_indices(other.window)] -= other.data[other.get_indices(self.window)] - else: - self.data = self.data - other - return self - - def __getitem__(self, *args): - if len(args) == 1 and isinstance(args[0], (BaseImage, Window)): - return self.get_window(args[0]) - return super().__getitem__(*args) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 0333586b..d8060a04 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -4,6 +4,7 @@ import torch import numpy as np +from caskade import Param as CParam from ..param import Module, forward, Param from ..utils.decorators import classproperty from ..image import Window, ImageList, ModelImage, ModelImageList @@ -113,6 +114,9 @@ def build_parameter_specs(self, kwargs, parameter_specs) -> dict: else: parameter_specs[p]["dynamic_value"] = kwargs.pop(p) parameter_specs[p].pop("value", None) + if isinstance(parameter_specs[p].get("dynamic_value", None), CParam): + parameter_specs[p]["value"] = parameter_specs[p]["dynamic_value"] + parameter_specs[p].pop("dynamic_value", None) return parameter_specs diff --git a/docs/source/tutorials/ImageAlignment.ipynb b/docs/source/tutorials/ImageAlignment.ipynb new file mode 100644 index 00000000..ea2a850c --- /dev/null +++ b/docs/source/tutorials/ImageAlignment.ipynb @@ -0,0 +1,341 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Aligning Images\n", + "\n", + "In AstroPhot, the image WCS is part of the model and so can be optimized alongside other model parameters. Here we will demonstrate a basic example of image alignment, but the sky is the limit, you can perform highly detailed image alignment with AstroPhot!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import astrophot as ap\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import torch" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Relative shift\n", + "\n", + "Often the WCS solution is already really good, we just need a local shift in x and/or y to get things just right. Lets start by optimizing a translation in the WCS that improves the fit for our models!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "target_r = ap.TargetImage(\n", + " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=r\",\n", + " name=\"target_r\",\n", + " variance=\"auto\",\n", + ")\n", + "target_g = ap.TargetImage(\n", + " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=g\",\n", + " name=\"target_g\",\n", + " variance=\"auto\",\n", + ")\n", + "\n", + "# Uh-oh! our images are misaligned by 1 pixel, this will cause problems!\n", + "target_g.crpix = target_g.crpix + 1\n", + "\n", + "fig, axarr = plt.subplots(1, 2, figsize=(15, 7))\n", + "ap.plots.target_image(fig, axarr[0], target_r)\n", + "axarr[0].set_title(\"Target Image (r-band)\")\n", + "ap.plots.target_image(fig, axarr[1], target_g)\n", + "axarr[1].set_title(\"Target Image (g-band)\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# r-band model\n", + "psfr = ap.Model(\n", + " name=\"psfr\",\n", + " model_type=\"moffat psf model\",\n", + " n=2,\n", + " Rd=1.0,\n", + " target=target_r.psf_image(data=np.zeros((51, 51))),\n", + ")\n", + "star1r = ap.Model(\n", + " name=\"star1-r\",\n", + " model_type=\"point model\",\n", + " window=[0, 60, 80, 135],\n", + " center=[12, 9],\n", + " psf=psfr,\n", + " target=target_r,\n", + ")\n", + "star2r = ap.Model(\n", + " name=\"star2-r\",\n", + " model_type=\"point model\",\n", + " window=[40, 90, 20, 70],\n", + " center=[3, -7],\n", + " psf=psfr,\n", + " target=target_r,\n", + ")\n", + "star3r = ap.Model(\n", + " name=\"star3-r\",\n", + " model_type=\"point model\",\n", + " window=[109, 150, 40, 90],\n", + " center=[-15, -3],\n", + " psf=psfr,\n", + " target=target_r,\n", + ")\n", + "modelr = ap.Model(\n", + " name=\"model-r\", model_type=\"group model\", models=[star1r, star2r, star3r], target=target_r\n", + ")\n", + "\n", + "# g-band model\n", + "psfg = ap.Model(\n", + " name=\"psfg\",\n", + " model_type=\"moffat psf model\",\n", + " n=2,\n", + " Rd=1.0,\n", + " target=target_g.psf_image(data=np.zeros((51, 51))),\n", + ")\n", + "star1g = ap.Model(\n", + " name=\"star1-g\",\n", + " model_type=\"point model\",\n", + " window=[0, 60, 80, 135],\n", + " center=star1r.center,\n", + " psf=psfg,\n", + " target=target_g,\n", + ")\n", + "star2g = ap.Model(\n", + " name=\"star2-g\",\n", + " model_type=\"point model\",\n", + " window=[40, 90, 20, 70],\n", + " center=star2r.center,\n", + " psf=psfg,\n", + " target=target_g,\n", + ")\n", + "star3g = ap.Model(\n", + " name=\"star3-g\",\n", + " model_type=\"point model\",\n", + " window=[109, 150, 40, 90],\n", + " center=star3r.center,\n", + " psf=psfg,\n", + " target=target_g,\n", + ")\n", + "modelg = ap.Model(\n", + " name=\"model-g\", model_type=\"group model\", models=[star1g, star2g, star3g], target=target_g\n", + ")\n", + "\n", + "# total model\n", + "target_full = ap.TargetImageList([target_r, target_g])\n", + "model = ap.Model(\n", + " name=\"model\", model_type=\"group model\", models=[modelr, modelg], target=target_full\n", + ")\n", + "\n", + "fig, axarr = plt.subplots(1, 2, figsize=(15, 7))\n", + "ap.plots.target_image(fig, axarr, target_full)\n", + "axarr[0].set_title(\"Target Image (r-band)\")\n", + "axarr[1].set_title(\"Target Image (g-band)\")\n", + "ap.plots.model_window(fig, axarr[0], modelr)\n", + "ap.plots.model_window(fig, axarr[1], modelg)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "model.initialize()\n", + "res = ap.fit.LM(model, verbose=1).fit()\n", + "fig, axarr = plt.subplots(2, 2, figsize=(15, 10))\n", + "ap.plots.model_image(fig, axarr[0], model)\n", + "axarr[0, 0].set_title(\"Model Image (r-band)\")\n", + "axarr[0, 1].set_title(\"Model Image (g-band)\")\n", + "ap.plots.residual_image(fig, axarr[1], model)\n", + "axarr[1, 0].set_title(\"Residual Image (r-band)\")\n", + "axarr[1, 1].set_title(\"Residual Image (g-band)\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "Here we see a clear signal of an image misalignment, in the g-band all of the residuals have a dipole in the same direction! Lets free up the position of the g-band image and optimize a shift. This only requires a single line of code!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "target_g.crtan.to_dynamic()" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Now we can optimize the model again, notice how it now has two more parameters. These are the x,y position of the image in the tangent plane. See the AstroPhot coordinate description on the website for more details on why this works." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "res = ap.fit.LM(model, verbose=1).fit()\n", + "fig, axarr = plt.subplots(2, 2, figsize=(15, 10))\n", + "ap.plots.model_image(fig, axarr[0], model)\n", + "axarr[0, 0].set_title(\"Model Image (r-band)\")\n", + "axarr[0, 1].set_title(\"Model Image (g-band)\")\n", + "ap.plots.residual_image(fig, axarr[1], model)\n", + "axarr[1, 0].set_title(\"Residual Image (r-band)\")\n", + "axarr[1, 1].set_title(\"Residual Image (g-band)\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "Yay! no more dipole. The fits aren't the best, clearly these objects aren't super well described by a single moffat model. But the main goal today was to show that we could align the images very easily. Note, its probably best to start with a reasonably good WCS from the outset, and this two stage approach where we optimize the models and then optimize the models plus a shift might be more stable than just fitting everything at once from the outset. Often for more complex models it is best to start with a simpler model and fit each time you introduce more complexity." + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "## Shift and rotation\n", + "\n", + "Lets say we really don't trust our WCS, we think something has gone wrong and we want freedom to fully shift and rotate the relative positions of the images relative to each other. How can we do this?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "def rotate(phi):\n", + " \"\"\"Create a 2D rotation matrix for a given angle in radians.\"\"\"\n", + " return torch.stack(\n", + " [\n", + " torch.stack([torch.cos(phi), -torch.sin(phi)]),\n", + " torch.stack([torch.sin(phi), torch.cos(phi)]),\n", + " ]\n", + " )\n", + "\n", + "\n", + "# Uh-oh! Our image is misaligned by some small angle\n", + "target_g.CD = target_g.CD.value @ rotate(torch.tensor(np.pi / 32, dtype=torch.float64))\n", + "# Uh-oh! our alignment from before has been erased\n", + "target_g.crtan.value = (0, 0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axarr = plt.subplots(2, 2, figsize=(15, 10))\n", + "ap.plots.model_image(fig, axarr[0], model)\n", + "axarr[0, 0].set_title(\"Model Image (r-band)\")\n", + "axarr[0, 1].set_title(\"Model Image (g-band)\")\n", + "ap.plots.residual_image(fig, axarr[1], model)\n", + "axarr[1, 0].set_title(\"Residual Image (r-band)\")\n", + "axarr[1, 1].set_title(\"Residual Image (g-band)\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "# this will control the relative rotation of the g-band image\n", + "phi = ap.Param(name=\"phi\", dynamic_value=0.0, dtype=torch.float64)\n", + "\n", + "# Set the target_g CD matrix to be a function of the rotation angle\n", + "init_CD = target_g.CD.value.clone()\n", + "target_g.CD = lambda p: init_CD @ rotate(p.phi.value)\n", + "target_g.CD.link(phi)\n", + "\n", + "# also optimize the shift of the g-band image\n", + "target_g.crtan.to_dynamic()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "res = ap.fit.LM(model, verbose=1).fit()\n", + "fig, axarr = plt.subplots(2, 2, figsize=(15, 10))\n", + "ap.plots.model_image(fig, axarr[0], model)\n", + "axarr[0, 0].set_title(\"Model Image (r-band)\")\n", + "axarr[0, 1].set_title(\"Model Image (g-band)\")\n", + "ap.plots.residual_image(fig, axarr[1], model)\n", + "axarr[1, 0].set_title(\"Residual Image (r-band)\")\n", + "axarr[1, 1].set_title(\"Residual Image (g-band)\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index c1fa8b91..9c759cbb 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -15,6 +15,7 @@ version of each tutorial is available here. ModelZoo BasicPSFModels JointModels + ImageAlignment CustomModels AdvancedPSFModels ConstrainedModels diff --git a/tests/test_model.py b/tests/test_model.py index ed046138..ed16800a 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -95,6 +95,8 @@ def test_all_model_sample(model_type): ), "Model should evaluate a real number for the full image" res = ap.fit.LM(MODEL, max_iter=10).fit() + # sky has little freedom to fit, some more complex models need extra + # attention to get a good fit so here we just check that they can improve if ( "sky" in model_type or "king" in model_type @@ -103,13 +105,14 @@ def test_all_model_sample(model_type): "spline ray galaxy model", "exponential warp galaxy model", "spline wedge galaxy model", + "ferrer warp galaxy model", ] - ): # sky has little freedom to fit + ): assert res.loss_history[0] > res.loss_history[-1], ( f"Model {model_type} should fit to the target image, but did not. " f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" ) - else: + else: # Most models should get significantly better after just a few iterations assert res.loss_history[0] > (2 * res.loss_history[-1]), ( f"Model {model_type} should fit to the target image, but did not. " f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" From e09ddf8162f8c36cda4a6264385214d909fde5aa Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 20 Jul 2025 22:51:00 -0400 Subject: [PATCH 066/191] add gravitational lensing tutorial --- astrophot/fit/func/lm.py | 4 +- astrophot/fit/lm.py | 2 - astrophot/image/image_object.py | 6 +- astrophot/models/base.py | 4 +- docs/requirements.txt | 1 + .../tutorials/GravitationalLensing.ipynb | 203 ++++++++++++++++++ docs/source/tutorials/ImageAlignment.ipynb | 100 +++------ docs/source/tutorials/index.rst | 1 + 8 files changed, 237 insertions(+), 84 deletions(-) create mode 100644 docs/source/tutorials/GravitationalLensing.ipynb diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 30b39cb9..42494ef3 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -30,11 +30,11 @@ def solve(hess, grad, L): return hessD, h -def lm_step(x, data, model, weight, jacobian, ndf, chi2, L=1.0, Lup=9.0, Ldn=11.0): - chi20 = chi2 +def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0): M0 = model(x) # (M,) J = jacobian(x) # (M, N) R = data - M0 # (M,) + chi20 = torch.sum(weight * R**2).item() / ndf grad = gradient(J, weight, R) # (N, 1) hess = hessian(J, weight) # (N, N) if torch.allclose(grad, torch.zeros_like(grad)): diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 312c884d..7511c468 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -278,7 +278,6 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: weight=self.W, jacobian=self.jacobian, ndf=self.ndf, - chi2=self.loss_history[-1], L=self.L, Lup=self.Lup, Ldn=self.Ldn, @@ -292,7 +291,6 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: weight=self.W, jacobian=self.jacobian, ndf=self.ndf, - chi2=self.loss_history[-1], L=self.L, Lup=self.Lup, Ldn=self.Ldn, diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 892e593f..1685619a 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -70,6 +70,7 @@ def __init__( ) self.crtan = Param( "crtan", + crtan, shape=(2,), units="arcsec", dtype=AP_config.ap_dtype, @@ -114,7 +115,6 @@ def __init__( # set the data self.crval = crval - self.crtan = crtan self.crpix = crpix if isinstance(CD, (float, int)): @@ -464,12 +464,8 @@ def load(self, filename: str, hduext=0): self.crval = (hdulist[hduext].header["CRVAL1"], hdulist[hduext].header["CRVAL2"]) if "CRTAN1" in hdulist[hduext].header and "CRTAN2" in hdulist[hduext].header: self.crtan = (hdulist[hduext].header["CRTAN1"], hdulist[hduext].header["CRTAN2"]) - else: - self.crtan = (0.0, 0.0) if "MAGZP" in hdulist[hduext].header and hdulist[hduext].header["MAGZP"] > -998: self.zeropoint = hdulist[hduext].header["MAGZP"] - else: - self.zeropoint = None self.identity = hdulist[hduext].header.get("IDNTY", str(id(self))) return hdulist diff --git a/astrophot/models/base.py b/astrophot/models/base.py index d8060a04..5cc7409c 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -114,7 +114,9 @@ def build_parameter_specs(self, kwargs, parameter_specs) -> dict: else: parameter_specs[p]["dynamic_value"] = kwargs.pop(p) parameter_specs[p].pop("value", None) - if isinstance(parameter_specs[p].get("dynamic_value", None), CParam): + if isinstance(parameter_specs[p].get("dynamic_value", None), CParam) or callable( + parameter_specs[p].get("dynamic_value", None) + ): parameter_specs[p]["value"] = parameter_specs[p]["dynamic_value"] parameter_specs[p].pop("dynamic_value", None) diff --git a/docs/requirements.txt b/docs/requirements.txt index e32d2be5..6807916c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +caustics ipywidgets jupyter-book matplotlib diff --git a/docs/source/tutorials/GravitationalLensing.ipynb b/docs/source/tutorials/GravitationalLensing.ipynb new file mode 100644 index 00000000..2a7daa77 --- /dev/null +++ b/docs/source/tutorials/GravitationalLensing.ipynb @@ -0,0 +1,203 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Gravitational Lensing\n", + "\n", + "AstroPhot is now part of the caskade ecosystem. caskade simulators can interface\n", + "very easily since the parameter management is handled automatically. Here we\n", + "demonstrate how the caustics package, which is also written in caskade, can be\n", + "used to add gravitational lensing to AstroPhot models. This is similar to the\n", + "Custom Models tutorial although more specific." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import astrophot as ap\n", + "import matplotlib.pyplot as plt\n", + "import caustics\n", + "import numpy as np\n", + "import torch" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "class LensSersic(ap.models.SersicGalaxy):\n", + " _model_type = \"lensed\"\n", + "\n", + " def __init__(self, *args, lens, **kwargs):\n", + " super().__init__(*args, **kwargs)\n", + " self.lens = lens\n", + "\n", + " def transform_coordinates(self, x, y):\n", + " x, y = self.lens.raytrace(x, y)\n", + " x, y = super().transform_coordinates(x, y)\n", + " return x, y" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "target = ap.TargetImage(\n", + " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=177.1380&dec=19.5008&size=150&layer=ls-dr9&pixscale=0.262&bands=g\",\n", + " name=\"horseshoe\",\n", + " variance=\"auto\",\n", + " zeropoint=22.5,\n", + ")\n", + "target.psf = target.psf_image(data=ap.utils.initialize.gaussian_psf(0.5, 51, 0.262))\n", + "\n", + "cosmology = caustics.FlatLambdaCDM(name=\"cosmology\")\n", + "lens = caustics.SIE(\n", + " name=\"lens\",\n", + " x0=0.28,\n", + " y0=0.79,\n", + " q=0.9,\n", + " phi=2.5 * np.pi / 10,\n", + " Rein=5.5,\n", + " z_l=0.4457,\n", + " z_s=2.379,\n", + " cosmology=cosmology,\n", + ")\n", + "lens.to_dynamic()\n", + "lens.z_l.to_static()\n", + "lens.z_s.to_static()\n", + "source = ap.Model(\n", + " name=\"source\",\n", + " model_type=\"lensed sersic galaxy model\",\n", + " lens=lens,\n", + " center=[0.2, 0.42],\n", + " q=0.6,\n", + " PA=np.pi / 3,\n", + " n=1,\n", + " Re=0.1,\n", + " Ie=1.5,\n", + " target=target,\n", + " psf_convolve=True,\n", + ")\n", + "lenslight = ap.Model(\n", + " name=\"lenslight\",\n", + " model_type=\"sersic galaxy model\",\n", + " center=lambda p: torch.stack((p.x0.value, p.y0.value)),\n", + " q=lens.q,\n", + " PA=0,\n", + " n=4.7,\n", + " Re=1,\n", + " Ie=0.2,\n", + " target=target,\n", + " psf_convolve=True,\n", + ")\n", + "lenslight.center.link((lens.x0, lens.y0))\n", + "\n", + "model = ap.Model(\n", + " name=\"horseshoe\",\n", + " model_type=\"group model\",\n", + " models=[source, lenslight],\n", + " target=target,\n", + ")\n", + "model.initialize()\n", + "\n", + "fig, axarr = plt.subplots(1, 3, figsize=(15, 4))\n", + "ap.plots.target_image(fig, axarr[0], target)\n", + "axarr[0].set_title(\"Target Image\")\n", + "ap.plots.model_image(fig, axarr[1], model)\n", + "axarr[1].set_title(\"Model Image\")\n", + "ap.plots.residual_image(fig, axarr[2], model)\n", + "axarr[2].set_title(\"Residual Image\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "Note that we give reasonable starting parameters for the lensing model. Gravitational lensing is notoriously hard to model, so we need to start near the correct minimum otherwise we may easily fall to some poor local minimum." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "model.graphviz()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "res = ap.fit.LM(model, verbose=1).fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axarr = plt.subplots(1, 3, figsize=(15, 4))\n", + "ap.plots.target_image(fig, axarr[0], target)\n", + "axarr[0].set_title(\"Target Image\")\n", + "ap.plots.model_image(fig, axarr[1], model, vmax=32)\n", + "axarr[1].set_title(\"Model Image\")\n", + "ap.plots.residual_image(fig, axarr[2], model)\n", + "axarr[2].set_title(\"Residual Image\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "This is not an exceptionally good fit, but it is well known that the horseshoe requires a more detailed model than an SIE lens. The cool result here is that we were able to link AstroPhot and caustics very easily to create a detailed lensing model!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/ImageAlignment.ipynb b/docs/source/tutorials/ImageAlignment.ipynb index ea2a850c..5ebcc669 100644 --- a/docs/source/tutorials/ImageAlignment.ipynb +++ b/docs/source/tutorials/ImageAlignment.ipynb @@ -69,84 +69,26 @@ "metadata": {}, "outputs": [], "source": [ + "# fmt: off\n", "# r-band model\n", - "psfr = ap.Model(\n", - " name=\"psfr\",\n", - " model_type=\"moffat psf model\",\n", - " n=2,\n", - " Rd=1.0,\n", - " target=target_r.psf_image(data=np.zeros((51, 51))),\n", - ")\n", - "star1r = ap.Model(\n", - " name=\"star1-r\",\n", - " model_type=\"point model\",\n", - " window=[0, 60, 80, 135],\n", - " center=[12, 9],\n", - " psf=psfr,\n", - " target=target_r,\n", - ")\n", - "star2r = ap.Model(\n", - " name=\"star2-r\",\n", - " model_type=\"point model\",\n", - " window=[40, 90, 20, 70],\n", - " center=[3, -7],\n", - " psf=psfr,\n", - " target=target_r,\n", - ")\n", - "star3r = ap.Model(\n", - " name=\"star3-r\",\n", - " model_type=\"point model\",\n", - " window=[109, 150, 40, 90],\n", - " center=[-15, -3],\n", - " psf=psfr,\n", - " target=target_r,\n", - ")\n", - "modelr = ap.Model(\n", - " name=\"model-r\", model_type=\"group model\", models=[star1r, star2r, star3r], target=target_r\n", - ")\n", + "psfr = ap.Model(name=\"psfr\", model_type=\"moffat psf model\", n=2, Rd=1.0, target=target_r.psf_image(data=np.zeros((51, 51))))\n", + "star1r = ap.Model(name=\"star1-r\", model_type=\"point model\", window=[0, 60, 80, 135], center=[12, 9], psf=psfr, target=target_r)\n", + "star2r = ap.Model(name=\"star2-r\", model_type=\"point model\", window=[40, 90, 20, 70], center=[3, -7], psf=psfr, target=target_r)\n", + "star3r = ap.Model(name=\"star3-r\", model_type=\"point model\", window=[109, 150, 40, 90], center=[-15, -3], psf=psfr, target=target_r)\n", + "modelr = ap.Model(name=\"model-r\", model_type=\"group model\", models=[star1r, star2r, star3r], target=target_r)\n", "\n", "# g-band model\n", - "psfg = ap.Model(\n", - " name=\"psfg\",\n", - " model_type=\"moffat psf model\",\n", - " n=2,\n", - " Rd=1.0,\n", - " target=target_g.psf_image(data=np.zeros((51, 51))),\n", - ")\n", - "star1g = ap.Model(\n", - " name=\"star1-g\",\n", - " model_type=\"point model\",\n", - " window=[0, 60, 80, 135],\n", - " center=star1r.center,\n", - " psf=psfg,\n", - " target=target_g,\n", - ")\n", - "star2g = ap.Model(\n", - " name=\"star2-g\",\n", - " model_type=\"point model\",\n", - " window=[40, 90, 20, 70],\n", - " center=star2r.center,\n", - " psf=psfg,\n", - " target=target_g,\n", - ")\n", - "star3g = ap.Model(\n", - " name=\"star3-g\",\n", - " model_type=\"point model\",\n", - " window=[109, 150, 40, 90],\n", - " center=star3r.center,\n", - " psf=psfg,\n", - " target=target_g,\n", - ")\n", - "modelg = ap.Model(\n", - " name=\"model-g\", model_type=\"group model\", models=[star1g, star2g, star3g], target=target_g\n", - ")\n", + "psfg = ap.Model(name=\"psfg\", model_type=\"moffat psf model\", n=2, Rd=1.0, target=target_g.psf_image(data=np.zeros((51, 51))))\n", + "star1g = ap.Model(name=\"star1-g\", model_type=\"point model\", window=[0, 60, 80, 135], center=star1r.center, psf=psfg, target=target_g)\n", + "star2g = ap.Model(name=\"star2-g\", model_type=\"point model\", window=[40, 90, 20, 70], center=star2r.center, psf=psfg, target=target_g)\n", + "star3g = ap.Model(name=\"star3-g\", model_type=\"point model\", window=[109, 150, 40, 90], center=star3r.center, psf=psfg, target=target_g)\n", + "modelg = ap.Model(name=\"model-g\", model_type=\"group model\", models=[star1g, star2g, star3g], target=target_g)\n", "\n", "# total model\n", "target_full = ap.TargetImageList([target_r, target_g])\n", - "model = ap.Model(\n", - " name=\"model\", model_type=\"group model\", models=[modelr, modelg], target=target_full\n", - ")\n", + "model = ap.Model(name=\"model\", model_type=\"group model\", models=[modelr, modelg], target=target_full)\n", "\n", + "# fmt: on\n", "fig, axarr = plt.subplots(1, 2, figsize=(15, 7))\n", "ap.plots.target_image(fig, axarr, target_full)\n", "axarr[0].set_title(\"Target Image (r-band)\")\n", @@ -277,10 +219,18 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "Notice that there is not a universal dipole like in the shift example. Most of the offset is caused by the rotation in this example." + ] + }, { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -288,6 +238,8 @@ "phi = ap.Param(name=\"phi\", dynamic_value=0.0, dtype=torch.float64)\n", "\n", "# Set the target_g CD matrix to be a function of the rotation angle\n", + "# The CD matrix can encode rotation, skew, and rectangular pixels. We\n", + "# are only interested in the rotation here.\n", "init_CD = target_g.CD.value.clone()\n", "target_g.CD = lambda p: init_CD @ rotate(p.phi.value)\n", "target_g.CD.link(phi)\n", @@ -299,7 +251,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -317,7 +269,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [] diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 9c759cbb..7a57d9f4 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -17,5 +17,6 @@ version of each tutorial is available here. JointModels ImageAlignment CustomModels + GravitationalLensing AdvancedPSFModels ConstrainedModels From e2706b14e0525a20b748629d699a4a6d0193f889 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 20 Jul 2025 23:08:21 -0400 Subject: [PATCH 067/191] more stable LM hess fix --- astrophot/fit/iterative.py | 23 +++++++++++++---------- astrophot/fit/lm.py | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 3da95027..43eafbd3 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -44,21 +44,19 @@ class Iter(BaseOptimizer): def __init__( self, model: Model, - method: BaseOptimizer = LM, initial_state: np.ndarray = None, max_iter: int = 100, - method_kwargs: Dict[str, Any] = {}, + lm_kwargs: Dict[str, Any] = {}, **kwargs: Dict[str, Any], ) -> None: super().__init__(model, initial_state, max_iter=max_iter, **kwargs) self.current_state = model.build_params_array() - self.method = method - self.method_kwargs = method_kwargs - if "relative_tolerance" not in method_kwargs and isinstance(method, LM): + self.lm_kwargs = lm_kwargs + if "relative_tolerance" not in lm_kwargs: # Lower tolerance since it's not worth fine tuning a model when its neighbors will be shifting soon anyway - self.method_kwargs["relative_tolerance"] = 1e-3 - self.method_kwargs["max_iter"] = 15 + self.lm_kwargs["relative_tolerance"] = 1e-3 + self.lm_kwargs["max_iter"] = 15 # # pixels # parameters self.ndf = self.model.target[self.model.window].flatten("data").size(0) - len( self.current_state @@ -67,7 +65,7 @@ def __init__( # subtract masked pixels from degrees of freedom self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() - def sub_step(self, model: Model) -> None: + def sub_step(self, model: Model, update_uncertainty=False) -> None: """ Perform optimization for a single model. @@ -77,7 +75,7 @@ def sub_step(self, model: Model) -> None: self.Y -= model() initial_values = model.target.copy() model.target = model.target - self.Y - res = self.method(model, **self.method_kwargs).fit() + res = LM(model, **self.lm_kwargs).fit(update_uncertainty=update_uncertainty) self.Y += model() if self.verbose > 1: AP_config.ap_logger.info(res.message) @@ -134,7 +132,7 @@ def step(self) -> None: self.iteration += 1 - def fit(self) -> BaseOptimizer: + def fit(self, update_uncertainty=True) -> BaseOptimizer: """ Fit the models to the target. @@ -160,6 +158,11 @@ def fit(self) -> BaseOptimizer: self.model.fill_dynamic_values( torch.tensor(self.res(), dtype=AP_config.ap_dtype, device=AP_config.ap_device) ) + if update_uncertainty: + for model in self.model.models: + if self.verbose > 1: + AP_config.ap_logger.info(model.name) + self.sub_step(model, update_uncertainty=True) if self.verbose > 1: AP_config.ap_logger.info( f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 7511c468..ced5f8fd 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -363,7 +363,7 @@ def covariance_matrix(self) -> torch.Tensor: "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will massage Hessian to continue but results should be inspected." ) hess += torch.eye(len(hess), dtype=AP_config.ap_dtype, device=AP_config.ap_device) * ( - torch.diag(hess) == 0 + torch.diag(hess) < 1e-9 ) self._covariance_matrix = torch.linalg.inv(hess) return self._covariance_matrix From 1490e8d3165e85771d66730d6fba6fb71550b6b9 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 10:22:52 -0400 Subject: [PATCH 068/191] more unit tests --- .readthedocs.yaml | 1 + astrophot/fit/lm.py | 2 + astrophot/image/image_object.py | 7 --- astrophot/image/mixins/sip_mixin.py | 8 +-- astrophot/image/sip_image.py | 8 +-- astrophot/utils/interpolate.py | 15 ++++-- docs/requirements.txt | 1 + tests/test_image.py | 66 ++++++++++++++---------- tests/test_sip_image.py | 80 +++++++++++++++++++++++++++++ tests/utils.py | 8 +-- 10 files changed, 148 insertions(+), 48 deletions(-) create mode 100644 tests/test_sip_image.py diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 6ef33248..a819dc9e 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -23,6 +23,7 @@ build: python: "3.9" apt_packages: - pandoc # Specify pandoc to be installed via apt-get + - graphviz jobs: pre_build: # Generate the Sphinx configuration for this Jupyter Book so it builds. diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index ced5f8fd..40eee418 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -362,9 +362,11 @@ def covariance_matrix(self) -> torch.Tensor: AP_config.ap_logger.warning( "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will massage Hessian to continue but results should be inspected." ) + print("diag hess:", torch.diag(hess).cpu().numpy()) hess += torch.eye(len(hess), dtype=AP_config.ap_dtype, device=AP_config.ap_device) * ( torch.diag(hess) < 1e-9 ) + print("diag hess after:", torch.diag(hess).cpu().numpy()) self._covariance_matrix = torch.linalg.inv(hess) return self._covariance_matrix diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 1685619a..ab68dbfe 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -234,13 +234,6 @@ def pixel_to_world(self, i, j): """ return self.plane_to_world(*self.pixel_to_plane(i, j)) - @forward - def pixel_angle_to_plane_angle(self, theta, crtan): - """Convert an angle in pixel space (in radians) to an angle in the tangent plane (in radians).""" - i, j = torch.cos(theta), torch.sin(theta) - x, y = self.pixel_to_plane(i, j) - return torch.atan2(y - crtan[1], x - crtan[0]) - def pixel_center_meshgrid(self): """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" return func.pixel_center_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index ee5d6037..ff49633b 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -40,15 +40,15 @@ def __init__( @forward def pixel_to_plane(self, i, j, crtan, CD): - di = interp2d(self.distortion_ij[0], j, i) - dj = interp2d(self.distortion_ij[1], j, i) + di = interp2d(self.distortion_ij[0], j, i, padding_mode="border") + dj = interp2d(self.distortion_ij[1], j, i, padding_mode="border") return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, CD, *crtan) @forward def plane_to_pixel(self, x, y, crtan, CD): I, J = func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) - dI = interp2d(self.distortion_IJ[0], J, I) - dJ = interp2d(self.distortion_IJ[1], J, I) + dI = interp2d(self.distortion_IJ[0], J, I, padding_mode="border") + dJ = interp2d(self.distortion_IJ[1], J, I, padding_mode="border") return I + dI, J + dJ @property diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index 42bbacb0..0f46612e 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -101,10 +101,12 @@ def model_image(self, upsample=1, pad=0, **kwargs): new_distortion_IJ = self.distortion_IJ if upsample > 1: U = torch.nn.Upsample(scale_factor=upsample, mode="nearest") - new_area_map = U(new_area_map) / upsample**2 + new_area_map = ( + U(new_area_map.unsqueeze(0).unsqueeze(0)).squeeze(0).squeeze(0) / upsample**2 + ) U = torch.nn.Upsample(scale_factor=upsample, mode="bilinear", align_corners=False) - new_distortion_ij = U(self.distortion_ij) - new_distortion_IJ = U(self.distortion_IJ) + new_distortion_ij = U(self.distortion_ij.unsqueeze(1)).squeeze(1) + new_distortion_IJ = U(self.distortion_IJ.unsqueeze(1)).squeeze(1) if pad > 0: new_area_map = ( torch.nn.functional.pad( diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index 1bbb5862..147a0945 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -19,6 +19,7 @@ def interp2d( im: torch.Tensor, x: torch.Tensor, y: torch.Tensor, + padding_mode: str = "zeros", ) -> torch.Tensor: """ Interpolates a 2D image at specified coordinates. @@ -41,8 +42,13 @@ def interp2d( x = x.flatten() y = y.flatten() - # valid - valid = (x >= -0.5) & (x <= (w - 0.5)) & (y >= -0.5) & (y <= (h - 0.5)) + if padding_mode == "zeros": + valid = (x >= -0.5) & (x <= (w - 0.5)) & (y >= -0.5) & (y <= (h - 0.5)) + elif padding_mode == "border": + x = x.clamp(-0.5, w - 0.5) + y = y.clamp(-0.5, h - 0.5) + else: + raise ValueError(f"Unsupported padding mode: {padding_mode}") x0 = x.floor().long() y0 = y.floor().long() @@ -63,7 +69,10 @@ def interp2d( result = fa * wa + fb * wb + fc * wc + fd * wd - return (result * valid).reshape(start_shape) + if padding_mode == "zeros": + return (result * valid).reshape(start_shape) + elif padding_mode == "border": + return result.reshape(start_shape) def interp2d_ij( diff --git a/docs/requirements.txt b/docs/requirements.txt index 6807916c..78a5747a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ caustics +graphviz ipywidgets jupyter-book matplotlib diff --git a/tests/test_image.py b/tests/test_image.py index a2a5aea3..758b4983 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -2,7 +2,7 @@ import torch import numpy as np -from utils import make_basic_sersic +from utils import make_basic_sersic, get_astropy_wcs import pytest ###################################################################### @@ -10,14 +10,17 @@ ###################################################################### -def test_image_creation(): +@pytest.fixture() +def base_image(): arr = torch.zeros((10, 15)) - base_image = ap.Image( + return ap.Image( data=arr, pixelscale=1.0, zeropoint=1.0, ) + +def test_image_creation(base_image): assert base_image.pixelscale == 1.0, "image should track pixelscale" assert base_image.zeropoint == 1.0, "image should track zeropoint" assert base_image.crpix[0] == 0, "image should track crpix" @@ -30,43 +33,33 @@ def test_image_creation(): assert sliced_image.shape == (6, 3), "sliced image should have correct shape" -def test_copy(): - new_image = ap.Image( - data=torch.zeros((10, 15)), - pixelscale=1.0, - zeropoint=1.0, - ) - - copy_image = new_image.copy() - assert new_image.pixelscale == copy_image.pixelscale, "copied image should have same pixelscale" - assert new_image.zeropoint == copy_image.zeropoint, "copied image should have same zeropoint" +def test_copy(base_image): + copy_image = base_image.copy() + assert ( + base_image.pixelscale == copy_image.pixelscale + ), "copied image should have same pixelscale" + assert base_image.zeropoint == copy_image.zeropoint, "copied image should have same zeropoint" assert ( - new_image.window.extent == copy_image.window.extent + base_image.window.extent == copy_image.window.extent ), "copied image should have same window" copy_image += 1 - assert new_image.data[0][0] == 0.0, "copied image should not share data with original" + assert base_image.data[0][0] == 0.0, "copied image should not share data with original" - blank_copy_image = new_image.blank_copy() + blank_copy_image = base_image.blank_copy() assert ( - new_image.pixelscale == blank_copy_image.pixelscale + base_image.pixelscale == blank_copy_image.pixelscale ), "copied image should have same pixelscale" assert ( - new_image.zeropoint == blank_copy_image.zeropoint + base_image.zeropoint == blank_copy_image.zeropoint ), "copied image should have same zeropoint" assert ( - new_image.window.extent == blank_copy_image.window.extent + base_image.window.extent == blank_copy_image.window.extent ), "copied image should have same window" blank_copy_image += 1 - assert new_image.data[0][0] == 0.0, "copied image should not share data with original" + assert base_image.data[0][0] == 0.0, "copied image should not share data with original" -def test_image_arithmetic(): - arr = torch.zeros((10, 12)) - base_image = ap.Image( - data=arr, - pixelscale=1.0, - zeropoint=1.0, - ) +def test_image_arithmetic(base_image): slicer = ap.Window((-1, 5, 6, 15), base_image) sliced_image = base_image[slicer] sliced_image += 1 @@ -348,3 +341,22 @@ def test_jacobian_add(): ), "Jacobian should flatten to Npix*Nparams tensor" assert new_image.data[0, 0, 0].item() == 1, "Jacobian addition should not change original data" assert new_image.data[0, 0, 1].item() == 6, " Jacobian addition should add correctly" + + +def test_image_with_wcs(): + WCS = get_astropy_wcs() + image = ap.TargetImage( + data=np.ones((170, 180)), + wcs=WCS, + ) + assert image.shape[0] == WCS.pixel_shape[0], "Image should have correct shape from WCS" + assert image.shape[1] == WCS.pixel_shape[1], "Image should have correct shape from WCS" + assert np.allclose( + image.CD.value * ap.utils.conversions.units.arcsec_to_deg, WCS.pixel_scale_matrix + ), "Image should have correct CD from WCS" + assert np.allclose( + image.crpix, WCS.wcs.crpix[::-1] - 1 + ), "Image should have correct CRPIX from WCS" + assert np.allclose( + image.crval.value.detach().cpu().numpy(), WCS.wcs.crval + ), "Image should have correct CRVAL from WCS" diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py new file mode 100644 index 00000000..18a4dff3 --- /dev/null +++ b/tests/test_sip_image.py @@ -0,0 +1,80 @@ +import astrophot as ap +import torch +import numpy as np + +from utils import make_basic_sersic +import pytest + +###################################################################### +# Image Objects +###################################################################### + + +@pytest.fixture() +def sip_target(): + arr = torch.zeros((10, 15)) + return ap.SIPTargetImage( + data=arr, + pixelscale=1.0, + zeropoint=1.0, + sipA={(1, 0): 1e-4, (0, 1): 1e-4, (2, 3): -1e-5}, + sipB={(1, 0): -1e-4, (0, 1): 5e-5, (2, 3): 2e-6}, + sipAP={(1, 0): -1e-4, (0, 1): -1e-4, (2, 3): 1e-5}, + sipBP={(1, 0): 1e-4, (0, 1): -5e-5, (2, 3): -2e-6}, + ) + + +def test_sip_image_creation(sip_target): + assert sip_target.pixelscale == 1.0, "image should track pixelscale" + assert sip_target.zeropoint == 1.0, "image should track zeropoint" + assert sip_target.crpix[0] == 0, "image should track crpix" + assert sip_target.crpix[1] == 0, "image should track crpix" + + slicer = ap.Window((7, 13, 4, 7), sip_target) + sliced_image = sip_target[slicer] + assert sliced_image.crpix[0] == -7, "crpix of subimage should give relative position" + assert sliced_image.crpix[1] == -4, "crpix of subimage should give relative position" + assert sliced_image.shape == (6, 3), "sliced image should have correct shape" + assert sliced_image.pixel_area_map.shape == ( + 6, + 3, + ), "sliced image should have correct pixel area map shape" + assert sliced_image.distortion_ij.shape == ( + 2, + 6, + 3, + ), "sliced image should have correct distortion shape" + assert sliced_image.distortion_IJ.shape == ( + 2, + 6, + 3, + ), "sliced image should have correct distortion shape" + + sip_model_image = sip_target.model_image(upsample=2, pad=1) + assert sip_model_image.shape == (32, 22), "model image should have correct shape" + assert sip_model_image.pixel_area_map.shape == ( + 32, + 22, + ), "model image pixel area map should have correct shape" + assert sip_model_image.distortion_ij.shape == ( + 2, + 32, + 22, + ), "model image distortion model should have correct shape" + assert sip_model_image.distortion_IJ.shape == ( + 2, + 32, + 22, + ), "model image distortion model should have correct shape" + + +def test_sip_image_wcs_roundtrip(sip_target): + """ + Test that the WCS roundtrip works correctly for SIP images. + """ + i, j = sip_target.pixel_center_meshgrid() + x, y = sip_target.pixel_to_plane(i, j) + i2, j2 = sip_target.plane_to_pixel(x, y) + + assert torch.allclose(i, i2, atol=0.5), "i coordinates should match after WCS roundtrip" + assert torch.allclose(j, j2, atol=0.5), "j coordinates should match after WCS roundtrip" diff --git a/tests/utils.py b/tests/utils.py index 22253db0..f8e277af 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -8,19 +8,19 @@ def get_astropy_wcs(): "SIMPLE": "T", "NAXIS": 2, "NAXIS1": 180, - "NAXIS2": 180, + "NAXIS2": 170, "CTYPE1": "RA---TAN", "CTYPE2": "DEC--TAN", "CRVAL1": 195.0588, "CRVAL2": 28.0608, "CRPIX1": 90.5, - "CRPIX2": 90.5, + "CRPIX2": 85.5, "CD1_1": -0.000416666666666667, "CD1_2": 0.0, "CD2_1": 0.0, "CD2_2": 0.000416666666666667, - "IMAGEW": 180.0, - "IMAGEH": 180.0, + # "IMAGEW": 180.0, + # "IMAGEH": 170.0, } return WCS(hdr) From 3493239d07f7341700250b740c73d1aa2bbd7904 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 13:20:24 -0400 Subject: [PATCH 069/191] more fitters tested. Add notebook test --- astrophot/fit/__init__.py | 4 +- astrophot/fit/gradient.py | 32 +- astrophot/fit/iterative.py | 324 ++++++++++----------- astrophot/fit/lm.py | 11 +- astrophot/fit/scipy_fit.py | 16 +- astrophot/models/group_model_object.py | 3 + docs/requirements.txt | 1 + docs/source/tutorials/FittingMethods.ipynb | 150 +++++++--- tests/test_image.py | 2 + tests/test_model.py | 40 ++- tests/test_notebooks.py | 14 + 11 files changed, 347 insertions(+), 250 deletions(-) create mode 100644 tests/test_notebooks.py diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index c9e31578..4ce7a90b 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,7 +1,7 @@ # from .base import * from .lm import LM -# from .gradient import * +from .gradient import Grad from .iterative import Iter from .scipy_fit import ScipyFit @@ -15,7 +15,7 @@ # print("Could not load HMC or NUTS due to:", str(e)) # from .mhmcmc import * -__all__ = ["LM", "Iter", "ScipyFit"] +__all__ = ["LM", "Grad", "Iter", "ScipyFit"] """ base: This module defines the base class BaseOptimizer, diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 24ffe0e3..18072cde 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -41,7 +41,15 @@ class Grad(BaseOptimizer): """ def __init__( - self, model: Model, initial_state: Sequence = None, likelihood="gaussian", **kwargs + self, + model: Model, + initial_state: Sequence = None, + likelihood="gaussian", + patience=None, + method="NAdam", + optim_kwargs={}, + report_freq=10, + **kwargs, ) -> None: """Initialize the gradient descent optimizer. @@ -58,10 +66,10 @@ def __init__( self.likelihood = likelihood # set parameters from the user - self.patience = kwargs.get("patience", None) - self.method = kwargs.get("method", "NAdam").strip() - self.optim_kwargs = kwargs.get("optim_kwargs", {}) - self.report_freq = kwargs.get("report_freq", 10) + self.patience = patience + self.method = method + self.optim_kwargs = optim_kwargs + self.report_freq = report_freq # Default learning rate if none given. Equalt to 1 / sqrt(parames) if "lr" not in self.optim_kwargs: @@ -79,9 +87,9 @@ def density(self, state: torch.Tensor) -> torch.Tensor: This is used to calculate the likelihood of the model at the given state. """ if self.likelihood == "gaussian": - return self.model.gaussian_log_likelihood(state) + return -self.model.gaussian_log_likelihood(state) elif self.likelihood == "poisson": - return self.model.poisson_log_likelihood(state) + return -self.model.poisson_log_likelihood(state) else: raise ValueError(f"Unknown likelihood type: {self.likelihood}") @@ -107,12 +115,14 @@ def step(self) -> None: self.iteration % int(self.max_iter / self.report_freq) == 0 ) or self.iteration == self.max_iter: if self.verbose > 0: - AP_config.ap_logger.info(f"iter: {self.iteration}, loss: {loss.item()}") + AP_config.ap_logger.info( + f"iter: {self.iteration}, posterior density: {loss.item():.e6}" + ) if self.verbose > 1: AP_config.ap_logger.info(f"gradient: {self.current_state.grad}") self.optimizer.step() - def fit(self) -> "BaseOptimizer": + def fit(self) -> BaseOptimizer: """ Perform an iterative fit of the model parameters using the specified optimizer. @@ -142,7 +152,9 @@ def fit(self) -> "BaseOptimizer": self.message = self.message + " fail interrupted" # Set the model parameters to the best values from the fit and clear any previous model sampling - self.model.fill_dynamic_values(self.res()) + self.model.fill_dynamic_values( + torch.tensor(self.res(), dtype=AP_config.ap_dtype, device=AP_config.ap_device) + ) if self.verbose > 1: AP_config.ap_logger.info( f"Grad Fitting complete in {time() - start_fit} sec with message: {self.message}" diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 43eafbd3..17ef9494 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -171,165 +171,165 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: return self -# class Iter_LM(BaseOptimizer): -# """Optimization wrapper that call LM optimizer on subsets of variables. - -# Iter_LM takes the full set of parameters for a model and breaks -# them down into chunks as specified by the user. It then calls -# Levenberg-Marquardt optimization on the subset of parameters, and -# iterates through all subsets until every parameter has been -# optimized. It cycles through these chunks until convergence. This -# method is very powerful in situations where the full optimization -# problem cannot fit in memory, or where the optimization problem is -# too complex to tackle as a single large problem. In full LM -# optimization a single problematic parameter can ripple into issues -# with every other parameter, so breaking the problem down can -# sometimes make an otherwise intractable problem easier. For small -# problems with only a few models, it is likely better to optimize -# the full problem with LM as, when it works, LM is faster than the -# Iter_LM method. - -# Args: -# chunks (Union[int, tuple]): Specify how to break down the model parameters. If an integer, at each iteration the algorithm will break the parameters into groups of that size. If a tuple, should be a tuple of tuples of strings which give an explicit pairing of parameters to optimize, note that it is allowed to have variable size chunks this way. Default: 50 -# method (str): How to iterate through the chunks. Should be one of: random, sequential. Default: random -# """ - -# def __init__( -# self, -# model: "AstroPhot_Model", -# initial_state: Sequence = None, -# chunks: Union[int, tuple] = 50, -# max_iter: int = 100, -# method: str = "random", -# LM_kwargs: dict = {}, -# **kwargs: Dict[str, Any], -# ) -> None: -# super().__init__(model, initial_state, max_iter=max_iter, **kwargs) - -# self.chunks = chunks -# self.method = method -# self.LM_kwargs = LM_kwargs - -# # # pixels # parameters -# self.ndf = self.model.target[self.model.window].flatten("data").numel() - len( -# self.current_state -# ) -# if self.model.target.has_mask: -# # subtract masked pixels from degrees of freedom -# self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() - -# def step(self): -# # These store the chunking information depending on which chunk mode is selected -# param_ids = list(self.model.parameters.vector_identities()) -# init_param_ids = list(self.model.parameters.vector_identities()) -# _chunk_index = 0 -# _chunk_choices = None -# res = None - -# if self.verbose > 0: -# AP_config.ap_logger.info("--------iter-------") - -# # Loop through all the chunks -# while True: -# chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=AP_config.ap_device) -# if isinstance(self.chunks, int): -# if len(param_ids) == 0: -# break -# if self.method == "random": -# # Draw a random chunk of ids -# for pid in random.sample(param_ids, min(len(param_ids), self.chunks)): -# chunk[init_param_ids.index(pid)] = True -# else: -# # Draw the next chunk of ids -# for pid in param_ids[: self.chunks]: -# chunk[init_param_ids.index(pid)] = True -# # Remove the selected ids from the list -# for p in np.array(init_param_ids)[chunk.detach().cpu().numpy()]: -# param_ids.pop(param_ids.index(p)) -# elif isinstance(self.chunks, (tuple, list)): -# if _chunk_choices is None: -# # Make a list of the chunks as given explicitly -# _chunk_choices = list(range(len(self.chunks))) -# if self.method == "random": -# if len(_chunk_choices) == 0: -# break -# # Select a random chunk from the given groups -# sub_index = random.choice(_chunk_choices) -# _chunk_choices.pop(_chunk_choices.index(sub_index)) -# for pid in self.chunks[sub_index]: -# chunk[param_ids.index(pid)] = True -# else: -# if _chunk_index >= len(self.chunks): -# break -# # Select the next chunk in order -# for pid in self.chunks[_chunk_index]: -# chunk[param_ids.index(pid)] = True -# _chunk_index += 1 -# else: -# raise ValueError( -# "Unrecognized chunks value, should be one of int, tuple. not: {type(self.chunks)}" -# ) -# if self.verbose > 1: -# AP_config.ap_logger.info(str(chunk)) -# del res -# with Param_Mask(self.model.parameters, chunk): -# res = LM( -# self.model, -# ndf=self.ndf, -# **self.LM_kwargs, -# ).fit() -# if self.verbose > 0: -# AP_config.ap_logger.info(f"chunk loss: {res.res_loss()}") -# if self.verbose > 1: -# AP_config.ap_logger.info(f"chunk message: {res.message}") - -# self.loss_history.append(res.res_loss()) -# self.lambda_history.append( -# self.model.parameters.vector_representation().detach().cpu().numpy() -# ) -# if self.verbose > 0: -# AP_config.ap_logger.info(f"Loss: {self.loss_history[-1]}") - -# # test for convergence -# if self.iteration >= 2 and ( -# (-self.relative_tolerance * 1e-3) -# < ((self.loss_history[-2] - self.loss_history[-1]) / self.loss_history[-1]) -# < (self.relative_tolerance / 10) -# ): -# self._count_finish += 1 -# else: -# self._count_finish = 0 - -# self.iteration += 1 - -# def fit(self): -# self.iteration = 0 - -# start_fit = time() -# try: -# while True: -# self.step() -# if self.save_steps is not None: -# self.model.save( -# os.path.join( -# self.save_steps, -# f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", -# ) -# ) -# if self.iteration > 2 and self._count_finish >= 2: -# self.message = self.message + "success" -# break -# elif self.iteration >= self.max_iter: -# self.message = self.message + f"fail max iterations reached: {self.iteration}" -# break - -# except KeyboardInterrupt: -# self.message = self.message + "fail interrupted" - -# self.model.parameters.vector_set_representation(self.res()) -# if self.verbose > 1: -# AP_config.ap_logger.info( -# f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" -# ) - -# return self +class IterParam(BaseOptimizer): + """Optimization wrapper that call LM optimizer on subsets of variables. + + IterParam takes the full set of parameters for a model and breaks + them down into chunks as specified by the user. It then calls + Levenberg-Marquardt optimization on the subset of parameters, and + iterates through all subsets until every parameter has been + optimized. It cycles through these chunks until convergence. This + method is very powerful in situations where the full optimization + problem cannot fit in memory, or where the optimization problem is + too complex to tackle as a single large problem. In full LM + optimization a single problematic parameter can ripple into issues + with every other parameter, so breaking the problem down can + sometimes make an otherwise intractable problem easier. For small + problems with only a few models, it is likely better to optimize + the full problem with LM as, when it works, LM is faster than the + IterParam method. + + Args: + chunks (Union[int, tuple]): Specify how to break down the model parameters. If an integer, at each iteration the algorithm will break the parameters into groups of that size. If a tuple, should be a tuple of tuples of strings which give an explicit pairing of parameters to optimize, note that it is allowed to have variable size chunks this way. Default: 50 + method (str): How to iterate through the chunks. Should be one of: random, sequential. Default: random + """ + + def __init__( + self, + model: Model, + initial_state: Sequence = None, + chunks: Union[int, tuple] = 50, + max_iter: int = 100, + method: str = "random", + LM_kwargs: dict = {}, + **kwargs: Dict[str, Any], + ) -> None: + super().__init__(model, initial_state, max_iter=max_iter, **kwargs) + + self.chunks = chunks + self.method = method + self.LM_kwargs = LM_kwargs + + # # pixels # parameters + self.ndf = self.model.target[self.model.window].flatten("data").numel() - len( + self.current_state + ) + if self.model.target.has_mask: + # subtract masked pixels from degrees of freedom + self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() + + def step(self): + # These store the chunking information depending on which chunk mode is selected + param_ids = list(self.model.parameters.vector_identities()) + init_param_ids = list(self.model.parameters.vector_identities()) + _chunk_index = 0 + _chunk_choices = None + res = None + + if self.verbose > 0: + AP_config.ap_logger.info("--------iter-------") + + # Loop through all the chunks + while True: + chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=AP_config.ap_device) + if isinstance(self.chunks, int): + if len(param_ids) == 0: + break + if self.method == "random": + # Draw a random chunk of ids + for pid in random.sample(param_ids, min(len(param_ids), self.chunks)): + chunk[init_param_ids.index(pid)] = True + else: + # Draw the next chunk of ids + for pid in param_ids[: self.chunks]: + chunk[init_param_ids.index(pid)] = True + # Remove the selected ids from the list + for p in np.array(init_param_ids)[chunk.detach().cpu().numpy()]: + param_ids.pop(param_ids.index(p)) + elif isinstance(self.chunks, (tuple, list)): + if _chunk_choices is None: + # Make a list of the chunks as given explicitly + _chunk_choices = list(range(len(self.chunks))) + if self.method == "random": + if len(_chunk_choices) == 0: + break + # Select a random chunk from the given groups + sub_index = random.choice(_chunk_choices) + _chunk_choices.pop(_chunk_choices.index(sub_index)) + for pid in self.chunks[sub_index]: + chunk[param_ids.index(pid)] = True + else: + if _chunk_index >= len(self.chunks): + break + # Select the next chunk in order + for pid in self.chunks[_chunk_index]: + chunk[param_ids.index(pid)] = True + _chunk_index += 1 + else: + raise ValueError( + "Unrecognized chunks value, should be one of int, tuple. not: {type(self.chunks)}" + ) + if self.verbose > 1: + AP_config.ap_logger.info(str(chunk)) + del res + with Param_Mask(self.model.parameters, chunk): + res = LM( + self.model, + ndf=self.ndf, + **self.LM_kwargs, + ).fit() + if self.verbose > 0: + AP_config.ap_logger.info(f"chunk loss: {res.res_loss()}") + if self.verbose > 1: + AP_config.ap_logger.info(f"chunk message: {res.message}") + + self.loss_history.append(res.res_loss()) + self.lambda_history.append( + self.model.parameters.vector_representation().detach().cpu().numpy() + ) + if self.verbose > 0: + AP_config.ap_logger.info(f"Loss: {self.loss_history[-1]}") + + # test for convergence + if self.iteration >= 2 and ( + (-self.relative_tolerance * 1e-3) + < ((self.loss_history[-2] - self.loss_history[-1]) / self.loss_history[-1]) + < (self.relative_tolerance / 10) + ): + self._count_finish += 1 + else: + self._count_finish = 0 + + self.iteration += 1 + + def fit(self): + self.iteration = 0 + + start_fit = time() + try: + while True: + self.step() + if self.save_steps is not None: + self.model.save( + os.path.join( + self.save_steps, + f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", + ) + ) + if self.iteration > 2 and self._count_finish >= 2: + self.message = self.message + "success" + break + elif self.iteration >= self.max_iter: + self.message = self.message + f"fail max iterations reached: {self.iteration}" + break + + except KeyboardInterrupt: + self.message = self.message + "fail interrupted" + + self.model.parameters.vector_set_representation(self.res()) + if self.verbose > 1: + AP_config.ap_logger.info( + f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" + ) + + return self diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 40eee418..5403bb38 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -222,7 +222,7 @@ def __init__( # The forward model which computes the output image given input parameters self.forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[self.mask] - # Compute the jacobian in representation units (defined for -inf, inf) + # Compute the jacobian self.jacobian = lambda x: model.jacobian(window=self.fit_window, params=x).flatten("data")[ self.mask ] @@ -360,14 +360,9 @@ def covariance_matrix(self) -> torch.Tensor: self._covariance_matrix = torch.linalg.inv(hess) except: AP_config.ap_logger.warning( - "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will massage Hessian to continue but results should be inspected." + "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will use pseudo-inverse of Hessian to continue but results should be inspected." ) - print("diag hess:", torch.diag(hess).cpu().numpy()) - hess += torch.eye(len(hess), dtype=AP_config.ap_dtype, device=AP_config.ap_device) * ( - torch.diag(hess) < 1e-9 - ) - print("diag hess after:", torch.diag(hess).cpu().numpy()) - self._covariance_matrix = torch.linalg.inv(hess) + self._covariance_matrix = torch.linalg.pinv(hess) return self._covariance_matrix @torch.no_grad() diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py index bd0fe1ae..36b8e960 100644 --- a/astrophot/fit/scipy_fit.py +++ b/astrophot/fit/scipy_fit.py @@ -1,4 +1,4 @@ -from typing import Sequence +from typing import Sequence, Literal import torch from scipy.optimize import minimize @@ -16,21 +16,15 @@ def __init__( self, model, initial_state: Sequence = None, - method="Nelder-Mead", - max_iter: int = 100, + method: Literal[ + "Nelder-Mead", "L-BFGS-B", "TNC", "SLSQP", "Powell", "trust-constr" + ] = "Nelder-Mead", ndf=None, **kwargs, ): - super().__init__( - model, - initial_state, - max_iter=max_iter, - **kwargs, - ) + super().__init__(model, initial_state, **kwargs) self.method = method - # Maximum number of iterations of the algorithm - self.max_iter = max_iter # mask fit_mask = self.model.fit_mask() if isinstance(fit_mask, tuple): diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index f938cbae..2fa015f1 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -50,6 +50,9 @@ def __init__( **kwargs, ): super().__init__(name=name, **kwargs) + for model in models: + if not isinstance(model, Model): + raise TypeError(f"Expected a Model instance in 'models', got {type(model)}") self.models = models self.update_window() diff --git a/docs/requirements.txt b/docs/requirements.txt index 78a5747a..8b0ca613 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,6 +4,7 @@ ipywidgets jupyter-book matplotlib nbsphinx +nbval photutils scikit-image sphinx diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index 30a0ece8..e9ce4e88 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -41,7 +41,7 @@ "def true_params():\n", "\n", " # just some random parameters to use for fitting. Feel free to play around with these to see what happens!\n", - " sky_param = np.array([1.5])\n", + " sky_param = np.array([10**1.5])\n", " sersic_params = np.array(\n", " [\n", " [\n", @@ -51,7 +51,7 @@ " 37.19794926 * np.pi / 180,\n", " 2.14513004,\n", " 22.05219055,\n", - " 2.45583024,\n", + " 10**2.45583024,\n", " ],\n", " [\n", " 44.00353786,\n", @@ -60,7 +60,7 @@ " 172.03862521 * np.pi / 180,\n", " 2.88613347,\n", " 12.095631,\n", - " 2.76711163,\n", + " 10**2.76711163,\n", " ],\n", " ]\n", " )\n", @@ -70,11 +70,11 @@ "\n", "def init_params():\n", "\n", - " sky_param = np.array([1.4])\n", + " sky_param = np.array([10**1.4])\n", " sersic_params = np.array(\n", " [\n", - " [57.0, 56.0, 0.6, 40.0 * np.pi / 180, 1.5, 25.0, 2.0],\n", - " [45.0, 30.0, 0.5, 170.0 * np.pi / 180, 2.0, 10.0, 3.0],\n", + " [57.0, 56.0, 0.6, 40.0 * np.pi / 180, 1.5, 25.0, 10**2.0],\n", + " [45.0, 30.0, 0.5, 170.0 * np.pi / 180, 2.0, 10.0, 10**3.0],\n", " ]\n", " )\n", "\n", @@ -91,36 +91,33 @@ "\n", " # List of models, starting with the sky\n", " model_list = [\n", - " ap.models.AstroPhot_Model(\n", + " ap.Model(\n", " name=\"sky\",\n", " model_type=\"flat sky model\",\n", " target=target,\n", - " parameters={\"F\": sky_param[0]},\n", + " I=sky_param[0],\n", " )\n", " ]\n", " # Add models to the list\n", " for i, params in enumerate(sersic_params):\n", " model_list.append(\n", - " [\n", - " ap.models.AstroPhot_Model(\n", - " name=f\"sersic {i}\",\n", - " model_type=\"sersic galaxy model\",\n", - " target=target,\n", - " parameters={\n", - " \"center\": [params[0], params[1]],\n", - " \"q\": params[2],\n", - " \"PA\": params[3],\n", - " \"n\": params[4],\n", - " \"Re\": params[5],\n", - " \"Ie\": params[6],\n", - " },\n", - " # psf_mode = \"full\", # uncomment to try everything with PSF blurring (takes longer)\n", - " )\n", - " ]\n", + " ap.Model(\n", + " name=f\"sersic {i}\",\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + " center=[params[0], params[1]],\n", + " q=params[2],\n", + " PA=params[3],\n", + " n=params[4],\n", + " Re=params[5],\n", + " Ie=params[6],\n", + " # psf_convolve = True, # uncomment to try everything with PSF blurring (takes longer)\n", + " )\n", " )\n", "\n", - " MODEL = ap.models.Group_Model(\n", + " MODEL = ap.Model(\n", " name=\"group\",\n", + " model_type=\"group model\",\n", " models=model_list,\n", " target=target,\n", " )\n", @@ -140,7 +137,7 @@ " PSF = ap.utils.initialize.gaussian_psf(2, 21, pixelscale)\n", " PSF /= np.sum(PSF)\n", "\n", - " target = ap.image.Target_Image(\n", + " target = ap.TargetImage(\n", " data=np.zeros((N, N)),\n", " pixelscale=pixelscale,\n", " psf=PSF,\n", @@ -149,7 +146,7 @@ " MODEL = initialize_model(target, True)\n", "\n", " # Sample the model with the true values to make a mock image\n", - " img = MODEL().data.detach().cpu().numpy()\n", + " img = MODEL().data.T.detach().cpu().numpy()\n", " # Add poisson noise\n", " target.data = torch.Tensor(img + rng.normal(scale=np.sqrt(img) / 2))\n", " target.variance = torch.Tensor(img / 4)\n", @@ -362,19 +359,11 @@ "metadata": {}, "outputs": [], "source": [ - "param_names = list(MODEL.parameters.vector_names())\n", - "i = 0\n", - "while i < len(param_names):\n", - " param_names[i] = param_names[i].replace(\" \", \"\")\n", - " if \"center\" in param_names[i]:\n", - " center_name = param_names.pop(i)\n", - " param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", - " param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", - " i += 1\n", + "param_names = list(MODEL.build_params_array_names())\n", "set, sky = true_params()\n", "corner_plot_covariance(\n", " res_lm.covariance_matrix.detach().cpu().numpy(),\n", - " MODEL.parameters.vector_values().detach().cpu().numpy(),\n", + " MODEL.build_params_array().detach().cpu().numpy(),\n", " labels=param_names,\n", " figsize=(20, 20),\n", " true_values=np.concatenate((sky, set.ravel())),\n", @@ -387,9 +376,9 @@ "source": [ "## Iterative Fit (models)\n", "\n", - "An iterative fitter is identified as `ap.fit.Iter`, this method is generally employed for large models where it is not feasible to hold all the relevant data in memory at once. The iterative fitter will cycle through the models in a `Group_Model` object and fit them one at a time to the image, using the residuals from the previous cycle. This can be a very robust way to deal with some fits, especially if the overlap between models is not too strong. It is however more dependent on good initialization than other methods like the Levenberg-Marquardt. Also, it is possible for the Iter method to get stuck in a local minimum under certain circumstances.\n", + "An iterative fitter is identified as `ap.fit.Iter`, this method is generally employed for large models where it is not feasible to hold all the relevant data in memory at once. The iterative fitter will cycle through the models in a `GroupModel` object and fit them one at a time to the image, using the residuals from the previous cycle. This can be a very robust way to deal with some fits, especially if the overlap between models is not too strong. It is however more dependent on good initialization than other methods like the Levenberg-Marquardt. Also, it is possible for the Iter method to get stuck in a local minimum under certain circumstances.\n", "\n", - "Note that while the Iterative fitter needs a `Group_Model` object to iterate over, it is not necessarily true that the sub models are `Component_Model` objects, they could be `Group_Model` objects as well. In this way it is possible to cycle through and fit \"clusters\" of objects that are nearby, so long as it doesn't consume too much memory.\n", + "Note that while the Iterative fitter needs a `GroupModel` object to iterate over, it is not necessarily true that the sub models are `ComponentModel` objects, they could be `GroupModel` objects as well. In this way it is possible to cycle through and fit \"clusters\" of objects that are nearby, so long as it doesn't consume too much memory.\n", "\n", "By only fitting one model at a time it is possible to get caught in a local minimum, or to get out of a local minimum that a different fitter was stuck in. For this reason it can be good to mix-and-match the iterative optimizers so they can help each other get unstuck if a fit is very challenging. " ] @@ -397,19 +386,32 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [ + "hide-output" + ] + }, "outputs": [], "source": [ "MODEL = initialize_model(target, False)\n", + "\n", + "res_iter = ap.fit.Iter(MODEL, verbose=1).fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "MODEL_init = initialize_model(target, False)\n", "fig, axarr = plt.subplots(1, 4, figsize=(24, 5))\n", "plt.subplots_adjust(wspace=0.1)\n", - "ap.plots.model_image(fig, axarr[0], MODEL)\n", + "ap.plots.model_image(fig, axarr[0], MODEL_init)\n", "axarr[0].set_title(\"Model before optimization\")\n", - "ap.plots.residual_image(fig, axarr[1], MODEL, normalize_residuals=True)\n", + "ap.plots.residual_image(fig, axarr[1], MODEL_init, normalize_residuals=True)\n", "axarr[1].set_title(\"Residuals before optimization\")\n", "\n", - "res_iter = ap.fit.Iter(MODEL, verbose=1).fit()\n", - "\n", "ap.plots.model_image(fig, axarr[2], MODEL)\n", "axarr[2].set_title(\"Model after optimization\")\n", "ap.plots.residual_image(fig, axarr[3], MODEL, normalize_residuals=True)\n", @@ -423,13 +425,59 @@ "source": [ "## Iterative Fit (parameters)\n", "\n", - "This is an iterative fitter identified as `ap.fit.Iter_LM` and is generally employed for large models where it is not feasible to hold all the relevant data in memory at once. This iterative fitter will cycle through chunks of parameters and fit them one at a time to the image. This can be a very robust way to deal with some fits, especially if the overlap between models is not too strong. This is very similar to the other iterative fitter, however it is necessary for certain fitting circumstances when the problem can't be broken down into individual component models. This occurs, for example, when the models have many shared (constrained) parameters and there is no obvious way to break down sub-groups of models (an example of this is discussed in the AstroPhot paper).\n", + "This is an iterative fitter identified as `ap.fit.IterParam` and is generally employed for complicated models where it is not feasible to hold all the relevant data in memory at once. This iterative fitter will cycle through chunks of parameters and fit them one at a time to the image. This can be a very robust way to deal with some fits, especially if the overlap between models is not too strong. This is very similar to the other iterative fitter, however it is necessary for certain fitting circumstances when the problem can't be broken down into individual component models. This occurs, for example, when the models have many shared (constrained) parameters and there is no obvious way to break down sub-groups of models.\n", "\n", "Note that this is iterating over the parameters, not the models. This allows it to handle parameter covariances even for very large models (if they happen to land in the same chunk). However, for this to work it must evaluate the whole model at each iteration making it somewhat slower than the regular `Iter` fitter, though it can make up for it by fitting larger chunks at a time which makes the whole optimization faster.\n", "\n", "By only fitting a subset of parameters at a time it is possible to get caught in a local minimum, or to get out of a local minimum that a different fitter was stuck in. For this reason it can be good to mix-and-match the iterative optimizers so they can help each other get unstuck. Since this iterative fitter chooses parameters randomly, it can sometimes get itself unstuck if it gets a lucky combination of parameters. Generally giving it more parameters to work with at a time is better." ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# MODEL = initialize_model(target, False)\n", + "# fig, axarr = plt.subplots(1, 4, figsize=(24, 5))\n", + "# plt.subplots_adjust(wspace=0.1)\n", + "# ap.plots.model_image(fig, axarr[0], MODEL)\n", + "# axarr[0].set_title(\"Model before optimization\")\n", + "# ap.plots.residual_image(fig, axarr[1], MODEL, normalize_residuals=True)\n", + "# axarr[1].set_title(\"Residuals before optimization\")\n", + "\n", + "# res_iterlm = ap.fit.Iter_LM(MODEL, chunks=11, verbose=1).fit()\n", + "\n", + "# ap.plots.model_image(fig, axarr[2], MODEL)\n", + "# axarr[2].set_title(\"Model after optimization\")\n", + "# ap.plots.residual_image(fig, axarr[3], MODEL, normalize_residuals=True)\n", + "# axarr[3].set_title(\"Residuals after optimization\")\n", + "# plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Scipy Minimize\n", + "\n", + "Any AstroPhot model becomes a function `model(x)` where `x` is a 1D tensor of\n", + "all the current dynamic parameters. This functional format is common for\n", + "external packages to use. AstroPhot includes a wrapper to access the\n", + "`scipy.optimize.minimize` minimizer list. AstroPhot will ensure the minimizers\n", + "respect the valid ranges set for each parameter.\n", + "\n", + "Typically, the AstroPhot LM optimizer is faster and more accurate than the Scipy\n", + "ones. The exact reason is unclear, but the Scipy minimizers are intended for\n", + "very general use, while the LM optimizer is specifically optimized for gaussian\n", + "log likelihoods.\n", + "\n", + "In the case below, the minimizer thinks it has terminated successfully, although\n", + "in fact it is quite far from the minimum. Consider this a lesson in trusting the\n", + "\"success\" message from an optimizer. It turns out to be very challenging to\n", + "identify if an optimizer is at a minimum, let alone the global minimum." + ] + }, { "cell_type": "code", "execution_count": null, @@ -444,7 +492,8 @@ "ap.plots.residual_image(fig, axarr[1], MODEL, normalize_residuals=True)\n", "axarr[1].set_title(\"Residuals before optimization\")\n", "\n", - "res_iterlm = ap.fit.Iter_LM(MODEL, chunks=11, verbose=1).fit()\n", + "res_scipy = ap.fit.ScipyFit(MODEL, method=\"SLSQP\", verbose=1).fit()\n", + "print(res_scipy.scipy_res)\n", "\n", "ap.plots.model_image(fig, axarr[2], MODEL)\n", "axarr[2].set_title(\"Model after optimization\")\n", @@ -453,6 +502,13 @@ "plt.show()" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, { "cell_type": "markdown", "metadata": {}, @@ -478,7 +534,7 @@ "ap.plots.residual_image(fig, axarr[1], MODEL, normalize_residuals=True)\n", "axarr[1].set_title(\"Residuals before optimization\")\n", "\n", - "res_grad = ap.fit.Grad(MODEL, verbose=1, max_iter=1000, optim_kwargs={\"lr\": 5e-3}).fit()\n", + "res_grad = ap.fit.Grad(MODEL, verbose=1, max_iter=1000, optim_kwargs={\"lr\": 5e-2}).fit()\n", "\n", "ap.plots.model_image(fig, axarr[2], MODEL)\n", "axarr[2].set_title(\"Model after optimization\")\n", diff --git a/tests/test_image.py b/tests/test_image.py index 758b4983..82b2d41f 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -21,11 +21,13 @@ def base_image(): def test_image_creation(base_image): + base_image.to() assert base_image.pixelscale == 1.0, "image should track pixelscale" assert base_image.zeropoint == 1.0, "image should track zeropoint" assert base_image.crpix[0] == 0, "image should track crpix" assert base_image.crpix[1] == 0, "image should track crpix" + base_image.to(dtype=torch.float64) slicer = ap.Window((7, 13, 4, 7), base_image) sliced_image = base_image[slicer] assert sliced_image.crpix[0] == -7, "crpix of subimage should give relative position" diff --git a/tests/test_model.py b/tests/test_model.py index ed16800a..d81fe041 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -17,23 +17,42 @@ def test_model_sampling_modes(): model = ap.Model( name="test sersic", model_type="sersic galaxy model", - center=[20, 20], + center=[40, 41.9], PA=60 * np.pi / 180, - q=0.5, - n=2, - Re=5, + q=0.8, + n=0.5, + Re=20, Ie=1, target=target, ) - model() + + # With subpixel integration + auto = model().data.detach().cpu().numpy() model.sampling_mode = "midpoint" - model() + midpoint = model().data.detach().cpu().numpy() model.sampling_mode = "simpsons" - model() - model.sampling_mode = "quad:3" - model() + simpsons = model().data.detach().cpu().numpy() + model.sampling_mode = "quad:5" + quad5 = model().data.detach().cpu().numpy() + assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" + assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" + assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" + assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" + + # Without subpixel integration model.integrate_mode = "none" - model() + auto = model().data.detach().cpu().numpy() + model.sampling_mode = "midpoint" + midpoint = model().data.detach().cpu().numpy() + model.sampling_mode = "simpsons" + simpsons = model().data.detach().cpu().numpy() + model.sampling_mode = "quad:5" + quad5 = model().data.detach().cpu().numpy() + assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" + assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" + assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" + assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" + model.integrate_mode = "should raise" with pytest.raises(ap.errors.SpecificationConflict): model() @@ -85,6 +104,7 @@ def test_all_model_sample(model_type): target=target, ) MODEL.initialize() + MODEL.to() for P in MODEL.dynamic_params: assert ( P.value is not None diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py new file mode 100644 index 00000000..41be8554 --- /dev/null +++ b/tests/test_notebooks.py @@ -0,0 +1,14 @@ +import nbformat +from nbconvert.preprocessors import ExecutePreprocessor +import glob +import pytest + +notebooks = glob.glob("../docs/source/tutorials/*.ipynb") + + +@pytest.mark.parametrize("nb_path", notebooks) +def test_notebook_runs(nb_path): + with open(nb_path) as f: + nb = nbformat.read(f, as_version=4) + ep = ExecutePreprocessor(timeout=600, kernel_name="python3") + ep.preprocess(nb, {"metadata": {"path": "./"}}) From 802b38dca112d1fbefc645e06a876f30e3b89add Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 13:35:14 -0400 Subject: [PATCH 070/191] nbval in correct requirements --- .github/workflows/coverage.yaml | 2 +- .github/workflows/testing.yaml | 2 +- docs/requirements.txt | 1 - requirements-dev.txt | 1 + 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 22e0d18d..76f285cd 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -44,7 +44,7 @@ jobs: python -m pip install pytest-github-actions-annotate-failures - name: Install AstroPhot run: | - pip install -e . + pip install -e ".[dev]"" pip show ${{ env.PROJECT_NAME }} shell: bash - name: Test with pytest diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index a2a28f01..e7b76de7 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -40,7 +40,7 @@ jobs: - name: Install AstroPhot run: | cd $GITHUB_WORKSPACE/ - pip install . + pip install .[dev] pip show astrophot shell: bash - name: Test with pytest diff --git a/docs/requirements.txt b/docs/requirements.txt index 8b0ca613..78a5747a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,7 +4,6 @@ ipywidgets jupyter-book matplotlib nbsphinx -nbval photutils scikit-image sphinx diff --git a/requirements-dev.txt b/requirements-dev.txt index 416634f5..f02e4a74 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,2 @@ +nbval pre-commit From 12e66df2478450a3e5af34db1acf2c4fb2a5843f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 13:40:11 -0400 Subject: [PATCH 071/191] add dev option --- pyproject.toml | 3 +++ requirements-dev.txt | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 requirements-dev.txt diff --git a/pyproject.toml b/pyproject.toml index 5beaae94..2a6b9786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ Documentation = "https://autostronomy.github.io/AstroPhot/" Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" +[project.optional-dependencies] +dev = ["pre-commit", "nbval"] + [project.scripts] astrophot = "astrophot:run_from_terminal" diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index f02e4a74..00000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,2 +0,0 @@ -nbval -pre-commit From 441b461b14f72cf8243fc927e262a9bf1f8ec322 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 13:42:57 -0400 Subject: [PATCH 072/191] add nbconvert package --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2a6b9786..090f4746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval"] +dev = ["pre-commit", "nbval", "nbconvert"] [project.scripts] astrophot = "astrophot:run_from_terminal" From 649d01238b61b49356e9309c78c0ff967ed7b961 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 14:00:00 -0400 Subject: [PATCH 073/191] adding more control and packages for notebooks --- .github/workflows/coverage.yaml | 2 +- astrophot/fit/gradient.py | 2 +- docs/source/tutorials/FittingMethods.ipynb | 162 ++++++++++----------- pyproject.toml | 2 +- tests/test_notebooks.py | 6 + 5 files changed, 90 insertions(+), 84 deletions(-) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 76f285cd..687150e3 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -44,7 +44,7 @@ jobs: python -m pip install pytest-github-actions-annotate-failures - name: Install AstroPhot run: | - pip install -e ".[dev]"" + pip install -e ".[dev]" pip show ${{ env.PROJECT_NAME }} shell: bash - name: Test with pytest diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 18072cde..743713b0 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -116,7 +116,7 @@ def step(self) -> None: ) or self.iteration == self.max_iter: if self.verbose > 0: AP_config.ap_logger.info( - f"iter: {self.iteration}, posterior density: {loss.item():.e6}" + f"iter: {self.iteration}, posterior density: {loss.item():.6e}" ) if self.verbose > 1: AP_config.ap_logger.info(f"gradient: {self.current_state.grad}") diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index e9ce4e88..6689a44c 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -565,19 +565,19 @@ "metadata": {}, "outputs": [], "source": [ - "MODEL = initialize_model(target, False)\n", + "# MODEL = initialize_model(target, False)\n", "\n", - "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", - "# In general, NUTS is quite fast to do burn-in so this is often not needed\n", - "res1 = ap.fit.LM(MODEL).fit()\n", - "\n", - "# Run the NUTS sampler\n", - "res_nuts = ap.fit.NUTS(\n", - " MODEL,\n", - " warmup=20,\n", - " max_iter=100,\n", - " inv_mass=res1.covariance_matrix,\n", - ").fit()" + "# # Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", + "# # In general, NUTS is quite fast to do burn-in so this is often not needed\n", + "# res1 = ap.fit.LM(MODEL).fit()\n", + "\n", + "# # Run the NUTS sampler\n", + "# res_nuts = ap.fit.NUTS(\n", + "# MODEL,\n", + "# warmup=20,\n", + "# max_iter=100,\n", + "# inv_mass=res1.covariance_matrix,\n", + "# ).fit()" ] }, { @@ -596,23 +596,23 @@ "# corner plot of the posterior\n", "# observe that it is very similar to the corner plot from the LM optimization since this case can be roughly\n", "# approximated as a multivariate gaussian centered on the maximum likelihood point\n", - "param_names = list(MODEL.parameters.vector_names())\n", - "i = 0\n", - "while i < len(param_names):\n", - " param_names[i] = param_names[i].replace(\" \", \"\")\n", - " if \"center\" in param_names[i]:\n", - " center_name = param_names.pop(i)\n", - " param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", - " param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", - " i += 1\n", - "\n", - "set, sky = true_params()\n", - "corner_plot(\n", - " res_nuts.chain.detach().cpu().numpy(),\n", - " labels=param_names,\n", - " figsize=(20, 20),\n", - " true_values=np.concatenate((sky, set.ravel())),\n", - ")" + "# param_names = list(MODEL.parameters.vector_names())\n", + "# i = 0\n", + "# while i < len(param_names):\n", + "# param_names[i] = param_names[i].replace(\" \", \"\")\n", + "# if \"center\" in param_names[i]:\n", + "# center_name = param_names.pop(i)\n", + "# param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", + "# param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", + "# i += 1\n", + "\n", + "# set, sky = true_params()\n", + "# corner_plot(\n", + "# res_nuts.chain.detach().cpu().numpy(),\n", + "# labels=param_names,\n", + "# figsize=(20, 20),\n", + "# true_values=np.concatenate((sky, set.ravel())),\n", + "# )" ] }, { @@ -630,20 +630,20 @@ "metadata": {}, "outputs": [], "source": [ - "MODEL = initialize_model(target, False)\n", + "# MODEL = initialize_model(target, False)\n", "\n", - "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", - "res1 = ap.fit.LM(MODEL).fit()\n", - "\n", - "# Run the HMC sampler\n", - "res_hmc = ap.fit.HMC(\n", - " MODEL,\n", - " warmup=1,\n", - " max_iter=150,\n", - " epsilon=1e-1,\n", - " leapfrog_steps=10,\n", - " inv_mass=res1.covariance_matrix,\n", - ").fit()" + "# # Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", + "# res1 = ap.fit.LM(MODEL).fit()\n", + "\n", + "# # Run the HMC sampler\n", + "# res_hmc = ap.fit.HMC(\n", + "# MODEL,\n", + "# warmup=1,\n", + "# max_iter=150,\n", + "# epsilon=1e-1,\n", + "# leapfrog_steps=10,\n", + "# inv_mass=res1.covariance_matrix,\n", + "# ).fit()" ] }, { @@ -653,23 +653,23 @@ "outputs": [], "source": [ "# corner plot of the posterior\n", - "param_names = list(MODEL.parameters.vector_names())\n", - "i = 0\n", - "while i < len(param_names):\n", - " param_names[i] = param_names[i].replace(\" \", \"\")\n", - " if \"center\" in param_names[i]:\n", - " center_name = param_names.pop(i)\n", - " param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", - " param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", - " i += 1\n", - "\n", - "set, sky = true_params()\n", - "corner_plot(\n", - " res_hmc.chain.detach().cpu().numpy(),\n", - " labels=param_names,\n", - " figsize=(20, 20),\n", - " true_values=np.concatenate((sky, set.ravel())),\n", - ")" + "# param_names = list(MODEL.parameters.vector_names())\n", + "# i = 0\n", + "# while i < len(param_names):\n", + "# param_names[i] = param_names[i].replace(\" \", \"\")\n", + "# if \"center\" in param_names[i]:\n", + "# center_name = param_names.pop(i)\n", + "# param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", + "# param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", + "# i += 1\n", + "\n", + "# set, sky = true_params()\n", + "# corner_plot(\n", + "# res_hmc.chain.detach().cpu().numpy(),\n", + "# labels=param_names,\n", + "# figsize=(20, 20),\n", + "# true_values=np.concatenate((sky, set.ravel())),\n", + "# )" ] }, { @@ -687,13 +687,13 @@ "metadata": {}, "outputs": [], "source": [ - "MODEL = initialize_model(target, False)\n", + "# MODEL = initialize_model(target, False)\n", "\n", - "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", - "res1 = ap.fit.LM(MODEL).fit()\n", + "# # Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", + "# res1 = ap.fit.LM(MODEL).fit()\n", "\n", - "# Run the HMC sampler\n", - "res_mh = ap.fit.MHMCMC(MODEL, verbose=1, max_iter=1000, epsilon=1e-4, report_after=np.inf).fit()" + "# # Run the HMC sampler\n", + "# res_mh = ap.fit.MHMCMC(MODEL, verbose=1, max_iter=1000, epsilon=1e-4, report_after=np.inf).fit()" ] }, { @@ -707,23 +707,23 @@ "# In fact it is not even close to convergence as can be seen by the multi-modal blobs in the posterior since this\n", "# problem is unimodal (except the modes where models are swapped). It is almost never worthwhile to use this\n", "# sampler except as a sanity check on very simple models.\n", - "param_names = list(MODEL.parameters.vector_names())\n", - "i = 0\n", - "while i < len(param_names):\n", - " param_names[i] = param_names[i].replace(\" \", \"\")\n", - " if \"center\" in param_names[i]:\n", - " center_name = param_names.pop(i)\n", - " param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", - " param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", - " i += 1\n", - "\n", - "set, sky = true_params()\n", - "corner_plot(\n", - " res_mh.chain[::10], # thin by a factor 10 so the plot works in reasonable time\n", - " labels=param_names,\n", - " figsize=(20, 20),\n", - " true_values=np.concatenate((sky, set.ravel())),\n", - ")" + "# param_names = list(MODEL.parameters.vector_names())\n", + "# i = 0\n", + "# while i < len(param_names):\n", + "# param_names[i] = param_names[i].replace(\" \", \"\")\n", + "# if \"center\" in param_names[i]:\n", + "# center_name = param_names.pop(i)\n", + "# param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", + "# param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", + "# i += 1\n", + "\n", + "# set, sky = true_params()\n", + "# corner_plot(\n", + "# res_mh.chain[::10], # thin by a factor 10 so the plot works in reasonable time\n", + "# labels=param_names,\n", + "# figsize=(20, 20),\n", + "# true_values=np.concatenate((sky, set.ravel())),\n", + "# )" ] }, { diff --git a/pyproject.toml b/pyproject.toml index 090f4746..885fdf74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics"] [project.scripts] astrophot = "astrophot:run_from_terminal" diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index 41be8554..a6041fee 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -1,8 +1,14 @@ +import platform import nbformat from nbconvert.preprocessors import ExecutePreprocessor import glob import pytest +pytestmark = pytest.mark.skipif( + platform.system() in ["Windows", "Darwin"], + reason="Graphviz not installed on Windows runner", +) + notebooks = glob.glob("../docs/source/tutorials/*.ipynb") From 92ff20c7f77d913a69bf6a140cd320f631c2b1ca Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 14:57:27 -0400 Subject: [PATCH 074/191] fitter test errors fixed --- astrophot/fit/__init__.py | 2 +- astrophot/fit/mhmcmc.py | 3 + astrophot/models/gaussian_ellipsoid.py | 5 - astrophot/models/mixins/king.py | 4 +- astrophot/models/moffat.py | 8 +- astrophot/models/multi_gaussian_expansion.py | 4 - tests/test_fit.py | 209 ++----------------- tests/test_model.py | 13 ++ 8 files changed, 43 insertions(+), 205 deletions(-) diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 4ce7a90b..4c6b4c02 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -13,7 +13,7 @@ # from .nuts import * # except AssertionError as e: # print("Could not load HMC or NUTS due to:", str(e)) -# from .mhmcmc import * +from .mhmcmc import MHMCMC __all__ = ["LM", "Grad", "Iter", "ScipyFit"] diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index 641f44ea..b02c5ff8 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -82,4 +82,7 @@ def fit( self.chain = sampler.get_chain() else: self.chain = np.append(self.chain, sampler.get_chain(), axis=0) + self.model.fill_dynamic_values( + torch.tensor(self.chain[-1][0], dtype=AP_config.ap_dtype, device=AP_config.ap_device) + ) return self diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index a4e14e20..99e7d43d 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -96,11 +96,6 @@ def initialize(self): self.gamma.dynamic_value = PA self.flux.dynamic_value = np.sum(dat) - @forward - def total_flux(self, flux): - """Total flux of the Gaussian ellipsoid.""" - return flux - @forward def brightness(self, x, y, sigma_a, sigma_b, sigma_c, alpha, beta, gamma, flux): """Brightness of the Gaussian ellipsoid.""" diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index de660c3d..7bad3cbe 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -36,7 +36,7 @@ class KingMixin: _parameter_specs = { "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "alpha": {"units": "unitless", "valid": (0, None), "shape": ()}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": (), "value": 2.0}, "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, } @@ -98,7 +98,7 @@ def initialize(self): super().initialize() if not self.alpha.initialized: - self.alpha.dynamic_value = 2.0 * np.ones(self.segments) + self.alpha.value = 2.0 * np.ones(self.segments) parametric_segment_initialize( model=self, target=self.target[self.window], diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py index d0432e7b..2ae5bacf 100644 --- a/astrophot/models/moffat.py +++ b/astrophot/models/moffat.py @@ -34,7 +34,7 @@ class MoffatGalaxy(MoffatMixin, RadialMixin, GalaxyModel): usable = True @forward - def total_flux(self, n, Rd, I0, q): + def total_flux(self, window=None, n=None, Rd=None, I0=None, q=None): return moffat_I0_to_flux(I0, n, Rd, q) @@ -45,7 +45,7 @@ class MoffatPSF(MoffatMixin, RadialMixin, PSFModel): usable = True @forward - def total_flux(self, n, Rd, I0): + def total_flux(self, window=None, n=None, Rd=None, I0=None): return moffat_I0_to_flux(I0, n, Rd, 1.0) @@ -56,10 +56,6 @@ class Moffat2DPSF(MoffatMixin, InclinedMixin, RadialMixin, PSFModel): _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} usable = True - @forward - def total_flux(self, n, Rd, I0, q): - return moffat_I0_to_flux(I0, n, Rd, q) - @combine_docstrings class MoffatSuperEllipse(MoffatMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index a52a1740..3084d6d6 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -95,10 +95,6 @@ def initialize(self): l = (0.7, 1.0) self.q.dynamic_value = ones * np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) - @forward - def total_flux(self, flux): - return torch.sum(flux) - @forward def transform_coordinates(self, x, y, q, PA): x, y = super().transform_coordinates(x, y) diff --git a/tests/test_fit.py b/tests/test_fit.py index e3b84a06..81f3db7e 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -49,152 +49,28 @@ def test_chunk_jacobian(center, PA, q, n, Re): ), "Pixel chunked Jacobian should match full Jacobian" -# LM already tested extensively -# def test_lm(): -# target = make_basic_sersic() -# new_model = ap.Model( -# name="test sersic", -# model_type="sersic galaxy model", -# center=[20, 20], -# PA=60 * np.pi / 180, -# q=0.5, -# n=2, -# Re=5, -# Ie=10, -# target=target, -# ) - -# res = ap.fit.LM(new_model).fit() -# print(res.loss_history) -# raise Exception() - -# assert res.message == "success", "LM should converge successfully" - - -# def test_chunk_parameter_jacobian(): -# target = make_basic_sersic() -# new_model = ap.Model( -# name="test sersic", -# model_type="sersic galaxy model", -# center=[20, 20], -# PA=60 * np.pi / 180, -# q=0.5, -# n=2, -# Re=5, -# Ie=10, -# target=target, -# jacobian_maxparams=3, -# ) - -# res = ap.fit.LM(new_model).fit() -# print(res.loss_history) -# raise Exception() -# assert res.message == "success", "LM should converge successfully" - - -# def test_chunk_image_jacobian(): -# target = make_basic_sersic() -# new_model = ap.Model( -# name="test sersic", -# model_type="sersic galaxy model", -# center=[20, 20], -# PA=60 * np.pi / 180, -# q=0.5, -# n=2, -# Re=5, -# Ie=1, -# target=target, -# jacobian_maxpixels=20**2, -# ) - -# res = ap.fit.LM(new_model).fit() -# print(res.loss_history) -# raise Exception() -# assert res.message == "success", "LM should converge successfully" - - -# class TestIter(unittest.TestCase): -# def test_iter_basic(self): -# target = make_basic_sersic() -# model_list = [] -# model_list.append( -# ap.models.AstroPhot_Model( -# name="basic sersic", -# model_type="sersic galaxy model", -# parameters={ -# "center": [20, 20], -# "PA": 60 * np.pi / 180, -# "q": 0.5, -# "n": 2, -# "Re": 5, -# "Ie": 1, -# }, -# target=target, -# ) -# ) -# model_list.append( -# ap.models.AstroPhot_Model( -# name="basic sky", -# model_type="flat sky model", -# parameters={"F": -1}, -# target=target, -# ) -# ) - -# MODEL = ap.models.AstroPhot_Model( -# name="model", -# model_type="group model", -# target=target, -# models=model_list, -# ) - -# MODEL.initialize() - -# res = ap.fit.Iter(MODEL, method=ap.fit.LM) - -# res.fit() - - -# class TestIterLM(unittest.TestCase): -# def test_iter_basic(self): -# target = make_basic_sersic() -# model_list = [] -# model_list.append( -# ap.models.AstroPhot_Model( -# name="basic sersic", -# model_type="sersic galaxy model", -# parameters={ -# "center": [20, 20], -# "PA": 60 * np.pi / 180, -# "q": 0.5, -# "n": 2, -# "Re": 5, -# "Ie": 1, -# }, -# target=target, -# ) -# ) -# model_list.append( -# ap.models.AstroPhot_Model( -# name="basic sky", -# model_type="flat sky model", -# parameters={"F": -1}, -# target=target, -# ) -# ) - -# MODEL = ap.models.AstroPhot_Model( -# name="model", -# model_type="group model", -# target=target, -# models=model_list, -# ) - -# MODEL.initialize() - -# res = ap.fit.Iter_LM(MODEL) - -# res.fit() +@pytest.mark.parametrize("fitter", [ap.fit.LM, ap.fit.Grad, ap.fit.ScipyFit, ap.fit.MHMCMC]) +def test_fitters(fitter): + target = make_basic_sersic() + model = ap.Model( + name="test sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=np.pi, + q=0.7, + n=2, + Re=15, + Ie=10.0, + target=target, + ) + model.initialize() + ll_init = model.gaussian_log_likelihood() + pll_init = model.poisson_log_likelihood() + result = fitter(model, max_iter=100).fit() + ll_final = model.gaussian_log_likelihood() + pll_final = model.poisson_log_likelihood() + assert ll_final > ll_init, f"{fitter.__name__} should improve the log likelihood" + assert pll_final > pll_init, f"{fitter.__name__} should improve the poisson log likelihood" # class TestHMC(unittest.TestCase): @@ -265,44 +141,3 @@ def test_chunk_jacobian(center, PA, q, n, Re): # NUTS = ap.fit.NUTS(MODEL, max_iter=5, warmup=2) # NUTS.fit() - - -# class TestMHMCMC(unittest.TestCase): -# def test_singlesersic(self): -# np.random.seed(12345) -# N = 50 -# pixelscale = 0.8 -# true_params = { -# "n": 2, -# "Re": 10, -# "Ie": 1, -# "center": [-3.3, 5.3], -# "q": 0.7, -# "PA": np.pi / 4, -# } -# target = ap.image.Target_Image( -# data=np.zeros((N, N)), -# pixelscale=pixelscale, -# ) - -# MODEL = ap.models.Sersic_Galaxy( -# name="sersic model", -# target=target, -# parameters=true_params, -# ) -# img = MODEL().data.detach().cpu().numpy() -# target.data = torch.Tensor( -# img -# + np.random.normal(scale=0.1, size=img.shape) -# + np.random.normal(scale=np.sqrt(img) / 10) -# ) -# target.variance = torch.Tensor(0.1**2 + img / 100) - -# MHMCMC = ap.fit.MHMCMC(MODEL, epsilon=1e-4, max_iter=100) -# MHMCMC.fit() - -# self.assertGreater( -# MHMCMC.acceptance, -# 0.1, -# "MHMCMC should have nonzero acceptance for simple fits", -# ) diff --git a/tests/test_model.py b/tests/test_model.py index d81fe041..c512ee4a 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -98,6 +98,7 @@ def test_model_errors(): def test_all_model_sample(model_type): target = make_basic_sersic() + target.zeropoint = 22.5 MODEL = ap.Model( name="test model", model_type=model_type, @@ -138,6 +139,18 @@ def test_all_model_sample(model_type): f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" ) + F = MODEL.total_flux() + assert torch.isfinite(F), "Model total flux should be finite after fitting" + assert F > 0, "Model total flux should be positive after fitting" + U = MODEL.total_flux_uncertainty() + assert torch.isfinite(U), "Model total flux uncertainty should be finite after fitting" + assert U >= 0, "Model total flux uncertainty should be non-negative after fitting" + M = MODEL.total_magnitude() + assert torch.isfinite(M), "Model total magnitude should be finite after fitting" + U_M = MODEL.total_magnitude_uncertainty() + assert torch.isfinite(U_M), "Model total magnitude uncertainty should be finite after fitting" + assert U_M >= 0, "Model total magnitude uncertainty should be non-negative after fitting" + def test_sersic_save_load(): From 3a426a8812c537d064707bfc7a0d1e4ceb229067 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 15:01:02 -0400 Subject: [PATCH 075/191] add emcee requirement for dev --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 885fdf74..4d8ff2d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee"] [project.scripts] astrophot = "astrophot:run_from_terminal" From 10138b042f5441bd893dc9ccecf4568f49bc902c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 15:43:03 -0400 Subject: [PATCH 076/191] more test coverage --- astrophot/models/base.py | 4 +- astrophot/models/func/__init__.py | 8 ---- astrophot/models/func/convolution.py | 55 ---------------------------- tests/test_fit.py | 26 +++++++++++++ tests/test_model.py | 14 +++++++ 5 files changed, 42 insertions(+), 65 deletions(-) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 5cc7409c..35514bec 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -140,11 +140,11 @@ def gaussian_log_likelihood( data = data.data if isinstance(data, ImageList): nll = 0.5 * sum( - torch.sum(((mo - da) ** 2 * wgt)[~ma]) + torch.sum(((da - mo) ** 2 * wgt)[~ma]) for mo, da, wgt, ma in zip(model, data, weight, mask) ) else: - nll = 0.5 * torch.sum(((model - data) ** 2 * weight)[~mask]) + nll = 0.5 * torch.sum(((data - model) ** 2 * weight)[~mask]) return -nll diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index bfb02698..63527b31 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -11,11 +11,7 @@ recursive_bright_integrate, ) from .convolution import ( - lanczos_kernel, - bilinear_kernel, - fft_shift_kernel, convolve, - convolve_and_shift, curvature_kernel, ) from .sersic import sersic, sersic_n_to_b @@ -37,11 +33,7 @@ "pixel_corner_integrator", "pixel_simpsons_integrator", "pixel_quad_integrator", - "lanczos_kernel", - "bilinear_kernel", - "fft_shift_kernel", "convolve", - "convolve_and_shift", "curvature_kernel", "sersic", "sersic_n_to_b", diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index 6a02ac6b..90dad3c6 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -3,42 +3,6 @@ import torch -def lanczos_1d(x, order): - """1D Lanczos kernel with window size `order`.""" - mask = (x.abs() < order).to(x.dtype) - return torch.sinc(x) * torch.sinc(x / order) * mask - - -def lanczos_kernel(di, dj, order): - grid = torch.arange(-order, order + 1, dtype=di.dtype, device=di.device) - li = lanczos_1d(grid - di, order) - lj = lanczos_1d(grid - dj, order) - kernel = torch.outer(li, lj) - return kernel / kernel.sum() - - -def bilinear_kernel(di, dj): - """Bilinear kernel for sub-pixel shifting.""" - w00 = (1 - di) * (1 - dj) - w10 = di * (1 - dj) - w01 = (1 - di) * dj - w11 = di * dj - - kernel = torch.stack([w00, w10, w01, w11]).reshape(2, 2) - return kernel - - -def fft_shift_kernel(shape, di, dj): - """FFT shift theorem gives "exact" shift in phase space. Not really exact for DFT""" - ni, nj = shape - ki = torch.fft.fftfreq(ni, dtype=di.dtype, device=di.device) - kj = torch.fft.fftfreq(nj, dtype=di.dtype, device=di.device) - Ki, Kj = torch.meshgrid(ki, kj, indexing="ij") - phase = -2j * torch.pi * (Ki * di + Kj * dj) - gauss = torch.exp(-0.5 * (Ki**2 + Kj**2) * 5**2) - return torch.exp(phase) * gauss - - def convolve(image, psf): image_fft = torch.fft.rfft2(image, s=image.shape) @@ -53,25 +17,6 @@ def convolve(image, psf): ) -def convolve_and_shift(image, psf, shift): - - image_fft = torch.fft.rfft2(image, s=image.shape) - psf_fft = torch.fft.rfft2(psf, s=image.shape) - - if shift is None: - convolved_fft = image_fft * psf_fft - else: - shift_kernel = fft_shift_kernel(image.shape, shift[0], shift[1]) - convolved_fft = image_fft * psf_fft * shift_kernel - - convolved = torch.fft.irfft2(convolved_fft, s=image.shape) - return torch.roll( - convolved, - shifts=(-(psf.shape[0] // 2), -(psf.shape[1] // 2)), - dims=(0, 1), - ) - - @lru_cache(maxsize=32) def curvature_kernel(dtype, device): kernel = torch.tensor( diff --git a/tests/test_fit.py b/tests/test_fit.py index 81f3db7e..1c2652f3 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -73,6 +73,32 @@ def test_fitters(fitter): assert pll_final > pll_init, f"{fitter.__name__} should improve the poisson log likelihood" +def test_gradient(): + target = make_basic_sersic() + target.weight = 1 / (10 + target.variance.T) + model = ap.Model( + name="test sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=np.pi, + q=0.7, + n=2, + Re=15, + Ie=10.0, + target=target, + ) + model.initialize() + x = model.build_params_array() + grad = model.gradient() + assert torch.all(torch.isfinite(grad)), "Gradient should be finite" + assert grad.shape == x.shape, "Gradient shape should match parameters shape" + x.requires_grad = True + ll = model.gaussian_log_likelihood(x) + ll.backward() + autograd = x.grad + assert torch.allclose(grad, autograd, rtol=1e-4), "Gradient should match autograd gradient" + + # class TestHMC(unittest.TestCase): # def test_hmc_sample(self): # np.random.seed(12345) diff --git a/tests/test_model.py b/tests/test_model.py index c512ee4a..5ffc997e 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -53,6 +53,20 @@ def test_model_sampling_modes(): assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" + # Without subpixel integration + model.integrate_mode = "threshold" + auto = model().data.detach().cpu().numpy() + model.sampling_mode = "midpoint" + midpoint = model().data.detach().cpu().numpy() + model.sampling_mode = "simpsons" + simpsons = model().data.detach().cpu().numpy() + model.sampling_mode = "quad:5" + quad5 = model().data.detach().cpu().numpy() + assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" + assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" + assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" + assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" + model.integrate_mode = "should raise" with pytest.raises(ap.errors.SpecificationConflict): model() From 6230a3ac0fc1465dd81c45ceb2885580519d6ade Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 16:29:30 -0400 Subject: [PATCH 077/191] add group model tests --- astrophot/models/group_model_object.py | 22 ++++++++---- tests/test_fit.py | 40 +++++++++++++++++++++ tests/test_group_models.py | 48 ++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 2fa015f1..0bcc77ab 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -62,26 +62,34 @@ def update_window(self): """ if isinstance(self.target, ImageList): # WindowList if target is a TargetImageList - new_window = [None] * len(self.target.images) + new_window = list(target.window.copy() for target in self.target) + n_windows = [0] * len(self.target.images) for model in self.models: if isinstance(model.target, ImageList): for target, window in zip(model.target, model.window): index = self.target.index(target) - if new_window[index] is None: - new_window[index] = window.copy() + if n_windows[index] == 0: + new_window[index] &= window else: new_window[index] |= window + n_windows[index] += 1 elif isinstance(model.target, TargetImage): index = self.target.index(model.target) - if new_window[index] is None: - new_window[index] = model.window.copy() + if n_windows[index] == 0: + new_window[index] &= model.window else: new_window[index] |= model.window + n_windows[index] += 1 else: raise NotImplementedError( f"Group_Model cannot construct a window for itself using {type(model.target)} object. Must be a Target_Image" ) new_window = WindowList(new_window) + for i, n in enumerate(n_windows): + if n == 0: + AP_config.ap_logger.warning( + f"Model {self.name} has no sub models in target '{self.target.images[i].name}', this may cause issues with fitting." + ) else: new_window = None for model in self.models: @@ -143,7 +151,7 @@ def match_window(self, image, window, model): indices = image.match_indices(model.target) if len(indices) == 0: raise IndexError - use_window = WindowList(window_list=list(image.images[i].window for i in indices)) + use_window = WindowList(windows=list(image.images[i].window for i in indices)) elif isinstance(image, ImageList) and isinstance(model.target, Image): try: image.index(model.target) @@ -261,7 +269,7 @@ def target(self, tar: Optional[Union[TargetImage, TargetImageList]]): self._target = tar @property - def window(self) -> Optional[Window]: + def window(self) -> Optional[Union[Window, WindowList]]: """The window defines a region on the sky in which this model will be optimized and typically evaluated. Two models with non-overlapping windows are in effect independent of each diff --git a/tests/test_fit.py b/tests/test_fit.py index 1c2652f3..bbf03750 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -73,6 +73,46 @@ def test_fitters(fitter): assert pll_final > pll_init, f"{fitter.__name__} should improve the poisson log likelihood" +def test_fitters_iter(): + target = make_basic_sersic() + model1 = ap.Model( + name="test1", + model_type="sersic galaxy model", + center=[20, 20], + PA=np.pi, + q=0.7, + n=2, + Re=15, + Ie=10.0, + target=target, + ) + model2 = ap.Model( + name="test2", + model_type="sersic galaxy model", + center=[20.5, 21], + PA=1.5 * np.pi, + q=0.9, + n=1, + Re=10, + Ie=8.0, + target=target, + ) + model = ap.Model( + name="test group", + model_type="group model", + models=[model1, model2], + target=target, + ) + model.initialize() + ll_init = model.gaussian_log_likelihood() + pll_init = model.poisson_log_likelihood() + result = ap.fit.Iter(model, max_iter=10).fit() + ll_final = model.gaussian_log_likelihood() + pll_final = model.poisson_log_likelihood() + assert ll_final > ll_init, f"Iter should improve the log likelihood" + assert pll_final > pll_init, f"Iter should improve the poisson log likelihood" + + def test_gradient(): target = make_basic_sersic() target.weight = 1 / (10 + target.variance.T) diff --git a/tests/test_group_models.py b/tests/test_group_models.py index 13947136..a6e7c54d 100644 --- a/tests/test_group_models.py +++ b/tests/test_group_models.py @@ -75,3 +75,51 @@ def test_psfgroupmodel_creation(): smod.initialize() assert torch.all(smod().data >= 0), "PSF group sample should be greater than or equal to zero" + + +def test_joint_multi_band_multi_object(): + target1 = make_basic_sersic(52, 53, name="target1") + target2 = make_basic_sersic(48, 65, name="target2") + target3 = make_basic_sersic(60, 49, name="target3") + target4 = make_basic_sersic(60, 49, name="target4") + + # fmt: off + model11 = ap.Model(name="model11", model_type="sersic galaxy model", window=(0, 50, 5, 52), target=target1) + model12 = ap.Model(name="model12", model_type="sersic galaxy model", window=(3, 53, 0, 49), target=target1) + model1 = ap.Model(name="model1", model_type="group model", models=[model11, model12], target=target1) + + model21 = ap.Model(name="model21", model_type="sersic galaxy model", window=(1, 62, 10, 48), target=target2) + model22 = ap.Model(name="model22", model_type="sersic galaxy model", window=(2, 60, 5, 49), target=target2) + model2 = ap.Model(name="model2", model_type="group model", models=[model21, model22], target=target2) + + model31 = ap.Model(name="model31", model_type="sersic galaxy model", window=(1, 62, 10, 48), target=target3) + model32 = ap.Model(name="model32", model_type="sersic galaxy model", window=(2, 60, 5, 49), target=target3) + model3 = ap.Model(name="model3", model_type="group model", models=[model31, model32], target=target3) + + model4 = ap.Model(name="model4", model_type="sersic galaxy model", window=(0, 53, 0, 52), target=target1) + + model51 = ap.Model(name="model51", model_type="sersic galaxy model", window=(0, 65, 0, 48), target=target2) + model52 = ap.Model(name="model52", model_type="sersic galaxy model", window=(0, 49, 0, 60), target=target3) + model5 = ap.Model(name="model5", model_type="group model", models=[model51, model52], target=ap.TargetImageList([target2, target3])) + + model = ap.Model(name="joint model", model_type="group model", models=[model1, model2, model3, model4, model5], target=ap.TargetImageList([target1, target2, target3, target4])) + # fmt: on + + model.initialize() + mask = model.fit_mask() + assert len(mask) == 4, "There should be 4 fit masks for the 4 targets" + for m in mask: + assert torch.all(torch.isfinite(m)), "this fit_mask should be finite" + sample = model.sample(window=ap.WindowList([target1.window, target2.window, target3.window])) + assert isinstance(sample, ap.ImageList), "Sample should be an ImageList" + for image in sample: + assert torch.all(torch.isfinite(image.data)), "Sample image data should be finite" + assert torch.all(image.data >= 0), "Sample image data should be non-negative" + + jacobian = model.jacobian() + assert isinstance(jacobian, ap.ImageList), "Jacobian should be an ImageList" + for image in jacobian: + assert torch.all(torch.isfinite(image.data)), "Jacobian image data should be finite" + + window = model.window + assert isinstance(window, ap.WindowList), "Window should be a WindowList" From a0dde5180a7274eef5bfc0063d04a4f9084b8a8e Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 21 Jul 2025 17:07:27 -0400 Subject: [PATCH 078/191] better test notebooks --- tests/conftest.py | 15 +++++++++++++++ tests/test_notebooks.py | 42 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..92081514 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +import matplotlib +import matplotlib.pyplot as plt +import pytest + + +@pytest.fixture(autouse=True) +def no_block_show(monkeypatch): + def close_show(*args, **kwargs): + # plt.savefig("/dev/null") # or do nothing + plt.close("all") + + monkeypatch.setattr(plt, "show", close_show) + + # Also ensure we are in a non-GUI backend + matplotlib.use("Agg") diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index a6041fee..26b3a9f6 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -3,6 +3,9 @@ from nbconvert.preprocessors import ExecutePreprocessor import glob import pytest +import runpy +import subprocess +import os pytestmark = pytest.mark.skipif( platform.system() in ["Windows", "Darwin"], @@ -12,9 +15,38 @@ notebooks = glob.glob("../docs/source/tutorials/*.ipynb") +# @pytest.mark.parametrize("nb_path", notebooks) +# def test_notebook_runs(nb_path): +# with open(nb_path) as f: +# nb = nbformat.read(f, as_version=4) +# ep = ExecutePreprocessor(timeout=600, kernel_name="python3") +# ep.preprocess(nb, {"metadata": {"path": "./"}}) +def convert_notebook_to_py(nbpath): + subprocess.run( + ["jupyter", "nbconvert", "--to", "python", nbpath], + check=True, + ) + pypath = nbpath.replace(".ipynb", ".py") + with open(pypath, "r") as f: + content = f.readlines() + with open(pypath, "w") as f: + for line in content: + if line.startswith("get_ipython()"): + # Remove get_ipython() lines to avoid errors in script execution + continue + f.write(line) + + +def cleanup_py_scripts(nbpath): + try: + os.remove(nbpath.replace(".ipynb", ".py")) + os.remove(nbpath.replace(".ipynb", ".pyc")) + except FileNotFoundError: + pass + + @pytest.mark.parametrize("nb_path", notebooks) -def test_notebook_runs(nb_path): - with open(nb_path) as f: - nb = nbformat.read(f, as_version=4) - ep = ExecutePreprocessor(timeout=600, kernel_name="python3") - ep.preprocess(nb, {"metadata": {"path": "./"}}) +def test_notebook(nb_path): + convert_notebook_to_py(nb_path) + runpy.run_path(nb_path.replace(".ipynb", ".py"), run_name="__main__") + cleanup_py_scripts(nb_path) From b4f1218dea4d36c5b5e45ece234a86ee087ae62c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 22 Jul 2025 09:46:26 -0400 Subject: [PATCH 079/191] fix sip save load, add tests --- astrophot/image/mixins/sip_mixin.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index ff49633b..498f17f2 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -137,14 +137,26 @@ def fits_info(self): info = super().fits_info() info["CTYPE1"] = "RA---TAN-SIP" info["CTYPE2"] = "DEC--TAN-SIP" + a_order = 0 for a, b in self.sipA: info[f"A_{a}_{b}"] = self.sipA[(a, b)] + a_order = max(a_order, a + b) + info["A_ORDER"] = a_order + b_order = 0 for a, b in self.sipB: info[f"B_{a}_{b}"] = self.sipB[(a, b)] + b_order = max(b_order, a + b) + info["B_ORDER"] = b_order + ap_order = 0 for a, b in self.sipAP: info[f"AP_{a}_{b}"] = self.sipAP[(a, b)] + ap_order = max(ap_order, a + b) + info["AP_ORDER"] = ap_order + bp_order = 0 for a, b in self.sipBP: info[f"BP_{a}_{b}"] = self.sipBP[(a, b)] + bp_order = max(bp_order, a + b) + info["BP_ORDER"] = bp_order return info def load(self, filename: str, hduext=0): From 631c73d418d201b52a6f2e302b627dac7941fc1c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 22 Jul 2025 09:46:37 -0400 Subject: [PATCH 080/191] add tests --- tests/test_image_list.py | 8 ++++++++ tests/test_sip_image.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/tests/test_image_list.py b/tests/test_image_list.py index cbfdf158..0f1edb8f 100644 --- a/tests/test_image_list.py +++ b/tests/test_image_list.py @@ -95,6 +95,14 @@ def test_image_arithmetic(): base_image2.data, torch.ones_like(base_image2.data) ), "image addition should update its region" + new_image = test_image + second_image + new_image = test_image - second_image + new_image = new_image.to(dtype=torch.float32, device="cpu") + assert isinstance(new_image, ap.ImageList), "new image should be an ImageList" + + new_image += base_image1 + new_image -= base_image2 + def test_model_image_list_error(): arr1 = torch.zeros((10, 15)) diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py index 18a4dff3..f8bd5d7c 100644 --- a/tests/test_sip_image.py +++ b/tests/test_sip_image.py @@ -78,3 +78,38 @@ def test_sip_image_wcs_roundtrip(sip_target): assert torch.allclose(i, i2, atol=0.5), "i coordinates should match after WCS roundtrip" assert torch.allclose(j, j2, atol=0.5), "j coordinates should match after WCS roundtrip" + + +def test_sip_image_save_load(sip_target): + """ + Test that SIP images can be saved and loaded correctly. + """ + # Save the SIP image to a file + sip_target.save("test_sip_image.fits") + + # Load the SIP image from the file + loaded_image = ap.SIPTargetImage(filename="test_sip_image.fits") + + # Check that the loaded image matches the original + assert torch.allclose( + sip_target.data, loaded_image.data + ), "Loaded image data should match original" + assert torch.allclose( + sip_target.pixelscale, loaded_image.pixelscale + ), "Loaded image pixelscale should match original" + assert torch.allclose( + sip_target.zeropoint, loaded_image.zeropoint + ), "Loaded image zeropoint should match original" + print(loaded_image.sipA) + assert all( + np.allclose(sip_target.sipA[key], loaded_image.sipA[key]) for key in sip_target.sipA + ), "Loaded image sipA should match original" + assert all( + np.allclose(sip_target.sipB[key], loaded_image.sipB[key]) for key in sip_target.sipB + ), "Loaded image sipB should match original" + assert all( + np.allclose(sip_target.sipAP[key], loaded_image.sipAP[key]) for key in sip_target.sipAP + ), "Loaded image sipAP should match original" + assert all( + np.allclose(sip_target.sipBP[key], loaded_image.sipBP[key]) for key in sip_target.sipBP + ), "Loaded image sipBP should match original" From 9486eae060e71004775cdcc51f7c0bab26446257 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 22 Jul 2025 13:21:18 -0400 Subject: [PATCH 081/191] increase socket timeout to help python 3.11 --- docs/source/tutorials/CustomModels.ipynb | 5 ++++- docs/source/tutorials/GettingStarted.ipynb | 5 ++++- docs/source/tutorials/GravitationalLensing.ipynb | 5 ++++- docs/source/tutorials/GroupModels.ipynb | 5 ++++- docs/source/tutorials/ImageAlignment.ipynb | 5 ++++- docs/source/tutorials/JointModels.ipynb | 5 ++++- 6 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/source/tutorials/CustomModels.ipynb b/docs/source/tutorials/CustomModels.ipynb index bcb61285..71e42779 100644 --- a/docs/source/tutorials/CustomModels.ipynb +++ b/docs/source/tutorials/CustomModels.ipynb @@ -65,7 +65,10 @@ "import torch\n", "from astropy.io import fits\n", "import numpy as np\n", - "import matplotlib.pyplot as plt" + "import matplotlib.pyplot as plt\n", + "import socket\n", + "\n", + "socket.setdefaulttimeout(60)" ] }, { diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index c52ced93..dc7bdb8a 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -24,7 +24,10 @@ "import torch\n", "from astropy.io import fits\n", "from astropy.wcs import WCS\n", - "import matplotlib.pyplot as plt" + "import matplotlib.pyplot as plt\n", + "import socket\n", + "\n", + "socket.setdefaulttimeout(60)" ] }, { diff --git a/docs/source/tutorials/GravitationalLensing.ipynb b/docs/source/tutorials/GravitationalLensing.ipynb index 2a7daa77..9e7fb5c8 100644 --- a/docs/source/tutorials/GravitationalLensing.ipynb +++ b/docs/source/tutorials/GravitationalLensing.ipynb @@ -25,7 +25,10 @@ "import matplotlib.pyplot as plt\n", "import caustics\n", "import numpy as np\n", - "import torch" + "import torch\n", + "import socket\n", + "\n", + "socket.setdefaulttimeout(60)" ] }, { diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index fc098cb7..d14cab2f 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -24,7 +24,10 @@ "import astrophot as ap\n", "import numpy as np\n", "from astropy.io import fits\n", - "import matplotlib.pyplot as plt" + "import matplotlib.pyplot as plt\n", + "import socket\n", + "\n", + "socket.setdefaulttimeout(60)" ] }, { diff --git a/docs/source/tutorials/ImageAlignment.ipynb b/docs/source/tutorials/ImageAlignment.ipynb index 5ebcc669..4b7e8701 100644 --- a/docs/source/tutorials/ImageAlignment.ipynb +++ b/docs/source/tutorials/ImageAlignment.ipynb @@ -20,7 +20,10 @@ "import astrophot as ap\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", - "import torch" + "import torch\n", + "import socket\n", + "\n", + "socket.setdefaulttimeout(60)" ] }, { diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 38d9a79a..445ad0b0 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -20,7 +20,10 @@ "outputs": [], "source": [ "import astrophot as ap\n", - "import matplotlib.pyplot as plt" + "import matplotlib.pyplot as plt\n", + "import socket\n", + "\n", + "socket.setdefaulttimeout(60)" ] }, { From 6daa698b6afded1f000377144a85f0cb59a04155 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 22 Jul 2025 13:48:10 -0400 Subject: [PATCH 082/191] more tests for sip --- tests/test_model.py | 5 +++++ tests/test_sip_image.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/tests/test_model.py b/tests/test_model.py index 5ffc997e..3212a81b 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -165,6 +165,11 @@ def test_all_model_sample(model_type): assert torch.isfinite(U_M), "Model total magnitude uncertainty should be finite after fitting" assert U_M >= 0, "Model total magnitude uncertainty should be non-negative after fitting" + allnames = set() + for name in MODEL.build_params_array_names(): + assert name not in allnames, f"Duplicate parameter name found: {name}" + allnames.add(name) + def test_sersic_save_load(): diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py index f8bd5d7c..d3971fd4 100644 --- a/tests/test_sip_image.py +++ b/tests/test_sip_image.py @@ -17,6 +17,8 @@ def sip_target(): data=arr, pixelscale=1.0, zeropoint=1.0, + variance=torch.ones_like(arr), + mask=torch.zeros_like(arr), sipA={(1, 0): 1e-4, (0, 1): 1e-4, (2, 3): -1e-5}, sipB={(1, 0): -1e-4, (0, 1): 5e-5, (2, 3): 2e-6}, sipAP={(1, 0): -1e-4, (0, 1): -1e-4, (2, 3): 1e-5}, @@ -67,6 +69,30 @@ def test_sip_image_creation(sip_target): 22, ), "model image distortion model should have correct shape" + # reduce + sip_model_reduce = sip_model_image.reduce(scale=1) + assert sip_model_reduce is sip_model_image, "reduce should return the same image if scale is 1" + sip_model_reduce = sip_model_image.reduce(scale=2) + assert sip_model_reduce.shape == (16, 11), "reduced model image should have correct shape" + + # crop + sip_model_crop = sip_model_image.crop(1) + assert sip_model_crop.shape == (30, 20), "cropped model image should have correct shape" + sip_model_crop = sip_model_image.crop([1]) + assert sip_model_crop.shape == (30, 20), "cropped model image should have correct shape" + sip_model_crop = sip_model_image.crop([1, 2]) + assert sip_model_crop.shape == (30, 18), "cropped model image should have correct shape" + sip_model_crop = sip_model_image.crop([1, 2, 3, 4]) + assert sip_model_crop.shape == (29, 15), "cropped model image should have correct shape" + + sip_model_crop.flux_density_to_flux() + assert torch.all( + sip_model_crop.data >= 0 + ), "cropped model image data should be non-negative after flux density to flux conversion" + assert torch.all( + sip_model_crop.variance >= 0 + ), "cropped model image variance should be non-negative after flux density to flux conversion" + def test_sip_image_wcs_roundtrip(sip_target): """ From 0af590363b92fc0696bd38ed195c48997b26b15c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 22 Jul 2025 14:08:48 -0400 Subject: [PATCH 083/191] fix sip image reduce and crop --- astrophot/image/mixins/sip_mixin.py | 24 ------------------------ astrophot/image/sip_image.py | 16 ++++++++-------- 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 498f17f2..63b27c1b 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -195,27 +195,3 @@ def load(self, filename: str, hduext=0): self.sipBP[key] = hdulist[hduext].header[f"BP_{i}_{j}"] self.update_distortion_model() return hdulist - - def reduce(self, scale, **kwargs): - MS = self.data.shape[0] // scale - NS = self.data.shape[1] // scale - - return super().reduce( - scale=scale, - pixel_area_map=( - self.pixel_area_map[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .sum(axis=(1, 3)) - ), - distortion_ij=( - self.distortion_ij[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .mean(axis=(1, 3)) - ), - distortion_IJ=( - self.distortion_IJ[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .mean(axis=(1, 3)) - ), - **kwargs, - ) diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index 0f46612e..a9ad3114 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -32,8 +32,8 @@ def crop(self, pixels, **kwargs): ) kwargs = { "pixel_area_map": self.pixel_area_map[crop], - "distortion_ij": self.distortion_ij[crop], - "distortion_IJ": self.distortion_IJ[crop], + "distortion_ij": self.distortion_ij[:, crop[0], crop[1]], + "distortion_IJ": self.distortion_IJ[:, crop[0], crop[1]], **kwargs, } return super().crop(pixels, **kwargs) @@ -68,14 +68,14 @@ def reduce(self, scale: int, **kwargs): .sum(axis=(1, 3)) ), "distortion_ij": ( - self.distortion_ij[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .mean(axis=(1, 3)) + self.distortion_ij[:, : MS * scale, : NS * scale] + .reshape(2, MS, scale, NS, scale) + .mean(axis=(2, 4)) ), "distortion_IJ": ( - self.distortion_IJ[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .mean(axis=(1, 3)) + self.distortion_IJ[:, : MS * scale, : NS * scale] + .reshape(2, MS, scale, NS, scale) + .mean(axis=(2, 4)) ), **kwargs, } From 286d271b23099508ccf63147e4d3aaecd13c48ee Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 22 Jul 2025 14:08:59 -0400 Subject: [PATCH 084/191] fix test --- tests/test_sip_image.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py index d3971fd4..c55bfbd4 100644 --- a/tests/test_sip_image.py +++ b/tests/test_sip_image.py @@ -85,13 +85,10 @@ def test_sip_image_creation(sip_target): sip_model_crop = sip_model_image.crop([1, 2, 3, 4]) assert sip_model_crop.shape == (29, 15), "cropped model image should have correct shape" - sip_model_crop.flux_density_to_flux() + sip_model_crop.fluxdensity_to_flux() assert torch.all( sip_model_crop.data >= 0 ), "cropped model image data should be non-negative after flux density to flux conversion" - assert torch.all( - sip_model_crop.variance >= 0 - ), "cropped model image variance should be non-negative after flux density to flux conversion" def test_sip_image_wcs_roundtrip(sip_target): From ab15b71f76288372f3061b0b2371165728861db6 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 23 Jul 2025 16:48:19 -0400 Subject: [PATCH 085/191] transposing docs to new astrophot --- astrophot/param/module.py | 8 + docs/source/coordinates.rst | 285 ++++++---------------- docs/source/prebuilt/segmap_models_fit.py | 117 +++------ docs/source/prebuilt/single_model_fit.py | 96 ++------ 4 files changed, 148 insertions(+), 358 deletions(-) diff --git a/astrophot/param/module.py b/astrophot/param/module.py index e009d66c..a25ae581 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -39,6 +39,14 @@ def build_params_array_names(self): names.append(f"{param.name}_{i}") return names + def build_params_array_units(self): + units = [] + for param in self.dynamic_params: + numel = max(1, np.prod(param.shape)) + for _ in range(numel): + units.append(param.unit) + return units + def fill_dynamic_value_uncertainties(self, uncertainty): if self.active: raise ActiveStateError(f"Cannot fill dynamic values when Module {self.name} is active") diff --git a/docs/source/coordinates.rst b/docs/source/coordinates.rst index f87377c7..95c22907 100644 --- a/docs/source/coordinates.rst +++ b/docs/source/coordinates.rst @@ -6,228 +6,103 @@ Coordinate systems in astronomy can be complicated, AstroPhot is no different. Here we explain how coordinate systems are handled to help you avoid possible pitfalls. -Basics ------- +For the most part, AstroPhot follows the FITS standard for coordinates, though +limited to the types of images that AstroPhot can model. -There are three main coordinate systems to think about. +Three Coordinate Systems +------------------------ + +There are three coordinate systems to think about. #. ``world`` coordinates are the classic (RA, DEC) that many astronomical sources are represented in. These should always be used in degree units as far as AstroPhot is concerned. -#. ``plane`` coordinates are the tangent plane on which AstroPhot - performs its calculations. Working on a plane makes everything - linear and does not introduce a noticeable effect for small enough - images. In the tangent plane everything should be represented in - arcsecond units. -#. ``pixel`` coordinates are specific to each image, they start at - (0,0) in the center of the [0,0] indexed pixel. These are - effectively unitless, a step of 1 in pixel coordinates is the same - as changing an index by 1. Though image array indexing is flipped - so pixel coordinate (3,10) represents the center of the index - [10,3] pixel. It is a convention for most images that the first - axis indexes vertically and the second axis indexis horizontally, - if this is not the case for your images you can apply a transpose - before passing the data to AstroPhot. Also, in the pixel coordinate - system the values are represented by floating point numbers and so - (1.3,2.8) is a valid pixel coordinate that is just partway between - pixel centers. +#. ``plane`` coordinates are the tangent plane on which AstroPhot performs its + calculations. Working on a plane makes everything linear and does not + introduce a noticeable projection effect for small enough images. In the + tangent plane everything should be represented in arcsecond units. +#. ``pixel`` coordinates are specific to each image, they start at (0,0) in the + center of the [0,0] indexed pixel. These are effectively unitless, a step of + 1 in pixel coordinates is the same as changing an index by 1. AstroPhot + adopts an indexing scheme standard to FITS files meaning the pixel coordinate + (5,9) corresponds to the pixel indexed at [5,9]. Normally for numpy arrays + and PyTorch tensors, the indexing would be flipped as [9,5] so AstroPhot + applies a transpose on any image it receives in an Image object. Also, in + the pixel coordinate system the values are represented by floating point + numbers, so (1.3,2.8) is a valid pixel coordinate that is just partway + between pixel centers. Transformations exist in AstroPhot for converting ``world`` to/from ``plane`` and for converting ``plane`` to/from ``pixel``. The best way -to interface with these is to use the ``image.window.world_to_plane`` +to interface with these is to use the ``image.world_to_plane`` for any AstroPhot image object (you may similarly swap ``world``, ``plane``, and ``pixel``). One gotcha to keep in mind with regards to ``world_to_plane`` and -``plane_to_world`` is that AstroPhot needs to know the reference -(RA_0, DEC_0) where the tangent plane meets with the celestial -sphere. You can set this by including ``reference_radec = (RA_0, -DEC_0)`` as an argument in an image you create. If a reference is not -given, then one will be assumed based on available information. Note -that if you are doing simultaneous multi-image analysis you should -ensure that the ``reference_radec`` is same for all images! +``plane_to_world`` is that AstroPhot needs to know the reference (RA, DEC) where +the tangent plane meets with the celestial sphere. AstroPhot now adopts the FITS +standard for this using ``image.crval`` to store the reference world +coordinates. Note that if you are doing simultaneous multi-image analysis you +should ensure that the ``crval`` is same for all images! Projection Systems ------------------ -AstroPhot currently implements three coordinate reference systems: -Gnomonic, Orthographic, and Steriographic. The default projection is -the Gnomonic, which represents the perspective of an observer at the -center of a sphere projected onto a plane. For the exact -implementation by AstroPhot see the `Wolfram MathWorld -`_ page. - -On small scales the choice of projection doesn't matter. For very -large images the effect may be detectable, though it is likely -insignificant compared to other effects in an image. Just like the -``reference_radec`` you can choose your projection system in an image -you construct by passing ``projection = 'gnomonic'`` as an argument. -Just like with the reference coordinate, for images to "talk" to each -other they should have the same projection. - -If you really want to change the projection after an image has -been created (warning, this may cause serious missalignments between -images), you can force it to update with:: - - image.window.projection = 'steriographic' - -which would change the projection to steriographic. The image won't -recompute its position in the new projection system, it will just use -new equations going forward. Hence the potential to seriously mess up -your image alignment if this is done after some calculations have -already been performed. - -Talking to the world --------------------- - -If you have images with WCS information then you will want to use this -to map images onto the same tangent plane. Often this will take the -form of information in a FITS file, which can easily be accessed using -Astropy like:: - - from astropy.io import fits - from astropy.wcs import WCS - hdu = fits.open("myimage.fits") - data = hdu[0].data - wcs = WCS(hdu[0].header) - -That is somewhat described in the basics section, however there are -some more features you can take advantage of. When creating an image -in AstroPhot, you need to tell it some basic properties so that the -image knows how to place itself in the tangent plane. Using the -Astropy WCS object above you can recover the reference coordinates -of the image in (RA, DEC), for an example Astropy wcs object you could -accomplish this with: - - ra, dec = wcs.wcs.crval - -meaning that you know the world position of the reference RA, Dec -of the image WCS. To have -AstroPhot place the image at the right location in the tangent plane -you can use the ``wcs`` argument when constructing the image:: - - image = ap.image.Target_Image( - data = data, - reference_radec = (ra, dec), - wcs = wcs, - ) - -AstroPhot will set the reference RA, DEC to these coordinates and also -set the image in the correct position. A more explicit alternative is -to just say what the reference coordinate should be. That would look -something like:: - - image = ap.image.Target_Image( - data = data, - pixelscale = pixelscale, - reference_radec = (ra,dec), - reference_imagexy = (x, y), - ) - -which uniquely defines the position of the image in the coordinate -system. Remember that the ``reference_radec`` should be the same for -all images in a multi-image analysis, while ``reference_imagexy`` -specifies the position of a particular image. Another similar option is to set -``center_radec`` like:: - - image = ap.image.Target_Image( - data = data, - pixelscale = pixelscale, - reference_radec = (ra,dec), - center_radec = (c_ra, c_dec), - ) - -You may also have a catalogue of objects that you would like to -project into the image. The easiest way to do this if you already have -an image object is to call the ``world_to_plane`` functions -manually. Say for example that you know the object position as an -Astropy ``SkyCoord`` object, and you want to use this to set the -center position of a sersic model. That would look like:: - - model = ap.models.AstroPhot_Model( - name = "knowloc", - model_type = "sersic galaxy model", - target = image, - parameters = { - "center": image.window.world_to_plane(obj_pos.ra.deg, obj_pos.dec.deg), - } - ) - -Which will start the object at the correct position in the image given -its world coordinates. As you can see, the ``center`` and in fact all -parameters for AstroPhot models are defined in the tangent plane. This -means that if you have optimized a model and you would like to present -its position in world coordinates that can be compared with other -sources, you will need to do the opposite operation:: - - world_position = image.window.plane_to_world(model["center"].value) - -That should assign ``world_position`` the coordinates in RA and DEC -(degrees), assuming that you initialized the image with a WCS or by -other means ensured that the world coordinates being used are -correct. If you never gave AstroPhot the information it needs, then it -likely assumed a reference position of (0,0) in the world coordinate -system. +AstroPhot currently only supports the Gnomonic projection system. This means +that the tangent plane is defined as "contacting" the celestial sphere at a +single point, the reference (crval) coordinates. The tangent plane coordinates +correspond to the world coordinates as viewed from the center of the celestial +sphere. This is the most common projection system used in astronomy and commonly +used in the FITS standard. It is also the one that Astropy usually uses for its +WCS objects. Coordinate reference points --------------------------- -As stated earlier, there are essentially three coordinate systems in -AstroPhot: ``world``, ``plane``, and ``pixel``. To uniquely specify -the transformation from ``world`` to ``plane`` AstroPhot keeps track -of two vectors: ``reference_radec`` and ``reference_planexy``. These -variables are stored in all ``Image_Header`` objects and essentially -pin down the mapping such that one coordinate will get mapped to the -other. All other coordinates follow from the projection system assumed -(i.e., Gnomonic). It is possible to specify these variables directly -when constructing an image, or implicitly if you give some other -relevant information (e.g., an Astropy WCS). AstroPhot Window objects -also keep track of two more vectors: ``reference_imageij`` and -``reference_imagexy``. These variables control where an image is -placed in the tangent plane and represent a fixed point between the -pixel coordinates and the tangent plane coordinates. If your pixel -scale matrix includes a rotation then the rotation will be performed -about this position. - -All together, these reference positions define how pixels are mapped -in AstroPhot. This level of generality is overkill for analyzing a -single image, so AstroPhot makes reasonable assumptions about these -reference points if you don't specify them all. This makes it easy to -do single image analysis without thinking too much about the -coordinate systems. However, for multi-band or multi-epoch imaging it -is critical to be absolutely clear about these coordinate -transformations so that images can be aligned properly on the sky. As -an intuitive explanation, think of ``reference_radec`` and -``reference_planexy`` as defining the coordinate system that is shared -between images, while ``reference_imageij`` and ``reference_imagexy`` -specify where a single image is located. As such, in multi-image -analysis if you wish to use world coordinates, you should explitcitly -pass the same ``reference_radec`` and ``reference_planexy`` to every -image so that the same coordinate system is defined for all of them -(the same tangent plane at the same point on the celestial sphere). If -you aren't going to interact with world coordinates, you can ignore -those reference points entirely and it won't affect your images. - -Below is a summary of the reference coordinates and their meaning: - -#. ``reference_radec`` world coordinates on the celestial sphere (RA, - DEC in degrees) where the tangent plane makes contact. This should - be the same for every image in a multi-image analysis. -#. ``reference_planexy`` tangent plane coordinates (arcsec) where it - makes contact with the celesial sphere. This should typically be - (0,0) though that is not stricktly enforced (it is assumed if not - given). This reference coordinate should be the same for all - images in a multi-image analysis. -#. ``reference_imageij`` pixel coordinates about which the image is - defined. For example in an Astropy WCS object the wcs.wcs.crpix - array gives the pixel coordinate reference point for which the - world coordinate mapping (wcs.wcs.crval) is defined. One may think - of the referenced pixel location as being "pinned" to the tangent - plane. This may be different for each image in a multi-image - analysis. -#. ``reference_imagexy`` tangent plane coordinates (arcsec) about - which the image is defined. This is the pivot point about which the - pixelscale matrix operates, therefore if the pixelscale matrix - defines a rotation then this is the coordinate about which the - rotation will be performed. This may be different for each image in - a multi-image analysis. +There are three coordinate systems in AstroPhot: ``world``, ``plane``, and +``pixel``. AstroPhot tracks a reference point in each coordinate system used to +connect each system. Below is a summary of the reference coordinates and their +meaning: + +#. ``crval`` world coordinates on the celestial sphere (RA, DEC in degrees) + where the tangent plane makes contact. crval always contacts the tangent + plane at (0,0) in the tangent plane coordinates. This should be the same for + every image in a multi-image analysis. +#. ``crtan`` tangent plane coordinates (arcsec) where the pixel grid makes + contact with the tangent plane. This is the pivot point about which the + pixelscale matrix operates, therefore if the pixelscale matrix defines a + rotation then this is the coordinate about which the rotation will be + performed. This may be different for each image in a multi-image analysis. +#. ``crpix`` pixel coordinates where the pixel grid makes contact with the + tangent plane. One may think of the referenced pixel location as being + "pinned" to the tangent plane. This may be different for each image in a + multi-image analysis. + +Thinking of the celestial sphere, tangent plane, and pixel grid as three +interconnected coordinate systems is crucial for understanding how AstroPhot +operates in a multi-image context. While the transformations may get +complicated, try to remember these contact points: + +* ``crval`` is in the world coordinates and contacts the tangent plane at + (0,0) in the tangent plane coordinates. +* ``crtan`` is in the tangent plane coordinates and contacts the pixel grid at + ``crpix`` in the pixel coordinates. + +What parts go where? +-------------------- + +Since AstroPhot works in multiple reference frames it can be easy to get lost. +Keep these basics in mind. The world coordinates are where catalogues exist, so +this is the coordinate system you should use when interfacing with external +resources. The tangent plane coordinates are where the models exist. So when +creating a model and considering factors like the position angle, you should +think in the tangent plane coordinates. The pixel coordinates are where the data +exists. So when you create a TargetImage object it is in pixel coordinates, but +so too is a ModelImage object since it is intended to be compared against a +TargetImage. This means that any distortions in the TargetImage (i.e. SIP +distortions) will show up in the ModelImage, but aren't actually part of the +model. This can manifest for example as a round Gaussian model looking +elliptical in its ModelImage because there is a skew in the CD matrix in the +TargetImage it is matching. In general this is a good thing because we care +about how our models look on the sky (tangent plane), not strictly how they look +in the pixel grid. diff --git a/docs/source/prebuilt/segmap_models_fit.py b/docs/source/prebuilt/segmap_models_fit.py index dd9f0e61..5b481644 100644 --- a/docs/source/prebuilt/segmap_models_fit.py +++ b/docs/source/prebuilt/segmap_models_fit.py @@ -25,10 +25,7 @@ name = "field_name" # used for saving files target_file = ".fits" # can be a numpy array instead segmap_file = ".fits" # can be a numpy array instead -mask_file = None # ".fits" # can be a numpy array instead psf_file = None # ".fits" # can be a numpy array instead -variance_file = None # ".fits" # or numpy array or "auto" -pixelscale = 0.1 # arcsec/pixel zeropoint = 22.5 # mag initial_sky = None # If None, sky will be estimated. Recommended to set manually sky_locked = False @@ -46,8 +43,6 @@ save_residual_image = True target_hdu = 0 # FITS file index for image data segmap_hdu = 0 -mask_hdu = 0 -variance_hdu = 0 psf_hdu = 0 window_expand_scale = 2 # Windows from segmap will be expanded by this factor window_expand_border = 10 # Windows from segmap will be expanded by this number of pixels @@ -58,11 +53,11 @@ # load target and segmentation map # --------------------------------------------------------------------- print("loading target and segmentation map") -if isinstance(target_file, str): - hdu = fits.open(target_file) - target_data = np.array(hdu[target_hdu].data, dtype=np.float64) -else: - target_data = target_file +target = ap.TargetImage( + filename=target_file, + hduext=target_hdu, + zeropoint=zeropoint, +) if isinstance(segmap_file, str): hdu = fits.open(segmap_file) @@ -70,53 +65,18 @@ else: segmap_data = segmap_file -# load mask, variance, and psf +# load psf # --------------------------------------------------------------------- -# Mask -if isinstance(mask_file, str): - print("loading mask") - hdu = fits.open(mask_file) - mask_data = np.array(hdu[mask_hdu].data, dtype=bool) -elif mask_file is None: - mask_data = None -else: - mask_data = mask_file -# Variance -if isinstance(variance_file, str) and not variance_file == "auto": - print("loading variance") - hdu = fits.open(variance_file) - variance_data = np.array(hdu[variance_hdu].data, dtype=np.float64) -elif variance_file is None: - variance_data = None -else: - variance_data = variance_file # PSF if isinstance(psf_file, str): print("loading psf") hdu = fits.open(psf_file) psf_data = np.array(hdu[psf_hdu].data, dtype=np.float64) - psf = ap.image.PSF_Image( - data=psf_data, - pixelscale=pixelscale, - ) + target.psf = target.psf_image(data=psf_data) elif psf_file is None: psf = None else: - psf = ap.image.PSF_Image( - data=psf_file, - pixelscale=pixelscale, - ) - -# Create target object -# --------------------------------------------------------------------- -target = ap.image.Target_Image( - data=target_data, - pixelscale=pixelscale, - zeropoint=zeropoint, - mask=mask_data, - psf=psf, - variance=variance_data, -) + target.psf = target.psf_image(data=psf_file) # Initialization from segmap # --------------------------------------------------------------------- @@ -126,23 +86,21 @@ windows = ap.utils.initialize.filter_windows( windows, **segmap_filter, - image=target_data, + image=target, ) for ids in segmap_filter_ids: del windows[ids] -centers = ap.utils.initialize.centroids_from_segmentation_map(segmap_data, target_data) +centers = ap.utils.initialize.centroids_from_segmentation_map(segmap_data, target) if "galaxy" in model_type: - PAs = ap.utils.initialize.PA_from_segmentation_map(segmap_data, target_data, centers) - qs = ap.utils.initialize.q_from_segmentation_map(segmap_data, target_data, centers, PAs) + PAs = ap.utils.initialize.PA_from_segmentation_map(segmap_data, target, centers) + qs = ap.utils.initialize.q_from_segmentation_map(segmap_data, target, centers) else: PAs = None qs = None init_params = {} for window in windows: - init_params[window] = { - "center": np.array(centers[window]) * pixelscale, - } + init_params[window] = {"center": centers[window]} if "galaxy" in model_type: init_params[window]["PA"] = PAs[window] init_params[window]["q"] = qs[window] @@ -153,14 +111,15 @@ print("Creating models") models = [] models.append( - ap.models.AstroPhot_Model( + ap.Model( name="sky", model_type=sky_model_type, target=target, - parameters={"F": initial_sky} if initial_sky is not None else {}, - locked=sky_locked, + I=initial_sky if initial_sky is not None else {}, ) ) +if sky_locked: + models[0].to_static() primary_model = None for window in windows: if primary_key is not None and window == primary_key: @@ -175,25 +134,25 @@ primary_initial_params["PA"] = PAs[window] if "q" not in primary_initial_params and qs is not None and "galaxy" in primary_model_type: primary_initial_params["q"] = qs[window] - model = ap.models.AstroPhot_Model( + model = ap.Model( name=primary_name, model_type=primary_model_type, target=target, - parameters=primary_initial_params, + **primary_initial_params, window=windows[window], ) primary_model = model else: print(window) - model = ap.models.AstroPhot_Model( + model = ap.Model( name=f"{model_type} {window}", model_type=model_type, target=target, window=windows[window], - parameters=init_params[window], + **init_params[window], ) models.append(model) -model = ap.models.AstroPhot_Model( +model = ap.Model( name=f"{name} model", model_type="group model", target=target, @@ -204,12 +163,12 @@ # --------------------------------------------------------------------- print("Initializing model") model.initialize() -print("Fitting model") +print("Fitting model round 1") result = ap.fit.Iter(model, verbose=1).fit() print("expanding windows") windows = ap.utils.initialize.scale_windows( windows, - image_shape=target_data.shape, + image=target, expand_scale=window_expand_scale, expand_border=window_expand_border, ) @@ -217,7 +176,6 @@ models[i + 1].window = windows[window] print("Fitting round 2") result = ap.fit.Iter(model, verbose=1).fit() -# result.update_uncertainty() coming soon # Report Results # ---------------------------------------------------------------------- @@ -225,36 +183,37 @@ print(models[0].parameters) if not primary_model is None: - print(primary_model.parameters) - totflux = primary_model.total_flux().detach().cpu().numpy() - print(f"Total Magnitude: {zeropoint - 2.5 * np.log10(totflux)}") + print(primary_model) + totmag = primary_model.total_magnitude().detach().cpu().numpy() + print(f"Total Magnitude: {totmag}") if hasattr(primary_model, "radial_model"): fig, ax = plt.subplots(figsize=(8, 8)) ap.plots.radial_light_profile(fig, ax, primary_model) plt.savefig(f"{name}_radial_light_profile.jpg") plt.close() + with open(f"{name}_primary_params.csv", "w") as f: + f.write("Name,Total Magnitude," + ",".join(primary_model.build_params_array_names()) + "\n") + f.write("string,mag," + ",".join(primary_model.build_params_array_units()) + "\n") + params = primary_model.build_params_array().detach().cpu().numpy() + f.write(",".join([str(x) for x in params]) + "\n") if print_all_models: + print(model) segmap_params = [] for segmodel in models[1:]: if segmodel.name == primary_name: continue - print(segmodel.parameters) - totflux = segmodel.total_flux().detach().cpu().numpy() + totmag = segmodel.total_magnitude().detach().cpu().numpy() segmap_params.append( - [segmodel.name, totflux] - + list(segmodel.parameters.vector_values().detach().cpu().numpy()) + [segmodel.name, totmag] + list(segmodel.build_params_array().detach().cpu().numpy()) ) with open(f"{name}_segmap_params.csv", "w") as f: - f.write("Name,Total Flux," + ",".join(segmodel.parameters.vector_names()) + "\n") - flat_params = segmodel.parameters.flat(False, False).values() - f.write( - "string,mag," + ",".join(p.units for p in flat_params for _ in range(p.size)) + "\n" - ) + f.write("Name,Total Magnitude," + ",".join(segmodel.build_params_array_names()) + "\n") + f.write("string,mag," + ",".join(segmodel.build_params_array_units()) + "\n") for row in segmap_params: f.write(",".join([str(x) for x in row]) + "\n") -model.save(f"{name}_parameters.yaml") +model.save_state(f"{name}_parameters.hdf5") if save_model_image: model().save(f"{name}_model_image.fits") fig, ax = plt.subplots() diff --git a/docs/source/prebuilt/single_model_fit.py b/docs/source/prebuilt/single_model_fit.py index acdfb17e..6529b011 100644 --- a/docs/source/prebuilt/single_model_fit.py +++ b/docs/source/prebuilt/single_model_fit.py @@ -22,13 +22,10 @@ ###################################################################### name = "object_name" # used for saving files target_file = ".fits" # can be a numpy array instead -mask_file = None # ".fits" # can be a numpy array instead psf_file = None # ".fits" # can be a numpy array instead -variance_file = None # ".fits" # or numpy array or "auto" -pixelscale = 0.1 # arcsec/pixel zeropoint = 22.5 # mag initial_params = None # e.g. {"center": [3, 3], "q": {"value": 0.8, "locked": True}} -window = None # None to fit whole image, otherwise ((xmin,xmax),(ymin,ymax)) pixels +window = None # None to fit whole image, otherwise (xmin,xmax,ymin,ymax) pixels initial_sky = None # If None, sky will be estimated sky_locked = False model_type = "sersic galaxy model" @@ -38,8 +35,6 @@ save_residual_image = True save_covariance_matrix = True target_hdu = 0 # FITS file index for image data -mask_hdu = 0 -variance_hdu = 0 psf_hdu = 0 sky_model_type = "flat sky model" ###################################################################### @@ -47,79 +42,43 @@ # load target # --------------------------------------------------------------------- print("loading target") -if isinstance(target_file, str): - hdu = fits.open(target_file) - target_data = np.array(hdu[target_hdu].data, dtype=np.float64) -else: - target_data = target_file +target = ap.TargetImage( + filename=target_file, + hduext=target_hdu, + zeropoint=zeropoint, +) -# load mask, variance, and psf -# --------------------------------------------------------------------- -# Mask -if isinstance(mask_file, str): - print("loading mask") - hdu = fits.open(mask_file) - mask_data = np.array(hdu[mask_hdu].data, dtype=bool) -elif mask_file is None: - mask_data = None -else: - mask_data = mask_file -# Variance -if isinstance(variance_file, str) and not variance_file == "auto": - print("loading variance") - hdu = fits.open(variance_file) - variance_data = np.array(hdu[variance_hdu].data, dtype=np.float64) -elif variance_file is None: - variance_data = None -else: - variance_data = variance_file # PSF if isinstance(psf_file, str): print("loading psf") hdu = fits.open(psf_file) psf_data = np.array(hdu[psf_hdu].data, dtype=np.float64) - psf = ap.image.PSF_Image( - data=psf_data, - pixelscale=pixelscale, - ) + target.psf = target.psf_image(data=psf_data) elif psf_file is None: psf = None else: - psf = ap.image.PSF_Image( - data=psf_file, - pixelscale=pixelscale, - ) - -# Create target object -# --------------------------------------------------------------------- -target = ap.image.Target_Image( - data=target_data, - pixelscale=pixelscale, - zeropoint=zeropoint, - mask=mask_data, - psf=psf, - variance=variance_data, -) + target.psf = target.psf_image(data=psf_file) # Create Model # --------------------------------------------------------------------- -model_object = ap.models.AstroPhot_Model( +model_object = ap.Model( name=name, model_type=model_type, target=target, - psf_mode="full" if psf_file is not None else "none", - parameters=initial_params, + psf_convolve=True if psf_file is not None else False, + **initial_params, window=window, ) -model_sky = ap.models.AstroPhot_Model( +model_sky = ap.Model( name="sky", model_type=sky_model_type, target=target, - parameters={"F": initial_sky} if initial_sky is not None else {}, + I=initial_sky if initial_sky is not None else {}, window=window, - locked=sky_locked, ) -model = ap.models.AstroPhot_Model( +if sky_locked: + model_sky.to_static() +model = ap.Model( name="astrophot model", model_type="group model", target=target, @@ -132,26 +91,15 @@ model.initialize() print("Fitting model") result = ap.fit.LM(model, verbose=1).fit() -print("Update uncertainty") -result.update_uncertainty() # Report Results # ---------------------------------------------------------------------- -if not sky_locked: - print(model_sky.parameters) -print(model_object.parameters) -totflux = model_object.total_flux().detach().cpu().numpy() -try: - totflux_err = model_object.total_flux_uncertainty().detach().cpu().numpy() -except AttributeError: - print( - "sorry, total flux uncertainty not available yet for this model. You are welcome to contribute! :)" - ) - totflux_err = 0 -print( - f"Total Magnitude: {zeropoint - 2.5 * np.log10(totflux)} +- {2.5 * totflux_err / (totflux * np.log(10))}" -) -model.save(f"{name}_parameters.yaml") +print(model) +totmag = model_object.total_magnitude().detach().cpu().numpy() +totmag_err = model_object.total_magnitude_uncertainty().detach().cpu().numpy() +print(f"Total Magnitude: {totmag} +- {totmag_err}") + +model.save_state(f"{name}_parameters.hdf5") if save_model_image: model().save(f"{name}_model_image.fits") fig, ax = plt.subplots() From 5101c0936526d7dd1daed43ce6942c6c5199837e Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 23 Jul 2025 18:34:03 -0400 Subject: [PATCH 086/191] change AP_config to just config --- astrophot/__init__.py | 16 ++--- astrophot/{AP_config.py => config.py} | 31 +++++----- astrophot/fit/base.py | 6 +- astrophot/fit/gradient.py | 12 ++-- astrophot/fit/iterative.py | 32 +++++----- astrophot/fit/lm.py | 26 ++++---- astrophot/fit/mhmcmc.py | 6 +- astrophot/fit/minifit.py | 4 +- astrophot/fit/scipy_fit.py | 16 +++-- astrophot/image/image_object.py | 62 ++++++++----------- astrophot/image/jacobian_image.py | 1 - astrophot/image/mixins/data_mixin.py | 10 +-- astrophot/image/psf_image.py | 6 +- astrophot/image/target_image.py | 8 +-- astrophot/models/_shared_methods.py | 15 +---- astrophot/models/base.py | 10 ++- astrophot/models/basis.py | 4 +- astrophot/models/flatsky.py | 1 - astrophot/models/group_model_object.py | 6 +- astrophot/models/mixins/sample.py | 4 +- astrophot/models/mixins/transform.py | 4 +- astrophot/models/model_object.py | 4 +- astrophot/plots/image.py | 4 +- astrophot/plots/profile.py | 14 ++--- astrophot/utils/initialize/__init__.py | 3 +- astrophot/utils/initialize/construct_psf.py | 67 --------------------- astrophot/utils/optimization.py | 6 +- docs/source/prebuilt/segmap_models_fit.py | 2 +- docs/source/tutorials/GettingStarted.ipynb | 24 ++++---- 29 files changed, 153 insertions(+), 251 deletions(-) rename astrophot/{AP_config.py => config.py} (76%) diff --git a/astrophot/__init__.py b/astrophot/__init__.py index e3df93c7..345a5ce4 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -1,7 +1,7 @@ import argparse import requests import torch -from . import models, plots, utils, fit, AP_config +from . import config, models, plots, utils, fit from .param import forward, Param, Module from .image import ( @@ -40,7 +40,7 @@ def run_from_terminal() -> None: Running from terminal no longer supported. This is only used for convenience to download the tutorials. """ - AP_config.ap_logger.debug("running from the terminal, not sure if it will catch me.") + config.logger.debug("running from the terminal, not sure if it will catch me.") parser = argparse.ArgumentParser( prog="astrophot", description="Fast and flexible astronomical image photometry package. For the documentation go to: https://astrophot.readthedocs.io", @@ -96,16 +96,16 @@ def run_from_terminal() -> None: args = parser.parse_args() if args.log is not None: - AP_config.set_logging_output( + config.set_logging_output( stdout=not args.q, filename=None if args.log == "none" else args.log ) elif args.q: - AP_config.set_logging_output(stdout=not args.q, filename="AstroPhot.log") + config.set_logging_output(stdout=not args.q, filename="AstroPhot.log") if args.dtype is not None: - AP_config.dtype = torch.float64 if args.dtype == "float64" else torch.float32 + config.DTYPE = torch.float64 if args.dtype == "float64" else torch.float32 if args.device is not None: - AP_config.device = "cpu" if args.device == "cpu" else "cuda:0" + config.DEVICE = "cpu" if args.device == "cpu" else "cuda:0" if args.filename is None: raise RuntimeError( @@ -133,7 +133,7 @@ def run_from_terminal() -> None: f"WARNING: couldn't find tutorial: {url[url.rfind('/')+1:]} check internet connection" ) - AP_config.ap_logger.info("collected the tutorials") + config.logger.info("collected the tutorials") else: raise ValueError(f"Unrecognized request") @@ -159,7 +159,7 @@ def run_from_terminal() -> None: "forward", "Param", "Module", - "AP_config", + "config", "run_from_terminal", "__version__", "__author__", diff --git a/astrophot/AP_config.py b/astrophot/config.py similarity index 76% rename from astrophot/AP_config.py rename to astrophot/config.py index 722ccc2c..3f11da8c 100644 --- a/astrophot/AP_config.py +++ b/astrophot/config.py @@ -2,29 +2,28 @@ import logging import torch -__all__ = ["ap_dtype", "ap_device", "ap_logger", "set_logging_output"] +__all__ = ["DTYPE", "DEVICE", "logger", "set_logging_output"] -ap_dtype = torch.float64 -ap_device = "cuda:0" if torch.cuda.is_available() else "cpu" -ap_verbose = 0 +DTYPE = torch.float64 +DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu" logging.basicConfig( filename="AstroPhot.log", level=logging.INFO, format="%(asctime)s:%(levelname)s: %(message)s", ) -ap_logger = logging.getLogger() +logger = logging.getLogger() out_handler = logging.StreamHandler(sys.stdout) out_handler.setLevel(logging.INFO) out_handler.setFormatter(logging.Formatter("%(message)s")) -ap_logger.addHandler(out_handler) +logger.addHandler(out_handler) def set_logging_output(stdout=True, filename=None, **kwargs): """ Change the logging system for AstroPhot. Here you can set whether output prints to screen or to a logging file. - This function will remove all handlers from the current logger in ap_logger, + This function will remove all handlers from the current logger in logger, then add new handlers based on the input to the function. Parameters: @@ -39,11 +38,11 @@ def set_logging_output(stdout=True, filename=None, **kwargs): """ hi = 0 - while hi < len(ap_logger.handlers): - if isinstance(ap_logger.handlers[hi], logging.StreamHandler): - ap_logger.removeHandler(ap_logger.handlers[hi]) - elif isinstance(ap_logger.handlers[hi], logging.FileHandler): - ap_logger.removeHandler(ap_logger.handlers[hi]) + while hi < len(logger.handlers): + if isinstance(logger.handlers[hi], logging.StreamHandler): + logger.removeHandler(logger.handlers[hi]) + elif isinstance(logger.handlers[hi], logging.FileHandler): + logger.removeHandler(logger.handlers[hi]) else: hi += 1 @@ -51,8 +50,8 @@ def set_logging_output(stdout=True, filename=None, **kwargs): out_handler = logging.StreamHandler(sys.stdout) out_handler.setLevel(kwargs.get("stdout_level", logging.INFO)) out_handler.setFormatter(kwargs.get("stdout_formatter", logging.Formatter("%(message)s"))) - ap_logger.addHandler(out_handler) - ap_logger.debug("logging now going to stdout") + logger.addHandler(out_handler) + logger.debug("logging now going to stdout") if filename is not None: out_handler = logging.FileHandler(filename) out_handler.setLevel(kwargs.get("filename_level", logging.INFO)) @@ -62,5 +61,5 @@ def set_logging_output(stdout=True, filename=None, **kwargs): logging.Formatter("%(asctime)s:%(levelname)s: %(message)s"), ) ) - ap_logger.addHandler(out_handler) - ap_logger.debug("logging now going to %s" % filename) + logger.addHandler(out_handler) + logger.debug("logging now going to %s" % filename) diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index aea7a22b..98e175b5 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -5,7 +5,7 @@ from scipy.optimize import minimize from scipy.special import gammainc -from .. import AP_config +from .. import config from ..models import Model from ..image import Window from ..param import ValidContext @@ -118,7 +118,7 @@ def res(self) -> np.ndarray: """ N = np.isfinite(self.loss_history) if np.sum(N) == 0: - AP_config.ap_logger.warning( + config.logger.warning( "Getting optimizer res with no real loss history, using current state" ) return self.current_state.detach().cpu().numpy() @@ -154,4 +154,4 @@ def _f(x: float, nu: int) -> float: if res.success: return res.x[0] - raise RuntimeError(f"Unable to compute Chi^2 contour for ndf: {ndf}") + raise RuntimeError(f"Unable to compute Chi^2 contour for n params: {n_params}") diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 743713b0..b366b846 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -5,7 +5,7 @@ import numpy as np from .base import BaseOptimizer -from .. import AP_config +from .. import config from ..models import Model __all__ = ["Grad"] @@ -115,11 +115,9 @@ def step(self) -> None: self.iteration % int(self.max_iter / self.report_freq) == 0 ) or self.iteration == self.max_iter: if self.verbose > 0: - AP_config.ap_logger.info( - f"iter: {self.iteration}, posterior density: {loss.item():.6e}" - ) + config.logger.info(f"iter: {self.iteration}, posterior density: {loss.item():.6e}") if self.verbose > 1: - AP_config.ap_logger.info(f"gradient: {self.current_state.grad}") + config.logger.info(f"gradient: {self.current_state.grad}") self.optimizer.step() def fit(self) -> BaseOptimizer: @@ -153,10 +151,10 @@ def fit(self) -> BaseOptimizer: # Set the model parameters to the best values from the fit and clear any previous model sampling self.model.fill_dynamic_values( - torch.tensor(self.res(), dtype=AP_config.ap_dtype, device=AP_config.ap_device) + torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if self.verbose > 1: - AP_config.ap_logger.info( + config.logger.info( f"Grad Fitting complete in {time() - start_fit} sec with message: {self.message}" ) return self diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 17ef9494..4003ca1b 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -10,7 +10,7 @@ from .base import BaseOptimizer from ..models import Model from .lm import LM -from .. import AP_config +from .. import config __all__ = [ "Iter", @@ -78,7 +78,7 @@ def sub_step(self, model: Model, update_uncertainty=False) -> None: res = LM(model, **self.lm_kwargs).fit(update_uncertainty=update_uncertainty) self.Y += model() if self.verbose > 1: - AP_config.ap_logger.info(res.message) + config.logger.info(res.message) model.target = initial_values def step(self) -> None: @@ -86,12 +86,12 @@ def step(self) -> None: Perform a single iteration of optimization. """ if self.verbose > 0: - AP_config.ap_logger.info("--------iter-------") + config.logger.info("--------iter-------") # Fit each model individually for model in self.model.models: if self.verbose > 0: - AP_config.ap_logger.info(model.name) + config.logger.info(model.name) self.sub_step(model) # Update the current state self.current_state = self.model.build_params_array() @@ -99,7 +99,7 @@ def step(self) -> None: # Update the loss value with torch.no_grad(): if self.verbose > 0: - AP_config.ap_logger.info("Update Chi^2 with new parameters") + config.logger.info("Update Chi^2 with new parameters") self.Y = self.model(params=self.current_state) D = self.model.target[self.model.window].flatten("data") V = ( @@ -116,7 +116,7 @@ def step(self) -> None: else: loss = torch.sum(((D - self.Y.flatten("data")) ** 2 / V)) / self.ndf if self.verbose > 0: - AP_config.ap_logger.info(f"Loss: {loss.item()}") + config.logger.info(f"Loss: {loss.item()}") self.lambda_history.append(np.copy((self.current_state).detach().cpu().numpy())) self.loss_history.append(loss.item()) @@ -156,15 +156,15 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.message = self.message + "fail interrupted" self.model.fill_dynamic_values( - torch.tensor(self.res(), dtype=AP_config.ap_dtype, device=AP_config.ap_device) + torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if update_uncertainty: for model in self.model.models: if self.verbose > 1: - AP_config.ap_logger.info(model.name) + config.logger.info(model.name) self.sub_step(model, update_uncertainty=True) if self.verbose > 1: - AP_config.ap_logger.info( + config.logger.info( f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" ) @@ -227,11 +227,11 @@ def step(self): res = None if self.verbose > 0: - AP_config.ap_logger.info("--------iter-------") + config.logger.info("--------iter-------") # Loop through all the chunks while True: - chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=AP_config.ap_device) + chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=config.DEVICE) if isinstance(self.chunks, int): if len(param_ids) == 0: break @@ -270,7 +270,7 @@ def step(self): "Unrecognized chunks value, should be one of int, tuple. not: {type(self.chunks)}" ) if self.verbose > 1: - AP_config.ap_logger.info(str(chunk)) + config.logger.info(str(chunk)) del res with Param_Mask(self.model.parameters, chunk): res = LM( @@ -279,16 +279,16 @@ def step(self): **self.LM_kwargs, ).fit() if self.verbose > 0: - AP_config.ap_logger.info(f"chunk loss: {res.res_loss()}") + config.logger.info(f"chunk loss: {res.res_loss()}") if self.verbose > 1: - AP_config.ap_logger.info(f"chunk message: {res.message}") + config.logger.info(f"chunk message: {res.message}") self.loss_history.append(res.res_loss()) self.lambda_history.append( self.model.parameters.vector_representation().detach().cpu().numpy() ) if self.verbose > 0: - AP_config.ap_logger.info(f"Loss: {self.loss_history[-1]}") + config.logger.info(f"Loss: {self.loss_history[-1]}") # test for convergence if self.iteration >= 2 and ( @@ -328,7 +328,7 @@ def fit(self): self.model.parameters.vector_set_representation(self.res()) if self.verbose > 1: - AP_config.ap_logger.info( + config.logger.info( f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" ) diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 5403bb38..7b0b0fff 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -5,7 +5,7 @@ import numpy as np from .base import BaseOptimizer -from .. import AP_config +from .. import config from . import func from ..errors import OptimizeStopFail, OptimizeStopSuccess from ..param import ValidContext @@ -212,9 +212,9 @@ def __init__( # 1 / (sigma^2) kW = kwargs.get("W", None) if kW is not None: - self.W = torch.as_tensor( - kW, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ).flatten()[self.mask] + self.W = torch.as_tensor(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ + self.mask + ] elif model.target.has_variance: self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] else: @@ -252,7 +252,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: if len(self.current_state) == 0: if self.verbose > 0: - AP_config.ap_logger.warning("No parameters to optimize. Exiting fit") + config.logger.warning("No parameters to optimize. Exiting fit") self.message = "No parameters to optimize. Exiting fit" return self @@ -261,13 +261,13 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.L_history = [self.L] self.lambda_history = [self.current_state.detach().clone().cpu().numpy()] if self.verbose > 0: - AP_config.ap_logger.info( + config.logger.info( f"==Starting LM fit for '{self.model.name}' with {len(self.current_state)} dynamic parameters and {len(self.Y)} pixels==" ) for _ in range(self.max_iter): if self.verbose > 0: - AP_config.ap_logger.info(f"Chi^2/DoF: {self.loss_history[-1]:.6g}, L: {self.L:.3g}") + config.logger.info(f"Chi^2/DoF: {self.loss_history[-1]:.6g}, L: {self.L:.3g}") try: if self.fit_valid: with ValidContext(self.model): @@ -298,12 +298,12 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.current_state = res["x"].detach() except OptimizeStopFail: if self.verbose > 0: - AP_config.ap_logger.warning("Could not find step to improve Chi^2, stopping") + config.logger.warning("Could not find step to improve Chi^2, stopping") self.message = self.message + "fail. Could not find step to improve Chi^2" break except OptimizeStopSuccess as e: if self.verbose > 0: - AP_config.ap_logger.info(f"Optimization converged successfully: {e}") + config.logger.info(f"Optimization converged successfully: {e}") self.message = self.message + "success" break @@ -331,7 +331,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.message = self.message + "fail. Maximum iterations" if self.verbose > 0: - AP_config.ap_logger.info( + config.logger.info( f"Final Chi^2/DoF: {self.loss_history[-1]:.6g}, L: {self.L_history[-1]:.3g}. Converged: {self.message}" ) @@ -359,7 +359,7 @@ def covariance_matrix(self) -> torch.Tensor: try: self._covariance_matrix = torch.linalg.inv(hess) except: - AP_config.ap_logger.warning( + config.logger.warning( "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will use pseudo-inverse of Hessian to continue but results should be inspected." ) self._covariance_matrix = torch.linalg.pinv(hess) @@ -379,8 +379,8 @@ def update_uncertainty(self) -> None: try: self.model.fill_dynamic_value_uncertainties(torch.sqrt(torch.abs(torch.diag(cov)))) except RuntimeError as e: - AP_config.ap_logger.warning(f"Unable to update uncertainty due to: {e}") + config.logger.warning(f"Unable to update uncertainty due to: {e}") else: - AP_config.ap_logger.warning( + config.logger.warning( "Unable to update uncertainty due to non finite covariance matrix" ) diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index b02c5ff8..5b10e854 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -11,7 +11,7 @@ from .base import BaseOptimizer from ..models import Model -from .. import AP_config +from .. import config __all__ = ["MHMCMC"] @@ -45,7 +45,7 @@ def density(self, state: np.ndarray) -> np.ndarray: Returns the density of the model at the given state vector. This is used to calculate the likelihood of the model at the given state. """ - state = torch.tensor(state, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + state = torch.tensor(state, dtype=config.DTYPE, device=config.DEVICE) if self.likelihood == "gaussian": return np.array(list(self.model.gaussian_log_likelihood(s).item() for s in state)) elif self.likelihood == "poisson": @@ -83,6 +83,6 @@ def fit( else: self.chain = np.append(self.chain, sampler.get_chain(), axis=0) self.model.fill_dynamic_values( - torch.tensor(self.chain[-1][0], dtype=AP_config.ap_dtype, device=AP_config.ap_device) + torch.tensor(self.chain[-1][0], dtype=config.DTYPE, device=config.DEVICE) ) return self diff --git a/astrophot/fit/minifit.py b/astrophot/fit/minifit.py index a08b00d5..d56fecf7 100644 --- a/astrophot/fit/minifit.py +++ b/astrophot/fit/minifit.py @@ -6,7 +6,7 @@ from .base import BaseOptimizer from ..models import AstroPhot_Model from .lm import LM -from .. import AP_config +from .. import config __all__ = ["MiniFit"] @@ -42,7 +42,7 @@ def fit(self) -> BaseOptimizer: self.downsample_factor += 1 if self.verbose > 0: - AP_config.ap_logger.info(f"Downsampling target by {self.downsample_factor}x") + config.logger.info(f"Downsampling target by {self.downsample_factor}x") self.small_target = small_target self.model.target = small_target diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py index 36b8e960..67adfcdb 100644 --- a/astrophot/fit/scipy_fit.py +++ b/astrophot/fit/scipy_fit.py @@ -4,7 +4,7 @@ from scipy.optimize import minimize from .base import BaseOptimizer -from .. import AP_config +from .. import config from ..errors import OptimizeStopSuccess __all__ = ("ScipyFit",) @@ -54,9 +54,9 @@ def __init__( # 1 / (sigma^2) kW = kwargs.get("W", None) if kW is not None: - self.W = torch.as_tensor( - kW, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ).flatten()[self.mask] + self.W = torch.as_tensor(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ + self.mask + ] elif model.target.has_variance: self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] else: @@ -106,7 +106,7 @@ def fit(self): res = minimize( lambda x: self.chi2_ndf( - torch.tensor(x, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + torch.tensor(x, dtype=config.DTYPE, device=config.DEVICE) ).item(), self.current_state, method=self.method, @@ -117,11 +117,9 @@ def fit(self): ) self.scipy_res = res self.message = self.message + f"success: {res.success}, message: {res.message}" - self.current_state = torch.tensor( - res.x, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) + self.current_state = torch.tensor(res.x, dtype=config.DTYPE, device=config.DEVICE) if self.verbose > 0: - AP_config.ap_logger.info( + config.logger.info( f"Final Chi^2/DoF: {self.chi2_ndf(self.current_state):.6g}. Converged: {self.message}" ) self.model.fill_dynamic_values(self.current_state) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index ab68dbfe..49ee08da 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -6,7 +6,7 @@ from astropy.io import fits from ..param import Module, Param, forward -from .. import AP_config +from .. import config from ..utils.conversions.units import deg_to_arcsec, arcsec_to_deg from .window import Window, WindowList from ..errors import InvalidImage, SpecificationConflict @@ -66,22 +66,22 @@ def __init__( else: self._data = _data self.crval = Param( - "crval", shape=(2,), units="deg", dtype=AP_config.ap_dtype, device=AP_config.ap_device + "crval", shape=(2,), units="deg", dtype=config.DTYPE, device=config.DEVICE ) self.crtan = Param( "crtan", crtan, shape=(2,), units="arcsec", - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + dtype=config.DTYPE, + device=config.DEVICE, ) self.CD = Param( "CD", shape=(2, 2), units="arcsec/pixel", - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + dtype=config.DTYPE, + device=config.DEVICE, ) self.zeropoint = zeropoint @@ -96,11 +96,11 @@ def __init__( if wcs is not None: if wcs.wcs.ctype[0] not in self.expect_ctype[0]: - AP_config.ap_logger.warning( + config.logger.warning( "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." ) if wcs.wcs.ctype[1] not in self.expect_ctype[1]: - AP_config.ap_logger.warning( + config.logger.warning( "Astropy WCS not tangent plane coordinate system! May not be compatible with AstroPhot." ) @@ -108,9 +108,7 @@ def __init__( crpix = np.array(wcs.wcs.crpix)[::-1] - 1 # handle FITS 1-indexing if CD is not None: - AP_config.ap_logger.warning( - "WCS CD set with supplied WCS, ignoring user supplied CD!" - ) + config.logger.warning("WCS CD set with supplied WCS, ignoring user supplied CD!") CD = deg_to_arcsec * wcs.pixel_scale_matrix # set the data @@ -134,11 +132,11 @@ def data(self): def data(self, value: Optional[torch.Tensor]): """Set the image data. If value is None, the data is initialized to an empty tensor.""" if value is None: - self._data = torch.empty((0, 0), dtype=AP_config.ap_dtype, device=AP_config.ap_device) + self._data = torch.empty((0, 0), dtype=config.DTYPE, device=config.DEVICE) else: # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates self._data = torch.transpose( - torch.as_tensor(value, dtype=AP_config.ap_dtype, device=AP_config.ap_device), 0, 1 + torch.as_tensor(value, dtype=config.DTYPE, device=config.DEVICE), 0, 1 ) @property @@ -161,9 +159,7 @@ def zeropoint(self, value): if value is None: self._zeropoint = None else: - self._zeropoint = torch.as_tensor( - value, dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) + self._zeropoint = torch.as_tensor(value, dtype=config.DTYPE, device=config.DEVICE) @property def window(self): @@ -171,9 +167,7 @@ def window(self): @property def center(self): - shape = torch.as_tensor( - self.data.shape[:2], dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) + shape = torch.as_tensor(self.data.shape[:2], dtype=config.DTYPE, device=config.DEVICE) return torch.stack(self.pixel_to_plane(*((shape - 1) / 2))) @property @@ -236,21 +230,19 @@ def pixel_to_world(self, i, j): def pixel_center_meshgrid(self): """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" - return func.pixel_center_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + return func.pixel_center_meshgrid(self.shape, config.DTYPE, config.DEVICE) def pixel_corner_meshgrid(self): """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" - return func.pixel_corner_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + return func.pixel_corner_meshgrid(self.shape, config.DTYPE, config.DEVICE) def pixel_simpsons_meshgrid(self): """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" - return func.pixel_simpsons_meshgrid(self.shape, AP_config.ap_dtype, AP_config.ap_device) + return func.pixel_simpsons_meshgrid(self.shape, config.DTYPE, config.DEVICE) def pixel_quad_meshgrid(self, order=3): """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" - return func.pixel_quad_meshgrid( - self.shape, AP_config.ap_dtype, AP_config.ap_device, order=order - ) + return func.pixel_quad_meshgrid(self.shape, config.DTYPE, config.DEVICE, order=order) @forward def coordinate_center_meshgrid(self): @@ -382,9 +374,9 @@ def reduce(self, scale: int, **kwargs): def to(self, dtype=None, device=None): if dtype is None: - dtype = AP_config.ap_dtype + dtype = config.DTYPE if device is None: - device = AP_config.ap_device + device = config.DEVICE super().to(dtype=dtype, device=device) self._data = self._data.to(dtype=dtype, device=device) if self.zeropoint is not None: @@ -463,19 +455,17 @@ def load(self, filename: str, hduext=0): return hdulist def corners(self): - pixel_lowleft = torch.tensor( - (-0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) + pixel_lowleft = torch.tensor((-0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE) pixel_lowright = torch.tensor( - (self.data.shape[0] - 0.5, -0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device + (self.data.shape[0] - 0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE ) pixel_upleft = torch.tensor( - (-0.5, self.data.shape[1] - 0.5), dtype=AP_config.ap_dtype, device=AP_config.ap_device + (-0.5, self.data.shape[1] - 0.5), dtype=config.DTYPE, device=config.DEVICE ) pixel_upright = torch.tensor( (self.data.shape[0] - 0.5, self.data.shape[1] - 0.5), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + dtype=config.DTYPE, + device=config.DEVICE, ) lowleft = self.pixel_to_plane(*pixel_lowleft) lowright = self.pixel_to_plane(*pixel_lowright) @@ -613,9 +603,9 @@ def match_indices(self, other: "ImageList"): def to(self, dtype=None, device=None): if dtype is not None: - dtype = AP_config.ap_dtype + dtype = config.DTYPE if device is not None: - device = AP_config.ap_device + device = config.DEVICE super().to(dtype=dtype, device=device) return self diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 7c3666cd..ea1bbb19 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -3,7 +3,6 @@ import torch from .image_object import Image, ImageList -from .. import AP_config from ..errors import SpecificationConflict, InvalidImage __all__ = ["JacobianImage", "JacobianImageList"] diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 0475e41d..7c3c906c 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -5,7 +5,7 @@ from astropy.io import fits from ...utils.initialize import auto_variance -from ... import AP_config +from ... import config from ...errors import SpecificationConflict from ..image_object import Image from ..window import Window @@ -170,7 +170,7 @@ def weight(self, weight): if isinstance(weight, str) and weight == "auto": weight = 1 / auto_variance(self.data, self.mask).T self._weight = torch.transpose( - torch.as_tensor(weight, dtype=AP_config.ap_dtype, device=AP_config.ap_device), 0, 1 + torch.as_tensor(weight, dtype=config.DTYPE, device=config.DEVICE), 0, 1 ) if self._weight.shape != self.data.shape: self._weight = None @@ -216,7 +216,7 @@ def mask(self, mask): self._mask = None return self._mask = torch.transpose( - torch.as_tensor(mask, dtype=torch.bool, device=AP_config.ap_device), 0, 1 + torch.as_tensor(mask, dtype=torch.bool, device=config.DEVICE), 0, 1 ) if self._mask.shape != self.data.shape: self._mask = None @@ -240,9 +240,9 @@ def to(self, dtype=None, device=None): """ if dtype is not None: - dtype = AP_config.ap_dtype + dtype = config.DTYPE if device is not None: - device = AP_config.ap_device + device = config.DEVICE super().to(dtype=dtype, device=device) if self.has_weight: diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 550df982..d4c3ac34 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -5,7 +5,7 @@ from .image_object import Image from .jacobian_image import JacobianImage -from .. import AP_config +from .. import config from .mixins import DataMixin __all__ = ["PSFImage"] @@ -61,8 +61,8 @@ def jacobian_image( elif data is None: data = torch.zeros( (*self.data.shape, len(parameters)), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + dtype=config.DTYPE, + device=config.DEVICE, ) kwargs = { "CD": self.CD.value, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 876ead8b..ae6cbbfe 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -9,7 +9,7 @@ from .jacobian_image import JacobianImage, JacobianImageList from .model_image import ModelImage, ModelImageList from .psf_image import PSFImage -from .. import AP_config +from .. import config from ..errors import InvalidImage from .mixins import DataMixin @@ -160,7 +160,7 @@ def fits_images(self): ) ) else: - AP_config.ap_logger.warning("Unable to save PSF to FITS, not a PSF_Image.") + config.logger.warning("Unable to save PSF to FITS, not a PSF_Image.") return images def load(self, filename: str, hduext=0): @@ -191,8 +191,8 @@ def jacobian_image( if data is None: data = torch.zeros( (*self.data.shape, len(parameters)), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + dtype=config.DTYPE, + device=config.DEVICE, ) kwargs = { "CD": self.CD.value, diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index ce18eb6d..58f932ab 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -4,7 +4,7 @@ from scipy.optimize import minimize from ..utils.decorators import ignore_numpy_warnings -from .. import AP_config +from .. import config def _sample_image( @@ -101,17 +101,13 @@ def optim(x, r, f, u): if res.success: x0 = res.x - elif AP_config.ap_verbose >= 2: - AP_config.ap_logger.warning( - f"initialization fit not successful for {model.name}, falling back to defaults" - ) for param, x0x in zip(params, x0): if not model[param].initialized: if not model[param].is_valid(x0x): print("soft valid", param, x0x) x0x = model[param].soft_valid( - torch.tensor(x0x, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + torch.tensor(x0x, dtype=config.DTYPE, device=config.DEVICE) ) model[param].dynamic_value = x0x @@ -153,12 +149,7 @@ def optim(x, r, f, u): return np.mean(residual[N][:-2]) res = minimize(optim, x0=x0, args=(R, I, S), method="Nelder-Mead") - if not res.success: - if AP_config.ap_verbose >= 2: - AP_config.ap_logger.warning( - f"initialization fit not successful for {model.name}, falling back to defaults" - ) - else: + if res.success: x0 = res.x values.append(x0) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 35514bec..a1fb83ca 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -9,7 +9,7 @@ from ..utils.decorators import classproperty from ..image import Window, ImageList, ModelImage, ModelImageList from ..errors import UnrecognizedModel, InvalidWindow -from .. import AP_config +from .. import config from . import func __all__ = ("Model",) @@ -59,9 +59,7 @@ def __init__(self, *, name=None, target=None, window=None, mask=None, filename=N # Create Param objects for this Module parameter_specs = self.build_parameter_specs(kwargs, self.parameter_specs) for key in parameter_specs: - param = Param( - key, **parameter_specs[key], dtype=AP_config.ap_dtype, device=AP_config.ap_device - ) + param = Param(key, **parameter_specs[key], dtype=config.DTYPE, device=config.DEVICE) setattr(self, key, param) self.saveattrs.update(self.options) @@ -250,9 +248,9 @@ def angular_metric(self, x, y): def to(self, dtype=None, device=None): if dtype is None: - dtype = AP_config.ap_dtype + dtype = config.DTYPE if device is None: - device = AP_config.ap_device + device = config.DEVICE super().to(dtype=dtype, device=device) @forward diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index 05fb80fb..fc94032a 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -4,7 +4,7 @@ from .psf_model_object import PSFModel from ..utils.decorators import ignore_numpy_warnings from ..utils.interpolate import interp2d -from .. import AP_config +from .. import config from ..errors import SpecificationConflict from ..param import forward from . import func @@ -53,7 +53,7 @@ def basis(self, value): else: # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates self._basis = torch.transpose( - torch.as_tensor(value, dtype=AP_config.ap_dtype, device=AP_config.ap_device), 1, 2 + torch.as_tensor(value, dtype=config.DTYPE, device=config.DEVICE), 1, 2 ) @torch.no_grad() diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 0541414c..59db6c3c 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -1,5 +1,4 @@ import numpy as np -from scipy.stats import iqr import torch from ..utils.decorators import ignore_numpy_warnings diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 0bcc77ab..12b0fa63 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -16,7 +16,7 @@ JacobianImage, JacobianImageList, ) -from .. import AP_config +from .. import config from ..utils.decorators import ignore_numpy_warnings from ..errors import InvalidTarget, InvalidWindow @@ -87,7 +87,7 @@ def update_window(self): new_window = WindowList(new_window) for i, n in enumerate(n_windows): if n == 0: - AP_config.ap_logger.warning( + config.logger.warning( f"Model {self.name} has no sub models in target '{self.target.images[i].name}', this may cause issues with fitting." ) else: @@ -109,7 +109,7 @@ def initialize(self): target (Optional["Target_Image"]): A Target_Image instance to use as the source for initializing the model parameters on this image. """ for model in self.models: - AP_config.ap_logger.info(f"Initializing model {model.name}") + config.logger.info(f"Initializing model {model.name}") model.initialize() def fit_mask(self) -> torch.Tensor: diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 72f4f3eb..a21c695b 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -6,7 +6,7 @@ from torch import Tensor from ...param import forward -from ... import AP_config +from ... import config from ...image import Image, Window, JacobianImage from .. import func from ...errors import SpecificationConflict @@ -81,7 +81,7 @@ def _bright_integrate(self, sample, image): @forward def _threshold_integrate(self, sample, image: Image): i, j = image.pixel_center_meshgrid() - kernel = func.curvature_kernel(AP_config.ap_dtype, AP_config.ap_device) + kernel = func.curvature_kernel(config.DTYPE, config.DEVICE) curvature = ( torch.nn.functional.pad( torch.nn.functional.conv2d( diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 37f614a0..0ca75330 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -5,7 +5,7 @@ from ...utils.interpolate import default_prof from ...param import forward from .. import func -from ... import AP_config +from ... import config class InclinedMixin: @@ -155,7 +155,7 @@ class FourierEllipseMixin: def __init__(self, *args, modes=(3, 4), **kwargs): super().__init__(*args, **kwargs) - self.modes = torch.tensor(modes, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + self.modes = torch.tensor(modes, dtype=config.DTYPE, device=config.DEVICE) @forward def radius_metric(self, x, y, am, phim): diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index c0042c69..d88ad086 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -13,7 +13,7 @@ ) from ..utils.initialize import recursive_center_of_mass from ..utils.decorators import ignore_numpy_warnings -from .. import AP_config +from .. import config from ..errors import InvalidTarget from .mixins import SampleMixin @@ -136,7 +136,7 @@ def initialize(self): if not np.all(np.isfinite(COM)): return COM_center = target_area.pixel_to_plane( - *torch.tensor(COM, dtype=AP_config.ap_dtype, device=AP_config.ap_device) + *torch.tensor(COM, dtype=config.DTYPE, device=config.DEVICE) ) self.center.dynamic_value = COM_center diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 4af700b9..194ae199 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -9,7 +9,7 @@ from ..models import GroupModel, PSFModel from ..image import ImageList, WindowList -from .. import AP_config +from .. import config from ..utils.conversions.units import flux_to_sb from ..utils.decorators import ignore_numpy_warnings from .visuals import * @@ -378,7 +378,7 @@ def residual_image( if scaling == "clip": if normalize_residuals is not True: - AP_config.logger.warning( + config.logger.warning( "Using clipping scaling without normalizing residuals. This may lead to confusing results." ) residuals = np.clip(residuals, -5, 5) diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 28fa5101..6adad800 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -5,7 +5,7 @@ import torch from scipy.stats import binned_statistic, iqr -from .. import AP_config +from .. import config from ..models import Model # from ..models import Warp_Galaxy @@ -38,8 +38,8 @@ def radial_light_profile( * extend_profile / 2, int(resolution), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + dtype=config.DTYPE, + device=config.DEVICE, ) flux = model.radial_model(xx, params=()).detach().cpu().numpy() if model.target.zeropoint is not None: @@ -183,8 +183,8 @@ def ray_light_profile( 0, max(model.window.shape) * model.target.pixelscale * extend_profile / 2, int(resolution), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + dtype=config.DTYPE, + device=config.DEVICE, ) for r in range(model.segments): if model.segments <= 3: @@ -217,8 +217,8 @@ def wedge_light_profile( 0, max(model.window.shape) * model.target.pixelscale * extend_profile / 2, int(resolution), - dtype=AP_config.ap_dtype, - device=AP_config.ap_device, + dtype=config.DTYPE, + device=config.DEVICE, ) for r in range(model.segments): if model.segments <= 3: diff --git a/astrophot/utils/initialize/__init__.py b/astrophot/utils/initialize/__init__.py index a10777ea..592e63e9 100644 --- a/astrophot/utils/initialize/__init__.py +++ b/astrophot/utils/initialize/__init__.py @@ -1,6 +1,6 @@ from .segmentation_map import * from .center import center_of_mass, recursive_center_of_mass -from .construct_psf import gaussian_psf, moffat_psf, construct_psf +from .construct_psf import gaussian_psf, moffat_psf from .variance import auto_variance from .PA import polar_decomposition @@ -9,7 +9,6 @@ "recursive_center_of_mass", "gaussian_psf", "moffat_psf", - "construct_psf", "centroids_from_segmentation_map", "PA_from_segmentation_map", "q_from_segmentation_map", diff --git a/astrophot/utils/initialize/construct_psf.py b/astrophot/utils/initialize/construct_psf.py index b9b0c232..f764e4c7 100644 --- a/astrophot/utils/initialize/construct_psf.py +++ b/astrophot/utils/initialize/construct_psf.py @@ -59,70 +59,3 @@ def moffat_psf(n, Rd, img_width, pixelscale, upsample=4, normalize=True): if normalize: return ZZ / np.sum(ZZ) return ZZ - - -def construct_psf(stars, image, sky_est, size=51, mask=None, keep_init=False, Lanczos_scale=3): - """Given a list of initial guesses for star center locations, finds - the interpolated flux peak, re-centers the stars such that they - are exactly on a pixel center, then median stacks the normalized - stars to determine an average PSF. - - Note that all coordinates in this function are pixel - coordinates. That is, the image[0][0] pixel is at location (0,0) - and the image[2][7] pixel is at location (2,7) in this coordinate - system. - """ - size += 1 - (size % 2) - star_centers = [] - # determine exact (sub-pixel) center for each star - - for star in stars: - if keep_init: - star_centers = list(np.array(s) for s in stars) - break - try: - peak = GaussianDensity_Peak(star, image) - except Exception as e: - AP_config.ap_logger.warning("issue finding star center") - AP_config.ap_logger.warning(e) - AP_config.ap_logger.warning("skipping") - continue - pixel_cen = np.round(peak) - if ( - pixel_cen[0] < ((size - 1) / 2) - or pixel_cen[0] > (image.shape[1] - ((size - 1) / 2) - 1) - or pixel_cen[1] < ((size - 1) / 2) - or pixel_cen[1] > (image.shape[0] - ((size - 1) / 2) - 1) - ): - AP_config.ap_logger.debug("skipping star near edge at: {peak}") - continue - star_centers.append(peak) - - stacking = [] - # Extract the star from the image, and shift to align exactly with pixel grid - for star in star_centers: - center = np.round(star) - border = int((size - 1) / 2 + Lanczos_scale) - I = image[ - int(center[1] - border) : int(center[1] + border + 1), - int(center[0] - border) : int(center[0] + border + 1), - ] - shift = center - star - I = shift_Lanczos_np(I - sky_est, shift[0], shift[1], scale=Lanczos_scale) - I = I[Lanczos_scale:-Lanczos_scale, Lanczos_scale:-Lanczos_scale] - border = (size - 1) / 2 - if mask is not None: - I[ - mask[ - int(center[1] - border) : int(center[1] + border + 1), - int(center[0] - border) : int(center[0] + border + 1), - ] - ] = np.nan - # Add the normalized star image to the list - stacking.append(I / np.sum(I)) - - # Median stack the pixel images - stacked_psf = np.nanmedian(stacking, axis=0) - stacked_psf[stacked_psf < 0] = 0 - - return stacked_psf / np.sum(stacked_psf) diff --git a/astrophot/utils/optimization.py b/astrophot/utils/optimization.py index 03edc409..dbdb4399 100644 --- a/astrophot/utils/optimization.py +++ b/astrophot/utils/optimization.py @@ -1,6 +1,6 @@ import torch -from .. import AP_config +from .. import config def chi_squared(target, model, mask=None, variance=None): @@ -20,9 +20,7 @@ def chi_squared(target, model, mask=None, variance=None): def reduced_chi_squared(target, model, params, mask=None, variance=None): if mask is None: ndf = ( - torch.prod( - torch.tensor(target.shape, dtype=AP_config.ap_dtype, device=AP_config.ap_device) - ) + torch.prod(torch.tensor(target.shape, dtype=config.DTYPE, device=config.DEVICE)) - params ) else: diff --git a/docs/source/prebuilt/segmap_models_fit.py b/docs/source/prebuilt/segmap_models_fit.py index 5b481644..ad1d819b 100644 --- a/docs/source/prebuilt/segmap_models_fit.py +++ b/docs/source/prebuilt/segmap_models_fit.py @@ -180,7 +180,7 @@ # Report Results # ---------------------------------------------------------------------- if not sky_locked: - print(models[0].parameters) + print(models[0]) if not primary_model is None: print(primary_model) diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index dc7bdb8a..4639ff5b 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -580,7 +580,7 @@ "outputs": [], "source": [ "# check if AstroPhot has detected your GPU\n", - "print(ap.AP_config.ap_device) # most likely this will say \"cpu\" unless you already have a cuda GPU,\n", + "print(ap.config.DEVICE) # most likely this will say \"cpu\" unless you already have a cuda GPU,\n", "# in which case it should say \"cuda:0\"" ] }, @@ -591,7 +591,7 @@ "outputs": [], "source": [ "# If you have a GPU but want to use the cpu for some reason, just set:\n", - "ap.AP_config.ap_device = \"cpu\"\n", + "ap.config.DEVICE = \"cpu\"\n", "# BEFORE creating anything else (models, images, etc.)" ] }, @@ -611,7 +611,7 @@ "outputs": [], "source": [ "# Again do this BEFORE creating anything else\n", - "ap.AP_config.ap_dtype = torch.float32\n", + "ap.config.DTYPE = torch.float32\n", "\n", "# Now new AstroPhot objects will be made with single bit precision\n", "T1 = ap.TargetImage(data=np.zeros((100, 100)))\n", @@ -619,7 +619,7 @@ "print(\"now a single:\", T1.data.dtype)\n", "\n", "# Here we switch back to double precision\n", - "ap.AP_config.ap_dtype = torch.float64\n", + "ap.config.DTYPE = torch.float64\n", "T2 = ap.TargetImage(data=np.zeros((100, 100)))\n", "T2.to()\n", "print(\"back to double:\", T2.data.dtype)\n", @@ -639,7 +639,7 @@ "source": [ "## Tracking output\n", "\n", - "The AstroPhot optimizers, and occasionally the other AstroPhot objects, will provide status updates about themselves which can be very useful for debugging problems or just keeping tabs on progress. There are a number of use cases for AstroPhot, each having different desired output behaviors. To accommodate all users, AstroPhot implements a general logging system. The object `ap.AP_config.ap_logger` is a logging object which by default writes to AstroPhot.log in the local directory. As the user, you can set that logger to be any logging object you like for arbitrary complexity. Most users will, however, simply want to control the filename, or have it output to screen instead of a file. Below you can see examples of how to do that." + "The AstroPhot optimizers, and occasionally the other AstroPhot objects, will provide status updates about themselves which can be very useful for debugging problems or just keeping tabs on progress. There are a number of use cases for AstroPhot, each having different desired output behaviors. To accommodate all users, AstroPhot implements a general logging system. The object `ap.config.logger` is a logging object which by default writes to AstroPhot.log in the local directory. As the user, you can set that logger to be any logging object you like for arbitrary complexity. Most users will, however, simply want to control the filename, or have it output to screen instead of a file. Below you can see examples of how to do that." ] }, { @@ -651,23 +651,23 @@ "# note that the log file will be where these tutorial notebooks are in your filesystem\n", "\n", "# Here we change the settings so AstroPhot only prints to a log file\n", - "ap.AP_config.set_logging_output(stdout=False, filename=\"AstroPhot.log\")\n", - "ap.AP_config.ap_logger.info(\"message 1: this should only appear in the AstroPhot log file\")\n", + "ap.config.set_logging_output(stdout=False, filename=\"AstroPhot.log\")\n", + "ap.config.logger.info(\"message 1: this should only appear in the AstroPhot log file\")\n", "\n", "# Here we change the settings so AstroPhot only prints to console\n", - "ap.AP_config.set_logging_output(stdout=True, filename=None)\n", - "ap.AP_config.ap_logger.info(\"message 2: this should only print to the console\")\n", + "ap.config.set_logging_output(stdout=True, filename=None)\n", + "ap.config.logger.info(\"message 2: this should only print to the console\")\n", "\n", "# Here we change the settings so AstroPhot prints to both, which is the default\n", - "ap.AP_config.set_logging_output(stdout=True, filename=\"AstroPhot.log\")\n", - "ap.AP_config.ap_logger.info(\"message 3: this should appear in both the console and the log file\")" + "ap.config.set_logging_output(stdout=True, filename=\"AstroPhot.log\")\n", + "ap.config.logger.info(\"message 3: this should appear in both the console and the log file\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "You can also change the logging level and/or formatter for the stdout and filename options (see `help(ap.AP_config.set_logging_output)` for details). However, at that point you may want to simply make your own logger object and assign it to the `ap.AP_config.ap_logger` variable." + "You can also change the logging level and/or formatter for the stdout and filename options (see `help(ap.config.set_logging_output)` for details). However, at that point you may want to simply make your own logger object and assign it to the `ap.config.logger` variable." ] }, { From b804a244b53d4edf67adec3de43e7e90e69ea4e4 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 23 Jul 2025 18:49:25 -0400 Subject: [PATCH 087/191] fix notebook test directory --- tests/test_notebooks.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index 26b3a9f6..80730a75 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -1,6 +1,4 @@ import platform -import nbformat -from nbconvert.preprocessors import ExecutePreprocessor import glob import pytest import runpy @@ -12,15 +10,13 @@ reason="Graphviz not installed on Windows runner", ) -notebooks = glob.glob("../docs/source/tutorials/*.ipynb") +notebooks = glob.glob( + os.path.join( + os.path.split(os.path.dirname(__file__))[0], "docs", "source", "tutorials", "*.ipynb" + ) +) -# @pytest.mark.parametrize("nb_path", notebooks) -# def test_notebook_runs(nb_path): -# with open(nb_path) as f: -# nb = nbformat.read(f, as_version=4) -# ep = ExecutePreprocessor(timeout=600, kernel_name="python3") -# ep.preprocess(nb, {"metadata": {"path": "./"}}) def convert_notebook_to_py(nbpath): subprocess.run( ["jupyter", "nbconvert", "--to", "python", nbpath], From 1fa779897bd1cd124806538ac80186001f02dedb Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 24 Jul 2025 11:48:31 -0400 Subject: [PATCH 088/191] now vmap compatible, get MALA online --- astrophot/fit/__init__.py | 28 +- astrophot/fit/hmc.py | 63 ++-- astrophot/fit/iterative.py | 329 ++++++++++--------- astrophot/fit/mhmcmc.py | 7 +- astrophot/fit/nuts.py | 171 ---------- astrophot/models/base.py | 13 +- astrophot/models/group_model_object.py | 16 +- astrophot/models/mixins/sample.py | 6 + docs/requirements.txt | 1 + docs/source/tutorials/FittingMethods.ipynb | 351 +++++++++++++-------- docs/source/tutorials/GettingStarted.ipynb | 21 ++ docs/source/tutorials/GroupModels.ipynb | 14 + tests/test_fit.py | 30 ++ 13 files changed, 526 insertions(+), 524 deletions(-) delete mode 100644 astrophot/fit/nuts.py diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 4c6b4c02..7cd5616d 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -8,31 +8,7 @@ # from .minifit import * -# try: -# from .hmc import * -# from .nuts import * -# except AssertionError as e: -# print("Could not load HMC or NUTS due to:", str(e)) +from .hmc import HMC from .mhmcmc import MHMCMC -__all__ = ["LM", "Grad", "Iter", "ScipyFit"] - -""" -base: This module defines the base class BaseOptimizer, - which is used as the parent class for all optimization algorithms in AstroPhot. - This module contains helper functions used across multiple optimization algorithms, - such as computing gradients and making copies of models. - -LM: This module defines the class LM, - which uses the Levenberg-Marquardt algorithm to perform optimization. - This algorithm adjusts the learning rate at each step to find the optimal value. - -Grad: This module defines the class Gradient-Optimizer, - which uses a simple gradient descent algorithm to perform optimization. - This algorithm adjusts the learning rate at each step to find the optimal value. - -Iterative: This module defines the class Iter, - which uses an iterative algorithm to perform Optimization. - This algorithm repeatedly fits each model individually until they all converge. - -""" +__all__ = ["LM", "Grad", "Iter", "ScipyFit", "HMC", "MHMCMC"] diff --git a/astrophot/fit/hmc.py b/astrophot/fit/hmc.py index f5e7d466..2099cf4c 100644 --- a/astrophot/fit/hmc.py +++ b/astrophot/fit/hmc.py @@ -2,15 +2,19 @@ from typing import Optional, Sequence import torch -import pyro -import pyro.distributions as dist -from pyro.infer import MCMC as pyro_MCMC -from pyro.infer import HMC as pyro_HMC -from pyro.infer.mcmc.adaptation import BlockMassMatrix -from pyro.ops.welford import WelfordCovariance + +try: + import pyro + import pyro.distributions as dist + from pyro.infer import MCMC as pyro_MCMC + from pyro.infer import HMC as pyro_HMC + from pyro.infer.mcmc.adaptation import BlockMassMatrix + from pyro.ops.welford import WelfordCovariance +except ImportError: + pyro = None from .base import BaseOptimizer -from ..models import AstroPhot_Model +from ..models import Model __all__ = ["HMC"] @@ -80,21 +84,33 @@ class HMC(BaseOptimizer): def __init__( self, - model: AstroPhot_Model, + model: Model, initial_state: Optional[Sequence] = None, max_iter: int = 1000, + inv_mass: Optional[torch.Tensor] = None, + epsilon: float = 1e-5, + leapfrog_steps: int = 20, + progress_bar: bool = True, + prior: Optional[dist.Distribution] = None, + warmup: int = 100, + hmc_kwargs: dict = {}, + mcmc_kwargs: dict = {}, + likelihood: str = "gaussian", **kwargs, ): + if pyro is None: + raise ImportError("Pyro must be installed to use HMC.") super().__init__(model, initial_state, max_iter=max_iter, **kwargs) - self.inv_mass = kwargs.get("inv_mass", None) - self.epsilon = kwargs.get("epsilon", 1e-3) - self.leapfrog_steps = kwargs.get("leapfrog_steps", 20) - self.progress_bar = kwargs.get("progress_bar", True) - self.prior = kwargs.get("prior", None) - self.warmup = kwargs.get("warmup", 100) - self.hmc_kwargs = kwargs.get("hmc_kwargs", {}) - self.mcmc_kwargs = kwargs.get("mcmc_kwargs", {}) + self.inv_mass = inv_mass + self.epsilon = epsilon + self.leapfrog_steps = leapfrog_steps + self.progress_bar = progress_bar + self.prior = prior + self.warmup = warmup + self.hmc_kwargs = hmc_kwargs + self.mcmc_kwargs = mcmc_kwargs + self.likelihood = likelihood self.acceptance = None def fit( @@ -116,10 +132,12 @@ def fit( def step(model, prior): x = pyro.sample("x", prior) # Log-likelihood function - model.parameters.flat_detach() - log_likelihood_value = -model.negative_log_likelihood( - parameters=x, as_representation=True - ) + if self.likelihood == "gaussian": + log_likelihood_value = model.gaussian_log_likelihood(params=x) + elif self.likelihood == "poisson": + log_likelihood_value = model.poisson_log_likelihood(params=x) + else: + raise ValueError(f"Unsupported likelihood type: {self.likelihood}") # Observe the log-likelihood pyro.factor("obs", log_likelihood_value) @@ -145,7 +163,7 @@ def step(model, prior): hmc_kernel.mass_matrix_adapter.inverse_mass_matrix = {("x",): self.inv_mass} # Provide an initial guess for the parameters - init_params = {"x": self.model.parameters.vector_representation()} + init_params = {"x": self.model.build_params_array()} # Run MCMC with the HMC sampler and the initial guess mcmc_kwargs = { @@ -163,9 +181,6 @@ def step(model, prior): # Extract posterior samples chain = mcmc.get_samples()["x"] - with torch.no_grad(): - for i in range(len(chain)): - chain[i] = self.model.parameters.vector_transform_rep_to_val(chain[i]) self.chain = chain return self diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 4003ca1b..b72d54a2 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -158,11 +158,6 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.model.fill_dynamic_values( torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) - if update_uncertainty: - for model in self.model.models: - if self.verbose > 1: - config.logger.info(model.name) - self.sub_step(model, update_uncertainty=True) if self.verbose > 1: config.logger.info( f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" @@ -171,165 +166,165 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: return self -class IterParam(BaseOptimizer): - """Optimization wrapper that call LM optimizer on subsets of variables. - - IterParam takes the full set of parameters for a model and breaks - them down into chunks as specified by the user. It then calls - Levenberg-Marquardt optimization on the subset of parameters, and - iterates through all subsets until every parameter has been - optimized. It cycles through these chunks until convergence. This - method is very powerful in situations where the full optimization - problem cannot fit in memory, or where the optimization problem is - too complex to tackle as a single large problem. In full LM - optimization a single problematic parameter can ripple into issues - with every other parameter, so breaking the problem down can - sometimes make an otherwise intractable problem easier. For small - problems with only a few models, it is likely better to optimize - the full problem with LM as, when it works, LM is faster than the - IterParam method. - - Args: - chunks (Union[int, tuple]): Specify how to break down the model parameters. If an integer, at each iteration the algorithm will break the parameters into groups of that size. If a tuple, should be a tuple of tuples of strings which give an explicit pairing of parameters to optimize, note that it is allowed to have variable size chunks this way. Default: 50 - method (str): How to iterate through the chunks. Should be one of: random, sequential. Default: random - """ - - def __init__( - self, - model: Model, - initial_state: Sequence = None, - chunks: Union[int, tuple] = 50, - max_iter: int = 100, - method: str = "random", - LM_kwargs: dict = {}, - **kwargs: Dict[str, Any], - ) -> None: - super().__init__(model, initial_state, max_iter=max_iter, **kwargs) - - self.chunks = chunks - self.method = method - self.LM_kwargs = LM_kwargs - - # # pixels # parameters - self.ndf = self.model.target[self.model.window].flatten("data").numel() - len( - self.current_state - ) - if self.model.target.has_mask: - # subtract masked pixels from degrees of freedom - self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() - - def step(self): - # These store the chunking information depending on which chunk mode is selected - param_ids = list(self.model.parameters.vector_identities()) - init_param_ids = list(self.model.parameters.vector_identities()) - _chunk_index = 0 - _chunk_choices = None - res = None - - if self.verbose > 0: - config.logger.info("--------iter-------") - - # Loop through all the chunks - while True: - chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=config.DEVICE) - if isinstance(self.chunks, int): - if len(param_ids) == 0: - break - if self.method == "random": - # Draw a random chunk of ids - for pid in random.sample(param_ids, min(len(param_ids), self.chunks)): - chunk[init_param_ids.index(pid)] = True - else: - # Draw the next chunk of ids - for pid in param_ids[: self.chunks]: - chunk[init_param_ids.index(pid)] = True - # Remove the selected ids from the list - for p in np.array(init_param_ids)[chunk.detach().cpu().numpy()]: - param_ids.pop(param_ids.index(p)) - elif isinstance(self.chunks, (tuple, list)): - if _chunk_choices is None: - # Make a list of the chunks as given explicitly - _chunk_choices = list(range(len(self.chunks))) - if self.method == "random": - if len(_chunk_choices) == 0: - break - # Select a random chunk from the given groups - sub_index = random.choice(_chunk_choices) - _chunk_choices.pop(_chunk_choices.index(sub_index)) - for pid in self.chunks[sub_index]: - chunk[param_ids.index(pid)] = True - else: - if _chunk_index >= len(self.chunks): - break - # Select the next chunk in order - for pid in self.chunks[_chunk_index]: - chunk[param_ids.index(pid)] = True - _chunk_index += 1 - else: - raise ValueError( - "Unrecognized chunks value, should be one of int, tuple. not: {type(self.chunks)}" - ) - if self.verbose > 1: - config.logger.info(str(chunk)) - del res - with Param_Mask(self.model.parameters, chunk): - res = LM( - self.model, - ndf=self.ndf, - **self.LM_kwargs, - ).fit() - if self.verbose > 0: - config.logger.info(f"chunk loss: {res.res_loss()}") - if self.verbose > 1: - config.logger.info(f"chunk message: {res.message}") - - self.loss_history.append(res.res_loss()) - self.lambda_history.append( - self.model.parameters.vector_representation().detach().cpu().numpy() - ) - if self.verbose > 0: - config.logger.info(f"Loss: {self.loss_history[-1]}") - - # test for convergence - if self.iteration >= 2 and ( - (-self.relative_tolerance * 1e-3) - < ((self.loss_history[-2] - self.loss_history[-1]) / self.loss_history[-1]) - < (self.relative_tolerance / 10) - ): - self._count_finish += 1 - else: - self._count_finish = 0 - - self.iteration += 1 - - def fit(self): - self.iteration = 0 - - start_fit = time() - try: - while True: - self.step() - if self.save_steps is not None: - self.model.save( - os.path.join( - self.save_steps, - f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", - ) - ) - if self.iteration > 2 and self._count_finish >= 2: - self.message = self.message + "success" - break - elif self.iteration >= self.max_iter: - self.message = self.message + f"fail max iterations reached: {self.iteration}" - break - - except KeyboardInterrupt: - self.message = self.message + "fail interrupted" - - self.model.parameters.vector_set_representation(self.res()) - if self.verbose > 1: - config.logger.info( - f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" - ) - - return self +# class IterParam(BaseOptimizer): +# """Optimization wrapper that call LM optimizer on subsets of variables. + +# IterParam takes the full set of parameters for a model and breaks +# them down into chunks as specified by the user. It then calls +# Levenberg-Marquardt optimization on the subset of parameters, and +# iterates through all subsets until every parameter has been +# optimized. It cycles through these chunks until convergence. This +# method is very powerful in situations where the full optimization +# problem cannot fit in memory, or where the optimization problem is +# too complex to tackle as a single large problem. In full LM +# optimization a single problematic parameter can ripple into issues +# with every other parameter, so breaking the problem down can +# sometimes make an otherwise intractable problem easier. For small +# problems with only a few models, it is likely better to optimize +# the full problem with LM as, when it works, LM is faster than the +# IterParam method. + +# Args: +# chunks (Union[int, tuple]): Specify how to break down the model parameters. If an integer, at each iteration the algorithm will break the parameters into groups of that size. If a tuple, should be a tuple of tuples of strings which give an explicit pairing of parameters to optimize, note that it is allowed to have variable size chunks this way. Default: 50 +# method (str): How to iterate through the chunks. Should be one of: random, sequential. Default: random +# """ + +# def __init__( +# self, +# model: Model, +# initial_state: Sequence = None, +# chunks: Union[int, tuple] = 50, +# max_iter: int = 100, +# method: str = "random", +# LM_kwargs: dict = {}, +# **kwargs: Dict[str, Any], +# ) -> None: +# super().__init__(model, initial_state, max_iter=max_iter, **kwargs) + +# self.chunks = chunks +# self.method = method +# self.LM_kwargs = LM_kwargs + +# # # pixels # parameters +# self.ndf = self.model.target[self.model.window].flatten("data").numel() - len( +# self.current_state +# ) +# if self.model.target.has_mask: +# # subtract masked pixels from degrees of freedom +# self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() + +# def step(self): +# # These store the chunking information depending on which chunk mode is selected +# param_ids = list(self.model.parameters.vector_identities()) +# init_param_ids = list(self.model.parameters.vector_identities()) +# _chunk_index = 0 +# _chunk_choices = None +# res = None + +# if self.verbose > 0: +# config.logger.info("--------iter-------") + +# # Loop through all the chunks +# while True: +# chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=config.DEVICE) +# if isinstance(self.chunks, int): +# if len(param_ids) == 0: +# break +# if self.method == "random": +# # Draw a random chunk of ids +# for pid in random.sample(param_ids, min(len(param_ids), self.chunks)): +# chunk[init_param_ids.index(pid)] = True +# else: +# # Draw the next chunk of ids +# for pid in param_ids[: self.chunks]: +# chunk[init_param_ids.index(pid)] = True +# # Remove the selected ids from the list +# for p in np.array(init_param_ids)[chunk.detach().cpu().numpy()]: +# param_ids.pop(param_ids.index(p)) +# elif isinstance(self.chunks, (tuple, list)): +# if _chunk_choices is None: +# # Make a list of the chunks as given explicitly +# _chunk_choices = list(range(len(self.chunks))) +# if self.method == "random": +# if len(_chunk_choices) == 0: +# break +# # Select a random chunk from the given groups +# sub_index = random.choice(_chunk_choices) +# _chunk_choices.pop(_chunk_choices.index(sub_index)) +# for pid in self.chunks[sub_index]: +# chunk[param_ids.index(pid)] = True +# else: +# if _chunk_index >= len(self.chunks): +# break +# # Select the next chunk in order +# for pid in self.chunks[_chunk_index]: +# chunk[param_ids.index(pid)] = True +# _chunk_index += 1 +# else: +# raise ValueError( +# "Unrecognized chunks value, should be one of int, tuple. not: {type(self.chunks)}" +# ) +# if self.verbose > 1: +# config.logger.info(str(chunk)) +# del res +# with Param_Mask(self.model.parameters, chunk): +# res = LM( +# self.model, +# ndf=self.ndf, +# **self.LM_kwargs, +# ).fit() +# if self.verbose > 0: +# config.logger.info(f"chunk loss: {res.res_loss()}") +# if self.verbose > 1: +# config.logger.info(f"chunk message: {res.message}") + +# self.loss_history.append(res.res_loss()) +# self.lambda_history.append( +# self.model.parameters.vector_representation().detach().cpu().numpy() +# ) +# if self.verbose > 0: +# config.logger.info(f"Loss: {self.loss_history[-1]}") + +# # test for convergence +# if self.iteration >= 2 and ( +# (-self.relative_tolerance * 1e-3) +# < ((self.loss_history[-2] - self.loss_history[-1]) / self.loss_history[-1]) +# < (self.relative_tolerance / 10) +# ): +# self._count_finish += 1 +# else: +# self._count_finish = 0 + +# self.iteration += 1 + +# def fit(self): +# self.iteration = 0 + +# start_fit = time() +# try: +# while True: +# self.step() +# if self.save_steps is not None: +# self.model.save( +# os.path.join( +# self.save_steps, +# f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", +# ) +# ) +# if self.iteration > 2 and self._count_finish >= 2: +# self.message = self.message + "success" +# break +# elif self.iteration >= self.max_iter: +# self.message = self.message + f"fail max iterations reached: {self.iteration}" +# break + +# except KeyboardInterrupt: +# self.message = self.message + "fail interrupted" + +# self.model.parameters.vector_set_representation(self.res()) +# if self.verbose > 1: +# config.logger.info( +# f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" +# ) + +# return self diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index 5b10e854..3faa4e74 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -59,6 +59,7 @@ def fit( nsamples: Optional[int] = None, restart_chain: bool = True, skip_initial_state_check: bool = True, + flat_chain: bool = True, ): """ Performs the MCMC sampling using a Metropolis Hastings acceptance step and records the chain for later examination. @@ -79,10 +80,10 @@ def fit( sampler = emcee.EnsembleSampler(nwalkers, ndim, self.density, vectorize=True) state = sampler.run_mcmc(state, nsamples, skip_initial_state_check=skip_initial_state_check) if restart_chain: - self.chain = sampler.get_chain() + self.chain = sampler.get_chain(flat=flat_chain) else: - self.chain = np.append(self.chain, sampler.get_chain(), axis=0) + self.chain = np.append(self.chain, sampler.get_chain(flat=flat_chain), axis=0) self.model.fill_dynamic_values( - torch.tensor(self.chain[-1][0], dtype=config.DTYPE, device=config.DEVICE) + torch.tensor(self.chain[-1], dtype=config.DTYPE, device=config.DEVICE) ) return self diff --git a/astrophot/fit/nuts.py b/astrophot/fit/nuts.py deleted file mode 100644 index 3fcee171..00000000 --- a/astrophot/fit/nuts.py +++ /dev/null @@ -1,171 +0,0 @@ -# No U-Turn Sampler variant of Hamiltonian Monte-Carlo -from typing import Optional, Sequence - -import torch -import pyro -import pyro.distributions as dist -from pyro.infer import MCMC as pyro_MCMC -from pyro.infer import NUTS as pyro_NUTS -from pyro.infer.mcmc.adaptation import BlockMassMatrix -from pyro.ops.welford import WelfordCovariance - -from .base import BaseOptimizer -from ..models import AstroPhot_Model - -__all__ = ["NUTS"] - - -########################################### -# !Overwrite pyro configuration behavior! -# currently this is the only way to provide -# mass matrix manually -########################################### -def new_configure(self, mass_matrix_shape, adapt_mass_matrix=True, options={}): - """ - Sets up an initial mass matrix. - - :param dict mass_matrix_shape: a dict that maps tuples of site names to the shape of - the corresponding mass matrix. Each tuple of site names corresponds to a block. - :param bool adapt_mass_matrix: a flag to decide whether an adaptation scheme will be used. - :param dict options: tensor options to construct the initial mass matrix. - """ - inverse_mass_matrix = {} - for site_names, shape in mass_matrix_shape.items(): - self._mass_matrix_size[site_names] = shape[0] - diagonal = len(shape) == 1 - inverse_mass_matrix[site_names] = ( - torch.full(shape, self._init_scale, **options) - if diagonal - else torch.eye(*shape, **options) * self._init_scale - ) - if adapt_mass_matrix: - adapt_scheme = WelfordCovariance(diagonal=diagonal) - self._adapt_scheme[site_names] = adapt_scheme - - if len(self.inverse_mass_matrix.keys()) == 0: - self.inverse_mass_matrix = inverse_mass_matrix - - -BlockMassMatrix.configure = new_configure -############################################ - - -class NUTS(BaseOptimizer): - """No U-Turn Sampler (NUTS) implementation for Hamiltonian Monte Carlo - (HMC) based MCMC sampling. - - This is a wrapper for the Pyro package: https://docs.pyro.ai/en/stable/index.html - - The NUTS class provides an implementation of the No-U-Turn Sampler - (NUTS) algorithm, which is a variation of the Hamiltonian Monte - Carlo (HMC) method for Markov Chain Monte Carlo (MCMC) - sampling. This implementation uses the Pyro library to perform the - sampling. The NUTS algorithm utilizes gradients of the target - distribution to more efficiently explore the probability - distribution of the model. - - More information on HMC and NUTS can be found at: - https://en.wikipedia.org/wiki/Hamiltonian_Monte_Carlo, - https://arxiv.org/abs/1701.02434, and - http://www.mcmchandbook.net/HandbookChapter5.pdf - - Args: - model (AstroPhot_Model): The model which will be sampled. - initial_state (Optional[Sequence], optional): A 1D array with the values for each parameter in the model. These values should be in the form of "as_representation" in the model. Defaults to None. - max_iter (int, optional): The number of sampling steps to perform. Defaults to 1000. - epsilon (float, optional): The step size for the NUTS sampler. Defaults to 1e-3. - inv_mass (Optional[Tensor], optional): Inverse Mass matrix (covariance matrix) for the Hamiltonian system. Defaults to None. - progress_bar (bool, optional): If True, display a progress bar during sampling. Defaults to True. - prior (Optional[Distribution], optional): Prior distribution for the model parameters. Defaults to None. - warmup (int, optional): Number of warmup (or burn-in) steps to perform before sampling. Defaults to 100. - nuts_kwargs (Dict[str, Any], optional): A dictionary of additional keyword arguments to pass to the NUTS sampler. Defaults to {}. - mcmc_kwargs (Dict[str, Any], optional): A dictionary of additional keyword arguments to pass to the MCMC function. Defaults to {}. - - Methods: - fit(state: Optional[torch.Tensor] = None, nsamples: Optional[int] = None, restart_chain: bool = True) -> 'NUTS': - Performs the MCMC sampling using a NUTS HMC and records the chain for later examination. - - """ - - def __init__( - self, - model: AstroPhot_Model, - initial_state: Optional[Sequence] = None, - max_iter: int = 1000, - **kwargs, - ): - super().__init__(model, initial_state, max_iter=max_iter, **kwargs) - - self.inv_mass = kwargs.get("inv_mass", None) - self.epsilon = kwargs.get("epsilon", 1e-4) - self.progress_bar = kwargs.get("progress_bar", True) - self.prior = kwargs.get("prior", None) - self.warmup = kwargs.get("warmup", 100) - self.nuts_kwargs = kwargs.get("nuts_kwargs", {}) - self.mcmc_kwargs = kwargs.get("mcmc_kwargs", {}) - - def fit( - self, - state: Optional[torch.Tensor] = None, - nsamples: Optional[int] = None, - restart_chain: bool = True, - ): - """ - Performs the MCMC sampling using a NUTS HMC and records the chain for later examination. - """ - - def step(model, prior): - x = pyro.sample("x", prior) - # Log-likelihood function - model.parameters.flat_detach() - log_likelihood_value = -model.negative_log_likelihood( - parameters=x, as_representation=True - ) - # Observe the log-likelihood - pyro.factor("obs", log_likelihood_value) - - if self.prior is None: - self.prior = dist.Normal( - self.current_state, - torch.ones_like(self.current_state) * 1e2 + torch.abs(self.current_state) * 1e2, - ) - - # Set up the NUTS sampler - nuts_kwargs = { - "jit_compile": False, - "ignore_jit_warnings": True, - "step_size": self.epsilon, - "full_mass": True, - "adapt_step_size": True, - "adapt_mass_matrix": self.inv_mass is None, - } - nuts_kwargs.update(self.nuts_kwargs) - nuts_kernel = pyro_NUTS(step, **nuts_kwargs) - if self.inv_mass is not None: - nuts_kernel.mass_matrix_adapter.inverse_mass_matrix = {("x",): self.inv_mass} - - # Provide an initial guess for the parameters - init_params = {"x": self.model.parameters.vector_representation()} - - # Run MCMC with the NUTS sampler and the initial guess - mcmc_kwargs = { - "num_samples": self.max_iter, - "warmup_steps": self.warmup, - "initial_params": init_params, - "disable_progbar": not self.progress_bar, - } - mcmc_kwargs.update(self.mcmc_kwargs) - mcmc = pyro_MCMC(nuts_kernel, **mcmc_kwargs) - - mcmc.run(self.model, self.prior) - self.iteration += self.max_iter - - # Extract posterior samples - chain = mcmc.get_samples()["x"] - - with torch.no_grad(): - for i in range(len(chain)): - chain[i] = self.model.parameters.vector_transform_rep_to_val(chain[i]) - self.chain = chain - - return self diff --git a/astrophot/models/base.py b/astrophot/models/base.py index a1fb83ca..deac9439 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -2,6 +2,7 @@ from copy import deepcopy import torch +from torch.func import hessian import numpy as np from caskade import Param as CParam @@ -136,7 +137,7 @@ def gaussian_log_likelihood( weight = data.weight mask = data.mask data = data.data - if isinstance(data, ImageList): + if isinstance(data, tuple): nll = 0.5 * sum( torch.sum(((da - mo) ** 2 * wgt)[~ma]) for mo, da, wgt, ma in zip(model, data, weight, mask) @@ -161,7 +162,7 @@ def poisson_log_likelihood( mask = data.mask data = data.data - if isinstance(data, ImageList): + if isinstance(data, tuple): nll = sum( torch.sum((mo - da * (mo + 1e-10).log() + torch.lgamma(da + 1))[~ma]) for mo, da, ma in zip(model, data, mask) @@ -171,6 +172,14 @@ def poisson_log_likelihood( return -nll + def hessian(self, likelihood="gaussian"): + if likelihood == "gaussian": + return hessian(self.gaussian_log_likelihood)(self.build_params_array()) + elif likelihood == "poisson": + return hessian(self.poisson_log_likelihood)(self.build_params_array()) + else: + raise ValueError(f"Unknown likelihood type: {likelihood}") + def total_flux(self, window=None) -> torch.Tensor: F = self(window=window) return torch.sum(F.data) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 12b0fa63..cf8b1c68 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -174,6 +174,18 @@ def match_window(self, image, window, model): ) return use_window + def _ensure_vmap_compatible(self, image, other): + if isinstance(image, ImageList): + for img in image.images: + self._ensure_vmap_compatible(img, other) + return + if isinstance(other, ImageList): + for img in other.images: + self._ensure_vmap_compatible(image, img) + return + if image.identity == other.identity: + image += torch.zeros_like(other.data[0, 0]) + @forward def sample( self, @@ -202,7 +214,9 @@ def sample( except IndexError: # If the model target is not in the image, skip it continue - image += model(window=model.window & use_window) + model_image = model(window=model.window & use_window) + self._ensure_vmap_compatible(image, model_image) + image += model_image return image diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index a21c695b..7578c08d 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -2,6 +2,7 @@ import numpy as np from torch.autograd.functional import jacobian +from torch.func import jacfwd, hessian import torch from torch import Tensor @@ -152,6 +153,11 @@ def sample_image(self, image: Image): return sample def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor): + # return jacfwd( # this should be more efficient, but the trace overhead is too high + # lambda x: self.sample( + # window=window, params=torch.cat((params_pre, x, params_post), dim=-1) + # ).data + # )(params) return jacobian( lambda x: self.sample( window=window, params=torch.cat((params_pre, x, params_post), dim=-1) diff --git a/docs/requirements.txt b/docs/requirements.txt index 78a5747a..73496626 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -8,3 +8,4 @@ photutils scikit-image sphinx sphinx-rtd-theme +tqdm diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index 6689a44c..aa0f2c1a 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -17,6 +17,7 @@ "source": [ "%load_ext autoreload\n", "%autoreload 2\n", + "%matplotlib inline\n", "\n", "import torch\n", "import numpy as np\n", @@ -24,15 +25,19 @@ "from matplotlib.patches import Ellipse\n", "from scipy.stats import gaussian_kde as kde\n", "from scipy.stats import norm\n", + "from tqdm import tqdm\n", "\n", - "%matplotlib inline\n", "import astrophot as ap" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [ + "hide-input" + ] + }, "outputs": [], "source": [ "# Setup a fitting problem. You can ignore this cell to start, it just makes some test data to fit\n", @@ -329,16 +334,29 @@ "outputs": [], "source": [ "MODEL = initialize_model(target, False)\n", + "\n", + "res_lm = ap.fit.LM(MODEL, verbose=1).fit()\n", + "print(res_lm.message)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "MODEL_init = initialize_model(target, False)\n", "fig, axarr = plt.subplots(1, 4, figsize=(24, 5))\n", "plt.subplots_adjust(wspace=0.1)\n", - "ap.plots.model_image(fig, axarr[0], MODEL)\n", + "ap.plots.model_image(fig, axarr[0], MODEL_init)\n", "axarr[0].set_title(\"Model before optimization\")\n", - "ap.plots.residual_image(fig, axarr[1], MODEL, normalize_residuals=True)\n", + "ap.plots.residual_image(fig, axarr[1], MODEL_init, normalize_residuals=True)\n", "axarr[1].set_title(\"Residuals before optimization\")\n", "\n", - "res_lm = ap.fit.LM(MODEL, verbose=1).fit()\n", - "print(res_lm.message)\n", - "\n", "ap.plots.model_image(fig, axarr[2], MODEL)\n", "axarr[2].set_title(\"Model after optimization\")\n", "ap.plots.residual_image(fig, axarr[3], MODEL, normalize_residuals=True)\n", @@ -401,7 +419,11 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [ + "hide-input" + ] + }, "outputs": [], "source": [ "MODEL_init = initialize_model(target, False)\n", @@ -485,16 +507,29 @@ "outputs": [], "source": [ "MODEL = initialize_model(target, False)\n", + "\n", + "res_scipy = ap.fit.ScipyFit(MODEL, method=\"SLSQP\", verbose=1).fit()\n", + "print(res_scipy.scipy_res)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "MODEL_init = initialize_model(target, False)\n", "fig, axarr = plt.subplots(1, 4, figsize=(24, 5))\n", "plt.subplots_adjust(wspace=0.1)\n", - "ap.plots.model_image(fig, axarr[0], MODEL)\n", + "ap.plots.model_image(fig, axarr[0], MODEL_init)\n", "axarr[0].set_title(\"Model before optimization\")\n", - "ap.plots.residual_image(fig, axarr[1], MODEL, normalize_residuals=True)\n", + "ap.plots.residual_image(fig, axarr[1], MODEL_init, normalize_residuals=True)\n", "axarr[1].set_title(\"Residuals before optimization\")\n", "\n", - "res_scipy = ap.fit.ScipyFit(MODEL, method=\"SLSQP\", verbose=1).fit()\n", - "print(res_scipy.scipy_res)\n", - "\n", "ap.plots.model_image(fig, axarr[2], MODEL)\n", "axarr[2].set_title(\"Model after optimization\")\n", "ap.plots.residual_image(fig, axarr[3], MODEL, normalize_residuals=True)\n", @@ -502,13 +537,6 @@ "plt.show()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, { "cell_type": "markdown", "metadata": {}, @@ -527,15 +555,28 @@ "outputs": [], "source": [ "MODEL = initialize_model(target, False)\n", + "\n", + "res_grad = ap.fit.Grad(MODEL, verbose=1, max_iter=1000, optim_kwargs={\"lr\": 5e-2}).fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "MODEL_init = initialize_model(target, False)\n", "fig, axarr = plt.subplots(1, 4, figsize=(24, 5))\n", "plt.subplots_adjust(wspace=0.1)\n", - "ap.plots.model_image(fig, axarr[0], MODEL)\n", + "ap.plots.model_image(fig, axarr[0], MODEL_init)\n", "axarr[0].set_title(\"Model before optimization\")\n", - "ap.plots.residual_image(fig, axarr[1], MODEL, normalize_residuals=True)\n", + "ap.plots.residual_image(fig, axarr[1], MODEL_init, normalize_residuals=True)\n", "axarr[1].set_title(\"Residuals before optimization\")\n", "\n", - "res_grad = ap.fit.Grad(MODEL, verbose=1, max_iter=1000, optim_kwargs={\"lr\": 5e-2}).fit()\n", - "\n", "ap.plots.model_image(fig, axarr[2], MODEL)\n", "axarr[2].set_title(\"Model after optimization\")\n", "ap.plots.residual_image(fig, axarr[3], MODEL, normalize_residuals=True)\n", @@ -547,72 +588,131 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## No U-Turn Sampler (NUTS)\n", - "\n", - "Unlike the above methods, `ap.fit.NUTS` does not stricktly seek a minimum $\\chi^2$, instead it is an MCMC method which seeks to explore the likelihood space and provide a full posterior in the form of random samples. The NUTS method in AstroPhot is actually just a wrapper for the Pyro implementation (__[link here](https://docs.pyro.ai/en/stable/index.html)__). Most of the functionality can be accessed this way, though for very advanced applications it may be necessary to manually interface with Pyro (this is not very challenging as AstroPhot is fully differentiable).\n", - "\n", - "The first iteration of NUTS is always very slow since it compiles the forward method on the fly, after that each sample is drawn much faster. The warmup iterations take longer as the method is exploring the space and determining the ideal step size and mass matrix for fast integration with minimal numerical error (we only do 20 warmup steps here, if something goes wrong just try rerunning). Once the algorithm begins sampling it is able to move quickly (for an MCMC) through the parameter space. For many models, the NUTS sampler is able to collect nearly completely uncorrelated samples, meaning that even 100 is enough to get a good estimate of the posterior.\n", + "## Metropolis Adjusted Langevin Algorithm (MALA)\n", "\n", - "NUTS is far faster than other MCMC implementations such as a standard Metropolis Hastings MCMC. However, it is still a lot slower than the other optimizers (LM) since it is doing more than seeking a single high likelihood point, it is fully exploring the likelihood space. In simple cases, the automatic covariance matrix from LM is likely good enough, but if one really needs access to the full posterior of a complex model then NUTS is the best way to get it.\n", - "\n", - "For an excellent introduction to the Hamiltonian Monte-Carlo and a high level explanation of NUTS see this review:\n", - "__[Betancourt 2018](https://arxiv.org/pdf/1701.02434.pdf)__" + "This is one of the simplest gradient based samplers, and is very powerful. The standard Metropolis Hastings algorithm will use a gaussian proposal distribution then use the Metropolis Hastings accept/reject stage. MALA uses gradient information to determine a better proposal distribution locally (while maintaining detailed balance) and then uses the Metropolis Hastings accept/reject stage. We have not integrated this algorithm directly into AstroPhot, instead we write it all out below to show the simplicity and power of the method. Expand the cell below if you are interested!" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [ + "hide-cell" + ] + }, "outputs": [], "source": [ - "# MODEL = initialize_model(target, False)\n", + "def mala_sampler(initial_state, log_prob, log_prob_grad, num_samples, epsilon, mass_matrix):\n", + " \"\"\"Metropolis Adjusted Langevin Algorithm (MALA) sampler with batch dimension.\n", + "\n", + " Args:\n", + " - initial_state (numpy array): Initial states of the chains, shape (num_chains, dim).\n", + " - log_prob (function): Function to compute the log probabilities of the current states.\n", + " - log_prob_grad (function): Function to compute the gradients of the log probabilities.\n", + " - num_samples (int): Number of samples to generate.\n", + " - epsilon (float): Step size for the Langevin dynamics.\n", + " - mass_matrix (numpy array): Mass matrix, shape (dim, dim), used to scale the dynamics.\n", + "\n", + "\n", + " Returns:\n", + " - samples (numpy array): Array of sampled values, shape (num_samples, num_chains, dim).\n", + " \"\"\"\n", + " num_chains, dim = initial_state.shape\n", + " samples = np.zeros((num_samples, num_chains, dim))\n", + " x_current = np.array(initial_state)\n", + " current_log_prob = log_prob(x_current)\n", + " inv_mass_matrix = np.linalg.inv(mass_matrix)\n", + " chol_inv_mass_matrix = np.linalg.cholesky(inv_mass_matrix)\n", + "\n", + " pbar = tqdm(range(num_samples))\n", + " acceptance_rate = np.zeros([0])\n", + " for i in pbar:\n", + " gradients = log_prob_grad(x_current)\n", + " noise = np.dot(np.random.randn(num_chains, dim), chol_inv_mass_matrix.T)\n", + " proposal = (\n", + " x_current + 0.5 * epsilon**2 * np.dot(gradients, inv_mass_matrix) + epsilon * noise\n", + " )\n", + "\n", + " # proposal = x_current + 0.5 * epsilon**2 * gradients + epsilon * np.random.randn(num_chains, *dim)\n", + " proposal_log_prob = log_prob(proposal)\n", + " # Metropolis-Hastings acceptance criterion, computed for each chain\n", + " acceptance_log_prob = proposal_log_prob - current_log_prob\n", + " accept = np.log(np.random.rand(num_chains)) < acceptance_log_prob\n", + " acceptance_rate = np.concatenate([acceptance_rate, accept])\n", + " pbar.set_description(f\"Acceptance rate: {acceptance_rate.mean():.2f}\")\n", + "\n", + " # Update states where accepted\n", + " x_current[accept] = proposal[accept]\n", + " current_log_prob[accept] = proposal_log_prob[accept]\n", "\n", - "# # Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", - "# # In general, NUTS is quite fast to do burn-in so this is often not needed\n", - "# res1 = ap.fit.LM(MODEL).fit()\n", - "\n", - "# # Run the NUTS sampler\n", - "# res_nuts = ap.fit.NUTS(\n", - "# MODEL,\n", - "# warmup=20,\n", - "# max_iter=100,\n", - "# inv_mass=res1.covariance_matrix,\n", - "# ).fit()" + " samples[i] = x_current\n", + "\n", + " return samples" ] }, { - "cell_type": "markdown", - "metadata": {}, + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "hide-output" + ] + }, + "outputs": [], "source": [ - "Note that there is no \"after optimization\" image above, because optimization was not done, it was full likelihood exploration. We can now create a corner plot with 2D projections of the 22 dimensional space that NUTS was exploring. The resulting corner plot is about what you would expect to get with 100 samples drawn from the multivariate gaussian found by LM above. If you run it again with more samples then the results will get even smoother." + "MODEL = initialize_model(target, False)\n", + "\n", + "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", + "res1 = ap.fit.LM(MODEL).fit()\n", + "\n", + "\n", + "def density(x):\n", + " x = torch.as_tensor(x, dtype=ap.config.DTYPE)\n", + " return torch.vmap(MODEL.gaussian_log_likelihood)(x).detach().cpu().numpy()\n", + "\n", + "\n", + "sim_grad = torch.vmap(torch.func.grad(MODEL.gaussian_log_likelihood))\n", + "\n", + "\n", + "def density_grad(x):\n", + " x = torch.as_tensor(x, dtype=ap.config.DTYPE)\n", + " return sim_grad(x).numpy()\n", + "\n", + "\n", + "x0 = MODEL.build_params_array().detach().cpu().numpy()\n", + "x0 = x0 + np.random.normal(scale=0.001, size=(8, x0.shape[0]))\n", + "chain_mala = mala_sampler(\n", + " initial_state=x0,\n", + " log_prob=density,\n", + " log_prob_grad=density_grad,\n", + " num_samples=300,\n", + " epsilon=2e-1,\n", + " mass_matrix=torch.linalg.inv(res1.covariance_matrix).detach().cpu().numpy(),\n", + ")\n", + "chain_mala = chain_mala.reshape(-1, chain_mala.shape[-1])" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [ + "hide-input" + ] + }, "outputs": [], "source": [ - "# corner plot of the posterior\n", - "# observe that it is very similar to the corner plot from the LM optimization since this case can be roughly\n", - "# approximated as a multivariate gaussian centered on the maximum likelihood point\n", - "# param_names = list(MODEL.parameters.vector_names())\n", - "# i = 0\n", - "# while i < len(param_names):\n", - "# param_names[i] = param_names[i].replace(\" \", \"\")\n", - "# if \"center\" in param_names[i]:\n", - "# center_name = param_names.pop(i)\n", - "# param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", - "# param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", - "# i += 1\n", - "\n", - "# set, sky = true_params()\n", - "# corner_plot(\n", - "# res_nuts.chain.detach().cpu().numpy(),\n", - "# labels=param_names,\n", - "# figsize=(20, 20),\n", - "# true_values=np.concatenate((sky, set.ravel())),\n", - "# )" + "# # corner plot of the posterior\n", + "param_names = list(MODEL.build_params_array_names())\n", + "\n", + "set, sky = true_params()\n", + "corner_plot(\n", + " chain_mala,\n", + " labels=param_names,\n", + " figsize=(20, 20),\n", + " true_values=np.concatenate((sky, set.ravel())),\n", + ")" ] }, { @@ -621,55 +721,55 @@ "source": [ "## Hamiltonian Monte-Carlo (HMC)\n", "\n", - "The `ap.fit.HMC` is a simpler variant of the NUTS sampler. HMC takes a fixed number of steps at a fixed step size following Hamiltonian dynamics. This is in contrast to NUTS which attempts to optimally choose these parameters. HMC may be suitable in some cases where NUTS is unable to find ideal parameters. Also in some cases where you already know the pretty good step parameters HMC may run faster. If you don't want to fiddle around with parameters then stick with NUTS, HMC results will still have autocorrelation which will depend on the problem and choice of step parameters." + "The `ap.fit.HMC` takes a fixed number of steps at a fixed step size following Hamiltonian dynamics. This is in contrast to NUTS which attempts to optimally choose these parameters. The simplest way to think of HMC is as performing a number of MALA steps all in one go, so if `leapfrog_steps = 10` then HMC is very similar to running MALA then taking every tenth step and adding it to the chain. HMC results will still have autocorrelation which will depend on the problem and choice of step parameters." ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [ + "hide-output" + ] + }, "outputs": [], "source": [ - "# MODEL = initialize_model(target, False)\n", + "MODEL = initialize_model(target, False)\n", "\n", - "# # Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", - "# res1 = ap.fit.LM(MODEL).fit()\n", - "\n", - "# # Run the HMC sampler\n", - "# res_hmc = ap.fit.HMC(\n", - "# MODEL,\n", - "# warmup=1,\n", - "# max_iter=150,\n", - "# epsilon=1e-1,\n", - "# leapfrog_steps=10,\n", - "# inv_mass=res1.covariance_matrix,\n", - "# ).fit()" + "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", + "res1 = ap.fit.LM(MODEL).fit()\n", + "\n", + "# Run the HMC sampler\n", + "res_hmc = ap.fit.HMC(\n", + " MODEL,\n", + " warmup=1,\n", + " max_iter=150,\n", + " epsilon=1e-1,\n", + " leapfrog_steps=10,\n", + " inv_mass=res1.covariance_matrix,\n", + ").fit()" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [ + "hide-input" + ] + }, "outputs": [], "source": [ "# corner plot of the posterior\n", - "# param_names = list(MODEL.parameters.vector_names())\n", - "# i = 0\n", - "# while i < len(param_names):\n", - "# param_names[i] = param_names[i].replace(\" \", \"\")\n", - "# if \"center\" in param_names[i]:\n", - "# center_name = param_names.pop(i)\n", - "# param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", - "# param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", - "# i += 1\n", - "\n", - "# set, sky = true_params()\n", - "# corner_plot(\n", - "# res_hmc.chain.detach().cpu().numpy(),\n", - "# labels=param_names,\n", - "# figsize=(20, 20),\n", - "# true_values=np.concatenate((sky, set.ravel())),\n", - "# )" + "param_names = list(MODEL.build_params_array_names())\n", + "\n", + "set, sky = true_params()\n", + "corner_plot(\n", + " res_hmc.chain.detach().cpu().numpy(),\n", + " labels=param_names,\n", + " figsize=(20, 20),\n", + " true_values=np.concatenate((sky, set.ravel())),\n", + ")" ] }, { @@ -678,7 +778,7 @@ "source": [ "## Metropolis Hastings\n", "\n", - "This is the classic MCMC algorithm using the Metropolis Hastngs accept step identified with `ap.fit.MHMCMC`. One can set the gaussian random step scale and then explore the posterior. While this technically always works, in practice it can take exceedingly long to actually converge to the posterior. This is because the step size must be set very small to have a reasonable likelihood of accepting each step, so it never moves very far in parameter space. With each subsequent sample being very close to the previous sample it can take a long time for it to wander away from its starting point. In the example below it would take an extremely long time for the chain to converge. Instead of waiting that long, we demonstrate the functionality with 1000 steps, but suggest using NUTS for any real world problem. Still, if there is something NUTS can't handle (a function that isn't differentiable) then MHMCMC can save the day (even if it takes all day to do it)." + "This is the more standard MCMC algorithm using the Metropolis Hastngs accept step identified with `ap.fit.MHMCMC`. Under the hood, this is just a wrapper for the excellent `emcee` package, if you want to take advantage of more `emcee` features you can very easily use `ap.fit.MHMCMC` as a starting point. However, one should keep in mind that for large models it can take exceedingly long to actually converge to the posterior. Instead of waiting that long, we demonstrate the functionality with 100 steps (and 30 chains), but suggest using MALA for any real world problem. Still, if there is something NUTS can't handle (a function that isn't differentiable) then MHMCMC can save the day (even if it takes all day to do it)." ] }, { @@ -687,13 +787,15 @@ "metadata": {}, "outputs": [], "source": [ - "# MODEL = initialize_model(target, False)\n", + "MODEL = initialize_model(target, False)\n", "\n", - "# # Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", - "# res1 = ap.fit.LM(MODEL).fit()\n", + "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", + "print(\"running LM fit\")\n", + "res1 = ap.fit.LM(MODEL).fit()\n", "\n", - "# # Run the HMC sampler\n", - "# res_mh = ap.fit.MHMCMC(MODEL, verbose=1, max_iter=1000, epsilon=1e-4, report_after=np.inf).fit()" + "# Run the HMC sampler\n", + "print(\"running MHMCMC sampling\")\n", + "res_mh = ap.fit.MHMCMC(MODEL, verbose=1, max_iter=100).fit()" ] }, { @@ -703,27 +805,16 @@ "outputs": [], "source": [ "# corner plot of the posterior\n", - "# note that, even 1000 samples is not enough to overcome the autocorrelation so the posterior has not converged.\n", - "# In fact it is not even close to convergence as can be seen by the multi-modal blobs in the posterior since this\n", - "# problem is unimodal (except the modes where models are swapped). It is almost never worthwhile to use this\n", - "# sampler except as a sanity check on very simple models.\n", - "# param_names = list(MODEL.parameters.vector_names())\n", - "# i = 0\n", - "# while i < len(param_names):\n", - "# param_names[i] = param_names[i].replace(\" \", \"\")\n", - "# if \"center\" in param_names[i]:\n", - "# center_name = param_names.pop(i)\n", - "# param_names.insert(i, center_name.replace(\"center\", \"y\"))\n", - "# param_names.insert(i, center_name.replace(\"center\", \"x\"))\n", - "# i += 1\n", - "\n", - "# set, sky = true_params()\n", - "# corner_plot(\n", - "# res_mh.chain[::10], # thin by a factor 10 so the plot works in reasonable time\n", - "# labels=param_names,\n", - "# figsize=(20, 20),\n", - "# true_values=np.concatenate((sky, set.ravel())),\n", - "# )" + "# note that, even 3000 samples is not enough to overcome the autocorrelation so the posterior has not converged.\n", + "param_names = list(MODEL.build_params_array_names())\n", + "\n", + "set, sky = true_params()\n", + "corner_plot(\n", + " res_mh.chain[::10], # thin by a factor 10 so the plot works in reasonable time\n", + " labels=param_names,\n", + " figsize=(20, 20),\n", + " true_values=np.concatenate((sky, set.ravel())),\n", + ")" ] }, { diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 4639ff5b..89e2655d 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -81,6 +81,27 @@ "plt.show()" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from time import time\n", + "\n", + "x = model1.build_params_array()\n", + "x = x.repeat(8, 1)\n", + "start = time()\n", + "for _ in range(100):\n", + " imgs = torch.vmap(lambda x: model1(x).data)(x)\n", + "print(\"Inference time:\", time() - start)\n", + "print(\"Inferred image shape:\", imgs.shape)\n", + "start = time()\n", + "for _ in range(100):\n", + " jac = model1.jacobian()\n", + "print(\"Jacobian time:\", time() - start)" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index d14cab2f..d25d6b9e 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -148,6 +148,20 @@ "groupmodel.initialize()" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "\n", + "x = groupmodel.build_params_array()\n", + "x = x.repeat(5, 1)\n", + "imgs = torch.vmap(lambda x: groupmodel(x).data)(x)\n", + "print(imgs.shape)" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/tests/test_fit.py b/tests/test_fit.py index bbf03750..ccfddc17 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -112,6 +112,36 @@ def test_fitters_iter(): assert ll_final > ll_init, f"Iter should improve the log likelihood" assert pll_final > pll_init, f"Iter should improve the poisson log likelihood" + # test hessian + Hgauss = model.hessian(likelihood="gaussian") + assert torch.all(torch.isfinite(Hgauss)), "Hessian should be finite for Gaussian likelihood" + Hpoisson = model.hessian(likelihood="poisson") + assert torch.all(torch.isfinite(Hpoisson)), "Hessian should be finite for Poisson likelihood" + + +def test_hessian(): + target = make_basic_sersic() + model = ap.Model( + name="test sersic", + model_type="sersic galaxy model", + center=[20, 20], + PA=np.pi, + q=0.7, + n=2, + Re=15, + Ie=10.0, + target=target, + ) + model.initialize() + Hgauss = model.hessian(likelihood="gaussian") + assert torch.all(torch.isfinite(Hgauss)), "Hessian should be finite for Gaussian likelihood" + Hpoisson = model.hessian(likelihood="poisson") + assert torch.all(torch.isfinite(Hpoisson)), "Hessian should be finite for Poisson likelihood" + assert Hgauss is not None, "Hessian should be computed for Gaussian likelihood" + assert Hpoisson is not None, "Hessian should be computed for Poisson likelihood" + with pytest.raises(ValueError): + model.hessian(likelihood="unknown") + def test_gradient(): target = make_basic_sersic() From 0b3edf8a36e5457ab386da3b6b9a7a242f6cb7aa Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 24 Jul 2025 14:38:20 -0400 Subject: [PATCH 089/191] adjustments to fitters --- astrophot/fit/__init__.py | 9 +- astrophot/fit/gradient.py | 2 +- astrophot/fit/iterative.py | 8 +- astrophot/fit/lm.py | 46 +---- astrophot/fit/minifit.py | 8 +- astrophot/image/jacobian_image.py | 39 ++++- astrophot/models/mixins/sample.py | 3 + docs/source/tutorials/FittingMethods.ipynb | 36 ---- docs/source/tutorials/ImageAlignment.py | 191 +++++++++++++++++++++ tests/test_fit.py | 47 ++--- 10 files changed, 254 insertions(+), 135 deletions(-) create mode 100644 docs/source/tutorials/ImageAlignment.py diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 7cd5616d..fbed6a89 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,14 +1,9 @@ -# from .base import * from .lm import LM - from .gradient import Grad from .iterative import Iter - from .scipy_fit import ScipyFit - -# from .minifit import * - +from .minifit import MiniFit from .hmc import HMC from .mhmcmc import MHMCMC -__all__ = ["LM", "Grad", "Iter", "ScipyFit", "HMC", "MHMCMC"] +__all__ = ["LM", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC"] diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index b366b846..abbe3dba 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -71,7 +71,7 @@ def __init__( self.optim_kwargs = optim_kwargs self.report_freq = report_freq - # Default learning rate if none given. Equalt to 1 / sqrt(parames) + # Default learning rate if none given. Equal to 1 / sqrt(parames) if "lr" not in self.optim_kwargs: self.optim_kwargs["lr"] = 0.1 / (len(self.current_state) ** (0.5)) diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index b72d54a2..7b569fcb 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -132,13 +132,7 @@ def step(self) -> None: self.iteration += 1 - def fit(self, update_uncertainty=True) -> BaseOptimizer: - """ - Fit the models to the target. - - - """ - + def fit(self) -> BaseOptimizer: self.iteration = 0 self.Y = self.model(params=self.current_state) start_fit = time() diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 7b0b0fff..3f9574c2 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -110,47 +110,6 @@ class LM(BaseOptimizer): state, and various other optional parameters as inputs and seeks to find the parameters that minimize the cost function. - Args: - model: The model to be optimized. - initial_state (Sequence): Initial values for the parameters to be optimized. - max_iter (int): Maximum number of iterations for the algorithm. - relative_tolerance (float): Tolerance level for relative change in cost function value to trigger termination of the algorithm. - fit_parameters_identity: Used to select a subset of parameters. This is mostly used internally. - verbose: Controls the verbosity of the output during optimization. A higher value results in more detailed output. If not provided, defaults to 0 (no output). - max_step_iter (optional): The maximum number of steps while searching for chi^2 improvement on a single Jacobian evaluation. Default is 10. - curvature_limit (optional): Controls how cautious the optimizer is for changing curvature. It should be a number greater than 0, where smaller is more cautious. Default is 1. - Lup and Ldn (optional): These adjust the step sizes for the damping parameter. Default is 5 and 3 respectively. - L0 (optional): This is the starting damping parameter. For easy problems with good initialization, this can be set lower. Default is 1. - acceleration (optional): Controls the use of geodesic acceleration, which can be helpful in some scenarios. Set 1 for full acceleration, 0 for no acceleration. Default is 0. - - Here is some basic usage of the LM optimizer: - - .. code-block:: python - - import astrophot as ap - - # build model - # ... - - # Initialize model parameters - model.initialize() - - # Fit the parameters - result = ap.fit.lm(model, verbose=1) - - # Check that a minimum was found - print(result.message) - - # See the minimum chi^2 value - print(f"min chi2: {result.res_loss()}") - - # Update parameter uncertainties - result.update_uncertainty() - - # Extract multivariate Gaussian of uncertainties - mu = result.res() - cov = result.covariance_matrix - """ def __init__( @@ -178,11 +137,10 @@ def __init__( self.max_iter = max_iter # Maximum number of steps while searching for chi^2 improvement on a single jacobian evaluation self.max_step_iter = max_step_iter - # These are the adjustment step sized for the damping parameter self.Lup = Lup self.Ldn = Ldn - # This is the starting damping parameter, for easy problems with good initialization, this can be set lower self.L = L0 + # mask fit_mask = self.model.fit_mask() if isinstance(fit_mask, tuple): @@ -215,7 +173,7 @@ def __init__( self.W = torch.as_tensor(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ self.mask ] - elif model.target.has_variance: + elif model.target.has_weight: self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] else: self.W = torch.ones_like(self.Y) diff --git a/astrophot/fit/minifit.py b/astrophot/fit/minifit.py index d56fecf7..20ad1a1c 100644 --- a/astrophot/fit/minifit.py +++ b/astrophot/fit/minifit.py @@ -4,7 +4,7 @@ import numpy as np from .base import BaseOptimizer -from ..models import AstroPhot_Model +from ..models import Model from .lm import LM from .. import config @@ -14,8 +14,8 @@ class MiniFit(BaseOptimizer): def __init__( self, - model: AstroPhot_Model, - downsample_factor: int = 1, + model: Model, + downsample_factor: int = 2, max_pixels: int = 10000, method: BaseOptimizer = LM, initial_state: np.ndarray = None, @@ -37,7 +37,7 @@ def fit(self) -> BaseOptimizer: target_area = self.model.target[self.model.window] while True: small_target = target_area.reduce(self.downsample_factor) - if small_target.size < self.max_pixels: + if np.prod(small_target.shape) < self.max_pixels: break self.downsample_factor += 1 diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index ea1bbb19..9565f1b9 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Union import torch @@ -33,19 +33,25 @@ def __init__( def copy(self, **kwargs): return super().copy(parameters=self.parameters, **kwargs) + def match_parameters(self, other: Union["JacobianImage", "JacobianImageList", List]): + self_i = [] + other_i = [] + other_parameters = other if isinstance(other, list) else other.parameters + for i, other_param in enumerate(other_parameters): + if other_param in self.parameters: + self_i.append(self.parameters.index(other_param)) + other_i.append(i) + return self_i, other_i + def __iadd__(self, other: "JacobianImage"): if not isinstance(other, JacobianImage): raise InvalidImage("Jacobian images can only add with each other, not: type(other)") self_indices = self.get_indices(other.window) other_indices = other.get_indices(self.window) - for i, other_identity in enumerate(other.parameters): - if other_identity in self.parameters: - other_loc = self.parameters.index(other_identity) - else: - continue - self._data[self_indices[0], self_indices[1], other_loc] += other.data[ - other_indices[0], other_indices[1], i + for self_i, other_i in zip(*self.match_parameters(other)): + self._data[self_indices[0], self_indices[1], self_i] += other.data[ + other_indices[0], other_indices[1], other_i ] return self @@ -71,6 +77,13 @@ def __init__(self, *args, **kwargs): f"JacobianImageList can only hold JacobianImage objects, not {tuple(type(image) for image in self.images)}" ) + @property + def parameters(self) -> List[str]: + """List of parameters for the jacobian images in this list.""" + if not self.images: + return [] + return self.images[0].parameters + def flatten(self, attribute="data"): if len(self.images) > 1: for image in self.images[1:]: @@ -79,3 +92,13 @@ def flatten(self, attribute="data"): "Jacobian image list sub-images track different parameters. Please initialize with all parameters that will be used." ) return torch.cat(tuple(image.flatten(attribute) for image in self.images), dim=0) + + def match_parameters(self, other: Union[JacobianImage, "JacobianImageList", List[str]]): + self_i = [] + other_i = [] + other_parameters = other if isinstance(other, list) else other.parameters + for i, other_param in enumerate(other_parameters): + if other_param in self.parameters: + self_i.append(self.parameters.index(other_param)) + other_i.append(i) + return self_i, other_i diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 7578c08d..2f512bf5 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -198,6 +198,9 @@ def jacobian( return jac_img identities = self.build_params_array_identities() + if len(jac_img.match_parameters(identities)[0]) == 0: + return jac_img + target = self.target[window] if len(params) > self.jacobian_maxparams: # handle large number of parameters chunksize = len(params) // self.jacobian_maxparams + 1 diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index aa0f2c1a..998d9fd7 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -441,42 +441,6 @@ "plt.show()" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Iterative Fit (parameters)\n", - "\n", - "This is an iterative fitter identified as `ap.fit.IterParam` and is generally employed for complicated models where it is not feasible to hold all the relevant data in memory at once. This iterative fitter will cycle through chunks of parameters and fit them one at a time to the image. This can be a very robust way to deal with some fits, especially if the overlap between models is not too strong. This is very similar to the other iterative fitter, however it is necessary for certain fitting circumstances when the problem can't be broken down into individual component models. This occurs, for example, when the models have many shared (constrained) parameters and there is no obvious way to break down sub-groups of models.\n", - "\n", - "Note that this is iterating over the parameters, not the models. This allows it to handle parameter covariances even for very large models (if they happen to land in the same chunk). However, for this to work it must evaluate the whole model at each iteration making it somewhat slower than the regular `Iter` fitter, though it can make up for it by fitting larger chunks at a time which makes the whole optimization faster.\n", - "\n", - "By only fitting a subset of parameters at a time it is possible to get caught in a local minimum, or to get out of a local minimum that a different fitter was stuck in. For this reason it can be good to mix-and-match the iterative optimizers so they can help each other get unstuck. Since this iterative fitter chooses parameters randomly, it can sometimes get itself unstuck if it gets a lucky combination of parameters. Generally giving it more parameters to work with at a time is better." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# MODEL = initialize_model(target, False)\n", - "# fig, axarr = plt.subplots(1, 4, figsize=(24, 5))\n", - "# plt.subplots_adjust(wspace=0.1)\n", - "# ap.plots.model_image(fig, axarr[0], MODEL)\n", - "# axarr[0].set_title(\"Model before optimization\")\n", - "# ap.plots.residual_image(fig, axarr[1], MODEL, normalize_residuals=True)\n", - "# axarr[1].set_title(\"Residuals before optimization\")\n", - "\n", - "# res_iterlm = ap.fit.Iter_LM(MODEL, chunks=11, verbose=1).fit()\n", - "\n", - "# ap.plots.model_image(fig, axarr[2], MODEL)\n", - "# axarr[2].set_title(\"Model after optimization\")\n", - "# ap.plots.residual_image(fig, axarr[3], MODEL, normalize_residuals=True)\n", - "# axarr[3].set_title(\"Residuals after optimization\")\n", - "# plt.show()" - ] - }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/source/tutorials/ImageAlignment.py b/docs/source/tutorials/ImageAlignment.py new file mode 100644 index 00000000..48a40273 --- /dev/null +++ b/docs/source/tutorials/ImageAlignment.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# coding: utf-8 + +# # Aligning Images +# +# In AstroPhot, the image WCS is part of the model and so can be optimized alongside other model parameters. Here we will demonstrate a basic example of image alignment, but the sky is the limit, you can perform highly detailed image alignment with AstroPhot! + +# In[ ]: + + +import astrophot as ap +import matplotlib.pyplot as plt +import numpy as np +import torch +import socket + +socket.setdefaulttimeout(60) + + +# ## Relative shift +# +# Often the WCS solution is already really good, we just need a local shift in x and/or y to get things just right. Lets start by optimizing a translation in the WCS that improves the fit for our models! + +# In[ ]: + + +target_r = ap.TargetImage( + filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=r", + name="target_r", + variance="auto", +) +target_g = ap.TargetImage( + filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=g", + name="target_g", + variance="auto", +) + +# Uh-oh! our images are misaligned by 1 pixel, this will cause problems! +target_g.crpix = target_g.crpix + 1 + +fig, axarr = plt.subplots(1, 2, figsize=(15, 7)) +ap.plots.target_image(fig, axarr[0], target_r) +axarr[0].set_title("Target Image (r-band)") +ap.plots.target_image(fig, axarr[1], target_g) +axarr[1].set_title("Target Image (g-band)") +plt.show() + + +# In[ ]: + + +# fmt: off +# r-band model +psfr = ap.Model(name="psfr", model_type="moffat psf model", n=2, Rd=1.0, target=target_r.psf_image(data=np.zeros((51, 51)))) +star1r = ap.Model(name="star1-r", model_type="point model", window=[0, 60, 80, 135], center=[12, 9], psf=psfr, target=target_r) +star2r = ap.Model(name="star2-r", model_type="point model", window=[40, 90, 20, 70], center=[3, -7], psf=psfr, target=target_r) +star3r = ap.Model(name="star3-r", model_type="point model", window=[109, 150, 40, 90], center=[-15, -3], psf=psfr, target=target_r) +modelr = ap.Model(name="model-r", model_type="group model", models=[star1r, star2r, star3r], target=target_r) + +# g-band model +psfg = ap.Model(name="psfg", model_type="moffat psf model", n=2, Rd=1.0, target=target_g.psf_image(data=np.zeros((51, 51)))) +star1g = ap.Model(name="star1-g", model_type="point model", window=[0, 60, 80, 135], center=star1r.center, psf=psfg, target=target_g) +star2g = ap.Model(name="star2-g", model_type="point model", window=[40, 90, 20, 70], center=star2r.center, psf=psfg, target=target_g) +star3g = ap.Model(name="star3-g", model_type="point model", window=[109, 150, 40, 90], center=star3r.center, psf=psfg, target=target_g) +modelg = ap.Model(name="model-g", model_type="group model", models=[star1g, star2g, star3g], target=target_g) + +# total model +target_full = ap.TargetImageList([target_r, target_g]) +model = ap.Model(name="model", model_type="group model", models=[modelr, modelg], target=target_full) + +# fmt: on +fig, axarr = plt.subplots(1, 2, figsize=(15, 7)) +ap.plots.target_image(fig, axarr, target_full) +axarr[0].set_title("Target Image (r-band)") +axarr[1].set_title("Target Image (g-band)") +ap.plots.model_window(fig, axarr[0], modelr) +ap.plots.model_window(fig, axarr[1], modelg) +plt.show() + + +# In[ ]: + + +model.initialize() +res = ap.fit.LM(model, verbose=1).fit() +fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) +ap.plots.model_image(fig, axarr[0], model) +axarr[0, 0].set_title("Model Image (r-band)") +axarr[0, 1].set_title("Model Image (g-band)") +ap.plots.residual_image(fig, axarr[1], model) +axarr[1, 0].set_title("Residual Image (r-band)") +axarr[1, 1].set_title("Residual Image (g-band)") +plt.show() + + +# Here we see a clear signal of an image misalignment, in the g-band all of the residuals have a dipole in the same direction! Lets free up the position of the g-band image and optimize a shift. This only requires a single line of code! + +# In[ ]: + + +target_g.crtan.to_dynamic() + + +# Now we can optimize the model again, notice how it now has two more parameters. These are the x,y position of the image in the tangent plane. See the AstroPhot coordinate description on the website for more details on why this works. + +# In[ ]: + + +res = ap.fit.LM(model, verbose=1).fit() +fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) +ap.plots.model_image(fig, axarr[0], model) +axarr[0, 0].set_title("Model Image (r-band)") +axarr[0, 1].set_title("Model Image (g-band)") +ap.plots.residual_image(fig, axarr[1], model) +axarr[1, 0].set_title("Residual Image (r-band)") +axarr[1, 1].set_title("Residual Image (g-band)") +plt.show() + + +# Yay! no more dipole. The fits aren't the best, clearly these objects aren't super well described by a single moffat model. But the main goal today was to show that we could align the images very easily. Note, its probably best to start with a reasonably good WCS from the outset, and this two stage approach where we optimize the models and then optimize the models plus a shift might be more stable than just fitting everything at once from the outset. Often for more complex models it is best to start with a simpler model and fit each time you introduce more complexity. + +# ## Shift and rotation +# +# Lets say we really don't trust our WCS, we think something has gone wrong and we want freedom to fully shift and rotate the relative positions of the images relative to each other. How can we do this? + +# In[ ]: + + +def rotate(phi): + """Create a 2D rotation matrix for a given angle in radians.""" + return torch.stack( + [ + torch.stack([torch.cos(phi), -torch.sin(phi)]), + torch.stack([torch.sin(phi), torch.cos(phi)]), + ] + ) + + +# Uh-oh! Our image is misaligned by some small angle +target_g.CD = target_g.CD.value @ rotate(torch.tensor(np.pi / 32, dtype=torch.float64)) +# Uh-oh! our alignment from before has been erased +target_g.crtan.value = (0, 0) + + +# In[ ]: + + +fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) +ap.plots.model_image(fig, axarr[0], model) +axarr[0, 0].set_title("Model Image (r-band)") +axarr[0, 1].set_title("Model Image (g-band)") +ap.plots.residual_image(fig, axarr[1], model) +axarr[1, 0].set_title("Residual Image (r-band)") +axarr[1, 1].set_title("Residual Image (g-band)") +plt.show() + + +# Notice that there is not a universal dipole like in the shift example. Most of the offset is caused by the rotation in this example. + +# In[ ]: + + +# this will control the relative rotation of the g-band image +phi = ap.Param(name="phi", dynamic_value=0.0, dtype=torch.float64) + +# Set the target_g CD matrix to be a function of the rotation angle +# The CD matrix can encode rotation, skew, and rectangular pixels. We +# are only interested in the rotation here. +init_CD = target_g.CD.value.clone() +target_g.CD = lambda p: init_CD @ rotate(p.phi.value) +target_g.CD.link(phi) + +# also optimize the shift of the g-band image +target_g.crtan.to_dynamic() + + +# In[ ]: + + +res = ap.fit.LM(model, verbose=1).fit() +fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) +ap.plots.model_image(fig, axarr[0], model) +axarr[0, 0].set_title("Model Image (r-band)") +axarr[0, 1].set_title("Model Image (g-band)") +ap.plots.residual_image(fig, axarr[1], model) +axarr[1, 0].set_title("Residual Image (r-band)") +axarr[1, 1].set_title("Residual Image (g-band)") +plt.show() + + +# In[ ]: diff --git a/tests/test_fit.py b/tests/test_fit.py index ccfddc17..4d3f3d0c 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -49,8 +49,8 @@ def test_chunk_jacobian(center, PA, q, n, Re): ), "Pixel chunked Jacobian should match full Jacobian" -@pytest.mark.parametrize("fitter", [ap.fit.LM, ap.fit.Grad, ap.fit.ScipyFit, ap.fit.MHMCMC]) -def test_fitters(fitter): +@pytest.fixture +def sersic_model(): target = make_basic_sersic() model = ap.Model( name="test sersic", @@ -64,6 +64,15 @@ def test_fitters(fitter): target=target, ) model.initialize() + return model + + +@pytest.mark.parametrize( + "fitter", [ap.fit.LM, ap.fit.Grad, ap.fit.ScipyFit, ap.fit.MHMCMC, ap.fit.MiniFit] +) +def test_fitters(fitter, sersic_model): + model = sersic_model + model.initialize() ll_init = model.gaussian_log_likelihood() pll_init = model.poisson_log_likelihood() result = fitter(model, max_iter=100).fit() @@ -119,19 +128,8 @@ def test_fitters_iter(): assert torch.all(torch.isfinite(Hpoisson)), "Hessian should be finite for Poisson likelihood" -def test_hessian(): - target = make_basic_sersic() - model = ap.Model( - name="test sersic", - model_type="sersic galaxy model", - center=[20, 20], - PA=np.pi, - q=0.7, - n=2, - Re=15, - Ie=10.0, - target=target, - ) +def test_hessian(sersic_model): + model = sersic_model model.initialize() Hgauss = model.hessian(likelihood="gaussian") assert torch.all(torch.isfinite(Hgauss)), "Hessian should be finite for Gaussian likelihood" @@ -143,20 +141,10 @@ def test_hessian(): model.hessian(likelihood="unknown") -def test_gradient(): - target = make_basic_sersic() +def test_gradient(sersic_model): + model = sersic_model + target = model.target target.weight = 1 / (10 + target.variance.T) - model = ap.Model( - name="test sersic", - model_type="sersic galaxy model", - center=[20, 20], - PA=np.pi, - q=0.7, - n=2, - Re=15, - Ie=10.0, - target=target, - ) model.initialize() x = model.build_params_array() grad = model.gradient() @@ -168,6 +156,9 @@ def test_gradient(): autograd = x.grad assert torch.allclose(grad, autograd, rtol=1e-4), "Gradient should match autograd gradient" + funcgrad = torch.func.grad(model.gaussian_log_likelihood)(x) + assert torch.allclose(grad, funcgrad, rtol=1e-4), "Gradient should match functional gradient" + # class TestHMC(unittest.TestCase): # def test_hmc_sample(self): From 626290f0c895ab547497ee90164db63560e0262d Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 24 Jul 2025 15:13:36 -0400 Subject: [PATCH 090/191] fix group psf normlaization --- astrophot/models/group_psf_model.py | 11 +++ astrophot/models/mixins/transform.py | 2 +- astrophot/plots/image.py | 4 +- docs/source/tutorials/AdvancedPSFModels.ipynb | 77 ++++++++++++++++--- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/astrophot/models/group_psf_model.py b/astrophot/models/group_psf_model.py index aba8a3ce..2d1f977c 100644 --- a/astrophot/models/group_psf_model.py +++ b/astrophot/models/group_psf_model.py @@ -1,6 +1,7 @@ from .group_model_object import GroupModel from ..image import PSFImage from ..errors import InvalidTarget +from ..param import forward __all__ = ["PSFGroupModel"] @@ -11,6 +12,8 @@ class PSFGroupModel(GroupModel): usable = True normalize_psf = True + _options = ("normalize_psf",) + @property def target(self): try: @@ -28,3 +31,11 @@ def target(self, target): pass self._target = target + + @forward + def sample(self, *args, **kwargs): + """Sample the PSF group model on the target image.""" + psf_img = super().sample(*args, **kwargs) + if self.normalize_psf: + psf_img.normalize() + return psf_img diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 0ca75330..5b30098b 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -103,7 +103,7 @@ class SuperEllipseMixin: @forward def radius_metric(self, x, y, C): - return torch.pow(x.abs().pow(C) + y.abs().pow(C), 1.0 / C) + return torch.pow(x.abs().pow(C) + y.abs().pow(C) + self.softening**C, 1.0 / C) class FourierEllipseMixin: diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 194ae199..6cfd2c93 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -7,7 +7,7 @@ import matplotlib from scipy.stats import iqr -from ..models import GroupModel, PSFModel +from ..models import GroupModel, PSFModel, PSFGroupModel from ..image import ImageList, WindowList from .. import config from ..utils.conversions.units import flux_to_sb @@ -114,7 +114,7 @@ def psf_image( vmax=None, **kwargs, ): - if isinstance(psf, PSFModel): + if isinstance(psf, (PSFModel, PSFGroupModel)): psf = psf() # recursive call for target image list if isinstance(psf, ImageList): diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index 484bcb13..5201c988 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -102,6 +102,59 @@ "cell_type": "markdown", "id": "6", "metadata": {}, + "source": [ + "## Group PSF Model\n", + "\n", + "Just like group models for regular models, it is possible to make a `psf group model` to combine multiple psf models." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "psf_model1 = ap.Model(\n", + " name=\"psf1\",\n", + " model_type=\"moffat psf model\",\n", + " n=2,\n", + " Rd=10,\n", + " I0=20, # essentially controls relative flux of this component\n", + " normalize_psf=False, # sub components shouldnt be individually normalized\n", + " target=psf_target,\n", + ")\n", + "psf_model2 = ap.Model(\n", + " name=\"psf2\",\n", + " model_type=\"sersic psf model\",\n", + " n=4,\n", + " Re=5,\n", + " Ie=1,\n", + " normalize_psf=False,\n", + " target=psf_target,\n", + ")\n", + "psf_group_model = ap.Model(\n", + " name=\"psf group\",\n", + " model_type=\"psf group model\",\n", + " target=psf_target,\n", + " models=[psf_model1, psf_model2],\n", + " normalize_psf=True, # group model should normalize the combined PSF\n", + ")\n", + "psf_group_model.initialize()\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 5))\n", + "ap.plots.psf_image(fig, ax[0], psf_group_model)\n", + "ax[0].set_title(\"PSF group model with two PSF models\")\n", + "ap.plots.psf_image(fig, ax[1], psf_group_model.models[0])\n", + "ax[1].set_title(\"PSF model component 1\")\n", + "ap.plots.psf_image(fig, ax[2], psf_group_model.models[1])\n", + "ax[2].set_title(\"PSF model component 2\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, "source": [ "## PSF modeling without stars\n", "\n", @@ -111,7 +164,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -166,7 +219,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -190,7 +243,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -208,7 +261,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -244,7 +297,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -264,7 +317,7 @@ }, { "cell_type": "markdown", - "id": "12", + "id": "14", "metadata": {}, "source": [ "This is truly remarkable! With no stars available we were still able to extract an accurate PSF from the image! To be fair, this example is essentially perfect for this kind of fitting and we knew the true model types (sersic and moffat) from the start. Still, this is a powerful capability in certain scenarios. For many applications (e.g. weak lensing) it is essential to get the absolute best PSF model possible. Here we have shown that not only stars, but galaxies in the field can be useful tools for measuring the PSF!" @@ -273,7 +326,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -287,7 +340,7 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "16", "metadata": {}, "source": [ "There are regions of parameter space that are degenerate and so even in this idealized scenario the PSF model can get stuck. If you rerun the notebook with different random number seeds for pytorch you may find some where the optimizer \"fails by immobility\" this is when it gets stuck in the parameter space and can't find any way to improve the likelihood. In fact most of these \"fail\" fits do return really good values for the PSF model, so keep in mind that the \"fail\" flag only means the possibility of a truly failed fit. Unfortunately, detecting convergence is hard." @@ -295,7 +348,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "17", "metadata": {}, "source": [ "## PSF fitting for faint stars\n", @@ -306,7 +359,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -315,7 +368,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "19", "metadata": {}, "source": [ "## PSF fitting for saturated stars\n", @@ -326,7 +379,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "20", "metadata": {}, "outputs": [], "source": [ From e225e207f72f597d161b69e4a3d9b353f6e2f6d5 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 24 Jul 2025 21:28:55 -0400 Subject: [PATCH 091/191] remove temp file --- docs/source/tutorials/ImageAlignment.py | 191 ------------------------ 1 file changed, 191 deletions(-) delete mode 100644 docs/source/tutorials/ImageAlignment.py diff --git a/docs/source/tutorials/ImageAlignment.py b/docs/source/tutorials/ImageAlignment.py deleted file mode 100644 index 48a40273..00000000 --- a/docs/source/tutorials/ImageAlignment.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 - -# # Aligning Images -# -# In AstroPhot, the image WCS is part of the model and so can be optimized alongside other model parameters. Here we will demonstrate a basic example of image alignment, but the sky is the limit, you can perform highly detailed image alignment with AstroPhot! - -# In[ ]: - - -import astrophot as ap -import matplotlib.pyplot as plt -import numpy as np -import torch -import socket - -socket.setdefaulttimeout(60) - - -# ## Relative shift -# -# Often the WCS solution is already really good, we just need a local shift in x and/or y to get things just right. Lets start by optimizing a translation in the WCS that improves the fit for our models! - -# In[ ]: - - -target_r = ap.TargetImage( - filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=r", - name="target_r", - variance="auto", -) -target_g = ap.TargetImage( - filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=g", - name="target_g", - variance="auto", -) - -# Uh-oh! our images are misaligned by 1 pixel, this will cause problems! -target_g.crpix = target_g.crpix + 1 - -fig, axarr = plt.subplots(1, 2, figsize=(15, 7)) -ap.plots.target_image(fig, axarr[0], target_r) -axarr[0].set_title("Target Image (r-band)") -ap.plots.target_image(fig, axarr[1], target_g) -axarr[1].set_title("Target Image (g-band)") -plt.show() - - -# In[ ]: - - -# fmt: off -# r-band model -psfr = ap.Model(name="psfr", model_type="moffat psf model", n=2, Rd=1.0, target=target_r.psf_image(data=np.zeros((51, 51)))) -star1r = ap.Model(name="star1-r", model_type="point model", window=[0, 60, 80, 135], center=[12, 9], psf=psfr, target=target_r) -star2r = ap.Model(name="star2-r", model_type="point model", window=[40, 90, 20, 70], center=[3, -7], psf=psfr, target=target_r) -star3r = ap.Model(name="star3-r", model_type="point model", window=[109, 150, 40, 90], center=[-15, -3], psf=psfr, target=target_r) -modelr = ap.Model(name="model-r", model_type="group model", models=[star1r, star2r, star3r], target=target_r) - -# g-band model -psfg = ap.Model(name="psfg", model_type="moffat psf model", n=2, Rd=1.0, target=target_g.psf_image(data=np.zeros((51, 51)))) -star1g = ap.Model(name="star1-g", model_type="point model", window=[0, 60, 80, 135], center=star1r.center, psf=psfg, target=target_g) -star2g = ap.Model(name="star2-g", model_type="point model", window=[40, 90, 20, 70], center=star2r.center, psf=psfg, target=target_g) -star3g = ap.Model(name="star3-g", model_type="point model", window=[109, 150, 40, 90], center=star3r.center, psf=psfg, target=target_g) -modelg = ap.Model(name="model-g", model_type="group model", models=[star1g, star2g, star3g], target=target_g) - -# total model -target_full = ap.TargetImageList([target_r, target_g]) -model = ap.Model(name="model", model_type="group model", models=[modelr, modelg], target=target_full) - -# fmt: on -fig, axarr = plt.subplots(1, 2, figsize=(15, 7)) -ap.plots.target_image(fig, axarr, target_full) -axarr[0].set_title("Target Image (r-band)") -axarr[1].set_title("Target Image (g-band)") -ap.plots.model_window(fig, axarr[0], modelr) -ap.plots.model_window(fig, axarr[1], modelg) -plt.show() - - -# In[ ]: - - -model.initialize() -res = ap.fit.LM(model, verbose=1).fit() -fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) -ap.plots.model_image(fig, axarr[0], model) -axarr[0, 0].set_title("Model Image (r-band)") -axarr[0, 1].set_title("Model Image (g-band)") -ap.plots.residual_image(fig, axarr[1], model) -axarr[1, 0].set_title("Residual Image (r-band)") -axarr[1, 1].set_title("Residual Image (g-band)") -plt.show() - - -# Here we see a clear signal of an image misalignment, in the g-band all of the residuals have a dipole in the same direction! Lets free up the position of the g-band image and optimize a shift. This only requires a single line of code! - -# In[ ]: - - -target_g.crtan.to_dynamic() - - -# Now we can optimize the model again, notice how it now has two more parameters. These are the x,y position of the image in the tangent plane. See the AstroPhot coordinate description on the website for more details on why this works. - -# In[ ]: - - -res = ap.fit.LM(model, verbose=1).fit() -fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) -ap.plots.model_image(fig, axarr[0], model) -axarr[0, 0].set_title("Model Image (r-band)") -axarr[0, 1].set_title("Model Image (g-band)") -ap.plots.residual_image(fig, axarr[1], model) -axarr[1, 0].set_title("Residual Image (r-band)") -axarr[1, 1].set_title("Residual Image (g-band)") -plt.show() - - -# Yay! no more dipole. The fits aren't the best, clearly these objects aren't super well described by a single moffat model. But the main goal today was to show that we could align the images very easily. Note, its probably best to start with a reasonably good WCS from the outset, and this two stage approach where we optimize the models and then optimize the models plus a shift might be more stable than just fitting everything at once from the outset. Often for more complex models it is best to start with a simpler model and fit each time you introduce more complexity. - -# ## Shift and rotation -# -# Lets say we really don't trust our WCS, we think something has gone wrong and we want freedom to fully shift and rotate the relative positions of the images relative to each other. How can we do this? - -# In[ ]: - - -def rotate(phi): - """Create a 2D rotation matrix for a given angle in radians.""" - return torch.stack( - [ - torch.stack([torch.cos(phi), -torch.sin(phi)]), - torch.stack([torch.sin(phi), torch.cos(phi)]), - ] - ) - - -# Uh-oh! Our image is misaligned by some small angle -target_g.CD = target_g.CD.value @ rotate(torch.tensor(np.pi / 32, dtype=torch.float64)) -# Uh-oh! our alignment from before has been erased -target_g.crtan.value = (0, 0) - - -# In[ ]: - - -fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) -ap.plots.model_image(fig, axarr[0], model) -axarr[0, 0].set_title("Model Image (r-band)") -axarr[0, 1].set_title("Model Image (g-band)") -ap.plots.residual_image(fig, axarr[1], model) -axarr[1, 0].set_title("Residual Image (r-band)") -axarr[1, 1].set_title("Residual Image (g-band)") -plt.show() - - -# Notice that there is not a universal dipole like in the shift example. Most of the offset is caused by the rotation in this example. - -# In[ ]: - - -# this will control the relative rotation of the g-band image -phi = ap.Param(name="phi", dynamic_value=0.0, dtype=torch.float64) - -# Set the target_g CD matrix to be a function of the rotation angle -# The CD matrix can encode rotation, skew, and rectangular pixels. We -# are only interested in the rotation here. -init_CD = target_g.CD.value.clone() -target_g.CD = lambda p: init_CD @ rotate(p.phi.value) -target_g.CD.link(phi) - -# also optimize the shift of the g-band image -target_g.crtan.to_dynamic() - - -# In[ ]: - - -res = ap.fit.LM(model, verbose=1).fit() -fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) -ap.plots.model_image(fig, axarr[0], model) -axarr[0, 0].set_title("Model Image (r-band)") -axarr[0, 1].set_title("Model Image (g-band)") -ap.plots.residual_image(fig, axarr[1], model) -axarr[1, 0].set_title("Residual Image (r-band)") -axarr[1, 1].set_title("Residual Image (g-band)") -plt.show() - - -# In[ ]: From 751690ef59c4baca57dcf7f9df35a9ff554fee3f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 25 Jul 2025 11:58:03 -0400 Subject: [PATCH 092/191] automatic sip backwards coefficients --- astrophot/image/func/__init__.py | 6 ++ astrophot/image/func/wcs.py | 94 ++++++++++------------------- astrophot/image/mixins/sip_mixin.py | 43 +++++++++++++ astrophot/models/mixins/gaussian.py | 4 +- tests/test_sip_image.py | 8 +-- 5 files changed, 86 insertions(+), 69 deletions(-) diff --git a/astrophot/image/func/__init__.py b/astrophot/image/func/__init__.py index ae7c920e..efffdb48 100644 --- a/astrophot/image/func/__init__.py +++ b/astrophot/image/func/__init__.py @@ -11,6 +11,9 @@ pixel_to_plane_linear, plane_to_pixel_linear, sip_delta, + sip_coefs, + sip_backward_transform, + sip_matrix, ) from .window import window_or, window_and @@ -25,6 +28,9 @@ "pixel_to_plane_linear", "plane_to_pixel_linear", "sip_delta", + "sip_coefs", + "sip_backward_transform", + "sip_matrix", "window_or", "window_and", ) diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index b716e320..728e823a 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -118,6 +118,37 @@ def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): return xy[0].reshape(i.shape) + x0, xy[1].reshape(i.shape) + y0 +def sip_coefs(order): + coefs = [] + for p in range(order + 1): + for q in range(order + 1 - p): + coefs.append((p, q)) + return tuple(coefs) + + +def sip_matrix(u, v, order): + M = torch.zeros((len(u), (order + 1) * (order + 2) // 2), dtype=u.dtype, device=u.device) + for i, (p, q) in enumerate(sip_coefs(order)): + M[:, i] = u**p * v**q + return M + + +def sip_backward_transform(u, v, U, V, A_ORDER, B_ORDER): + """ + Credit: Shu Liu and Lei Hi, see here: + https://github.com/Roman-Supernova-PIT/sfft/blob/master/sfft/utils/CupyWCSTransform.py + + Compute the backward transformation from (U, V) to (u, v) + """ + + FP_UV = sip_matrix(U, V, A_ORDER) + GP_UV = sip_matrix(U, V, B_ORDER) + + AP = torch.linalg.lstsq(FP_UV, (u.flatten() - U).reshape(-1, 1))[0].squeeze(1) + BP = torch.linalg.lstsq(GP_UV, (v.flatten() - V).reshape(-1, 1))[0].squeeze(1) + return AP, BP + + def sip_delta(u, v, sipA=(), sipB=()): """ u = j - j0 @@ -141,69 +172,6 @@ def sip_delta(u, v, sipA=(), sipB=()): return delta_u, delta_v -def pixel_to_plane_sip(i, j, i0, j0, CD, sip_powers=[], sip_coefs=[], x0=0.0, y0=0.0): - """ - Convert pixel coordinates to a tangent plane using the WCS information. This - matches the FITS convention for SIP transformations. - - For more information see: - - * FITS World Coordinate System (WCS): - https://fits.gsfc.nasa.gov/fits_wcs.html - * Representations of world coordinates in FITS, 2002, by Geisen and - Calabretta - * The SIP Convention for Representing Distortion in FITS Image Headers, - 2008, by Shupe and Hook - - Parameters - ---------- - i: Tensor - The first coordinate of the pixel in pixel units. - j: Tensor - The second coordinate of the pixel in pixel units. - i0: Tensor - The i reference pixel coordinate in pixel units. - j0: Tensor - The j reference pixel coordinate in pixel units. - CD: Tensor - The CD matrix in degrees per pixel. This 2x2 matrix is used to convert - from pixel to degree units and also handles rotation/skew. - sip_powers: Tensor - The powers of the pixel coordinates for the SIP distortion, should be a - shape (N orders, 2) tensor. ``N orders`` is the number of non-zero - polynomial coefficients. The second axis has the powers in order ``i, - j``. - sip_coefs: Tensor - The coefficients of the pixel coordinates for the SIP distortion, should - be a shape (N orders, 2) tensor. ``N orders`` is the number of non-zero - polynomial coefficients. The second axis has the coefficients in order - ``delta_x, delta_y``. - x0: float - The x reference coordinate in arcsec. - y0: float - The y reference coordinate in arcsec. - - Note - ---- - The representation of the SIP powers and coefficients assumes that the SIP - polynomial will use the same orders for both the x and y coordinates. If - this is not the case you may use zeros for the coefficients to ensure all - polynomial combinations are evaluated. However, it is very common to have - the same orders for both. - - Returns - ------- - Tuple: [Tensor, Tensor] - Tuple containing the x and y tangent plane coordinates in arcsec. - """ - uv = torch.stack((j.reshape(-1) - j0, i.reshape(-1) - i0), dim=1) - delta_p = torch.zeros_like(uv) - for p in range(len(sip_powers)): - delta_p += sip_coefs[p] * torch.prod(uv ** sip_powers[p], dim=-1).unsqueeze(-1) - plane = torch.einsum("ij,...j->...i", CD, uv + delta_p) - return plane[..., 0] + x0, plane[..., 1] + y0 - - def plane_to_pixel_linear(x, y, i0, j0, CD, x0=0.0, y0=0.0): """ Convert tangent plane coordinates to pixel coordinates using the WCS diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 63b27c1b..b05872c6 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -34,6 +34,9 @@ def __init__( self.sipAP = sipAP self.sipBP = sipBP + if len(self.sipAP) == 0 and len(self.sipA) > 0: + self.compute_backward_sip_coefs() + self.update_distortion_model( distortion_ij=distortion_ij, distortion_IJ=distortion_IJ, pixel_area_map=pixel_area_map ) @@ -55,6 +58,40 @@ def plane_to_pixel(self, x, y, crtan, CD): def pixel_area_map(self): return self._pixel_area_map + @property + def A_ORDER(self): + if self.sipA: + return max(a + b for a, b in self.sipA) + return 0 + + @property + def B_ORDER(self): + if self.sipB: + return max(a + b for a, b in self.sipB) + return 0 + + def compute_backward_sip_coefs(self): + """ + Credit: Shu Liu and Lei Hi, see here: + https://github.com/Roman-Supernova-PIT/sfft/blob/master/sfft/utils/CupyWCSTransform.py + + Compute the backward transformation from (U, V) to (u, v) + """ + i, j = self.pixel_center_meshgrid() + u, v = i - self.crpix[0], j - self.crpix[1] + du, dv = func.sip_delta(u, v, self.sipA, self.sipB) + U = (u + du).flatten() + V = (v + dv).flatten() + AP, BP = func.sip_backward_transform( + u.flatten(), v.flatten(), U, V, self.A_ORDER, self.B_ORDER + ) + self.sipAP = dict( + ((p, q), ap.item()) for (p, q), ap in zip(func.sip_coefs(self.A_ORDER), AP) + ) + self.sipBP = dict( + ((p, q), bp.item()) for (p, q), bp in zip(func.sip_coefs(self.B_ORDER), BP) + ) + def update_distortion_model(self, distortion_ij=None, distortion_IJ=None, pixel_area_map=None): """ Update the pixel area map based on the current SIP coefficients. @@ -107,6 +144,8 @@ def copy(self, **kwargs): "sipAP": self.sipAP, "sipBP": self.sipBP, "pixel_area_map": self.pixel_area_map, + "distortion_ij": self.distortion_ij, + "distortion_IJ": self.distortion_IJ, **kwargs, } return super().copy(**kwargs) @@ -118,6 +157,8 @@ def blank_copy(self, **kwargs): "sipAP": self.sipAP, "sipBP": self.sipBP, "pixel_area_map": self.pixel_area_map, + "distortion_ij": self.distortion_ij, + "distortion_IJ": self.distortion_IJ, **kwargs, } return super().blank_copy(**kwargs) @@ -129,6 +170,8 @@ def get_window(self, other: Union[Image, Window], indices=None, **kwargs): return super().get_window( other, pixel_area_map=self.pixel_area_map[indices], + distortion_ij=self.distortion_ij[:, indices[0], indices[1]], + distortion_IJ=self.distortion_IJ[:, indices[0], indices[1]], indices=indices, **kwargs, ) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 2485f8fe..8c84d49b 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -17,7 +17,7 @@ class GaussianMixin: The Gaussian profile is a simple and widely used model for extended objects. The functional form of the Gaussian profile is defined as: - $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \exp(-R^2 / (2 \sigma^2))$$ + $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2))$$ where `I_0` is the intensity at the center of the profile and `sigma` is the standard deviation which controls the width of the profile. @@ -57,7 +57,7 @@ class iGaussianMixin: The Gaussian profile is a simple and widely used model for extended objects. The functional form of the Gaussian profile is defined as: - $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \exp(-R^2 / (2 \sigma^2))$$ + $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2))$$ where `sigma` is the standard deviation which controls the width of the profile and `flux` gives the total flux of the profile (assuming no diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py index c55bfbd4..bf411a89 100644 --- a/tests/test_sip_image.py +++ b/tests/test_sip_image.py @@ -21,8 +21,8 @@ def sip_target(): mask=torch.zeros_like(arr), sipA={(1, 0): 1e-4, (0, 1): 1e-4, (2, 3): -1e-5}, sipB={(1, 0): -1e-4, (0, 1): 5e-5, (2, 3): 2e-6}, - sipAP={(1, 0): -1e-4, (0, 1): -1e-4, (2, 3): 1e-5}, - sipBP={(1, 0): 1e-4, (0, 1): -5e-5, (2, 3): -2e-6}, + # sipAP={(1, 0): -1e-4, (0, 1): -1e-4, (2, 3): 1e-5}, + # sipBP={(1, 0): 1e-4, (0, 1): -5e-5, (2, 3): -2e-6}, ) @@ -99,8 +99,8 @@ def test_sip_image_wcs_roundtrip(sip_target): x, y = sip_target.pixel_to_plane(i, j) i2, j2 = sip_target.plane_to_pixel(x, y) - assert torch.allclose(i, i2, atol=0.5), "i coordinates should match after WCS roundtrip" - assert torch.allclose(j, j2, atol=0.5), "j coordinates should match after WCS roundtrip" + assert torch.allclose(i, i2, atol=0.05), "i coordinates should match after WCS roundtrip" + assert torch.allclose(j, j2, atol=0.05), "j coordinates should match after WCS roundtrip" def test_sip_image_save_load(sip_target): From 94595f715d59a4520c71cc065ed10db60c70d411 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 26 Jul 2025 11:44:21 -0400 Subject: [PATCH 093/191] tweaking LM performance --- astrophot/fit/func/lm.py | 15 ++++++------ astrophot/fit/lm.py | 51 ++++++++++++++++++++++++++++------------ astrophot/models/base.py | 1 + tests/test_model.py | 2 ++ 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 42494ef3..887ea0a2 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -30,7 +30,8 @@ def solve(hess, grad, L): return hessD, h -def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0): +def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0, tolerance=1e-4): + L0 = L M0 = model(x) # (M,) J = jacobian(x) # (M, N) R = data - M0 # (M,) @@ -41,8 +42,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0): raise OptimizeStopSuccess("Gradient is zero, optimization converged.") best = {"x": torch.zeros_like(x), "chi2": chi20, "L": L} - scary = {"x": None, "chi2": chi20, "L": L} - + scary = {"x": None, "chi2": np.inf, "L": None} nostep = True improving = None for _ in range(10): @@ -58,14 +58,15 @@ def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0): improving = False continue - if chi21 < scary["chi2"]: - scary = {"x": x + h.squeeze(1), "chi2": chi21, "L": L} - if torch.allclose(h, torch.zeros_like(h)) and L < 0.1: raise OptimizeStopSuccess("Step with zero length means optimization complete.") # actual chi2 improvement vs expected from linearization rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() + + if chi21 < scary["chi2"] and rho > -10: + scary = {"x": x + h.squeeze(1), "chi2": chi21, "L": L0} + # Avoid highly non-linear regions if rho < 0.1 or rho > 2: L *= Lup @@ -94,7 +95,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0): break if nostep: - if scary["x"] is not None: + if scary["x"] is not None and (scary["chi2"] - chi20) / chi20 < tolerance: return scary raise OptimizeStopFail("Could not find step to improve chi^2") diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 3f9574c2..e97f2459 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -257,7 +257,10 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: except OptimizeStopFail: if self.verbose > 0: config.logger.warning("Could not find step to improve Chi^2, stopping") - self.message = self.message + "fail. Could not find step to improve Chi^2" + self.message = ( + self.message + + "success by immobility. Could not find step to improve Chi^2. Convergence not guaranteed" + ) break except OptimizeStopSuccess as e: if self.verbose > 0: @@ -270,20 +273,8 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.loss_history.append(res["chi2"]) self.lambda_history.append(self.current_state.detach().clone().cpu().numpy()) - if len(self.loss_history) >= 3: - if (self.loss_history[-3] - self.loss_history[-1]) / self.loss_history[ - -1 - ] < self.relative_tolerance and self.L < 0.1: - self.message = self.message + "success" - break - if len(self.loss_history) > 10: - if (self.loss_history[-10] - self.loss_history[-1]) / self.loss_history[ - -1 - ] < self.relative_tolerance: - self.message = ( - self.message + "success by immobility. Convergence not guaranteed" - ) - break + if self.check_convergence(): + break else: self.message = self.message + "fail. Maximum iterations" @@ -299,6 +290,36 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: return self + def check_convergence(self) -> bool: + """Check if the optimization has converged based on the last + iteration's chi^2 and the relative tolerance. + + Returns: + bool: True if the optimization has converged, False otherwise. + """ + if len(self.loss_history) < 3: + return False + good_history = [self.loss_history[0]] + for l in self.loss_history[1:]: + if good_history[-1] > l: + good_history.append(l) + if len(self.loss_history) - len(good_history) >= 10: + self.message = self.message + "success by immobility. Convergence not guaranteed" + return True + if len(good_history) < 3: + return False + if (good_history[-2] - good_history[-1]) / good_history[ + -1 + ] < self.relative_tolerance and self.L < 0.1: + self.message = self.message + "success" + return True + if len(good_history) < 10: + return False + if (good_history[-10] - good_history[-1]) / good_history[-1] < self.relative_tolerance: + self.message = self.message + "success by immobility. Convergence not guaranteed" + return True + return False + @property @torch.no_grad() def covariance_matrix(self) -> torch.Tensor: diff --git a/astrophot/models/base.py b/astrophot/models/base.py index deac9439..f1be7432 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -186,6 +186,7 @@ def total_flux(self, window=None) -> torch.Tensor: def total_flux_uncertainty(self, window=None) -> torch.Tensor: jac = self.jacobian(window=window).flatten("data") + print("jac finite", torch.isfinite(jac).all()) dF = torch.sum(jac, dim=0) # VJP for sum(total_flux) current_uncertainty = self.build_params_array_uncertainty() return torch.sqrt(torch.sum((dF * current_uncertainty) ** 2)) diff --git a/tests/test_model.py b/tests/test_model.py index 3212a81b..6f6efe3a 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -153,6 +153,8 @@ def test_all_model_sample(model_type): f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" ) + print(MODEL) # test printing + F = MODEL.total_flux() assert torch.isfinite(F), "Model total flux should be finite after fitting" assert F > 0, "Model total flux should be positive after fitting" From a69d7c758eca2b220e5584f95a90de772e125c63 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 26 Jul 2025 14:19:56 -0400 Subject: [PATCH 094/191] model test passes now --- astrophot/models/mixins/spline.py | 5 ++++- astrophot/models/mixins/transform.py | 2 +- astrophot/models/moffat.py | 10 ---------- astrophot/utils/interpolate.py | 3 ++- tests/test_model.py | 14 ++++++++------ tests/utils.py | 4 ++-- 6 files changed, 17 insertions(+), 21 deletions(-) diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 5bd38ef6..b706a480 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -35,6 +35,7 @@ def initialize(self): # Create the I_R profile radii if needed if self.I_R.prof is None: prof = default_prof(self.window.shape, target_area.pixelscale, 2, 0.2) + prof = np.append(prof, prof[-1] * 10) self.I_R.prof = prof else: prof = self.I_R.prof @@ -49,7 +50,8 @@ def initialize(self): @forward def radial_model(self, R, I_R): - return func.spline(R, self.I_R.prof, I_R) + ret = func.spline(R, self.I_R.prof, I_R) + return ret class iSplineMixin: @@ -83,6 +85,7 @@ def initialize(self): # Create the I_R profile radii if needed if self.I_R.prof is None: prof = default_prof(self.window.shape, target_area.pixelscale, 2, 0.2) + prof = np.append(prof, prof[-1] * 10) prof = np.stack([prof] * self.segments) self.I_R.prof = prof else: diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 5b30098b..ac0af952 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -98,7 +98,7 @@ class SuperEllipseMixin: _model_type = "superellipse" _parameter_specs = { - "C": {"units": "none", "dynamic_value": 2.0, "valid": (0, None)}, + "C": {"units": "none", "dynamic_value": 2.0, "valid": (0, 10)}, } @forward diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py index 2ae5bacf..56f3b817 100644 --- a/astrophot/models/moffat.py +++ b/astrophot/models/moffat.py @@ -33,25 +33,15 @@ class MoffatGalaxy(MoffatMixin, RadialMixin, GalaxyModel): usable = True - @forward - def total_flux(self, window=None, n=None, Rd=None, I0=None, q=None): - return moffat_I0_to_flux(I0, n, Rd, q) - @combine_docstrings class MoffatPSF(MoffatMixin, RadialMixin, PSFModel): _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} - usable = True - @forward - def total_flux(self, window=None, n=None, Rd=None, I0=None): - return moffat_I0_to_flux(I0, n, Rd, 1.0) - @combine_docstrings class Moffat2DPSF(MoffatMixin, InclinedMixin, RadialMixin, PSFModel): - _model_type = "2d" _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} usable = True diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index 147a0945..0587397a 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -4,7 +4,8 @@ def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): prof = [0, min_pixels * pixelscale] - while prof[-1] < (np.max(shape) * pixelscale / 2): + imagescale = max(shape) # np.sqrt(np.sum(np.array(shape) ** 2)) + while prof[-1] < (imagescale * pixelscale / 2): prof.append(prof[-1] + max(min_pixels * pixelscale, prof[-1] * scale)) return np.array(prof) diff --git a/tests/test_model.py b/tests/test_model.py index 6f6efe3a..f07e646f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -128,19 +128,23 @@ def test_all_model_sample(model_type): assert torch.all( torch.isfinite(img.data) ), "Model should evaluate a real number for the full image" - res = ap.fit.LM(MODEL, max_iter=10).fit() + + res = ap.fit.LM(MODEL, max_iter=10, verbose=1).fit() + print(res.loss_history) + + print(MODEL) # test printing # sky has little freedom to fit, some more complex models need extra # attention to get a good fit so here we just check that they can improve if ( "sky" in model_type or "king" in model_type + or "spline" in model_type or model_type in [ - "spline ray galaxy model", "exponential warp galaxy model", - "spline wedge galaxy model", "ferrer warp galaxy model", + "ferrer ray galaxy model", ] ): assert res.loss_history[0] > res.loss_history[-1], ( @@ -148,13 +152,11 @@ def test_all_model_sample(model_type): f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" ) else: # Most models should get significantly better after just a few iterations - assert res.loss_history[0] > (2 * res.loss_history[-1]), ( + assert res.loss_history[0] > (1.5 * res.loss_history[-1]), ( f"Model {model_type} should fit to the target image, but did not. " f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" ) - print(MODEL) # test printing - F = MODEL.total_flux() assert torch.isfinite(F), "Model total flux should be finite after fitting" assert F > 0, "Model total flux should be positive after fitting" diff --git a/tests/utils.py b/tests/utils.py index 7144d321..1eee826d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -67,10 +67,10 @@ def make_basic_sersic( img = MODEL().data.T.detach().cpu().numpy() target.data = ( img - + np.random.normal(scale=0.1, size=img.shape) + + np.random.normal(scale=0.5, size=img.shape) + np.random.normal(scale=np.sqrt(img) / 10) ) - target.variance = 0.1**2 + img / 100 + target.variance = 0.5**2 + img / 100 return target From 09a340ec1602d855447f90b2ede0abf5a8b1c4ff Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 26 Jul 2025 14:39:02 -0400 Subject: [PATCH 095/191] add emcee to docs requirements --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index 73496626..39b704a4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ caustics +emcee graphviz ipywidgets jupyter-book From 1e2a23bf5975027a1bac8cdf4f6d35a4d75ceb3a Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 27 Jul 2025 14:24:49 -0400 Subject: [PATCH 096/191] add new gradient descent optimizer --- astrophot/fit/__init__.py | 4 +- astrophot/fit/func/__init__.py | 3 +- astrophot/fit/func/lm.py | 8 +- astrophot/fit/func/slalom.py | 49 +++++++++++ astrophot/fit/gradient.py | 99 ++++++++++++++++++++++ astrophot/fit/hmc.py | 9 +- astrophot/fit/lm.py | 6 +- docs/source/tutorials/FittingMethods.ipynb | 8 +- docs/source/tutorials/GettingStarted.ipynb | 2 +- tests/test_fit.py | 11 ++- 10 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 astrophot/fit/func/slalom.py diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index fbed6a89..852e6581 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,9 +1,9 @@ from .lm import LM -from .gradient import Grad +from .gradient import Grad, Slalom from .iterative import Iter from .scipy_fit import ScipyFit from .minifit import MiniFit from .hmc import HMC from .mhmcmc import MHMCMC -__all__ = ["LM", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC"] +__all__ = ["LM", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC", "Slalom"] diff --git a/astrophot/fit/func/__init__.py b/astrophot/fit/func/__init__.py index e5f23230..b2997e4e 100644 --- a/astrophot/fit/func/__init__.py +++ b/astrophot/fit/func/__init__.py @@ -1,3 +1,4 @@ from .lm import lm_step, hessian, gradient +from .slalom import slalom_step -__all__ = ["lm_step", "hessian", "gradient"] +__all__ = ["lm_step", "hessian", "gradient", "slalom_step"] diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 887ea0a2..d3879cdf 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -42,7 +42,7 @@ def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0, tol raise OptimizeStopSuccess("Gradient is zero, optimization converged.") best = {"x": torch.zeros_like(x), "chi2": chi20, "L": L} - scary = {"x": None, "chi2": np.inf, "L": None} + scary = {"x": None, "chi2": np.inf, "L": None, "rho": np.inf} nostep = True improving = None for _ in range(10): @@ -64,8 +64,10 @@ def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0, tol # actual chi2 improvement vs expected from linearization rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() - if chi21 < scary["chi2"] and rho > -10: - scary = {"x": x + h.squeeze(1), "chi2": chi21, "L": L0} + if (chi21 < (chi20 + tolerance) and abs(rho - 1) < abs(scary["rho"] - 1)) or ( + chi21 < scary["chi2"] and rho > -10 + ): + scary = {"x": x + h.squeeze(1), "chi2": chi21, "L": L0, "rho": rho} # Avoid highly non-linear regions if rho < 0.1 or rho > 2: diff --git a/astrophot/fit/func/slalom.py b/astrophot/fit/func/slalom.py new file mode 100644 index 00000000..479d65e7 --- /dev/null +++ b/astrophot/fit/func/slalom.py @@ -0,0 +1,49 @@ +import numpy as np +import torch + +from ...errors import OptimizeStopFail, OptimizeStopSuccess + + +def slalom_step(f, g, x0, m, S, N=10, up=1.3, down=0.5): + l = [f(x0).item()] + d = [0.0] + grad = g(x0) + if torch.allclose(grad, torch.zeros_like(grad)): + raise OptimizeStopSuccess("success: Gradient is zero, optimization converged.") + + D = grad + m + D = D / torch.linalg.norm(D) + seeking = False + for _ in range(N): + l.append(f(x0 - S * D).item()) + d.append(S) + + # Check if the last value is finite + if not np.isfinite(l[-1]): + l.pop() + d.pop() + S *= down + continue + + if seeking and np.argmin(l) == len(l) - 1: + # If we are seeking a minimum and the last value is the minimum, we can stop + break + + if len(l) < 3: + # Seek better step size based on loss improvement + if l[-1] < l[-2]: + S *= up + else: + S *= down + else: + O = np.polyfit(d[-3:], l[-3:], 2) + if O[0] > 0: + S = -O[1] / (2 * O[0]) + seeking = True + else: + S *= down + seeking = False + + if np.argmin(l) == 0: + raise OptimizeStopFail("fail: cannot find step to improve.") + return d[np.argmin(l)], l[np.argmin(l)], grad diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index abbe3dba..d8ba2226 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -1,12 +1,15 @@ # Traditional gradient descent with Adam from time import time from typing import Sequence +from caustics import ValidContext import torch import numpy as np from .base import BaseOptimizer from .. import config from ..models import Model +from ..errors import OptimizeStopFail, OptimizeStopSuccess +from . import func __all__ = ["Grad"] @@ -158,3 +161,99 @@ def fit(self) -> BaseOptimizer: f"Grad Fitting complete in {time() - start_fit} sec with message: {self.message}" ) return self + + +class Slalom(BaseOptimizer): + + def __init__( + self, + model: Model, + initial_state: Sequence = None, + S=1e-4, + likelihood: str = "gaussian", + report_freq: int = 10, + relative_tolerance: float = 1e-4, + momentum: float = 0.5, + max_iter: int = 1000, + **kwargs, + ) -> None: + """Initialize the Slalom optimizer.""" + super().__init__( + model, initial_state, relative_tolerance=relative_tolerance, max_iter=max_iter, **kwargs + ) + self.likelihood = likelihood + self.S = S + self.report_freq = report_freq + self.momentum = momentum + + def density(self, state: torch.Tensor) -> torch.Tensor: + """Calculate the density of the model at the given state.""" + if self.likelihood == "gaussian": + return -self.model.gaussian_log_likelihood(state) + elif self.likelihood == "poisson": + return -self.model.poisson_log_likelihood(state) + else: + raise ValueError(f"Unknown likelihood type: {self.likelihood}") + + def fit(self) -> BaseOptimizer: + """Perform the Slalom optimization.""" + + grad_func = torch.func.grad(self.density) + momentum = torch.zeros_like(self.current_state) + self.S_history = [self.S] + self.loss_history = [self.density(self.current_state).item()] + self.lambda_history = [self.current_state.detach().cpu().numpy()] + self.start_fit = time() + + for i in range(self.max_iter): + + try: + # Perform the Slalom step + vstate = self.model.to_valid(self.current_state) + with ValidContext(self.model): + self.S, loss, grad = func.slalom_step( + self.density, grad_func, vstate, m=momentum, S=self.S + ) + self.current_state = self.model.from_valid( + vstate - self.S * (grad + momentum) / torch.linalg.norm(grad + momentum) + ) + momentum = self.momentum * (momentum + grad) + except OptimizeStopSuccess as e: + self.message = self.message + str(e) + break + except OptimizeStopFail as e: + if torch.allclose(momentum, torch.zeros_like(momentum)): + self.message = self.message + str(e) + break + print("momentum reset") + momentum = torch.zeros_like(self.current_state) + continue + # Log the loss + self.S_history.append(self.S) + self.loss_history.append(loss) + self.lambda_history.append(self.current_state.detach().cpu().numpy()) + + if self.verbose > 0 and (i % int(self.report_freq) == 0 or i == self.max_iter - 1): + config.logger.info( + f"iter: {i}, step size: {self.S:.6e}, posterior density: {loss:.6e}" + ) + + if len(self.loss_history) >= 5: + relative_loss = (self.loss_history[-5] - self.loss_history[-1]) / self.loss_history[ + -1 + ] + if relative_loss < self.relative_tolerance: + self.message = self.message + " success" + break + else: + self.message = self.message + " fail. max iteration reached" + + # Set the model parameters to the best values from the fit + self.model.fill_dynamic_values( + torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) + ) + if self.verbose > 0: + config.logger.info( + f"Slalom Fitting complete in {time() - self.start_fit} sec with message: {self.message}" + ) + return self diff --git a/astrophot/fit/hmc.py b/astrophot/fit/hmc.py index 2099cf4c..a87e8861 100644 --- a/astrophot/fit/hmc.py +++ b/astrophot/fit/hmc.py @@ -15,6 +15,7 @@ from .base import BaseOptimizer from ..models import Model +from .. import config __all__ = ["HMC"] @@ -88,8 +89,8 @@ def __init__( initial_state: Optional[Sequence] = None, max_iter: int = 1000, inv_mass: Optional[torch.Tensor] = None, - epsilon: float = 1e-5, - leapfrog_steps: int = 20, + epsilon: float = 1e-4, + leapfrog_steps: int = 10, progress_bar: bool = True, prior: Optional[dist.Distribution] = None, warmup: int = 100, @@ -182,5 +183,7 @@ def step(model, prior): chain = mcmc.get_samples()["x"] self.chain = chain - + self.model.fill_dynamic_values( + torch.as_tensor(self.chain[-1], dtype=config.DTYPE, device=config.DEVICE) + ) return self diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index e97f2459..397bf587 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -281,10 +281,12 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: if self.verbose > 0: config.logger.info( - f"Final Chi^2/DoF: {self.loss_history[-1]:.6g}, L: {self.L_history[-1]:.3g}. Converged: {self.message}" + f"Final Chi^2/DoF: {np.nanmin(self.loss_history):.6g}, L: {self.L_history[np.nanargmin(self.loss_history)]:.3g}. Converged: {self.message}" ) - self.model.fill_dynamic_values(self.current_state) + self.model.fill_dynamic_values( + torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) + ) if update_uncertainty: self.update_uncertainty() diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index 998d9fd7..f36d8586 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -505,11 +505,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Gradient Descent\n", + "## Gradient Descent (Slalom)\n", "\n", - "A gradient descent fitter is identified as `ap.fit.Grad` and uses standard first order derivative methods as provided by PyTorch. These gradient descent methods include Adam, SGD, and LBFGS to name a few. The first order gradient is faster to evaluate and uses less memory, however it is considerably slower to converge than Levenberg-Marquardt. The gradient descent method with a small learning rate will reliably converge towards a local minimum, it will just do so slowly. \n", - "\n", - "In the example below we let it run for 1000 steps and even still it has not converged. In general you should not use gradient descent to optimize a model. However, in a challenging fitting scenario the small step size of gradient descent can actually be an advantage as it will not take any unedpectedly large steps which could mix up some models, or hop over the $\\chi^2$ minimum into impossible parameter space. Just make sure to finish with LM after using Grad so that it fully converges to a reliable minimum." + "A gradient descent fitter uses local gradient information to determine the direction of increased likelihood in parameter space. The challenge with gradient descent is choosing a step size. The `Slalom` algorithm developed for AstroPhot uses a few samples along the gradient direction to determine a parabola which it can then jump to the minimum of. In some sense this is like a 1D version of the Levenberg-Marquardt algorithm and the 1 dimension it choses is that along the gradient (plus momentum)." ] }, { @@ -520,7 +518,7 @@ "source": [ "MODEL = initialize_model(target, False)\n", "\n", - "res_grad = ap.fit.Grad(MODEL, verbose=1, max_iter=1000, optim_kwargs={\"lr\": 5e-2}).fit()" + "res_grad = ap.fit.Slalom(MODEL, verbose=1, momentum=0.5).fit()" ] }, { diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 89e2655d..04b6e97a 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -171,7 +171,7 @@ "outputs": [], "source": [ "# Now that the model has been set up with a target and initialized with parameter values, it is time to fit the image\n", - "result = ap.fit.LM(model2, verbose=1).fit()\n", + "result = ap.fit.Slalom(model2, verbose=1, report_freq=1, momentum=0.5).fit()\n", "\n", "# See that we use ap.fit.LM, this is the Levenberg-Marquardt Chi^2 minimization method, it is the recommended technique\n", "# for most least-squares problems. See the Fitting Methods tutorial for more on fitters!\n", diff --git a/tests/test_fit.py b/tests/test_fit.py index 4d3f3d0c..1c9a91f6 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -68,7 +68,16 @@ def sersic_model(): @pytest.mark.parametrize( - "fitter", [ap.fit.LM, ap.fit.Grad, ap.fit.ScipyFit, ap.fit.MHMCMC, ap.fit.MiniFit] + "fitter", + [ + ap.fit.LM, + ap.fit.Grad, + ap.fit.ScipyFit, + ap.fit.MHMCMC, + ap.fit.HMC, + ap.fit.MiniFit, + ap.fit.Slalom, + ], ) def test_fitters(fitter, sersic_model): model = sersic_model From ffc56d26d75b16c49c140ab365d866f9d9148809 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 27 Jul 2025 14:31:31 -0400 Subject: [PATCH 097/191] tweaks to tutorials --- docs/source/tutorials/FittingMethods.ipynb | 8 ++++++-- docs/source/tutorials/GettingStarted.ipynb | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index f36d8586..c38f27eb 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -507,7 +507,11 @@ "source": [ "## Gradient Descent (Slalom)\n", "\n", - "A gradient descent fitter uses local gradient information to determine the direction of increased likelihood in parameter space. The challenge with gradient descent is choosing a step size. The `Slalom` algorithm developed for AstroPhot uses a few samples along the gradient direction to determine a parabola which it can then jump to the minimum of. In some sense this is like a 1D version of the Levenberg-Marquardt algorithm and the 1 dimension it choses is that along the gradient (plus momentum)." + "A gradient descent fitter uses local gradient information to determine the direction of increased likelihood in parameter space. The challenge with gradient descent is choosing a step size. The `Slalom` algorithm developed for AstroPhot uses a few samples along the gradient direction to determine a parabola which it can then jump to the minimum of. In some sense this is like a 1D version of the Levenberg-Marquardt algorithm and the 1 dimension it choses is that along the gradient (plus momentum).\n", + "\n", + "It is also possible to access the PyTorch gradient descent algorithms like `Adam` through the AstroPhot wrapper `ap.fit.Grad` which perform gradient descent using various algorithm designed for machine learning. In general though, those algorithms perform better on stochastic gradient descent problems, not static problems like seen by AstroPhot. So `Slalom` tends to perform better.\n", + "\n", + "As you see below, `Slalom` ends with a decent fit, though not good enough for perfect residuals like some other methods (Levenberg-Marquardt). This is typically the case. However, gradient descent can be very helpful for complex optimization tasks, because it is a slower optimization algorithm, it can be more stable in some circumstances. Try using it in cases where LM fails to get things back on track. Just make sure to finish off with an LM round to ensure you have settled into the minimum." ] }, { @@ -518,7 +522,7 @@ "source": [ "MODEL = initialize_model(target, False)\n", "\n", - "res_grad = ap.fit.Slalom(MODEL, verbose=1, momentum=0.5).fit()" + "res_grad = ap.fit.Slalom(MODEL, verbose=1).fit()" ] }, { diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 04b6e97a..89e2655d 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -171,7 +171,7 @@ "outputs": [], "source": [ "# Now that the model has been set up with a target and initialized with parameter values, it is time to fit the image\n", - "result = ap.fit.Slalom(model2, verbose=1, report_freq=1, momentum=0.5).fit()\n", + "result = ap.fit.LM(model2, verbose=1).fit()\n", "\n", "# See that we use ap.fit.LM, this is the Levenberg-Marquardt Chi^2 minimization method, it is the recommended technique\n", "# for most least-squares problems. See the Fitting Methods tutorial for more on fitters!\n", From 615b5b999667fb17a77773936de2747bfe249f94 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 27 Jul 2025 16:03:32 -0400 Subject: [PATCH 098/191] add cmos image --- astrophot/__init__.py | 6 ++ astrophot/image/__init__.py | 6 +- astrophot/image/cmos_image.py | 41 +++++++++++++ astrophot/image/func/__init__.py | 2 + astrophot/image/func/image.py | 6 ++ astrophot/image/image_object.py | 18 +++--- astrophot/image/mixins/__init__.py | 3 +- astrophot/image/mixins/cmos_mixin.py | 50 +++++++++++++++ astrophot/image/mixins/data_mixin.py | 12 +--- astrophot/image/mixins/sip_mixin.py | 17 +----- astrophot/image/target_image.py | 9 +-- astrophot/models/func/integration.py | 6 +- astrophot/models/mixins/sample.py | 7 +++ tests/test_cmos_image.py | 91 ++++++++++++++++++++++++++++ tests/test_sip_image.py | 1 - 15 files changed, 229 insertions(+), 46 deletions(-) create mode 100644 astrophot/image/cmos_image.py create mode 100644 astrophot/image/mixins/cmos_mixin.py create mode 100644 tests/test_cmos_image.py diff --git a/astrophot/__init__.py b/astrophot/__init__.py index 345a5ce4..70369ab8 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -9,7 +9,10 @@ ImageList, TargetImage, TargetImageList, + SIPModelImage, SIPTargetImage, + CMOSModelImage, + CMOSTargetImage, JacobianImage, JacobianImageList, PSFImage, @@ -145,7 +148,10 @@ def run_from_terminal() -> None: "ImageList", "TargetImage", "TargetImageList", + "SIPModelImage", "SIPTargetImage", + "CMOSModelImage", + "CMOSTargetImage", "JacobianImage", "JacobianImageList", "PSFImage", diff --git a/astrophot/image/__init__.py b/astrophot/image/__init__.py index 88be690f..2867c482 100644 --- a/astrophot/image/__init__.py +++ b/astrophot/image/__init__.py @@ -1,6 +1,7 @@ from .image_object import Image, ImageList from .target_image import TargetImage, TargetImageList -from .sip_image import SIPTargetImage +from .sip_image import SIPModelImage, SIPTargetImage +from .cmos_image import CMOSModelImage, CMOSTargetImage from .jacobian_image import JacobianImage, JacobianImageList from .psf_image import PSFImage from .model_image import ModelImage, ModelImageList @@ -12,7 +13,10 @@ "ImageList", "TargetImage", "TargetImageList", + "SIPModelImage", "SIPTargetImage", + "CMOSModelImage", + "CMOSTargetImage", "JacobianImage", "JacobianImageList", "PSFImage", diff --git a/astrophot/image/cmos_image.py b/astrophot/image/cmos_image.py new file mode 100644 index 00000000..f58a25fc --- /dev/null +++ b/astrophot/image/cmos_image.py @@ -0,0 +1,41 @@ +import torch + +from .target_image import TargetImage +from .mixins import CMOSMixin +from .model_image import ModelImage + + +class CMOSModelImage(CMOSMixin, ModelImage): + def fluxdensity_to_flux(self): + # CMOS pixels only sensitive in sub area, so scale the flux density + self._data = self.data * self.pixel_area * self.subpixel_scale**2 + + +class CMOSTargetImage(CMOSMixin, TargetImage): + """ + A TargetImage with CMOS-specific functionality. + This class is used to represent a target image with CMOS-specific features. + It inherits from TargetImage and CMOSMixin. + """ + + def model_image(self, upsample=1, pad=0, **kwargs): + """Model the image with CMOS-specific features.""" + if upsample > 1 or pad > 0: + raise NotImplementedError("Upsampling and padding are not implemented for CMOS images.") + + kwargs = { + "subpixel_loc": self.subpixel_loc, + "subpixel_scale": self.subpixel_scale, + "_data": torch.zeros( + self.data.shape[:2], dtype=self.data.dtype, device=self.data.device + ), + "CD": self.CD.value, + "crpix": self.crpix, + "crtan": self.crtan.value, + "crval": self.crval.value, + "zeropoint": self.zeropoint, + "identity": self.identity, + "name": self.name + "_model", + **kwargs, + } + return CMOSModelImage(**kwargs) diff --git a/astrophot/image/func/__init__.py b/astrophot/image/func/__init__.py index efffdb48..f0723080 100644 --- a/astrophot/image/func/__init__.py +++ b/astrophot/image/func/__init__.py @@ -1,5 +1,6 @@ from .image import ( pixel_center_meshgrid, + cmos_pixel_center_meshgrid, pixel_corner_meshgrid, pixel_simpsons_meshgrid, pixel_quad_meshgrid, @@ -19,6 +20,7 @@ __all__ = ( "pixel_center_meshgrid", + "cmos_pixel_center_meshgrid", "pixel_corner_meshgrid", "pixel_simpsons_meshgrid", "pixel_quad_meshgrid", diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index 7e1815f8..c878ce87 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -9,6 +9,12 @@ def pixel_center_meshgrid(shape, dtype, device): return torch.meshgrid(i, j, indexing="ij") +def cmos_pixel_center_meshgrid(shape, loc, dtype, device): + i = torch.arange(shape[0], dtype=dtype, device=device) + loc[0] + j = torch.arange(shape[1], dtype=dtype, device=device) + loc[1] + return torch.meshgrid(i, j, indexing="ij") + + def pixel_corner_meshgrid(shape, dtype, device): i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 49ee08da..b70fd79e 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -26,6 +26,7 @@ class Image(Module): default_CD = ((1.0, 0.0), (0.0, 1.0)) expect_ctype = (("RA---TAN",), ("DEC--TAN",)) + base_scale = 1.0 def __init__( self, @@ -268,12 +269,7 @@ def coordinate_quad_meshgrid(self, order=3): i, j, _ = self.pixel_quad_meshgrid(order=order) return self.pixel_to_plane(i, j) - def copy(self, **kwargs): - """Produce a copy of this image with all of the same properties. This - can be used when one wishes to make temporary modifications to - an image and then will want the original again. - - """ + def copy_kwargs(self, **kwargs): kwargs = { "_data": torch.clone(self.data.detach()), "CD": self.CD.value, @@ -285,7 +281,15 @@ def copy(self, **kwargs): "name": self.name, **kwargs, } - return self.__class__(**kwargs) + return kwargs + + def copy(self, **kwargs): + """Produce a copy of this image with all of the same properties. This + can be used when one wishes to make temporary modifications to + an image and then will want the original again. + + """ + return self.__class__(**self.copy_kwargs(**kwargs)) def blank_copy(self, **kwargs): """Produces a blank copy of the image which has the same properties diff --git a/astrophot/image/mixins/__init__.py b/astrophot/image/mixins/__init__.py index c8a342e8..00c57f96 100644 --- a/astrophot/image/mixins/__init__.py +++ b/astrophot/image/mixins/__init__.py @@ -1,4 +1,5 @@ from .data_mixin import DataMixin from .sip_mixin import SIPMixin +from .cmos_mixin import CMOSMixin -__all__ = ("DataMixin", "SIPMixin") +__all__ = ("DataMixin", "SIPMixin", "CMOSMixin") diff --git a/astrophot/image/mixins/cmos_mixin.py b/astrophot/image/mixins/cmos_mixin.py new file mode 100644 index 00000000..2a22abd6 --- /dev/null +++ b/astrophot/image/mixins/cmos_mixin.py @@ -0,0 +1,50 @@ +from .. import func +from ... import config + + +class CMOSMixin: + """ + A mixin class for CMOS image processing. This class can be used to add + CMOS-specific functionality to image processing classes. + """ + + def __init__(self, *args, subpixel_loc=(0, 0), subpixel_scale=1.0, filename=None, **kwargs): + super().__init__(*args, filename=filename, **kwargs) + if filename is not None: + return + self.subpixel_loc = subpixel_loc + self.subpixel_scale = subpixel_scale + + @property + def base_scale(self): + """Get the base scale of the image, which is the subpixel scale.""" + return self.subpixel_scale + + def pixel_center_meshgrid(self): + """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" + return func.cmos_pixel_center_meshgrid( + self.shape, self.subpixel_loc, config.DTYPE, config.DEVICE + ) + + def copy(self, **kwargs): + return super().copy( + subpixel_loc=self.subpixel_loc, subpixel_scale=self.subpixel_scale, **kwargs + ) + + def fits_info(self): + info = super().fits_info() + info["SPIXLOC1"] = self.subpixel_loc[0] + info["SPIXLOC2"] = self.subpixel_loc[1] + info["SPIXSCL"] = self.subpixel_scale + return info + + def load(self, filename: str, hduext=0): + hdulist = super().load(filename, hduext=hduext) + if "SPIXLOC1" in hdulist[hduext].header: + self.subpixel_loc = ( + hdulist[0].header.get("SPIXLOC1", 0), + hdulist[0].header.get("SPIXLOC2", 0), + ) + if "SPIXSCL" in hdulist[hduext].header: + self.subpixel_scale = hdulist[0].header.get("SPIXSCL", 1.0) + return hdulist diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 7c3c906c..07e4740a 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -251,22 +251,14 @@ def to(self, dtype=None, device=None): self._mask = self._mask.to(dtype=torch.bool, device=device) return self - def copy(self, **kwargs): + def copy_kwargs(self, **kwargs): """Produce a copy of this image with all of the same properties. This can be used when one wishes to make temporary modifications to an image and then will want the original again. """ kwargs = {"_mask": self._mask, "_weight": self._weight, **kwargs} - return super().copy(**kwargs) - - def blank_copy(self, **kwargs): - """Produces a blank copy of the image which has the same properties - except that its data is now filled with zeros. - - """ - kwargs = {"_mask": self._mask, "_weight": self._weight, **kwargs} - return super().blank_copy(**kwargs) + return super().copy_kwargs(**kwargs) def get_window(self, other: Union[Image, Window], indices=None, **kwargs): """Get a sub-region of the image as defined by an other image on the sky.""" diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index b05872c6..bdce04fd 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -137,7 +137,7 @@ def update_distortion_model(self, distortion_ij=None, distortion_IJ=None, pixel_ ) self._pixel_area_map = A.abs() - def copy(self, **kwargs): + def copy_kwargs(self, **kwargs): kwargs = { "sipA": self.sipA, "sipB": self.sipB, @@ -148,20 +148,7 @@ def copy(self, **kwargs): "distortion_IJ": self.distortion_IJ, **kwargs, } - return super().copy(**kwargs) - - def blank_copy(self, **kwargs): - kwargs = { - "sipA": self.sipA, - "sipB": self.sipB, - "sipAP": self.sipAP, - "sipBP": self.sipBP, - "pixel_area_map": self.pixel_area_map, - "distortion_ij": self.distortion_ij, - "distortion_IJ": self.distortion_IJ, - **kwargs, - } - return super().blank_copy(**kwargs) + return super().copy_kwargs(**kwargs) def get_window(self, other: Union[Image, Window], indices=None, **kwargs): """Get a sub-region of the image as defined by an other image on the sky.""" diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index ae6cbbfe..37d4ad6a 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -139,14 +139,9 @@ def psf(self, psf): name=self.name + "_psf", ) - def copy(self, **kwargs): - """Produce a copy of this image with all of the same properties. This - can be used when one wishes to make temporary modifications to - an image and then will want the original again. - - """ + def copy_kwargs(self, **kwargs): kwargs = {"psf": self.psf, **kwargs} - return super().copy(**kwargs) + return super().copy_kwargs(**kwargs) def fits_images(self): images = super().fits_images() diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index 0d0f587b..bedb927c 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -74,7 +74,6 @@ def recursive_quad_integrate( _current_depth=0, max_depth=1, ): - scale = 1 / (gridding**_current_depth) z, z0 = single_quad_integrate(i, j, brightness_ij, scale, quad_order) if _current_depth >= max_depth: @@ -92,7 +91,7 @@ def recursive_quad_integrate( sj, brightness_ij, threshold, - scale=scale, + scale=scale / gridding, quad_order=quad_order, gridding=gridding, _current_depth=_current_depth + 1, @@ -113,7 +112,6 @@ def recursive_bright_integrate( _current_depth=0, max_depth=1, ): - scale = 1 / (gridding**_current_depth) z, _ = single_quad_integrate(i, j, brightness_ij, scale, quad_order) if _current_depth >= max_depth: @@ -131,7 +129,7 @@ def recursive_bright_integrate( sj, brightness_ij, bright_frac, - scale=scale, + scale=scale / gridding, quad_order=quad_order, gridding=gridding, _current_depth=_current_depth + 1, diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 2f512bf5..188e251d 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -67,11 +67,13 @@ def _bright_integrate(self, sample, image): i, j = image.pixel_center_meshgrid() N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) sample_flat = sample.flatten(-2) + print(f"Integrating {N} brightest pixels of {sample_flat.shape} total pixels") select = torch.topk(sample_flat, N, dim=-1).indices sample_flat[select] = func.recursive_bright_integrate( i.flatten(-2)[select], j.flatten(-2)[select], lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), + scale=image.base_scale, bright_frac=self.integrate_fraction, quad_order=self.integrate_quad_order, gridding=self.integrate_gridding, @@ -105,6 +107,7 @@ def _threshold_integrate(self, sample, image: Image): i[select], j[select], lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), + scale=image.base_scale, threshold=threshold, quad_order=self.integrate_quad_order, gridding=self.integrate_gridding, @@ -125,9 +128,13 @@ def sample_image(self, image: Image): else: sampling_mode = self.sampling_mode if sampling_mode == "midpoint": + print(f"Sampling model {self.name} with midpoint sampling") x, y = image.coordinate_center_meshgrid() + print(f"x shape: {x.shape}, y shape: {y.shape}") res = self.brightness(x, y) + print(f"Brightness result shape: {res.shape}") sample = func.pixel_center_integrator(res) + print(f"Sample shape: {sample.shape}") elif sampling_mode == "simpsons": x, y = image.coordinate_simpsons_meshgrid() res = self.brightness(x, y) diff --git a/tests/test_cmos_image.py b/tests/test_cmos_image.py new file mode 100644 index 00000000..bc2876cd --- /dev/null +++ b/tests/test_cmos_image.py @@ -0,0 +1,91 @@ +import astrophot as ap +import torch +import numpy as np + +import pytest + +###################################################################### +# Image Objects +###################################################################### + + +@pytest.fixture() +def cmos_target(): + arr = torch.zeros((10, 15)) + return ap.CMOSTargetImage( + data=arr, + pixelscale=0.7, + zeropoint=1.0, + variance=torch.ones_like(arr), + mask=torch.zeros_like(arr), + subpixel_loc=(-0.25, -0.25), + subpixel_scale=0.5, + ) + + +def test_cmos_image_creation(cmos_target): + cmos_copy = cmos_target.copy() + assert cmos_copy.pixelscale == 0.7, "image should track pixelscale" + assert cmos_copy.zeropoint == 1.0, "image should track zeropoint" + assert cmos_copy.crpix[0] == 0, "image should track crpix" + assert cmos_copy.crpix[1] == 0, "image should track crpix" + assert cmos_copy.subpixel_loc == (-0.25, -0.25), "image should track subpixel location" + assert cmos_copy.subpixel_scale == 0.5, "image should track subpixel scale" + + i, j = cmos_target.pixel_center_meshgrid() + assert i.shape == (15, 10), "meshgrid should have correct shape" + assert j.shape == (15, 10), "meshgrid should have correct shape" + + x, y = cmos_target.coordinate_center_meshgrid() + assert x.shape == (15, 10), "coordinate meshgrid should have correct shape" + assert y.shape == (15, 10), "coordinate meshgrid should have correct shape" + + +def test_cmos_model_sample(cmos_target): + model = ap.Model( + name="test cmos", + model_type="sersic galaxy model", + target=cmos_target, + center=(3, 5), + q=0.7, + PA=np.pi / 3, + n=2.5, + Re=4, + Ie=1.0, + sampling_mode="midpoint", + integrate_mode="bright", + ) + model.initialize() + img = model.sample() + + assert isinstance(img, ap.CMOSModelImage), "sampled image should be a CMOSModelImage" + assert img.pixelscale == cmos_target.pixelscale, "sampled image should have the same pixelscale" + assert img.zeropoint == cmos_target.zeropoint, "sampled image should have the same zeropoint" + assert ( + img.subpixel_loc == cmos_target.subpixel_loc + ), "sampled image should have the same subpixel location" + + +def test_cmos_image_save_load(cmos_target): + # Save the image + cmos_target.save("cmos_image.fits") + + # Load the image + loaded_image = ap.CMOSTargetImage(filename="cmos_image.fits") + + # Check if the loaded image matches the original + assert torch.allclose( + cmos_target.data, loaded_image.data + ), "Loaded image data should match original" + assert torch.allclose( + cmos_target.pixelscale, loaded_image.pixelscale + ), "Loaded image pixelscale should match original" + assert torch.allclose( + cmos_target.zeropoint, loaded_image.zeropoint + ), "Loaded image zeropoint should match original" + assert np.allclose( + cmos_target.subpixel_loc, loaded_image.subpixel_loc + ), "Loaded image subpixel location should match original" + assert np.allclose( + cmos_target.subpixel_scale, loaded_image.subpixel_scale + ), "Loaded image subpixel scale should match original" diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py index bf411a89..f01acc72 100644 --- a/tests/test_sip_image.py +++ b/tests/test_sip_image.py @@ -2,7 +2,6 @@ import torch import numpy as np -from utils import make_basic_sersic import pytest ###################################################################### From b8c63b6c61b64d6e532fa0670f5144f650c04b77 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sun, 27 Jul 2025 16:47:17 -0400 Subject: [PATCH 099/191] change threshold to curvature, all go by pixel fraction now --- astrophot/models/func/integration.py | 7 ++-- astrophot/models/mixins/sample.py | 33 ++++++++----------- docs/source/tutorials/AdvancedPSFModels.ipynb | 2 +- tests/test_model.py | 2 +- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index bedb927c..d1d6969c 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -67,7 +67,7 @@ def recursive_quad_integrate( i, j, brightness_ij, - threshold, + curve_frac, scale=1.0, quad_order=3, gridding=5, @@ -79,7 +79,8 @@ def recursive_quad_integrate( if _current_depth >= max_depth: return z - select = torch.abs(z - z0) > threshold / scale**2 + N = max(1, int(np.prod(z.shape) * curve_frac)) + select = torch.topk(torch.abs(z - z0).flatten(), N, dim=-1).indices integral = torch.zeros_like(z) integral[~select] = z[~select] @@ -90,7 +91,7 @@ def recursive_quad_integrate( si, sj, brightness_ij, - threshold, + curve_frac=curve_frac, scale=scale / gridding, quad_order=quad_order, gridding=gridding, diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 188e251d..d238ed77 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -43,8 +43,7 @@ class SampleMixin: # Maximum size of parameter list before jacobian will be broken into smaller chunks, this is helpful for limiting the memory requirements to build a model, lower jacobian_chunksize is slower but uses less memory jacobian_maxparams = 10 jacobian_maxpixels = 1000**2 - integrate_mode = "bright" # none, bright, threshold - integrate_tolerance = 1e-4 # total flux fraction + integrate_mode = "bright" # none, bright, curvature integrate_fraction = 0.05 # fraction of the pixels to super sample integrate_max_depth = 2 integrate_gridding = 5 @@ -55,7 +54,6 @@ class SampleMixin: "jacobian_maxparams", "jacobian_maxpixels", "integrate_mode", - "integrate_tolerance", "integrate_fraction", "integrate_max_depth", "integrate_gridding", @@ -63,11 +61,10 @@ class SampleMixin: ) @forward - def _bright_integrate(self, sample, image): + def _bright_integrate(self, sample, image: Image): i, j = image.pixel_center_meshgrid() N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) sample_flat = sample.flatten(-2) - print(f"Integrating {N} brightest pixels of {sample_flat.shape} total pixels") select = torch.topk(sample_flat, N, dim=-1).indices sample_flat[select] = func.recursive_bright_integrate( i.flatten(-2)[select], @@ -82,7 +79,7 @@ def _bright_integrate(self, sample, image): return sample_flat.reshape(sample.shape) @forward - def _threshold_integrate(self, sample, image: Image): + def _curvature_integrate(self, sample, image: Image): i, j = image.pixel_center_meshgrid() kernel = func.curvature_kernel(config.DTYPE, config.DEVICE) curvature = ( @@ -99,21 +96,21 @@ def _threshold_integrate(self, sample, image: Image): .squeeze(0) .abs() ) - total_est = torch.sum(sample) - threshold = total_est * self.integrate_tolerance - select = curvature > threshold + N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) + select = torch.topk(curvature.flatten(-2), N, dim=-1).indices - sample[select] = func.recursive_quad_integrate( - i[select], - j[select], + sample_flat = sample.flatten(-2) + sample_flat[select] = func.recursive_quad_integrate( + i.flatten(-2)[select], + j.flatten(-2)[select], lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), scale=image.base_scale, - threshold=threshold, + curve_frac=self.integrate_fraction, quad_order=self.integrate_quad_order, gridding=self.integrate_gridding, max_depth=self.integrate_max_depth, ) - return sample + return sample_flat.reshape(sample.shape) @forward def sample_image(self, image: Image): @@ -128,13 +125,9 @@ def sample_image(self, image: Image): else: sampling_mode = self.sampling_mode if sampling_mode == "midpoint": - print(f"Sampling model {self.name} with midpoint sampling") x, y = image.coordinate_center_meshgrid() - print(f"x shape: {x.shape}, y shape: {y.shape}") res = self.brightness(x, y) - print(f"Brightness result shape: {res.shape}") sample = func.pixel_center_integrator(res) - print(f"Sample shape: {sample.shape}") elif sampling_mode == "simpsons": x, y = image.coordinate_simpsons_meshgrid() res = self.brightness(x, y) @@ -149,8 +142,8 @@ def sample_image(self, image: Image): raise SpecificationConflict( f"Unknown sampling mode {self.sampling_mode} for model {self.name}" ) - if self.integrate_mode == "threshold": - sample = self._threshold_integrate(sample, image) + if self.integrate_mode == "curvature": + sample = self._curvature_integrate(sample, image) elif self.integrate_mode == "bright": sample = self._bright_integrate(sample, image) elif self.integrate_mode != "none": diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index 5201c988..f594a818 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -278,7 +278,7 @@ " model_type=\"moffat psf model\",\n", " target=psf_target,\n", " n=1.0, # True value is 2.\n", - " Rd=3.5, # True value is 3.\n", + " Rd=2.0, # True value is 3.\n", ")\n", "\n", "# Here we set up a sersic model for the galaxy\n", diff --git a/tests/test_model.py b/tests/test_model.py index f07e646f..bd880c2e 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -54,7 +54,7 @@ def test_model_sampling_modes(): assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" # Without subpixel integration - model.integrate_mode = "threshold" + model.integrate_mode = "curvature" auto = model().data.detach().cpu().numpy() model.sampling_mode = "midpoint" midpoint = model().data.detach().cpu().numpy() From df31b5c5052380b584f1c5abccba8682c12ac2a5 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 28 Jul 2025 09:56:55 -0400 Subject: [PATCH 100/191] clear unused, test module --- astrophot/fit/gradient.py | 1 - astrophot/image/mixins/sip_mixin.py | 8 +- astrophot/models/_shared_methods.py | 1 - astrophot/models/base.py | 1 - astrophot/models/basis.py | 2 +- astrophot/models/bilinear_sky.py | 2 +- astrophot/models/func/integration.py | 9 +-- astrophot/models/pixelated_psf.py | 4 +- astrophot/param/module.py | 2 +- astrophot/utils/__init__.py | 2 - .../utils/initialize/segmentation_map.py | 9 +-- astrophot/utils/interpolate.py | 79 +++---------------- astrophot/utils/optimization.py | 28 ------- tests/test_model.py | 10 ++- tests/test_param.py | 24 ++++++ 15 files changed, 56 insertions(+), 126 deletions(-) delete mode 100644 astrophot/utils/optimization.py diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index d8ba2226..1e2a7788 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -225,7 +225,6 @@ def fit(self) -> BaseOptimizer: if torch.allclose(momentum, torch.zeros_like(momentum)): self.message = self.message + str(e) break - print("momentum reset") momentum = torch.zeros_like(self.current_state) continue # Log the loss diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index bdce04fd..0e5cfe6f 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -43,15 +43,15 @@ def __init__( @forward def pixel_to_plane(self, i, j, crtan, CD): - di = interp2d(self.distortion_ij[0], j, i, padding_mode="border") - dj = interp2d(self.distortion_ij[1], j, i, padding_mode="border") + di = interp2d(self.distortion_ij[0], i, j, padding_mode="border") + dj = interp2d(self.distortion_ij[1], i, j, padding_mode="border") return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, CD, *crtan) @forward def plane_to_pixel(self, x, y, crtan, CD): I, J = func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) - dI = interp2d(self.distortion_IJ[0], J, I, padding_mode="border") - dJ = interp2d(self.distortion_IJ[1], J, I, padding_mode="border") + dI = interp2d(self.distortion_IJ[0], I, J, padding_mode="border") + dJ = interp2d(self.distortion_IJ[1], I, J, padding_mode="border") return I + dI, J + dJ @property diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 58f932ab..8bba0cf6 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -105,7 +105,6 @@ def optim(x, r, f, u): for param, x0x in zip(params, x0): if not model[param].initialized: if not model[param].is_valid(x0x): - print("soft valid", param, x0x) x0x = model[param].soft_valid( torch.tensor(x0x, dtype=config.DTYPE, device=config.DEVICE) ) diff --git a/astrophot/models/base.py b/astrophot/models/base.py index f1be7432..deac9439 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -186,7 +186,6 @@ def total_flux(self, window=None) -> torch.Tensor: def total_flux_uncertainty(self, window=None) -> torch.Tensor: jac = self.jacobian(window=window).flatten("data") - print("jac finite", torch.isfinite(jac).all()) dF = torch.sum(jac, dim=0) # VJP for sum(total_flux) current_uncertainty = self.build_params_array_uncertainty() return torch.sqrt(torch.sum((dF * current_uncertainty) ** 2)) diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index fc94032a..1a23ba5c 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -99,4 +99,4 @@ def transform_coordinates(self, x, y, PA, scale): @forward def brightness(self, x, y, weights): x, y = self.transform_coordinates(x, y) - return torch.sum(torch.vmap(lambda w, b: w * interp2d(b, y, x))(weights, self.basis), dim=0) + return torch.sum(torch.vmap(lambda w, b: w * interp2d(b, x, y))(weights, self.basis), dim=0) diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py index a65aabe2..87b4d5aa 100644 --- a/astrophot/models/bilinear_sky.py +++ b/astrophot/models/bilinear_sky.py @@ -80,4 +80,4 @@ def transform_coordinates(self, x, y, I, PA, scale): @forward def brightness(self, x, y, I): x, y = self.transform_coordinates(x, y) - return interp2d(I, y, x) + return interp2d(I, x, y) diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index d1d6969c..4647d4bd 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -82,12 +82,11 @@ def recursive_quad_integrate( N = max(1, int(np.prod(z.shape) * curve_frac)) select = torch.topk(torch.abs(z - z0).flatten(), N, dim=-1).indices - integral = torch.zeros_like(z) - integral[~select] = z[~select] + integral_flat = z.clone().flatten() - si, sj = upsample(i[select], j[select], quad_order, scale) + si, sj = upsample(i.flatten()[select], j.flatten()[select], quad_order, scale) - integral[select] = recursive_quad_integrate( + integral_flat[select] = recursive_quad_integrate( si, sj, brightness_ij, @@ -99,7 +98,7 @@ def recursive_quad_integrate( max_depth=max_depth, ).mean(dim=-1) - return integral + return integral_flat.reshape(z.shape) def recursive_bright_integrate( diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index 47d3e9c8..1dce90c5 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -55,6 +55,6 @@ def initialize(self): @forward def brightness(self, x, y, pixels, center): with OverrideParam(self.target.crtan, center): - pX, pY = self.target.plane_to_pixel(x, y) - result = interp2d(pixels, pY, pX) + i, j = self.target.plane_to_pixel(x, y) + result = interp2d(pixels, i, j) return result diff --git a/astrophot/param/module.py b/astrophot/param/module.py index a25ae581..78e87f65 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -44,7 +44,7 @@ def build_params_array_units(self): for param in self.dynamic_params: numel = max(1, np.prod(param.shape)) for _ in range(numel): - units.append(param.unit) + units.append(param.units) return units def fill_dynamic_value_uncertainties(self, uncertainty): diff --git a/astrophot/utils/__init__.py b/astrophot/utils/__init__.py index 4e70516c..b66971a3 100644 --- a/astrophot/utils/__init__.py +++ b/astrophot/utils/__init__.py @@ -4,12 +4,10 @@ decorators, integration, interpolate, - optimization, parametric_profiles, ) __all__ = [ - "optimization", "decorators", "interpolate", "integration", diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index 526f3018..39eb3757 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -190,14 +190,7 @@ def windows_from_segmentation_map(seg_map, hdul_index=0, skip_index=(0,)): """ - if isinstance(seg_map, str): - if seg_map.endswith(".fits"): - hdul = fits.open(seg_map) - seg_map = hdul[hdul_index].data - elif seg_map.endswith(".npy"): - seg_map = np.load(seg_map) - else: - raise ValueError(f"unrecognized file type, should be one of: fits, npy\n{seg_map}") + seg_map = _select_img(seg_map, hdul_index) seg_map = seg_map.T diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index 0587397a..11ff687d 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -10,76 +10,11 @@ def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): return np.array(prof) -def interp1d_torch(x_in, y_in, x_out): - indices = torch.searchsorted(x_in[:-1], x_out) - 1 - weights = (y_in[1:] - y_in[:-1]) / (x_in[1:] - x_in[:-1]) - return y_in[indices] + weights[indices] * (x_out - x_in[indices]) - - def interp2d( - im: torch.Tensor, - x: torch.Tensor, - y: torch.Tensor, - padding_mode: str = "zeros", -) -> torch.Tensor: - """ - Interpolates a 2D image at specified coordinates. - Similar to `torch.nn.functional.grid_sample` with `align_corners=False`. - - Args: - im (Tensor): A 2D tensor representing the image. - x (Tensor): A tensor of x coordinates (in pixel space) at which to interpolate. - y (Tensor): A tensor of y coordinates (in pixel space) at which to interpolate. - - Returns: - Tensor: Tensor with the same shape as `x` and `y` containing the interpolated values. - """ - - # Convert coordinates to pixel indices - h, w = im.shape - - # reshape for indexing purposes - start_shape = x.shape - x = x.flatten() - y = y.flatten() - - if padding_mode == "zeros": - valid = (x >= -0.5) & (x <= (w - 0.5)) & (y >= -0.5) & (y <= (h - 0.5)) - elif padding_mode == "border": - x = x.clamp(-0.5, w - 0.5) - y = y.clamp(-0.5, h - 0.5) - else: - raise ValueError(f"Unsupported padding mode: {padding_mode}") - - x0 = x.floor().long() - y0 = y.floor().long() - x0 = x0.clamp(0, w - 2) - x1 = x0 + 1 - y0 = y0.clamp(0, h - 2) - y1 = y0 + 1 - - fa = im[y0, x0] - fb = im[y1, x0] - fc = im[y0, x1] - fd = im[y1, x1] - - wa = (x1 - x) * (y1 - y) - wb = (x1 - x) * (y - y0) - wc = (x - x0) * (y1 - y) - wd = (x - x0) * (y - y0) - - result = fa * wa + fb * wb + fc * wc + fd * wd - - if padding_mode == "zeros": - return (result * valid).reshape(start_shape) - elif padding_mode == "border": - return result.reshape(start_shape) - - -def interp2d_ij( im: torch.Tensor, i: torch.Tensor, j: torch.Tensor, + padding_mode: str = "zeros", ) -> torch.Tensor: """ Interpolates a 2D image at specified coordinates. @@ -87,11 +22,11 @@ def interp2d_ij( Args: im (Tensor): A 2D tensor representing the image. - x (Tensor): A tensor of x coordinates (in pixel space) at which to interpolate. - y (Tensor): A tensor of y coordinates (in pixel space) at which to interpolate. + i (Tensor): A tensor of i coordinates (in pixel space) at which to interpolate. + j (Tensor): A tensor of j coordinates (in pixel space) at which to interpolate. Returns: - Tensor: Tensor with the same shape as `x` and `y` containing the interpolated values. + Tensor: Tensor with the same shape as `i` and `j` containing the interpolated values. """ # Convert coordinates to pixel indices @@ -124,4 +59,8 @@ def interp2d_ij( result = fa * wa + fb * wb + fc * wc + fd * wd - return (result * valid).view(*start_shape) + if padding_mode == "zeros": + return (result * valid).reshape(start_shape) + elif padding_mode == "border": + return result.reshape(start_shape) + raise ValueError(f"Unsupported padding mode: {padding_mode}") diff --git a/astrophot/utils/optimization.py b/astrophot/utils/optimization.py deleted file mode 100644 index dbdb4399..00000000 --- a/astrophot/utils/optimization.py +++ /dev/null @@ -1,28 +0,0 @@ -import torch - -from .. import config - - -def chi_squared(target, model, mask=None, variance=None): - if mask is None: - if variance is None: - return torch.sum((target - model) ** 2) - else: - return torch.sum(((target - model) ** 2) / variance) - else: - mask = torch.logical_not(mask) - if variance is None: - return torch.sum((target[mask] - model[mask]) ** 2) - else: - return torch.sum(((target[mask] - model[mask]) ** 2) / variance[mask]) - - -def reduced_chi_squared(target, model, params, mask=None, variance=None): - if mask is None: - ndf = ( - torch.prod(torch.tensor(target.shape, dtype=config.DTYPE, device=config.DEVICE)) - - params - ) - else: - ndf = torch.sum(torch.logical_not(mask)) - params - return chi_squared(target, model, mask, variance) / ndf diff --git a/tests/test_model.py b/tests/test_model.py index bd880c2e..e0add2c0 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -27,9 +27,11 @@ def test_model_sampling_modes(): ) # With subpixel integration + model.integrate_mode = "bright" auto = model().data.detach().cpu().numpy() model.sampling_mode = "midpoint" midpoint = model().data.detach().cpu().numpy() + midpoint_bright = midpoint.copy() model.sampling_mode = "simpsons" simpsons = model().data.detach().cpu().numpy() model.sampling_mode = "quad:5" @@ -48,12 +50,15 @@ def test_model_sampling_modes(): simpsons = model().data.detach().cpu().numpy() model.sampling_mode = "quad:5" quad5 = model().data.detach().cpu().numpy() + assert np.allclose( + midpoint, midpoint_bright, rtol=1e-2 + ), "no integrate sampling should match bright sampling" assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" - # Without subpixel integration + # curvature based subpixel integration model.integrate_mode = "curvature" auto = model().data.detach().cpu().numpy() model.sampling_mode = "midpoint" @@ -62,6 +67,9 @@ def test_model_sampling_modes(): simpsons = model().data.detach().cpu().numpy() model.sampling_mode = "quad:5" quad5 = model().data.detach().cpu().numpy() + assert np.allclose( + midpoint, midpoint_bright, rtol=1e-2 + ), "curvature integrate sampling should match bright sampling" assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" diff --git a/tests/test_param.py b/tests/test_param.py index aa6885a6..cdef1376 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -1,6 +1,9 @@ +import astrophot as ap from astrophot.param import Param import torch +from utils import make_basic_sersic + def test_param(): @@ -30,3 +33,24 @@ def test_param(): assert c.initialized, "pointer should be marked as initialized" assert c.is_valid(0.5), "value should be valid" assert c.uncertainty is None + + +def test_module(): + + target = make_basic_sersic() + model1 = ap.Model(name="test model 1", model_type="sersic galaxy model", target=target) + model2 = ap.Model(name="test model 2", model_type="sersic galaxy model", target=target) + model = ap.Model(name="test", model_type="group model", target=target, models=[model1, model2]) + model.initialize() + + U = torch.ones_like(model.build_params_array()) * 0.1 + model.fill_dynamic_value_uncertainties(U) + + paramsu = model.build_params_array_uncertainty() + assert torch.all(torch.isfinite(paramsu)), "All parameters should be finite" + + paramsn = model.build_params_array_names() + assert all(isinstance(name, str) for name in paramsn), "All parameter names should be strings" + + paramsun = model.build_params_array_units() + assert all(isinstance(unit, str) for unit in paramsun), "All parameter units should be strings" From cfc7e8cac0fb0a931edc5aadccb00d2cb910df4b Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 28 Jul 2025 11:16:44 -0400 Subject: [PATCH 101/191] Add alternate image types tutorial --- docs/source/tutorials/ImageTypes.ipynb | 166 +++++++++++++++++++++++++ docs/source/tutorials/index.rst | 1 + 2 files changed, 167 insertions(+) create mode 100644 docs/source/tutorials/ImageTypes.ipynb diff --git a/docs/source/tutorials/ImageTypes.ipynb b/docs/source/tutorials/ImageTypes.ipynb new file mode 100644 index 00000000..229d9e97 --- /dev/null +++ b/docs/source/tutorials/ImageTypes.ipynb @@ -0,0 +1,166 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Alternate Image Types\n", + "\n", + "AstroPhot operates in the tangent plane space and so must have a mapping between the pixels and the sky that it can use to properly perform integration within every pixel. Aside from the standard `ap.TargetImage` used to store regular data with a linear mapping between pixel space and the tangent plane, there are two more image types `ap.SIPTargetImage` and `ap.CMOSTargetImage` which are explained below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import astrophot as ap\n", + "import torch\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.patches import Rectangle" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## SIP Target Image\n", + "\n", + "The `ap.SIPTargetImage` object stores data for a pixel array that is distorted using Simple-Image-Polynomial distortions. This is a non-linear polynomial transformation that is used to account for optical effects in images that result in the sky being non-linearly projected onto the pixel grid used to collect data. AstroPhot follows the WCS standard when it comes to SIP distortions and can read the SIP coefficients directly from an image. AstroPhot can also save a SIP distortion model to a FITS image. Internally the SIP coefficients are stored in `image.sipA`, `image.sipB`, `image.sipAP` and `image.SIPBP` which are formatted as dictionaries with the keys as tuples of two integers giving the powers and the value as the coefficient. For example in a FITS file the header line `A_1_2 = 0.01` will translate to `image.sipA = {(1,2): 0.01}`. \n", + "\n", + "Some particulars of the AstroPhot implementation. For the sake of efficiency, when a SIP image is created AstroPhot evaluates the SIP distortion at every pixel and stores that in a distortion map with the same size as the image. Afterwards, calling `image.pixel_to_plane` will not evaluate the SIP polynomial, but instead a bilinear interpolation of the distortion model will be used. This massively increases speed, but means that the distortion model is only accurate up to the bilinear interpolation accuracy, since most SIP distortions are quite smooth, this interpolation is extremely accurate. For queries beyond the borders of the image, AstroPhot will not extrapolate the SIP polynomials, instead the distortion amount at the pixel border is simply carried onwards. As second element of the AstroPhot implementation is that if a backwards model (`AP` and `BP`) is not provided, then AstroPhot will use linear algebra to determine the backwards model. This is taken from the very clever code written by Shu Liu and Lei Hi that you [can find here](https://github.com/Roman-Supernova-PIT/sfft/blob/master/sfft/utils/CupyWCSTransform.py).\n", + "\n", + "For the most part, once you define a `ap.SIPTargetImage` you can use it like a regular `ap.TargetImage` object." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "target = ap.SIPTargetImage(\n", + " data=torch.randn(128, 256),\n", + " sipA={(0, 1): 1e-3, (1, 0): -1e-3, (1, 1): 1e-4, (2, 0): -5e-5, (0, 2): -5e-4},\n", + " sipB={(0, 1): 1e-3, (1, 0): -1e-3, (1, 1): -1e-3, (2, 0): 1e-4, (0, 2): 2e-3},\n", + ")\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 5))\n", + "ap.plots.target_image(fig, ax, target)\n", + "ax.set_title(\"SIP Target Image\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "Because the pixels are distorted on the sky, this means that the amount of area on the sky for each pixel is different. One would expect a pixel that projects to a larger area to collect more light than one that gets squished smaller. A uniform source observed through a telescope with SIP distortions will therefore produce a non-uniform image. As such, AstroPhot tracks the projected area of each pixel to ensure its calculations are accurate. Here is what that pixel area map looks like for the above image. As you can see, the parts which get stretched out then correspond to larger areas, and the parts that get squished correspond to smaller areas." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "plt.imshow(target.pixel_area_map.T, cmap=\"inferno\", origin=\"lower\")\n", + "plt.colorbar(label=\"Pixel Area (arcsec$^2$)\")\n", + "plt.title(\"Pixel Area Map\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## CMOS Target Image\n", + "\n", + "A CMOS sensor is an alternative technology from a CCD for collecting light in an optical system. While it has certain advantages, one challenge with CMOS sensors is that only a sub region of each pixel is actually sensitive to light, the rest holding per-pixel electronics. This means there are gaps in the true placement of the CMOS pixels on the sky. Currently AstroPhot implements this by ensuring that the models are only sampled and integrated in the appropriate pixel areas. However, this treatment is not appropriate for certain PSF convolution modes and so the `ap.CMOSTargetImage` is under active development. Expect some changes in the future as we ensure it is viable for all model types. Currently, sky models, point source models, and un-convolved galaxy models should all work accurately. Adding convolved galaxy models is set for future work." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "target = ap.CMOSTargetImage(\n", + " data=torch.randn(128, 256),\n", + " subpixel_loc=(-0.1, -0.1),\n", + " subpixel_scale=0.8,\n", + ")\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 5))\n", + "ap.plots.target_image(fig, ax, target)\n", + "ax.set_title(\"CMOS Target Image\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "There is no visible difference when plotting the data as compressing every pixel in an image like above would make it hard to see what is happening. Below we plot what a single pixel truly looks like in the CMOS target representation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(figsize=(5, 5))\n", + "r1 = Rectangle((-0.5, -0.5), 1, 1, facecolor=\"grey\", label=\"Pixel Area\")\n", + "ax.add_patch(r1)\n", + "r2 = Rectangle((-0.5, -0.5), 0.8, 0.8, facecolor=\"blue\", label=\"Subpixel Area\")\n", + "ax.add_patch(r2)\n", + "ax.set_xlim(-0.5, 0.5)\n", + "ax.set_ylim(-0.5, 0.5)\n", + "ax.set_title(\"CMOS Pixel Representation\")\n", + "ax.legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "Where the blue subpixel area is actually sensitive to light. Note that pixel indexing places (0,0) at the center of the pixel and every pixel has size 1, so for the first pixel show here the pixel coordinates range from -0.5 to +0.5 on both axes. This is also the representation used to define a `ap.CMOSTargetImage` where `subpixel_loc` gives the pixel coordinates of the center of the subpixel and `subpixel_scale` gives the side length of the subpixel." + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 7a57d9f4..b4b600c6 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -19,4 +19,5 @@ version of each tutorial is available here. CustomModels GravitationalLensing AdvancedPSFModels + ImageTypes ConstrainedModels From 8731a69efe551d029a1a18cd7b8af4fc9b5ab8ef Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 28 Jul 2025 22:01:44 -0400 Subject: [PATCH 102/191] add poisson noise tutorial and lm fitter --- astrophot/fit/func/__init__.py | 4 +- astrophot/fit/func/lm.py | 89 +++++++--- astrophot/fit/lm.py | 30 +++- astrophot/models/mixins/transform.py | 6 +- docs/source/tutorials/PoissonLikelihood.ipynb | 156 ++++++++++++++++++ docs/source/tutorials/index.rst | 1 + 6 files changed, 254 insertions(+), 32 deletions(-) create mode 100644 docs/source/tutorials/PoissonLikelihood.ipynb diff --git a/astrophot/fit/func/__init__.py b/astrophot/fit/func/__init__.py index b2997e4e..dd4ba512 100644 --- a/astrophot/fit/func/__init__.py +++ b/astrophot/fit/func/__init__.py @@ -1,4 +1,4 @@ -from .lm import lm_step, hessian, gradient +from .lm import lm_step, hessian, gradient, hessian_poisson, gradient_poisson from .slalom import slalom_step -__all__ = ["lm_step", "hessian", "gradient", "slalom_step"] +__all__ = ["lm_step", "hessian", "gradient", "slalom_step", "hessian_poisson", "gradient_poisson"] diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index d3879cdf..8d892502 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -4,12 +4,39 @@ from ...errors import OptimizeStopFail, OptimizeStopSuccess +def nll(D, M, W): + """ + Negative log-likelihood for Gaussian noise. + D: data + M: model prediction + W: weights + """ + return 0.5 * torch.sum(W * (D - M) ** 2) + + +def nll_poisson(D, M): + """ + Negative log-likelihood for Poisson noise. + D: data + M: model prediction + """ + return torch.sum(M - D * torch.log(M + 1e-10)) # Adding small value to avoid log(0) + + +def gradient(J, W, D, M): + return J.T @ (W * (D - M)).unsqueeze(1) + + +def gradient_poisson(J, D, M): + return J.T @ (D / M - 1).unsqueeze(1) + + def hessian(J, W): return J.T @ (W.unsqueeze(1) * J) -def gradient(J, W, R): - return J.T @ (W * R).unsqueeze(1) +def hessian_poisson(J, D, M): + return J.T @ ((D / (M**2 + 1e-10)).unsqueeze(1) * J) def damp_hessian(hess, L): @@ -30,28 +57,50 @@ def solve(hess, grad, L): return hessD, h -def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0, tolerance=1e-4): +def lm_step( + x, + data, + model, + weight, + jacobian, + L=1.0, + Lup=9.0, + Ldn=11.0, + tolerance=1e-4, + likelihood="gaussian", +): L0 = L M0 = model(x) # (M,) J = jacobian(x) # (M, N) - R = data - M0 # (M,) - chi20 = torch.sum(weight * R**2).item() / ndf - grad = gradient(J, weight, R) # (N, 1) - hess = hessian(J, weight) # (N, N) + + if likelihood == "gaussian": + nll0 = nll(data, M0, weight).item() # torch.sum(weight * R**2).item() / ndf + grad = gradient(J, weight, data, M0) # (N, 1) + hess = hessian(J, weight) # (N, N) + elif likelihood == "poisson": + nll0 = nll_poisson(data, M0).item() + grad = gradient_poisson(J, data, M0) # (N, 1) + hess = hessian_poisson(J, data, M0) # (N, N) + else: + raise ValueError(f"Unsupported likelihood: {likelihood}") + if torch.allclose(grad, torch.zeros_like(grad)): raise OptimizeStopSuccess("Gradient is zero, optimization converged.") - best = {"x": torch.zeros_like(x), "chi2": chi20, "L": L} - scary = {"x": None, "chi2": np.inf, "L": None, "rho": np.inf} + best = {"x": torch.zeros_like(x), "nll": nll0, "L": L} + scary = {"x": None, "nll": np.inf, "L": None, "rho": np.inf} nostep = True improving = None for _ in range(10): hessD, h = solve(hess, grad, L) # (N, N), (N, 1) M1 = model(x + h.squeeze(1)) # (M,) - chi21 = torch.sum(weight * (data - M1) ** 2).item() / ndf + if likelihood == "gaussian": + nll1 = nll(data, M1, weight).item() # torch.sum(weight * (data - M1) ** 2).item() / ndf + elif likelihood == "poisson": + nll1 = nll_poisson(data, M1).item() # Handle nan chi2 - if not np.isfinite(chi21): + if not np.isfinite(nll1): L *= Lup if improving is True: break @@ -61,13 +110,13 @@ def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0, tol if torch.allclose(h, torch.zeros_like(h)) and L < 0.1: raise OptimizeStopSuccess("Step with zero length means optimization complete.") - # actual chi2 improvement vs expected from linearization - rho = (chi20 - chi21) * ndf / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() + # actual nll improvement vs expected from linearization + rho = (nll0 - nll1) / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() - if (chi21 < (chi20 + tolerance) and abs(rho - 1) < abs(scary["rho"] - 1)) or ( - chi21 < scary["chi2"] and rho > -10 + if (nll1 < (nll0 + tolerance) and abs(rho - 1) < abs(scary["rho"] - 1)) or ( + nll1 < scary["nll"] and rho > -10 ): - scary = {"x": x + h.squeeze(1), "chi2": chi21, "L": L0, "rho": rho} + scary = {"x": x + h.squeeze(1), "nll": nll1, "L": L0, "rho": rho} # Avoid highly non-linear regions if rho < 0.1 or rho > 2: @@ -77,8 +126,8 @@ def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0, tol improving = False continue - if chi21 < best["chi2"]: # new best - best = {"x": x + h.squeeze(1), "chi2": chi21, "L": L} + if nll1 < best["nll"]: # new best + best = {"x": x + h.squeeze(1), "nll": nll1, "L": L} nostep = False L /= Ldn if L < 1e-8 or improving is False: @@ -93,11 +142,11 @@ def lm_step(x, data, model, weight, jacobian, ndf, L=1.0, Lup=9.0, Ldn=11.0, tol improving = False # If we are improving chi2 by more than 10% then we can stop - if (best["chi2"] - chi20) / chi20 < -0.1: + if (best["nll"] - nll0) / nll0 < -0.1: break if nostep: - if scary["x"] is not None and (scary["chi2"] - chi20) / chi20 < tolerance: + if scary["x"] is not None and (scary["nll"] - nll0) / nll0 < tolerance: return scary raise OptimizeStopFail("Could not find step to improve chi^2") diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 397bf587..6c2a4a72 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -123,6 +123,7 @@ def __init__( L0=1.0, max_step_iter: int = 10, ndf=None, + likelihood="gaussian", **kwargs, ): @@ -140,6 +141,9 @@ def __init__( self.Lup = Lup self.Ldn = Ldn self.L = L0 + self.likelihood = likelihood + if self.likelihood not in ["gaussian", "poisson"]: + raise ValueError(f"Unsupported likelihood: {self.likelihood}") # mask fit_mask = self.model.fit_mask() @@ -197,6 +201,10 @@ def __init__( def chi2_ndf(self): return torch.sum(self.W * (self.Y - self.forward(self.current_state)) ** 2) / self.ndf + def poisson_2nll_ndf(self): + M = self.forward(self.current_state) + return 2 * torch.sum(M - self.Y * torch.log(M + 1e-10)) / self.ndf + @torch.no_grad() def fit(self, update_uncertainty=True) -> BaseOptimizer: """This performs the fitting operation. It iterates the LM step @@ -214,8 +222,13 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.message = "No parameters to optimize. Exiting fit" return self + if self.likelihood == "gaussian": + quantity = "Chi^2/DoF" + self.loss_history = [self.chi2_ndf().item()] + elif self.likelihood == "poisson": + quantity = "2NLL/DoF" + self.loss_history = [self.poisson_2nll_ndf().item()] self._covariance_matrix = None - self.loss_history = [self.chi2_ndf().item()] self.L_history = [self.L] self.lambda_history = [self.current_state.detach().clone().cpu().numpy()] if self.verbose > 0: @@ -225,7 +238,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: for _ in range(self.max_iter): if self.verbose > 0: - config.logger.info(f"Chi^2/DoF: {self.loss_history[-1]:.6g}, L: {self.L:.3g}") + config.logger.info(f"{quantity}: {self.loss_history[-1]:.6g}, L: {self.L:.3g}") try: if self.fit_valid: with ValidContext(self.model): @@ -235,10 +248,10 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: model=self.forward, weight=self.W, jacobian=self.jacobian, - ndf=self.ndf, L=self.L, Lup=self.Lup, Ldn=self.Ldn, + likelihood=self.likelihood, ) self.current_state = self.model.from_valid(res["x"]).detach() else: @@ -248,10 +261,10 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: model=self.forward, weight=self.W, jacobian=self.jacobian, - ndf=self.ndf, L=self.L, Lup=self.Lup, Ldn=self.Ldn, + likelihood=self.likelihood, ) self.current_state = res["x"].detach() except OptimizeStopFail: @@ -270,7 +283,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.L = np.clip(res["L"], 1e-9, 1e9) self.L_history.append(res["L"]) - self.loss_history.append(res["chi2"]) + self.loss_history.append(2 * res["nll"] / self.ndf) self.lambda_history.append(self.current_state.detach().clone().cpu().numpy()) if self.check_convergence(): @@ -281,7 +294,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: if self.verbose > 0: config.logger.info( - f"Final Chi^2/DoF: {np.nanmin(self.loss_history):.6g}, L: {self.L_history[np.nanargmin(self.loss_history)]:.3g}. Converged: {self.message}" + f"Final {quantity}: {np.nanmin(self.loss_history):.6g}, L: {self.L_history[np.nanargmin(self.loss_history)]:.3g}. Converged: {self.message}" ) self.model.fill_dynamic_values( @@ -336,7 +349,10 @@ def covariance_matrix(self) -> torch.Tensor: if self._covariance_matrix is not None: return self._covariance_matrix J = self.jacobian(self.current_state) - hess = func.hessian(J, self.W) + if self.likelihood == "gaussian": + hess = func.hessian(J, self.W) + elif self.likelihood == "poisson": + hess = func.hessian_poisson(J, self.Y, self.forward(self.current_state)) try: self._covariance_matrix = torch.linalg.inv(hess) except: diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index ac0af952..ba10623f 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -50,9 +50,9 @@ def initialize(self): x, y = target_area.coordinate_center_meshgrid() x = (x - self.center.value[0]).detach().cpu().numpy() y = (y - self.center.value[1]).detach().cpu().numpy() - mu20 = np.median(dat * np.abs(x)) - mu02 = np.median(dat * np.abs(y)) - mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) + mu20 = np.mean(dat * np.abs(x)) + mu02 = np.mean(dat * np.abs(y)) + mu11 = np.mean(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) M = np.array([[mu20, mu11], [mu11, mu02]]) if not self.PA.initialized: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): diff --git a/docs/source/tutorials/PoissonLikelihood.ipynb b/docs/source/tutorials/PoissonLikelihood.ipynb new file mode 100644 index 00000000..4eb09097 --- /dev/null +++ b/docs/source/tutorials/PoissonLikelihood.ipynb @@ -0,0 +1,156 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Poisson Noise Model\n", + "\n", + "For the most part, astronomical images are modelled assuming an independent Gaussian uncertainty on every pixel resulting in a negative log likelihood of the form: $\\sum_i\\frac{(d_i-m_i)^2}{2\\sigma_i^2}$ where $d_i$ is the pixel value, $m_i$ is the model value for that pixel, and $\\sigma_i$ is the uncertainty on that pixel. However, in truth the best model for an astronomical image is the Poisson distribution with negative log likelihood of: $\\sum_i m_i + \\log(d_i!) - d_i\\log(m_i)$ with the same definitions, except specifying that $d_i$ is in counts (number of photons or electrons). For large enough $d_i$ these likelihoods are essentially identical and Gaussian is easier to work with. When signal-to-noise ratios get very low, the differences between Poisson and Gaussian distributions can become apparent and so it is important to treat the data with a Poisson likelihood. These conditions regularly occur for gamma ray, x-ray, and low SNR UV data, but are less common for longer wavelengths. AstroPhot can model Poisson likelihood data, here we will demo an example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import astrophot as ap\n", + "import torch\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Make some mock data\n", + "\n", + "Lets create some mock low SNR data. Notice that poisson noise isn't additive like gaussian noise. To sample the image, out true model acts as a photon rate and the `np.random.poisson` samples some number of counts based on that rate. Our goal will be to recover the rate of every pixel and ultimately the sersic parameters that produce the correct rate model. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# make some mock data\n", + "target = ap.TargetImage(data=np.zeros((128, 128)))\n", + "true_model = ap.Model(\n", + " name=\"truth\",\n", + " model_type=\"sersic galaxy model\",\n", + " center=(64, 64),\n", + " q=0.7,\n", + " PA=0,\n", + " n=1,\n", + " Re=32,\n", + " Ie=1,\n", + " target=target,\n", + ")\n", + "img = true_model().data.T.detach().cpu().numpy()\n", + "np.random.seed(42) # for reproducibility\n", + "target.data = np.random.poisson(img) # sample poisson distribution\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 5))\n", + "ap.plots.model_image(fig, ax[0], true_model)\n", + "ax[0].set_title(\"True Model\")\n", + "ap.plots.target_image(fig, ax[1], target)\n", + "ax[1].set_title(\"Target Image (Poisson Sampled)\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "Indeed this is some noisy data. The AstroPhot target_image plotting routine struggles a bit with this image, but it kind of looks neat anyway.\n", + "\n", + "## Model the data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "model = ap.Model(name=\"model\", model_type=\"sersic galaxy model\", target=target)\n", + "model.initialize()" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "While the Levenberg-Marquardt algorithm is traditionally considered as a least squares algorithm, that is actually just its most common application. LM naturally generalizes to a broad class of problems, including the Poisson Likelihood. Here we see the AstroPhot automatic initialization does well on this image and recovers decent starting parameters, LM has an easy time finishing the job to find the maximum likelihood.\n", + "\n", + "Note that the idea of a $\\chi^2/{\\rm dof}$ is not as clearly defined for a Poisson likelihood. We take the closest analogue by taking 2 times the negative log likelihood divided by the DoF. This doesn't have any strict statistical meaning but is somewhat intuitive to work with for those used to $\\chi^2/{\\rm dof}$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "res = ap.fit.LM(model, likelihood=\"poisson\", verbose=1).fit()\n", + "\n", + "fig, ax = plt.subplots()\n", + "ap.plots.model_image(fig, ax, model)\n", + "ax.set_title(\"Fitted Model\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Printing the model and its parameters, we see that we have indeed recovered very close to the true values for all parameters!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "print(model)" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "If you encounter a problem where LM struggles to fit the poisson data, the `Slalom` optimizer is also quite efficient in these settings. See the fitting methods tutorial for more details." + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index b4b600c6..bd710a35 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -16,6 +16,7 @@ version of each tutorial is available here. BasicPSFModels JointModels ImageAlignment + PoissonLikelihood CustomModels GravitationalLensing AdvancedPSFModels From 2a257913edcbc09175a01951e8ce900bf56abcc5 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 29 Jul 2025 13:28:23 -0400 Subject: [PATCH 103/191] working on docs --- astrophot/models/mixins/exponential.py | 14 +++--- astrophot/models/mixins/gaussian.py | 8 +++- astrophot/models/mixins/transform.py | 13 ++++-- docs/source/getting_started.rst | 62 ++++++++------------------ 4 files changed, 42 insertions(+), 55 deletions(-) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 36c1966b..f3d4147a 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -17,10 +17,12 @@ class ExponentialMixin: An exponential is a classical radial model used in many contexts. The functional form of the exponential profile is defined as: - $$I(R) = I_e * \\exp(- b_1(\\frac{R}{R_e} - 1))$$ + $$ + I(R) = I_e * \exp(- b_1(\frac{R}{R_e} - 1)) + $$ Ie is the brightness at the effective radius, and Re is the effective - radius. `b_1` is a constant that ensures `Ie` is the brightness at `R_e`. + radius. $b_1$ is a constant that ensures $I_e$ is the brightness at $R_e$. Parameters: Re: effective radius in arcseconds @@ -57,10 +59,12 @@ class iExponentialMixin: An exponential is a classical radial model used in many contexts. The functional form of the exponential profile is defined as: - $$I(R) = I_e * \\exp(- b_1(\\frac{R}{R_e} - 1))$$ + $$ + I(R) = I_e * \exp(- b_1(\frac{R}{R_e} - 1)) + $$ - Ie is the brightness at the effective radius, and Re is the effective - radius. `b_1` is a constant that ensures `Ie` is the brightness at `R_e`. + $I_e$ is the brightness at the effective radius, and $R_e$ is the effective + radius. $b_1$ is a constant that ensures $I_e$ is the brightness at $R_e$. `Re` and `Ie` are batched by their first dimension, allowing for multiple exponential profiles to be defined at once. diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 8c84d49b..feb7ae09 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -17,7 +17,9 @@ class GaussianMixin: The Gaussian profile is a simple and widely used model for extended objects. The functional form of the Gaussian profile is defined as: - $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2))$$ + ```{math} + I(R) = \frac{{\rm flux}}{\sqrt{2\pi}\sigma} \exp(-R^2 / (2 \sigma^2)) + ``` where `I_0` is the intensity at the center of the profile and `sigma` is the standard deviation which controls the width of the profile. @@ -57,7 +59,9 @@ class iGaussianMixin: The Gaussian profile is a simple and widely used model for extended objects. The functional form of the Gaussian profile is defined as: - $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2))$$ + ```{math} + I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2)) + ``` where `sigma` is the standard deviation which controls the width of the profile and `flux` gives the total flux of the profile (assuming no diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index ba10623f..2af468d4 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -83,7 +83,9 @@ class SuperEllipseMixin: extension of the standard elliptical representation, especially for early-type galaxies. The functional form for this is: - $$R = (|x|^C + |y|^C)^(1/C)$$ + $$ + R = (|x|^C + |y|^C)^(1/C) + $$ where R is the new distance metric, X Y are the coordinates, and C is the coefficient for the superellipse. C can take on any value @@ -136,14 +138,17 @@ class FourierEllipseMixin: science case at hand. Parameters: - am: Tensor of amplitudes for the Fourier modes, indicates the strength + am: + Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. - phim: Tensor of phases for the Fourier modes, adjusts the + phim: + Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) Options: - modes: Tuple of integers indicating which Fourier modes to use. + modes: + Tuple of integers indicating which Fourier modes to use. """ _model_type = "fourier" diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index a07cc2fa..d7ea2c57 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -5,8 +5,8 @@ Getting Started First follow the installation instructions, then come here to learn how to use AstroPhot for the first time. -Basic AstroPhot code philosophy ------------------------------- +Basic AstroPhot code organization +--------------------------------- AstroPhot is a modular and object oriented astronomical image modelling package. Modularity means that it is relatively simple to change or replace one aspect of @@ -22,14 +22,13 @@ would expect. This makes the experience more user friendly hopefully meaning that you can quickly take advantage of the powerful features available. One of the core components of AstroPhot is the model objects, these are -organized in a class hierarchy with several layers of inheritance. While this is -not considered best programming practice for many situations, in AstroPhot it is -very intentional and we think helpful to users. With this hierarchy it is very -easy to customize a model to suit your needs without needing to rewrite a great -deal of code. Simply access the point in the hierarchy which most closely -matches your desired result and make minor modifications. In the tutorials you -can see how detailed models can be implemented with only a few lines of code -even though the user has complete freedom to change any aspect of the model. +organized in a class hierarchy with several layers of inheritance. With this +hierarchy it is very easy to customize a model to suit your needs without +needing to rewrite a great deal of code. Simply access the point in the +hierarchy which most closely matches your desired result and make minor +modifications. In the tutorials you can see how detailed models can be +implemented with only a few lines of code even though the user has complete +freedom to change any aspect of the model. Install ------- @@ -59,40 +58,15 @@ tutorials then run the:: command to download the AstroPhot tutorials. If you run into difficulty with this, you can also access the tutorials directly at :doc:`tutorials` to download -as PDFs. Once you have the tutorials, start a jupyter session and run through -them. The recommended order is: - -#. :doc:`tutorials/GettingStarted` -#. :doc:`tutorials/GroupModels` -#. :doc:`tutorials/ModelZoo` -#. :doc:`tutorials/FittingMethods` -#. :doc:`tutorials/BasicPSFModels` -#. :doc:`tutorials/JointModels` -#. :doc:`tutorials/AdvancedPSFModels` -#. :doc:`tutorials/CustomModels` - -When downloading the tutorials, you will also get a file called -``simple_config.py``, this is an example AstroPhot config file. Configuration -files are an alternate interface to the AstroPhot functionality. They are -somewhat more limited in capacity, but very easy to interface with. See the -guide on configuration files here: :doc:`configfile_interface` . - -Model Org Chart ---------------- - -As a quick reference for what kinds of models are available in AstroPhot, the -org chart shows you the class hierarchy where the leaf nodes at the bottom are -the models that can actually be used. Following different paths through the -hierarchy gives models with different properties. Just use the second line at -each step in the flow chart to construct the name. For example one could follow -a fairly direct path to get a ``sersic galaxy model``, or a more complex path to -get a ``nuker fourier warp galaxy model``. Note that the ``Component_Model`` -object doesn't have an identifier, it is really meant to hide in the background -while its subclasses do the work. - -.. image:: https://github.com/Autostronomy/AstroPhot/blob/main/media/AstroPhotModelOrgchart.png?raw=true - :alt: AstroPhot Model Org Chart - :width: 100 % +as PDFs or jupyter notebooks. Once you have the tutorials, start a jupyter +session and run through them. + +Model Zoo +--------- + +The best way to see what models are available in AstroPhot is to peruse the +:doc:`tutorials/ModelZoo`. Here you can see the models evaluated on a regular +grid, and play around with the values if you are running the tutorial locally. Detailed Documentation ---------------------- From e35ffedab8ea8cdd63b0adc086cea86fda1eed41 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 29 Jul 2025 14:00:57 -0400 Subject: [PATCH 104/191] still working on docs display --- astrophot/utils/decorators.py | 2 +- docs/source/_config.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/astrophot/utils/decorators.py b/astrophot/utils/decorators.py index 97b1070e..a82a1fb6 100644 --- a/astrophot/utils/decorators.py +++ b/astrophot/utils/decorators.py @@ -38,6 +38,6 @@ def combine_docstrings(cls): combined_docs = [cls.__doc__ or ""] for base in cls.__bases__: if base.__doc__: - combined_docs.append(f"\n[UNIT {base.__name__}]\n{base.__doc__}") + combined_docs.append(f"\n[UNIT {base.__name__}]\n\n{base.__doc__}") cls.__doc__ = "\n".join(combined_docs).strip() return cls diff --git a/docs/source/_config.yml b/docs/source/_config.yml index d72b8966..635dc983 100644 --- a/docs/source/_config.yml +++ b/docs/source/_config.yml @@ -38,12 +38,12 @@ sphinx: extra_extensions: - "sphinx.ext.autodoc" - "sphinx.ext.autosummary" - - "sphinx.ext.napoleon" - - "sphinx.ext.doctest" - - "sphinx.ext.coverage" - - "sphinx.ext.mathjax" - - "sphinx.ext.ifconfig" - "sphinx.ext.viewcode" + # - "sphinx.ext.napoleon" + # - "sphinx.ext.doctest" + # - "sphinx.ext.coverage" + # - "sphinx.ext.mathjax" + # - "sphinx.ext.ifconfig" config: html_theme_options: logo: From a381ae829feab22897749135762f9ed614ec870f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 29 Jul 2025 22:44:19 -0400 Subject: [PATCH 105/191] add all and docstrings --- astrophot/models/__init__.py | 5 --- astrophot/models/group_psf_model.py | 3 ++ astrophot/models/model_object.py | 3 +- astrophot/plots/__init__.py | 31 +++++++++++++-- astrophot/plots/diagnostic.py | 2 + astrophot/plots/image.py | 2 + astrophot/plots/profile.py | 9 ++++- astrophot/utils/conversions/__init__.py | 49 ++++++++++++++++++++++++ astrophot/utils/conversions/functions.py | 15 ++++++++ astrophot/utils/conversions/units.py | 12 ++++++ astrophot/utils/decorators.py | 2 + astrophot/utils/initialize/__init__.py | 10 ++++- astrophot/utils/integration.py | 2 + astrophot/utils/interpolate.py | 2 + astrophot/utils/parametric_profiles.py | 10 +++++ 15 files changed, 145 insertions(+), 12 deletions(-) diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 00d58d37..c56408f5 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -140,10 +140,6 @@ "GalaxyModel", "SkyModel", "PointSource", - "RayGalaxy", - "SuperEllipseGalaxy", - "WedgeGalaxy", - "WarpGalaxy", "PixelBasisPSF", "AiryPSF", "PixelatedPSF", @@ -155,7 +151,6 @@ "EdgeonIsothermal", "MultiGaussianExpansion", "GaussianEllipsoid", - "FourierEllipseGalaxy", "SersicGalaxy", "SersicPSF", "SersicFourierEllipse", diff --git a/astrophot/models/group_psf_model.py b/astrophot/models/group_psf_model.py index 2d1f977c..2d861200 100644 --- a/astrophot/models/group_psf_model.py +++ b/astrophot/models/group_psf_model.py @@ -7,6 +7,9 @@ class PSFGroupModel(GroupModel): + """ + A group of PSF models. Behaves similarly to a `GroupModel`, but specifically designed for PSF models. + """ _model_type = "psf" usable = True diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index d88ad086..63b082b6 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -21,7 +21,8 @@ class ComponentModel(SampleMixin, Model): - """Component of a model for an object in an image. + """ + Component of a model for an object in an image. This is a single component of an image model. It has a position on the sky determined by `center` and may or may not be convolved with a PSF to represent some data. diff --git a/astrophot/plots/__init__.py b/astrophot/plots/__init__.py index e5799f23..3ee6dc30 100644 --- a/astrophot/plots/__init__.py +++ b/astrophot/plots/__init__.py @@ -1,4 +1,27 @@ -from .profile import * -from .image import * -from .visuals import * -from .diagnostic import * +from .profile import ( + radial_light_profile, + radial_median_profile, + ray_light_profile, + wedge_light_profile, + warp_phase_profile, +) +from .image import target_image, model_image, residual_image, model_window, psf_image +from .visuals import main_pallet, cmap_div, cmap_grad +from .diagnostic import covariance_matrix + +__all__ = ( + "radial_light_profile", + "radial_median_profile", + "ray_light_profile", + "wedge_light_profile", + "warp_phase_profile", + "target_image", + "model_image", + "residual_image", + "model_window", + "psf_image", + "main_pallet", + "cmap_div", + "cmap_grad", + "covariance_matrix", +) diff --git a/astrophot/plots/diagnostic.py b/astrophot/plots/diagnostic.py index 75a9e4e4..a78392be 100644 --- a/astrophot/plots/diagnostic.py +++ b/astrophot/plots/diagnostic.py @@ -18,6 +18,8 @@ def covariance_matrix( showticks=True, **kwargs, ): + """ + Create a covariance matrix plot.""" num_params = covariance_matrix.shape[0] fig, axes = plt.subplots(num_params, num_params, figsize=figsize) plt.subplots_adjust(wspace=0.0, hspace=0.0) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 6cfd2c93..a845ce83 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -114,6 +114,7 @@ def psf_image( vmax=None, **kwargs, ): + """For plotting PSF images, or the output of a PSF model.""" if isinstance(psf, (PSFModel, PSFGroupModel)): psf = psf() # recursive call for target image list @@ -428,6 +429,7 @@ def residual_image( @ignore_numpy_warnings def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): + """Used for plotting the window(s) of a model on an image.""" if target is None: target = model.target if isinstance(ax, np.ndarray): diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 6adad800..ec153431 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -31,6 +31,9 @@ def radial_light_profile( resolution=1000, plot_kwargs={}, ): + """ + Used to plot the brightness profile as a function of radius for modes which define a `radial_model` + """ xx = torch.linspace( R0, max(model.window.shape) @@ -179,6 +182,9 @@ def ray_light_profile( extend_profile=1.0, resolution=1000, ): + """ + Used for plotting ray type models which define a `iradial_model` method. These have multiple radial profiles. + """ xx = torch.linspace( 0, max(model.window.shape) * model.target.pixelscale * extend_profile / 2, @@ -213,6 +219,7 @@ def wedge_light_profile( extend_profile=1.0, resolution=1000, ): + """same as ray light profile but for wedges""" xx = torch.linspace( 0, max(model.window.shape) * model.target.pixelscale * extend_profile / 2, @@ -240,7 +247,7 @@ def wedge_light_profile( def warp_phase_profile(fig, ax, model: Model, rad_unit="arcsec"): - + """Used to plot the phase profile of a warp model. This gives the axis ratio and position angle as a function of radius.""" ax.plot( model.q_R.prof.detach().cpu().numpy(), model.q_R.npvalue, diff --git a/astrophot/utils/conversions/__init__.py b/astrophot/utils/conversions/__init__.py index e69de29b..9c679bf9 100644 --- a/astrophot/utils/conversions/__init__.py +++ b/astrophot/utils/conversions/__init__.py @@ -0,0 +1,49 @@ +from .functions import ( + sersic_n_to_b, + sersic_I0_to_flux_np, + sersic_flux_to_I0_np, + sersic_Ie_to_flux_np, + sersic_flux_to_Ie_np, + sersic_I0_to_flux_torch, + sersic_flux_to_I0_torch, + sersic_Ie_to_flux_torch, + sersic_flux_to_Ie_torch, + sersic_inv_np, + sersic_inv_torch, + moffat_I0_to_flux, +) +from .units import ( + deg_to_arcsec, + arcsec_to_deg, + flux_to_sb, + flux_to_mag, + sb_to_flux, + mag_to_flux, + magperarcsec2_to_mag, + mag_to_magperarcsec2, + PA_shift_convention, +) + +__all__ = ( + "sersic_n_to_b", + "sersic_I0_to_flux_np", + "sersic_flux_to_I0_np", + "sersic_Ie_to_flux_np", + "sersic_flux_to_Ie_np", + "sersic_I0_to_flux_torch", + "sersic_flux_to_I0_torch", + "sersic_Ie_to_flux_torch", + "sersic_flux_to_Ie_torch", + "sersic_inv_np", + "sersic_inv_torch", + "moffat_I0_to_flux", + "deg_to_arcsec", + "arcsec_to_deg", + "flux_to_sb", + "flux_to_mag", + "sb_to_flux", + "mag_to_flux", + "magperarcsec2_to_mag", + "mag_to_magperarcsec2", + "PA_shift_convention", +) diff --git a/astrophot/utils/conversions/functions.py b/astrophot/utils/conversions/functions.py index 98540df4..68e9303c 100644 --- a/astrophot/utils/conversions/functions.py +++ b/astrophot/utils/conversions/functions.py @@ -3,6 +3,21 @@ from scipy.special import gamma from torch.special import gammaln +__all__ = ( + "sersic_n_to_b", + "sersic_I0_to_flux_np", + "sersic_flux_to_I0_np", + "sersic_Ie_to_flux_np", + "sersic_flux_to_Ie_np", + "sersic_I0_to_flux_torch", + "sersic_flux_to_I0_torch", + "sersic_Ie_to_flux_torch", + "sersic_flux_to_Ie_torch", + "sersic_inv_np", + "sersic_inv_torch", + "moffat_I0_to_flux", +) + def sersic_n_to_b(n): """Compute the `b(n)` for a sersic model. This factor ensures that diff --git a/astrophot/utils/conversions/units.py b/astrophot/utils/conversions/units.py index e8ff6436..d32d4f83 100644 --- a/astrophot/utils/conversions/units.py +++ b/astrophot/utils/conversions/units.py @@ -1,5 +1,17 @@ import numpy as np +__all__ = ( + "deg_to_arcsec", + "arcsec_to_deg", + "flux_to_sb", + "flux_to_mag", + "sb_to_flux", + "mag_to_flux", + "magperarcsec2_to_mag", + "mag_to_magperarcsec2", + "PA_shift_convention", +) + deg_to_arcsec = 3600.0 arcsec_to_deg = 1.0 / deg_to_arcsec diff --git a/astrophot/utils/decorators.py b/astrophot/utils/decorators.py index a82a1fb6..428f634a 100644 --- a/astrophot/utils/decorators.py +++ b/astrophot/utils/decorators.py @@ -3,6 +3,8 @@ import numpy as np +__all__ = ("classproperty", "ignore_numpy_warnings", "combine_docstrings") + class classproperty: def __init__(self, fget): diff --git a/astrophot/utils/initialize/__init__.py b/astrophot/utils/initialize/__init__.py index 592e63e9..9708041a 100644 --- a/astrophot/utils/initialize/__init__.py +++ b/astrophot/utils/initialize/__init__.py @@ -1,4 +1,12 @@ -from .segmentation_map import * +from .segmentation_map import ( + centroids_from_segmentation_map, + PA_from_segmentation_map, + q_from_segmentation_map, + windows_from_segmentation_map, + scale_windows, + filter_windows, + transfer_windows, +) from .center import center_of_mass, recursive_center_of_mass from .construct_psf import gaussian_psf, moffat_psf from .variance import auto_variance diff --git a/astrophot/utils/integration.py b/astrophot/utils/integration.py index 99517f7d..c72dc3da 100644 --- a/astrophot/utils/integration.py +++ b/astrophot/utils/integration.py @@ -3,6 +3,8 @@ from scipy.special import roots_legendre import torch +__all__ = ("quad_table",) + @lru_cache(maxsize=32) def quad_table(order, dtype, device): diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index 11ff687d..97375b2e 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -1,6 +1,8 @@ import torch import numpy as np +__all__ = ("default_prof", "interp2d") + def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): prof = [0, min_pixels * pixelscale] diff --git a/astrophot/utils/parametric_profiles.py b/astrophot/utils/parametric_profiles.py index 433fb68c..7d4cbf16 100644 --- a/astrophot/utils/parametric_profiles.py +++ b/astrophot/utils/parametric_profiles.py @@ -1,6 +1,16 @@ import numpy as np from .conversions.functions import sersic_n_to_b +__all__ = ( + "sersic_np", + "gaussian_np", + "exponential_np", + "moffat_np", + "nuker_np", + "ferrer_np", + "king_np", +) + def sersic_np(R, n, Re, Ie): """Sersic 1d profile function, works more generally with numpy From e11aab7d1b2ebf9b3427b5980110d8bf1b91c8dd Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 30 Jul 2025 19:35:25 -0400 Subject: [PATCH 106/191] improving docstrings --- astrophot/__init__.py | 3 +- astrophot/fit/__init__.py | 3 +- astrophot/fit/gradient.py | 53 +++++----- astrophot/fit/lm.py | 78 +++++++-------- astrophot/image/__init__.py | 3 +- astrophot/image/image_object.py | 2 +- astrophot/models/__init__.py | 2 + astrophot/models/airy.py | 33 ++++--- astrophot/models/basis.py | 20 +++- astrophot/models/bilinear_sky.py | 19 ++-- astrophot/models/edgeon.py | 19 +++- astrophot/models/flatsky.py | 10 +- astrophot/models/galaxy_model_object.py | 2 + astrophot/models/gaussian_ellipsoid.py | 26 ++++- astrophot/models/group_model_object.py | 10 +- astrophot/models/mixins/brightness.py | 43 +++++---- astrophot/models/mixins/exponential.py | 25 +++-- astrophot/models/mixins/ferrer.py | 29 +++--- astrophot/models/mixins/gaussian.py | 25 +++-- astrophot/models/mixins/king.py | 27 +++--- astrophot/models/mixins/moffat.py | 30 +++--- astrophot/models/mixins/nuker.py | 33 ++++--- astrophot/models/mixins/sample.py | 52 +++++----- astrophot/models/mixins/sersic.py | 25 ++--- astrophot/models/mixins/spline.py | 13 +-- astrophot/models/mixins/transform.py | 96 +++++++++++-------- astrophot/models/model_object.py | 42 ++++---- astrophot/models/multi_gaussian_expansion.py | 15 +-- astrophot/models/pixelated_psf.py | 7 +- astrophot/models/planesky.py | 15 +-- astrophot/models/point_source.py | 6 +- astrophot/models/sky_model_object.py | 2 + astrophot/plots/profile.py | 3 +- astrophot/utils/decorators.py | 8 +- astrophot/utils/interpolate.py | 4 +- docs/source/tutorials/PoissonLikelihood.ipynb | 2 +- 36 files changed, 442 insertions(+), 343 deletions(-) diff --git a/astrophot/__init__.py b/astrophot/__init__.py index 70369ab8..439fd063 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -1,7 +1,7 @@ import argparse import requests import torch -from . import config, models, plots, utils, fit +from . import config, models, plots, utils, fit, image from .param import forward, Param, Module from .image import ( @@ -143,6 +143,7 @@ def run_from_terminal() -> None: __all__ = ( "models", + "image", "Model", "Image", "ImageList", diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 852e6581..70998cfd 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -5,5 +5,6 @@ from .minifit import MiniFit from .hmc import HMC from .mhmcmc import MHMCMC +from . import func -__all__ = ["LM", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC", "Slalom"] +__all__ = ["LM", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC", "Slalom", "func"] diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 1e2a7788..0522b185 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -22,25 +22,12 @@ class Grad(BaseOptimizer): The optimizer is instantiated with a set of initial parameters and optimization options provided by the user. The `fit` method performs the optimization, taking a series of gradient steps until a stopping criteria is met. - Parameters: - model (AstroPhot_Model): an AstroPhot_Model object with which to perform optimization. - initial_state (torch.Tensor, optional): an optional initial state for optimization. - method (str, optional): the optimization method to use for the update step. Defaults to "NAdam". - patience (int or None, optional): the number of iterations without improvement before the optimizer will exit early. Defaults to None. - optim_kwargs (dict, optional): a dictionary of keyword arguments to pass to the pytorch optimizer. - - Attributes: - model (AstroPhot_Model): the AstroPhot_Model object to optimize. - current_state (torch.Tensor): the current state of the parameters being optimized. - iteration (int): the number of iterations performed during the optimization. - loss_history (list): the history of loss values at each iteration of the optimization. - lambda_history (list): the history of parameter values at each iteration of the optimization. - optimizer (torch.optimizer): the PyTorch optimizer object being used. - patience (int or None): the number of iterations without improvement before the optimizer will exit early. - method (str): the optimization method being used. - optim_kwargs (dict): the dictionary of keyword arguments passed to the PyTorch optimizer. - - + **Args:** + - `model` (AstroPhot_Model): an AstroPhot_Model object with which to perform optimization. + - `initial_state` (torch.Tensor, optional): an optional initial state for optimization. + - `method` (str, optional): the optimization method to use for the update step. Defaults to "NAdam". + - `patience` (int or None, optional): the number of iterations without improvement before the optimizer will exit early. Defaults to None. + - `optim_kwargs` (dict, optional): a dictionary of keyword arguments to pass to the pytorch optimizer. """ def __init__( @@ -54,15 +41,6 @@ def __init__( report_freq=10, **kwargs, ) -> None: - """Initialize the gradient descent optimizer. - - Args: - - model: instance of the model to be optimized. - - initial_state: Initial state of the model. - - patience: (optional) If a positive integer, then stop the optimization if there has been no improvement in the loss for this number of iterations. - - method: (optional) The name of the optimization method to use. Default is NAdam. - - optim_kwargs: (optional) Keyword arguments to be passed to the optimizer. - """ super().__init__(model, initial_state, **kwargs) @@ -164,6 +142,25 @@ def fit(self) -> BaseOptimizer: class Slalom(BaseOptimizer): + """Slalom optimizer for AstroPhot_Model objects. + + Slalom is a gradient descent optimization algorithm that uses a few + evaluations along the direction of the gradient to find the optimal step + size. This is done by assuming that the posterior density is a parabola and + then finding the minimum. + + The optimizer quickly finds the minimum of the posterior density along the + gradient direction, then updates the gradient at the new position and + repeats. This continues until it reaches a set of 5 steps which collectively + improve the posterior density by an amount smaller than the + `relative_tolerance` threshold, indicating that convergence has been + achieved. Note that this convergence criteria is not a guarantee, simply a + heuristic. The default tolerance was such that the optimizer will + substantially improve from the starting point, and do so quickly, but may + not reach all the way to the minimum of the posterior density. Like other + gradient descent algorithms, Slalom slows down considerably when trying to + achieve very high precision. + """ def __init__( self, diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 6c2a4a72..7df195cb 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -29,65 +29,67 @@ class LM(BaseOptimizer): The cost function that the LM algorithm tries to minimize is of the form: - .. math:: - f(\\boldsymbol{\\beta}) = \\frac{1}{2}\\sum_{i=1}^{N} r_i(\\boldsymbol{\\beta})^2 + $$f(\\boldsymbol{\\beta}) = \\frac{1}{2}\\sum_{i=1}^{N} r_i(\\boldsymbol{\\beta})^2$$ - where :math:`\\boldsymbol{\\beta}` is the vector of parameters, - :math:`r_i` are the residuals, and :math:`N` is the number of + where $\\boldsymbol{\\beta}$ is the vector of parameters, + $r_i$ are the residuals, and $N$ is the number of observations. The LM algorithm iteratively performs the following update to the parameters: - .. math:: - \\boldsymbol{\\beta}_{n+1} = \\boldsymbol{\\beta}_{n} - (J^T J + \\lambda diag(J^T J))^{-1} J^T \\boldsymbol{r} + $$\\boldsymbol{\\beta}_{n+1} = \\boldsymbol{\\beta}_{n} - (J^T J + \\lambda diag(J^T J))^{-1} J^T \\boldsymbol{r}$$ where: - - :math:`J` is the Jacobian matrix whose elements are :math:`J_{ij} = \\frac{\\partial r_i}{\\partial \\beta_j}`, - - :math:`\\boldsymbol{r}` is the vector of residuals :math:`r_i(\\boldsymbol{\\beta})`, - - :math:`\\lambda` is a damping factor which is adjusted at each iteration. - - When :math:`\\lambda = 0` this can be seen as the Gauss-Newton - method. In the limit that :math:`\\lambda` is large, the - :math:`J^T J` matrix (an approximation of the Hessian) becomes - subdominant and the update essentially points along :math:`J^T - \\boldsymbol{r}` which is the gradient. In this scenario the - gradient descent direction is also modified by the :math:`\\lambda - diag(J^T J)` scaling which in some sense makes each gradient + - $J$ is the Jacobian matrix whose elements are $J_{ij} = \\frac{\\partial r_i}{\\partial \\beta_j}$, + - $\\boldsymbol{r}$ is the vector of residuals $r_i(\\boldsymbol{\\beta})$, + - $\\lambda$ is a damping factor which is adjusted at each iteration. + + When $\\lambda = 0$ this can be seen as the Gauss-Newton + method. In the limit that $\\lambda$ is large, the + $J^T J$ matrix (an approximation of the Hessian) becomes + subdominant and the update essentially points along $J^T + \\boldsymbol{r}$ which is the gradient. In this scenario the + gradient descent direction is also modified by the $\\lambda + diag(J^T J)$ scaling which in some sense makes each gradient unitless and further improves the step. Note as well that as - :math:`\\lambda` gets larger the step taken will be smaller, which + $\\lambda$ gets larger the step taken will be smaller, which helps to ensure convergence when the initial guess of the parameters are far from the optimal solution. - Note that the residuals :math:`r_i` are typically also scaled by + Note that the residuals $r_i$ are typically also scaled by the variance of the pixels, but this does not change the equations above. For a detailed explanation of the LM method see the article by Henri Gavin on which much of the AstroPhot LM implementation is based:: - @article{Gavin2019, - title={The Levenberg-Marquardt algorithm for nonlinear least squares curve-fitting problems}, - author={Gavin, Henri P}, - journal={Department of Civil and Environmental Engineering, Duke University}, - volume={19}, - year={2019} - } + ```{latex} + @article{Gavin2019, + title={The Levenberg-Marquardt algorithm for nonlinear least squares curve-fitting problems}, + author={Gavin, Henri P}, + journal={Department of Civil and Environmental Engineering, Duke University}, + volume={19}, + year={2019} + } + ``` as well as the paper on LM geodesic acceleration by Mark Transtrum:: - @article{Tanstrum2012, - author = {{Transtrum}, Mark K. and {Sethna}, James P.}, - title = "{Improvements to the Levenberg-Marquardt algorithm for nonlinear least-squares minimization}", - year = 2012, - doi = {10.48550/arXiv.1201.5885}, - adsurl = {https://ui.adsabs.harvard.edu/abs/2012arXiv1201.5885T}, - } - - The damping factor :math:`\\lambda` is adjusted at each iteration: + ```{latex} + @article{Tanstrum2012, + author = {{Transtrum}, Mark K. and {Sethna}, James P.}, + title = "{Improvements to the Levenberg-Marquardt algorithm for nonlinear least-squares minimization}", + year = 2012, + doi = {10.48550/arXiv.1201.5885}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2012arXiv1201.5885T}, + } + ``` + + The damping factor $\\lambda$ is adjusted at each iteration: it is effectively increased when we are far from the solution, and decreased when we are close to it. In practice, the algorithm - attempts to pick the smallest :math:`\\lambda` that is can while - making sure that the :math:`\\chi^2` decreases at each step. + attempts to pick the smallest $\\lambda$ that is can while + making sure that the $\\chi^2$ decreases at each step. The main advantage of the LM algorithm is its adaptability. When the current estimate is far from the optimum, the algorithm @@ -99,7 +101,7 @@ class LM(BaseOptimizer): enhancements to improve its performance. For example, the Jacobian may be approximated with finite differences, geodesic acceleration can be used to speed up convergence, and more sophisticated - strategies can be used to adjust the damping factor :math:`\\lambda`. + strategies can be used to adjust the damping factor $\\lambda$. The exact performance of the LM algorithm will depend on the nature of the problem, including the complexity of the function diff --git a/astrophot/image/__init__.py b/astrophot/image/__init__.py index 2867c482..cc3615f8 100644 --- a/astrophot/image/__init__.py +++ b/astrophot/image/__init__.py @@ -6,7 +6,7 @@ from .psf_image import PSFImage from .model_image import ModelImage, ModelImageList from .window import Window, WindowList - +from . import func __all__ = ( "Image", @@ -24,4 +24,5 @@ "ModelImageList", "Window", "WindowList", + "func", ) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index b70fd79e..7946cee0 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -246,7 +246,7 @@ def pixel_quad_meshgrid(self, order=3): return func.pixel_quad_meshgrid(self.shape, config.DTYPE, config.DEVICE, order=order) @forward - def coordinate_center_meshgrid(self): + def coordinate_center_meshgrid(self) -> torch.Tensor: """Get a meshgrid of coordinate locations in the image, centered on the pixel grid.""" i, j = self.pixel_center_meshgrid() return self.pixel_to_plane(i, j) diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index c56408f5..6858ddca 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -129,6 +129,7 @@ WarpMixin, TruncationMixin, ) +from . import func __all__ = ( @@ -233,4 +234,5 @@ "FourierEllipseMixin", "WarpMixin", "TruncationMixin", + "func", ) diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index 403de922..7fa3a38b 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -1,6 +1,7 @@ import torch +from torch import Tensor -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from .psf_model_object import PSFModel from .mixins import RadialMixin from ..param import forward @@ -8,6 +9,7 @@ __all__ = ("AiryPSF",) +@combine_docstrings class AiryPSF(RadialMixin, PSFModel): """The Airy disk is an analytic description of the diffraction pattern for a circular aperture. @@ -16,25 +18,28 @@ class AiryPSF(RadialMixin, PSFModel): of the lens system under the assumption that all elements are perfect. This expression goes as: - .. math:: + $$I(\\theta) = I_0\\left[\\frac{2J_1(x)}{x}\\right]^2$$ + $$x = ka\\sin(\\theta) = \\frac{2\\pi a r}{\\lambda R}$$ - I(\\theta) = I_0\\left[\\frac{2J_1(x)}{x}\\right]^2 - - x = ka\\sin(\\theta) = \\frac{2\\pi a r}{\\lambda R} - - where :math:`I(\\theta)` is the intensity as a function of the + where $I(\\theta)$ is the intensity as a function of the angular position within the diffraction system along its main - axis, :math:`I_0` is the central intensity of the airy disk, - :math:`J_1` is the Bessel function of the first kind of order one, - :math:`k = \\frac{2\\pi}{\\lambda}` is the wavenumber of the - light, :math:`a` is the aperture radius, :math:`r` is the radial - position from the center of the pattern, :math:`R` is the distance + axis, $I_0$ is the central intensity of the airy disk, + $J_1$ is the Bessel function of the first kind of order one, + $k = \\frac{2\\pi}{\\lambda}$ is the wavenumber of the + light, $a$ is the aperture radius, $r$ is the radial + position from the center of the pattern, $R$ is the distance from the circular aperture to the observation plane. In the `Airy_PSF` class we combine the parameters - :math:`a,R,\\lambda` into a single ratio to be optimized (or fixed + $a,R,\\lambda$ into a single ratio to be optimized (or fixed by the optical configuration). + **Parameters:** + - `I0`: The central intensity of the airy disk in flux/arcsec^2. + - `aRL`: The ratio of the aperture radius to the + product of the wavelength and the distance from the aperture to the + observation plane, $\\frac{a}{R \\lambda}$. + """ _model_type = "airy" @@ -63,6 +68,6 @@ def initialize(self): self.aRL.dynamic_value = (5.0 / 8.0) * 2 * self.target.pixelscale @forward - def radial_model(self, R, I0, aRL): + def radial_model(self, R: Tensor, I0: Tensor, aRL: Tensor) -> Tensor: x = 2 * torch.pi * aRL * R return I0 * (2 * torch.special.bessel_j1(x) / x) ** 2 diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index 1a23ba5c..aa262662 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -1,8 +1,10 @@ +from typing import Union, Tuple import torch +from torch import Tensor import numpy as np from .psf_model_object import PSFModel -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from .. import config from ..errors import SpecificationConflict @@ -13,6 +15,7 @@ __all__ = ["BasisPSF"] +@combine_docstrings class PixelBasisPSF(PSFModel): """point source model which uses multiple images as a basis for the PSF as its representation for point sources. Using bilinear interpolation it @@ -21,6 +24,11 @@ class PixelBasisPSF(PSFModel): as any image can be supplied. Bilinear interpolation is very fast and accurate for smooth models, so it is possible to do the expensive interpolation before optimization and save time. + + **Parameters:** + - `weights`: The weights of the basis set of images in units of flux. + - `PA`: The position angle of the PSF in radians. + - `scale`: The scale of the PSF in arcseconds per grid unit. """ _model_type = "basis" @@ -31,7 +39,7 @@ class PixelBasisPSF(PSFModel): } usable = True - def __init__(self, *args, basis="zernike:3", **kwargs): + def __init__(self, *args, basis: Union[str, Tensor] = "zernike:3", **kwargs): """Initialize the PixelBasisPSF model with a basis set of images.""" super().__init__(*args, **kwargs) self.basis = basis @@ -42,7 +50,7 @@ def basis(self): return self._basis @basis.setter - def basis(self, value): + def basis(self, value: Union[str, Tensor]): """Set the basis set of images. If value is None, the basis is initialized to an empty tensor.""" if value is None: raise SpecificationConflict( @@ -90,13 +98,15 @@ def initialize(self): self.weights.dynamic_value = w @forward - def transform_coordinates(self, x, y, PA, scale): + def transform_coordinates( + self, x: Tensor, y: Tensor, PA: Tensor, scale: Tensor + ) -> Tuple[Tensor, Tensor]: x, y = super().transform_coordinates(x, y) i, j = func.rotate(-PA, x, y) pixel_center = (self.basis.shape[1] - 1) / 2, (self.basis.shape[2] - 1) / 2 return i / scale + pixel_center[0], j / scale + pixel_center[1] @forward - def brightness(self, x, y, weights): + def brightness(self, x: Tensor, y: Tensor, weights: Tensor) -> Tensor: x, y = self.transform_coordinates(x, y) return torch.sum(torch.vmap(lambda w, b: w * interp2d(b, x, y))(weights, self.basis), dim=0) diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py index 87b4d5aa..09bf1ce0 100644 --- a/astrophot/models/bilinear_sky.py +++ b/astrophot/models/bilinear_sky.py @@ -1,8 +1,10 @@ +from typing import Tuple import numpy as np import torch +from torch import Tensor from .sky_model_object import SkyModel -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from ..param import forward from . import func @@ -11,11 +13,14 @@ __all__ = ["BilinearSky"] +@combine_docstrings class BilinearSky(SkyModel): """Sky background model using a coarse bilinear grid for the sky flux. - Parameters: - I: sky brightness grid + **Parameters:** + - `I`: sky brightness grid + - `PA`: position angle of the sky grid in radians. + - `scale`: scale of the sky grid in arcseconds per grid unit. """ @@ -28,7 +33,7 @@ class BilinearSky(SkyModel): sampling_mode = "midpoint" usable = True - def __init__(self, *args, nodes=(3, 3), **kwargs): + def __init__(self, *args, nodes: Tuple[int, int] = (3, 3), **kwargs): """Initialize the BilinearSky model with a grid of nodes.""" super().__init__(*args, **kwargs) self.nodes = nodes @@ -71,13 +76,15 @@ def initialize(self): ) @forward - def transform_coordinates(self, x, y, I, PA, scale): + def transform_coordinates( + self, x: Tensor, y: Tensor, I: Tensor, PA: Tensor, scale: Tensor + ) -> Tuple[Tensor, Tensor]: x, y = super().transform_coordinates(x, y) i, j = func.rotate(-PA, x, y) pixel_center = (I.shape[0] - 1) / 2, (I.shape[1] - 1) / 2 return i / scale + pixel_center[0], j / scale + pixel_center[1] @forward - def brightness(self, x, y, I): + def brightness(self, x: Tensor, y: Tensor, I: Tensor) -> Tensor: x, y = self.transform_coordinates(x, y) return interp2d(I, x, y) diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index 471a7d2f..1e627e2f 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -1,8 +1,10 @@ +from typing import Tuple import torch import numpy as np +from torch import Tensor from .model_object import ComponentModel -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from . import func from ..param import forward @@ -15,6 +17,9 @@ class EdgeonModel(ComponentModel): the galaxy on the sky. Defines an edgeon galaxy as an object with a position angle, no inclination information is included. + **Parameters:** + - `PA`: Position angle of the edgeon disk in radians. + """ _model_type = "edgeon" @@ -48,7 +53,7 @@ def initialize(self): self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi @forward - def transform_coordinates(self, x, y, PA): + def transform_coordinates(self, x: Tensor, y: Tensor, PA: Tensor) -> Tuple[Tensor, Tensor]: x, y = super().transform_coordinates(x, y) return func.rotate(-(PA + np.pi / 2), x, y) @@ -57,6 +62,9 @@ class EdgeonSech(EdgeonModel): """An edgeon profile where the vertical distribution is a sech^2 profile, subclasses define the radial profile. + **Parameters:** + - `I0`: The central intensity of the sech^2 profile in flux/arcsec^2. + - `hs`: The scale height of the sech^2 profile in arcseconds. """ _model_type = "sech2" @@ -85,15 +93,18 @@ def initialize(self): self.hs.value = max(self.window.shape) * target_area.pixelscale * 0.1 @forward - def brightness(self, x, y, I0, hs): + def brightness(self, x: Tensor, y: Tensor, I0: Tensor, hs: Tensor) -> Tensor: x, y = self.transform_coordinates(x, y) return I0 * self.radial_model(x) / (torch.cosh((y + self.softening) / hs) ** 2) +@combine_docstrings class EdgeonIsothermal(EdgeonSech): """A self-gravitating locally-isothermal edgeon disk. This comes from van der Kruit & Searle 1981. + **Parameters:** + - `rs`: Scale radius of the isothermal disk in arcseconds. """ _model_type = "isothermal" @@ -109,7 +120,7 @@ def initialize(self): self.rs.value = max(self.window.shape) * self.target.pixelscale * 0.4 @forward - def radial_model(self, R, rs): + def radial_model(self, R: Tensor, rs: Tensor) -> Tensor: Rscaled = torch.abs(R / rs) return ( Rscaled diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 59db6c3c..2d215e21 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -1,19 +1,21 @@ import numpy as np import torch +from torch import Tensor -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from .sky_model_object import SkyModel from ..param import forward __all__ = ["FlatSky"] +@combine_docstrings class FlatSky(SkyModel): """Model for the sky background in which all values across the image are the same. - Parameters: - I: brightness for the sky, represented as the log of the brightness over pixel scale squared, this is proportional to a surface brightness + **Parameters:** + - `I`: brightness for the sky, represented as the log of the brightness over pixel scale squared, this is proportional to a surface brightness """ @@ -35,5 +37,5 @@ def initialize(self): self.I.dynamic_value = np.median(dat) / self.target.pixel_area.item() @forward - def brightness(self, x, y, I): + def brightness(self, x: Tensor, y: Tensor, I: Tensor) -> Tensor: return torch.ones_like(x) * I diff --git a/astrophot/models/galaxy_model_object.py b/astrophot/models/galaxy_model_object.py index 6b708963..53beb529 100644 --- a/astrophot/models/galaxy_model_object.py +++ b/astrophot/models/galaxy_model_object.py @@ -1,10 +1,12 @@ from .model_object import ComponentModel from .mixins import InclinedMixin +from ..utils.decorators import combine_docstrings __all__ = ["GalaxyModel"] +@combine_docstrings class GalaxyModel(InclinedMixin, ComponentModel): """Intended to represent a galaxy or extended component in an image.""" diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index 99e7d43d..8366044c 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -1,14 +1,16 @@ import torch import numpy as np +from torch import Tensor from .model_object import ComponentModel -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from . import func from ..param import forward __all__ = ["GaussianEllipsoid"] +@combine_docstrings class GaussianEllipsoid(ComponentModel): """Model that represents a galaxy as a 3D Gaussian ellipsoid. @@ -36,6 +38,15 @@ class GaussianEllipsoid(ComponentModel): initialization for this model assumes exactly this interpretation with a disk thickness of sigma_c = 0.2 *sigma_a. + **Parameters:** + - `sigma_a`: Standard deviation of the Gaussian along the alpha axis in arcseconds. + - `sigma_b`: Standard deviation of the Gaussian along the beta axis in arcseconds. + - `sigma_c`: Standard deviation of the Gaussian along the gamma axis in arcseconds. + - `alpha`: Euler angle representing the rotation around the alpha axis in radians. + - `beta`: Euler angle representing the rotation around the beta axis in radians. + - `gamma`: Euler angle representing the rotation around the gamma axis in radians. + - `flux`: Total flux of the galaxy in arbitrary units. + """ _model_type = "gaussianellipsoid" @@ -97,7 +108,18 @@ def initialize(self): self.flux.dynamic_value = np.sum(dat) @forward - def brightness(self, x, y, sigma_a, sigma_b, sigma_c, alpha, beta, gamma, flux): + def brightness( + self, + x: Tensor, + y: Tensor, + sigma_a: Tensor, + sigma_b: Tensor, + sigma_c: Tensor, + alpha: Tensor, + beta: Tensor, + gamma: Tensor, + flux: Tensor, + ) -> Tensor: """Brightness of the Gaussian ellipsoid.""" D = torch.diag(torch.stack((sigma_a, sigma_b, sigma_c)) ** 2) R = func.euler_rotation_matrix(alpha, beta, gamma) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index cf8b1c68..fbff464e 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -54,9 +54,9 @@ def __init__( if not isinstance(model, Model): raise TypeError(f"Expected a Model instance in 'models', got {type(model)}") self.models = models - self.update_window() + self._update_window() - def update_window(self): + def _update_window(self): """Makes a new window object which encloses all the windows of the sub models in this group model object. @@ -146,7 +146,7 @@ def fit_mask(self) -> torch.Tensor: mask[group_indices] &= model.fit_mask()[model_indices] return mask - def match_window(self, image, window, model): + def match_window(self, image: Union[Image, ImageList], window: Window, model: Model) -> Window: if isinstance(image, ImageList) and isinstance(model.target, ImageList): indices = image.match_indices(model.target) if len(indices) == 0: @@ -174,7 +174,9 @@ def match_window(self, image, window, model): ) return use_window - def _ensure_vmap_compatible(self, image, other): + def _ensure_vmap_compatible( + self, image: Union[Image, ImageList], other: Union[Image, ImageList] + ): if isinstance(image, ImageList): for img in image.images: self._ensure_vmap_compatible(img, other) diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index 154493c5..b3767bea 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor import numpy as np from ...param import forward @@ -22,7 +23,7 @@ class RadialMixin: """ @forward - def brightness(self, x, y): + def brightness(self, x: Tensor, y: Tensor) -> Tensor: """ Calculate the brightness at a given point (x, y) based on radial distance from the center. """ @@ -36,23 +37,23 @@ class WedgeMixin: model which defines multiple radial models separately along some number of wedges projected from the center. These wedges have sharp transitions along boundary angles theta. - Options: - symmetric: If True, the model will have symmetry for rotations of pi radians - and each ray will appear twice on the sky on opposite sides of the model. - If False, each ray is independent. - segments: The number of segments to divide the model into. This controls - how many rays are used in the model. The default is 2 + **Options:** + - `symmetric`: If True, the model will have symmetry for rotations of pi radians + and each ray will appear twice on the sky on opposite sides of the model. + If False, each ray is independent. + - `segments`: The number of segments to divide the model into. This controls + how many rays are used in the model. The default is 2 """ _model_type = "wedge" _options = ("segments", "symmetric") - def __init__(self, *args, symmetric=True, segments=2, **kwargs): + def __init__(self, *args, symmetric: bool = True, segments: int = 2, **kwargs): super().__init__(*args, **kwargs) self.symmetric = symmetric self.segments = segments - def polar_model(self, R, T): + def polar_model(self, R: Tensor, T: Tensor) -> Tensor: model = torch.zeros_like(R) cycle = np.pi if self.symmetric else 2 * np.pi w = cycle / self.segments @@ -63,7 +64,7 @@ def polar_model(self, R, T): model[indices] += self.iradial_model(s, R[indices]) return model - def brightness(self, x, y): + def brightness(self, x: Tensor, y: Tensor) -> Tensor: x, y = self.transform_coordinates(x, y) return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) @@ -77,28 +78,28 @@ class RayMixin: function which depends on the number of rays, for example with two rays the brightness would be: - $$I(R,theta) = I_1(R)*\\cos(\\theta \\% \\pi) + I_2(R)*\\cos((theta + \\pi/2) \\% \\pi)$$ + $$I(R,\\theta) = I_1(R)*\\cos(\\theta \\% \\pi) + I_2(R)*\\cos((\\theta + \\pi/2) \\% \\pi)$$ - For `theta = 0` the brightness comes entirely from `I_1` while for `theta = pi/2` + For $\\theta = 0$ the brightness comes entirely from `I_1` while for $\\theta = \\pi/2$ the brightness comes entirely from `I_2`. - Options: - symmetric: If True, the model will have symmetry for rotations of pi radians - and each ray will appear twice on the sky on opposite sides of the model. - If False, each ray is independent. - segments: The number of segments to divide the model into. This controls - how many rays are used in the model. The default is 2 + **Options:** + - `symmetric`: If True, the model will have symmetry for rotations of pi radians + and each ray will appear twice on the sky on opposite sides of the model. + If False, each ray is independent. + - `segments`: The number of segments to divide the model into. This controls + how many rays are used in the model. The default is 2 """ _model_type = "ray" _options = ("symmetric", "segments") - def __init__(self, *args, symmetric=True, segments=2, **kwargs): + def __init__(self, *args, symmetric: bool = True, segments: int = 2, **kwargs): super().__init__(*args, **kwargs) self.symmetric = symmetric self.segments = segments - def polar_model(self, R, T): + def polar_model(self, R: Tensor, T: Tensor) -> Tensor: model = torch.zeros_like(R) weight = torch.zeros_like(R) cycle = np.pi if self.symmetric else 2 * np.pi @@ -112,6 +113,6 @@ def polar_model(self, R, T): weight[indices] += weights return model / weight - def brightness(self, x, y): + def brightness(self, x: Tensor, y: Tensor) -> Tensor: x, y = self.transform_coordinates(x, y) return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index f3d4147a..25dcfd81 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor from ...param import forward from ...utils.decorators import ignore_numpy_warnings @@ -17,16 +18,14 @@ class ExponentialMixin: An exponential is a classical radial model used in many contexts. The functional form of the exponential profile is defined as: - $$ - I(R) = I_e * \exp(- b_1(\frac{R}{R_e} - 1)) - $$ + $$I(R) = I_e * \\exp\\left(- b_1\\left(\\frac{R}{R_e} - 1\\right)\\right)$$ Ie is the brightness at the effective radius, and Re is the effective radius. $b_1$ is a constant that ensures $I_e$ is the brightness at $R_e$. - Parameters: - Re: effective radius in arcseconds - Ie: effective surface density in flux/arcsec^2 + **Parameters:** + - `Re`: effective radius in arcseconds + - `Ie`: effective surface density in flux/arcsec^2 """ _model_type = "exponential" @@ -49,7 +48,7 @@ def initialize(self): ) @forward - def radial_model(self, R, Re, Ie): + def radial_model(self, R: Tensor, Re: Tensor, Ie: Tensor) -> Tensor: return func.exponential(R, Re, Ie) @@ -59,9 +58,7 @@ class iExponentialMixin: An exponential is a classical radial model used in many contexts. The functional form of the exponential profile is defined as: - $$ - I(R) = I_e * \exp(- b_1(\frac{R}{R_e} - 1)) - $$ + $$I(R) = I_e * \\exp\\left(- b_1\\left(\\frac{R}{R_e} - 1\\right)\\right)$$ $I_e$ is the brightness at the effective radius, and $R_e$ is the effective radius. $b_1$ is a constant that ensures $I_e$ is the brightness at $R_e$. @@ -69,9 +66,9 @@ class iExponentialMixin: `Re` and `Ie` are batched by their first dimension, allowing for multiple exponential profiles to be defined at once. - Parameters: - Re: effective radius in arcseconds - Ie: effective surface density in flux/arcsec^2 + **Parameters:** + - `Re`: effective radius in arcseconds + - `Ie`: effective surface density in flux/arcsec^2 """ _model_type = "exponential" @@ -95,5 +92,5 @@ def initialize(self): ) @forward - def iradial_model(self, i, R, Re, Ie): + def iradial_model(self, i: int, R: Tensor, Re: Tensor, Ie: Tensor) -> Tensor: return func.exponential(R, Re[i], Ie[i]) diff --git a/astrophot/models/mixins/ferrer.py b/astrophot/models/mixins/ferrer.py index c8491d7f..e3632f49 100644 --- a/astrophot/models/mixins/ferrer.py +++ b/astrophot/models/mixins/ferrer.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor from ...param import forward from ...utils.decorators import ignore_numpy_warnings @@ -24,11 +25,11 @@ class FerrerMixin: of the truncation, `beta` controls the shape, and `I0` is the intensity at the center of the profile. - Parameters: - rout: Outer truncation radius in arcseconds. - alpha: Inner slope parameter. - beta: Outer slope parameter. - I0: Intensity at the center of the profile in flux/arcsec^2 + **Parameters:** + - `rout`: Outer truncation radius in arcseconds. + - `alpha`: Inner slope parameter. + - `beta`: Outer slope parameter. + - `I0`: Intensity at the center of the profile in flux/arcsec^2 """ _model_type = "ferrer" @@ -53,7 +54,9 @@ def initialize(self): ) @forward - def radial_model(self, R, rout, alpha, beta, I0): + def radial_model( + self, R: Tensor, rout: Tensor, alpha: Tensor, beta: Tensor, I0: Tensor + ) -> Tensor: return func.ferrer(R, rout, alpha, beta, I0) @@ -73,11 +76,11 @@ class iFerrerMixin: `rout`, `alpha`, `beta`, and `I0` are batched by their first dimension, allowing for multiple Ferrer profiles to be defined at once. - Parameters: - rout: Outer truncation radius in arcseconds. - alpha: Inner slope parameter. - beta: Outer slope parameter. - I0: Intensity at the center of the profile in flux/arcsec^2 + **Parameters:** + - `rout`: Outer truncation radius in arcseconds. + - `alpha`: Inner slope parameter. + - `beta`: Outer slope parameter. + - `I0`: Intensity at the center of the profile in flux/arcsec^2 """ _model_type = "ferrer" @@ -103,5 +106,7 @@ def initialize(self): ) @forward - def iradial_model(self, i, R, rout, alpha, beta, I0): + def iradial_model( + self, i: int, R: Tensor, rout: Tensor, alpha: Tensor, beta: Tensor, I0: Tensor + ) -> Tensor: return func.ferrer(R, rout[i], alpha[i], beta[i], I0[i]) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index feb7ae09..014c13a7 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor from ...param import forward from ...utils.decorators import ignore_numpy_warnings @@ -17,16 +18,14 @@ class GaussianMixin: The Gaussian profile is a simple and widely used model for extended objects. The functional form of the Gaussian profile is defined as: - ```{math} - I(R) = \frac{{\rm flux}}{\sqrt{2\pi}\sigma} \exp(-R^2 / (2 \sigma^2)) - ``` + $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2))$$ where `I_0` is the intensity at the center of the profile and `sigma` is the standard deviation which controls the width of the profile. - Parameters: - sigma: Standard deviation of the Gaussian profile in arcseconds. - flux: Total flux of the Gaussian profile. + **Parameters:** + - `sigma`: Standard deviation of the Gaussian profile in arcseconds. + - `flux`: Total flux of the Gaussian profile. """ _model_type = "gaussian" @@ -49,7 +48,7 @@ def initialize(self): ) @forward - def radial_model(self, R, sigma, flux): + def radial_model(self, R: Tensor, sigma: Tensor, flux: Tensor) -> Tensor: return func.gaussian(R, sigma, flux) @@ -59,9 +58,7 @@ class iGaussianMixin: The Gaussian profile is a simple and widely used model for extended objects. The functional form of the Gaussian profile is defined as: - ```{math} - I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2)) - ``` + $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2))$$ where `sigma` is the standard deviation which controls the width of the profile and `flux` gives the total flux of the profile (assuming no @@ -70,9 +67,9 @@ class iGaussianMixin: `sigma` and `flux` are batched by their first dimension, allowing for multiple Gaussian profiles to be defined at once. - Parameters: - sigma: Standard deviation of the Gaussian profile in arcseconds. - flux: Total flux of the Gaussian profile. + **Parameters:** + - `sigma`: Standard deviation of the Gaussian profile in arcseconds. + - `flux`: Total flux of the Gaussian profile. """ _model_type = "gaussian" @@ -96,5 +93,5 @@ def initialize(self): ) @forward - def iradial_model(self, i, R, sigma, flux): + def iradial_model(self, i: int, R: Tensor, sigma: Tensor, flux: Tensor) -> Tensor: return func.gaussian(R, sigma[i], flux[i]) diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index 7bad3cbe..efbab564 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor import numpy as np from ...param import forward @@ -25,11 +26,11 @@ class KingMixin: the intensity at the center of the profile. `alpha` is the concentration index which controls the shape of the profile. - Parameters: - Rc: core radius - Rt: truncation radius - alpha: concentration index which controls the shape of the brightness profile - I0: intensity at the center of the profile + **Parameters:** + - `Rc`: core radius + - `Rt`: truncation radius + - `alpha`: concentration index which controls the shape of the brightness profile + - `I0`: intensity at the center of the profile """ _model_type = "king" @@ -57,7 +58,7 @@ def initialize(self): ) @forward - def radial_model(self, R, Rc, Rt, alpha, I0): + def radial_model(self, R: Tensor, Rc: Tensor, Rt: Tensor, alpha: Tensor, I0: Tensor) -> Tensor: return func.king(R, Rc, Rt, alpha, I0) @@ -77,11 +78,11 @@ class iKingMixin: `Rc`, `Rt`, `alpha`, and `I0` are batched by their first dimension, allowing for multiple King profiles to be defined at once. - Parameters: - Rc: core radius - Rt: truncation radius - alpha: concentration index which controls the shape of the brightness profile - I0: intensity at the center of the profile + **Parameters:** + - `Rc`: core radius + - `Rt`: truncation radius + - `alpha`: concentration index which controls the shape of the brightness profile + - `I0`: intensity at the center of the profile """ _model_type = "king" @@ -109,5 +110,7 @@ def initialize(self): ) @forward - def iradial_model(self, i, R, Rc, Rt, alpha, I0): + def iradial_model( + self, i: int, R: Tensor, Rc: Tensor, Rt: Tensor, alpha: Tensor, I0: Tensor + ) -> Tensor: return func.king(R, Rc[i], Rt[i], alpha[i], I0[i]) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 1e8c21aa..43dd03e2 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -1,5 +1,5 @@ import torch -import numpy as np +from torch import Tensor from ...param import forward from ...utils.decorators import ignore_numpy_warnings @@ -15,18 +15,18 @@ def _x0_func(model_params, R, F): class MoffatMixin: """Moffat radial light profile (Moffat 1969). - The moffat profile gives a good representation of the gneeral structure of + The moffat profile gives a good representation of the general structure of PSF functions for ground based data. It can also be used to fit extended objects. The functional form of the Moffat profile is defined as: $$I(R) = \\frac{I_0}{(1 + (R/R_d)^2)^n}$$ - n is the concentration index which controls the shape of the profile. + `n` is the concentration index which controls the shape of the profile. - Parameters: - n: Concentration index which controls the shape of the brightness profile - Rd: Scale length radius - I0: Intensity at the center of the profile + **Parameters:** + - `n`: Concentration index which controls the shape of the brightness profile + - `Rd`: Scale length radius + - `I0`: Intensity at the center of the profile """ _model_type = "moffat" @@ -50,28 +50,28 @@ def initialize(self): ) @forward - def radial_model(self, R, n, Rd, I0): + def radial_model(self, R: Tensor, n: Tensor, Rd: Tensor, I0: Tensor) -> Tensor: return func.moffat(R, n, Rd, I0) class iMoffatMixin: """Moffat radial light profile (Moffat 1969). - The moffat profile gives a good representation of the gneeral structure of + The moffat profile gives a good representation of the general structure of PSF functions for ground based data. It can also be used to fit extended objects. The functional form of the Moffat profile is defined as: $$I(R) = \\frac{I_0}{(1 + (R/R_d)^2)^n}$$ - n is the concentration index which controls the shape of the profile. + `n` is the concentration index which controls the shape of the profile. `n`, `Rd`, and `I0` are batched by their first dimension, allowing for multiple Moffat profiles to be defined at once. - Parameters: - n: Concentration index which controls the shape of the brightness profile - Rd: Scale length radius - I0: Intensity at the center of the profile + **Parameters:** + - `n`: Concentration index which controls the shape of the brightness profile + - `Rd`: Scale length radius + - `I0`: Intensity at the center of the profile """ _model_type = "moffat" @@ -96,5 +96,5 @@ def initialize(self): ) @forward - def iradial_model(self, i, R, n, Rd, I0): + def iradial_model(self, i: int, R: Tensor, n: Tensor, Rd: Tensor, I0: Tensor) -> Tensor: return func.moffat(R, n[i], Rd[i], I0[i]) diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index f138b15d..9a071004 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor from ...param import forward from ...utils.decorators import ignore_numpy_warnings @@ -23,12 +24,12 @@ class NukerMixin: slope, $\\beta$ gives the outer slope, $\\alpha$ is somewhat degenerate with the other slopes. - Parameters: - Rb: scale length radius - Ib: intensity at the scale length - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope + **Parameters:** + - `Rb`: scale length radius + - `Ib`: intensity at the scale length + - `alpha`: sharpness of transition between power law slopes + - `beta`: outer power law slope + - `gamma`: inner power law slope """ _model_type = "nuker" @@ -54,7 +55,9 @@ def initialize(self): ) @forward - def radial_model(self, R, Rb, Ib, alpha, beta, gamma): + def radial_model( + self, R: Tensor, Rb: Tensor, Ib: Tensor, alpha: Tensor, beta: Tensor, gamma: Tensor + ) -> Tensor: return func.nuker(R, Rb, Ib, alpha, beta, gamma) @@ -73,12 +76,12 @@ class iNukerMixin: `Rb`, `Ib`, `alpha`, `beta`, and `gamma` are batched by their first dimension, allowing for multiple Nuker profiles to be defined at once. - Parameters: - Rb: scale length radius - Ib: intensity at the scale length - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope + **Parameters:** + - `Rb`: scale length radius + - `Ib`: intensity at the scale length + - `alpha`: sharpness of transition between power law slopes + - `beta`: outer power law slope + - `gamma`: inner power law slope """ _model_type = "nuker" @@ -105,5 +108,7 @@ def initialize(self): ) @forward - def iradial_model(self, i, R, Rb, Ib, alpha, beta, gamma): + def iradial_model( + self, i: int, R: Tensor, Rb: Tensor, Ib: Tensor, alpha: Tensor, beta: Tensor, gamma: Tensor + ) -> Tensor: return func.nuker(R, Rb[i], Ib[i], alpha[i], beta[i], gamma[i]) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index d238ed77..e481aa7e 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -2,7 +2,6 @@ import numpy as np from torch.autograd.functional import jacobian -from torch.func import jacfwd, hessian import torch from torch import Tensor @@ -15,26 +14,23 @@ class SampleMixin: """ - options: - sampling_mode: The method used to sample the model in image pixels. Options are: - - auto: Automatically choose the sampling method based on the image size. - - midpoint: Use midpoint sampling, evaluate the brightness at the center of each pixel. - - simpsons: Use Simpson's rule for sampling integrating each pixel. - - quad:x: Use quadrature sampling with order x, where x is a positive integer to integrate each pixel. - jacobian_maxparams: The maximum number of parameters before the Jacobian will be broken into - smaller chunks. This is helpful for limiting the memory requirements to build a model. - jacobian_maxpixels: The maximum number of pixels before the Jacobian will be broken into - smaller chunks. This is helpful for limiting the memory requirements to build a model. - integrate_mode: The method used to select pixels to integrate further where the model varies significantly. Options are: - - none: No extra integration is performed (beyond the sampling_mode). - - bright: Select the brightest pixels for further integration. - - threshold: Select pixels which show signs of significant higher order derivatives. - integrate_tolerance: The tolerance for selecting a pixel in the integration method. This is the total flux fraction - that is integrated over the image. - integrate_fraction: The fraction of the pixels to super sample during integration. - integrate_max_depth: The maximum depth of the integration method. - integrate_gridding: The gridding used for the integration method to super-sample a pixel at each iteration. - integrate_quad_order: The order of the quadrature used for the integration method on the super sampled pixels. + **Options:** + - `sampling_mode`: The method used to sample the model in image pixels. Options are: + - `auto`: Automatically choose the sampling method based on the image size. + - `midpoint`: Use midpoint sampling, evaluate the brightness at the center of each pixel. + - `simpsons`: Use Simpson's rule for sampling integrating each pixel. + - `quad:x`: Use quadrature sampling with order x, where x is a positive integer to integrate each pixel. + - `jacobian_maxparams`: The maximum number of parameters before the Jacobian will be broken into smaller chunks. This is helpful for limiting the memory requirements to build a model. + - `jacobian_maxpixels`: The maximum number of pixels before the Jacobian will be broken into smaller chunks. This is helpful for limiting the memory requirements to build a model. + - `integrate_mode`: The method used to select pixels to integrate further where the model varies significantly. Options are: + - `none`: No extra integration is performed (beyond the sampling_mode). + - `bright`: Select the brightest pixels for further integration. + - `threshold`: Select pixels which show signs of significant higher order derivatives. + - `integrate_tolerance`: The tolerance for selecting a pixel in the integration method. This is the total flux fraction that is integrated over the image. + - `integrate_fraction`: The fraction of the pixels to super sample during integration. + - `integrate_max_depth`: The maximum depth of the integration method. + - `integrate_gridding`: The gridding used for the integration method to super-sample a pixel at each iteration. + - `integrate_quad_order`: The order of the quadrature used for the integration method on the super sampled pixels. """ # Method for initial sampling of model @@ -61,7 +57,7 @@ class SampleMixin: ) @forward - def _bright_integrate(self, sample, image: Image): + def _bright_integrate(self, sample: Tensor, image: Image) -> Tensor: i, j = image.pixel_center_meshgrid() N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) sample_flat = sample.flatten(-2) @@ -79,7 +75,7 @@ def _bright_integrate(self, sample, image: Image): return sample_flat.reshape(sample.shape) @forward - def _curvature_integrate(self, sample, image: Image): + def _curvature_integrate(self, sample: Tensor, image: Image) -> Tensor: i, j = image.pixel_center_meshgrid() kernel = func.curvature_kernel(config.DTYPE, config.DEVICE) curvature = ( @@ -113,7 +109,7 @@ def _curvature_integrate(self, sample, image: Image): return sample_flat.reshape(sample.shape) @forward - def sample_image(self, image: Image): + def sample_image(self, image: Image) -> Tensor: if self.sampling_mode == "auto": N = np.prod(image.data.shape) if N <= 100: @@ -152,7 +148,9 @@ def sample_image(self, image: Image): ) return sample - def _jacobian(self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor): + def _jacobian( + self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor + ) -> Tensor: # return jacfwd( # this should be more efficient, but the trace overhead is too high # lambda x: self.sample( # window=window, params=torch.cat((params_pre, x, params_post), dim=-1) @@ -173,7 +171,7 @@ def jacobian( window: Optional[Window] = None, pass_jacobian: Optional[JacobianImage] = None, params: Optional[Tensor] = None, - ): + ) -> JacobianImage: if window is None: window = self.window @@ -224,7 +222,7 @@ def gradient( window: Optional[Window] = None, params: Optional[Tensor] = None, likelihood: Literal["gaussian", "poisson"] = "gaussian", - ): + ) -> Tensor: """Compute the gradient of the model with respect to its parameters.""" if window is None: window = self.window diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index a14b5393..7e630e75 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor from ...param import forward from ...utils.decorators import ignore_numpy_warnings @@ -18,17 +19,17 @@ class SersicMixin: starting point for many extended objects. The functional form of the Sersic profile is defined as: - $$I(R) = I_e * \\exp(- b_n((R/R_e)^(1/n) - 1))$$ + $$I(R) = I_e * \\exp(- b_n((R/R_e)^{1/n} - 1))$$ It is a generalization of a gaussian, exponential, and de-Vaucouleurs profile. The Sersic index `n` controls the shape of the profile, with `n=1` being an exponential profile, `n=4` being a de-Vaucouleurs profile, and `n=0.5` being a Gaussian profile. - Parameters: - n: Sersic index which controls the shape of the brightness profile - Re: half light radius [arcsec] - Ie: intensity at the half light radius [flux/arcsec^2] + **Parameters:** + - `n`: Sersic index which controls the shape of the brightness profile + - `Re`: half light radius [arcsec] + - `Ie`: intensity at the half light radius [flux/arcsec^2] """ _model_type = "sersic" @@ -48,7 +49,7 @@ def initialize(self): ) @forward - def radial_model(self, R, n, Re, Ie): + def radial_model(self, R: Tensor, n: Tensor, Re: Tensor, Ie: Tensor) -> Tensor: return func.sersic(R, n, Re, Ie) @@ -59,7 +60,7 @@ class iSersicMixin: starting point for many extended objects. The functional form of the Sersic profile is defined as: - $$I(R) = I_e * \\exp(- b_n((R/R_e)^(1/n) - 1))$$ + $$I(R) = I_e * \\exp(- b_n((R/R_e)^{1/n} - 1))$$ It is a generalization of a gaussian, exponential, and de-Vaucouleurs profile. The Sersic index `n` controls the shape of the profile, with `n=1` @@ -69,10 +70,10 @@ class iSersicMixin: `n`, `Re`, and `Ie` are batched by their first dimension, allowing for multiple Sersic profiles to be defined at once. - Parameters: - n: Sersic index which controls the shape of the brightness profile - Re: half light radius [arcsec] - Ie: intensity at the half light radius [flux/arcsec^2] + **Parameters:** + - `n`: Sersic index which controls the shape of the brightness profile + - `Re`: half light radius [arcsec] + - `Ie`: intensity at the half light radius [flux/arcsec^2] """ _model_type = "sersic" @@ -97,5 +98,5 @@ def initialize(self): ) @forward - def iradial_model(self, i, R, n, Re, Ie): + def iradial_model(self, i: int, R: Tensor, n: Tensor, Re: Tensor, Ie: Tensor) -> Tensor: return func.sersic(R, n[i], Re[i], Ie[i]) diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index b706a480..22169748 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor import numpy as np from ...param import forward @@ -16,8 +17,8 @@ class SplineMixin: that contains the radial profile of the brightness in units of flux/arcsec^2. The radius of each node is determined from `I_R.prof`. - Parameters: - I_R: Tensor of radial brightness values in units of flux/arcsec^2. + **Parameters:** + - `I_R`: Tensor of radial brightness values in units of flux/arcsec^2. """ _model_type = "spline" @@ -49,7 +50,7 @@ def initialize(self): self.I_R.dynamic_value = 10**I @forward - def radial_model(self, R, I_R): + def radial_model(self, R: Tensor, I_R: Tensor) -> Tensor: ret = func.spline(R, self.I_R.prof, I_R) return ret @@ -66,8 +67,8 @@ class iSplineMixin: multiple spline profiles to be defined at once. Each individual spline model is then `I_R[i]` and `I_R.prof[i]` where `i` indexes the profiles. - Parameters: - I_R: Tensor of radial brightness values in units of flux/arcsec^2. + **Parameters:** + - `I_R`: Tensor of radial brightness values in units of flux/arcsec^2. """ _model_type = "spline" @@ -111,5 +112,5 @@ def initialize(self): self.I_R.dynamic_value = 10**value @forward - def iradial_model(self, i, R, I_R): + def iradial_model(self, i: int, R: Tensor, I_R: Tensor) -> Tensor: return func.spline(R, self.I_R.prof[i], I_R[i]) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 2af468d4..3e17653d 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -1,5 +1,7 @@ +from typing import Tuple import numpy as np import torch +from torch import Tensor from ...utils.decorators import ignore_numpy_warnings from ...utils.interpolate import default_prof @@ -14,17 +16,25 @@ class InclinedMixin: PA and q operate on the coordinates to transform the model. Given some x,y the updated values are: - $$x', y' = \\rm{rotate}(-PA + \\pi/2, x, y)$$ + $$x', y' = {\\rm rotate}(-PA + \\pi/2, x, y)$$ $$y'' = y' / q$$ - where x' and y'' are the final transformed coordinates. The pi/2 is included + where x' and y'' are the final transformed coordinates. The $\pi/2$ is included such that the position angle is defined with 0 at north. The -PA is such that the position angle increases to the East. Thus, the position angle is a standard East of North definition assuming the WCS of the image is correct. Note that this means radii are defined with $R = \\sqrt{x^2 + - (\\frac{y}{q})^2}$ rather than the common alternative which is $R = + \\left(\\frac{y}{q}\\right)^2}$ rather than the common alternative which is $R = \\sqrt{qx^2 + \\frac{y^2}{q}}$ + + **Parameters:** + - `q`: Axis ratio of the model, defined as the ratio of the + semi-minor axis to the semi-major axis. A value of 1.0 is + circular. + - `PA`: Position angle of the model, defined as the angle + between the semi-major axis and North, measured East of North. + A value of 0.0 is North, a value of pi/2 is East. """ _parameter_specs = { @@ -69,7 +79,9 @@ def initialize(self): self.q.dynamic_value = np.clip(np.sqrt(np.abs(l[0] / l[1])), 0.1, 0.9) @forward - def transform_coordinates(self, x, y, PA, q): + def transform_coordinates( + self, x: Tensor, y: Tensor, PA: Tensor, q: Tensor + ) -> Tuple[Tensor, Tensor]: x, y = super().transform_coordinates(x, y) x, y = func.rotate(-PA + np.pi / 2, x, y) return x, y / q @@ -80,21 +92,22 @@ class SuperEllipseMixin: A superellipse transformation allows for the expression of "boxy" and "disky" modifications to traditional elliptical isophotes. This is a common - extension of the standard elliptical representation, especially - for early-type galaxies. The functional form for this is: + extension of the standard elliptical representation, especially for + early-type galaxies. The functional form for this is: - $$ - R = (|x|^C + |y|^C)^(1/C) - $$ + $$R = (|x|^C + |y|^C)^{1/C}$$ - where R is the new distance metric, X Y are the coordinates, and C - is the coefficient for the superellipse. C can take on any value - greater than zero where C = 2 is the standard distance metric, 0 < - C < 2 creates disky or pointed perturbations to an ellipse, and C - > 2 transforms an ellipse to be more boxy. + where $R$ is the new distance metric, $X$ and $Y$ are the coordinates, and $C$ is the + coefficient for the superellipse. $C$ can take on any value greater than zero + where $C = 2$ is the standard distance metric, $0 < C < 2$ creates disky or + pointed perturbations to an ellipse, and $C > 2$ transforms an ellipse to be + more boxy. - Parameters: - C: superellipse distance metric parameter. + **Parameters:** + - `C`: Superellipse distance metric parameter, controls the shape of the isophotes. + A value of 2.0 is a standard elliptical distance metric, values + less than 2.0 create disky or pointed perturbations to an ellipse, + and values greater than 2.0 create boxy perturbations to an ellipse. """ @@ -104,7 +117,7 @@ class SuperEllipseMixin: } @forward - def radius_metric(self, x, y, C): + def radius_metric(self, x: Tensor, y: Tensor, C: Tensor) -> Tensor: return torch.pow(x.abs().pow(C) + y.abs().pow(C) + self.softening**C, 1.0 / C) @@ -115,7 +128,7 @@ class FourierEllipseMixin: pure ellipses. This is a common extension of the standard elliptical representation. The form of the Fourier perturbations is: - $$R' = R * \\exp(\\sum_m(a_m * \\cos(m * \\theta + \\phi_m)))$$ + $$R' = R * \\exp\\left(\\sum_m(a_m * \\cos(m * \\theta + \\phi_m))\\right)$$ where R' is the new radius value, R is the original radius (typically computed as $\\sqrt{x^2+y^2}$), m is the index of the Fourier mode, a_m is @@ -137,18 +150,15 @@ class FourierEllipseMixin: should consider carefully why the Fourier modes are being used for the science case at hand. - Parameters: - am: - Tensor of amplitudes for the Fourier modes, indicates the strength + **Parameters:** + - `am`: Tensor of amplitudes for the Fourier modes, indicates the strength of each mode. - phim: - Tensor of phases for the Fourier modes, adjusts the + - `phim`: Tensor of phases for the Fourier modes, adjusts the orientation of the mode perturbation relative to the major axis. It is cyclically defined in the range [0,2pi) - Options: - modes: - Tuple of integers indicating which Fourier modes to use. + **Options:** + - `modes`: Tuple of integers indicating which Fourier modes to use. """ _model_type = "fourier" @@ -158,12 +168,12 @@ class FourierEllipseMixin: } _options = ("modes",) - def __init__(self, *args, modes=(3, 4), **kwargs): + def __init__(self, *args, modes: Tuple[int] = (3, 4), **kwargs): super().__init__(*args, **kwargs) self.modes = torch.tensor(modes, dtype=config.DTYPE, device=config.DEVICE) @forward - def radius_metric(self, x, y, am, phim): + def radius_metric(self, x: Tensor, y: Tensor, am: Tensor, phim: Tensor) -> Tensor: R = super().radius_metric(x, y) theta = self.angular_metric(x, y) return R * torch.exp( @@ -203,9 +213,9 @@ class WarpMixin: original coordinates X, Y. This is achieved by making PA and q a spline profile. - Parameters: - q_R: Tensor of axis ratio values for axis ratio spline - PA_R: Tensor of position angle values as input to the spline + **Parameters:** + - `q_R`: Tensor of axis ratio values for axis ratio spline + - `PA_R`: Tensor of position angle values as input to the spline """ @@ -230,7 +240,9 @@ def initialize(self): self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 @forward - def transform_coordinates(self, x, y, q_R, PA_R): + def transform_coordinates( + self, x: Tensor, y: Tensor, q_R: Tensor, PA_R: Tensor + ) -> Tuple[Tensor, Tensor]: x, y = super().transform_coordinates(x, y) R = self.radius_metric(x, y) PA = func.spline(R, self.PA_R.prof, PA_R, extend="const") @@ -253,15 +265,15 @@ class TruncationMixin: optimized in a model, though it is possible for this parameter to be unstable if there isn't a clear truncation signal in the data. - Parameters: - Rt: The truncation radius in arcseconds. - St: The steepness of the truncation profile, controlling how quickly - the brightness drops to zero at the truncation radius. + **Parameters:** + - `Rt`: The truncation radius in arcseconds. + - `St`: The steepness of the truncation profile, controlling how quickly + the brightness drops to zero at the truncation radius. - Options: - outer_truncation: If True, the model will truncate the brightness beyond - the truncation radius. If False, the model will truncate the - brightness within the truncation radius. + **Options:** + - `outer_truncation`: If True, the model will truncate the brightness beyond + the truncation radius. If False, the model will truncate the + brightness within the truncation radius. """ _model_type = "truncated" @@ -271,7 +283,7 @@ class TruncationMixin: } _options = ("outer_truncation",) - def __init__(self, *args, outer_truncation=True, **kwargs): + def __init__(self, *args, outer_truncation: bool = True, **kwargs): super().__init__(*args, **kwargs) self.outer_truncation = outer_truncation @@ -284,7 +296,7 @@ def initialize(self): self.Rt.dynamic_value = prof[len(prof) // 2] @forward - def radial_model(self, R, Rt, St): + def radial_model(self, R: Tensor, Rt: Tensor, St: Tensor) -> Tensor: I = super().radial_model(R) if self.outer_truncation: return I * (1 - torch.tanh(St * (R - Rt))) / 2 diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 63b082b6..c33224aa 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -12,36 +12,39 @@ PSFImage, ) from ..utils.initialize import recursive_center_of_mass -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from .. import config from ..errors import InvalidTarget from .mixins import SampleMixin -__all__ = ["ComponentModel"] +__all__ = ("ComponentModel",) +@combine_docstrings class ComponentModel(SampleMixin, Model): - """ - Component of a model for an object in an image. + """Component of a model for an object in an image. This is a single component of an image model. It has a position on the sky determined by `center` and may or may not be convolved with a PSF to represent some data. - Options: - psf_convolve: Whether to convolve the model with a PSF. (bool) + **Parameters:** + - `center`: The center of the component in arcseconds [x, y] defined on the tangent plane. + + **Options:** + - `psf_convolve`: Whether to convolve the model with a PSF. (bool) """ _parameter_specs = {"center": {"units": "arcsec", "shape": (2,)}} _options = ("psf_convolve",) - psf_convolve: bool = False usable = False - def __init__(self, *args, psf=None, **kwargs): + def __init__(self, *args, psf=None, psf_convolve: bool = False, **kwargs): super().__init__(*args, **kwargs) self.psf = psf + self.psf_convolve = psf_convolve @property def psf(self): @@ -66,9 +69,9 @@ def psf(self, val): else: self._psf = self.target.psf_image(data=val) self.psf_convolve = True - self.update_psf_upscale() + self._update_psf_upscale() - def update_psf_upscale(self): + def _update_psf_upscale(self): """Update the PSF upscale factor based on the current target pixel length.""" if self.psf is None: self.psf_upscale = 1 @@ -102,7 +105,7 @@ def target(self, tar): pass self._target = tar try: - self.update_psf_upscale() + self._update_psf_upscale() except AttributeError: pass @@ -165,17 +168,12 @@ def sample( with the original pixel grid. The final model is then added to the requested image. - Args: - image (Optional[Image]): An AstroPhot Image object (likely a Model_Image) - on which to evaluate the model values. If not - provided, a new Model_Image object will be created. - window (Optional[Window]): A window within which to evaluate the model. - Should only be used if a subset of the full image - is needed. If not provided, the entire image will - be used. - - Returns: - Image: The image with the computed model values. + **Args:** + - `window` (Optional[Window]): A window within which to evaluate the model. + By default this is the model's window. + + **Returns:** + - `Image` (ModelImage): The image with the computed model values. """ # Window within which to evaluate model diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 3084d6d6..2f0d3476 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -2,26 +2,27 @@ import numpy as np from .model_object import ComponentModel -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from . import func from ..param import forward __all__ = ["MultiGaussianExpansion"] +@combine_docstrings class MultiGaussianExpansion(ComponentModel): """Model that represents a galaxy as a sum of multiple Gaussian profiles. The model is defined as: - I(R) = sum_i flux_i * exp(-0.5*(R_i / sigma_i)^2) / (2 * pi * q_i * sigma_i^2) + $$I(R) = \\sum_i {\\rm flux}_i * \\exp(-0.5*(R_i / \\sigma_i)^2) / (2 * \\pi * q_i * \\sigma_i^2)$$ where $R_i$ is a radius computed using $q_i$ and $PA_i$ for that component. All components share the same center. - Parameters: - q: axis ratio to scale minor axis from the ratio of the minor/major axis b/a, this parameter is unitless, it is restricted to the range (0,1) - PA: position angle of the semi-major axis relative to the image positive x-axis in radians, it is a cyclic parameter in the range [0,pi) - sigma: standard deviation of each Gaussian - flux: amplitude of each Gaussian + **Parameters:** + - `q`: axis ratio to scale minor axis from the ratio of the minor/major axis b/a, this parameter is unitless, it is restricted to the range (0,1) + - `PA`: position angle of the semi-major axis relative to the image positive x-axis in radians, it is a cyclic parameter in the range [0,pi) + - `sigma`: standard deviation of each Gaussian + - `flux`: amplitude of each Gaussian """ _model_type = "mge" diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index 1dce90c5..784f0bae 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -1,7 +1,7 @@ import torch from .psf_model_object import PSFModel -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from caskade import OverrideParam from ..param import forward @@ -9,6 +9,7 @@ __all__ = ["PixelatedPSF"] +@combine_docstrings class PixelatedPSF(PSFModel): """point source model which uses an image of the PSF as its representation for point sources. Using bilinear interpolation it @@ -32,8 +33,8 @@ class PixelatedPSF(PSFModel): (essentially just divide the pixelscale by the upsampling factor you used). - Parameters: - pixels: the total flux within each pixel, represented as the log of the flux. + **Parameters:** + - `pixels`: the total flux within each pixel, represented as the log of the flux. """ diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index ce34644c..bb37b213 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -2,24 +2,25 @@ import torch from .sky_model_object import SkyModel -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..param import forward __all__ = ["PlaneSky"] +@combine_docstrings class PlaneSky(SkyModel): """Sky background model using a tilted plane for the sky flux. The brightness for each pixel is defined as: - I(X, Y) = S + X*dx + Y*dy + $$I(X, Y) = I_0 + X*\\delta_x + Y*\\delta_y$$ - where I(X,Y) is the brightness as a function of image position X Y, - S is the central sky brightness value, and dx dy are the slopes of + where $I(X,Y)$ is the brightness as a function of image position $X, Y$, + $I_0$ is the central sky brightness value, and $\\delta_x, \\delta_y$ are the slopes of the sky brightness plane. - Parameters: - sky: central sky brightness value - delta: Tensor for slope of the sky brightness in each image dimension + **Parameters:** + - `I0`: central sky brightness value + - `delta`: Tensor for slope of the sky brightness in each image dimension """ diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 46caaec3..5965cff2 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -5,7 +5,7 @@ from .base import Model from .model_object import ComponentModel -from ..utils.decorators import ignore_numpy_warnings +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from ..image import Window, PSFImage from ..errors import SpecificationConflict @@ -14,6 +14,7 @@ __all__ = ("PointSource",) +@combine_docstrings class PointSource(ComponentModel): """Describes a point source in the image, this is a delta function at some position in the sky. This is typically used to describe @@ -21,6 +22,9 @@ class PointSource(ComponentModel): other object which can essentially be entirely described by a position and total flux (no structure). + **Parameters:** + - `flux`: The total flux of the point source + """ _model_type = "point" diff --git a/astrophot/models/sky_model_object.py b/astrophot/models/sky_model_object.py index 4112ec17..e1418697 100644 --- a/astrophot/models/sky_model_object.py +++ b/astrophot/models/sky_model_object.py @@ -1,8 +1,10 @@ from .model_object import ComponentModel +from ..utils.decorators import combine_docstrings __all__ = ["SkyModel"] +@combine_docstrings class SkyModel(ComponentModel): """prototype class for any sky background model. This simply imposes that the center is a locked parameter, not involved in the diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index ec153431..ad3a4098 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -83,7 +83,8 @@ def radial_median_profile( rad_unit: str = "arcsec", plot_kwargs: dict = {}, ): - """Plot an SB profile by taking flux median at each radius. + """ + Plot an SB profile by taking flux median at each radius. Using the coordinate transforms defined by the model object, assigns a radius to each pixel then bins the pixel-radii and diff --git a/astrophot/utils/decorators.py b/astrophot/utils/decorators.py index 428f634a..ec556f60 100644 --- a/astrophot/utils/decorators.py +++ b/astrophot/utils/decorators.py @@ -1,5 +1,6 @@ from functools import wraps import warnings +from inspect import cleandoc import numpy as np @@ -37,9 +38,12 @@ def wrapped(*args, **kwargs): def combine_docstrings(cls): - combined_docs = [cls.__doc__ or ""] + try: + combined_docs = [cleandoc(cls.__doc__)] + except AttributeError: + combined_docs = [] for base in cls.__bases__: if base.__doc__: - combined_docs.append(f"\n[UNIT {base.__name__}]\n\n{base.__doc__}") + combined_docs.append(f"\n\n> SUBUNIT {base.__name__}\n\n{cleandoc(base.__doc__)}") cls.__doc__ = "\n".join(combined_docs).strip() return cls diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index 97375b2e..3f498b29 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -4,7 +4,9 @@ __all__ = ("default_prof", "interp2d") -def default_prof(shape, pixelscale, min_pixels=2, scale=0.2): +def default_prof( + shape: tuple[int, int], pixelscale: float, min_pixels: int = 2, scale: float = 0.2 +) -> np.ndarray: prof = [0, min_pixels * pixelscale] imagescale = max(shape) # np.sqrt(np.sum(np.array(shape) ** 2)) while prof[-1] < (imagescale * pixelscale / 2): diff --git a/docs/source/tutorials/PoissonLikelihood.ipynb b/docs/source/tutorials/PoissonLikelihood.ipynb index 4eb09097..dabe7636 100644 --- a/docs/source/tutorials/PoissonLikelihood.ipynb +++ b/docs/source/tutorials/PoissonLikelihood.ipynb @@ -91,7 +91,7 @@ "id": "6", "metadata": {}, "source": [ - "While the Levenberg-Marquardt algorithm is traditionally considered as a least squares algorithm, that is actually just its most common application. LM naturally generalizes to a broad class of problems, including the Poisson Likelihood. Here we see the AstroPhot automatic initialization does well on this image and recovers decent starting parameters, LM has an easy time finishing the job to find the maximum likelihood.\n", + "While the Levenberg-Marquardt algorithm is traditionally considered as a least squares algorithm, that is actually just its most common application. LM naturally generalizes to a broad class of problems, including the Poisson Likelihood (see [Fowler 2014](https://ui.adsabs.harvard.edu/abs/2014JLTP..176..414F/abstract)). Here we see the AstroPhot automatic initialization does well on this image and recovers decent starting parameters, LM has an easy time finishing the job to find the maximum likelihood.\n", "\n", "Note that the idea of a $\\chi^2/{\\rm dof}$ is not as clearly defined for a Poisson likelihood. We take the closest analogue by taking 2 times the negative log likelihood divided by the DoF. This doesn't have any strict statistical meaning but is somewhat intuitive to work with for those used to $\\chi^2/{\\rm dof}$." ] From 3640e4a2aeb125a488cc6dff093d0d90570a6263 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 30 Jul 2025 21:41:24 -0400 Subject: [PATCH 107/191] more work on docstrings, auto build notebook docs --- .gitignore | 2 +- .readthedocs.yaml | 2 + astrophot/__init__.py | 3 +- astrophot/errors/__init__.py | 21 +++- astrophot/errors/base.py | 14 +-- astrophot/errors/fit.py | 4 - astrophot/errors/image.py | 32 +---- astrophot/errors/models.py | 14 +-- astrophot/errors/param.py | 11 -- astrophot/fit/base.py | 70 ++++------- astrophot/fit/gradient.py | 2 +- astrophot/fit/lm.py | 10 +- astrophot/image/cmos_image.py | 2 +- astrophot/image/func/image.py | 24 +++- astrophot/image/func/wcs.py | 123 +++++++------------ astrophot/image/image_object.py | 85 ++++++------- astrophot/image/jacobian_image.py | 4 +- astrophot/image/mixins/cmos_mixin.py | 13 +- astrophot/image/mixins/data_mixin.py | 26 ++-- astrophot/image/mixins/sip_mixin.py | 39 +++--- astrophot/image/psf_image.py | 12 +- astrophot/image/sip_image.py | 9 +- astrophot/image/target_image.py | 32 ++--- astrophot/image/window.py | 6 +- astrophot/models/func/convolution.py | 2 +- astrophot/models/func/exponential.py | 10 +- astrophot/models/func/ferrer.py | 27 ++-- astrophot/models/func/gaussian.py | 10 +- astrophot/models/func/gaussian_ellipsoid.py | 4 +- astrophot/models/func/integration.py | 72 ++++++----- astrophot/models/func/king.py | 27 ++-- astrophot/models/func/moffat.py | 15 ++- astrophot/models/func/nuker.py | 26 ++-- astrophot/models/func/sersic.py | 19 +-- astrophot/models/func/spline.py | 33 +++-- astrophot/models/func/transform.py | 6 +- astrophot/models/func/zernike.py | 6 +- astrophot/models/group_model_object.py | 13 +- astrophot/models/model_object.py | 4 - astrophot/models/moffat.py | 3 - astrophot/models/multi_gaussian_expansion.py | 10 +- astrophot/models/pixelated_psf.py | 3 +- astrophot/models/planesky.py | 3 +- astrophot/models/point_source.py | 16 ++- astrophot/models/psf_model_object.py | 23 ++-- astrophot/models/sky_model_object.py | 12 +- docs/requirements.txt | 1 + docs/source/_toc.yml | 1 + docs/source/astrophotdocs/index.rst | 21 ++++ make_docs.py | 102 +++++++++++++++ 50 files changed, 533 insertions(+), 496 deletions(-) delete mode 100644 astrophot/errors/param.py create mode 100644 docs/source/astrophotdocs/index.rst create mode 100644 make_docs.py diff --git a/.gitignore b/.gitignore index 7844bc7d..763be6f1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ tests/*.yaml docs/source/tutorials/*.fits docs/source/tutorials/*.yaml docs/source/tutorials/*.jpg -docs/autophot.*rst +docs/source/astrophotdocs/*.ipynb docs/modules.rst pip_cheatsheet.txt .gitpod.yml diff --git a/.readthedocs.yaml b/.readthedocs.yaml index a819dc9e..1c4c322b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -26,6 +26,8 @@ build: - graphviz jobs: pre_build: + # Build docstring jupyter notebooks + - "python make_docs.py" # Generate the Sphinx configuration for this Jupyter Book so it builds. - "jupyter-book config sphinx docs/source/" # Create font cache ahead of jupyter book diff --git a/astrophot/__init__.py b/astrophot/__init__.py index 439fd063..7aa353e7 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -1,7 +1,7 @@ import argparse import requests import torch -from . import config, models, plots, utils, fit, image +from . import config, models, plots, utils, fit, image, errors from .param import forward, Param, Module from .image import ( @@ -165,6 +165,7 @@ def run_from_terminal() -> None: "fit", "forward", "Param", + "errors", "Module", "config", "run_from_terminal", diff --git a/astrophot/errors/__init__.py b/astrophot/errors/__init__.py index 88392248..924f120c 100644 --- a/astrophot/errors/__init__.py +++ b/astrophot/errors/__init__.py @@ -1,5 +1,16 @@ -from .base import * -from .fit import * -from .image import * -from .models import * -from .param import * +from .base import AstroPhotError, SpecificationConflict +from .fit import OptimizeStopFail, OptimizeStopSuccess +from .image import InvalidWindow, InvalidData, InvalidImage +from .models import InvalidTarget, UnrecognizedModel + +__all__ = ( + "AstroPhotError", + "SpecificationConflict", + "OptimizeStopFail", + "OptimizeStopSuccess", + "InvalidWindow", + "InvalidData", + "InvalidImage", + "InvalidTarget", + "UnrecognizedModel", +) diff --git a/astrophot/errors/base.py b/astrophot/errors/base.py index 0f6a2433..b64b0b4b 100644 --- a/astrophot/errors/base.py +++ b/astrophot/errors/base.py @@ -1,4 +1,4 @@ -__all__ = ("AstroPhotError", "NameNotAllowed", "SpecificationConflict") +__all__ = ("AstroPhotError", "SpecificationConflict") class AstroPhotError(Exception): @@ -6,20 +6,8 @@ class AstroPhotError(Exception): Base exception for all AstroPhot processes. """ - ... - - -class NameNotAllowed(AstroPhotError): - """ - Used for invalid names of AstroPhot objects - """ - - ... - class SpecificationConflict(AstroPhotError): """ Raised when the inputs to an object are conflicting and/or ambiguous """ - - ... diff --git a/astrophot/errors/fit.py b/astrophot/errors/fit.py index 1a40c8df..0aa61620 100644 --- a/astrophot/errors/fit.py +++ b/astrophot/errors/fit.py @@ -8,12 +8,8 @@ class OptimizeStopFail(AstroPhotError): Raised at any point to stop optimization process due to failure. """ - pass - class OptimizeStopSuccess(AstroPhotError): """ Raised at any point to stop optimization process due to success condition. """ - - pass diff --git a/astrophot/errors/image.py b/astrophot/errors/image.py index ef77642a..cdf73fc4 100644 --- a/astrophot/errors/image.py +++ b/astrophot/errors/image.py @@ -1,12 +1,6 @@ from .base import AstroPhotError -__all__ = ( - "InvalidWindow", - "ConflicingWCS", - "InvalidData", - "InvalidImage", - "InvalidWCS", -) +__all__ = ("InvalidWindow", "InvalidData", "InvalidImage") class InvalidWindow(AstroPhotError): @@ -14,36 +8,14 @@ class InvalidWindow(AstroPhotError): Raised whenever a window is misspecified """ - ... - - -class ConflicingWCS(InvalidWindow): - """ - Raised when windows are compared and have WCS prescriptions which do not agree - """ - - ... - class InvalidData(AstroPhotError): """ - Raised when an image object can't determine the data it is holding. + Raised when the data provided to an image is invalid or cannot be processed. """ - ... - class InvalidImage(AstroPhotError): """ Raised when an image object cannot be used as given. """ - - ... - - -class InvalidWCS(AstroPhotError): - """ - Raised when the WCS is not appropriate as given. - """ - - ... diff --git a/astrophot/errors/models.py b/astrophot/errors/models.py index 9de693f4..78cfdc4c 100644 --- a/astrophot/errors/models.py +++ b/astrophot/errors/models.py @@ -1,14 +1,6 @@ from .base import AstroPhotError -__all__ = ("InvalidModel", "InvalidTarget", "UnrecognizedModel") - - -class InvalidModel(AstroPhotError): - """ - Catches when a model object is inappropriate for this instance. - """ - - ... +__all__ = ("InvalidTarget", "UnrecognizedModel") class InvalidTarget(AstroPhotError): @@ -16,12 +8,8 @@ class InvalidTarget(AstroPhotError): Catches when a target object is assigned incorrectly. """ - ... - class UnrecognizedModel(AstroPhotError): """ Raised when the user tries to invoke a model that does not exist. """ - - ... diff --git a/astrophot/errors/param.py b/astrophot/errors/param.py deleted file mode 100644 index afa068a3..00000000 --- a/astrophot/errors/param.py +++ /dev/null @@ -1,11 +0,0 @@ -from .base import AstroPhotError - -__all__ = ("InvalidParameter",) - - -class InvalidParameter(AstroPhotError): - """ - Catches when a parameter object is assigned incorrectly. - """ - - ... diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index 98e175b5..95cd924d 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -11,18 +11,18 @@ from ..param import ValidContext -__all__ = ["BaseOptimizer"] +__all__ = ("BaseOptimizer",) class BaseOptimizer(object): """ Base optimizer object that other optimizers inherit from. Ensures consistent signature for the classes. - Parameters: - model: an AstroPhot_Model object that will have its (unlocked) parameters optimized [AstroPhot_Model] - initial_state: optional initialization for the parameters as a 1D tensor [tensor] - max_iter: maximum allowed number of iterations [int] - relative_tolerance: tolerance for counting success steps as: 0 < (Chi2^2 - Chi1^2)/Chi1^2 < tol [float] + **Args:** + - `model`: an AstroPhot_Model object that will have its (unlocked) parameters optimized [AstroPhot_Model] + - `initial_state`: optional initialization for the parameters as a 1D tensor [tensor] + - `max_iter`: maximum allowed number of iterations [int] + - `relative_tolerance`: tolerance for counting success steps as: $0 < (\\chi_2^2 - \\chi_1^2)/\\chi_1^2 < \\text{tol}$ [float] """ @@ -37,29 +37,6 @@ def __init__( save_steps: Optional[str] = None, fit_valid: bool = True, ) -> None: - """ - Initializes a new instance of the class. - - Args: - model (object): An object representing the model. - initial_state (Optional[Sequence]): The initial state of the model could be any tensor. - If `None`, the model's default initial state will be used. - relative_tolerance (float): The relative tolerance for the optimization. - fit_parameters_identity (Optional[tuple]): a tuple of parameter identity strings which tell the LM optimizer which parameters of the model to fit. - **kwargs (dict): Additional keyword arguments. - - Attributes: - model (object): An object representing the model. - verbose (int): The verbosity level. - current_state (Tensor): The current state of the model. - max_iter (int): The maximum number of iterations. - iteration (int): The current iteration number. - save_steps (Optional[str]): Save intermediate results to this path. - relative_tolerance (float): The relative tolerance for the optimization. - lambda_history (List[ndarray]): A list of the optimization steps. - loss_history (List[float]): A list of the optimization losses. - message (str): An informational message. - """ self.model = model self.verbose = verbose @@ -88,17 +65,18 @@ def __init__( def fit(self) -> "BaseOptimizer": """ - Raises: - NotImplementedError: Error is raised if this method is not implemented in a subclass of BaseOptimizer. + **Raises:** + - `NotImplementedError`: Error is raised if this method is not implemented in a subclass of BaseOptimizer. """ raise NotImplementedError("Please use a subclass of BaseOptimizer for optimization") def step(self, current_state: torch.Tensor = None) -> None: - """Args: - current_state (torch.Tensor, optional): Current state of the model parameters. Defaults to None. + """ + **Args:** + - `current_state` (torch.Tensor, optional): Current state of the model parameters. Defaults to None. - Raises: - NotImplementedError: Error is raised if this method is not implemented in a subclass of BaseOptimizer. + **Raises:** + - `NotImplementedError`: Error is raised if this method is not implemented in a subclass of BaseOptimizer. """ raise NotImplementedError("Please use a subclass of BaseOptimizer for optimization") @@ -106,15 +84,16 @@ def chi2min(self) -> float: """ Returns the minimum value of chi^2 loss in the loss history. - Returns: - float: Minimum value of chi^2 loss. + **Returns:** + - `float`: Minimum value of chi^2 loss. """ return np.nanmin(self.loss_history) def res(self) -> np.ndarray: """Returns the value of lambda (regularization strength) at which minimum chi^2 loss was achieved. - Returns: ndarray which is the Value of lambda at which minimum chi^2 loss was achieved. + **Returns:** + - `ndarray`: Value of lambda at which minimum chi^2 loss was achieved. """ N = np.isfinite(self.loss_history) if np.sum(N) == 0: @@ -133,16 +112,15 @@ def chi2contour(n_params: int, confidence: float = 0.682689492137) -> float: """ Calculates the chi^2 contour for the given number of parameters. - Args: - n_params (int): The number of parameters. - confidence (float, optional): The confidence interval (default is 0.682689492137). - - Returns: - float: The calculated chi^2 contour value. + **Args:** + - `n_params` (int): The number of parameters. + - `confidence` (float, optional): The confidence interval (default is 0.682689492137). - Raises: - RuntimeError: If unable to compute the Chi^2 contour for the given number of parameters. + **Returns:** + - `float`: The calculated chi^2 contour value. + **Raises:** + - `RuntimeError`: If unable to compute the Chi^2 contour for the given number of parameters. """ def _f(x: float, nu: int) -> float: diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 0522b185..e631adb0 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -142,7 +142,7 @@ def fit(self) -> BaseOptimizer: class Slalom(BaseOptimizer): - """Slalom optimizer for AstroPhot_Model objects. + """Slalom optimizer for Model objects. Slalom is a gradient descent optimization algorithm that uses a few evaluations along the direction of the gradient to find the optimal step diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 7df195cb..6896d617 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -310,9 +310,6 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: def check_convergence(self) -> bool: """Check if the optimization has converged based on the last iteration's chi^2 and the relative tolerance. - - Returns: - bool: True if the optimization has converged, False otherwise. """ if len(self.loss_history) < 3: return False @@ -341,10 +338,9 @@ def check_convergence(self) -> bool: @torch.no_grad() def covariance_matrix(self) -> torch.Tensor: """The covariance matrix for the model at the current - parameters. This can be used to construct a full Gaussian PDF - for the parameters using: :math:`\\mathcal{N}(\\mu,\\Sigma)` - where :math:`\\mu` is the optimized parameters and - :math:`\\Sigma` is the covariance matrix. + parameters. This can be used to construct a full Gaussian PDF for the + parameters using: $\\mathcal{N}(\\mu,\\Sigma)$ where $\\mu$ is the + optimized parameters and $\\Sigma$ is the covariance matrix. """ diff --git a/astrophot/image/cmos_image.py b/astrophot/image/cmos_image.py index f58a25fc..700b0305 100644 --- a/astrophot/image/cmos_image.py +++ b/astrophot/image/cmos_image.py @@ -18,7 +18,7 @@ class CMOSTargetImage(CMOSMixin, TargetImage): It inherits from TargetImage and CMOSMixin. """ - def model_image(self, upsample=1, pad=0, **kwargs): + def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> CMOSModelImage: """Model the image with CMOS-specific features.""" if upsample > 1 or pad > 0: raise NotImplementedError("Upsampling and padding are not implemented for CMOS images.") diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index c878ce87..515a5138 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -3,31 +3,41 @@ from ...utils.integration import quad_table -def pixel_center_meshgrid(shape, dtype, device): +def pixel_center_meshgrid( + shape: tuple[int, int], dtype: torch.dtype, device: torch.device +) -> tuple[torch.Tensor, torch.Tensor]: i = torch.arange(shape[0], dtype=dtype, device=device) j = torch.arange(shape[1], dtype=dtype, device=device) return torch.meshgrid(i, j, indexing="ij") -def cmos_pixel_center_meshgrid(shape, loc, dtype, device): +def cmos_pixel_center_meshgrid( + shape: tuple[int, int], loc: tuple[float, float], dtype: torch.dtype, device: torch.device +) -> tuple[torch.Tensor, torch.Tensor]: i = torch.arange(shape[0], dtype=dtype, device=device) + loc[0] j = torch.arange(shape[1], dtype=dtype, device=device) + loc[1] return torch.meshgrid(i, j, indexing="ij") -def pixel_corner_meshgrid(shape, dtype, device): +def pixel_corner_meshgrid( + shape: tuple[int, int], dtype: torch.dtype, device: torch.device +) -> tuple[torch.Tensor, torch.Tensor]: i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 return torch.meshgrid(i, j, indexing="ij") -def pixel_simpsons_meshgrid(shape, dtype, device): +def pixel_simpsons_meshgrid( + shape: tuple[int, int], dtype: torch.dtype, device: torch.device +) -> tuple[torch.Tensor, torch.Tensor]: i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 return torch.meshgrid(i, j, indexing="ij") -def pixel_quad_meshgrid(shape, dtype, device, order=3): +def pixel_quad_meshgrid( + shape: tuple[int, int], dtype: torch.dtype, device: torch.device, order=3 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: i, j = pixel_center_meshgrid(shape, dtype, device) di, dj, w = quad_table(order, dtype, device) i = torch.repeat_interleave(i[..., None], order**2, -1) + di.flatten() @@ -35,7 +45,9 @@ def pixel_quad_meshgrid(shape, dtype, device, order=3): return i, j, w.flatten() -def rotate(theta, x, y): +def rotate( + theta: torch.Tensor, x: torch.Tensor, y: torch.Tensor +) -> tuple[torch.Tensor, torch.Tensor]: """ Applies a rotation matrix to the X,Y coordinates """ diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 728e823a..5a041cc9 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -11,23 +11,15 @@ def world_to_plane_gnomonic(ra, dec, ra0, dec0, x0=0.0, y0=0.0): """ Convert world coordinates (RA, Dec) to plane coordinates (x, y) using the gnomonic projection. - Parameters - ---------- - ra : torch.Tensor - Right Ascension in degrees. - dec : torch.Tensor - Declination in degrees. - ra0 : torch.Tensor - Reference Right Ascension in degrees. - dec0 : torch.Tensor - Reference Declination in degrees. - - Returns - ------- - x : torch.Tensor - x coordinate in arcseconds. - y : torch.Tensor - y coordinate in arcseconds. + **Args:** + - `ra`: (torch.Tensor) Right Ascension in degrees. + - `dec`: (torch.Tensor) Declination in degrees. + - `ra0`: (torch.Tensor) Reference Right Ascension in degrees. + - `dec0`: (torch.Tensor) Reference Declination in degrees. + + **Returns:** + - `x`: (torch.Tensor) x coordinate in arcseconds. + - `y`: (torch.Tensor) y coordinate in arcseconds. """ ra = ra * deg_to_rad dec = dec * deg_to_rad @@ -46,25 +38,17 @@ def world_to_plane_gnomonic(ra, dec, ra0, dec0, x0=0.0, y0=0.0): def plane_to_world_gnomonic(x, y, ra0, dec0, x0=0.0, y0=0.0, s=1e-10): """ Convert plane coordinates (x, y) to world coordinates (RA, Dec) using the gnomonic projection. - Parameters - ---------- - x : torch.Tensor - x coordinate in arcseconds. - y : torch.Tensor - y coordinate in arcseconds. - ra0 : torch.Tensor - Reference Right Ascension in degrees. - dec0 : torch.Tensor - Reference Declination in degrees. - s : float - Small constant to avoid division by zero. - - Returns - ------- - ra : torch.Tensor - Right Ascension in degrees. - dec : torch.Tensor - Declination in degrees. + + **Args:** + - `x`: (Tensor) x coordinate in arcseconds. + - `y`: (Tensor) y coordinate in arcseconds. + - `ra0`: (Tensor) Reference Right Ascension in degrees. + - `dec0`: (Tensor) Reference Declination in degrees. + - `s`: (float) Small constant to avoid division by zero. + + **Returns:** + - `ra`: (Tensor) Right Ascension in degrees. + - `dec`: (Tensor) Declination in degrees. """ x = (x - x0) * arcsec_to_rad y = (y - y0) * arcsec_to_rad @@ -89,28 +73,18 @@ def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): Convert pixel coordinates to a tangent plane using the WCS information. This matches the FITS convention for linear transformations. - Parameters - ---------- - i: Tensor - The first coordinate of the pixel in pixel units. - j: Tensor - The second coordinate of the pixel in pixel units. - i0: Tensor - The i reference pixel coordinate in pixel units. - j0: Tensor - The j reference pixel coordinate in pixel units. - CD: Tensor - The CD matrix in arcsec per pixel. This 2x2 matrix is used to convert - from pixel to arcsec units and also handles rotation/skew. - x0: float - The x reference coordinate in arcsec. - y0: float - The y reference coordinate in arcsec. - - Returns - ------- - Tuple: [Tensor, Tensor] - Tuple containing the x and y tangent plane coordinates in arcsec. + **Args:** + - `i` (Tensor): The first coordinate of the pixel in pixel units. + - `j` (Tensor): The second coordinate of the pixel in pixel units. + - `i0` (Tensor): The i reference pixel coordinate in pixel units. + - `j0` (Tensor): The j reference pixel coordinate in pixel units. + - `CD` (Tensor): The CD matrix in arcsec per pixel. This 2x2 matrix is used to convert + from pixel to arcsec units and also handles rotation/skew. + - `x0` (float): The x reference coordinate in arcseconds. + - `y0` (float): The y reference coordinate in arcseconds. + + **Returns:** + - Tuple[Tensor, Tensor]: Tuple containing the x and y coordinates in arcseconds """ uv = torch.stack((i.flatten() - i0, j.flatten() - j0), dim=0) xy = CD @ uv @@ -177,28 +151,17 @@ def plane_to_pixel_linear(x, y, i0, j0, CD, x0=0.0, y0=0.0): Convert tangent plane coordinates to pixel coordinates using the WCS information. This matches the FITS convention for linear transformations. - Parameters - ---------- - x: Tensor - The first coordinate of the pixel in arcsec. - y: Tensor - The second coordinate of the pixel in arcsec. - i0: Tensor - The i reference pixel coordinate in pixel units. - j0: Tensor - The j reference pixel coordinate in pixel units. - iCD: Tensor - The inverse CD matrix in arcsec per pixel. This 2x2 matrix is used to convert - from pixel to arcsec units and also handles rotation/skew. - x0: float - The x reference coordinate in arcsec. - y0: float - The y reference coordinate in arcsec. - - Returns - ------- - Tuple: [Tensor, Tensor] - Tuple containing the i and j pixel coordinates in pixel units. + **Args:** + - `x`: (Tensor) The first coordinate of the pixel in arcsec. + - `y`: (Tensor) The second coordinate of the pixel in arcsec. + - `i0`: (Tensor) The i reference pixel coordinate in pixel units. + - `j0`: (Tensor) The j reference pixel coordinate in pixel units. + - `CD`: (Tensor) The CD matrix in arcsec per pixel. + - `x0`: (float) The x reference coordinate in arcsec. + - `y0`: (float) The y reference coordinate in arcsec. + + **Returns:** + - Tuple[Tensor, Tensor]: Tuple containing the i and j pixel coordinates in pixel units. """ xy = torch.stack((x.flatten() - x0, y.flatten() - y0), dim=0) uv = torch.linalg.inv(CD) @ xy diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 7946cee0..72a7efd2 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Optional, Tuple, Union import torch import numpy as np @@ -19,9 +19,10 @@ class Image(Module): """Core class to represent images with pixel values, pixel scale, - and a window defining the spatial coordinates on the sky. - It supports arithmetic operations with other image objects while preserving logical image boundaries. - It also provides methods for determining the coordinate locations of pixels + and a window defining the spatial coordinates on the sky. It supports + arithmetic operations with other image objects while preserving logical + image boundaries. It also provides methods for determining the coordinate + locations of pixels """ default_CD = ((1.0, 0.0), (0.0, 1.0)) @@ -40,27 +41,11 @@ def __init__( pixelscale: Optional[Union[torch.Tensor, float]] = None, wcs: Optional[AstropyWCS] = None, filename: Optional[str] = None, - hduext=0, + hduext: int = 0, identity: str = None, name: Optional[str] = None, _data: Optional[torch.Tensor] = None, - ) -> None: - """Initialize an instance of the APImage class. - - Parameters: - ----------- - data : numpy.ndarray or None, optional - The image data. Default is None. - wcs : astropy.wcs.wcs.WCS or None, optional - A WCS object which defines a coordinate system for the image. Note that AstroPhot only handles basic WCS conventions. It will use the WCS object to get `wcs.pixel_to_world(-0.5, -0.5)` to determine the position of the origin in world coordinates. It will also extract the `pixel_scale_matrix` to index pixels going forward. - pixelscale : float or None, optional - The physical scale of the pixels in the image, in units of arcseconds. Default is None. - filename : str or None, optional - The name of a file containing the image data. Default is None. - zeropoint : float or None, optional - The image's zeropoint, used for flux calibration. Default is None. - - """ + ): super().__init__(name=name) if _data is None: self.data = data # units: flux @@ -141,7 +126,7 @@ def data(self, value: Optional[torch.Tensor]): ) @property - def crpix(self): + def crpix(self) -> np.ndarray: """The reference pixel coordinates in the image, which is used to convert from pixel coordinates to tangent plane coordinates.""" return self._crpix @@ -150,7 +135,7 @@ def crpix(self, value: Union[torch.Tensor, tuple]): self._crpix = np.asarray(value, dtype=np.float64) @property - def zeropoint(self): + def zeropoint(self) -> torch.Tensor: """The zeropoint of the image, which is used to convert from pixel flux to magnitude.""" return self._zeropoint @@ -163,7 +148,7 @@ def zeropoint(self, value): self._zeropoint = torch.as_tensor(value, dtype=config.DTYPE, device=config.DEVICE) @property - def window(self): + def window(self) -> Window: return Window(window=((0, 0), self.data.shape[:2]), image=self) @property @@ -196,23 +181,33 @@ def pixelscale(self): return self.pixel_area.sqrt() @forward - def pixel_to_plane(self, i, j, crtan, CD): + def pixel_to_plane( + self, i: torch.Tensor, j: torch.Tensor, crtan: torch.Tensor, CD: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: return func.pixel_to_plane_linear(i, j, *self.crpix, CD, *crtan) @forward - def plane_to_pixel(self, x, y, crtan, CD): + def plane_to_pixel( + self, x: torch.Tensor, y: torch.Tensor, crtan: torch.Tensor, CD: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: return func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) @forward - def plane_to_world(self, x, y, crval): + def plane_to_world( + self, x: torch.Tensor, y: torch.Tensor, crval: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: return func.plane_to_world_gnomonic(x, y, *crval) @forward - def world_to_plane(self, ra, dec, crval): + def world_to_plane( + self, ra: torch.Tensor, dec: torch.Tensor, crval: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: return func.world_to_plane_gnomonic(ra, dec, *crval) @forward - def world_to_pixel(self, ra, dec): + def world_to_pixel( + self, ra: torch.Tensor, dec: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: """A wrapper which applies :meth:`world_to_plane` then :meth:`plane_to_pixel`, see those methods for further information. @@ -221,7 +216,7 @@ def world_to_pixel(self, ra, dec): return self.plane_to_pixel(*self.world_to_plane(ra, dec)) @forward - def pixel_to_world(self, i, j): + def pixel_to_world(self, i: torch.Tensor, j: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """A wrapper which applies :meth:`pixel_to_plane` then :meth:`plane_to_world`, see those methods for further information. @@ -229,47 +224,47 @@ def pixel_to_world(self, i, j): """ return self.plane_to_world(*self.pixel_to_plane(i, j)) - def pixel_center_meshgrid(self): + def pixel_center_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" return func.pixel_center_meshgrid(self.shape, config.DTYPE, config.DEVICE) - def pixel_corner_meshgrid(self): + def pixel_corner_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" return func.pixel_corner_meshgrid(self.shape, config.DTYPE, config.DEVICE) - def pixel_simpsons_meshgrid(self): + def pixel_simpsons_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" return func.pixel_simpsons_meshgrid(self.shape, config.DTYPE, config.DEVICE) - def pixel_quad_meshgrid(self, order=3): + def pixel_quad_meshgrid(self, order=3) -> Tuple[torch.Tensor, torch.Tensor]: """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" return func.pixel_quad_meshgrid(self.shape, config.DTYPE, config.DEVICE, order=order) @forward - def coordinate_center_meshgrid(self) -> torch.Tensor: + def coordinate_center_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: """Get a meshgrid of coordinate locations in the image, centered on the pixel grid.""" i, j = self.pixel_center_meshgrid() return self.pixel_to_plane(i, j) @forward - def coordinate_corner_meshgrid(self): + def coordinate_corner_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: """Get a meshgrid of coordinate locations in the image, with corners at the pixel grid.""" i, j = self.pixel_corner_meshgrid() return self.pixel_to_plane(i, j) @forward - def coordinate_simpsons_meshgrid(self): + def coordinate_simpsons_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: """Get a meshgrid of coordinate locations in the image, with Simpson's rule sampling.""" i, j = self.pixel_simpsons_meshgrid() return self.pixel_to_plane(i, j) @forward - def coordinate_quad_meshgrid(self, order=3): + def coordinate_quad_meshgrid(self, order=3) -> Tuple[torch.Tensor, torch.Tensor]: """Get a meshgrid of coordinate locations in the image, with quadrature sampling.""" i, j, _ = self.pixel_quad_meshgrid(order=order) return self.pixel_to_plane(i, j) - def copy_kwargs(self, **kwargs): + def copy_kwargs(self, **kwargs) -> dict: kwargs = { "_data": torch.clone(self.data.detach()), "CD": self.CD.value, @@ -302,7 +297,7 @@ def blank_copy(self, **kwargs): } return self.copy(**kwargs) - def crop(self, pixels, **kwargs): + def crop(self, pixels: Union[int, Tuple[int, int], Tuple[int, int, int, int]], **kwargs): """Crop the image by the number of pixels given. This will crop the image in all four directions by the number of pixels given. @@ -390,7 +385,7 @@ def to(self, dtype=None, device=None): def flatten(self, attribute: str = "data") -> torch.Tensor: return getattr(self, attribute).flatten(end_dim=1) - def fits_info(self): + def fits_info(self) -> dict: return { "CTYPE1": "RA---TAN", "CTYPE2": "DEC--TAN", @@ -430,7 +425,7 @@ def save(self, filename: str): hdulist = fits.HDUList(self.fits_images()) hdulist.writeto(filename, overwrite=True) - def load(self, filename: str, hduext=0): + def load(self, filename: str, hduext: int = 0): """Load an image from a FITS file. This will load the primary HDU and set the data, CD, crpix, crval, and crtan attributes accordingly. If the WCS is not tangent plane, it will warn the user. @@ -458,7 +453,7 @@ def load(self, filename: str, hduext=0): self.identity = hdulist[hduext].header.get("IDNTY", str(id(self))) return hdulist - def corners(self): + def corners(self) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: pixel_lowleft = torch.tensor((-0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE) pixel_lowright = torch.tensor( (self.data.shape[0] - 0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE @@ -613,7 +608,7 @@ def to(self, dtype=None, device=None): super().to(dtype=dtype, device=device) return self - def flatten(self, attribute="data"): + def flatten(self, attribute: str = "data") -> torch.Tensor: return torch.cat(tuple(image.flatten(attribute) for image in self.images)) def __sub__(self, other): diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 9565f1b9..8534c023 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -5,7 +5,7 @@ from .image_object import Image, ImageList from ..errors import SpecificationConflict, InvalidImage -__all__ = ["JacobianImage", "JacobianImageList"] +__all__ = ("JacobianImage", "JacobianImageList") ###################################################################### @@ -84,7 +84,7 @@ def parameters(self) -> List[str]: return [] return self.images[0].parameters - def flatten(self, attribute="data"): + def flatten(self, attribute: str = "data"): if len(self.images) > 1: for image in self.images[1:]: if self.images[0].parameters != image.parameters: diff --git a/astrophot/image/mixins/cmos_mixin.py b/astrophot/image/mixins/cmos_mixin.py index 2a22abd6..c3029de2 100644 --- a/astrophot/image/mixins/cmos_mixin.py +++ b/astrophot/image/mixins/cmos_mixin.py @@ -1,3 +1,5 @@ +from typing import Optional, Tuple + from .. import func from ... import config @@ -8,7 +10,14 @@ class CMOSMixin: CMOS-specific functionality to image processing classes. """ - def __init__(self, *args, subpixel_loc=(0, 0), subpixel_scale=1.0, filename=None, **kwargs): + def __init__( + self, + *args, + subpixel_loc: Tuple[float, float] = (0, 0), + subpixel_scale: float = 1.0, + filename: Optional[str] = None, + **kwargs, + ): super().__init__(*args, filename=filename, **kwargs) if filename is not None: return @@ -38,7 +47,7 @@ def fits_info(self): info["SPIXSCL"] = self.subpixel_scale return info - def load(self, filename: str, hduext=0): + def load(self, filename: str, hduext: int = 0): hdulist = super().load(filename, hduext=hduext) if "SPIXLOC1" in hdulist[hduext].header: self.subpixel_loc = ( diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 07e4740a..99e82056 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Union, Optional import torch import numpy as np @@ -16,12 +16,12 @@ class DataMixin: def __init__( self, *args, - mask=None, - std=None, - variance=None, - weight=None, - _mask=None, - _weight=None, + mask: Optional[torch.Tensor] = None, + std: Optional[torch.Tensor] = None, + variance: Optional[torch.Tensor] = None, + weight: Optional[torch.Tensor] = None, + _mask: Optional[torch.Tensor] = None, + _weight: Optional[torch.Tensor] = None, **kwargs, ): super().__init__(*args, **kwargs) @@ -76,7 +76,7 @@ def std(self, std): self.weight = 1 / std**2 @property - def has_std(self): + def has_std(self) -> bool: """Returns True when the image object has stored standard deviation values. If this is False and the std property is called then a tensor of ones will be returned. @@ -115,7 +115,7 @@ def variance(self, variance): self.weight = 1 / variance @property - def has_variance(self): + def has_variance(self) -> bool: """Returns True when the image object has stored variance values. If this is False and the variance property is called then a tensor of ones will be returned. @@ -179,7 +179,7 @@ def weight(self, weight): ) @property - def has_weight(self): + def has_weight(self) -> bool: """Returns True when the image object has stored weight values. If this is False and the weight property is called then a tensor of ones will be returned. @@ -225,7 +225,7 @@ def mask(self, mask): ) @property - def has_mask(self): + def has_mask(self) -> bool: """ Single boolean to indicate if a mask has been provided by the user. """ @@ -288,7 +288,7 @@ def fits_images(self): ) return images - def load(self, filename: str, hduext=0): + def load(self, filename: str, hduext: int = 0): """Load the image from a FITS file. This will load the data, WCS, and any ancillary data such as variance, mask, and PSF. @@ -302,7 +302,7 @@ def load(self, filename: str, hduext=0): self.mask = np.array(hdulist["DQ"].data, dtype=bool) return hdulist - def reduce(self, scale, **kwargs): + def reduce(self, scale: int, **kwargs) -> Image: """Returns a new `Target_Image` object with a reduced resolution compared to the current image. `scale` should be an integer indicating how much to reduce the resolution. If the diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 0e5cfe6f..b802b77d 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Union, Optional, Tuple import torch @@ -16,14 +16,14 @@ class SIPMixin: def __init__( self, *args, - sipA={}, - sipB={}, - sipAP={}, - sipBP={}, - pixel_area_map=None, - distortion_ij=None, - distortion_IJ=None, - filename=None, + sipA: dict[Tuple[int, int], float] = {}, + sipB: dict[Tuple[int, int], float] = {}, + sipAP: dict[Tuple[int, int], float] = {}, + sipBP: dict[Tuple[int, int], float] = {}, + pixel_area_map: Optional[torch.Tensor] = None, + distortion_ij: Optional[torch.Tensor] = None, + distortion_IJ: Optional[torch.Tensor] = None, + filename: Optional[str] = None, **kwargs, ): super().__init__(*args, filename=filename, **kwargs) @@ -42,13 +42,17 @@ def __init__( ) @forward - def pixel_to_plane(self, i, j, crtan, CD): + def pixel_to_plane( + self, i: torch.Tensor, j: torch.Tensor, crtan: torch.Tensor, CD: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: di = interp2d(self.distortion_ij[0], i, j, padding_mode="border") dj = interp2d(self.distortion_ij[1], i, j, padding_mode="border") return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, CD, *crtan) @forward - def plane_to_pixel(self, x, y, crtan, CD): + def plane_to_pixel( + self, x: torch.Tensor, y: torch.Tensor, crtan: torch.Tensor, CD: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: I, J = func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) dI = interp2d(self.distortion_IJ[0], I, J, padding_mode="border") dJ = interp2d(self.distortion_IJ[1], I, J, padding_mode="border") @@ -59,13 +63,13 @@ def pixel_area_map(self): return self._pixel_area_map @property - def A_ORDER(self): + def A_ORDER(self) -> int: if self.sipA: return max(a + b for a, b in self.sipA) return 0 @property - def B_ORDER(self): + def B_ORDER(self) -> int: if self.sipB: return max(a + b for a, b in self.sipB) return 0 @@ -92,7 +96,12 @@ def compute_backward_sip_coefs(self): ((p, q), bp.item()) for (p, q), bp in zip(func.sip_coefs(self.B_ORDER), BP) ) - def update_distortion_model(self, distortion_ij=None, distortion_IJ=None, pixel_area_map=None): + def update_distortion_model( + self, + distortion_ij: Optional[torch.Tensor] = None, + distortion_IJ: Optional[torch.Tensor] = None, + pixel_area_map: Optional[torch.Tensor] = None, + ): """ Update the pixel area map based on the current SIP coefficients. """ @@ -189,7 +198,7 @@ def fits_info(self): info["BP_ORDER"] = bp_order return info - def load(self, filename: str, hduext=0): + def load(self, filename: str, hduext: int = 0): hdulist = super().load(filename, hduext=hduext) self.sipA = {} if "A_ORDER" in hdulist[hduext].header: diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index d4c3ac34..e33c1bd6 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -38,12 +38,12 @@ def __init__(self, *args, **kwargs): def normalize(self): """Normalizes the PSF image to have a sum of 1.""" norm = torch.sum(self.data) - self.data = self.data / norm + self._data = self.data / norm if self.has_weight: - self.weight = self.weight * norm**2 + self._weight = self.weight * norm**2 @property - def psf_pad(self): + def psf_pad(self) -> int: return max(self.data.shape) // 2 def jacobian_image( @@ -51,7 +51,7 @@ def jacobian_image( parameters: Optional[List[str]] = None, data: Optional[torch.Tensor] = None, **kwargs, - ): + ) -> JacobianImage: """ Construct a blank `Jacobian_Image` object formatted like this current `PSF_Image` object. Mostly used internally. """ @@ -75,9 +75,9 @@ def jacobian_image( } return JacobianImage(parameters=parameters, data=data, **kwargs) - def model_image(self, **kwargs): + def model_image(self, **kwargs) -> "PSFImage": """ - Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. + Construct a blank `ModelImage` object formatted like this current `TargetImage` object. Mostly used internally. """ kwargs = { "data": torch.zeros_like(self.data), diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index a9ad3114..9a465a85 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -1,3 +1,4 @@ +from typing import Tuple, Union import torch from .target_image import TargetImage @@ -7,7 +8,7 @@ class SIPModelImage(SIPMixin, ModelImage): - def crop(self, pixels, **kwargs): + def crop(self, pixels: Union[int, Tuple[int, int], Tuple[int, int, int, int]], **kwargs): """ Crop the image by the number of pixels given. This will crop the image in all four directions by the number of pixels given. @@ -47,8 +48,8 @@ def reduce(self, scale: int, **kwargs): pixels are condensed, but the pixel size is increased correspondingly. - Parameters: - scale: factor by which to condense the image pixels. Each scale X scale region will be summed [int] + **Args:** + - `scale`: factor by which to condense the image pixels. Each scale X scale region will be summed [int] """ if not isinstance(scale, int) and not ( @@ -95,7 +96,7 @@ class SIPTargetImage(SIPMixin, TargetImage): It inherits from TargetImage and SIPMixin. """ - def model_image(self, upsample=1, pad=0, **kwargs): + def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> SIPModelImage: new_area_map = self.pixel_area_map new_distortion_ij = self.distortion_ij new_distortion_IJ = self.distortion_IJ diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 37d4ad6a..bb184fa0 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import List, Optional, Tuple import numpy as np import torch @@ -88,7 +88,7 @@ def __init__(self, *args, psf=None, **kwargs): self.psf = psf @property - def has_psf(self): + def has_psf(self) -> bool: """Returns True when the target image object has a PSF model.""" try: return self._psf is not None @@ -158,7 +158,7 @@ def fits_images(self): config.logger.warning("Unable to save PSF to FITS, not a PSF_Image.") return images - def load(self, filename: str, hduext=0): + def load(self, filename: str, hduext: int = 0): """Load the image from a FITS file. This will load the data, WCS, and any ancillary data such as variance, mask, and PSF. @@ -179,9 +179,9 @@ def jacobian_image( parameters: List[str], data: Optional[torch.Tensor] = None, **kwargs, - ): + ) -> JacobianImage: """ - Construct a blank `Jacobian_Image` object formatted like this current `Target_Image` object. Mostly used internally. + Construct a blank `JacobianImage` object formatted like this current `TargetImage` object. Mostly used internally. """ if data is None: data = torch.zeros( @@ -201,9 +201,9 @@ def jacobian_image( } return JacobianImage(parameters=parameters, _data=data, **kwargs) - def model_image(self, upsample=1, pad=0, **kwargs): + def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> ModelImage: """ - Construct a blank `Model_Image` object formatted like this current `Target_Image` object. Mostly used internally. + Construct a blank `ModelImage` object formatted like this current `TargetImage` object. Mostly used internally. """ kwargs = { "_data": torch.zeros( @@ -222,7 +222,7 @@ def model_image(self, upsample=1, pad=0, **kwargs): } return ModelImage(**kwargs) - def psf_image(self, data, upscale=1, **kwargs): + def psf_image(self, data: torch.Tensor, upscale: int = 1, **kwargs) -> PSFImage: kwargs = { "data": data, "CD": self.CD.value / upscale, @@ -232,11 +232,11 @@ def psf_image(self, data, upscale=1, **kwargs): } return PSFImage(**kwargs) - def reduce(self, scale, **kwargs): - """Returns a new `Target_Image` object with a reduced resolution + def reduce(self, scale: int, **kwargs) -> "TargetImage": + """Returns a new `TargetImage` object with a reduced resolution compared to the current image. `scale` should be an integer indicating how much to reduce the resolution. If the - `Target_Image` was originally (48,48) pixels across with a + `TargetImage` was originally (48,48) pixels across with a pixelscale of 1 and `reduce(2)` is called then the image will be (24,24) pixels and the pixelscale will be 2. If `reduce(3)` is called then the returned image will be (16,16) pixels @@ -285,14 +285,16 @@ def weight(self, weight): def has_weight(self): return any(image.has_weight for image in self.images) - def jacobian_image(self, parameters: List[str], data: Optional[List[torch.Tensor]] = None): + def jacobian_image( + self, parameters: List[str], data: Optional[List[torch.Tensor]] = None + ) -> JacobianImageList: if data is None: data = tuple(None for _ in range(len(self.images))) return JacobianImageList( list(image.jacobian_image(parameters, dat) for image, dat in zip(self.images, data)) ) - def model_image(self): + def model_image(self) -> ModelImageList: return ModelImageList(list(image.model_image() for image in self.images)) @property @@ -305,7 +307,7 @@ def mask(self, mask): image.mask = M @property - def has_mask(self): + def has_mask(self) -> bool: return any(image.has_mask for image in self.images) @property @@ -318,5 +320,5 @@ def psf(self, psf): image.psf = P @property - def has_psf(self): + def has_psf(self) -> bool: return any(image.has_psf for image in self.images) diff --git a/astrophot/image/window.py b/astrophot/image/window.py index efd697a7..397e3cde 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -1,4 +1,4 @@ -from typing import Union, Tuple +from typing import Union, Tuple, List import numpy as np @@ -46,7 +46,7 @@ def extent( "Extent must be formatted as (i_low, i_high, j_low, j_high) or ((i_low, j_low), (i_high, j_high))" ) - def chunk(self, chunk_size: int): + def chunk(self, chunk_size: int) -> List["Window"]: # number of pixels on each axis px = self.i_high - self.i_low py = self.j_high - self.j_low @@ -135,7 +135,7 @@ def __init__(self, windows: list[Window]): ) self.windows = windows - def index(self, other: Window): + def index(self, other: Window) -> int: for i, window in enumerate(self.windows): if other.identity == window.identity: return i diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index 90dad3c6..44be804a 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -3,7 +3,7 @@ import torch -def convolve(image, psf): +def convolve(image: torch.Tensor, psf: torch.Tensor) -> torch.Tensor: image_fft = torch.fft.rfft2(image, s=image.shape) psf_fft = torch.fft.rfft2(psf, s=image.shape) diff --git a/astrophot/models/func/exponential.py b/astrophot/models/func/exponential.py index ff7e1469..8c4bf62b 100644 --- a/astrophot/models/func/exponential.py +++ b/astrophot/models/func/exponential.py @@ -4,13 +4,13 @@ b = sersic_n_to_b(1.0) -def exponential(R, Re, Ie): +def exponential(R: torch.Tensor, Re: torch.Tensor, Ie: torch.Tensor) -> torch.Tensor: """Exponential 1d profile function, specifically designed for pytorch operations. - Parameters: - R: Radii tensor at which to evaluate the sersic function - Re: Effective radius in the same units as R - Ie: Effective surface density + **Args:** + - `R`: Radius tensor at which to evaluate the exponential function + - `Re`: Effective radius in the same units as R + - `Ie`: Effective surface density """ return Ie * torch.exp(-b * ((R / Re) - 1.0)) diff --git a/astrophot/models/func/ferrer.py b/astrophot/models/func/ferrer.py index 53f40988..09f06a3f 100644 --- a/astrophot/models/func/ferrer.py +++ b/astrophot/models/func/ferrer.py @@ -1,27 +1,18 @@ import torch -def ferrer(R, rout, alpha, beta, I0): +def ferrer( + R: torch.Tensor, rout: torch.Tensor, alpha: torch.Tensor, beta: torch.Tensor, I0: torch.Tensor +) -> torch.Tensor: """ Modified Ferrer profile. - Parameters - ---------- - R : array_like - Radial distance from the center. - rout : float - Outer radius of the profile. - alpha : float - Power-law index. - beta : float - Exponent for the modified Ferrer function. - I0 : float - Central intensity. - - Returns - ------- - array_like - The modified Ferrer profile evaluated at R. + **Args:** + - `R`: Radius tensor at which to evaluate the modified Ferrer function + - `rout`: Outer radius of the profile + - `alpha`: Power-law index + - `beta`: Exponent for the modified Ferrer function + - `I0`: Central intensity """ return torch.where( R < rout, diff --git a/astrophot/models/func/gaussian.py b/astrophot/models/func/gaussian.py index 87b8b42d..780b1b26 100644 --- a/astrophot/models/func/gaussian.py +++ b/astrophot/models/func/gaussian.py @@ -4,13 +4,13 @@ sq_2pi = np.sqrt(2 * np.pi) -def gaussian(R, sigma, flux): +def gaussian(R: torch.Tensor, sigma: torch.Tensor, flux: torch.Tensor) -> torch.Tensor: """Gaussian 1d profile function, specifically designed for pytorch operations. - Parameters: - R: Radii tensor at which to evaluate the sersic function - sigma: standard deviation of the gaussian in the same units as R - I0: central surface density + **Args:** + - `R`: Radii tensor at which to evaluate the gaussian function + - `sigma`: Standard deviation of the gaussian in the same units as R + - `flux`: Central surface density """ return (flux / (sq_2pi * sigma)) * torch.exp(-0.5 * torch.pow(R / sigma, 2)) diff --git a/astrophot/models/func/gaussian_ellipsoid.py b/astrophot/models/func/gaussian_ellipsoid.py index c70fd464..d66317e4 100644 --- a/astrophot/models/func/gaussian_ellipsoid.py +++ b/astrophot/models/func/gaussian_ellipsoid.py @@ -1,7 +1,9 @@ import torch -def euler_rotation_matrix(alpha, beta, gamma): +def euler_rotation_matrix( + alpha: torch.Tensor, beta: torch.Tensor, gamma: torch.Tensor +) -> torch.Tensor: """Compute the rotation matrix from Euler angles. See the Z_alpha X_beta Z_gamma convention for the order of rotations here: diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index 4647d4bd..4a344257 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -1,20 +1,21 @@ +from typing import Tuple import torch import numpy as np from ...utils.integration import quad_table -def pixel_center_integrator(Z: torch.Tensor): +def pixel_center_integrator(Z: torch.Tensor) -> torch.Tensor: return Z -def pixel_corner_integrator(Z: torch.Tensor): +def pixel_corner_integrator(Z: torch.Tensor) -> torch.Tensor: kernel = torch.ones((1, 1, 2, 2), dtype=Z.dtype, device=Z.device) / 4.0 Z = torch.nn.functional.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid") return Z.squeeze(0).squeeze(0) -def pixel_simpsons_integrator(Z: torch.Tensor): +def pixel_simpsons_integrator(Z: torch.Tensor) -> torch.Tensor: kernel = ( torch.tensor([[[[1, 4, 1], [4, 16, 4], [1, 4, 1]]]], dtype=Z.dtype, device=Z.device) / 36.0 ) @@ -22,21 +23,14 @@ def pixel_simpsons_integrator(Z: torch.Tensor): return Z.squeeze(0).squeeze(0) -def pixel_quad_integrator(Z: torch.Tensor, w: torch.Tensor = None, order=3): +def pixel_quad_integrator(Z: torch.Tensor, w: torch.Tensor = None, order: int = 3) -> torch.Tensor: """ Integrate the pixel values using quadrature weights. - Parameters - ---------- - Z : torch.Tensor - The tensor containing pixel values. - w : torch.Tensor - The quadrature weights. - - Returns - ------- - torch.Tensor - The integrated value. + **Args:** + - `Z`: The tensor containing pixel values. + - `w`: The quadrature weights. + - `order`: The order of the quadrature. """ if w is None: _, _, w = quad_table(order, Z.dtype, Z.device) @@ -44,7 +38,9 @@ def pixel_quad_integrator(Z: torch.Tensor, w: torch.Tensor = None, order=3): return Z.sum(dim=(-1)) -def upsample(i, j, order, scale): +def upsample( + i: torch.Tensor, j: torch.Tensor, order: int, scale: float +) -> Tuple[torch.Tensor, torch.Tensor]: dp = torch.linspace(-1, 1, order, dtype=i.dtype, device=i.device) * (order - 1) / (2.0 * order) di, dj = torch.meshgrid(dp, dp, indexing="xy") @@ -53,7 +49,9 @@ def upsample(i, j, order, scale): return si, sj -def single_quad_integrate(i, j, brightness_ij, scale, quad_order=3): +def single_quad_integrate( + i: torch.Tensor, j: torch.Tensor, brightness_ij, scale: float, quad_order: int = 3 +) -> Tuple[torch.Tensor, torch.Tensor]: di, dj, w = quad_table(quad_order, i.dtype, i.device) qi = torch.repeat_interleave(i.unsqueeze(-1), quad_order**2, -1) + scale * di.flatten() qj = torch.repeat_interleave(j.unsqueeze(-1), quad_order**2, -1) + scale * dj.flatten() @@ -64,16 +62,16 @@ def single_quad_integrate(i, j, brightness_ij, scale, quad_order=3): def recursive_quad_integrate( - i, - j, - brightness_ij, - curve_frac, - scale=1.0, - quad_order=3, - gridding=5, - _current_depth=0, - max_depth=1, -): + i: torch.Tensor, + j: torch.Tensor, + brightness_ij: callable, + curve_frac: float, + scale: float = 1.0, + quad_order: int = 3, + gridding: int = 5, + _current_depth: int = 0, + max_depth: int = 1, +) -> torch.Tensor: z, z0 = single_quad_integrate(i, j, brightness_ij, scale, quad_order) if _current_depth >= max_depth: @@ -102,16 +100,16 @@ def recursive_quad_integrate( def recursive_bright_integrate( - i, - j, - brightness_ij, - bright_frac, - scale=1.0, - quad_order=3, - gridding=5, - _current_depth=0, - max_depth=1, -): + i: torch.Tensor, + j: torch.Tensor, + brightness_ij: callable, + bright_frac: float, + scale: float = 1.0, + quad_order: int = 3, + gridding: int = 5, + _current_depth: int = 0, + max_depth: int = 1, +) -> torch.Tensor: z, _ = single_quad_integrate(i, j, brightness_ij, scale, quad_order) if _current_depth >= max_depth: diff --git a/astrophot/models/func/king.py b/astrophot/models/func/king.py index b498dc46..04a0bcba 100644 --- a/astrophot/models/func/king.py +++ b/astrophot/models/func/king.py @@ -1,27 +1,18 @@ import torch -def king(R, Rc, Rt, alpha, I0): +def king( + R: torch.Tensor, Rc: torch.Tensor, Rt: torch.Tensor, alpha: torch.Tensor, I0: torch.Tensor +) -> torch.Tensor: """ Empirical King profile. - Parameters - ---------- - R : array_like - The radial distance from the center. - Rc : float - The core radius of the profile. - Rt : float - The truncation radius of the profile. - alpha : float - The power-law index of the profile. - I0 : float - The central intensity of the profile. - - Returns - ------- - array_like - The intensity at each radial distance. + **Args:** + - `R`: Radial distance from the center of the profile. + - `Rc`: Core radius of the profile. + - `Rt`: Truncation radius of the profile. + - `alpha`: Power-law index of the profile. + - `I0`: Central intensity of the profile. """ beta = 1 / (1 + (Rt / Rc) ** 2) ** (1 / alpha) gamma = 1 / (1 + (R / Rc) ** 2) ** (1 / alpha) diff --git a/astrophot/models/func/moffat.py b/astrophot/models/func/moffat.py index 274b73fe..ec6ba411 100644 --- a/astrophot/models/func/moffat.py +++ b/astrophot/models/func/moffat.py @@ -1,11 +1,14 @@ -def moffat(R, n, Rd, I0): +import torch + + +def moffat(R: torch.Tensor, n: torch.Tensor, Rd: torch.Tensor, I0: torch.Tensor) -> torch.Tensor: """Moffat 1d profile function - Parameters: - R: Radii tensor at which to evaluate the moffat function - n: concentration index - Rd: scale length in the same units as R - I0: central surface density + **Args:** + - `R`: Radii tensor at which to evaluate the moffat function + - `n`: concentration index + - `Rd`: scale length in the same units as R + - `I0`: central surface density """ return I0 / (1 + (R / Rd) ** 2) ** n diff --git a/astrophot/models/func/nuker.py b/astrophot/models/func/nuker.py index 556135b2..e7977b22 100644 --- a/astrophot/models/func/nuker.py +++ b/astrophot/models/func/nuker.py @@ -1,13 +1,23 @@ -def nuker(R, Rb, Ib, alpha, beta, gamma): +import torch + + +def nuker( + R: torch.Tensor, + Rb: torch.Tensor, + Ib: torch.Tensor, + alpha: torch.Tensor, + beta: torch.Tensor, + gamma: torch.Tensor, +) -> torch.Tensor: """Nuker 1d profile function - Parameters: - R: Radii tensor at which to evaluate the nuker function - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope + **Args:** + - `R`: Radii tensor at which to evaluate the nuker function + - `Ib`: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. + - `Rb`: scale length radius + - `alpha`: sharpness of transition between power law slopes + - `beta`: outer power law slope + - `gamma`: inner power law slope """ return ( diff --git a/astrophot/models/func/sersic.py b/astrophot/models/func/sersic.py index 3244f019..f405cc1e 100644 --- a/astrophot/models/func/sersic.py +++ b/astrophot/models/func/sersic.py @@ -1,12 +1,15 @@ +import torch + + C1 = 4 / 405 C2 = 46 / 25515 C3 = 131 / 1148175 C4 = -2194697 / 30690717750 -def sersic_n_to_b(n): +def sersic_n_to_b(n: float) -> float: """Compute the `b(n)` for a sersic model. This factor ensures that - the :math:`R_e` and :math:`I_e` parameters do in fact correspond + the $R_e$ and $I_e$ parameters do in fact correspond to the half light values and not some other scale radius/intensity. @@ -15,15 +18,15 @@ def sersic_n_to_b(n): return 2 * n - 1 / 3 + x * (C1 + x * (C2 + x * (C3 + C4 * x))) -def sersic(R, n, Re, Ie): +def sersic(R: torch.Tensor, n: torch.Tensor, Re: torch.Tensor, Ie: torch.Tensor) -> torch.Tensor: """Seric 1d profile function, specifically designed for pytorch operations - Parameters: - R: Radii tensor at which to evaluate the sersic function - n: sersic index restricted to n > 0.36 - Re: Effective radius in the same units as R - Ie: Effective surface density + **Args:** + - `R`: Radii tensor at which to evaluate the sersic function + - `n`: sersic index restricted to n > 0.36 + - `Re`: Effective radius in the same units as R + - `Ie`: Effective surface density """ bn = sersic_n_to_b(n) return Ie * (-bn * ((R / Re) ** (1 / n) - 1)).exp() diff --git a/astrophot/models/func/spline.py b/astrophot/models/func/spline.py index cf818c5f..f7fd50e6 100644 --- a/astrophot/models/func/spline.py +++ b/astrophot/models/func/spline.py @@ -1,7 +1,7 @@ import torch -def _h_poly(t): +def _h_poly(t: torch.Tensor) -> torch.Tensor: """Helper function to compute the 'h' polynomial matrix used in the cubic spline. @@ -26,19 +26,11 @@ def cubic_spline_torch(x: torch.Tensor, y: torch.Tensor, xs: torch.Tensor) -> to """Compute the 1D cubic spline interpolation for the given data points using PyTorch. - Args: - x (Tensor): A 1D tensor representing the x-coordinates of the known data points. - y (Tensor): A 1D tensor representing the y-coordinates of the known data points. - xs (Tensor): A 1D tensor representing the x-coordinates of the positions where - the cubic spline function should be evaluated. - extend (str, optional): The method for handling extrapolation, either "const" or "linear". - Default is "const". - "const": Use the value of the last known data point for extrapolation. - "linear": Use linear extrapolation based on the last two known data points. - - Returns: - Tensor: A 1D tensor representing the interpolated values at the specified positions (xs). - + **Args:** + - `x` (Tensor): A 1D tensor representing the x-coordinates of the known data points. + - `y` (Tensor): A 1D tensor representing the y-coordinates of the known data points. + - `xs` (Tensor): A 1D tensor representing the x-coordinates of the positions where + the cubic spline function should be evaluated. """ m = (y[1:] - y[:-1]) / (x[1:] - x[:-1]) m = torch.cat([m[[0]], (m[1:] + m[:-1]) / 2, m[[-1]]]) @@ -49,14 +41,17 @@ def cubic_spline_torch(x: torch.Tensor, y: torch.Tensor, xs: torch.Tensor) -> to return ret -def spline(R, profR, profI, extend="zeros"): +def spline( + R: torch.Tensor, profR: torch.Tensor, profI: torch.Tensor, extend: str = "zeros" +) -> torch.Tensor: """Spline 1d profile function, cubic spline between points up to second last point beyond which is linear - Parameters: - R: Radii tensor at which to evaluate the sersic function - profR: radius values for the surface density profile in the same units as R - profI: surface density values for the surface density profile + **Args:** + - `R`: Radii tensor at which to evaluate the spline function + - `profR`: radius values for the surface density profile in the same units as `R` + - `profI`: surface density values for the surface density profile + - `extend`: How to extend the spline beyond the last point. Options are 'zeros' or 'const'. """ I = cubic_spline_torch(profR, profI, R.view(-1)).reshape(*R.shape) if extend == "zeros": diff --git a/astrophot/models/func/transform.py b/astrophot/models/func/transform.py index 58ab12f1..d53a869b 100644 --- a/astrophot/models/func/transform.py +++ b/astrophot/models/func/transform.py @@ -1,4 +1,8 @@ -def rotate(theta, x, y): +from typing import Tuple +from torch import Tensor + + +def rotate(theta: Tensor, x: Tensor, y: Tensor) -> Tuple[Tensor, Tensor]: """ Applies a rotation matrix to the X,Y coordinates """ diff --git a/astrophot/models/func/zernike.py b/astrophot/models/func/zernike.py index a3eb8ea3..34efa822 100644 --- a/astrophot/models/func/zernike.py +++ b/astrophot/models/func/zernike.py @@ -4,7 +4,7 @@ @lru_cache(maxsize=1024) -def coefficients(n, m): +def coefficients(n: int, m: int) -> list[tuple[int, float]]: C = [] for k in range(int((n - abs(m)) / 2) + 1): C.append( @@ -16,7 +16,7 @@ def coefficients(n, m): return C -def zernike_n_m_list(n): +def zernike_n_m_list(n: int) -> list[tuple[int, int]]: nm = [] for n_i in range(n + 1): for m_i in range(-n_i, n_i + 1, 2): @@ -24,7 +24,7 @@ def zernike_n_m_list(n): return nm -def zernike_n_m_modes(rho, phi, n, m): +def zernike_n_m_modes(rho: np.ndarray, phi: np.ndarray, n: int, m: int) -> np.ndarray: Z = np.zeros_like(rho) for k, c in coefficients(n, m): R = rho ** (n - 2 * k) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index fbff464e..2f0443f6 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -104,9 +104,6 @@ def _update_window(self): def initialize(self): """ Initialize each model in this group. Does this by iteratively initializing a model then subtracting it from a copy of the target. - - Args: - target (Optional["Target_Image"]): A Target_Image instance to use as the source for initializing the model parameters on this image. """ for model in self.models: config.logger.info(f"Initializing model {model.name}") @@ -198,8 +195,8 @@ def sample( model is called individually and the results are added together in one larger image. - Args: - image (Optional["Model_Image"]): Image to sample on, overrides the windows for each sub model, they will all be evaluated over this entire image. If left as none then each sub model will be evaluated in its window. + **Args:** + - `image` (Optional[ModelImage]): Image to sample on, overrides the windows for each sub model, they will all be evaluated over this entire image. If left as none then each sub model will be evaluated in its window. """ if window is None: @@ -233,8 +230,10 @@ def jacobian( full jacobian (Npixels * Nparameters) of zeros then call the jacobian method of each sub model and add it in to the total. - Args: - pass_jacobian (Optional["Jacobian_Image"]): A Jacobian image pre-constructed to be passed along instead of constructing new Jacobians + **Args:** + - `pass_jacobian` (Optional[JacobianImage]): A Jacobian image pre-constructed to be passed along instead of constructing new Jacobians + - `window` (Optional[Window]): A window within which to evaluate the jacobian. If not provided, the model's window will be used. + - `params` (Optional[Sequence[Param]]): Parameters to use for the jacobian. If not provided, the model's parameters will be used. """ if window is None: diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index c33224aa..e2c1d4ae 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -118,10 +118,6 @@ def initialize(self): with a local center of mass search which iterates by finding the center of light in a window, then iteratively updates until the iterations move by less than a pixel. - - Args: - target (Optional[Target_Image]): A target image object to use as a reference when setting parameter values - """ if self.psf is not None and isinstance(self.psf, Model): self.psf.initialize() diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py index 56f3b817..1cff5e0d 100644 --- a/astrophot/models/moffat.py +++ b/astrophot/models/moffat.py @@ -1,8 +1,5 @@ -from caskade import forward - from .galaxy_model_object import GalaxyModel from .psf_model_object import PSFModel -from ..utils.conversions.functions import moffat_I0_to_flux from .mixins import ( MoffatMixin, InclinedMixin, diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 2f0d3476..877d909b 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -1,4 +1,6 @@ +from typing import Optional, Tuple import torch +from torch import Tensor import numpy as np from .model_object import ComponentModel @@ -34,7 +36,7 @@ class MultiGaussianExpansion(ComponentModel): } usable = True - def __init__(self, *args, n_components=None, **kwargs): + def __init__(self, *args, n_components: Optional[int] = None, **kwargs): super().__init__(*args, **kwargs) if n_components is None: for key in ("q", "sigma", "flux"): @@ -97,7 +99,9 @@ def initialize(self): self.q.dynamic_value = ones * np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) @forward - def transform_coordinates(self, x, y, q, PA): + def transform_coordinates( + self, x: Tensor, y: Tensor, q: Tensor, PA: Tensor + ) -> Tuple[Tensor, Tensor]: x, y = super().transform_coordinates(x, y) if PA.numel() == 1: x, y = func.rotate(-(PA + np.pi / 2), x, y) @@ -109,7 +113,7 @@ def transform_coordinates(self, x, y, q, PA): return x, y @forward - def brightness(self, x, y, flux, sigma, q): + def brightness(self, x: Tensor, y: Tensor, flux: Tensor, sigma: Tensor, q: Tensor) -> Tensor: x, y = self.transform_coordinates(x, y) R = self.radius_metric(x, y) return torch.sum( diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index 784f0bae..9d5a053a 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -1,4 +1,5 @@ import torch +from torch import Tensor from .psf_model_object import PSFModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings @@ -54,7 +55,7 @@ def initialize(self): self.pixels.dynamic_value = target_area.data.clone() / target_area.pixel_area @forward - def brightness(self, x, y, pixels, center): + def brightness(self, x: Tensor, y: Tensor, pixels: Tensor, center: Tensor) -> Tensor: with OverrideParam(self.target.crtan, center): i, j = self.target.plane_to_pixel(x, y) result = interp2d(pixels, i, j) diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index bb37b213..d1473593 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -1,5 +1,6 @@ import numpy as np import torch +from torch import Tensor from .sky_model_object import SkyModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings @@ -43,5 +44,5 @@ def initialize(self): self.delta.dynamic_value = [0.0, 0.0] @forward - def brightness(self, x, y, I0, delta): + def brightness(self, x: Tensor, y: Tensor, I0: Tensor, delta: Tensor) -> Tensor: return I0 + x * delta[0] + y * delta[1] diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 5965cff2..4639f48b 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -5,6 +5,7 @@ from .base import Model from .model_object import ComponentModel +from ..image import ModelImage from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from ..image import Window, PSFImage @@ -55,11 +56,11 @@ def initialize(self): # Psf convolution should be on by default since this is a delta function @property - def psf_mode(self): - return "full" + def psf_convolve(self): + return True - @psf_mode.setter - def psf_mode(self, value): + @psf_convolve.setter + def psf_convolve(self, value): pass @property @@ -71,7 +72,12 @@ def integrate_mode(self, value): pass @forward - def sample(self, window: Optional[Window] = None, center=None, flux=None): + def sample( + self, + window: Optional[Window] = None, + center: torch.Tensor = None, + flux: torch.Tensor = None, + ) -> ModelImage: """Evaluate the model on the space covered by an image object. This function properly calls integration methods and PSF convolution. This should not be overloaded except in special diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 7836c415..9061acc3 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -1,8 +1,10 @@ +from typing import Optional, Tuple import torch +from torch import Tensor from caskade import forward from .base import Model -from ..image import ModelImage, PSFImage +from ..image import ModelImage, PSFImage, Window from ..errors import InvalidTarget from .mixins import SampleMixin @@ -39,13 +41,13 @@ def initialize(self): pass @forward - def transform_coordinates(self, x, y, center): + def transform_coordinates(self, x: Tensor, y: Tensor, center: Tensor) -> Tuple[Tensor, Tensor]: return x - center[0], y - center[1] # Fit loop functions ###################################################################### @forward - def sample(self, window=None): + def sample(self, window: Optional[Window] = None) -> PSFImage: """Evaluate the model on the space covered by an image object. This function properly calls integration methods. This should not be overloaded except in special cases. @@ -57,17 +59,14 @@ def sample(self, window=None): pixel grid. The final model is then added to the requested image. - Args: - image (Optional[Image]): An AstroPhot Image object (likely a Model_Image) - on which to evaluate the model values. If not - provided, a new Model_Image object will be created. - window (Optional[Window]): A window within which to evaluate the model. + **Args:** + - `window` (Optional[Window]): A window within which to evaluate the model. Should only be used if a subset of the full image is needed. If not provided, the entire image will be used. - Returns: - Image: The image with the computed model values. + **Returns:** + - `PSFImage`: The image with the computed model values. """ # Create an image to store pixel samples @@ -80,7 +79,7 @@ def sample(self, window=None): return working_image - def fit_mask(self): + def fit_mask(self) -> Tensor: return torch.zeros_like(self.target[self.window].mask, dtype=torch.bool) @property @@ -104,5 +103,5 @@ def target(self, target): self._target = target @forward - def __call__(self, window=None) -> ModelImage: + def __call__(self, window: Optional[Window] = None) -> ModelImage: return self.sample(window=window) diff --git a/astrophot/models/sky_model_object.py b/astrophot/models/sky_model_object.py index e1418697..f684768b 100644 --- a/astrophot/models/sky_model_object.py +++ b/astrophot/models/sky_model_object.py @@ -29,17 +29,17 @@ def initialize(self): self.center.to_static() @property - def psf_mode(self): - return "none" + def psf_convolve(self) -> bool: + return False - @psf_mode.setter - def psf_mode(self, val): + @psf_convolve.setter + def psf_convolve(self, val: bool): pass @property - def integrate_mode(self): + def integrate_mode(self) -> str: return "none" @integrate_mode.setter - def integrate_mode(self, val): + def integrate_mode(self, val: str): pass diff --git a/docs/requirements.txt b/docs/requirements.txt index 39b704a4..527e75ac 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,6 +4,7 @@ graphviz ipywidgets jupyter-book matplotlib +nbformat nbsphinx photutils scikit-image diff --git a/docs/source/_toc.yml b/docs/source/_toc.yml index ebd38f80..e35a6cd2 100644 --- a/docs/source/_toc.yml +++ b/docs/source/_toc.yml @@ -15,4 +15,5 @@ chapters: - file: contributing - file: citation - file: license + - file: astrophotdocs/index - file: modules diff --git a/docs/source/astrophotdocs/index.rst b/docs/source/astrophotdocs/index.rst new file mode 100644 index 00000000..c37a08e1 --- /dev/null +++ b/docs/source/astrophotdocs/index.rst @@ -0,0 +1,21 @@ +==================== +AstroPhot Docstrings +==================== + +Here you will find all of the AstroPhot class and method docstrings, built using +markdown formatting. These are useful for understanding the details of a given +model and can also be accessed via the python help command +```help(ap.object)```. For the AstroPhot models, the docstrings are a +combination of the various base-classes and mixins that make them up. They are +very detailed, but can be a bit awkward in their formatting, the good news is +that a lot of useful information is available there! + +.. toctree:: + :maxdepth: 3 + + models + image + fit + plots + utils + errors diff --git a/make_docs.py b/make_docs.py new file mode 100644 index 00000000..a62a6d44 --- /dev/null +++ b/make_docs.py @@ -0,0 +1,102 @@ +import astrophot as ap +import nbformat +from nbformat.v4 import new_notebook, new_markdown_cell +import pkgutil +from types import ModuleType, FunctionType +import os +from textwrap import dedent +from inspect import cleandoc, getmodule, signature + +skip_methods = [ + "to_valid", + "topological_ordering", + "to_static", + "to_dynamic", + "unlink", + "update_graph", + "save_state", + "load_state", + "append_state", + "link", + "graphviz", + "graph_print", + "graph_dict", + "from_valid", + "fill_params", + "fill_kwargs", + "fill_dynamic_values", + "clear_params", + "build_params_list", + "build_params_dict", + "build_params_array", +] + + +def dot_path(path): + i = path.rfind("AstroPhot") + path = path[i + 10 :] + path = path.replace("/", ".") + return path[:-3] + + +def gather_docs(module, module_only=False): + docs = {} + for name in module.__all__: + obj = getattr(module, name) + if module_only and not isinstance(obj, ModuleType): + continue + if isinstance(obj, type): + if obj.__doc__ is None: + continue + docs[name] = cleandoc(obj.__doc__) + subfuncs = [docs[name]] + for attr in dir(obj): + if attr.startswith("_"): + continue + if attr in skip_methods: + continue + attrobj = getattr(obj, attr) + if not isinstance(attrobj, FunctionType): + continue + if attrobj.__doc__ is None: + continue + sig = str(signature(attrobj)).replace("self,", "").replace("self", "") + subfuncs.append(f"> **method**: {attr}{sig}\n\n" + cleandoc(attrobj.__doc__)) + if len(subfuncs) > 1: + docs[name] = "\n\n".join(subfuncs) + elif isinstance(obj, FunctionType): + if obj.__doc__ is None: + continue + docs[name] = cleandoc(obj.__doc__) + elif isinstance(obj, ModuleType): + docs[name] = gather_docs(obj) + else: + print(f"!!!unexpected type {type(obj)}!!!") + return docs + + +def make_cells(mod_dict, path, depth=2): + print(mod_dict.keys()) + cells = [] + for k in mod_dict: + if isinstance(mod_dict[k], str): + cells.append(new_markdown_cell(f"{'#'*depth} {path}.{k}\n\n" + mod_dict[k])) + elif isinstance(mod_dict[k], dict): + print(k) + cells += make_cells(mod_dict[k], path=path + "." + k, depth=depth + 1) + return cells + + +output_dir = "docs/source/astrophotdocs" +all_ap = gather_docs(ap, True) + +for submodule in all_ap: + nb = new_notebook() + nb.cells = [new_markdown_cell(f"# {submodule}")] + make_cells( + all_ap[submodule], f"astrophot.{submodule}" + ) + + filename = f"{submodule}.ipynb" + path = os.path.join(output_dir, filename) + with open(path, "w", encoding="utf-8") as f: + nbformat.write(nb, f) From d7c9220e27e57f3e52d74b5550d57564923c4891 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 09:18:18 -0400 Subject: [PATCH 108/191] Cleanup docs for fit and plot --- astrophot/fit/base.py | 38 ++----- astrophot/fit/gradient.py | 34 ++++-- astrophot/fit/hmc.py | 42 ++++---- astrophot/fit/iterative.py | 45 ++++---- astrophot/fit/mhmcmc.py | 8 ++ astrophot/fit/minifit.py | 16 +++ astrophot/fit/scipy_fit.py | 88 ++++++---------- astrophot/plots/__init__.py | 2 - astrophot/plots/diagnostic.py | 14 ++- astrophot/plots/image.py | 152 +++++++++++++-------------- astrophot/plots/profile.py | 75 +++++-------- docs/source/astrophotdocs/index.rst | 2 +- docs/source/tutorials/ModelZoo.ipynb | 2 +- make_docs.py | 5 +- 14 files changed, 251 insertions(+), 272 deletions(-) diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index 95cd924d..d571f45a 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -8,21 +8,24 @@ from .. import config from ..models import Model from ..image import Window -from ..param import ValidContext __all__ = ("BaseOptimizer",) -class BaseOptimizer(object): +class BaseOptimizer: """ Base optimizer object that other optimizers inherit from. Ensures consistent signature for the classes. **Args:** - `model`: an AstroPhot_Model object that will have its (unlocked) parameters optimized [AstroPhot_Model] - `initial_state`: optional initialization for the parameters as a 1D tensor [tensor] - - `max_iter`: maximum allowed number of iterations [int] - `relative_tolerance`: tolerance for counting success steps as: $0 < (\\chi_2^2 - \\chi_1^2)/\\chi_1^2 < \\text{tol}$ [float] + - `fit_window`: optional window to fit the model on [Window] + - `verbose`: verbosity level for the optimizer [int] + - `max_iter`: maximum allowed number of iterations [int] + - `save_steps`: optional string for path to save the model at each step (fitter dependent), e.g. "model_step_{step}.hdf5" [str] + - `fit_valid`: whether to fit while forcing parameters into valid range, or allow any value for each parameter. Default True [bool] """ @@ -32,7 +35,7 @@ def __init__( initial_state: Sequence = None, relative_tolerance: float = 1e-3, fit_window: Optional[Window] = None, - verbose: int = 0, + verbose: int = 1, max_iter: int = None, save_steps: Optional[str] = None, fit_valid: bool = True, @@ -64,37 +67,19 @@ def __init__( self.message = "" def fit(self) -> "BaseOptimizer": - """ - **Raises:** - - `NotImplementedError`: Error is raised if this method is not implemented in a subclass of BaseOptimizer. - """ raise NotImplementedError("Please use a subclass of BaseOptimizer for optimization") def step(self, current_state: torch.Tensor = None) -> None: - """ - **Args:** - - `current_state` (torch.Tensor, optional): Current state of the model parameters. Defaults to None. - - **Raises:** - - `NotImplementedError`: Error is raised if this method is not implemented in a subclass of BaseOptimizer. - """ raise NotImplementedError("Please use a subclass of BaseOptimizer for optimization") def chi2min(self) -> float: """ Returns the minimum value of chi^2 loss in the loss history. - - **Returns:** - - `float`: Minimum value of chi^2 loss. """ return np.nanmin(self.loss_history) def res(self) -> np.ndarray: - """Returns the value of lambda (regularization strength) at which minimum chi^2 loss was achieved. - - **Returns:** - - `ndarray`: Value of lambda at which minimum chi^2 loss was achieved. - """ + """Returns the value of lambda (state parameters) at which minimum loss was achieved.""" N = np.isfinite(self.loss_history) if np.sum(N) == 0: config.logger.warning( @@ -104,6 +89,7 @@ def res(self) -> np.ndarray: return np.array(self.lambda_history)[N][np.argmin(np.array(self.loss_history)[N])] def res_loss(self): + """returns the minimum value from the loss history.""" N = np.isfinite(self.loss_history) return np.min(np.array(self.loss_history)[N]) @@ -115,12 +101,6 @@ def chi2contour(n_params: int, confidence: float = 0.682689492137) -> float: **Args:** - `n_params` (int): The number of parameters. - `confidence` (float, optional): The confidence interval (default is 0.682689492137). - - **Returns:** - - `float`: The calculated chi^2 contour value. - - **Raises:** - - `RuntimeError`: If unable to compute the Chi^2 contour for the given number of parameters. """ def _f(x: float, nu: int) -> float: diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index e631adb0..804946a0 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -10,12 +10,14 @@ from ..models import Model from ..errors import OptimizeStopFail, OptimizeStopSuccess from . import func +from ..utils.decorators import combine_docstrings __all__ = ["Grad"] +@combine_docstrings class Grad(BaseOptimizer): - """A gradient descent optimization wrapper for AstroPhot_Model objects. + """A gradient descent optimization wrapper for AstroPhot Model objects. The default method is "NAdam", a variant of the Adam optimization algorithm. This optimizer uses a combination of gradient descent and Nesterov momentum for faster convergence. @@ -23,11 +25,11 @@ class Grad(BaseOptimizer): The `fit` method performs the optimization, taking a series of gradient steps until a stopping criteria is met. **Args:** - - `model` (AstroPhot_Model): an AstroPhot_Model object with which to perform optimization. - - `initial_state` (torch.Tensor, optional): an optional initial state for optimization. + - `likelihood` (str, optional): The likelihood function to use for the optimization. Defaults to "gaussian". - `method` (str, optional): the optimization method to use for the update step. Defaults to "NAdam". - - `patience` (int or None, optional): the number of iterations without improvement before the optimizer will exit early. Defaults to None. - `optim_kwargs` (dict, optional): a dictionary of keyword arguments to pass to the pytorch optimizer. + - `patience` (int, optional): number of steps with no improvement before stopping the optimization. Defaults to 10. + - `report_freq` (int, optional): frequency of reporting the optimization progress. Defaults to 10 steps. """ def __init__( @@ -35,9 +37,9 @@ def __init__( model: Model, initial_state: Sequence = None, likelihood="gaussian", - patience=None, method="NAdam", optim_kwargs={}, + patience: int = 10, report_freq=10, **kwargs, ) -> None: @@ -64,8 +66,10 @@ def __init__( def density(self, state: torch.Tensor) -> torch.Tensor: """ - Returns the density of the model at the given state vector. - This is used to calculate the likelihood of the model at the given state. + Returns the density of the model at the given state vector. This is used + to calculate the likelihood of the model at the given state. Based on + ``self.likelihood``, will be either the Gaussian or Poisson negative log + likelihood. """ if self.likelihood == "gaussian": return -self.model.gaussian_log_likelihood(state) @@ -75,7 +79,7 @@ def density(self, state: torch.Tensor) -> torch.Tensor: raise ValueError(f"Unknown likelihood type: {self.likelihood}") def step(self) -> None: - """Take a single gradient step. Take a single gradient step. + """Take a single gradient step. Computes the loss function of the model, computes the gradient of the parameters using automatic differentiation, @@ -124,7 +128,7 @@ def fit(self) -> BaseOptimizer: self.message = self.message + " fail no improvement" break L = np.sort(self.loss_history) - if len(L) >= 3 and 0 < L[1] - L[0] < 1e-6 and 0 < L[2] - L[1] < 1e-6: + if len(L) >= 5 and 0 < (L[4] - L[0]) / L[0] < self.relative_tolerance: self.message = self.message + " success" break except KeyboardInterrupt: @@ -160,6 +164,14 @@ class Slalom(BaseOptimizer): not reach all the way to the minimum of the posterior density. Like other gradient descent algorithms, Slalom slows down considerably when trying to achieve very high precision. + + **Args:** + - `S` (float, optional): The initial step size for the Slalom optimizer. Defaults to 1e-4. + - `likelihood` (str, optional): The likelihood function to use for the optimization. Defaults to "gaussian". + - `report_freq` (int, optional): Frequency of reporting the optimization progress. Defaults to 10 steps. + - `relative_tolerance` (float, optional): The relative tolerance for convergence. Defaults to 1e-4. + - `momentum` (float, optional): The momentum factor for the Slalom optimizer. Defaults to 0.5. + - `max_iter` (int, optional): The maximum number of iterations for the optimizer. Defaults to 1000. """ def __init__( @@ -184,7 +196,9 @@ def __init__( self.momentum = momentum def density(self, state: torch.Tensor) -> torch.Tensor: - """Calculate the density of the model at the given state.""" + """Calculate the density of the model at the given state. Based on + ``self.likelihood``, will be either the Gaussian or Poisson negative log + likelihood.""" if self.likelihood == "gaussian": return -self.model.gaussian_log_likelihood(state) elif self.likelihood == "poisson": diff --git a/astrophot/fit/hmc.py b/astrophot/fit/hmc.py index a87e8861..106e657e 100644 --- a/astrophot/fit/hmc.py +++ b/astrophot/fit/hmc.py @@ -17,7 +17,7 @@ from ..models import Model from .. import config -__all__ = ["HMC"] +__all__ = ("HMC",) ########################################### @@ -29,10 +29,11 @@ def new_configure(self, mass_matrix_shape, adapt_mass_matrix=True, options={}): """ Sets up an initial mass matrix. - :param dict mass_matrix_shape: a dict that maps tuples of site names to the shape of + **Args:** + - `mass_matrix_shape`: a dict that maps tuples of site names to the shape of the corresponding mass matrix. Each tuple of site names corresponds to a block. - :param bool adapt_mass_matrix: a flag to decide whether an adaptation scheme will be used. - :param dict options: tensor options to construct the initial mass matrix. + - `adapt_mass_matrix`: a flag to decide whether an adaptation scheme will be used. + - `options`: tensor options to construct the initial mass matrix. """ inverse_mass_matrix = {} for site_names, shape in mass_matrix_shape.items(): @@ -58,28 +59,24 @@ def new_configure(self, mass_matrix_shape, adapt_mass_matrix=True, options={}): class HMC(BaseOptimizer): """Hamiltonian Monte-Carlo sampler wrapper for the Pyro package. - This MCMC algorithm uses gradients of the Chi^2 to more - efficiently explore the probability distribution. Consider using - the NUTS sampler instead of HMC, as it is generally better in most - aspects. + This MCMC algorithm uses gradients of the $\\chi^2$ to more + efficiently explore the probability distribution. More information on HMC can be found at: https://en.wikipedia.org/wiki/Hamiltonian_Monte_Carlo, https://arxiv.org/abs/1701.02434, and http://www.mcmchandbook.net/HandbookChapter5.pdf - Args: - model (AstroPhot_Model): The model which will be sampled. - initial_state (Optional[Sequence], optional): A 1D array with the values for each parameter in the model. These values should be in the form of "as_representation" in the model. Defaults to None. - max_iter (int, optional): The number of sampling steps to perform. Defaults to 1000. - epsilon (float, optional): The length of the integration step to perform for each leapfrog iteration. The momentum update will be of order epsilon * score. Defaults to 1e-5. - leapfrog_steps (int, optional): Number of steps to perform with leapfrog integrator per sample of the HMC. Defaults to 20. - inv_mass (float or array, optional): Inverse Mass matrix (covariance matrix) which can tune the behavior in each dimension to ensure better mixing when sampling. Defaults to the identity. - progress_bar (bool, optional): Whether to display a progress bar during sampling. Defaults to True. - prior (distribution, optional): Prior distribution for the parameters. Defaults to None. - warmup (int, optional): Number of warmup steps before actual sampling begins. Defaults to 100. - hmc_kwargs (dict, optional): Additional keyword arguments for the HMC sampler. Defaults to {}. - mcmc_kwargs (dict, optional): Additional keyword arguments for the MCMC process. Defaults to {}. + **Args:** + - `max_iter` (int, optional): The number of sampling steps to perform. Defaults to 1000. + - `epsilon` (float, optional): The length of the integration step to perform for each leapfrog iteration. The momentum update will be of order epsilon * score. Defaults to 1e-5. + - `leapfrog_steps` (int, optional): Number of steps to perform with leapfrog integrator per sample of the HMC. Defaults to 10. + - `inv_mass` (float or array, optional): Inverse Mass matrix (covariance matrix) which can tune the behavior in each dimension to ensure better mixing when sampling. Defaults to the identity. + - `progress_bar` (bool, optional): Whether to display a progress bar during sampling. Defaults to True. + - `prior` (distribution, optional): Prior distribution for the parameters. Defaults to None. + - `warmup` (int, optional): Number of warmup steps before actual sampling begins. Defaults to 100. + - `hmc_kwargs` (dict, optional): Additional keyword arguments for the HMC sampler. Defaults to {}. + - `mcmc_kwargs` (dict, optional): Additional keyword arguments for the MCMC process. Defaults to {}. """ @@ -122,12 +119,9 @@ def fit( Records the chain for later examination. - Args: + **Args:** state (torch.Tensor, optional): Model parameters as a 1D tensor. - Returns: - HMC: An instance of the HMC class with updated chain. - """ def step(model, prior): diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 7b569fcb..076554cc 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -21,24 +21,21 @@ class Iter(BaseOptimizer): """Optimizer wrapper that performs optimization iteratively. - This optimizer applies a different optimizer to a group model iteratively. - It can be used for complex fits or when the number of models to fit is too large to fit in memory. - - Args: - model: An `AstroPhot_Model` object to perform optimization on. - method: The optimizer class to apply at each iteration step. - initial_state: Optional initial state for optimization, defaults to None. - max_iter: Maximum number of iterations, defaults to 100. - method_kwargs: Keyword arguments to pass to `method`. - **kwargs: Additional keyword arguments. - - Attributes: - ndf: Degrees of freedom of the data. - method: The optimizer class to apply at each iteration step. Default: Levenberg-Marquardt - method_kwargs: Keyword arguments to pass to `method`. - iteration: The number of iterations performed. - lambda_history: A list of the states at each iteration step. - loss_history: A list of the losses at each iteration step + This optimizer applies the LM optimizer to a group model iteratively one + model at a time. It can be used for complex fits or when the number of + models to fit is too large to fit in memory. Note that it will iterate + through the group model, but if models within the group are themselves group + models, then they will be optimized as a whole. This gives some flexibility + to structure the models in a useful way. + + If not given, the `lm_kwargs` will be set to a relative tolerance of 1e-3 + and a maximum of 15 iterations. This is to allow for faster convergence, it + is not worthwhile for a single model to spend lots of time optimizing when + its neighbors havent converged. + + **Args:** + - `max_iter`: Maximum number of iterations, defaults to 100. + - `lm_kwargs`: Keyword arguments to pass to `LM` optimizer. """ def __init__( @@ -48,7 +45,7 @@ def __init__( max_iter: int = 100, lm_kwargs: Dict[str, Any] = {}, **kwargs: Dict[str, Any], - ) -> None: + ): super().__init__(model, initial_state, max_iter=max_iter, **kwargs) self.current_state = model.build_params_array() @@ -65,12 +62,9 @@ def __init__( # subtract masked pixels from degrees of freedom self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() - def sub_step(self, model: Model, update_uncertainty=False) -> None: + def sub_step(self, model: Model, update_uncertainty=False): """ Perform optimization for a single model. - - Args: - model: The model to perform optimization on. """ self.Y -= model() initial_values = model.target.copy() @@ -81,7 +75,7 @@ def sub_step(self, model: Model, update_uncertainty=False) -> None: config.logger.info(res.message) model.target = initial_values - def step(self) -> None: + def step(self): """ Perform a single iteration of optimization. """ @@ -133,6 +127,9 @@ def step(self) -> None: self.iteration += 1 def fit(self) -> BaseOptimizer: + """ + Perform the iterative fitting process until convergence or maximum iterations reached. + """ self.iteration = 0 self.Y = self.model(params=self.current_state) start_fit = time() diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index 3faa4e74..0ae021a7 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -20,6 +20,14 @@ class MHMCMC(BaseOptimizer): """Metropolis-Hastings Markov-Chain Monte-Carlo sampler, based on: https://en.wikipedia.org/wiki/Metropolis-Hastings_algorithm . This is simply a thin wrapper for the Emcee package, which is a well-known MCMC sampler. + + Note that the Emcee sampler requires multiple walkers to sample the + parameter space efficiently. The number of walkers is set to twice the + number of parameters by default, but can be made higher (not lower) if desired. + This is done by passing a 2D array of shape (nwalkers, ndim) to the `fit` method. + + **Args:** + - `likelihood`: The likelihood function to use for the MCMC sampling. Can be "gaussian" or "poisson". Default is "gaussian". """ def __init__( diff --git a/astrophot/fit/minifit.py b/astrophot/fit/minifit.py index 20ad1a1c..350697ea 100644 --- a/astrophot/fit/minifit.py +++ b/astrophot/fit/minifit.py @@ -12,6 +12,22 @@ class MiniFit(BaseOptimizer): + """MiniFit optimizer that applies a fitting method to a downsampled version + of the model's target image. + + This is useful for quickly optimizing parameters on a smaller scale before + applying them to the full resolution image. With fewer pixels, the optimization + can be faster and more efficient, especially for large images. + + This Optimizer can wrap any optimizer that follows the BaseOptimizer interface. + + **Args:** + - `downsample_factor`: Factor by which to downsample the target image. Default is 2. + - `max_pixels`: Maximum number of pixels in the downsampled image. Default is 10000. + - `method`: The optimizer method to use, e.g., `LM` for Levenberg-Marquardt. Default is `LM`. + - `method_kwargs`: Additional keyword arguments to pass to the optimizer method. + """ + def __init__( self, model: Model, diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py index 67adfcdb..af03552e 100644 --- a/astrophot/fit/scipy_fit.py +++ b/astrophot/fit/scipy_fit.py @@ -5,12 +5,25 @@ from .base import BaseOptimizer from .. import config -from ..errors import OptimizeStopSuccess __all__ = ("ScipyFit",) class ScipyFit(BaseOptimizer): + """Scipy-based optimizer for fitting models to data using various + optimization methods. + + The optimizer uses the `scipy.optimize.minimize` function to perform the + fitting. The Scipy package is widely used and well tested for optimization + tasks. It supports a variety of methods, however only a subset allow users to + define boundaries for the parameters. This wrapper is only for those methods. + + **Args:** + - `model`: The model to fit, which should be an instance of `Model`. + - `initial_state`: Initial guess for the model parameters as a 1D tensor. + - `method`: The optimization method to use. Default is "Nelder-Mead", but can be set to any of: "Nelder-Mead", "L-BFGS-B", "TNC", "SLSQP", "Powell", or "trust-constr". + - `ndf`: Optional number of degrees of freedom for the fit. If not provided, it is calculated as the number of data points minus the number of parameters. + """ def __init__( self, @@ -19,68 +32,23 @@ def __init__( method: Literal[ "Nelder-Mead", "L-BFGS-B", "TNC", "SLSQP", "Powell", "trust-constr" ] = "Nelder-Mead", + likelihood: Literal["gaussian", "poisson"] = "gaussian", ndf=None, **kwargs, ): super().__init__(model, initial_state, **kwargs) self.method = method - # mask - fit_mask = self.model.fit_mask() - if isinstance(fit_mask, tuple): - fit_mask = torch.cat(tuple(FM.flatten() for FM in fit_mask)) - else: - fit_mask = fit_mask.flatten() - if torch.sum(fit_mask).item() == 0: - fit_mask = None - - if model.target.has_mask: - mask = self.model.target[self.fit_window].flatten("mask") - if fit_mask is not None: - mask = mask | fit_mask - self.mask = ~mask - elif fit_mask is not None: - self.mask = ~fit_mask - else: - self.mask = torch.ones_like( - self.model.target[self.fit_window].flatten("data"), dtype=torch.bool - ) - if self.mask is not None and torch.sum(self.mask).item() == 0: - raise OptimizeStopSuccess("No data to fit. All pixels are masked") - - # Initialize optimizer attributes - self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] - - # 1 / (sigma^2) - kW = kwargs.get("W", None) - if kW is not None: - self.W = torch.as_tensor(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ - self.mask - ] - elif model.target.has_variance: - self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] - else: - self.W = torch.ones_like(self.Y) - - # The forward model which computes the output image given input parameters - self.forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[self.mask] - # Compute the jacobian in representation units (defined for -inf, inf) - self.jacobian = lambda x: model.jacobian(window=self.fit_window, params=x).flatten("data")[ - self.mask - ] - - # variable to store covariance matrix if it is ever computed - self._covariance_matrix = None + self.likelihood = likelihood # Degrees of freedom if ndf is None: - self.ndf = max(1.0, len(self.Y) - len(self.current_state)) + sub_target = self.model.target[self.model.window] + ndf = sub_target.flatten("data").numel() - torch.sum(sub_target.flatten("mask")).item() + self.ndf = max(1.0, ndf - len(self.current_state)) else: self.ndf = ndf - def chi2_ndf(self, x): - return torch.sum(self.W * (self.Y - self.forward(x)) ** 2) / self.ndf - def numpy_bounds(self): """Convert the model's parameter bounds to a format suitable for scipy.optimize.""" bounds = [] @@ -102,12 +70,22 @@ def numpy_bounds(self): bounds.append(tuple(bound)) return bounds + def density(self, state: Sequence) -> float: + if self.likelihood == "gaussian": + return -self.model.gaussian_log_likelihood( + torch.tensor(state, dtype=config.DTYPE, device=config.DEVICE) + ).item() + elif self.likelihood == "poisson": + return -self.model.poisson_log_likelihood( + torch.tensor(state, dtype=config.DTYPE, device=config.DEVICE) + ).item() + else: + raise ValueError(f"Unknown likelihood type: {self.likelihood}") + def fit(self): res = minimize( - lambda x: self.chi2_ndf( - torch.tensor(x, dtype=config.DTYPE, device=config.DEVICE) - ).item(), + lambda x: self.density(x), self.current_state, method=self.method, bounds=self.numpy_bounds(), @@ -120,7 +98,7 @@ def fit(self): self.current_state = torch.tensor(res.x, dtype=config.DTYPE, device=config.DEVICE) if self.verbose > 0: config.logger.info( - f"Final Chi^2/DoF: {self.chi2_ndf(self.current_state):.6g}. Converged: {self.message}" + f"Final 2NLL/DoF: {2*self.density(self.current_state)/self.ndf:.6g}. Converged: {self.message}" ) self.model.fill_dynamic_values(self.current_state) diff --git a/astrophot/plots/__init__.py b/astrophot/plots/__init__.py index 3ee6dc30..2981a510 100644 --- a/astrophot/plots/__init__.py +++ b/astrophot/plots/__init__.py @@ -2,7 +2,6 @@ radial_light_profile, radial_median_profile, ray_light_profile, - wedge_light_profile, warp_phase_profile, ) from .image import target_image, model_image, residual_image, model_window, psf_image @@ -13,7 +12,6 @@ "radial_light_profile", "radial_median_profile", "ray_light_profile", - "wedge_light_profile", "warp_phase_profile", "target_image", "model_image", diff --git a/astrophot/plots/diagnostic.py b/astrophot/plots/diagnostic.py index a78392be..1e0df730 100644 --- a/astrophot/plots/diagnostic.py +++ b/astrophot/plots/diagnostic.py @@ -19,7 +19,19 @@ def covariance_matrix( **kwargs, ): """ - Create a covariance matrix plot.""" + Create a covariance matrix plot. Creates a corner plot with ellipses representing the covariance between parameters. + + **Args:** + - `covariance_matrix` (np.ndarray): Covariance matrix of shape (n_params, n_params). + - `mean` (np.ndarray): Mean values of the parameters, shape (n_params,). + - `labels` (list, optional): Labels for the parameters. + - `figsize` (tuple, optional): Size of the figure. Default is (10, 10). + - `reference_values` (np.ndarray, optional): Reference values for the parameters, used to draw vertical and horizontal lines. Typically these are the true values of the parameters. + - `ellipse_colors` (str or list, optional): Color for the ellipses. Default is `main_pallet["primary1"]`. + - `showticks` (bool, optional): Whether to show ticks on the axes. Default is True. + + returns the fig and ax objects created to allow further customization by the user. + """ num_params = covariance_matrix.shape[0] fig, axes = plt.subplots(num_params, num_params, figsize=figsize) plt.subplots_adjust(wspace=0.0, hspace=0.0) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index a845ce83..f0470658 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Literal, Optional, Union import numpy as np import torch @@ -8,32 +8,31 @@ from scipy.stats import iqr from ..models import GroupModel, PSFModel, PSFGroupModel -from ..image import ImageList, WindowList +from ..image import ImageList, WindowList, PSFImage from .. import config from ..utils.conversions.units import flux_to_sb from ..utils.decorators import ignore_numpy_warnings from .visuals import * -__all__ = ["target_image", "psf_image", "model_image", "residual_image", "model_window"] +__all__ = ("target_image", "psf_image", "model_image", "residual_image", "model_window") @ignore_numpy_warnings def target_image(fig, ax, target, window=None, **kwargs): """ - This function is used to display a target image using the provided figure and axes. - - Args: - fig (matplotlib.figure.Figure): The figure object in which the target image will be displayed. - ax (matplotlib.axes.Axes): The axes object on which the target image will be plotted. - target (Image or Image_List): The image or list of images to be displayed. - window (Window, optional): The window through which the image is viewed. If `None`, the window of the - provided `target` is used. Defaults to `None`. - **kwargs: Arbitrary keyword arguments. - - Returns: - fig (matplotlib.figure.Figure): The figure object containing the displayed target image. - ax (matplotlib.axes.Axes): The axes object containing the displayed target image. + This function is used to display a target image using the provided figure + and axes. The target is plotted using histogram equalization for better + visibility of the image data for the faint areas of the image, while it uses + log scale normalization for the bright areas. + + **Args:** + - `fig` (matplotlib.figure.Figure): The figure object in which the target image will be displayed. + - `ax` (matplotlib.axes.Axes): The axes object on which the target image will be plotted. + - `target` (Image or Image_List): The image or list of images to be displayed. + - `window` (Window, optional): The window through which the image is viewed. If `None`, the window of the + provided `target` is used. Defaults to `None`. + - **kwargs: Arbitrary keyword arguments. Note: If the `target` is an `Image_List`, this function will recursively call itself for each image in the list. @@ -58,8 +57,6 @@ def target_image(fig, ax, target, window=None, **kwargs): noise = iqr(dat[np.isfinite(dat)], rng=(16, 84)) / 2 if noise == 0: noise = np.nanstd(dat) - vmin = sky - 5 * noise - vmax = sky + 5 * noise if kwargs.get("linear", False): im = ax.pcolormesh( @@ -108,13 +105,22 @@ def target_image(fig, ax, target, window=None, **kwargs): def psf_image( fig, ax, - psf, - cmap_levels=None, - vmin=None, - vmax=None, + psf: Union[PSFImage, PSFModel, PSFGroupModel], + cmap_levels: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ): - """For plotting PSF images, or the output of a PSF model.""" + """For plotting PSF images, or the output of a PSF model. + + **Args:** + - `fig` (matplotlib.figure.Figure): The figure object in which the PSF image will be displayed. + - `ax` (matplotlib.axes.Axes): The axes object on which the PSF image will be plotted. + - `psf` (PSFImage or PSFModel or PSFGroupModel): The PSF model or group model to be displayed. + - `cmap_levels` (int, optional): The number of discrete levels to convert the continuous color map to. If not `None`, the color map is converted to a ListedColormap with the specified number of levels. Defaults to `None`. + - `vmin` (float, optional): The minimum value for the color scale. Defaults to `None`. + - `vmax` (float, optional): The maximum value for the color scale. Defaults to `None`. + """ if isinstance(psf, (PSFModel, PSFGroupModel)): psf = psf() # recursive call for target image list @@ -164,36 +170,31 @@ def model_image( sample_image=None, window=None, target=None, - showcbar=True, - target_mask=False, - cmap_levels=None, - magunits=True, + showcbar: bool = True, + target_mask: bool = False, + cmap_levels: Optional[int] = None, + magunits: bool = True, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ): """ This function is used to generate a model image and display it using the provided figure and axes. - Args: - fig (matplotlib.figure.Figure): The figure object in which the image will be displayed. - ax (matplotlib.axes.Axes): The axes object on which the image will be plotted. - model (Model): The model object used to generate a model image if `sample_image` is not provided. - sample_image (Image or Image_List, optional): The image or list of images to be displayed. - If `None`, a model image is generated using the provided `model`. Defaults to `None`. - window (Window, optional): The window through which the image is viewed. If `None`, the window of the - provided `model` is used. Defaults to `None`. - target (Target, optional): The target or list of targets for the image or image list. - If `None`, the target of the `model` is used. Defaults to `None`. - showcbar (bool, optional): Whether to show the color bar. Defaults to `True`. - target_mask (bool, optional): Whether to apply the mask of the target. If `True` and if the target has a mask, - the mask is applied to the image. Defaults to `False`. - cmap_levels (int, optional): The number of discrete levels to convert the continuous color map to. - If not `None`, the color map is converted to a ListedColormap with the specified number of levels. - Defaults to `None`. - **kwargs: Arbitrary keyword arguments. These are used to override the default imshow_kwargs. - - Returns: - fig (matplotlib.figure.Figure): The figure object containing the displayed image. - ax (matplotlib.axes.Axes): The axes object containing the displayed image. + **Args:** + - `fig` (matplotlib.figure.Figure): The figure object in which the image will be displayed. + - `ax` (matplotlib.axes.Axes): The axes object on which the image will be plotted. + - `model` (Model): The model object used to generate a model image if `sample_image` is not provided. + - `sample_image` (Image or Image_List, optional): The image or list of images to be displayed. If `None`, a model image is generated using the provided `model`. Defaults to `None`. + - `window` (Window, optional): The window through which the image is viewed. If `None`, the window of the provided `model` is used. Defaults to `None`. + - `target` (Target, optional): The target or list of targets for the image or image list. If `None`, the target of the `model` is used. Defaults to `None`. + - `showcbar` (bool, optional): Whether to show the color bar. Defaults to `True`. + - `target_mask` (bool, optional): Whether to apply the mask of the target. If `True` and if the target has a mask, the mask is applied to the image. Defaults to `False`. + - `cmap_levels` (int, optional): The number of discrete levels to convert the continuous color map to. If not `None`, the color map is converted to a ListedColormap with the specified number of levels. Defaults to `None`. + - `magunits` (bool, optional): Whether to convert the image to surface brightness units. If `True`, the zeropoint of the target is used to convert the image to surface brightness units. Defaults to `True`. + - `vmin` (float, optional): The minimum value for the color scale. Defaults to `None`. + - `vmax` (float, optional): The maximum value for the color scale. Defaults to `None`. + - **kwargs: Arbitrary keyword arguments. These are used to override the default imshow_kwargs. Note: If the `sample_image` is an `Image_List`, this function will recursively call itself for each image in the list, @@ -255,8 +256,6 @@ def model_image( sample_image = flux_to_sb(sample_image, target.pixel_area.item(), target.zeropoint.item()) kwargs["cmap"] = kwargs["cmap"].reversed() else: - vmin = kwargs.pop("vmin", None) - vmax = kwargs.pop("vmax", None) kwargs = { "norm": matplotlib.colors.LogNorm( vmin=vmin, vmax=vmax @@ -307,31 +306,20 @@ def residual_image( ): """ This function is used to calculate and display the residuals of a model image with respect to a target image. - The residuals are calculated as the difference between the target image and the sample image. - - Args: - fig (matplotlib.figure.Figure): The figure object in which the residuals will be displayed. - ax (matplotlib.axes.Axes): The axes object on which the residuals will be plotted. - model (Model): The model object used to generate a model image if `sample_image` is not provided. - target (Target or Image_List, optional): The target or list of targets for the image or image list. - If `None`, the target of the `model` is used. Defaults to `None`. - sample_image (Image or Image_List, optional): The image or list of images from which residuals will be calculated. - If `None`, a model image is generated using the provided `model`. Defaults to `None`. - showcbar (bool, optional): Whether to show the color bar. Defaults to `True`. - window (Window or Window_List, optional): The window through which the image is viewed. If `None`, the window of the - provided `model` is used. Defaults to `None`. - center_residuals (bool, optional): Whether to subtract the median of the residuals. If `True`, the median is subtracted - from the residuals. Defaults to `False`. - clb_label (str, optional): The label for the colorbar. If `None`, a default label is used based on the normalization of the - residuals. Defaults to `None`. - normalize_residuals (bool, optional): Whether to normalize the residuals. If `True`, residuals are divided by the square root - of the variance of the target. Defaults to `False`. - sample_full_image: If True, every model will be sampled on the full image window. If False (default) each model will only be sampled in its fitting window. - **kwargs: Arbitrary keyword arguments. These are used to override the default imshow_kwargs. - - Returns: - fig (matplotlib.figure.Figure): The figure object containing the displayed residuals. - ax (matplotlib.axes.Axes): The axes object containing the displayed residuals. + The residuals are calculated as the difference between the target image and the sample image and may be normalized by the standard deviation. + + **Args:** + - `fig` (matplotlib.figure.Figure): The figure object in which the residuals will be displayed. + - `ax` (matplotlib.axes.Axes): The axes object on which the residuals will be plotted. + - `model` (Model): The model object used to generate a model image if `sample_image` is not provided. + - `target` (Target or Image_List, optional): The target or list of targets for the image or image list. If `None`, the target of the `model` is used. Defaults to `None`. + - `sample_image` (Image or Image_List, optional): The image or list of images from which residuals will be calculated. If `None`, a model image is generated using the provided `model`. Defaults to `None`. + - `showcbar` (bool, optional): Whether to show the color bar. Defaults to `True`. + - `window` (Window or Window_List, optional): The window through which the image is viewed. If `None`, the window of the provided `model` is used. Defaults to `None`. + - `clb_label` (str, optional): The label for the colorbar. If `None`, a default label is used based on the normalization of the residuals. Defaults to `None`. + - `normalize_residuals` (bool, optional): Whether to normalize the residuals. If `True`, residuals are divided by the square root of the variance of the target. Defaults to `False`. + - `scaling` (str, optional): The scaling method for the residuals. Options are "arctan", "clip", or "none". arctan will show all residuals, though squish high values to make the fainter residuals more visible, clip will show the residuals in linear space but remove any values above/below 5 sigma, none does no scaling and simply shows the residuals in linear space. Defaults to "arctan". + - `**kwargs`: Arbitrary keyword arguments. These are used to override the default imshow_kwargs. Note: If the `window`, `target`, or `sample_image` are lists, this function will recursively call itself for each element in the list, @@ -429,7 +417,17 @@ def residual_image( @ignore_numpy_warnings def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): - """Used for plotting the window(s) of a model on an image.""" + """Used for plotting the window(s) of a model on a target image. These + windows bound the region that a model will be evaluated/fit to. + + **Args:** + - `fig` (matplotlib.figure.Figure): The figure object in which the model window will be displayed. + - `ax` (matplotlib.axes.Axes): The axes object on which the model window will be plotted. + - `model` (Model): The model object whose window will be displayed. + - `target` (Target or Image_List, optional): The target or list of targets for the image or image list. If `None`, the target of the `model` is used. Defaults to `None`. + - `rectangle_linewidth` (int, optional): The linewidth of the rectangle drawn around the model window. Defaults to 2. + - **kwargs: Arbitrary keyword arguments. These are used to override the default rectangle properties. + """ if target is None: target = model.target if isinstance(ax, np.ndarray): @@ -463,6 +461,7 @@ def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): fill=False, linewidth=rectangle_linewidth, edgecolor=main_pallet["secondary1"], + **kwargs, ) ) else: @@ -486,6 +485,7 @@ def model_window(fig, ax, model, target=None, rectangle_linewidth=2, **kwargs): fill=False, linewidth=rectangle_linewidth, edgecolor=main_pallet["secondary1"], + **kwargs, ) ) diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index ad3a4098..609b32c0 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -32,7 +32,17 @@ def radial_light_profile( plot_kwargs={}, ): """ - Used to plot the brightness profile as a function of radius for modes which define a `radial_model` + Used to plot the brightness profile as a function of radius for models which define a `radial_model`. + + **Args:** + - `fig`: matplotlib figure object + - `ax`: matplotlib axis object + - `model` (Model): Model object from which to plot the radial profile. + - `rad_unit` (str): The name of the radius units to plot. If you select "pixel" then the plot will work in pixel units (physical radii divided by pixelscale) if you choose any other string then it will remain in the physical units of the image and the axis label will be whatever you set the value to. Default: "arcsec". Options: "arcsec", "pixel" + - `extend_profile` (float): The factor by which to extend the profile beyond the maximum radius of the model's window. Default: 1.0 + - `R0` (float): The starting radius for the profile. Default: 0.0 + - `resolution` (int): The number of points to use in the profile. Default: 1000 + - `plot_kwargs` (dict): Additional keyword arguments to pass to the plot function, such as `linewidth`, `color`, etc. """ xx = torch.linspace( R0, @@ -92,16 +102,14 @@ def radial_median_profile( representation of the image data if one were to simply average the pixels along isophotes. - Args: - fig: matplotlib figure object - ax: matplotlib axis object - model (AstroPhot_Model): Model object from which to determine the radial binning. Also provides the target image to extract the data - count_limit (int): The limit of pixels in a bin, below which uncertainties are not computed. Default: 10 - return_profile (bool): Instead of just returning the fig and ax object, will return the extracted profile formatted as: Rbins (the radial bin edges), medians (the median in each bin), scatter (the 16-84 quartile range / 2), count (the number of pixels in each bin). Default: False - rad_unit (str): The name of the radius units to plot. If you select "pixel" then the plot will work in pixel units (physical radii divided by pixelscale) if you choose any other string then it will remain in the physical units of the image and the axis label will be whatever you set the value to. Default: "arcsec". Options: "arcsec", "pixel" - bin_scale (float): The geometric scaling factor for the binning, each bin will be this much larger than the previous. Default: 0.1 - min_bin_width (float): The minimum width of a bin in pixel units, default is 2 so that each bin will have some data to compute the median with. Default: 2 - doassert (bool): If any requirements are imposed on which kind of profile can be plotted, this activates them. Default: True + **Args:** + - `fig`: matplotlib figure object + - `ax`: matplotlib axis object + - `model` (AstroPhot_Model): Model object from which to determine the radial binning. Also provides the target image to extract the data + - `count_limit` (int): The limit of pixels in a bin, below which uncertainties are not computed. Default: 10 + - `return_profile` (bool): Instead of just returning the fig and ax object, will return the extracted profile formatted as: Rbins (the radial bin edges), medians (the median in each bin), scatter (the 16-84 quartile range / 2), count (the number of pixels in each bin). Default: False + - `rad_unit` (str): The name of the radius units to plot. If you select "pixel" then the plot will work in pixel units (physical radii divided by pixelscale) if you choose any other string then it will remain in the physical units of the image and the axis label will be whatever you set the value to. Default: "arcsec". Options: "arcsec", "pixel" + - `plot_kwargs` (dict): Additional keyword arguments to pass to the plot function, such as `linewidth`, `color`, etc. """ @@ -184,7 +192,15 @@ def ray_light_profile( resolution=1000, ): """ - Used for plotting ray type models which define a `iradial_model` method. These have multiple radial profiles. + Used for plotting ray (wedge) type models which define a `iradial_model` method. These have multiple radial profiles. + + **Args:** + - `fig`: matplotlib figure object + - `ax`: matplotlib axis object + - `model` (Model): Model object from which to plot the radial profile. + - `rad_unit` (str): The name of the radius units to plot. + - `extend_profile` (float): The factor by which to extend the profile beyond the maximum radius of the model's window. Default: 1.0 + - `resolution` (int): The number of points to use in the profile. Default: 1000 """ xx = torch.linspace( 0, @@ -212,41 +228,6 @@ def ray_light_profile( return fig, ax -def wedge_light_profile( - fig, - ax, - model: Model, - rad_unit="arcsec", - extend_profile=1.0, - resolution=1000, -): - """same as ray light profile but for wedges""" - xx = torch.linspace( - 0, - max(model.window.shape) * model.target.pixelscale * extend_profile / 2, - int(resolution), - dtype=config.DTYPE, - device=config.DEVICE, - ) - for r in range(model.segments): - if model.segments <= 3: - col = main_pallet[f"primary{r+1}"] - else: - col = cmap_grad(r / model.segments) - with torch.no_grad(): - ax.plot( - xx.detach().cpu().numpy(), - np.log10(model.iradial_model(r, xx, params=()).detach().cpu().numpy()), - linewidth=2, - color=col, - label=f"{model.name} profile {r}", - ) - ax.set_ylabel("log$_{10}$(flux)") - ax.set_xlabel(f"Radius [{rad_unit}]") - - return fig, ax - - def warp_phase_profile(fig, ax, model: Model, rad_unit="arcsec"): """Used to plot the phase profile of a warp model. This gives the axis ratio and position angle as a function of radius.""" ax.plot( diff --git a/docs/source/astrophotdocs/index.rst b/docs/source/astrophotdocs/index.rst index c37a08e1..9e7ad58f 100644 --- a/docs/source/astrophotdocs/index.rst +++ b/docs/source/astrophotdocs/index.rst @@ -5,7 +5,7 @@ AstroPhot Docstrings Here you will find all of the AstroPhot class and method docstrings, built using markdown formatting. These are useful for understanding the details of a given model and can also be accessed via the python help command -```help(ap.object)```. For the AstroPhot models, the docstrings are a +```help(ap.object)```. For the AstroPhot ``ap.Model`` objects, the docstrings are a combination of the various base-classes and mixins that make them up. They are very detailed, but can be a bit awkward in their formatting, the good news is that a lot of useful information is available there! diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index 061396c4..fef82261 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -1032,7 +1032,7 @@ "\n", "fig, ax = plt.subplots(1, 2, figsize=(14, 6))\n", "ap.plots.model_image(fig, ax[0], M)\n", - "ap.plots.wedge_light_profile(fig, ax[1], M)\n", + "ap.plots.ray_light_profile(fig, ax[1], M)\n", "ax[0].set_title(M.name)\n", "plt.show()" ] diff --git a/make_docs.py b/make_docs.py index a62a6d44..f9670b26 100644 --- a/make_docs.py +++ b/make_docs.py @@ -61,13 +61,14 @@ def gather_docs(module, module_only=False): if attrobj.__doc__ is None: continue sig = str(signature(attrobj)).replace("self,", "").replace("self", "") - subfuncs.append(f"> **method**: {attr}{sig}\n\n" + cleandoc(attrobj.__doc__)) + subfuncs.append(f"**method:** {attr}{sig}\n\n" + cleandoc(attrobj.__doc__)) if len(subfuncs) > 1: docs[name] = "\n\n".join(subfuncs) elif isinstance(obj, FunctionType): if obj.__doc__ is None: continue - docs[name] = cleandoc(obj.__doc__) + sig = str(signature(obj)) + docs[name] = "**signature:** " + name + sig + "\n\n" + cleandoc(obj.__doc__) elif isinstance(obj, ModuleType): docs[name] = gather_docs(obj) else: From e01fcfd75617482274003cde2c826edec3b65640 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 09:43:02 -0400 Subject: [PATCH 109/191] fix docs for image objects --- astrophot/image/cmos_image.py | 2 ++ astrophot/image/image_object.py | 22 ++++++++++-- astrophot/image/jacobian_image.py | 10 ++++++ astrophot/image/mixins/data_mixin.py | 35 +++++++++++-------- astrophot/image/mixins/sip_mixin.py | 1 + astrophot/image/model_image.py | 2 ++ astrophot/image/psf_image.py | 16 ++------- astrophot/image/sip_image.py | 2 ++ astrophot/image/target_image.py | 52 +++++++++++++++------------- docs/source/astrophotdocs/index.rst | 2 +- 10 files changed, 88 insertions(+), 56 deletions(-) diff --git a/astrophot/image/cmos_image.py b/astrophot/image/cmos_image.py index 700b0305..518574c6 100644 --- a/astrophot/image/cmos_image.py +++ b/astrophot/image/cmos_image.py @@ -6,6 +6,8 @@ class CMOSModelImage(CMOSMixin, ModelImage): + """A ModelImage with CMOS-specific functionality.""" + def fluxdensity_to_flux(self): # CMOS pixels only sensitive in sub area, so scale the flux density self._data = self.data * self.pixel_area * self.subpixel_scale**2 diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 72a7efd2..53706194 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -23,6 +23,23 @@ class Image(Module): arithmetic operations with other image objects while preserving logical image boundaries. It also provides methods for determining the coordinate locations of pixels + + **Args:** + - `data`: The image data as a tensor of pixel values. If not provided, a tensor of zeros will be created. + - `zeropoint`: The zeropoint of the image, which is used to convert from pixel flux to magnitude. + - `crpix`: The reference pixel coordinates in the image, which is used to convert from pixel coordinates to tangent plane coordinates. + - `pixelscale`: The side length of a pixel, used to create a simple diagonal CD matrix. + - `wcs`: An optional Astropy WCS object to initialize the image. + - `filename`: The filename to load the image from. If provided, the image will be loaded from the file. + - `hduext`: The HDU extension to load from the FITS file specified in `filename`. + - `identity`: An optional identity string for the image. + + these parameters are added to the optimization model: + + **Parameters:** + - `crval`: The reference coordinate of the image in degrees [RA, DEC]. + - `crtan`: The tangent plane coordinate of the image in arcseconds [x, y]. + - `CD`: The coordinate transformation matrix in arcseconds/pixel. """ default_CD = ((1.0, 0.0), (0.0, 1.0)) @@ -347,9 +364,8 @@ def reduce(self, scale: int, **kwargs): pixels are condensed, but the pixel size is increased correspondingly. - Parameters: - scale: factor by which to condense the image pixels. Each scale X scale region will be summed [int] - + **Args:** + - `scale` (int): The scale factor by which to reduce the image. """ if not isinstance(scale, int) and not ( isinstance(scale, torch.Tensor) and scale.dtype is torch.int32 diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 8534c023..91406d5d 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -55,6 +55,16 @@ def __iadd__(self, other: "JacobianImage"): ] return self + def plane_to_world(self, x, y): + raise NotImplementedError( + "JacobianImage does not support plane_to_world conversion. There is no meaningful world position of a PSF image." + ) + + def world_to_plane(self, ra, dec): + raise NotImplementedError( + "JacobianImage does not support world_to_plane conversion. There is no meaningful world position of a PSF image." + ) + ###################################################################### class JacobianImageList(ImageList): diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 99e82056..c17f98b6 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -12,6 +12,20 @@ class DataMixin: + """Mixin for data handling in image objects. + + This mixin provides functionality for handling variance and mask, + as well as other ancillary data. + + **Args:** + - `mask`: A boolean mask indicating which pixels to ignore. + - `std`: Standard deviation of the image pixels. + - `variance`: Variance of the image pixels. + - `weight`: Weights for the image pixels. + + Note that only one of `std`, `variance`, or `weight` should be + provided at a time. If multiple are provided, an error will be raised. + """ def __init__( self, @@ -57,8 +71,7 @@ def std(self): stand in as the standard deviation values. The standard deviation is not stored directly, instead it is - computed as :math:`\\sqrt{1/W}` where :math:`W` is the - weights. + computed as $\\sqrt{1/W}$ where $W$ is the weights. """ if self.has_variance: @@ -96,7 +109,7 @@ def variance(self): the variance values. The variance is not stored directly, instead it is - computed as :math:`\\frac{1}{W}` where :math:`W` is the + computed as $\\frac{1}{W}$ where $W$ is the weights. """ @@ -138,24 +151,18 @@ def weight(self): likelihood. Most commonly this shows up as a :math:`\\chi^2` like: - .. math:: - - \\chi^2 = (\\vec{y} - \\vec{f(\\theta)})^TW(\\vec{y} - \\vec{f(\\theta)}) + $$\\chi^2 = (\\vec{y} - \\vec{f(\\theta)})^TW(\\vec{y} - \\vec{f(\\theta)})$$ which can be optimized to find parameter values. Using the Jacobian, which in this case is the derivative of every pixel wrt every parameter, the weight matrix also appears in the gradient: - .. math:: - - \\vec{g} = J^TW(\\vec{y} - \\vec{f(\\theta)}) + $$\\vec{g} = J^TW(\\vec{y} - \\vec{f(\\theta)})$$ and the hessian approximation used in Levenberg-Marquardt: - .. math:: - - H \\approx J^TWJ + $$H \\approx J^TWJ$$ """ if self.has_weight: @@ -303,10 +310,10 @@ def load(self, filename: str, hduext: int = 0): return hdulist def reduce(self, scale: int, **kwargs) -> Image: - """Returns a new `Target_Image` object with a reduced resolution + """Returns a new `TargetImage` object with a reduced resolution compared to the current image. `scale` should be an integer indicating how much to reduce the resolution. If the - `Target_Image` was originally (48,48) pixels across with a + `TargetImage` was originally (48,48) pixels across with a pixelscale of 1 and `reduce(2)` is called then the image will be (24,24) pixels and the pixelscale will be 2. If `reduce(3)` is called then the returned image will be (16,16) pixels diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index b802b77d..6fd01d57 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -10,6 +10,7 @@ class SIPMixin: + """A mixin class for SIP (Simple Image Polynomial) distortion model.""" expect_ctype = (("RA---TAN-SIP",), ("DEC--TAN-SIP",)) diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 4ac940d7..3a2d0fdf 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -20,6 +20,8 @@ def fluxdensity_to_flux(self): ###################################################################### class ModelImageList(ImageList): + """A list of ModelImage objects.""" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not all(isinstance(image, (ModelImage, ModelImageList)) for image in self.images): diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index e33c1bd6..f46aa3d6 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -14,20 +14,10 @@ class PSFImage(DataMixin, Image): """Image object which represents a model of PSF (Point Spread Function). - PSF_Image inherits from the base Image class and represents the model of a point spread function. + PSFImage inherits from the base Image class and represents the model of a point spread function. The point spread function characterizes the response of an imaging system to a point source or point object. - The shape of the PSF data must be odd. - - Attributes: - data (torch.Tensor): The image data of the PSF. - identity (str): The identity of the image. Default is None. - - Methods: - psf_border_int: Calculates and returns the convolution border size of the PSF image in integer format. - psf_border: Calculates and returns the convolution border size of the PSF image in the units of pixelscale. - _save_image_list: Saves the image list to the PSF HDU header. - reduce: Reduces the size of the image using a given scale factor. + The shape of the PSF data should be odd (for your sanity) but this is not enforced. """ def __init__(self, *args, **kwargs): @@ -53,7 +43,7 @@ def jacobian_image( **kwargs, ) -> JacobianImage: """ - Construct a blank `Jacobian_Image` object formatted like this current `PSF_Image` object. Mostly used internally. + Construct a blank `JacobianImage` object formatted like this current `PSFImage` object. Mostly used internally. """ if parameters is None: data = None diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index 9a465a85..f485bc48 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -7,6 +7,8 @@ class SIPModelImage(SIPMixin, ModelImage): + """ + A ModelImage with SIP distortion coefficients.""" def crop(self, pixels: Union[int, Tuple[int, int], Tuple[int, int, int, int]], **kwargs): """ diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index bb184fa0..f10361f9 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -12,10 +12,12 @@ from .. import config from ..errors import InvalidImage from .mixins import DataMixin +from ..utils.decorators import combine_docstrings __all__ = ["TargetImage", "TargetImageList"] +@combine_docstrings class TargetImage(DataMixin, Image): """Image object which represents the data to be fit by a model. It can include a variance image, mask, and PSF as anciliary data which @@ -29,32 +31,32 @@ class TargetImage(DataMixin, Image): Basic usage: - .. code-block:: python + ```{python} + import astrophot as ap - import astrophot as ap + # Create target image + image = ap.image.Target_Image( + data="pixel data", + wcs="astropy WCS object", + variance="pixel uncertainties", + psf="point spread function as PSF_Image object", + mask="True for pixels to ignore", + ) - # Create target image - image = ap.image.Target_Image( - data="pixel data", - wcs="astropy WCS object", - variance="pixel uncertainties", - psf="point spread function as PSF_Image object", - mask=" True for pixels to ignore", - ) + # Display the data + fig, ax = plt.subplots() + ap.plots.target_image(fig, ax, image) + plt.show() - # Display the data - fig, ax = plt.subplots() - ap.plots.target_image(fig, ax, image) - plt.show() + # Save the image + image.save("mytarget.fits") - # Save the image - image.save("mytarget.fits") + # Load the image + image2 = ap.image.Target_Image(filename="mytarget.fits") - # Load the image - image2 = ap.image.Target_Image(filename="mytarget.fits") - - # Make low resolution version - lowrez = image.reduce(2) + # Make low resolution version + lowrez = image.reduce(2) + ``` Some important information to keep in mind. First, providing an `astropy WCS` object is the best way to keep track of coordinates @@ -97,9 +99,9 @@ def has_psf(self) -> bool: @property def psf(self): - """The PSF for the `Target_Image`. This is used to convolve the + """The PSF for the `TargetImage`. This is used to convolve the model with the PSF before evaluating the likelihood. The PSF - should be a `PSF_Image` object or an `AstroPhot` PSF_Model. + should be a `PSFImage` object or an `AstroPhot` PSFModel. If no PSF is provided, then the image will not be convolved with a PSF and the model will be evaluated directly on the @@ -113,12 +115,12 @@ def psf(self): @psf.setter def psf(self, psf): - """Provide a psf for the `Target_Image`. This is stored and passed to + """Provide a psf for the `TargetImage`. This is stored and passed to models which need to be convolved. The PSF doesn't need to have the same pixelscale as the image. It should be some multiple of the resolution of the - `Target_Image` though. So if the image has a pixelscale of 1, + `TargetImage` though. So if the image has a pixelscale of 1, the psf may have a pixelscale of 1, 1/2, 1/3, 1/4 and so on. """ diff --git a/docs/source/astrophotdocs/index.rst b/docs/source/astrophotdocs/index.rst index 9e7ad58f..6d12dec0 100644 --- a/docs/source/astrophotdocs/index.rst +++ b/docs/source/astrophotdocs/index.rst @@ -11,7 +11,7 @@ very detailed, but can be a bit awkward in their formatting, the good news is that a lot of useful information is available there! .. toctree:: - :maxdepth: 3 + :maxdepth: 2 models image From cfa8c40a097d1ecc302e6dfe56ff1971fddd0476 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 10:53:43 -0400 Subject: [PATCH 110/191] fix utils docs --- astrophot/utils/parametric_profiles.py | 114 ++++++++++--------------- 1 file changed, 47 insertions(+), 67 deletions(-) diff --git a/astrophot/utils/parametric_profiles.py b/astrophot/utils/parametric_profiles.py index 7d4cbf16..9e945d9c 100644 --- a/astrophot/utils/parametric_profiles.py +++ b/astrophot/utils/parametric_profiles.py @@ -1,4 +1,5 @@ import numpy as np +from numpy import ndarray from .conversions.functions import sersic_n_to_b __all__ = ( @@ -12,17 +13,17 @@ ) -def sersic_np(R, n, Re, Ie): +def sersic_np(R: ndarray, n: ndarray, Re: ndarray, Ie: ndarray) -> ndarray: """Sersic 1d profile function, works more generally with numpy operations. In the event that impossible values are passed to the function it returns large values to guide optimizers away from such values. - Parameters: - R: Radii array at which to evaluate the sersic function - n: sersic index restricted to n > 0.36 - Re: Effective radius in the same units as R - Ie: Effective surface density + **Args:** + - `R`: Radii array at which to evaluate the sersic function + - `n`: sersic index restricted to n > 0.36 + - `Re`: Effective radius in the same units as R + - `Ie`: Effective surface density """ if np.any(np.array([n, Re, Ie]) <= 0): return np.ones(len(R)) * 1e6 @@ -30,53 +31,54 @@ def sersic_np(R, n, Re, Ie): return Ie * np.exp(-bn * ((R / Re) ** (1 / n) - 1)) -def gaussian_np(R, sigma, I0): +def gaussian_np(R: ndarray, sigma: ndarray, I0: ndarray) -> ndarray: """Gaussian 1d profile function, works more generally with numpy operations. - Parameters: - R: Radii array at which to evaluate the sersic function - sigma: standard deviation of the gaussian in the same units as R - I0: central surface density + **Args:** + - `R`: Radii array at which to evaluate the gaussian function + - `sigma`: standard deviation of the gaussian in the same units as R + - `I0`: central surface density """ return (I0 / np.sqrt(2 * np.pi * sigma**2)) * np.exp(-0.5 * ((R / sigma) ** 2)) -def exponential_np(R, Ie, Re): +def exponential_np(R: ndarray, Ie: ndarray, Re: ndarray) -> ndarray: """Exponential 1d profile function, works more generally with numpy operations. - Parameters: - R: Radii array at which to evaluate the sersic function - Re: Effective radius in the same units as R - Ie: Effective surface density + **Args:** + - `R`: Radii array at which to evaluate the exponential function + - `Ie`: Effective surface density + - `Re`: Effective radius in the same units as R """ return Ie * np.exp(-sersic_n_to_b(1.0) * (R / Re - 1.0)) -def moffat_np(R, n, Rd, I0): +def moffat_np(R: ndarray, n: ndarray, Rd: ndarray, I0: ndarray) -> ndarray: """Moffat 1d profile function, works with numpy operations. - Parameters: - R: Radii tensor at which to evaluate the moffat function - n: concentration index - Rd: scale length in the same units as R - I0: central surface density - + **Args:** + - `R`: Radii array at which to evaluate the moffat function + - `n`: concentration index + - `Rd`: scale length in the same units as R + - `I0`: central surface density """ return I0 / (1 + (R / Rd) ** 2) ** n -def nuker_np(R, Rb, Ib, alpha, beta, gamma): +def nuker_np( + R: ndarray, Rb: ndarray, Ib: ndarray, alpha: ndarray, beta: ndarray, gamma: ndarray +) -> ndarray: """Nuker 1d profile function, works with numpy functions - Parameters: - R: Radii tensor at which to evaluate the nuker function - Ib: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. - Rb: scale length radius - alpha: sharpness of transition between power law slopes - beta: outer power law slope - gamma: inner power law slope + **Args:** + - `R`: Radii tensor at which to evaluate the nuker function + - `Ib`: brightness at the scale length, represented as the log of the brightness divided by pixel scale squared. + - `Rb`: scale length radius + - `alpha`: sharpness of transition between power law slopes + - `beta`: outer power law slope + - `gamma`: inner power law slope """ return ( @@ -87,52 +89,30 @@ def nuker_np(R, Rb, Ib, alpha, beta, gamma): ) -def ferrer_np(R, rout, alpha, beta, I0): +def ferrer_np(R: ndarray, rout: ndarray, alpha: ndarray, beta: ndarray, I0: ndarray) -> ndarray: """ Modified Ferrer profile. - Parameters - ---------- - R : array_like - Radial distance from the center. - rout : float - Outer radius of the profile. - alpha : float - Power-law index. - beta : float - Exponent for the modified Ferrer function. - I0 : float - Central intensity. - - Returns - ------- - array_like - The modified Ferrer profile evaluated at R. + **Args:** + - `R`: Radial distance from the center. + - `rout`: Outer radius of the profile. + - `alpha`: Power-law index. + - `beta`: Exponent for the modified Ferrer function. + - `I0`: Central intensity. """ return (R < rout) * I0 * ((1 - (np.clip(R, 0, rout) / rout) ** (2 - beta)) ** alpha) -def king_np(R, Rc, Rt, alpha, I0): +def king_np(R: ndarray, Rc: ndarray, Rt: ndarray, alpha: ndarray, I0: ndarray) -> ndarray: """ Empirical King profile. - Parameters - ---------- - R : array_like - The radial distance from the center. - Rc : float - The core radius of the profile. - Rt : float - The truncation radius of the profile. - alpha : float - The power-law index of the profile. - I0 : float - The central intensity of the profile. - - Returns - ------- - array_like - The intensity at each radial distance. + **Args:** + - `R`: The radial distance from the center. + - `Rc`: The core radius of the profile. + - `Rt`: The truncation radius of the profile. + - `alpha`: The power-law index of the profile. + - `I0`: The central intensity of the profile. """ beta = 1 / (1 + (Rt / Rc) ** 2) ** (1 / alpha) gamma = 1 / (1 + (R / Rc) ** 2) ** (1 / alpha) From f9d0f665e393973bc2cb80d8286bb2ebb90fa22f Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 18:56:57 -0400 Subject: [PATCH 111/191] fix util docs --- astrophot/utils/conversions/functions.py | 219 +++++++++--------- astrophot/utils/conversions/units.py | 91 +++----- astrophot/utils/initialize/construct_psf.py | 21 ++ .../utils/initialize/segmentation_map.py | 71 +++--- tests/test_utils.py | 11 - 5 files changed, 199 insertions(+), 214 deletions(-) diff --git a/astrophot/utils/conversions/functions.py b/astrophot/utils/conversions/functions.py index 68e9303c..7cb36a35 100644 --- a/astrophot/utils/conversions/functions.py +++ b/astrophot/utils/conversions/functions.py @@ -1,3 +1,4 @@ +from typing import Union import numpy as np import torch from scipy.special import gamma @@ -19,9 +20,11 @@ ) -def sersic_n_to_b(n): +def sersic_n_to_b( + n: Union[float, np.ndarray, torch.Tensor], +) -> Union[float, np.ndarray, torch.Tensor]: """Compute the `b(n)` for a sersic model. This factor ensures that - the :math:`R_e` and :math:`I_e` parameters do in fact correspond + the $R_e$ and $I_e$ parameters do in fact correspond to the half light values and not some other scale radius/intensity. @@ -37,95 +40,90 @@ def sersic_n_to_b(n): ) -def sersic_I0_to_flux_np(I0, n, R, q): +def sersic_I0_to_flux_np(I0: np.ndarray, n: np.ndarray, R: np.ndarray, q: np.ndarray) -> np.ndarray: """Compute the total flux integrated to infinity for a 2D elliptical - sersic given the :math:`I_0,n,R_s,q` parameters which uniquely - define the profile (:math:`I_0` is the central intensity in - flux/arcsec^2). Note that :math:`R_s` is not the effective radius, + sersic given the $I_0,n,R_s,q$ parameters which uniquely + define the profile ($I_0$ is the central intensity in + flux/arcsec^2). Note that $R_s$ is not the effective radius, but in fact the scale radius in the more straightforward sersic representation: - .. math:: + $$I(R) = I_0e^{-(R/R_s)^{1/n}}$$ - I(R) = I_0e^{-(R/R_s)^{1/n}} - - Args: - I0: central intensity (flux/arcsec^2) - n: sersic index - R: Scale radius - q: axis ratio (b/a) + **Args:** + - `I0`: central intensity (flux/arcsec^2) + - `n`: sersic index + - `R`: Scale radius + - `q`: axis ratio (b/a) """ return 2 * np.pi * I0 * q * n * R**2 * gamma(2 * n) -def sersic_flux_to_I0_np(flux, n, R, q): +def sersic_flux_to_I0_np( + flux: np.ndarray, n: np.ndarray, R: np.ndarray, q: np.ndarray +) -> np.ndarray: """Compute the central intensity (flux/arcsec^2) for a 2D elliptical - sersic given the :math:`F,n,R_s,q` parameters which uniquely - define the profile (:math:`F` is the total flux integrated to - infinity). Note that :math:`R_s` is not the effective radius, but + sersic given the $F,n,R_s,q$ parameters which uniquely + define the profile ($F$ is the total flux integrated to + infinity). Note that $R_s$ is not the effective radius, but in fact the scale radius in the more straightforward sersic representation: - .. math:: - - I(R) = I_0e^{-(R/R_s)^{1/n}} + $$I(R) = I_0e^{-(R/R_s)^{1/n}}$$ - Args: - flux: total flux integrated to infinity (flux) - n: sersic index - R: Scale radius - q: axis ratio (b/a) + **Args:** + - `flux`: total flux integrated to infinity (flux) + - `n`: sersic index + - `R`: Scale radius + - `q`: axis ratio (b/a) """ return flux / (2 * np.pi * q * n * R**2 * gamma(2 * n)) -def sersic_Ie_to_flux_np(Ie, n, R, q): +def sersic_Ie_to_flux_np(Ie: np.ndarray, n: np.ndarray, R: np.ndarray, q: np.ndarray) -> np.ndarray: """Compute the total flux integrated to infinity for a 2D elliptical - sersic given the :math:`I_e,n,R_e,q` parameters which uniquely - define the profile (:math:`I_e` is the intensity at :math:`R_e` in - flux/arcsec^2). Note that :math:`R_e` is the effective radius in + sersic given the $I_e,n,R_e,q$ parameters which uniquely + define the profile ($I_e$ is the intensity at $R_e$ in + flux/arcsec^2). Note that $R_e$ is the effective radius in the sersic representation: - .. math:: - - I(R) = I_ee^{-b_n[(R/R_e)^{1/n}-1]} - - Args: - Ie: intensity at the effective radius (flux/arcsec^2) - n: sersic index - R: Scale radius - q: axis ratio (b/a) + $$I(R) = I_ee^{-b_n[(R/R_e)^{1/n}-1]}$$ + **Args:** + - `Ie`: intensity at the effective radius (flux/arcsec^2) + - `n`: sersic index + - `R`: Scale radius + - `q`: axis ratio (b/a) """ bn = sersic_n_to_b(n) return 2 * np.pi * Ie * R**2 * q * n * (np.exp(bn) * bn ** (-2 * n)) * gamma(2 * n) -def sersic_flux_to_Ie_np(flux, n, R, q): - """Compute the intensity at :math:`R_e` (flux/arcsec^2) for a 2D - elliptical sersic given the :math:`F,n,R_e,q` parameters which - uniquely define the profile (:math:`F` is the total flux - integrated to infinity). Note that :math:`R_e` is the effective +def sersic_flux_to_Ie_np( + flux: np.ndarray, n: np.ndarray, R: np.ndarray, q: np.ndarray +) -> np.ndarray: + """Compute the intensity at $R_e$ (flux/arcsec^2) for a 2D + elliptical sersic given the $F,n,R_e,q$ parameters which + uniquely define the profile ($F$ is the total flux + integrated to infinity). Note that $R_e$ is the effective radius in the sersic representation: - .. math:: - - I(R) = I_ee^{-b_n[(R/R_e)^{1/n}-1]} + $$I(R) = I_ee^{-b_n[(R/R_e)^{1/n}-1]}$$ - Args: - flux: flux integrated to infinity (flux) - n: sersic index - R: Scale radius - q: axis ratio (b/a) + **Args:** + - `flux`: flux integrated to infinity (flux) + - `n`: sersic index + - `R`: Scale radius + - `q`: axis ratio (b/a) """ bn = sersic_n_to_b(n) return flux / (2 * np.pi * R**2 * q * n * (np.exp(bn) * bn ** (-2 * n)) * gamma(2 * n)) -def sersic_inv_np(I, n, Re, Ie): +def sersic_inv_np(I: np.ndarray, n: np.ndarray, Re: np.ndarray, Ie: np.ndarray) -> np.ndarray: """Invert the sersic profile. Compute the radius corresponding to a given intensity for a pure sersic profile. @@ -134,68 +132,67 @@ def sersic_inv_np(I, n, Re, Ie): return Re * ((1 - (1 / bn) * np.log(I / Ie)) ** (n)) -def sersic_I0_to_flux_torch(I0, n, R, q): +def sersic_I0_to_flux_torch( + I0: torch.Tensor, n: torch.Tensor, R: torch.Tensor, q: torch.Tensor +) -> torch.Tensor: """Compute the total flux integrated to infinity for a 2D elliptical - sersic given the :math:`I_0,n,R_s,q` parameters which uniquely - define the profile (:math:`I_0` is the central intensity in - flux/arcsec^2). Note that :math:`R_s` is not the effective radius, + sersic given the $I_0,n,R_s,q$ parameters which uniquely + define the profile ($I_0$ is the central intensity in + flux/arcsec^2). Note that $R_s$ is not the effective radius, but in fact the scale radius in the more straightforward sersic representation: - .. math:: + $$I(R) = I_0e^{-(R/R_s)^{1/n}}$$ - I(R) = I_0e^{-(R/R_s)^{1/n}} - - Args: - I0: central intensity (flux/arcsec^2) - n: sersic index - R: Scale radius - q: axis ratio (b/a) + **Args:** + - `I0`: central intensity (flux/arcsec^2) + - `n`: sersic index + - `R`: Scale radius + - `q`: axis ratio (b/a) """ return 2 * np.pi * I0 * q * n * R**2 * torch.exp(gammaln(2 * n)) -def sersic_flux_to_I0_torch(flux, n, R, q): +def sersic_flux_to_I0_torch( + flux: torch.Tensor, n: torch.Tensor, R: torch.Tensor, q: torch.Tensor +) -> torch.Tensor: """Compute the central intensity (flux/arcsec^2) for a 2D elliptical - sersic given the :math:`F,n,R_s,q` parameters which uniquely - define the profile (:math:`F` is the total flux integrated to - infinity). Note that :math:`R_s` is not the effective radius, but + sersic given the $F,n,R_s,q$ parameters which uniquely + define the profile ($F$ is the total flux integrated to + infinity). Note that $R_s$ is not the effective radius, but in fact the scale radius in the more straightforward sersic representation: - .. math:: - - I(R) = I_0e^{-(R/R_s)^{1/n}} - - Args: - flux: total flux integrated to infinity (flux) - n: sersic index - R: Scale radius - q: axis ratio (b/a) + $$I(R) = I_0e^{-(R/R_s)^{1/n}}$$ + **Args:** + - `flux`: total flux integrated to infinity (flux) + - `n`: sersic index + - `R`: Scale radius + - `q`: axis ratio (b/a) """ return flux / (2 * np.pi * q * n * R**2 * torch.exp(gammaln(2 * n))) -def sersic_Ie_to_flux_torch(Ie, n, R, q): +def sersic_Ie_to_flux_torch( + Ie: torch.Tensor, n: torch.Tensor, R: torch.Tensor, q: torch.Tensor +) -> torch.Tensor: """Compute the total flux integrated to infinity for a 2D elliptical - sersic given the :math:`I_e,n,R_e,q` parameters which uniquely - define the profile (:math:`I_e` is the intensity at :math:`R_e` in - flux/arcsec^2). Note that :math:`R_e` is the effective radius in + sersic given the $I_e,n,R_e,q$ parameters which uniquely + define the profile ($I_e$ is the intensity at $R_e$ in + flux/arcsec^2). Note that $R_e$ is the effective radius in the sersic representation: - .. math:: + $$I(R) = I_ee^{-b_n[(R/R_e)^{1/n}-1]}$$ - I(R) = I_ee^{-b_n[(R/R_e)^{1/n}-1]} - - Args: - Ie: intensity at the effective radius (flux/arcsec^2) - n: sersic index - R: Scale radius - q: axis ratio (b/a) + **Args:** + - `Ie`: intensity at the effective radius (flux/arcsec^2) + - `n`: sersic index + - `R`: Scale radius + - `q`: axis ratio (b/a) """ @@ -205,22 +202,22 @@ def sersic_Ie_to_flux_torch(Ie, n, R, q): ) -def sersic_flux_to_Ie_torch(flux, n, R, q): - """Compute the intensity at :math:`R_e` (flux/arcsec^2) for a 2D - elliptical sersic given the :math:`F,n,R_e,q` parameters which - uniquely define the profile (:math:`F` is the total flux - integrated to infinity). Note that :math:`R_e` is the effective +def sersic_flux_to_Ie_torch( + flux: torch.Tensor, n: torch.Tensor, R: torch.Tensor, q: torch.Tensor +) -> torch.Tensor: + """Compute the intensity at $R_e$ (flux/arcsec^2) for a 2D + elliptical sersic given the $F,n,R_e,q$ parameters which + uniquely define the profile ($F$ is the total flux + integrated to infinity). Note that $R_e$ is the effective radius in the sersic representation: - .. math:: - - I(R) = I_ee^{-b_n[(R/R_e)^{1/n}-1]} + $$I(R) = I_ee^{-b_n[(R/R_e)^{1/n}-1]}$$ - Args: - flux: flux integrated to infinity (flux) - n: sersic index - R: Scale radius - q: axis ratio (b/a) + **Args:** + - `flux`: flux integrated to infinity (flux) + - `n`: sersic index + - `R`: Scale radius + - `q`: axis ratio (b/a) """ @@ -230,7 +227,9 @@ def sersic_flux_to_Ie_torch(flux, n, R, q): ) -def sersic_inv_torch(I, n, Re, Ie): +def sersic_inv_torch( + I: torch.Tensor, n: torch.Tensor, Re: torch.Tensor, Ie: torch.Tensor +) -> torch.Tensor: """Invert the sersic profile. Compute the radius corresponding to a given intensity for a pure sersic profile. @@ -239,14 +238,14 @@ def sersic_inv_torch(I, n, Re, Ie): return Re * ((1 - (1 / bn) * torch.log(I / Ie)) ** (n)) -def moffat_I0_to_flux(I0, n, rd, q): +def moffat_I0_to_flux(I0: float, n: float, rd: float, q: float) -> float: """ Compute the total flux integrated to infinity for a moffat profile. - Args: - I0: central intensity (flux/arcsec^2) - n: moffat curvature parameter (unitless) - rd: scale radius - q: axis ratio + **Args:** + - `I0`: central intensity (flux/arcsec^2) + - `n`: moffat curvature parameter (unitless) + - `rd`: scale radius + - `q`: axis ratio """ return I0 * np.pi * rd**2 * q / (n - 1) diff --git a/astrophot/utils/conversions/units.py b/astrophot/utils/conversions/units.py index d32d4f83..3e1a3026 100644 --- a/astrophot/utils/conversions/units.py +++ b/astrophot/utils/conversions/units.py @@ -1,3 +1,4 @@ +from typing import Optional import numpy as np __all__ = ( @@ -16,28 +17,24 @@ arcsec_to_deg = 1.0 / deg_to_arcsec -def flux_to_sb(flux, pixel_area, zeropoint): +def flux_to_sb(flux: float, pixel_area: float, zeropoint: float) -> float: """Conversion from flux units to logarithmic surface brightness units. - .. math:: + $$\\mu = -2.5\\log_{10}(flux) + z.p. + 2.5\\log_{10}(A)$$ - \\mu = -2.5\\log_{10}(flux) + z.p. + 2.5\\log_{10}(A) - - where :math:`z.p.` is the zeropoint and :math:`A` is the area of a pixel. + where $z.p.$ is the zeropoint and $A$ is the area of a pixel. """ return -2.5 * np.log10(flux) + zeropoint + 2.5 * np.log10(pixel_area) -def flux_to_mag(flux, zeropoint, fluxe=None): +def flux_to_mag(flux: float, zeropoint: float, fluxe: Optional[float] = None) -> float: """Converts a flux total into logarithmic magnitude units. - .. math:: - - m = -2.5\\log_{10}(flux) + z.p. + $$m = -2.5\\log_{10}(flux) + z.p.$$ - where :math:`z.p.` is the zeropoint. + where $z.p.$ is the zeropoint. """ if fluxe is None: @@ -46,27 +43,23 @@ def flux_to_mag(flux, zeropoint, fluxe=None): return -2.5 * np.log10(flux) + zeropoint, 2.5 * fluxe / (np.log(10) * flux) -def sb_to_flux(sb, pixel_area, zeropoint): +def sb_to_flux(sb: float, pixel_area: float, zeropoint: float) -> float: """Converts logarithmic surface brightness units into flux units. - .. math:: - - flux = A 10^{-(\\mu - z.p.)/2.5} + $$flux = A 10^{-(\\mu - z.p.)/2.5}$$ - where :math:`z.p.` is the zeropoint and :math:`A` is the area of a pixel. + where $z.p.$ is the zeropoint and $A$ is the area of a pixel. """ return pixel_area * 10 ** (-(sb - zeropoint) / 2.5) -def mag_to_flux(mag, zeropoint, mage=None): +def mag_to_flux(mag: float, zeropoint: float, mage: Optional[float] = None) -> float: """converts logarithmic magnitude units into a flux total. - .. math:: + $$flux = 10^{-(m - z.p.)/2.5}$$ - flux = 10^{-(m - z.p.)/2.5} - - where :math:`z.p.` is the zeropoint. + where $z.p.$ is the zeropoint. """ if mage is None: @@ -76,21 +69,22 @@ def mag_to_flux(mag, zeropoint, mage=None): return I, np.log(10) * I * mage / 2.5 -def magperarcsec2_to_mag(mu, a=None, b=None, A=None): +def magperarcsec2_to_mag( + mu: float, a: Optional[float] = None, b: Optional[float] = None, A: Optional[float] = None +) -> float: """ Converts mag/arcsec^2 to mag - mu: mag/arcsec^2 - a: semi major axis radius (arcsec) - b: semi minor axis radius (arcsec) - A: pre-calculated area (arcsec^2) - returns: mag + **Args:** + - `mu`: mag/arcsec^2 + - `a`: semi major axis radius (arcsec) + - `b`: semi minor axis radius (arcsec) + - `A`: pre-calculated area (arcsec^2) - .. math:: - m = \\mu -2.5\\log_{10}(A) + $$m = \\mu -2.5\\log_{10}(A)$$ - where :math:`A` is an area in arcsec^2. + where $A$ is an area in arcsec^2. """ assert (A is not None) or (a is not None and b is not None) @@ -101,20 +95,26 @@ def magperarcsec2_to_mag(mu, a=None, b=None, A=None): ) # https://en.wikipedia.org/wiki/Surface_brightness#Calculating_surface_brightness -def mag_to_magperarcsec2(m, a=None, b=None, R=None, A=None): +def mag_to_magperarcsec2( + m: float, + a: Optional[float] = None, + b: Optional[float] = None, + R: Optional[float] = None, + A: Optional[float] = None, +) -> float: """ Converts mag to mag/arcsec^2 - m: mag - a: semi major axis radius (arcsec) - b: semi minor axis radius (arcsec) - A: pre-calculated area (arcsec^2) - returns: mag/arcsec^2 - .. math:: + **Args:** + - `m`: mag + - `a`: semi major axis radius (arcsec) + - `b`: semi minor axis radius (arcsec) + - `A`: pre-calculated area (arcsec^2) + - \\mu = m + 2.5\\log_{10}(A) + $$\\mu = m + 2.5\\log_{10}(A)$$ - where :math:`A` is an area in arcsec^2. + where $A$ is an area in arcsec^2. """ assert (A is not None) or (a is not None and b is not None) or (R is not None) if R is not None: @@ -124,18 +124,3 @@ def mag_to_magperarcsec2(m, a=None, b=None, R=None, A=None): return m + 2.5 * np.log10( A ) # https://en.wikipedia.org/wiki/Surface_brightness#Calculating_surface_brightness - - -def PA_shift_convention(pa, unit="rad"): - """ - Alternates between standard mathematical convention for angles, and astronomical position angle convention. - The standard convention is to measure angles counter-clockwise relative to the positive x-axis - The astronomical convention is to measure angles counter-clockwise relative to the positive y-axis - """ - - if unit == "rad": - shift = np.pi - elif unit == "deg": - shift = 180.0 - - return (pa - (shift / 2)) % shift diff --git a/astrophot/utils/initialize/construct_psf.py b/astrophot/utils/initialize/construct_psf.py index f764e4c7..c05bc88e 100644 --- a/astrophot/utils/initialize/construct_psf.py +++ b/astrophot/utils/initialize/construct_psf.py @@ -2,6 +2,16 @@ def gaussian_psf(sigma, img_width, pixelscale, upsample=4, normalize=True): + """ + create a gaussian point spread function (PSF) image. + + **Args:** + - `sigma`: Standard deviation of the Gaussian in arcseconds. + - `img_width`: Width of the PSF image in pixels. + - `pixelscale`: Pixel scale in arcseconds per pixel. + - `upsample`: Upsampling factor to more accurately create the PSF (the outputted PSF is not upsampled). + - `normalize`: Whether to normalize the PSF so that the sum of all pixels equals 1. If False, the PSF will not be normalized. + """ assert img_width % 2 == 1, "psf images should have an odd shape" # Number of super sampled pixels @@ -32,6 +42,17 @@ def gaussian_psf(sigma, img_width, pixelscale, upsample=4, normalize=True): def moffat_psf(n, Rd, img_width, pixelscale, upsample=4, normalize=True): + """ + Create a Moffat point spread function (PSF) image. + + **Args:** + - `n`: Moffat index (power-law index). + - `Rd`: Scale radius of the Moffat profile in arcseconds. + - `img_width`: Width of the PSF image in pixels. + - `pixelscale`: Pixel scale in arcseconds per pixel. + - `upsample`: Upsampling factor to more accurately create the PSF (the outputted PSF is not upsampled). + - `normalize`: Whether to normalize the PSF so that the sum of all pixels equals 1. If False, the PSF will not be normalized. + """ assert img_width % 2 == 1, "psf images should have an odd shape" # Number of super sampled pixels diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index 39eb3757..7bc2cc21 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -1,5 +1,5 @@ from copy import deepcopy -from typing import Union +from typing import Optional, Union import numpy as np import torch @@ -31,7 +31,7 @@ def _select_img(img, hduli): def centroids_from_segmentation_map( seg_map: Union[np.ndarray, str], image: "Image", - sky_level=None, + sky_level: Optional[float] = None, hdul_index_seg: int = 0, skip_index: tuple = (0,), ): @@ -41,16 +41,12 @@ def centroids_from_segmentation_map( pixel space. A dictionary of pixel centers is produced where the keys of the dictionary correspond to the segment id's. - Parameters: - ---------- - seg_map (Union[np.ndarray, str]): A segmentation map which gives the object identity for each pixel - image (Union[np.ndarray, str]): An Image which will be used in the light weighted center of mass calculation - hdul_index_seg (int): If reading from a fits file this is the hdu list index at which the map is found. Default: 0 - hdul_index_img (int): If reading from a fits file this is the hdu list index at which the image is found. Default: 0 - skip_index (tuple): Lists which identities (if any) in the segmentation map should be ignored. Default (0,) - - Returns: - centroids (dict): dictionary of centroid positions matched to each segment ID. The centroids are in pixel coordinates + **Args:** + - `seg_map` (Union[np.ndarray, str]): A segmentation map which gives the object identity for each pixel + - `image` (Union[np.ndarray, str]): An Image which will be used in the light weighted center of mass calculation + - `sky_level` (float): The sky level to subtract from the image data before calculating centroids. Default: None, which uses the median of the image data. + - `hdul_index_seg` (int): If reading from a fits file this is the hdu list index at which the map is found. Default: 0 + - `skip_index` (tuple): Lists which identities (if any) in the segmentation map should be ignored. Default (0,) """ seg_map = _select_img(seg_map, hdul_index_seg) @@ -84,10 +80,10 @@ def PA_from_segmentation_map( seg_map: Union[np.ndarray, str], image: "Image", centroids=None, - sky_level=None, + sky_level: Optional[float] = None, hdul_index_seg: int = 0, skip_index: tuple = (0,), - softening=1e-3, + softening: float = 1e-3, ): seg_map = _select_img(seg_map, hdul_index_seg) @@ -130,10 +126,10 @@ def q_from_segmentation_map( seg_map: Union[np.ndarray, str], image: "Image", centroids=None, - sky_level=None, + sky_level: Optional[float] = None, hdul_index_seg: int = 0, skip_index: tuple = (0,), - softening=1e-3, + softening: float = 1e-3, ): seg_map = _select_img(seg_map, hdul_index_seg) @@ -245,26 +241,26 @@ def scale_windows(windows, image: "Image" = None, expand_scale=1.0, expand_borde def filter_windows( windows, - min_size=None, - max_size=None, - min_area=None, - max_area=None, - min_flux=None, - max_flux=None, + min_size: Optional[float] = None, + max_size: Optional[float] = None, + min_area: Optional[float] = None, + max_area: Optional[float] = None, + min_flux: Optional[float] = None, + max_flux: Optional[float] = None, image: "Image" = None, ): """ Filter a set of windows based on a set of criteria. - Parameters - ---------- - min_size: minimum size of the window in pixels - max_size: maximum size of the window in pixels - min_area: minimum area of the window in pixels - max_area: maximum area of the window in pixels - min_flux: minimum flux of the window in ADU - max_flux: maximum flux of the window in ADU - image: the image from which the flux is calculated for min_flux and max_flux + **Args:** + - `windows`: A dictionary of windows to filter. Each window is formatted as a list of lists with: window = [[xmin,ymin],[xmax,ymax]] + - `min_size`: minimum size of the window in pixels + - `max_size`: maximum size of the window in pixels + - `min_area`: minimum area of the window in pixels + - `max_area`: maximum area of the window in pixels + - `min_flux`: minimum flux of the window in ADU + - `max_flux`: maximum flux of the window in ADU + - `image`: the image from which the flux is calculated for min_flux and max_flux """ new_windows = {} for w in list(windows.keys()): @@ -328,15 +324,10 @@ def transfer_windows(windows, base_image, new_image): for the relative adjustments in origin, pixelscale, and rotation between the two images. - Parameters - ---------- - windows : dict - A dictionary of windows to be transferred. Each window is formatted as a list of lists with: - window = [[xmin,ymin],[xmax,ymax]] - base_image : Image - The image object from which the windows are being transferred. - new_image : Image - The image object to which the windows are being transferred. + **Args:** + - `windows`: A dictionary of windows to be transferred. Each window is formatted as a list of lists with: window = [[xmin,ymin],[xmax,ymax]] + - `base_image`: The image object from which the windows are being transferred. + - `new_image`: The image object to which the windows are being transferred. """ new_windows = {} for w in list(windows.keys()): diff --git a/tests/test_utils.py b/tests/test_utils.py index 25d18b79..b4e0d964 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -85,17 +85,6 @@ def test_conversions_units(): (1.0 + 2.5 * np.log10(np.pi)), ), "mag incorrectly converted to mag/arcsec^2 (area A given)" - # position angle PA to radians - assert np.isclose( - ap.utils.conversions.units.PA_shift_convention(1.0, unit="rad"), - ((1.0 - (np.pi / 2)) % np.pi), - ), "PA incorrectly converted to radians" - - # position angle PA to degrees - assert np.isclose( - ap.utils.conversions.units.PA_shift_convention(1.0, unit="deg"), ((1.0 - (180 / 2)) % 180) - ), "PA incorrectly converted to degrees" - def test_conversion_functions(): From 3c6b1d01d72200698ac9d7feade69b8c87cfd8a6 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 18:59:59 -0400 Subject: [PATCH 112/191] fix import --- astrophot/utils/conversions/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/astrophot/utils/conversions/__init__.py b/astrophot/utils/conversions/__init__.py index 9c679bf9..e3b9a8f4 100644 --- a/astrophot/utils/conversions/__init__.py +++ b/astrophot/utils/conversions/__init__.py @@ -21,7 +21,6 @@ mag_to_flux, magperarcsec2_to_mag, mag_to_magperarcsec2, - PA_shift_convention, ) __all__ = ( @@ -45,5 +44,4 @@ "mag_to_flux", "magperarcsec2_to_mag", "mag_to_magperarcsec2", - "PA_shift_convention", ) From a59b52587f86b9ebe236fa783b3e95154c632a07 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 19:26:49 -0400 Subject: [PATCH 113/191] increase download times --- docs/source/tutorials/CustomModels.ipynb | 2 +- docs/source/tutorials/GettingStarted.ipynb | 2 +- docs/source/tutorials/GravitationalLensing.ipynb | 2 +- docs/source/tutorials/GroupModels.ipynb | 2 +- docs/source/tutorials/ImageAlignment.ipynb | 2 +- docs/source/tutorials/JointModels.ipynb | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/source/tutorials/CustomModels.ipynb b/docs/source/tutorials/CustomModels.ipynb index 71e42779..760f3fb0 100644 --- a/docs/source/tutorials/CustomModels.ipynb +++ b/docs/source/tutorials/CustomModels.ipynb @@ -68,7 +68,7 @@ "import matplotlib.pyplot as plt\n", "import socket\n", "\n", - "socket.setdefaulttimeout(60)" + "socket.setdefaulttimeout(120)" ] }, { diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 89e2655d..a06e5e75 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -27,7 +27,7 @@ "import matplotlib.pyplot as plt\n", "import socket\n", "\n", - "socket.setdefaulttimeout(60)" + "socket.setdefaulttimeout(120)" ] }, { diff --git a/docs/source/tutorials/GravitationalLensing.ipynb b/docs/source/tutorials/GravitationalLensing.ipynb index 9e7fb5c8..b39f810c 100644 --- a/docs/source/tutorials/GravitationalLensing.ipynb +++ b/docs/source/tutorials/GravitationalLensing.ipynb @@ -28,7 +28,7 @@ "import torch\n", "import socket\n", "\n", - "socket.setdefaulttimeout(60)" + "socket.setdefaulttimeout(120)" ] }, { diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index d25d6b9e..24b7df40 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -27,7 +27,7 @@ "import matplotlib.pyplot as plt\n", "import socket\n", "\n", - "socket.setdefaulttimeout(60)" + "socket.setdefaulttimeout(120)" ] }, { diff --git a/docs/source/tutorials/ImageAlignment.ipynb b/docs/source/tutorials/ImageAlignment.ipynb index 4b7e8701..d30f326e 100644 --- a/docs/source/tutorials/ImageAlignment.ipynb +++ b/docs/source/tutorials/ImageAlignment.ipynb @@ -23,7 +23,7 @@ "import torch\n", "import socket\n", "\n", - "socket.setdefaulttimeout(60)" + "socket.setdefaulttimeout(120)" ] }, { diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 445ad0b0..5b95dd54 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -23,7 +23,7 @@ "import matplotlib.pyplot as plt\n", "import socket\n", "\n", - "socket.setdefaulttimeout(60)" + "socket.setdefaulttimeout(120)" ] }, { From 6d00062c9527b7a9fc7d7caf0128ce202d34f1cb Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 19:46:55 -0400 Subject: [PATCH 114/191] fix minor issues with iter and model_image --- astrophot/fit/iterative.py | 2 +- astrophot/plots/image.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 076554cc..3baa23bc 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -43,7 +43,7 @@ def __init__( model: Model, initial_state: np.ndarray = None, max_iter: int = 100, - lm_kwargs: Dict[str, Any] = {}, + lm_kwargs: Dict[str, Any] = {"verbose": 0}, **kwargs: Dict[str, Any], ): super().__init__(model, initial_state, max_iter=max_iter, **kwargs) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index f0470658..96a239ba 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -226,6 +226,8 @@ def model_image( target_mask=target_mask, cmap_levels=cmap_levels, magunits=magunits, + vmin=vmin, + vmax=vmax, **kwargs, ) return fig, ax @@ -255,6 +257,8 @@ def model_image( if target.zeropoint is not None and magunits: sample_image = flux_to_sb(sample_image, target.pixel_area.item(), target.zeropoint.item()) kwargs["cmap"] = kwargs["cmap"].reversed() + kwargs["vmin"] = vmin + kwargs["vmax"] = vmax else: kwargs = { "norm": matplotlib.colors.LogNorm( From 49966027d63cc3d06fa274f66357869b307b88a1 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 20:17:30 -0400 Subject: [PATCH 115/191] change scipy fitting example --- astrophot/fit/scipy_fit.py | 2 +- docs/source/tutorials/FittingMethods.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py index af03552e..e6126d65 100644 --- a/astrophot/fit/scipy_fit.py +++ b/astrophot/fit/scipy_fit.py @@ -98,7 +98,7 @@ def fit(self): self.current_state = torch.tensor(res.x, dtype=config.DTYPE, device=config.DEVICE) if self.verbose > 0: config.logger.info( - f"Final 2NLL/DoF: {2*self.density(self.current_state)/self.ndf:.6g}. Converged: {self.message}" + f"Final 2NLL/DoF: {2*self.density(res.x)/self.ndf:.6g}. Converged: {self.message}" ) self.model.fill_dynamic_values(self.current_state) diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index c38f27eb..8df2d0b5 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -472,7 +472,7 @@ "source": [ "MODEL = initialize_model(target, False)\n", "\n", - "res_scipy = ap.fit.ScipyFit(MODEL, method=\"SLSQP\", verbose=1).fit()\n", + "res_scipy = ap.fit.ScipyFit(MODEL, method=\"Powell\", verbose=1).fit()\n", "print(res_scipy.scipy_res)" ] }, From 5599dcd1d3080596863cd8c0169bb250aaad1a4b Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 20:40:35 -0400 Subject: [PATCH 116/191] fix caskade import --- astrophot/fit/gradient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 804946a0..98363f8b 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -1,7 +1,7 @@ # Traditional gradient descent with Adam from time import time from typing import Sequence -from caustics import ValidContext +from caskade import ValidContext import torch import numpy as np From 9b7d77cba39cc14e30a30b836b16f2e68c3cd9fa Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 31 Jul 2025 20:55:18 -0400 Subject: [PATCH 117/191] remove test cell in getting started tutorial --- docs/source/tutorials/GettingStarted.ipynb | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index a06e5e75..daa4188c 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -81,27 +81,6 @@ "plt.show()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from time import time\n", - "\n", - "x = model1.build_params_array()\n", - "x = x.repeat(8, 1)\n", - "start = time()\n", - "for _ in range(100):\n", - " imgs = torch.vmap(lambda x: model1(x).data)(x)\n", - "print(\"Inference time:\", time() - start)\n", - "print(\"Inferred image shape:\", imgs.shape)\n", - "start = time()\n", - "for _ in range(100):\n", - " jac = model1.jacobian()\n", - "print(\"Jacobian time:\", time() - start)" - ] - }, { "cell_type": "markdown", "metadata": {}, From 74d25ac3c3c1cbb3cb2540ede0466b9ea789d411 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 12 Aug 2025 15:08:13 -0400 Subject: [PATCH 118/191] making jax backend for AstroPhot --- astrophot/backend.py | 274 +++++++++++++++++++++++++++ astrophot/image/cmos_image.py | 5 +- astrophot/image/func/image.py | 55 +++--- astrophot/image/func/wcs.py | 42 ++-- astrophot/image/func/window.py | 10 +- astrophot/image/image_object.py | 109 ++++++----- astrophot/image/jacobian_image.py | 5 +- astrophot/image/mixins/data_mixin.py | 45 ++--- astrophot/image/mixins/sip_mixin.py | 35 ++-- astrophot/image/psf_image.py | 10 +- astrophot/image/sip_image.py | 3 +- 11 files changed, 439 insertions(+), 154 deletions(-) create mode 100644 astrophot/backend.py diff --git a/astrophot/backend.py b/astrophot/backend.py new file mode 100644 index 00000000..b85f134a --- /dev/null +++ b/astrophot/backend.py @@ -0,0 +1,274 @@ +import os +import importlib +from typing import Annotated + +from torch import Tensor, dtype, device +import numpy as np +import torch +from . import config + +ArrayLike = Annotated[ + Tensor, + "One of: torch.Tensor or jax.numpy.ndarray depending on the chosen backend.", +] +dtypeLike = Annotated[ + dtype, + "One of: torch.dtype or jax.numpy.dtype depending on the chosen backend.", +] +deviceLike = Annotated[ + device, + "One of: torch.device or jax.DeviceArray depending on the chosen backend.", +] + + +class Backend: + def __init__(self, backend=None): + self.backend = backend + + @property + def backend(self): + return self._backend + + @backend.setter + def backend(self, backend): + if backend is None: + backend = os.getenv("CASKADE_BACKEND", "torch") + self.module = self._load_backend(backend) + self._backend = backend + + def _load_backend(self, backend): + if backend == "torch": + self.setup_torch() + return importlib.import_module("torch") + elif backend == "jax": + self.setup_jax() + return importlib.import_module("jax.numpy") + else: + raise ValueError(f"Unsupported backend: {backend}") + + def setup_torch(self): + config.DTYPE = torch.float64 + config.DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu" + self.make_array = self._make_array_torch + self._array_type = self._array_type_torch + self.concatenate = self._concatenate_torch + self.copy = self._copy_torch + self.tolist = self._tolist_torch + self.view = self._view_torch + self.as_array = self._as_array_torch + self.to = self._to_torch + self.to_numpy = self._to_numpy_torch + self.logit = self._logit_torch + self.sigmoid = self._sigmoid_torch + self.arange = self._arange_torch + self.meshgrid = self._meshgrid_torch + self.repeat = self._repeat_torch + self.stack = self._stack_torch + self.transpose = self._transpose_torch + + def setup_jax(self): + self.jax = importlib.import_module("jax") + self.jax.config.update("jax_enable_x64", True) + config.DTYPE = self.jax.numpy.float64 + config.DEVICE = None + self.make_array = self._make_array_jax + self._array_type = self._array_type_jax + self.concatenate = self._concatenate_jax + self.copy = self._copy_jax + self.tolist = self._tolist_jax + self.view = self._view_jax + self.as_array = self._as_array_jax + self.to = self._to_jax + self.to_numpy = self._to_numpy_jax + self.logit = self._logit_jax + self.sigmoid = self._sigmoid_jax + self.arange = self._arange_jax + self.meshgrid = self._meshgrid_jax + self.repeat = self._repeat_jax + self.stack = self._stack_jax + self.transpose = self._transpose_jax + + @property + def array_type(self): + return self._array_type() + + def _make_array_torch(self, array, dtype=None, device=None): + return self.module.tensor(array, dtype=dtype, device=device) + + def _make_array_jax(self, array, dtype=None, **kwargs): + return self.module.array(array, dtype=dtype) + + def _array_type_torch(self): + return self.module.Tensor + + def _array_type_jax(self): + return self.module.ndarray + + def _concatenate_torch(self, arrays, axis=0): + return self.module.cat(arrays, dim=axis) + + def _concatenate_jax(self, arrays, axis=0): + return self.module.concatenate(arrays, axis=axis) + + def _copy_torch(self, array): + return array.detach().clone() + + def _copy_jax(self, array): + return self.module.copy(array) + + def _tolist_torch(self, array): + return array.detach().cpu().tolist() + + def _tolist_jax(self, array): + return array.block_until_ready().tolist() + + def _view_torch(self, array, shape): + return array.reshape(shape) + + def _view_jax(self, array, shape): + return array.reshape(shape) + + def _as_array_torch(self, array, dtype=None, device=None): + return self.module.as_tensor(array, dtype=dtype, device=device) + + def _as_array_jax(self, array, dtype=None, **kwargs): + return self.module.asarray(array, dtype=dtype) + + def _to_torch(self, array, dtype=None, device=None): + return array.to(dtype=dtype, device=device) + + def _to_jax(self, array, dtype=None, device=None): + return self.jax.device_put(array.astype(dtype), device=device) + + def _to_numpy_torch(self, array): + return array.detach().cpu().numpy() + + def _to_numpy_jax(self, array): + return np.array(array.block_until_ready()) + + def _arange_torch(self, *args, dtype=None, device=None): + return self.module.arange(*args, dtype=dtype, device=device) + + def _arange_jax(self, *args, dtype=None, device=None): + return self.jax.arange(*args, dtype=dtype, device=device) + + def _meshgrid_torch(self, *arrays, indexing="ij"): + return self.module.meshgrid(*arrays, indexing=indexing) + + def _meshgrid_jax(self, *arrays, indexing="ij"): + return self.jax.meshgrid(*arrays, indexing=indexing) + + def _repeat_torch(self, a, repeats, axis=None): + return self.module.repeat_interleave(a, repeats, dim=axis) + + def _repeat_jax(self, a, repeats, axis=None): + return self.jax.repeat(a, repeats, axis=axis) + + def _stack_torch(self, arrays, dim=0): + return self.module.stack(arrays, dim=dim) + + def _stack_jax(self, arrays, dim=0): + return self.jax.stack(arrays, axis=dim) + + def _transpose_torch(self, array, *args): + return self.module.transpose(array, *args) + + def _transpose_jax(self, array, *args): + return self.jax.transpose(array, args) + + def _sigmoid_torch(self, array): + return self.module.sigmoid(array) + + def _sigmoid_jax(self, array): + return self.jax.nn.sigmoid(array) + + def _logit_torch(self, array): + return self.module.logit(array) + + def _logit_jax(self, array): + return self.jax.scipy.special.logit(array) + + def _clone_torch(self, array): + return array.clone() + + def _clone_jax(self, array): + return self.module.copy(array) + + def any(self, array): + return self.module.any(array) + + def all(self, array): + return self.module.all(array) + + def log(self, array): + return self.module.log(array) + + def exp(self, array): + return self.module.exp(array) + + def sin(self, array): + return self.module.sin(array) + + def cos(self, array): + return self.module.cos(array) + + def sqrt(self, array): + return self.module.sqrt(array) + + def arctan(self, array): + return self.module.arctan(array) + + def arctan2(self, y, x): + return self.module.arctan2(y, x) + + def arcsin(self, array): + return self.module.arcsin(array) + + def sum(self, array, axis=None): + return self.module.sum(array, axis=axis) + + def zeros(self, shape, dtype=None, device=None): + return self.module.zeros(shape, dtype=dtype, device=device) + + def zeros_like(self, array): + return self.module.zeros_like(array) + + def ones(self, shape, dtype=None, device=None): + return self.module.ones(shape, dtype=dtype, device=device) + + def ones_like(self, array): + return self.module.ones_like(array) + + def empty(self, shape, dtype=None, device=None): + return self.module.empty(shape, dtype=dtype, device=device) + + def minimum(self, a, b): + return self.module.minimum(a, b) + + def maximum(self, a, b): + return self.module.maximum(a, b) + + def isnan(self, array): + return self.module.isnan(array) + + def where(self, condition, x, y): + return self.module.where(condition, x, y) + + @property + def linalg(self): + return self.module.linalg + + @property + def inf(self): + return self.module.inf + + @property + def bool(self): + return self.module.bool + + @property + def int32(self): + return self.module.int32 + + +backend = Backend() diff --git a/astrophot/image/cmos_image.py b/astrophot/image/cmos_image.py index 518574c6..dc0e6382 100644 --- a/astrophot/image/cmos_image.py +++ b/astrophot/image/cmos_image.py @@ -1,8 +1,7 @@ -import torch - from .target_image import TargetImage from .mixins import CMOSMixin from .model_image import ModelImage +from ..backend import backend class CMOSModelImage(CMOSMixin, ModelImage): @@ -28,7 +27,7 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> CMOSModelIma kwargs = { "subpixel_loc": self.subpixel_loc, "subpixel_scale": self.subpixel_scale, - "_data": torch.zeros( + "_data": backend.zeros( self.data.shape[:2], dtype=self.data.dtype, device=self.data.device ), "CD": self.CD.value, diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index 515a5138..8bc387c5 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -1,53 +1,42 @@ -import torch - from ...utils.integration import quad_table +from ...backend import backend, ArrayLike -def pixel_center_meshgrid( - shape: tuple[int, int], dtype: torch.dtype, device: torch.device -) -> tuple[torch.Tensor, torch.Tensor]: - i = torch.arange(shape[0], dtype=dtype, device=device) - j = torch.arange(shape[1], dtype=dtype, device=device) - return torch.meshgrid(i, j, indexing="ij") +def pixel_center_meshgrid(shape: tuple[int, int], dtype, device) -> tuple: + i = backend.arange(shape[0], dtype=dtype, device=device) + j = backend.arange(shape[1], dtype=dtype, device=device) + return backend.meshgrid(i, j, indexing="ij") def cmos_pixel_center_meshgrid( - shape: tuple[int, int], loc: tuple[float, float], dtype: torch.dtype, device: torch.device -) -> tuple[torch.Tensor, torch.Tensor]: - i = torch.arange(shape[0], dtype=dtype, device=device) + loc[0] - j = torch.arange(shape[1], dtype=dtype, device=device) + loc[1] - return torch.meshgrid(i, j, indexing="ij") + shape: tuple[int, int], loc: tuple[float, float], dtype, device +) -> tuple: + i = backend.arange(shape[0], dtype=dtype, device=device) + loc[0] + j = backend.arange(shape[1], dtype=dtype, device=device) + loc[1] + return backend.meshgrid(i, j, indexing="ij") -def pixel_corner_meshgrid( - shape: tuple[int, int], dtype: torch.dtype, device: torch.device -) -> tuple[torch.Tensor, torch.Tensor]: - i = torch.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = torch.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="ij") +def pixel_corner_meshgrid(shape: tuple[int, int], dtype, device) -> tuple: + i = backend.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = backend.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 + return backend.meshgrid(i, j, indexing="ij") -def pixel_simpsons_meshgrid( - shape: tuple[int, int], dtype: torch.dtype, device: torch.device -) -> tuple[torch.Tensor, torch.Tensor]: - i = 0.5 * torch.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = 0.5 * torch.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 - return torch.meshgrid(i, j, indexing="ij") +def pixel_simpsons_meshgrid(shape: tuple[int, int], dtype, device) -> tuple: + i = 0.5 * backend.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 + j = 0.5 * backend.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 + return backend.meshgrid(i, j, indexing="ij") -def pixel_quad_meshgrid( - shape: tuple[int, int], dtype: torch.dtype, device: torch.device, order=3 -) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: +def pixel_quad_meshgrid(shape: tuple[int, int], dtype, device, order=3) -> tuple: i, j = pixel_center_meshgrid(shape, dtype, device) di, dj, w = quad_table(order, dtype, device) - i = torch.repeat_interleave(i[..., None], order**2, -1) + di.flatten() - j = torch.repeat_interleave(j[..., None], order**2, -1) + dj.flatten() + i = backend.repeat(i[..., None], order**2, -1) + di.flatten() + j = backend.repeat(j[..., None], order**2, -1) + dj.flatten() return i, j, w.flatten() -def rotate( - theta: torch.Tensor, x: torch.Tensor, y: torch.Tensor -) -> tuple[torch.Tensor, torch.Tensor]: +def rotate(theta: ArrayLike, x: ArrayLike, y: ArrayLike) -> tuple: """ Applies a rotation matrix to the X,Y coordinates """ diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 5a041cc9..36e4b9aa 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -1,5 +1,5 @@ import numpy as np -import torch +from ...backend import backend deg_to_rad = np.pi / 180 rad_to_deg = 180 / np.pi @@ -26,11 +26,15 @@ def world_to_plane_gnomonic(ra, dec, ra0, dec0, x0=0.0, y0=0.0): ra0 = ra0 * deg_to_rad dec0 = dec0 * deg_to_rad - cosc = torch.sin(dec0) * torch.sin(dec) + torch.cos(dec0) * torch.cos(dec) * torch.cos(ra - ra0) + cosc = backend.sin(dec0) * backend.sin(dec) + backend.cos(dec0) * backend.cos( + dec + ) * backend.cos(ra - ra0) - x = torch.cos(dec) * torch.sin(ra - ra0) + x = backend.cos(dec) * backend.sin(ra - ra0) - y = torch.cos(dec0) * torch.sin(dec) - torch.sin(dec0) * torch.cos(dec) * torch.cos(ra - ra0) + y = backend.cos(dec0) * backend.sin(dec) - backend.sin(dec0) * backend.cos(dec) * backend.cos( + ra - ra0 + ) return x * rad_to_arcsec / cosc + x0, y * rad_to_arcsec / cosc + y0 @@ -55,15 +59,17 @@ def plane_to_world_gnomonic(x, y, ra0, dec0, x0=0.0, y0=0.0, s=1e-10): ra0 = ra0 * deg_to_rad dec0 = dec0 * deg_to_rad - rho = torch.sqrt(x**2 + y**2) + s - c = torch.arctan(rho) + rho = backend.sqrt(x**2 + y**2) + s + c = backend.arctan(rho) - ra = ra0 + torch.arctan2( - x * torch.sin(c), - rho * torch.cos(dec0) * torch.cos(c) - y * torch.sin(dec0) * torch.sin(c), + ra = ra0 + backend.arctan2( + x * backend.sin(c), + rho * backend.cos(dec0) * backend.cos(c) - y * backend.sin(dec0) * backend.sin(c), ) - dec = torch.arcsin(torch.cos(c) * torch.sin(dec0) + y * torch.sin(c) * torch.cos(dec0) / rho) + dec = backend.arcsin( + backend.cos(c) * backend.sin(dec0) + y * backend.sin(c) * backend.cos(dec0) / rho + ) return ra * rad_to_deg, dec * rad_to_deg @@ -86,7 +92,7 @@ def pixel_to_plane_linear(i, j, i0, j0, CD, x0=0.0, y0=0.0): **Returns:** - Tuple[Tensor, Tensor]: Tuple containing the x and y coordinates in arcseconds """ - uv = torch.stack((i.flatten() - i0, j.flatten() - j0), dim=0) + uv = backend.stack((i.flatten() - i0, j.flatten() - j0), dim=0) xy = CD @ uv return xy[0].reshape(i.shape) + x0, xy[1].reshape(i.shape) + y0 @@ -101,7 +107,7 @@ def sip_coefs(order): def sip_matrix(u, v, order): - M = torch.zeros((len(u), (order + 1) * (order + 2) // 2), dtype=u.dtype, device=u.device) + M = backend.zeros((len(u), (order + 1) * (order + 2) // 2), dtype=u.dtype, device=u.device) for i, (p, q) in enumerate(sip_coefs(order)): M[:, i] = u**p * v**q return M @@ -118,8 +124,8 @@ def sip_backward_transform(u, v, U, V, A_ORDER, B_ORDER): FP_UV = sip_matrix(U, V, A_ORDER) GP_UV = sip_matrix(U, V, B_ORDER) - AP = torch.linalg.lstsq(FP_UV, (u.flatten() - U).reshape(-1, 1))[0].squeeze(1) - BP = torch.linalg.lstsq(GP_UV, (v.flatten() - V).reshape(-1, 1))[0].squeeze(1) + AP = backend.linalg.lstsq(FP_UV, (u.flatten() - U).reshape(-1, 1))[0].squeeze(1) + BP = backend.linalg.lstsq(GP_UV, (v.flatten() - V).reshape(-1, 1))[0].squeeze(1) return AP, BP @@ -131,8 +137,8 @@ def sip_delta(u, v, sipA=(), sipB=()): The SIP coefficients, where the keys are tuples of powers (i, j) and the values are the coefficients. For example, {(1, 2): 0.1} means delta_u = 0.1 * (u * v^2). """ - delta_u = torch.zeros_like(u) - delta_v = torch.zeros_like(v) + delta_u = backend.zeros_like(u) + delta_v = backend.zeros_like(v) # Get all used coefficient powers all_a = set(s[0] for s in sipA) | set(s[0] for s in sipB) all_b = set(s[1] for s in sipA) | set(s[1] for s in sipB) @@ -163,7 +169,7 @@ def plane_to_pixel_linear(x, y, i0, j0, CD, x0=0.0, y0=0.0): **Returns:** - Tuple[Tensor, Tensor]: Tuple containing the i and j pixel coordinates in pixel units. """ - xy = torch.stack((x.flatten() - x0, y.flatten() - y0), dim=0) - uv = torch.linalg.inv(CD) @ xy + xy = backend.stack((x.flatten() - x0, y.flatten() - y0), dim=0) + uv = backend.linalg.inv(CD) @ xy return uv[0].reshape(x.shape) + i0, uv[1].reshape(y.shape) + j0 diff --git a/astrophot/image/func/window.py b/astrophot/image/func/window.py index 132370e1..4daade6d 100644 --- a/astrophot/image/func/window.py +++ b/astrophot/image/func/window.py @@ -1,16 +1,16 @@ -import torch +from ...backend import backend def window_or(other_origin, self_end, other_end): - new_origin = torch.minimum(-0.5 * torch.ones_like(other_origin), other_origin) - new_end = torch.maximum(self_end, other_end) + new_origin = backend.minimum(-0.5 * backend.ones_like(other_origin), other_origin) + new_end = backend.maximum(self_end, other_end) return new_origin, new_end def window_and(other_origin, self_end, other_end): - new_origin = torch.maximum(-0.5 * torch.ones_like(other_origin), other_origin) - new_end = torch.minimum(self_end, other_end) + new_origin = backend.maximum(-0.5 * backend.ones_like(other_origin), other_origin) + new_end = backend.minimum(self_end, other_end) return new_origin, new_end diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 53706194..b3aa659f 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -7,6 +7,7 @@ from ..param import Module, Param, forward from .. import config +from ..backend import backend, ArrayLike from ..utils.conversions.units import deg_to_arcsec, arcsec_to_deg from .window import Window, WindowList from ..errors import InvalidImage, SpecificationConflict @@ -49,19 +50,19 @@ class Image(Module): def __init__( self, *, - data: Optional[torch.Tensor] = None, - CD: Optional[Union[float, torch.Tensor]] = None, - zeropoint: Optional[Union[float, torch.Tensor]] = None, - crpix: Union[torch.Tensor, tuple] = (0.0, 0.0), - crtan: Union[torch.Tensor, tuple] = (0.0, 0.0), - crval: Union[torch.Tensor, tuple] = (0.0, 0.0), - pixelscale: Optional[Union[torch.Tensor, float]] = None, + data: Optional[ArrayLike] = None, + CD: Optional[Union[float, ArrayLike]] = None, + zeropoint: Optional[Union[float, ArrayLike]] = None, + crpix: Union[ArrayLike, tuple] = (0.0, 0.0), + crtan: Union[ArrayLike, tuple] = (0.0, 0.0), + crval: Union[ArrayLike, tuple] = (0.0, 0.0), + pixelscale: Optional[Union[ArrayLike, float]] = None, wcs: Optional[AstropyWCS] = None, filename: Optional[str] = None, hduext: int = 0, identity: str = None, name: Optional[str] = None, - _data: Optional[torch.Tensor] = None, + _data: Optional[ArrayLike] = None, ): super().__init__(name=name) if _data is None: @@ -132,14 +133,14 @@ def data(self): return self._data @data.setter - def data(self, value: Optional[torch.Tensor]): + def data(self, value: Optional[ArrayLike]): """Set the image data. If value is None, the data is initialized to an empty tensor.""" if value is None: - self._data = torch.empty((0, 0), dtype=config.DTYPE, device=config.DEVICE) + self._data = backend.empty((0, 0), dtype=config.DTYPE, device=config.DEVICE) else: # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates - self._data = torch.transpose( - torch.as_tensor(value, dtype=config.DTYPE, device=config.DEVICE), 0, 1 + self._data = backend.transpose( + backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE), 0, 1 ) @property @@ -148,11 +149,11 @@ def crpix(self) -> np.ndarray: return self._crpix @crpix.setter - def crpix(self, value: Union[torch.Tensor, tuple]): + def crpix(self, value: Union[ArrayLike, tuple]): self._crpix = np.asarray(value, dtype=np.float64) @property - def zeropoint(self) -> torch.Tensor: + def zeropoint(self) -> ArrayLike: """The zeropoint of the image, which is used to convert from pixel flux to magnitude.""" return self._zeropoint @@ -162,7 +163,7 @@ def zeropoint(self, value): if value is None: self._zeropoint = None else: - self._zeropoint = torch.as_tensor(value, dtype=config.DTYPE, device=config.DEVICE) + self._zeropoint = backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE) @property def window(self) -> Window: @@ -170,8 +171,8 @@ def window(self) -> Window: @property def center(self): - shape = torch.as_tensor(self.data.shape[:2], dtype=config.DTYPE, device=config.DEVICE) - return torch.stack(self.pixel_to_plane(*((shape - 1) / 2))) + shape = backend.as_array(self.data.shape[:2], dtype=config.DTYPE, device=config.DEVICE) + return backend.stack(self.pixel_to_plane(*((shape - 1) / 2))) @property def shape(self): @@ -182,7 +183,7 @@ def shape(self): @forward def pixel_area(self, CD): """The area inside a pixel in arcsec^2""" - return torch.linalg.det(CD).abs() + return backend.linalg.det(CD).abs() @property @forward @@ -199,32 +200,38 @@ def pixelscale(self): @forward def pixel_to_plane( - self, i: torch.Tensor, j: torch.Tensor, crtan: torch.Tensor, CD: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + self, + i: ArrayLike, + j: ArrayLike, + crtan: ArrayLike, + CD: ArrayLike, + ) -> Tuple[ArrayLike, ArrayLike]: return func.pixel_to_plane_linear(i, j, *self.crpix, CD, *crtan) @forward def plane_to_pixel( - self, x: torch.Tensor, y: torch.Tensor, crtan: torch.Tensor, CD: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + self, + x: ArrayLike, + y: ArrayLike, + crtan: ArrayLike, + CD: ArrayLike, + ) -> Tuple[ArrayLike, ArrayLike]: return func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) @forward def plane_to_world( - self, x: torch.Tensor, y: torch.Tensor, crval: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + self, x: ArrayLike, y: ArrayLike, crval: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: return func.plane_to_world_gnomonic(x, y, *crval) @forward def world_to_plane( - self, ra: torch.Tensor, dec: torch.Tensor, crval: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + self, ra: ArrayLike, dec: ArrayLike, crval: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: return func.world_to_plane_gnomonic(ra, dec, *crval) @forward - def world_to_pixel( - self, ra: torch.Tensor, dec: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + def world_to_pixel(self, ra: ArrayLike, dec: ArrayLike) -> Tuple[ArrayLike, ArrayLike]: """A wrapper which applies :meth:`world_to_plane` then :meth:`plane_to_pixel`, see those methods for further information. @@ -233,7 +240,7 @@ def world_to_pixel( return self.plane_to_pixel(*self.world_to_plane(ra, dec)) @forward - def pixel_to_world(self, i: torch.Tensor, j: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + def pixel_to_world(self, i: ArrayLike, j: ArrayLike) -> Tuple[ArrayLike, ArrayLike]: """A wrapper which applies :meth:`pixel_to_plane` then :meth:`plane_to_world`, see those methods for further information. @@ -241,49 +248,49 @@ def pixel_to_world(self, i: torch.Tensor, j: torch.Tensor) -> Tuple[torch.Tensor """ return self.plane_to_world(*self.pixel_to_plane(i, j)) - def pixel_center_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: + def pixel_center_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" return func.pixel_center_meshgrid(self.shape, config.DTYPE, config.DEVICE) - def pixel_corner_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: + def pixel_corner_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" return func.pixel_corner_meshgrid(self.shape, config.DTYPE, config.DEVICE) - def pixel_simpsons_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: + def pixel_simpsons_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" return func.pixel_simpsons_meshgrid(self.shape, config.DTYPE, config.DEVICE) - def pixel_quad_meshgrid(self, order=3) -> Tuple[torch.Tensor, torch.Tensor]: + def pixel_quad_meshgrid(self, order=3) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" return func.pixel_quad_meshgrid(self.shape, config.DTYPE, config.DEVICE, order=order) @forward - def coordinate_center_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: + def coordinate_center_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of coordinate locations in the image, centered on the pixel grid.""" i, j = self.pixel_center_meshgrid() return self.pixel_to_plane(i, j) @forward - def coordinate_corner_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: + def coordinate_corner_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of coordinate locations in the image, with corners at the pixel grid.""" i, j = self.pixel_corner_meshgrid() return self.pixel_to_plane(i, j) @forward - def coordinate_simpsons_meshgrid(self) -> Tuple[torch.Tensor, torch.Tensor]: + def coordinate_simpsons_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of coordinate locations in the image, with Simpson's rule sampling.""" i, j = self.pixel_simpsons_meshgrid() return self.pixel_to_plane(i, j) @forward - def coordinate_quad_meshgrid(self, order=3) -> Tuple[torch.Tensor, torch.Tensor]: + def coordinate_quad_meshgrid(self, order=3) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of coordinate locations in the image, with quadrature sampling.""" i, j, _ = self.pixel_quad_meshgrid(order=order) return self.pixel_to_plane(i, j) def copy_kwargs(self, **kwargs) -> dict: kwargs = { - "_data": torch.clone(self.data.detach()), + "_data": backend.copy(self.data), "CD": self.CD.value, "crpix": self.crpix, "crval": self.crval.value, @@ -309,7 +316,7 @@ def blank_copy(self, **kwargs): """ kwargs = { - "_data": torch.zeros_like(self.data), + "_data": backend.zeros_like(self.data), **kwargs, } return self.copy(**kwargs) @@ -368,7 +375,7 @@ def reduce(self, scale: int, **kwargs): - `scale` (int): The scale factor by which to reduce the image. """ if not isinstance(scale, int) and not ( - isinstance(scale, torch.Tensor) and scale.dtype is torch.int32 + isinstance(scale, ArrayLike) and scale.dtype is backend.int32 ): raise SpecificationConflict(f"Reduce scale must be an integer! not {type(scale)}") if scale == 1: @@ -398,7 +405,7 @@ def to(self, dtype=None, device=None): self.zeropoint = self.zeropoint.to(dtype=dtype, device=device) return self - def flatten(self, attribute: str = "data") -> torch.Tensor: + def flatten(self, attribute: str = "data") -> ArrayLike: return getattr(self, attribute).flatten(end_dim=1) def fits_info(self) -> dict: @@ -422,7 +429,7 @@ def fits_info(self) -> dict: def fits_images(self): return [ fits.PrimaryHDU( - torch.transpose(self.data, 0, 1).detach().cpu().numpy(), + backend.to_numpy(backend.transpose(self.data, 0, 1)), header=fits.Header(self.fits_info()), ) ] @@ -469,15 +476,17 @@ def load(self, filename: str, hduext: int = 0): self.identity = hdulist[hduext].header.get("IDNTY", str(id(self))) return hdulist - def corners(self) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: - pixel_lowleft = torch.tensor((-0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE) - pixel_lowright = torch.tensor( + def corners( + self, + ) -> Tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike]: + pixel_lowleft = backend.make_array((-0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE) + pixel_lowright = backend.make_array( (self.data.shape[0] - 0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE ) - pixel_upleft = torch.tensor( + pixel_upleft = backend.make_array( (-0.5, self.data.shape[1] - 0.5), dtype=config.DTYPE, device=config.DEVICE ) - pixel_upright = torch.tensor( + pixel_upright = backend.make_array( (self.data.shape[0] - 0.5, self.data.shape[1] - 0.5), dtype=config.DTYPE, device=config.DEVICE, @@ -624,8 +633,8 @@ def to(self, dtype=None, device=None): super().to(dtype=dtype, device=device) return self - def flatten(self, attribute: str = "data") -> torch.Tensor: - return torch.cat(tuple(image.flatten(attribute) for image in self.images)) + def flatten(self, attribute: str = "data") -> ArrayLike: + return backend.concatenate(tuple(image.flatten(attribute) for image in self.images)) def __sub__(self, other): if isinstance(other, ImageList): diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 91406d5d..0094359c 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -1,9 +1,8 @@ from typing import List, Union -import torch - from .image_object import Image, ImageList from ..errors import SpecificationConflict, InvalidImage +from ..backend import backend __all__ = ("JacobianImage", "JacobianImageList") @@ -101,7 +100,7 @@ def flatten(self, attribute: str = "data"): raise SpecificationConflict( "Jacobian image list sub-images track different parameters. Please initialize with all parameters that will be used." ) - return torch.cat(tuple(image.flatten(attribute) for image in self.images), dim=0) + return backend.concatenate(tuple(image.flatten(attribute) for image in self.images), dim=0) def match_parameters(self, other: Union[JacobianImage, "JacobianImageList", List[str]]): self_i = [] diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index c17f98b6..b0c77273 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -1,11 +1,11 @@ from typing import Union, Optional -import torch import numpy as np from astropy.io import fits from ...utils.initialize import auto_variance from ... import config +from ...backend import backend, ArrayLike from ...errors import SpecificationConflict from ..image_object import Image from ..window import Window @@ -30,12 +30,12 @@ class DataMixin: def __init__( self, *args, - mask: Optional[torch.Tensor] = None, - std: Optional[torch.Tensor] = None, - variance: Optional[torch.Tensor] = None, - weight: Optional[torch.Tensor] = None, - _mask: Optional[torch.Tensor] = None, - _weight: Optional[torch.Tensor] = None, + mask: Optional[ArrayLike] = None, + std: Optional[ArrayLike] = None, + variance: Optional[ArrayLike] = None, + weight: Optional[ArrayLike] = None, + _mask: Optional[ArrayLike] = None, + _weight: Optional[ArrayLike] = None, **kwargs, ): super().__init__(*args, **kwargs) @@ -59,8 +59,8 @@ def __init__( self.weight = weight # Set nan pixels to be masked automatically - if torch.any(torch.isnan(self.data)).item(): - self._mask = self.mask | torch.isnan(self.data) + if backend.any(backend.isnan(self.data)).item(): + self._mask = self.mask | backend.isnan(self.data) @property def std(self): @@ -75,8 +75,8 @@ def std(self): """ if self.has_variance: - return torch.sqrt(self.variance) - return torch.ones_like(self.data) + return backend.sqrt(self.variance) + return backend.ones_like(self.data) @std.setter def std(self, std): @@ -114,8 +114,8 @@ def variance(self): """ if self.has_variance: - return torch.where(self._weight == 0, torch.inf, 1 / self._weight) - return torch.ones_like(self.data) + return backend.where(self._weight == 0, backend.inf, 1 / self._weight) + return backend.ones_like(self.data) @variance.setter def variance(self, variance): @@ -167,7 +167,7 @@ def weight(self): """ if self.has_weight: return self._weight - return torch.ones_like(self.data) + return backend.ones_like(self.data) @weight.setter def weight(self, weight): @@ -176,8 +176,8 @@ def weight(self, weight): return if isinstance(weight, str) and weight == "auto": weight = 1 / auto_variance(self.data, self.mask).T - self._weight = torch.transpose( - torch.as_tensor(weight, dtype=config.DTYPE, device=config.DEVICE), 0, 1 + self._weight = backend.transpose( + backend.as_array(weight, dtype=config.DTYPE, device=config.DEVICE), 0, 1 ) if self._weight.shape != self.data.shape: self._weight = None @@ -215,15 +215,15 @@ def mask(self): """ if self.has_mask: return self._mask - return torch.zeros_like(self.data, dtype=torch.bool) + return backend.zeros_like(self.data, dtype=backend.bool) @mask.setter def mask(self, mask): if mask is None: self._mask = None return - self._mask = torch.transpose( - torch.as_tensor(mask, dtype=torch.bool, device=config.DEVICE), 0, 1 + self._mask = backend.transpose( + backend.as_tensor(mask, dtype=backend.bool, device=config.DEVICE), 0, 1 ) if self._mask.shape != self.data.shape: self._mask = None @@ -255,7 +255,7 @@ def to(self, dtype=None, device=None): if self.has_weight: self._weight = self._weight.to(dtype=dtype, device=device) if self.has_mask: - self._mask = self._mask.to(dtype=torch.bool, device=device) + self._mask = self._mask.to(dtype=backend.bool, device=device) return self def copy_kwargs(self, **kwargs): @@ -284,13 +284,14 @@ def fits_images(self): if self.has_weight: images.append( fits.ImageHDU( - torch.transpose(self.weight, 0, 1).detach().cpu().numpy(), name="WEIGHT" + backend.transpose(self.weight, 0, 1).detach().cpu().numpy(), name="WEIGHT" ) ) if self.has_mask: images.append( fits.ImageHDU( - torch.transpose(self.mask, 0, 1).detach().cpu().numpy().astype(int), name="MASK" + backend.transpose(self.mask, 0, 1).detach().cpu().numpy().astype(int), + name="MASK", ) ) return images diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 6fd01d57..5e872056 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -1,10 +1,9 @@ from typing import Union, Optional, Tuple -import torch - from ..image_object import Image from ..window import Window from .. import func +from ...backend import backend, ArrayLike from ...utils.interpolate import interp2d from ...param import forward @@ -21,9 +20,9 @@ def __init__( sipB: dict[Tuple[int, int], float] = {}, sipAP: dict[Tuple[int, int], float] = {}, sipBP: dict[Tuple[int, int], float] = {}, - pixel_area_map: Optional[torch.Tensor] = None, - distortion_ij: Optional[torch.Tensor] = None, - distortion_IJ: Optional[torch.Tensor] = None, + pixel_area_map: Optional[ArrayLike] = None, + distortion_ij: Optional[ArrayLike] = None, + distortion_IJ: Optional[ArrayLike] = None, filename: Optional[str] = None, **kwargs, ): @@ -44,16 +43,24 @@ def __init__( @forward def pixel_to_plane( - self, i: torch.Tensor, j: torch.Tensor, crtan: torch.Tensor, CD: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + self, + i: ArrayLike, + j: ArrayLike, + crtan: ArrayLike, + CD: ArrayLike, + ) -> Tuple[ArrayLike, ArrayLike]: di = interp2d(self.distortion_ij[0], i, j, padding_mode="border") dj = interp2d(self.distortion_ij[1], i, j, padding_mode="border") return func.pixel_to_plane_linear(i + di, j + dj, *self.crpix, CD, *crtan) @forward def plane_to_pixel( - self, x: torch.Tensor, y: torch.Tensor, crtan: torch.Tensor, CD: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + self, + x: ArrayLike, + y: ArrayLike, + crtan: ArrayLike, + CD: ArrayLike, + ) -> Tuple[ArrayLike, ArrayLike]: I, J = func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) dI = interp2d(self.distortion_IJ[0], I, J, padding_mode="border") dJ = interp2d(self.distortion_IJ[1], I, J, padding_mode="border") @@ -99,9 +106,9 @@ def compute_backward_sip_coefs(self): def update_distortion_model( self, - distortion_ij: Optional[torch.Tensor] = None, - distortion_IJ: Optional[torch.Tensor] = None, - pixel_area_map: Optional[torch.Tensor] = None, + distortion_ij: Optional[ArrayLike] = None, + distortion_IJ: Optional[ArrayLike] = None, + pixel_area_map: Optional[ArrayLike] = None, ): """ Update the pixel area map based on the current SIP coefficients. @@ -113,10 +120,10 @@ def update_distortion_model( i, j = self.pixel_center_meshgrid() u, v = i - self.crpix[0], j - self.crpix[1] if distortion_ij is None: - distortion_ij = torch.stack(func.sip_delta(u, v, self.sipA, self.sipB), dim=0) + distortion_ij = backend.stack(func.sip_delta(u, v, self.sipA, self.sipB), dim=0) if distortion_IJ is None: # fixme maybe - distortion_IJ = torch.stack(func.sip_delta(u, v, self.sipAP, self.sipBP), dim=0) + distortion_IJ = backend.stack(func.sip_delta(u, v, self.sipAP, self.sipBP), dim=0) self.distortion_ij = distortion_ij self.distortion_IJ = distortion_IJ diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index f46aa3d6..6793ee10 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -1,11 +1,11 @@ from typing import List, Optional -import torch import numpy as np from .image_object import Image from .jacobian_image import JacobianImage from .. import config +from ..backend import backend, ArrayLike from .mixins import DataMixin __all__ = ["PSFImage"] @@ -27,7 +27,7 @@ def __init__(self, *args, **kwargs): def normalize(self): """Normalizes the PSF image to have a sum of 1.""" - norm = torch.sum(self.data) + norm = backend.sum(self.data) self._data = self.data / norm if self.has_weight: self._weight = self.weight * norm**2 @@ -39,7 +39,7 @@ def psf_pad(self) -> int: def jacobian_image( self, parameters: Optional[List[str]] = None, - data: Optional[torch.Tensor] = None, + data: Optional[ArrayLike] = None, **kwargs, ) -> JacobianImage: """ @@ -49,7 +49,7 @@ def jacobian_image( data = None parameters = [] elif data is None: - data = torch.zeros( + data = backend.zeros( (*self.data.shape, len(parameters)), dtype=config.DTYPE, device=config.DEVICE, @@ -70,7 +70,7 @@ def model_image(self, **kwargs) -> "PSFImage": Construct a blank `ModelImage` object formatted like this current `TargetImage` object. Mostly used internally. """ kwargs = { - "data": torch.zeros_like(self.data), + "data": backend.zeros_like(self.data), "CD": self.CD.value, "crpix": self.crpix, "crtan": self.crtan.value, diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index f485bc48..4624c252 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -4,6 +4,7 @@ from .target_image import TargetImage from .model_image import ModelImage from .mixins import SIPMixin +from ..backend import backend, ArrayLike class SIPModelImage(SIPMixin, ModelImage): @@ -55,7 +56,7 @@ def reduce(self, scale: int, **kwargs): """ if not isinstance(scale, int) and not ( - isinstance(scale, torch.Tensor) and scale.dtype is torch.int32 + isinstance(scale, ArrayLike) and scale.dtype is backend.int32 ): raise SpecificationConflict(f"Reduce scale must be an integer! not {type(scale)}") if scale == 1: From 383c31108e4816bfe7b2a427d13b1f1c72ca9af0 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 13 Aug 2025 10:43:05 -0400 Subject: [PATCH 119/191] making more jax backend for AstroPhot --- astrophot/__init__.py | 2 + astrophot/{backend.py => backend_obj.py} | 129 +++++++++++++++++--- astrophot/fit/base.py | 6 +- astrophot/fit/func/lm.py | 26 ++-- astrophot/fit/func/slalom.py | 6 +- astrophot/fit/iterative.py | 18 ++- astrophot/fit/lm.py | 41 ++++--- astrophot/fit/mhmcmc.py | 6 +- astrophot/fit/scipy_fit.py | 22 ++-- astrophot/image/cmos_image.py | 2 +- astrophot/image/func/image.py | 2 +- astrophot/image/func/wcs.py | 2 +- astrophot/image/func/window.py | 2 +- astrophot/image/image_object.py | 2 +- astrophot/image/jacobian_image.py | 2 +- astrophot/image/mixins/data_mixin.py | 4 +- astrophot/image/mixins/sip_mixin.py | 2 +- astrophot/image/psf_image.py | 2 +- astrophot/image/sip_image.py | 37 +++--- astrophot/image/target_image.py | 17 ++- astrophot/models/func/convolution.py | 14 +-- astrophot/models/func/exponential.py | 6 +- astrophot/models/func/ferrer.py | 11 +- astrophot/models/func/gaussian.py | 5 +- astrophot/models/func/gaussian_ellipsoid.py | 26 ++-- astrophot/models/func/integration.py | 112 +++++++++-------- astrophot/models/func/king.py | 12 +- astrophot/models/func/moffat.py | 4 +- astrophot/models/func/nuker.py | 16 +-- astrophot/models/func/sersic.py | 5 +- astrophot/models/func/spline.py | 20 ++- astrophot/models/func/transform.py | 8 +- astrophot/models/mixins/brightness.py | 17 +-- 33 files changed, 350 insertions(+), 236 deletions(-) rename astrophot/{backend.py => backend_obj.py} (64%) diff --git a/astrophot/__init__.py b/astrophot/__init__.py index 7aa353e7..f863ad11 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -22,6 +22,7 @@ WindowList, ) from .models import Model +from .backend_obj import backend, ArrayLike try: from ._version import version as VERSION # noqa @@ -168,6 +169,7 @@ def run_from_terminal() -> None: "errors", "Module", "config", + "backend", "run_from_terminal", "__version__", "__author__", diff --git a/astrophot/backend.py b/astrophot/backend_obj.py similarity index 64% rename from astrophot/backend.py rename to astrophot/backend_obj.py index b85f134a..c3d7cc38 100644 --- a/astrophot/backend.py +++ b/astrophot/backend_obj.py @@ -33,16 +33,16 @@ def backend(self): def backend(self, backend): if backend is None: backend = os.getenv("CASKADE_BACKEND", "torch") - self.module = self._load_backend(backend) + self._load_backend(backend) self._backend = backend def _load_backend(self, backend): if backend == "torch": + self.module = importlib.import_module("torch") self.setup_torch() - return importlib.import_module("torch") elif backend == "jax": + self.module = importlib.import_module("jax.numpy") self.setup_jax() - return importlib.import_module("jax.numpy") else: raise ValueError(f"Unsupported backend: {backend}") @@ -65,6 +65,15 @@ def setup_torch(self): self.repeat = self._repeat_torch self.stack = self._stack_torch self.transpose = self._transpose_torch + self.upsample2d = self._upsample2d_torch + self.pad = self._pad_torch + self.LinAlgErr = self.module._C._LinAlgError + self.roll = self._roll_torch + self.clamp = self._clamp_torch + self.conv2d = self._conv2d_torch + self.mean = self._mean_torch + self.sum = self._sum_torch + self.topk = self._topk_torch def setup_jax(self): self.jax = importlib.import_module("jax") @@ -87,6 +96,15 @@ def setup_jax(self): self.repeat = self._repeat_jax self.stack = self._stack_jax self.transpose = self._transpose_jax + self.upsample2d = self._upsample2d_jax + self.pad = self._pad_jax + self.LinAlgErr = self.module.linalg.LinAlgError + self.roll = self._roll_jax + self.clamp = self._clamp_jax + self.conv2d = self._conv2d_jax + self.mean = self._mean_jax + self.sum = self._sum_jax + self.topk = self._topk_jax @property def array_type(self): @@ -188,11 +206,78 @@ def _logit_torch(self, array): def _logit_jax(self, array): return self.jax.scipy.special.logit(array) - def _clone_torch(self, array): - return array.clone() + def _upsample2d_torch(self, array, scale_factor, method): + U = self.module.nn.Upsample(scale_factor=scale_factor, mode=method) + array = U(array) / scale_factor**2 + return array - def _clone_jax(self, array): - return self.module.copy(array) + def _upsample2d_jax(self, array, scale_factor, method): + if method == "nearest": + method = "bilinear" # no nearest neighbor interpolation in jax + new_shape = list(array.shape) + new_shape[-2] = array.shape[-2] * scale_factor + new_shape[-1] = array.shape[-1] * scale_factor + return self.jax.image.resize(array, new_shape, method=method) + + def _pad_torch(self, array, padding, mode): + return self.module.nn.functional.pad(array, padding, mode=mode) + + def _pad_jax(self, array, padding, mode): + if mode == "replicate": + mode = "edge" + return self.module.pad(array, padding, mode=mode) + + def _roll_torch(self, array, shifts, dims): + return self.module.roll(array, shifts, dims=dims) + + def _roll_jax(self, array, shifts, dims): + return self.jax.roll(array, shifts, axis=dims) + + def _clamp_torch(self, array, min, max): + return self.module.clamp(array, min, max) + + def _clamp_jax(self, array, min, max): + return self.jax.clip(array, min, max) + + def _conv2d_torch(self, input, kernel, padding, stride=1): + return self.module.nn.functional.conv2d( + input, + kernel, + padding=padding, + stride=stride, + ) + + def _conv2d_jax(self, input, kernel, padding, stride=(1, 1)): + return self.jax.lax.conv_general_dilated( + input, kernel, window_strides=stride, padding=padding + ) + + def _mean_torch(self, array, dim=None): + return self.module.mean(array, dim=dim) + + def _mean_jax(self, array, dim=None): + return self.module.mean(array, axis=dim) + + def _sum_torch(self, array, dim=None): + return self.module.sum(array, dim=dim) + + def _sum_jax(self, array, dim=None): + return self.jax.numpy.sum(array, axis=dim) + + def _topk_torch(self, array, k, dim=None): + return self.module.topk(array, k=k, dim=dim) + + def _topk_jax(self, array, k, dim=None): + return self.jax.lax.top_k(array, k=k, axis=dim) + + def linspace(self, start, end, steps, dtype=None, device=None): + return self.module.linspace(start, end, steps, dtype=dtype, device=device) + + def arange(self, start, end=None, step=1, dtype=None, device=None): + return self.module.arange(start, end, step=step, dtype=dtype, device=device) + + def searchsorted(self, array, value): + return self.module.searchsorted(array, value) def any(self, array): return self.module.any(array) @@ -215,6 +300,9 @@ def cos(self, array): def sqrt(self, array): return self.module.sqrt(array) + def abs(self, array): + return self.module.abs(array) + def arctan(self, array): return self.module.arctan(array) @@ -224,24 +312,27 @@ def arctan2(self, y, x): def arcsin(self, array): return self.module.arcsin(array) - def sum(self, array, axis=None): - return self.module.sum(array, axis=axis) - def zeros(self, shape, dtype=None, device=None): return self.module.zeros(shape, dtype=dtype, device=device) - def zeros_like(self, array): - return self.module.zeros_like(array) + def zeros_like(self, array, dtype=None): + return self.module.zeros_like(array, dtype=dtype) def ones(self, shape, dtype=None, device=None): return self.module.ones(shape, dtype=dtype, device=device) - def ones_like(self, array): - return self.module.ones_like(array) + def ones_like(self, array, dtype=None): + return self.module.ones_like(array, dtype=dtype) def empty(self, shape, dtype=None, device=None): return self.module.empty(shape, dtype=dtype, device=device) + def eye(self, n, dtype=None, device=None): + return self.module.eye(n, dtype=dtype, device=device) + + def diag(self, array): + return self.module.diag(array) + def minimum(self, a, b): return self.module.minimum(a, b) @@ -251,13 +342,23 @@ def maximum(self, a, b): def isnan(self, array): return self.module.isnan(array) + def isfinite(self, array): + return self.module.isfinite(array) + def where(self, condition, x, y): return self.module.where(condition, x, y) + def allclose(self, a, b, rtol=1e-5, atol=1e-8): + return self.module.allclose(a, b, rtol=rtol, atol=atol) + @property def linalg(self): return self.module.linalg + @property + def fft(self): + return self.module.fft + @property def inf(self): return self.module.inf diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index d571f45a..d90d0b07 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -1,11 +1,11 @@ from typing import Sequence, Optional import numpy as np -import torch from scipy.optimize import minimize from scipy.special import gammainc from .. import config +from ..backend_obj import backend, ArrayLike from ..models import Model from ..image import Window @@ -47,7 +47,7 @@ def __init__( if initial_state is None: self.current_state = model.build_params_array() else: - self.current_state = torch.as_tensor( + self.current_state = backend.as_array( initial_state, dtype=model.dtype, device=model.device ) @@ -69,7 +69,7 @@ def __init__( def fit(self) -> "BaseOptimizer": raise NotImplementedError("Please use a subclass of BaseOptimizer for optimization") - def step(self, current_state: torch.Tensor = None) -> None: + def step(self, current_state: ArrayLike = None) -> None: raise NotImplementedError("Please use a subclass of BaseOptimizer for optimization") def chi2min(self) -> float: diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 8d892502..d06c9a46 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -1,7 +1,7 @@ -import torch import numpy as np from ...errors import OptimizeStopFail, OptimizeStopSuccess +from ...backend_obj import backend def nll(D, M, W): @@ -11,7 +11,7 @@ def nll(D, M, W): M: model prediction W: weights """ - return 0.5 * torch.sum(W * (D - M) ** 2) + return 0.5 * backend.sum(W * (D - M) ** 2) def nll_poisson(D, M): @@ -20,7 +20,7 @@ def nll_poisson(D, M): D: data M: model prediction """ - return torch.sum(M - D * torch.log(M + 1e-10)) # Adding small value to avoid log(0) + return backend.sum(M - D * backend.log(M + 1e-10)) # Adding small value to avoid log(0) def gradient(J, W, D, M): @@ -40,19 +40,19 @@ def hessian_poisson(J, D, M): def damp_hessian(hess, L): - I = torch.eye(len(hess), dtype=hess.dtype, device=hess.device) - D = torch.ones_like(hess) - I - return hess * (I + D / (1 + L)) + L * I * torch.diag(hess) + I = backend.eye(len(hess), dtype=hess.dtype, device=hess.device) + D = backend.ones_like(hess) - I + return hess * (I + D / (1 + L)) + L * I * backend.diag(hess) def solve(hess, grad, L): hessD = damp_hessian(hess, L) # (N, N) while True: try: - h = torch.linalg.solve(hessD, grad) + h = backend.linalg.solve(hessD, grad) break - except torch._C._LinAlgError: - hessD = hessD + L * torch.eye(len(hessD), dtype=hessD.dtype, device=hessD.device) + except backend.LinAlgErr: + hessD = hessD + L * backend.eye(len(hessD), dtype=hessD.dtype, device=hessD.device) L = L * 2 return hessD, h @@ -84,10 +84,10 @@ def lm_step( else: raise ValueError(f"Unsupported likelihood: {likelihood}") - if torch.allclose(grad, torch.zeros_like(grad)): + if backend.allclose(grad, backend.zeros_like(grad)): raise OptimizeStopSuccess("Gradient is zero, optimization converged.") - best = {"x": torch.zeros_like(x), "nll": nll0, "L": L} + best = {"x": backend.zeros_like(x), "nll": nll0, "L": L} scary = {"x": None, "nll": np.inf, "L": None, "rho": np.inf} nostep = True improving = None @@ -107,11 +107,11 @@ def lm_step( improving = False continue - if torch.allclose(h, torch.zeros_like(h)) and L < 0.1: + if backend.allclose(h, backend.zeros_like(h)) and L < 0.1: raise OptimizeStopSuccess("Step with zero length means optimization complete.") # actual nll improvement vs expected from linearization - rho = (nll0 - nll1) / torch.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() + rho = (nll0 - nll1) / backend.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() if (nll1 < (nll0 + tolerance) and abs(rho - 1) < abs(scary["rho"] - 1)) or ( nll1 < scary["nll"] and rho > -10 diff --git a/astrophot/fit/func/slalom.py b/astrophot/fit/func/slalom.py index 479d65e7..1bb76d68 100644 --- a/astrophot/fit/func/slalom.py +++ b/astrophot/fit/func/slalom.py @@ -1,18 +1,18 @@ import numpy as np -import torch from ...errors import OptimizeStopFail, OptimizeStopSuccess +from ...backend_obj import backend def slalom_step(f, g, x0, m, S, N=10, up=1.3, down=0.5): l = [f(x0).item()] d = [0.0] grad = g(x0) - if torch.allclose(grad, torch.zeros_like(grad)): + if backend.allclose(grad, backend.zeros_like(grad)): raise OptimizeStopSuccess("success: Gradient is zero, optimization converged.") D = grad + m - D = D / torch.linalg.norm(D) + D = D / backend.linalg.norm(D) seeking = False for _ in range(N): l.append(f(x0 - S * D).item()) diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 3baa23bc..9625e89e 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -1,8 +1,6 @@ # Apply a different optimizer iteratively -from typing import Dict, Any, Sequence, Union -import os +from typing import Dict, Any from time import time -import random import numpy as np import torch @@ -11,6 +9,7 @@ from ..models import Model from .lm import LM from .. import config +from ..backend_obj import backend __all__ = [ "Iter", @@ -60,7 +59,7 @@ def __init__( ) if self.model.target.has_mask: # subtract masked pixels from degrees of freedom - self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() + self.ndf -= backend.sum(self.model.target[self.model.window].flatten("mask")).item() def sub_step(self, model: Model, update_uncertainty=False): """ @@ -103,15 +102,12 @@ def step(self): ) if self.model.target.has_mask: M = self.model.target[self.model.window].flatten("mask") - loss = ( - torch.sum((((D - self.Y.flatten("data")) ** 2) / V)[torch.logical_not(M)]) - / self.ndf - ) + loss = backend.sum((((D - self.Y.flatten("data")) ** 2) / V)[~M]) / self.ndf else: - loss = torch.sum(((D - self.Y.flatten("data")) ** 2 / V)) / self.ndf + loss = backend.sum(((D - self.Y.flatten("data")) ** 2 / V)) / self.ndf if self.verbose > 0: config.logger.info(f"Loss: {loss.item()}") - self.lambda_history.append(np.copy((self.current_state).detach().cpu().numpy())) + self.lambda_history.append(np.copy(backend.to_numpy(self.current_state))) self.loss_history.append(loss.item()) # Test for convergence @@ -147,7 +143,7 @@ def fit(self) -> BaseOptimizer: self.message = self.message + "fail interrupted" self.model.fill_dynamic_values( - torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) + backend.as_array(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if self.verbose > 1: config.logger.info( diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 6896d617..dd2748c8 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -6,6 +6,7 @@ from .base import BaseOptimizer from .. import config +from ..backend_obj import backend, ArrayLike from . import func from ..errors import OptimizeStopFail, OptimizeStopSuccess from ..param import ValidContext @@ -150,10 +151,10 @@ def __init__( # mask fit_mask = self.model.fit_mask() if isinstance(fit_mask, tuple): - fit_mask = torch.cat(tuple(FM.flatten() for FM in fit_mask)) + fit_mask = backend.concatenate(tuple(FM.flatten() for FM in fit_mask)) else: fit_mask = fit_mask.flatten() - if torch.sum(fit_mask).item() == 0: + if backend.sum(fit_mask).item() == 0: fit_mask = None if model.target.has_mask: @@ -164,10 +165,10 @@ def __init__( elif fit_mask is not None: self.mask = ~fit_mask else: - self.mask = torch.ones_like( - self.model.target[self.fit_window].flatten("data"), dtype=torch.bool + self.mask = backend.ones_like( + self.model.target[self.fit_window].flatten("data"), dtype=backend.bool ) - if self.mask is not None and torch.sum(self.mask).item() == 0: + if self.mask is not None and backend.sum(self.mask).item() == 0: raise OptimizeStopSuccess("No data to fit. All pixels are masked") # Initialize optimizer attributes @@ -176,13 +177,13 @@ def __init__( # 1 / (sigma^2) kW = kwargs.get("W", None) if kW is not None: - self.W = torch.as_tensor(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ + self.W = backend.as_array(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ self.mask ] elif model.target.has_weight: self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] else: - self.W = torch.ones_like(self.Y) + self.W = backend.ones_like(self.Y) # The forward model which computes the output image given input parameters self.forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[self.mask] @@ -201,11 +202,11 @@ def __init__( self.ndf = ndf def chi2_ndf(self): - return torch.sum(self.W * (self.Y - self.forward(self.current_state)) ** 2) / self.ndf + return backend.sum(self.W * (self.Y - self.forward(self.current_state)) ** 2) / self.ndf def poisson_2nll_ndf(self): M = self.forward(self.current_state) - return 2 * torch.sum(M - self.Y * torch.log(M + 1e-10)) / self.ndf + return 2 * backend.sum(M - self.Y * backend.log(M + 1e-10)) / self.ndf @torch.no_grad() def fit(self, update_uncertainty=True) -> BaseOptimizer: @@ -232,7 +233,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.loss_history = [self.poisson_2nll_ndf().item()] self._covariance_matrix = None self.L_history = [self.L] - self.lambda_history = [self.current_state.detach().clone().cpu().numpy()] + self.lambda_history = [backend.to_numpy(backend.copy(self.current_state))] if self.verbose > 0: config.logger.info( f"==Starting LM fit for '{self.model.name}' with {len(self.current_state)} dynamic parameters and {len(self.Y)} pixels==" @@ -255,7 +256,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: Ldn=self.Ldn, likelihood=self.likelihood, ) - self.current_state = self.model.from_valid(res["x"]).detach() + self.current_state = self.model.from_valid(backend.copy(res["x"])) else: res = func.lm_step( x=self.current_state, @@ -268,7 +269,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: Ldn=self.Ldn, likelihood=self.likelihood, ) - self.current_state = res["x"].detach() + self.current_state = backend.copy(res["x"]) except OptimizeStopFail: if self.verbose > 0: config.logger.warning("Could not find step to improve Chi^2, stopping") @@ -286,7 +287,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: self.L = np.clip(res["L"], 1e-9, 1e9) self.L_history.append(res["L"]) self.loss_history.append(2 * res["nll"] / self.ndf) - self.lambda_history.append(self.current_state.detach().clone().cpu().numpy()) + self.lambda_history.append(backend.to_numpy(backend.copy(self.current_state))) if self.check_convergence(): break @@ -300,7 +301,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: ) self.model.fill_dynamic_values( - torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) + backend.as_array(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if update_uncertainty: self.update_uncertainty() @@ -336,7 +337,7 @@ def check_convergence(self) -> bool: @property @torch.no_grad() - def covariance_matrix(self) -> torch.Tensor: + def covariance_matrix(self) -> ArrayLike: """The covariance matrix for the model at the current parameters. This can be used to construct a full Gaussian PDF for the parameters using: $\\mathcal{N}(\\mu,\\Sigma)$ where $\\mu$ is the @@ -352,12 +353,12 @@ def covariance_matrix(self) -> torch.Tensor: elif self.likelihood == "poisson": hess = func.hessian_poisson(J, self.Y, self.forward(self.current_state)) try: - self._covariance_matrix = torch.linalg.inv(hess) + self._covariance_matrix = backend.linalg.inv(hess) except: config.logger.warning( "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will use pseudo-inverse of Hessian to continue but results should be inspected." ) - self._covariance_matrix = torch.linalg.pinv(hess) + self._covariance_matrix = backend.linalg.pinv(hess) return self._covariance_matrix @torch.no_grad() @@ -370,9 +371,11 @@ def update_uncertainty(self) -> None: """ # set the uncertainty for each parameter cov = self.covariance_matrix - if torch.all(torch.isfinite(cov)): + if backend.all(backend.isfinite(cov)): try: - self.model.fill_dynamic_value_uncertainties(torch.sqrt(torch.abs(torch.diag(cov)))) + self.model.fill_dynamic_value_uncertainties( + backend.sqrt(backend.abs(backend.diag(cov))) + ) except RuntimeError as e: config.logger.warning(f"Unable to update uncertainty due to: {e}") else: diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index 0ae021a7..3f3db269 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -1,7 +1,6 @@ # Metropolis-Hasting Markov-Chain Monte-Carlo from typing import Optional, Sequence -import torch import numpy as np try: @@ -12,6 +11,7 @@ from .base import BaseOptimizer from ..models import Model from .. import config +from ..backend_obj import backend __all__ = ["MHMCMC"] @@ -53,7 +53,7 @@ def density(self, state: np.ndarray) -> np.ndarray: Returns the density of the model at the given state vector. This is used to calculate the likelihood of the model at the given state. """ - state = torch.tensor(state, dtype=config.DTYPE, device=config.DEVICE) + state = backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) if self.likelihood == "gaussian": return np.array(list(self.model.gaussian_log_likelihood(s).item() for s in state)) elif self.likelihood == "poisson": @@ -92,6 +92,6 @@ def fit( else: self.chain = np.append(self.chain, sampler.get_chain(flat=flat_chain), axis=0) self.model.fill_dynamic_values( - torch.tensor(self.chain[-1], dtype=config.DTYPE, device=config.DEVICE) + backend.as_array(self.chain[-1], dtype=config.DTYPE, device=config.DEVICE) ) return self diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py index e6126d65..5b6c7e45 100644 --- a/astrophot/fit/scipy_fit.py +++ b/astrophot/fit/scipy_fit.py @@ -1,10 +1,11 @@ from typing import Sequence, Literal -import torch from scipy.optimize import minimize +import numpy as np from .base import BaseOptimizer from .. import config +from ..backend_obj import backend __all__ = ("ScipyFit",) @@ -44,7 +45,10 @@ def __init__( # Degrees of freedom if ndf is None: sub_target = self.model.target[self.model.window] - ndf = sub_target.flatten("data").numel() - torch.sum(sub_target.flatten("mask")).item() + ndf = ( + np.prod(sub_target.flatten("data").shape) + - backend.sum(sub_target.flatten("mask")).item() + ) self.ndf = max(1.0, ndf - len(self.current_state)) else: self.ndf = ndf @@ -56,28 +60,28 @@ def numpy_bounds(self): if param.shape == (): bound = [None, None] if param.valid[0] is not None: - bound[0] = param.valid[0].detach().cpu().numpy() + bound[0] = backend.to_numpy(param.valid[0]) if param.valid[1] is not None: - bound[1] = param.valid[1].detach().cpu().numpy() + bound[1] = backend.to_numpy(param.valid[1]) bounds.append(tuple(bound)) else: for i in range(param.value.numel()): bound = [None, None] if param.valid[0] is not None: - bound[0] = param.valid[0].flatten()[i].detach().cpu().numpy() + bound[0] = backend.to_numpy(param.valid[0].flatten()[i]) if param.valid[1] is not None: - bound[1] = param.valid[1].flatten()[i].detach().cpu().numpy() + bound[1] = backend.to_numpy(param.valid[1].flatten()[i]) bounds.append(tuple(bound)) return bounds def density(self, state: Sequence) -> float: if self.likelihood == "gaussian": return -self.model.gaussian_log_likelihood( - torch.tensor(state, dtype=config.DTYPE, device=config.DEVICE) + backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) ).item() elif self.likelihood == "poisson": return -self.model.poisson_log_likelihood( - torch.tensor(state, dtype=config.DTYPE, device=config.DEVICE) + backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) ).item() else: raise ValueError(f"Unknown likelihood type: {self.likelihood}") @@ -95,7 +99,7 @@ def fit(self): ) self.scipy_res = res self.message = self.message + f"success: {res.success}, message: {res.message}" - self.current_state = torch.tensor(res.x, dtype=config.DTYPE, device=config.DEVICE) + self.current_state = backend.as_array(res.x, dtype=config.DTYPE, device=config.DEVICE) if self.verbose > 0: config.logger.info( f"Final 2NLL/DoF: {2*self.density(res.x)/self.ndf:.6g}. Converged: {self.message}" diff --git a/astrophot/image/cmos_image.py b/astrophot/image/cmos_image.py index dc0e6382..2083c724 100644 --- a/astrophot/image/cmos_image.py +++ b/astrophot/image/cmos_image.py @@ -1,7 +1,7 @@ from .target_image import TargetImage from .mixins import CMOSMixin from .model_image import ModelImage -from ..backend import backend +from ..backend_obj import backend class CMOSModelImage(CMOSMixin, ModelImage): diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index 8bc387c5..74737a1f 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -1,5 +1,5 @@ from ...utils.integration import quad_table -from ...backend import backend, ArrayLike +from ...backend_obj import backend, ArrayLike def pixel_center_meshgrid(shape: tuple[int, int], dtype, device) -> tuple: diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 36e4b9aa..70547b3a 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -1,5 +1,5 @@ import numpy as np -from ...backend import backend +from ...backend_obj import backend deg_to_rad = np.pi / 180 rad_to_deg = 180 / np.pi diff --git a/astrophot/image/func/window.py b/astrophot/image/func/window.py index 4daade6d..46be8061 100644 --- a/astrophot/image/func/window.py +++ b/astrophot/image/func/window.py @@ -1,4 +1,4 @@ -from ...backend import backend +from ...backend_obj import backend def window_or(other_origin, self_end, other_end): diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index b3aa659f..8aab67e8 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -7,7 +7,7 @@ from ..param import Module, Param, forward from .. import config -from ..backend import backend, ArrayLike +from ..backend_obj import backend, ArrayLike from ..utils.conversions.units import deg_to_arcsec, arcsec_to_deg from .window import Window, WindowList from ..errors import InvalidImage, SpecificationConflict diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 0094359c..8e494429 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -2,7 +2,7 @@ from .image_object import Image, ImageList from ..errors import SpecificationConflict, InvalidImage -from ..backend import backend +from ..backend_obj import backend __all__ = ("JacobianImage", "JacobianImageList") diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index b0c77273..51aabccd 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -5,7 +5,7 @@ from ...utils.initialize import auto_variance from ... import config -from ...backend import backend, ArrayLike +from ...backend_obj import backend, ArrayLike from ...errors import SpecificationConflict from ..image_object import Image from ..window import Window @@ -223,7 +223,7 @@ def mask(self, mask): self._mask = None return self._mask = backend.transpose( - backend.as_tensor(mask, dtype=backend.bool, device=config.DEVICE), 0, 1 + backend.as_array(mask, dtype=backend.bool, device=config.DEVICE), 0, 1 ) if self._mask.shape != self.data.shape: self._mask = None diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 5e872056..fb40ae6f 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -3,7 +3,7 @@ from ..image_object import Image from ..window import Window from .. import func -from ...backend import backend, ArrayLike +from ...backend_obj import backend, ArrayLike from ...utils.interpolate import interp2d from ...param import forward diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 6793ee10..95aeec0c 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -5,7 +5,7 @@ from .image_object import Image from .jacobian_image import JacobianImage from .. import config -from ..backend import backend, ArrayLike +from ..backend_obj import backend, ArrayLike from .mixins import DataMixin __all__ = ["PSFImage"] diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index 4624c252..e463d9b8 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -1,10 +1,9 @@ from typing import Tuple, Union -import torch from .target_image import TargetImage from .model_image import ModelImage from .mixins import SIPMixin -from ..backend import backend, ArrayLike +from ..backend_obj import backend, ArrayLike class SIPModelImage(SIPMixin, ModelImage): @@ -104,30 +103,32 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> SIPModelImag new_distortion_ij = self.distortion_ij new_distortion_IJ = self.distortion_IJ if upsample > 1: - U = torch.nn.Upsample(scale_factor=upsample, mode="nearest") new_area_map = ( - U(new_area_map.unsqueeze(0).unsqueeze(0)).squeeze(0).squeeze(0) / upsample**2 + backend.upsample2d(new_area_map.unsqueeze(0).unsqueeze(0), upsample, "nearest") + .squeeze(0) + .squeeze(0) ) - U = torch.nn.Upsample(scale_factor=upsample, mode="bilinear", align_corners=False) - new_distortion_ij = U(self.distortion_ij.unsqueeze(1)).squeeze(1) - new_distortion_IJ = U(self.distortion_IJ.unsqueeze(1)).squeeze(1) + new_distortion_ij = backend.upsample2d( + new_distortion_ij.unsqueeze(1), upsample, "bilinear" + ).squeeze(1) + new_distortion_IJ = backend.upsample2d( + new_distortion_IJ.unsqueeze(1), upsample, "bilinear" + ).squeeze(1) if pad > 0: new_area_map = ( - torch.nn.functional.pad( - new_area_map.unsqueeze(0).unsqueeze(0), (pad, pad, pad, pad), mode="replicate" + backend.pad( + new_area_map.unsqueeze(0).unsqueeze(0), + (pad, pad, pad, pad), + mode="replicate", ) .squeeze(0) .squeeze(0) ) - new_distortion_ij = torch.nn.functional.pad( - new_distortion_ij.unsqueeze(1), - (pad, pad, pad, pad), - mode="replicate", + new_distortion_ij = backend.pad( + new_distortion_ij.unsqueeze(1), (pad, pad, pad, pad), mode="replicate" ).squeeze(1) - new_distortion_IJ = torch.nn.functional.pad( - new_distortion_IJ.unsqueeze(1), - (pad, pad, pad, pad), - mode="replicate", + new_distortion_IJ = backend.pad( + new_distortion_IJ.unsqueeze(1), (pad, pad, pad, pad), mode="replicate" ).squeeze(1) kwargs = { "pixel_area_map": new_area_map, @@ -137,7 +138,7 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> SIPModelImag "sipBP": self.sipBP, "distortion_ij": new_distortion_ij, "distortion_IJ": new_distortion_IJ, - "_data": torch.zeros( + "_data": backend.zeros( (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), dtype=self.data.dtype, device=self.data.device, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index f10361f9..1fd4a652 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -1,15 +1,14 @@ -from typing import List, Optional, Tuple +from typing import List, Optional import numpy as np -import torch from astropy.io import fits from .image_object import Image, ImageList -from .window import Window from .jacobian_image import JacobianImage, JacobianImageList from .model_image import ModelImage, ModelImageList from .psf_image import PSFImage from .. import config +from ..backend_obj import backend, ArrayLike from ..errors import InvalidImage from .mixins import DataMixin from ..utils.decorators import combine_docstrings @@ -151,7 +150,7 @@ def fits_images(self): if isinstance(self.psf, PSFImage): images.append( fits.ImageHDU( - torch.transpose(self.psf.data, 0, 1).detach().cpu().numpy(), + backend.transpose(self.psf.data, 0, 1).detach().cpu().numpy(), name="PSF", header=fits.Header(self.psf.fits_info()), ) @@ -179,14 +178,14 @@ def load(self, filename: str, hduext: int = 0): def jacobian_image( self, parameters: List[str], - data: Optional[torch.Tensor] = None, + data: Optional[ArrayLike] = None, **kwargs, ) -> JacobianImage: """ Construct a blank `JacobianImage` object formatted like this current `TargetImage` object. Mostly used internally. """ if data is None: - data = torch.zeros( + data = backend.zeros( (*self.data.shape, len(parameters)), dtype=config.DTYPE, device=config.DEVICE, @@ -208,7 +207,7 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> ModelImage: Construct a blank `ModelImage` object formatted like this current `TargetImage` object. Mostly used internally. """ kwargs = { - "_data": torch.zeros( + "_data": backend.zeros( (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), dtype=self.data.dtype, device=self.data.device, @@ -224,7 +223,7 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> ModelImage: } return ModelImage(**kwargs) - def psf_image(self, data: torch.Tensor, upscale: int = 1, **kwargs) -> PSFImage: + def psf_image(self, data: ArrayLike, upscale: int = 1, **kwargs) -> PSFImage: kwargs = { "data": data, "CD": self.CD.value / upscale, @@ -288,7 +287,7 @@ def has_weight(self): return any(image.has_weight for image in self.images) def jacobian_image( - self, parameters: List[str], data: Optional[List[torch.Tensor]] = None + self, parameters: List[str], data: Optional[List[ArrayLike]] = None ) -> JacobianImageList: if data is None: data = tuple(None for _ in range(len(self.images))) diff --git a/astrophot/models/func/convolution.py b/astrophot/models/func/convolution.py index 44be804a..aea0ecbc 100644 --- a/astrophot/models/func/convolution.py +++ b/astrophot/models/func/convolution.py @@ -1,16 +1,16 @@ from functools import lru_cache -import torch +from ...backend_obj import backend, ArrayLike -def convolve(image: torch.Tensor, psf: torch.Tensor) -> torch.Tensor: +def convolve(image: ArrayLike, psf: ArrayLike) -> ArrayLike: - image_fft = torch.fft.rfft2(image, s=image.shape) - psf_fft = torch.fft.rfft2(psf, s=image.shape) + image_fft = backend.fft.rfft2(image, s=image.shape) + psf_fft = backend.fft.rfft2(psf, s=image.shape) convolved_fft = image_fft * psf_fft - convolved = torch.fft.irfft2(convolved_fft, s=image.shape) - return torch.roll( + convolved = backend.fft.irfft2(convolved_fft, s=image.shape) + return backend.roll( convolved, shifts=(-(psf.shape[0] // 2), -(psf.shape[1] // 2)), dims=(0, 1), @@ -19,7 +19,7 @@ def convolve(image: torch.Tensor, psf: torch.Tensor) -> torch.Tensor: @lru_cache(maxsize=32) def curvature_kernel(dtype, device): - kernel = torch.tensor( + kernel = backend.as_array( [ [0.0, 1.0, 0.0], [1.0, -4.0, 1.0], diff --git a/astrophot/models/func/exponential.py b/astrophot/models/func/exponential.py index 8c4bf62b..91fe4250 100644 --- a/astrophot/models/func/exponential.py +++ b/astrophot/models/func/exponential.py @@ -1,10 +1,10 @@ -import torch +from ...backend_obj import backend, ArrayLike from .sersic import sersic_n_to_b b = sersic_n_to_b(1.0) -def exponential(R: torch.Tensor, Re: torch.Tensor, Ie: torch.Tensor) -> torch.Tensor: +def exponential(R: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: """Exponential 1d profile function, specifically designed for pytorch operations. @@ -13,4 +13,4 @@ def exponential(R: torch.Tensor, Re: torch.Tensor, Ie: torch.Tensor) -> torch.Te - `Re`: Effective radius in the same units as R - `Ie`: Effective surface density """ - return Ie * torch.exp(-b * ((R / Re) - 1.0)) + return Ie * backend.exp(-b * ((R / Re) - 1.0)) diff --git a/astrophot/models/func/ferrer.py b/astrophot/models/func/ferrer.py index 09f06a3f..b34c82db 100644 --- a/astrophot/models/func/ferrer.py +++ b/astrophot/models/func/ferrer.py @@ -1,9 +1,10 @@ import torch +from ...backend_obj import backend, ArrayLike def ferrer( - R: torch.Tensor, rout: torch.Tensor, alpha: torch.Tensor, beta: torch.Tensor, I0: torch.Tensor -) -> torch.Tensor: + R: ArrayLike, rout: ArrayLike, alpha: ArrayLike, beta: ArrayLike, I0: ArrayLike +) -> ArrayLike: """ Modified Ferrer profile. @@ -14,8 +15,8 @@ def ferrer( - `beta`: Exponent for the modified Ferrer function - `I0`: Central intensity """ - return torch.where( + return backend.where( R < rout, - I0 * ((1 - (torch.clamp(R, 0, rout) / rout) ** (2 - beta)) ** alpha), - torch.zeros_like(R), + I0 * ((1 - (backend.clamp(R, 0, rout) / rout) ** (2 - beta)) ** alpha), + backend.zeros_like(R), ) diff --git a/astrophot/models/func/gaussian.py b/astrophot/models/func/gaussian.py index 780b1b26..7a4085e1 100644 --- a/astrophot/models/func/gaussian.py +++ b/astrophot/models/func/gaussian.py @@ -1,10 +1,11 @@ import torch +from ...backend_obj import backend, ArrayLike import numpy as np sq_2pi = np.sqrt(2 * np.pi) -def gaussian(R: torch.Tensor, sigma: torch.Tensor, flux: torch.Tensor) -> torch.Tensor: +def gaussian(R: ArrayLike, sigma: ArrayLike, flux: ArrayLike) -> ArrayLike: """Gaussian 1d profile function, specifically designed for pytorch operations. @@ -13,4 +14,4 @@ def gaussian(R: torch.Tensor, sigma: torch.Tensor, flux: torch.Tensor) -> torch. - `sigma`: Standard deviation of the gaussian in the same units as R - `flux`: Central surface density """ - return (flux / (sq_2pi * sigma)) * torch.exp(-0.5 * torch.pow(R / sigma, 2)) + return (flux / (sq_2pi * sigma)) * backend.exp(-0.5 * (R / sigma) ** 2) diff --git a/astrophot/models/func/gaussian_ellipsoid.py b/astrophot/models/func/gaussian_ellipsoid.py index d66317e4..2a989f61 100644 --- a/astrophot/models/func/gaussian_ellipsoid.py +++ b/astrophot/models/func/gaussian_ellipsoid.py @@ -1,25 +1,23 @@ -import torch +from ...backend_obj import backend, ArrayLike -def euler_rotation_matrix( - alpha: torch.Tensor, beta: torch.Tensor, gamma: torch.Tensor -) -> torch.Tensor: +def euler_rotation_matrix(alpha: ArrayLike, beta: ArrayLike, gamma: ArrayLike) -> ArrayLike: """Compute the rotation matrix from Euler angles. See the Z_alpha X_beta Z_gamma convention for the order of rotations here: https://en.wikipedia.org/wiki/Euler_angles """ - ca = torch.cos(alpha) - sa = torch.sin(alpha) - cb = torch.cos(beta) - sb = torch.sin(beta) - cg = torch.cos(gamma) - sg = torch.sin(gamma) - R = torch.stack( + ca = backend.cos(alpha) + sa = backend.sin(alpha) + cb = backend.cos(beta) + sb = backend.sin(beta) + cg = backend.cos(gamma) + sg = backend.sin(gamma) + R = backend.stack( ( - torch.stack((ca * cg - cb * sa * sg, -ca * sg - cb * cg * sa, sb * sa)), - torch.stack((cg * sa + ca * cb * sg, ca * cb * cg - sa * sg, -ca * sb)), - torch.stack((sb * cg, sb * cg, cb)), + backend.stack((ca * cg - cb * sa * sg, -ca * sg - cb * cg * sa, sb * sa)), + backend.stack((cg * sa + ca * cb * sg, ca * cb * cg - sa * sg, -ca * sb)), + backend.stack((sb * cg, sb * cg, cb)), ), dim=-1, ) diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index 4a344257..0b622c2c 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -3,27 +3,29 @@ import numpy as np from ...utils.integration import quad_table +from ...backend_obj import backend, ArrayLike -def pixel_center_integrator(Z: torch.Tensor) -> torch.Tensor: +def pixel_center_integrator(Z: ArrayLike) -> ArrayLike: return Z -def pixel_corner_integrator(Z: torch.Tensor) -> torch.Tensor: - kernel = torch.ones((1, 1, 2, 2), dtype=Z.dtype, device=Z.device) / 4.0 - Z = torch.nn.functional.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid") +def pixel_corner_integrator(Z: ArrayLike) -> ArrayLike: + kernel = backend.ones((1, 1, 2, 2), dtype=Z.dtype, device=Z.device) / 4.0 + Z = backend.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid") return Z.squeeze(0).squeeze(0) -def pixel_simpsons_integrator(Z: torch.Tensor) -> torch.Tensor: +def pixel_simpsons_integrator(Z: ArrayLike) -> ArrayLike: kernel = ( - torch.tensor([[[[1, 4, 1], [4, 16, 4], [1, 4, 1]]]], dtype=Z.dtype, device=Z.device) / 36.0 + backend.as_array([[[[1, 4, 1], [4, 16, 4], [1, 4, 1]]]], dtype=Z.dtype, device=Z.device) + / 36.0 ) - Z = torch.nn.functional.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid", stride=2) + Z = backend.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid", stride=2) return Z.squeeze(0).squeeze(0) -def pixel_quad_integrator(Z: torch.Tensor, w: torch.Tensor = None, order: int = 3) -> torch.Tensor: +def pixel_quad_integrator(Z: ArrayLike, w: ArrayLike = None, order: int = 3) -> ArrayLike: """ Integrate the pixel values using quadrature weights. @@ -38,32 +40,32 @@ def pixel_quad_integrator(Z: torch.Tensor, w: torch.Tensor = None, order: int = return Z.sum(dim=(-1)) -def upsample( - i: torch.Tensor, j: torch.Tensor, order: int, scale: float -) -> Tuple[torch.Tensor, torch.Tensor]: - dp = torch.linspace(-1, 1, order, dtype=i.dtype, device=i.device) * (order - 1) / (2.0 * order) - di, dj = torch.meshgrid(dp, dp, indexing="xy") +def upsample(i: ArrayLike, j: ArrayLike, order: int, scale: float) -> Tuple[ArrayLike, ArrayLike]: + dp = ( + backend.linspace(-1, 1, order, dtype=i.dtype, device=i.device) * (order - 1) / (2.0 * order) + ) + di, dj = backend.meshgrid(dp, dp, indexing="xy") - si = torch.repeat_interleave(i.unsqueeze(-1), order**2, -1) + scale * di.flatten() - sj = torch.repeat_interleave(j.unsqueeze(-1), order**2, -1) + scale * dj.flatten() + si = backend.repeat(i.unsqueeze(-1), order**2, -1) + scale * di.flatten() + sj = backend.repeat(j.unsqueeze(-1), order**2, -1) + scale * dj.flatten() return si, sj def single_quad_integrate( - i: torch.Tensor, j: torch.Tensor, brightness_ij, scale: float, quad_order: int = 3 -) -> Tuple[torch.Tensor, torch.Tensor]: + i: ArrayLike, j: ArrayLike, brightness_ij, scale: float, quad_order: int = 3 +) -> Tuple[ArrayLike, ArrayLike]: di, dj, w = quad_table(quad_order, i.dtype, i.device) - qi = torch.repeat_interleave(i.unsqueeze(-1), quad_order**2, -1) + scale * di.flatten() - qj = torch.repeat_interleave(j.unsqueeze(-1), quad_order**2, -1) + scale * dj.flatten() + qi = backend.repeat(i.unsqueeze(-1), quad_order**2, -1) + scale * di.flatten() + qj = backend.repeat(j.unsqueeze(-1), quad_order**2, -1) + scale * dj.flatten() z = brightness_ij(qi, qj) - z0 = torch.mean(z, dim=-1) - z = torch.sum(z * w.flatten(), dim=-1) + z0 = backend.mean(z, dim=-1) + z = backend.sum(z * w.flatten(), dim=-1) return z, z0 def recursive_quad_integrate( - i: torch.Tensor, - j: torch.Tensor, + i: ArrayLike, + j: ArrayLike, brightness_ij: callable, curve_frac: float, scale: float = 1.0, @@ -71,37 +73,40 @@ def recursive_quad_integrate( gridding: int = 5, _current_depth: int = 0, max_depth: int = 1, -) -> torch.Tensor: +) -> ArrayLike: z, z0 = single_quad_integrate(i, j, brightness_ij, scale, quad_order) if _current_depth >= max_depth: return z N = max(1, int(np.prod(z.shape) * curve_frac)) - select = torch.topk(torch.abs(z - z0).flatten(), N, dim=-1).indices + select = backend.topk(backend.abs(z - z0).flatten(), N, dim=-1).indices - integral_flat = z.clone().flatten() + integral_flat = z.flatten() si, sj = upsample(i.flatten()[select], j.flatten()[select], quad_order, scale) - integral_flat[select] = recursive_quad_integrate( - si, - sj, - brightness_ij, - curve_frac=curve_frac, - scale=scale / gridding, - quad_order=quad_order, - gridding=gridding, - _current_depth=_current_depth + 1, - max_depth=max_depth, - ).mean(dim=-1) + integral_flat[select] = backend.mean( + recursive_quad_integrate( + si, + sj, + brightness_ij, + curve_frac=curve_frac, + scale=scale / gridding, + quad_order=quad_order, + gridding=gridding, + _current_depth=_current_depth + 1, + max_depth=max_depth, + ), + dim=-1, + ) return integral_flat.reshape(z.shape) def recursive_bright_integrate( - i: torch.Tensor, - j: torch.Tensor, + i: ArrayLike, + j: ArrayLike, brightness_ij: callable, bright_frac: float, scale: float = 1.0, @@ -109,7 +114,7 @@ def recursive_bright_integrate( gridding: int = 5, _current_depth: int = 0, max_depth: int = 1, -) -> torch.Tensor: +) -> ArrayLike: z, _ = single_quad_integrate(i, j, brightness_ij, scale, quad_order) if _current_depth >= max_depth: @@ -118,20 +123,23 @@ def recursive_bright_integrate( N = max(1, int(np.prod(z.shape) * bright_frac)) z_flat = z.flatten() - select = torch.topk(z_flat, N, dim=-1).indices + select = backend.topk(z_flat, N, dim=-1).indices si, sj = upsample(i.flatten()[select], j.flatten()[select], quad_order, scale) - z_flat[select] = recursive_bright_integrate( - si, - sj, - brightness_ij, - bright_frac, - scale=scale / gridding, - quad_order=quad_order, - gridding=gridding, - _current_depth=_current_depth + 1, - max_depth=max_depth, - ).mean(dim=-1) + z_flat[select] = backend.mean( + recursive_bright_integrate( + si, + sj, + brightness_ij, + bright_frac, + scale=scale / gridding, + quad_order=quad_order, + gridding=gridding, + _current_depth=_current_depth + 1, + max_depth=max_depth, + ), + dim=-1, + ) return z_flat.reshape(z.shape) diff --git a/astrophot/models/func/king.py b/astrophot/models/func/king.py index 04a0bcba..7246160b 100644 --- a/astrophot/models/func/king.py +++ b/astrophot/models/func/king.py @@ -1,9 +1,7 @@ -import torch +from ...backend_obj import backend, ArrayLike -def king( - R: torch.Tensor, Rc: torch.Tensor, Rt: torch.Tensor, alpha: torch.Tensor, I0: torch.Tensor -) -> torch.Tensor: +def king(R: ArrayLike, Rc: ArrayLike, Rt: ArrayLike, alpha: ArrayLike, I0: ArrayLike) -> ArrayLike: """ Empirical King profile. @@ -16,6 +14,8 @@ def king( """ beta = 1 / (1 + (Rt / Rc) ** 2) ** (1 / alpha) gamma = 1 / (1 + (R / Rc) ** 2) ** (1 / alpha) - return torch.where( - R < Rt, I0 * ((torch.clamp(gamma, 0, 1) - beta) / (1 - beta)) ** alpha, torch.zeros_like(R) + return backend.where( + R < Rt, + I0 * ((backend.clamp(gamma, 0, 1) - beta) / (1 - beta)) ** alpha, + backend.zeros_like(R), ) diff --git a/astrophot/models/func/moffat.py b/astrophot/models/func/moffat.py index ec6ba411..d50a0c3a 100644 --- a/astrophot/models/func/moffat.py +++ b/astrophot/models/func/moffat.py @@ -1,7 +1,7 @@ -import torch +from ...backend_obj import ArrayLike -def moffat(R: torch.Tensor, n: torch.Tensor, Rd: torch.Tensor, I0: torch.Tensor) -> torch.Tensor: +def moffat(R: ArrayLike, n: ArrayLike, Rd: ArrayLike, I0: ArrayLike) -> ArrayLike: """Moffat 1d profile function **Args:** diff --git a/astrophot/models/func/nuker.py b/astrophot/models/func/nuker.py index e7977b22..a5f34b25 100644 --- a/astrophot/models/func/nuker.py +++ b/astrophot/models/func/nuker.py @@ -1,14 +1,14 @@ -import torch +from ...backend_obj import ArrayLike def nuker( - R: torch.Tensor, - Rb: torch.Tensor, - Ib: torch.Tensor, - alpha: torch.Tensor, - beta: torch.Tensor, - gamma: torch.Tensor, -) -> torch.Tensor: + R: ArrayLike, + Rb: ArrayLike, + Ib: ArrayLike, + alpha: ArrayLike, + beta: ArrayLike, + gamma: ArrayLike, +) -> ArrayLike: """Nuker 1d profile function **Args:** diff --git a/astrophot/models/func/sersic.py b/astrophot/models/func/sersic.py index f405cc1e..3553ef14 100644 --- a/astrophot/models/func/sersic.py +++ b/astrophot/models/func/sersic.py @@ -1,4 +1,5 @@ import torch +from ...backend_obj import backend, ArrayLike C1 = 4 / 405 @@ -18,7 +19,7 @@ def sersic_n_to_b(n: float) -> float: return 2 * n - 1 / 3 + x * (C1 + x * (C2 + x * (C3 + C4 * x))) -def sersic(R: torch.Tensor, n: torch.Tensor, Re: torch.Tensor, Ie: torch.Tensor) -> torch.Tensor: +def sersic(R: ArrayLike, n: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: """Seric 1d profile function, specifically designed for pytorch operations @@ -29,4 +30,4 @@ def sersic(R: torch.Tensor, n: torch.Tensor, Re: torch.Tensor, Ie: torch.Tensor) - `Ie`: Effective surface density """ bn = sersic_n_to_b(n) - return Ie * (-bn * ((R / Re) ** (1 / n) - 1)).exp() + return Ie * backend.exp(-bn * ((R / Re) ** (1 / n) - 1)) diff --git a/astrophot/models/func/spline.py b/astrophot/models/func/spline.py index f7fd50e6..3ebe5d19 100644 --- a/astrophot/models/func/spline.py +++ b/astrophot/models/func/spline.py @@ -1,7 +1,7 @@ -import torch +from ...backend_obj import backend, ArrayLike -def _h_poly(t: torch.Tensor) -> torch.Tensor: +def _h_poly(t: ArrayLike) -> ArrayLike: """Helper function to compute the 'h' polynomial matrix used in the cubic spline. @@ -13,8 +13,8 @@ def _h_poly(t: torch.Tensor) -> torch.Tensor: """ - tt = t[None, :] ** (torch.arange(4, device=t.device)[:, None]) - A = torch.tensor( + tt = t[None, :] ** (backend.arange(4, device=t.device)[:, None]) + A = backend.as_array( [[1, 0, -3, 2], [0, 1, -2, 1], [0, 0, 3, -2], [0, 0, -1, 1]], dtype=t.dtype, device=t.device, @@ -22,7 +22,7 @@ def _h_poly(t: torch.Tensor) -> torch.Tensor: return A @ tt -def cubic_spline_torch(x: torch.Tensor, y: torch.Tensor, xs: torch.Tensor) -> torch.Tensor: +def cubic_spline_torch(x: ArrayLike, y: ArrayLike, xs: ArrayLike) -> ArrayLike: """Compute the 1D cubic spline interpolation for the given data points using PyTorch. @@ -33,17 +33,15 @@ def cubic_spline_torch(x: torch.Tensor, y: torch.Tensor, xs: torch.Tensor) -> to the cubic spline function should be evaluated. """ m = (y[1:] - y[:-1]) / (x[1:] - x[:-1]) - m = torch.cat([m[[0]], (m[1:] + m[:-1]) / 2, m[[-1]]]) - idxs = torch.searchsorted(x[:-1], xs) - 1 + m = backend.concatenate([m[[0]], (m[1:] + m[:-1]) / 2, m[[-1]]]) + idxs = backend.searchsorted(x[:-1], xs) - 1 dx = x[idxs + 1] - x[idxs] hh = _h_poly((xs - x[idxs]) / dx) ret = hh[0] * y[idxs] + hh[1] * m[idxs] * dx + hh[2] * y[idxs + 1] + hh[3] * m[idxs + 1] * dx return ret -def spline( - R: torch.Tensor, profR: torch.Tensor, profI: torch.Tensor, extend: str = "zeros" -) -> torch.Tensor: +def spline(R: ArrayLike, profR: ArrayLike, profI: ArrayLike, extend: str = "zeros") -> ArrayLike: """Spline 1d profile function, cubic spline between points up to second last point beyond which is linear @@ -53,7 +51,7 @@ def spline( - `profI`: surface density values for the surface density profile - `extend`: How to extend the spline beyond the last point. Options are 'zeros' or 'const'. """ - I = cubic_spline_torch(profR, profI, R.view(-1)).reshape(*R.shape) + I = cubic_spline_torch(profR, profI, R.flatten()).reshape(*R.shape) if extend == "zeros": I[R > profR[-1]] = 0 elif extend == "const": diff --git a/astrophot/models/func/transform.py b/astrophot/models/func/transform.py index d53a869b..b9252589 100644 --- a/astrophot/models/func/transform.py +++ b/astrophot/models/func/transform.py @@ -1,11 +1,11 @@ from typing import Tuple -from torch import Tensor +from ...backend_obj import backend, ArrayLike -def rotate(theta: Tensor, x: Tensor, y: Tensor) -> Tuple[Tensor, Tensor]: +def rotate(theta: ArrayLike, x: ArrayLike, y: ArrayLike) -> Tuple[ArrayLike, ArrayLike]: """ Applies a rotation matrix to the X,Y coordinates """ - s = theta.sin() - c = theta.cos() + s = backend.sin(theta) + c = backend.cos(theta) return c * x - s * y, s * x + c * y diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index b3767bea..020533c1 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -1,5 +1,6 @@ import torch from torch import Tensor +from ...backend_obj import backend, ArrayLike import numpy as np from ...param import forward @@ -23,7 +24,7 @@ class RadialMixin: """ @forward - def brightness(self, x: Tensor, y: Tensor) -> Tensor: + def brightness(self, x: ArrayLike, y: ArrayLike) -> ArrayLike: """ Calculate the brightness at a given point (x, y) based on radial distance from the center. """ @@ -53,8 +54,8 @@ def __init__(self, *args, symmetric: bool = True, segments: int = 2, **kwargs): self.symmetric = symmetric self.segments = segments - def polar_model(self, R: Tensor, T: Tensor) -> Tensor: - model = torch.zeros_like(R) + def polar_model(self, R: ArrayLike, T: ArrayLike) -> ArrayLike: + model = backend.zeros_like(R) cycle = np.pi if self.symmetric else 2 * np.pi w = cycle / self.segments angles = (T + w / 2) % cycle @@ -99,20 +100,20 @@ def __init__(self, *args, symmetric: bool = True, segments: int = 2, **kwargs): self.symmetric = symmetric self.segments = segments - def polar_model(self, R: Tensor, T: Tensor) -> Tensor: - model = torch.zeros_like(R) - weight = torch.zeros_like(R) + def polar_model(self, R: ArrayLike, T: ArrayLike) -> ArrayLike: + model = backend.zeros_like(R) + weight = backend.zeros_like(R) cycle = np.pi if self.symmetric else 2 * np.pi w = cycle / self.segments v = w * np.arange(self.segments) for s in range(self.segments): angles = (T + cycle / 2 - v[s]) % cycle - cycle / 2 indices = (angles >= -w) & (angles < w) - weights = (torch.cos(angles[indices] * self.segments) + 1) / 2 + weights = (backend.cos(angles[indices] * self.segments) + 1) / 2 model[indices] += weights * self.iradial_model(s, R[indices]) weight[indices] += weights return model / weight - def brightness(self, x: Tensor, y: Tensor) -> Tensor: + def brightness(self, x: ArrayLike, y: ArrayLike) -> ArrayLike: x, y = self.transform_coordinates(x, y) return self.polar_model(self.radius_metric(x, y), self.angular_metric(x, y)) From b2718d72eb02a842cae4d579709057689a3e41f2 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 13 Aug 2025 14:24:51 -0400 Subject: [PATCH 120/191] first set of backend replacements complete --- astrophot/backend_obj.py | 69 +++++++++++++++++-- astrophot/models/_shared_methods.py | 11 +-- astrophot/models/airy.py | 11 +-- astrophot/models/base.py | 41 +++++------ astrophot/models/basis.py | 20 +++--- astrophot/models/bilinear_sky.py | 14 ++-- astrophot/models/edgeon.py | 28 ++++---- astrophot/models/flatsky.py | 8 +-- astrophot/models/gaussian_ellipsoid.py | 41 +++++------ astrophot/models/group_model_object.py | 7 +- astrophot/models/mixins/exponential.py | 6 +- astrophot/models/mixins/ferrer.py | 16 +++-- astrophot/models/mixins/gaussian.py | 6 +- astrophot/models/mixins/king.py | 10 +-- astrophot/models/mixins/moffat.py | 7 +- astrophot/models/mixins/nuker.py | 23 +++++-- astrophot/models/mixins/sample.py | 46 +++++++------ astrophot/models/mixins/sersic.py | 8 ++- astrophot/models/mixins/spline.py | 5 +- astrophot/models/mixins/transform.py | 39 ++++++----- astrophot/models/model_object.py | 17 +++-- astrophot/models/multi_gaussian_expansion.py | 30 ++++---- astrophot/models/pixelated_psf.py | 8 ++- astrophot/models/planesky.py | 6 +- astrophot/models/point_source.py | 7 +- astrophot/models/psf_model_object.py | 10 +-- astrophot/param/module.py | 6 +- astrophot/param/param.py | 12 ++-- astrophot/plots/image.py | 41 +++++------ astrophot/plots/profile.py | 30 ++++---- astrophot/utils/conversions/functions.py | 43 ++++++------ .../utils/initialize/segmentation_map.py | 39 +++++------ astrophot/utils/initialize/variance.py | 11 ++- astrophot/utils/integration.py | 9 +-- astrophot/utils/interpolate.py | 18 ++--- tests/test_model.py | 1 + 36 files changed, 403 insertions(+), 301 deletions(-) diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index c3d7cc38..9d3e337c 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -74,6 +74,11 @@ def setup_torch(self): self.mean = self._mean_torch self.sum = self._sum_torch self.topk = self._topk_torch + self.bessel_j1 = self._bessel_j1_torch + self.bessel_k1 = self._bessel_k1_torch + self.lgamma = self._lgamma_torch + self.hessian = self._hessian_torch + self.long = self._long_torch def setup_jax(self): self.jax = importlib.import_module("jax") @@ -105,6 +110,11 @@ def setup_jax(self): self.mean = self._mean_jax self.sum = self._sum_jax self.topk = self._topk_jax + self.bessel_j1 = self._bessel_j1_jax + self.bessel_k1 = self._bessel_k1_jax + self.lgamma = self._lgamma_jax + self.hessian = self._hessian_jax + self.long = self._long_jax @property def array_type(self): @@ -122,11 +132,11 @@ def _array_type_torch(self): def _array_type_jax(self): return self.module.ndarray - def _concatenate_torch(self, arrays, axis=0): - return self.module.cat(arrays, dim=axis) + def _concatenate_torch(self, arrays, dim=0): + return self.module.cat(arrays, dim=dim) - def _concatenate_jax(self, arrays, axis=0): - return self.module.concatenate(arrays, axis=axis) + def _concatenate_jax(self, arrays, dim=0): + return self.module.concatenate(arrays, axis=dim) def _copy_torch(self, array): return array.detach().clone() @@ -239,6 +249,12 @@ def _clamp_torch(self, array, min, max): def _clamp_jax(self, array, min, max): return self.jax.clip(array, min, max) + def _long_torch(self, array): + return array.long() + + def _long_jax(self, array): + return self.module.astype(array, self.module.int64) + def _conv2d_torch(self, input, kernel, padding, stride=1): return self.module.nn.functional.conv2d( input, @@ -270,6 +286,30 @@ def _topk_torch(self, array, k, dim=None): def _topk_jax(self, array, k, dim=None): return self.jax.lax.top_k(array, k=k, axis=dim) + def _bessel_j1_torch(self, array): + return self.module.special.bessel_j1(array) + + def _bessel_j1_jax(self, array): + return self.jax.scipy.special.bessel_jn(array, 1) + + def _bessel_k1_torch(self, array): + return self.module.special.modified_bessel_k1(array) + + def _bessel_k1_jax(self, array): + return self.jax.scipy.special.kn(1, array) + + def _lgamma_torch(self, array): + return self.module.lgamma(array) + + def _lgamma_jax(self, array): + return self.jax.lax.lgamma(array) + + def _hessian_torch(self, func): + return self.module.func.hessian(func) + + def _hessian_jax(self, func): + return self.jax.hessian(func) + def linspace(self, start, end, steps, dtype=None, device=None): return self.module.linspace(start, end, steps, dtype=dtype, device=device) @@ -288,6 +328,9 @@ def all(self, array): def log(self, array): return self.module.log(array) + def log10(self, array): + return self.module.log10(array) + def exp(self, array): return self.module.exp(array) @@ -297,12 +340,21 @@ def sin(self, array): def cos(self, array): return self.module.cos(array) + def cosh(self, array): + return self.module.cosh(array) + def sqrt(self, array): return self.module.sqrt(array) def abs(self, array): return self.module.abs(array) + def floor(self, array): + return self.module.floor(array) + + def tanh(self, array): + return self.module.tanh(array) + def arctan(self, array): return self.module.arctan(array) @@ -312,6 +364,9 @@ def arctan2(self, y, x): def arcsin(self, array): return self.module.arcsin(array) + def round(self, array): + return self.module.round(array) + def zeros(self, shape, dtype=None, device=None): return self.module.zeros(shape, dtype=dtype, device=device) @@ -333,6 +388,9 @@ def eye(self, n, dtype=None, device=None): def diag(self, array): return self.module.diag(array) + def outer(self, a, b): + return self.module.outer(a, b) + def minimum(self, a, b): return self.module.minimum(a, b) @@ -351,6 +409,9 @@ def where(self, condition, x, y): def allclose(self, a, b, rtol=1e-5, atol=1e-8): return self.module.allclose(a, b, rtol=rtol, atol=atol) + def vmap(self, *args, **kwargs): + return self.module.vmap(*args, **kwargs) + @property def linalg(self): return self.module.linalg diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 8bba0cf6..7f7e231c 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -5,6 +5,7 @@ from ..utils.decorators import ignore_numpy_warnings from .. import config +from ..backend_obj import backend def _sample_image( @@ -16,20 +17,20 @@ def _sample_image( angle_range=None, cycle=2 * np.pi, ): - dat = image.data.detach().cpu().numpy().copy() + dat = backend.copy(image.data) # Fill masked pixels if image.has_mask: - mask = image.mask.detach().cpu().numpy() + mask = backend.to_numpy(image.mask) dat[mask] = np.median(dat[~mask]) # Subtract median of edge pixels to avoid effect of nearby sources edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) dat -= np.median(edge) # Get the radius of each pixel relative to object center x, y = transform(*image.coordinate_center_meshgrid(), params=()) - R = radius(x, y, params=()).detach().cpu().numpy().flatten() + R = backend.to_numpy(radius(x, y, params=())).flatten() if angle_range is not None: - T = angle(x, y, params=()).detach().cpu().numpy().flatten() + T = backend.to_numpy(angle(x, y, params=())).flatten() T = (T - angle_range[0]) % cycle CHOOSE = T < (angle_range[1] - angle_range[0]) R = R[CHOOSE] @@ -106,7 +107,7 @@ def optim(x, r, f, u): if not model[param].initialized: if not model[param].is_valid(x0x): x0x = model[param].soft_valid( - torch.tensor(x0x, dtype=config.DTYPE, device=config.DEVICE) + backend.as_array(x0x, dtype=config.DTYPE, device=config.DEVICE) ) model[param].dynamic_value = x0x diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index 7fa3a38b..115e0acb 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -1,10 +1,11 @@ import torch -from torch import Tensor +import numpy as np from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from .psf_model_object import PSFModel from .mixins import RadialMixin from ..param import forward +from ..backend_obj import backend, ArrayLike __all__ = ("AiryPSF",) @@ -63,11 +64,11 @@ def initialize(self): int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] - self.I0.dynamic_value = torch.mean(mid_chunk) / self.target.pixel_area + self.I0.dynamic_value = backend.mean(mid_chunk) / self.target.pixel_area if not self.aRL.initialized: self.aRL.dynamic_value = (5.0 / 8.0) * 2 * self.target.pixelscale @forward - def radial_model(self, R: Tensor, I0: Tensor, aRL: Tensor) -> Tensor: - x = 2 * torch.pi * aRL * R - return I0 * (2 * torch.special.bessel_j1(x) / x) ** 2 + def radial_model(self, R: ArrayLike, I0: ArrayLike, aRL: ArrayLike) -> ArrayLike: + x = 2 * np.pi * aRL * R + return I0 * (2 * backend.bessel_j1(x) / x) ** 2 diff --git a/astrophot/models/base.py b/astrophot/models/base.py index deac9439..daee9d89 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -1,8 +1,6 @@ from typing import Optional, Union from copy import deepcopy -import torch -from torch.func import hessian import numpy as np from caskade import Param as CParam @@ -11,6 +9,7 @@ from ..image import Window, ImageList, ModelImage, ModelImageList from ..errors import UnrecognizedModel, InvalidWindow from .. import config +from ..backend_obj import backend, ArrayLike from . import func __all__ = ("Model",) @@ -125,7 +124,7 @@ def build_parameter_specs(self, kwargs, parameter_specs) -> dict: def gaussian_log_likelihood( self, window: Optional[Window] = None, - ) -> torch.Tensor: + ) -> ArrayLike: """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. """ @@ -139,11 +138,11 @@ def gaussian_log_likelihood( data = data.data if isinstance(data, tuple): nll = 0.5 * sum( - torch.sum(((da - mo) ** 2 * wgt)[~ma]) + backend.sum(((da - mo) ** 2 * wgt)[~ma]) for mo, da, wgt, ma in zip(model, data, weight, mask) ) else: - nll = 0.5 * torch.sum(((data - model) ** 2 * weight)[~mask]) + nll = 0.5 * backend.sum(((data - model) ** 2 * weight)[~mask]) return -nll @@ -151,7 +150,7 @@ def gaussian_log_likelihood( def poisson_log_likelihood( self, window: Optional[Window] = None, - ) -> torch.Tensor: + ) -> ArrayLike: """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. """ @@ -164,38 +163,40 @@ def poisson_log_likelihood( if isinstance(data, tuple): nll = sum( - torch.sum((mo - da * (mo + 1e-10).log() + torch.lgamma(da + 1))[~ma]) + backend.sum((mo - da * (mo + 1e-10).log() + backend.lgamma(da + 1))[~ma]) for mo, da, ma in zip(model, data, mask) ) else: - nll = torch.sum((model - data * (model + 1e-10).log() + torch.lgamma(data + 1))[~mask]) + nll = backend.sum( + (model - data * (model + 1e-10).log() + backend.lgamma(data + 1))[~mask] + ) return -nll def hessian(self, likelihood="gaussian"): if likelihood == "gaussian": - return hessian(self.gaussian_log_likelihood)(self.build_params_array()) + return backend.hessian(self.gaussian_log_likelihood)(self.build_params_array()) elif likelihood == "poisson": - return hessian(self.poisson_log_likelihood)(self.build_params_array()) + return backend.hessian(self.poisson_log_likelihood)(self.build_params_array()) else: raise ValueError(f"Unknown likelihood type: {likelihood}") - def total_flux(self, window=None) -> torch.Tensor: + def total_flux(self, window=None) -> ArrayLike: F = self(window=window) - return torch.sum(F.data) + return backend.sum(F.data) - def total_flux_uncertainty(self, window=None) -> torch.Tensor: + def total_flux_uncertainty(self, window=None) -> ArrayLike: jac = self.jacobian(window=window).flatten("data") - dF = torch.sum(jac, dim=0) # VJP for sum(total_flux) + dF = backend.sum(jac, dim=0) # VJP for sum(total_flux) current_uncertainty = self.build_params_array_uncertainty() - return torch.sqrt(torch.sum((dF * current_uncertainty) ** 2)) + return backend.sqrt(backend.sum((dF * current_uncertainty) ** 2)) - def total_magnitude(self, window=None) -> torch.Tensor: + def total_magnitude(self, window=None) -> ArrayLike: """Compute the total magnitude of the model in the given window.""" F = self.total_flux(window=window) - return -2.5 * torch.log10(F) + self.target.zeropoint + return -2.5 * backend.log10(F) + self.target.zeropoint - def total_magnitude_uncertainty(self, window=None) -> torch.Tensor: + def total_magnitude_uncertainty(self, window=None) -> ArrayLike: """Compute the uncertainty in the total magnitude of the model in the given window.""" F = self.total_flux(window=window) dF = self.total_flux_uncertainty(window=window) @@ -249,11 +250,11 @@ def List_Models(cls, usable: Optional[bool] = None, types: bool = False) -> set: @forward def radius_metric(self, x, y): - return (x**2 + y**2 + self.softening**2).sqrt() + return backend.sqrt(x**2 + y**2 + self.softening**2) @forward def angular_metric(self, x, y): - return torch.atan2(y, x) + return backend.arctan2(y, x) def to(self, dtype=None, device=None): if dtype is None: diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index aa262662..e702984d 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -1,12 +1,12 @@ from typing import Union, Tuple import torch -from torch import Tensor import numpy as np from .psf_model_object import PSFModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from .. import config +from ..backend_obj import backend, ArrayLike from ..errors import SpecificationConflict from ..param import forward from . import func @@ -39,7 +39,7 @@ class PixelBasisPSF(PSFModel): } usable = True - def __init__(self, *args, basis: Union[str, Tensor] = "zernike:3", **kwargs): + def __init__(self, *args, basis: Union[str, ArrayLike] = "zernike:3", **kwargs): """Initialize the PixelBasisPSF model with a basis set of images.""" super().__init__(*args, **kwargs) self.basis = basis @@ -50,7 +50,7 @@ def basis(self): return self._basis @basis.setter - def basis(self, value: Union[str, Tensor]): + def basis(self, value: Union[str, ArrayLike]): """Set the basis set of images. If value is None, the basis is initialized to an empty tensor.""" if value is None: raise SpecificationConflict( @@ -60,8 +60,8 @@ def basis(self, value: Union[str, Tensor]): self._basis = value else: # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates - self._basis = torch.transpose( - torch.as_tensor(value, dtype=config.DTYPE, device=config.DEVICE), 1, 2 + self._basis = backend.transpose( + backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE), 1, 2 ) @torch.no_grad() @@ -99,14 +99,16 @@ def initialize(self): @forward def transform_coordinates( - self, x: Tensor, y: Tensor, PA: Tensor, scale: Tensor - ) -> Tuple[Tensor, Tensor]: + self, x: ArrayLike, y: ArrayLike, PA: ArrayLike, scale: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: x, y = super().transform_coordinates(x, y) i, j = func.rotate(-PA, x, y) pixel_center = (self.basis.shape[1] - 1) / 2, (self.basis.shape[2] - 1) / 2 return i / scale + pixel_center[0], j / scale + pixel_center[1] @forward - def brightness(self, x: Tensor, y: Tensor, weights: Tensor) -> Tensor: + def brightness(self, x: ArrayLike, y: ArrayLike, weights: ArrayLike) -> ArrayLike: x, y = self.transform_coordinates(x, y) - return torch.sum(torch.vmap(lambda w, b: w * interp2d(b, x, y))(weights, self.basis), dim=0) + return backend.sum( + backend.vmap(lambda w, b: w * interp2d(b, x, y))(weights, self.basis), dim=0 + ) diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py index 09bf1ce0..c63c400f 100644 --- a/astrophot/models/bilinear_sky.py +++ b/astrophot/models/bilinear_sky.py @@ -1,12 +1,12 @@ from typing import Tuple import numpy as np import torch -from torch import Tensor from .sky_model_object import SkyModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from ..param import forward +from ..backend_obj import backend, ArrayLike from . import func from ..utils.initialize import polar_decomposition @@ -47,7 +47,7 @@ def initialize(self): self.nodes = tuple(self.I.value.shape) if not self.PA.initialized: - R, _ = polar_decomposition(self.target.CD.value.detach().cpu().numpy()) + R, _ = polar_decomposition(self.target.CD.npvalue) self.PA.value = np.arccos(np.abs(R[0, 0])) if not self.scale.initialized: self.scale.value = ( @@ -58,9 +58,9 @@ def initialize(self): return target_dat = self.target[self.window] - dat = target_dat.data.detach().cpu().numpy().copy() + dat = backend.to_numpy(target_dat.data).copy() if self.target.has_mask: - mask = target_dat.mask.detach().cpu().numpy().copy() + mask = backend.to_numpy(target_dat.mask).copy() dat[mask] = np.nanmedian(dat) iS = dat.shape[0] // self.nodes[0] jS = dat.shape[1] // self.nodes[1] @@ -77,14 +77,14 @@ def initialize(self): @forward def transform_coordinates( - self, x: Tensor, y: Tensor, I: Tensor, PA: Tensor, scale: Tensor - ) -> Tuple[Tensor, Tensor]: + self, x: ArrayLike, y: ArrayLike, I: ArrayLike, PA: ArrayLike, scale: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: x, y = super().transform_coordinates(x, y) i, j = func.rotate(-PA, x, y) pixel_center = (I.shape[0] - 1) / 2, (I.shape[1] - 1) / 2 return i / scale + pixel_center[0], j / scale + pixel_center[1] @forward - def brightness(self, x: Tensor, y: Tensor, I: Tensor) -> Tensor: + def brightness(self, x: ArrayLike, y: ArrayLike, I: ArrayLike) -> ArrayLike: x, y = self.transform_coordinates(x, y) return interp2d(I, x, y) diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index 1e627e2f..3415fe9f 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -1,11 +1,11 @@ from typing import Tuple import torch import numpy as np -from torch import Tensor from .model_object import ComponentModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from . import func +from ..backend_obj import backend, ArrayLike from ..param import forward __all__ = ["EdgeonModel", "EdgeonSech", "EdgeonIsothermal"] @@ -35,14 +35,14 @@ def initialize(self): if self.PA.initialized: return target_area = self.target[self.window] - dat = target_area.data.detach().cpu().numpy().copy() + dat = backend.to_numpy(target_area.data).copy() edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) dat = dat - edge_average x, y = target_area.coordinate_center_meshgrid() - x = (x - self.center.value[0]).detach().cpu().numpy() - y = (y - self.center.value[1]).detach().cpu().numpy() + x = backend.to_numpy(x - self.center.value[0]) + y = backend.to_numpy(y - self.center.value[1]) mu20 = np.median(dat * np.abs(x)) mu02 = np.median(dat * np.abs(y)) mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y))) @@ -53,7 +53,9 @@ def initialize(self): self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi @forward - def transform_coordinates(self, x: Tensor, y: Tensor, PA: Tensor) -> Tuple[Tensor, Tensor]: + def transform_coordinates( + self, x: ArrayLike, y: ArrayLike, PA: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: x, y = super().transform_coordinates(x, y) return func.rotate(-(PA + np.pi / 2), x, y) @@ -88,14 +90,14 @@ def initialize(self): int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] - self.I0.dynamic_value = torch.mean(chunk) / self.target.pixel_area + self.I0.dynamic_value = backend.mean(chunk) / self.target.pixel_area if not self.hs.initialized: self.hs.value = max(self.window.shape) * target_area.pixelscale * 0.1 @forward - def brightness(self, x: Tensor, y: Tensor, I0: Tensor, hs: Tensor) -> Tensor: + def brightness(self, x: ArrayLike, y: ArrayLike, I0: ArrayLike, hs: ArrayLike) -> ArrayLike: x, y = self.transform_coordinates(x, y) - return I0 * self.radial_model(x) / (torch.cosh((y + self.softening) / hs) ** 2) + return I0 * self.radial_model(x) / (backend.cosh((y + self.softening) / hs) ** 2) @combine_docstrings @@ -120,10 +122,6 @@ def initialize(self): self.rs.value = max(self.window.shape) * self.target.pixelscale * 0.4 @forward - def radial_model(self, R: Tensor, rs: Tensor) -> Tensor: - Rscaled = torch.abs(R / rs) - return ( - Rscaled - * torch.exp(-Rscaled) - * torch.special.scaled_modified_bessel_k1(Rscaled + self.softening / rs) - ) + def radial_model(self, R: ArrayLike, rs: ArrayLike) -> ArrayLike: + Rscaled = backend.abs(R / rs) + return Rscaled * backend.exp(-Rscaled) * backend.bessel_k1(Rscaled + self.softening / rs) diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 2d215e21..19b07a58 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -1,9 +1,9 @@ import numpy as np import torch -from torch import Tensor from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from .sky_model_object import SkyModel +from ..backend_obj import backend, ArrayLike from ..param import forward __all__ = ["FlatSky"] @@ -33,9 +33,9 @@ def initialize(self): if self.I.initialized: return - dat = self.target[self.window].data.detach().cpu().numpy().copy() + dat = backend.to_numpy(self.target[self.window].data).copy() self.I.dynamic_value = np.median(dat) / self.target.pixel_area.item() @forward - def brightness(self, x: Tensor, y: Tensor, I: Tensor) -> Tensor: - return torch.ones_like(x) * I + def brightness(self, x: ArrayLike, y: ArrayLike, I: ArrayLike) -> ArrayLike: + return backend.ones_like(x) * I diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index 8366044c..250e52f8 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -6,6 +6,7 @@ from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from . import func from ..param import forward +from ..backend_obj import backend, ArrayLike __all__ = ["GaussianEllipsoid"] @@ -75,9 +76,9 @@ def initialize(self): self.alpha = 0.0 target_area = self.target[self.window] - dat = target_area.data.detach().cpu().numpy().copy() + dat = backend.to_numpy(target_area.data).copy() if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() + mask = backend.to_numpy(target_area.mask).copy() dat[mask] = np.median(dat[~mask]) edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) @@ -86,11 +87,11 @@ def initialize(self): center = self.center.value x = x - center[0] y = y - center[1] - r = self.radius_metric(x, y, params=()).detach().cpu().numpy() + r = backend.to_numpy(self.radius_metric(x, y, params=())) self.sigma_a.dynamic_value = np.sqrt(np.sum((r * dat) ** 2) / np.sum(r**2)) - x = x.detach().cpu().numpy() - y = y.detach().cpu().numpy() + x = backend.to_numpy(x) + y = backend.to_numpy(y) mu20 = np.median(dat * np.abs(x)) mu02 = np.median(dat * np.abs(y)) @@ -110,25 +111,25 @@ def initialize(self): @forward def brightness( self, - x: Tensor, - y: Tensor, - sigma_a: Tensor, - sigma_b: Tensor, - sigma_c: Tensor, - alpha: Tensor, - beta: Tensor, - gamma: Tensor, - flux: Tensor, - ) -> Tensor: + x: ArrayLike, + y: ArrayLike, + sigma_a: ArrayLike, + sigma_b: ArrayLike, + sigma_c: ArrayLike, + alpha: ArrayLike, + beta: ArrayLike, + gamma: ArrayLike, + flux: ArrayLike, + ) -> ArrayLike: """Brightness of the Gaussian ellipsoid.""" - D = torch.diag(torch.stack((sigma_a, sigma_b, sigma_c)) ** 2) + D = backend.diag(backend.stack((sigma_a, sigma_b, sigma_c)) ** 2) R = func.euler_rotation_matrix(alpha, beta, gamma) Sigma = R @ D @ R.T Sigma2D = Sigma[:2, :2] - inv_Sigma = torch.linalg.inv(Sigma2D) - v = torch.stack(self.transform_coordinates(x, y), dim=0).reshape(2, -1) + inv_Sigma = backend.linalg.inv(Sigma2D) + v = backend.stack(self.transform_coordinates(x, y), dim=0).reshape(2, -1) return ( flux - * torch.exp(-0.5 * (v * (inv_Sigma @ v)).sum(dim=0)) - / (2 * np.pi * torch.linalg.det(Sigma2D).sqrt()) + * backend.exp(-0.5 * (v * (inv_Sigma @ v)).sum(dim=0)) + / (2 * np.pi * backend.linalg.det(Sigma2D).sqrt()) ).reshape(x.shape) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 2f0443f6..257e1a9c 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -17,6 +17,7 @@ JacobianImageList, ) from .. import config +from ..backend_obj import backend from ..utils.decorators import ignore_numpy_warnings from ..errors import InvalidTarget, InvalidWindow @@ -119,7 +120,7 @@ def fit_mask(self) -> torch.Tensor: """ subtarget = self.target[self.window] if isinstance(subtarget, ImageList): - mask = tuple(torch.ones_like(submask) for submask in subtarget.mask) + mask = tuple(backend.ones_like(submask) for submask in subtarget.mask) for model in self.models: model_subtarget = model.target[model.window] model_fit_mask = model.fit_mask() @@ -135,7 +136,7 @@ def fit_mask(self) -> torch.Tensor: model_indices = model_subtarget.get_indices(subtarget.images[index].window) mask[index][group_indices] &= model_fit_mask[model_indices] else: - mask = torch.ones_like(subtarget.mask) + mask = backend.ones_like(subtarget.mask) for model in self.models: model_subtarget = model.target[model.window] group_indices = subtarget.get_indices(model.window) @@ -183,7 +184,7 @@ def _ensure_vmap_compatible( self._ensure_vmap_compatible(image, img) return if image.identity == other.identity: - image += torch.zeros_like(other.data[0, 0]) + image += backend.zeros_like(other.data[0, 0]) @forward def sample( diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 25dcfd81..833660c5 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -1,7 +1,7 @@ import torch -from torch import Tensor from ...param import forward +from ...backend_obj import ArrayLike from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import parametric_initialize, parametric_segment_initialize from ...utils.parametric_profiles import exponential_np @@ -48,7 +48,7 @@ def initialize(self): ) @forward - def radial_model(self, R: Tensor, Re: Tensor, Ie: Tensor) -> Tensor: + def radial_model(self, R: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: return func.exponential(R, Re, Ie) @@ -92,5 +92,5 @@ def initialize(self): ) @forward - def iradial_model(self, i: int, R: Tensor, Re: Tensor, Ie: Tensor) -> Tensor: + def iradial_model(self, i: int, R: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: return func.exponential(R, Re[i], Ie[i]) diff --git a/astrophot/models/mixins/ferrer.py b/astrophot/models/mixins/ferrer.py index e3632f49..d4fac0b6 100644 --- a/astrophot/models/mixins/ferrer.py +++ b/astrophot/models/mixins/ferrer.py @@ -1,7 +1,7 @@ import torch -from torch import Tensor from ...param import forward +from ...backend_obj import ArrayLike from ...utils.decorators import ignore_numpy_warnings from ...utils.parametric_profiles import ferrer_np from .._shared_methods import parametric_initialize, parametric_segment_initialize @@ -55,8 +55,8 @@ def initialize(self): @forward def radial_model( - self, R: Tensor, rout: Tensor, alpha: Tensor, beta: Tensor, I0: Tensor - ) -> Tensor: + self, R: ArrayLike, rout: ArrayLike, alpha: ArrayLike, beta: ArrayLike, I0: ArrayLike + ) -> ArrayLike: return func.ferrer(R, rout, alpha, beta, I0) @@ -107,6 +107,12 @@ def initialize(self): @forward def iradial_model( - self, i: int, R: Tensor, rout: Tensor, alpha: Tensor, beta: Tensor, I0: Tensor - ) -> Tensor: + self, + i: int, + R: ArrayLike, + rout: ArrayLike, + alpha: ArrayLike, + beta: ArrayLike, + I0: ArrayLike, + ) -> ArrayLike: return func.ferrer(R, rout[i], alpha[i], beta[i], I0[i]) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 014c13a7..18c8d534 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -1,7 +1,7 @@ import torch -from torch import Tensor from ...param import forward +from ...backend_obj import ArrayLike from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import parametric_initialize, parametric_segment_initialize from ...utils.parametric_profiles import gaussian_np @@ -48,7 +48,7 @@ def initialize(self): ) @forward - def radial_model(self, R: Tensor, sigma: Tensor, flux: Tensor) -> Tensor: + def radial_model(self, R: ArrayLike, sigma: ArrayLike, flux: ArrayLike) -> ArrayLike: return func.gaussian(R, sigma, flux) @@ -93,5 +93,5 @@ def initialize(self): ) @forward - def iradial_model(self, i: int, R: Tensor, sigma: Tensor, flux: Tensor) -> Tensor: + def iradial_model(self, i: int, R: ArrayLike, sigma: ArrayLike, flux: ArrayLike) -> ArrayLike: return func.gaussian(R, sigma[i], flux[i]) diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index efbab564..bf672a79 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -1,8 +1,8 @@ import torch -from torch import Tensor import numpy as np from ...param import forward +from ...backend_obj import ArrayLike from ...utils.decorators import ignore_numpy_warnings from ...utils.parametric_profiles import king_np from .._shared_methods import parametric_initialize, parametric_segment_initialize @@ -58,7 +58,9 @@ def initialize(self): ) @forward - def radial_model(self, R: Tensor, Rc: Tensor, Rt: Tensor, alpha: Tensor, I0: Tensor) -> Tensor: + def radial_model( + self, R: ArrayLike, Rc: ArrayLike, Rt: ArrayLike, alpha: ArrayLike, I0: ArrayLike + ) -> ArrayLike: return func.king(R, Rc, Rt, alpha, I0) @@ -111,6 +113,6 @@ def initialize(self): @forward def iradial_model( - self, i: int, R: Tensor, Rc: Tensor, Rt: Tensor, alpha: Tensor, I0: Tensor - ) -> Tensor: + self, i: int, R: ArrayLike, Rc: ArrayLike, Rt: ArrayLike, alpha: ArrayLike, I0: ArrayLike + ) -> ArrayLike: return func.king(R, Rc[i], Rt[i], alpha[i], I0[i]) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 43dd03e2..64712f52 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -2,6 +2,7 @@ from torch import Tensor from ...param import forward +from ...backend_obj import ArrayLike from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import parametric_initialize, parametric_segment_initialize from ...utils.parametric_profiles import moffat_np @@ -50,7 +51,7 @@ def initialize(self): ) @forward - def radial_model(self, R: Tensor, n: Tensor, Rd: Tensor, I0: Tensor) -> Tensor: + def radial_model(self, R: ArrayLike, n: ArrayLike, Rd: ArrayLike, I0: ArrayLike) -> ArrayLike: return func.moffat(R, n, Rd, I0) @@ -96,5 +97,7 @@ def initialize(self): ) @forward - def iradial_model(self, i: int, R: Tensor, n: Tensor, Rd: Tensor, I0: Tensor) -> Tensor: + def iradial_model( + self, i: int, R: ArrayLike, n: ArrayLike, Rd: ArrayLike, I0: ArrayLike + ) -> ArrayLike: return func.moffat(R, n[i], Rd[i], I0[i]) diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 9a071004..0c7007b4 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -1,7 +1,7 @@ import torch -from torch import Tensor from ...param import forward +from ...backend_obj import ArrayLike from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import parametric_initialize, parametric_segment_initialize from ...utils.parametric_profiles import nuker_np @@ -56,8 +56,14 @@ def initialize(self): @forward def radial_model( - self, R: Tensor, Rb: Tensor, Ib: Tensor, alpha: Tensor, beta: Tensor, gamma: Tensor - ) -> Tensor: + self, + R: ArrayLike, + Rb: ArrayLike, + Ib: ArrayLike, + alpha: ArrayLike, + beta: ArrayLike, + gamma: ArrayLike, + ) -> ArrayLike: return func.nuker(R, Rb, Ib, alpha, beta, gamma) @@ -109,6 +115,13 @@ def initialize(self): @forward def iradial_model( - self, i: int, R: Tensor, Rb: Tensor, Ib: Tensor, alpha: Tensor, beta: Tensor, gamma: Tensor - ) -> Tensor: + self, + i: int, + R: ArrayLike, + Rb: ArrayLike, + Ib: ArrayLike, + alpha: ArrayLike, + beta: ArrayLike, + gamma: ArrayLike, + ) -> ArrayLike: return func.nuker(R, Rb[i], Ib[i], alpha[i], beta[i], gamma[i]) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index e481aa7e..0cbb8863 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -6,6 +6,7 @@ from torch import Tensor from ...param import forward +from ...backend_obj import backend, ArrayLike from ... import config from ...image import Image, Window, JacobianImage from .. import func @@ -57,11 +58,11 @@ class SampleMixin: ) @forward - def _bright_integrate(self, sample: Tensor, image: Image) -> Tensor: + def _bright_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: i, j = image.pixel_center_meshgrid() N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) sample_flat = sample.flatten(-2) - select = torch.topk(sample_flat, N, dim=-1).indices + select = backend.topk(sample_flat, N, dim=-1).indices sample_flat[select] = func.recursive_bright_integrate( i.flatten(-2)[select], j.flatten(-2)[select], @@ -75,25 +76,26 @@ def _bright_integrate(self, sample: Tensor, image: Image) -> Tensor: return sample_flat.reshape(sample.shape) @forward - def _curvature_integrate(self, sample: Tensor, image: Image) -> Tensor: + def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: i, j = image.pixel_center_meshgrid() kernel = func.curvature_kernel(config.DTYPE, config.DEVICE) curvature = ( - torch.nn.functional.pad( - torch.nn.functional.conv2d( - sample.view(1, 1, *sample.shape), - kernel.view(1, 1, *kernel.shape), - padding="valid", - ), - (1, 1, 1, 1), - mode="replicate", + backend.abs( + backend.pad( + backend.conv2d( + sample.view(1, 1, *sample.shape), + kernel.view(1, 1, *kernel.shape), + padding="valid", + ), + (1, 1, 1, 1), + mode="replicate", + ) ) .squeeze(0) .squeeze(0) - .abs() ) N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) - select = torch.topk(curvature.flatten(-2), N, dim=-1).indices + select = backend.topk(curvature.flatten(-2), N, dim=-1).indices sample_flat = sample.flatten(-2) sample_flat[select] = func.recursive_quad_integrate( @@ -109,7 +111,7 @@ def _curvature_integrate(self, sample: Tensor, image: Image) -> Tensor: return sample_flat.reshape(sample.shape) @forward - def sample_image(self, image: Image) -> Tensor: + def sample_image(self, image: Image) -> ArrayLike: if self.sampling_mode == "auto": N = np.prod(image.data.shape) if N <= 100: @@ -149,8 +151,8 @@ def sample_image(self, image: Image) -> Tensor: return sample def _jacobian( - self, window: Window, params_pre: Tensor, params: Tensor, params_post: Tensor - ) -> Tensor: + self, window: Window, params_pre: ArrayLike, params: ArrayLike, params_post: ArrayLike + ) -> ArrayLike: # return jacfwd( # this should be more efficient, but the trace overhead is too high # lambda x: self.sample( # window=window, params=torch.cat((params_pre, x, params_post), dim=-1) @@ -158,7 +160,7 @@ def _jacobian( # )(params) return jacobian( lambda x: self.sample( - window=window, params=torch.cat((params_pre, x, params_post), dim=-1) + window=window, params=backend.concatenate((params_pre, x, params_post), dim=-1) ).data, params, strategy="forward-mode", @@ -170,7 +172,7 @@ def jacobian( self, window: Optional[Window] = None, pass_jacobian: Optional[JacobianImage] = None, - params: Optional[Tensor] = None, + params: Optional[ArrayLike] = None, ) -> JacobianImage: if window is None: window = self.window @@ -220,9 +222,9 @@ def jacobian( def gradient( self, window: Optional[Window] = None, - params: Optional[Tensor] = None, + params: Optional[ArrayLike] = None, likelihood: Literal["gaussian", "poisson"] = "gaussian", - ) -> Tensor: + ) -> ArrayLike: """Compute the gradient of the model with respect to its parameters.""" if window is None: window = self.window @@ -233,11 +235,11 @@ def gradient( model = self.sample(window=window).data if likelihood == "gaussian": weight = self.target[window].weight - gradient = torch.sum( + gradient = backend.sum( jacobian_image.data * ((data - model) * weight).unsqueeze(-1), dim=(0, 1) ) elif likelihood == "poisson": - gradient = torch.sum( + gradient = backend.sum( jacobian_image.data * (1 - data / model).unsqueeze(-1), dim=(0, 1), ) diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index 7e630e75..fad8ab4c 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -1,7 +1,7 @@ import torch -from torch import Tensor from ...param import forward +from ...backend_obj import ArrayLike from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import parametric_initialize, parametric_segment_initialize from ...utils.parametric_profiles import sersic_np @@ -49,7 +49,7 @@ def initialize(self): ) @forward - def radial_model(self, R: Tensor, n: Tensor, Re: Tensor, Ie: Tensor) -> Tensor: + def radial_model(self, R: ArrayLike, n: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: return func.sersic(R, n, Re, Ie) @@ -98,5 +98,7 @@ def initialize(self): ) @forward - def iradial_model(self, i: int, R: Tensor, n: Tensor, Re: Tensor, Ie: Tensor) -> Tensor: + def iradial_model( + self, i: int, R: ArrayLike, n: ArrayLike, Re: ArrayLike, Ie: ArrayLike + ) -> ArrayLike: return func.sersic(R, n[i], Re[i], Ie[i]) diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 22169748..3a21c11b 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -3,6 +3,7 @@ import numpy as np from ...param import forward +from ...backend_obj import ArrayLike from ...utils.decorators import ignore_numpy_warnings from .._shared_methods import _sample_image from ...utils.interpolate import default_prof @@ -50,7 +51,7 @@ def initialize(self): self.I_R.dynamic_value = 10**I @forward - def radial_model(self, R: Tensor, I_R: Tensor) -> Tensor: + def radial_model(self, R: ArrayLike, I_R: ArrayLike) -> ArrayLike: ret = func.spline(R, self.I_R.prof, I_R) return ret @@ -112,5 +113,5 @@ def initialize(self): self.I_R.dynamic_value = 10**value @forward - def iradial_model(self, i: int, R: Tensor, I_R: Tensor) -> Tensor: + def iradial_model(self, i: int, R: ArrayLike, I_R: ArrayLike) -> ArrayLike: return func.spline(R, self.I_R.prof[i], I_R[i]) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 3e17653d..3672e4bd 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -5,6 +5,7 @@ from ...utils.decorators import ignore_numpy_warnings from ...utils.interpolate import default_prof +from ...backend_obj import backend, ArrayLike from ...param import forward from .. import func from ... import config @@ -50,16 +51,16 @@ def initialize(self): if self.PA.initialized and self.q.initialized: return target_area = self.target[self.window] - dat = target_area.data.detach().cpu().numpy().copy() + dat = backend.to_numpy(backend.copy(target_area.data)) if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() + mask = backend.to_numpy(backend.copy(target_area.mask)) dat[mask] = np.median(dat[~mask]) edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) dat -= edge_average x, y = target_area.coordinate_center_meshgrid() - x = (x - self.center.value[0]).detach().cpu().numpy() - y = (y - self.center.value[1]).detach().cpu().numpy() + x = backend.to_numpy(x - self.center.value[0]) + y = backend.to_numpy(y - self.center.value[1]) mu20 = np.mean(dat * np.abs(x)) mu02 = np.mean(dat * np.abs(y)) mu11 = np.mean(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) @@ -80,8 +81,8 @@ def initialize(self): @forward def transform_coordinates( - self, x: Tensor, y: Tensor, PA: Tensor, q: Tensor - ) -> Tuple[Tensor, Tensor]: + self, x: ArrayLike, y: ArrayLike, PA: ArrayLike, q: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: x, y = super().transform_coordinates(x, y) x, y = func.rotate(-PA + np.pi / 2, x, y) return x, y / q @@ -117,8 +118,8 @@ class SuperEllipseMixin: } @forward - def radius_metric(self, x: Tensor, y: Tensor, C: Tensor) -> Tensor: - return torch.pow(x.abs().pow(C) + y.abs().pow(C) + self.softening**C, 1.0 / C) + def radius_metric(self, x: ArrayLike, y: ArrayLike, C: ArrayLike) -> ArrayLike: + return (x.abs().pow(C) + y.abs().pow(C) + self.softening**C) ** (1.0 / C) class FourierEllipseMixin: @@ -170,16 +171,18 @@ class FourierEllipseMixin: def __init__(self, *args, modes: Tuple[int] = (3, 4), **kwargs): super().__init__(*args, **kwargs) - self.modes = torch.tensor(modes, dtype=config.DTYPE, device=config.DEVICE) + self.modes = backend.as_array(modes, dtype=config.DTYPE, device=config.DEVICE) @forward - def radius_metric(self, x: Tensor, y: Tensor, am: Tensor, phim: Tensor) -> Tensor: + def radius_metric( + self, x: ArrayLike, y: ArrayLike, am: ArrayLike, phim: ArrayLike + ) -> ArrayLike: R = super().radius_metric(x, y) theta = self.angular_metric(x, y) - return R * torch.exp( - torch.sum( + return R * backend.exp( + backend.sum( am.unsqueeze(-1) - * torch.cos(self.modes.unsqueeze(-1) * theta.flatten() + phim.unsqueeze(-1)), + * backend.cos(self.modes.unsqueeze(-1) * theta.flatten() + phim.unsqueeze(-1)), 0, ).reshape(x.shape) ) @@ -241,8 +244,8 @@ def initialize(self): @forward def transform_coordinates( - self, x: Tensor, y: Tensor, q_R: Tensor, PA_R: Tensor - ) -> Tuple[Tensor, Tensor]: + self, x: ArrayLike, y: ArrayLike, q_R: ArrayLike, PA_R: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: x, y = super().transform_coordinates(x, y) R = self.radius_metric(x, y) PA = func.spline(R, self.PA_R.prof, PA_R, extend="const") @@ -296,8 +299,8 @@ def initialize(self): self.Rt.dynamic_value = prof[len(prof) // 2] @forward - def radial_model(self, R: Tensor, Rt: Tensor, St: Tensor) -> Tensor: + def radial_model(self, R: ArrayLike, Rt: ArrayLike, St: ArrayLike) -> ArrayLike: I = super().radial_model(R) if self.outer_truncation: - return I * (1 - torch.tanh(St * (R - Rt))) / 2 - return I * (torch.tanh(St * (R - Rt)) + 1) / 2 + return I * (1 - backend.tanh(St * (R - Rt))) / 2 + return I * (backend.tanh(St * (R - Rt)) + 1) / 2 diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index e2c1d4ae..d664f084 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -14,6 +14,7 @@ from ..utils.initialize import recursive_center_of_mass from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from .. import config +from ..backend_obj import backend, ArrayLike from ..errors import InvalidTarget from .mixins import SampleMixin @@ -76,12 +77,10 @@ def _update_psf_upscale(self): if self.psf is None: self.psf_upscale = 1 elif isinstance(self.psf, PSFImage): - self.psf_upscale = ( - torch.round(self.target.pixelscale / self.psf.pixelscale).int().item() - ) + self.psf_upscale = int(np.round((self.target.pixelscale / self.psf.pixelscale).item())) elif isinstance(self.psf, Model): - self.psf_upscale = ( - torch.round(self.target.pixelscale / self.psf.target.pixelscale).int().item() + self.psf_upscale = int( + np.round((self.target.pixelscale / self.psf.target.pixelscale).item()) ) else: raise TypeError( @@ -127,21 +126,21 @@ def initialize(self): return target_area = self.target[self.window] - dat = np.copy(target_area.data.detach().cpu().numpy()) + dat = np.copy(backend.to_numpy(target_area.data)) if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() + mask = backend.to_numpy(target_area.mask) dat[mask] = np.nanmedian(dat[~mask]) COM = recursive_center_of_mass(dat) if not np.all(np.isfinite(COM)): return COM_center = target_area.pixel_to_plane( - *torch.tensor(COM, dtype=config.DTYPE, device=config.DEVICE) + *backend.as_array(COM, dtype=config.DTYPE, device=config.DEVICE) ) self.center.dynamic_value = COM_center def fit_mask(self): - return torch.zeros_like(self.target[self.window].mask, dtype=torch.bool) + return backend.zeros_like(self.target[self.window].mask, dtype=torch.bool) @forward def transform_coordinates(self, x, y, center): diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 877d909b..29dd8e8c 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -1,11 +1,11 @@ from typing import Optional, Tuple import torch -from torch import Tensor import numpy as np from .model_object import ComponentModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from . import func +from ..backend_obj import backend, ArrayLike from ..param import forward __all__ = ["MultiGaussianExpansion"] @@ -54,9 +54,9 @@ def initialize(self): super().initialize() target_area = self.target[self.window] - dat = target_area.data.detach().cpu().numpy().copy() + dat = backend.to_numpy(target_area.data).copy() if target_area.has_mask: - mask = target_area.mask.detach().cpu().numpy() + mask = backend.to_numpy(target_area.mask) dat[mask] = np.median(dat[~mask]) edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) @@ -75,8 +75,8 @@ def initialize(self): return x, y = target_area.coordinate_center_meshgrid() - x = (x - self.center.value[0]).detach().cpu().numpy() - y = (y - self.center.value[1]).detach().cpu().numpy() + x = backend.to_numpy(x - self.center.value[0]) + y = backend.to_numpy(y - self.center.value[1]) mu20 = np.median(dat * np.abs(x)) mu02 = np.median(dat * np.abs(y)) mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y) + self.softening**2)) @@ -100,26 +100,28 @@ def initialize(self): @forward def transform_coordinates( - self, x: Tensor, y: Tensor, q: Tensor, PA: Tensor - ) -> Tuple[Tensor, Tensor]: + self, x: ArrayLike, y: ArrayLike, q: ArrayLike, PA: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: x, y = super().transform_coordinates(x, y) if PA.numel() == 1: x, y = func.rotate(-(PA + np.pi / 2), x, y) x = x.repeat(q.shape[0], *[1] * x.ndim) y = y.repeat(q.shape[0], *[1] * y.ndim) else: - x, y = torch.vmap(lambda pa: func.rotate(-(pa + np.pi / 2), x, y))(PA) - y = torch.vmap(lambda q, y: y / q)(q, y) + x, y = backend.vmap(lambda pa: func.rotate(-(pa + np.pi / 2), x, y))(PA) + y = backend.vmap(lambda q, y: y / q)(q, y) return x, y @forward - def brightness(self, x: Tensor, y: Tensor, flux: Tensor, sigma: Tensor, q: Tensor) -> Tensor: + def brightness( + self, x: ArrayLike, y: ArrayLike, flux: ArrayLike, sigma: ArrayLike, q: ArrayLike + ) -> ArrayLike: x, y = self.transform_coordinates(x, y) R = self.radius_metric(x, y) - return torch.sum( - torch.vmap( - lambda A, r, sig, _q: (A / torch.sqrt(2 * np.pi * _q * sig**2)) - * torch.exp(-0.5 * (r / sig) ** 2) + return backend.sum( + backend.vmap( + lambda A, r, sig, _q: (A / backend.sqrt(2 * np.pi * _q * sig**2)) + * backend.exp(-0.5 * (r / sig) ** 2) )(flux, R, sigma, q), dim=0, ) diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index 9d5a053a..98ac14de 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -1,11 +1,11 @@ import torch -from torch import Tensor from .psf_model_object import PSFModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from caskade import OverrideParam from ..param import forward +from ..backend_obj import backend, ArrayLike __all__ = ["PixelatedPSF"] @@ -52,10 +52,12 @@ def initialize(self): if self.pixels.initialized: return target_area = self.target[self.window] - self.pixels.dynamic_value = target_area.data.clone() / target_area.pixel_area + self.pixels.dynamic_value = backend.copy(target_area.data) / target_area.pixel_area @forward - def brightness(self, x: Tensor, y: Tensor, pixels: Tensor, center: Tensor) -> Tensor: + def brightness( + self, x: ArrayLike, y: ArrayLike, pixels: ArrayLike, center: ArrayLike + ) -> ArrayLike: with OverrideParam(self.target.crtan, center): i, j = self.target.plane_to_pixel(x, y) result = interp2d(pixels, i, j) diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index d1473593..e2eed950 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -1,10 +1,10 @@ import numpy as np import torch -from torch import Tensor from .sky_model_object import SkyModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..param import forward +from ..backend_obj import backend, ArrayLike __all__ = ["PlaneSky"] @@ -38,11 +38,11 @@ def initialize(self): super().initialize() if not self.I0.initialized: - dat = self.target[self.window].data.detach().cpu().numpy().copy() + dat = backend.to_numpy(self.target[self.window].data).copy() self.I0.dynamic_value = np.median(dat) / self.target.pixel_area.item() if not self.delta.initialized: self.delta.dynamic_value = [0.0, 0.0] @forward - def brightness(self, x: Tensor, y: Tensor, I0: Tensor, delta: Tensor) -> Tensor: + def brightness(self, x: ArrayLike, y: ArrayLike, I0: ArrayLike, delta: ArrayLike) -> ArrayLike: return I0 + x * delta[0] + y * delta[1] diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 4639f48b..a3feac66 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -11,6 +11,7 @@ from ..image import Window, PSFImage from ..errors import SpecificationConflict from ..param import forward +from ..backend_obj import backend, ArrayLike __all__ = ("PointSource",) @@ -49,7 +50,7 @@ def initialize(self): if self.flux.initialized: return target_area = self.target[self.window] - dat = target_area.data.detach().cpu().numpy().copy() + dat = backend.to_numpy(target_area.data).copy() edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) self.flux.dynamic_value = np.abs(np.sum(dat - edge_average)) @@ -75,8 +76,8 @@ def integrate_mode(self, value): def sample( self, window: Optional[Window] = None, - center: torch.Tensor = None, - flux: torch.Tensor = None, + center: ArrayLike = None, + flux: ArrayLike = None, ) -> ModelImage: """Evaluate the model on the space covered by an image object. This function properly calls integration methods and PSF diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 9061acc3..e86645b8 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -7,7 +7,7 @@ from ..image import ModelImage, PSFImage, Window from ..errors import InvalidTarget from .mixins import SampleMixin - +from ..backend_obj import backend, ArrayLike __all__ = ["PSFModel"] @@ -41,7 +41,9 @@ def initialize(self): pass @forward - def transform_coordinates(self, x: Tensor, y: Tensor, center: Tensor) -> Tuple[Tensor, Tensor]: + def transform_coordinates( + self, x: ArrayLike, y: ArrayLike, center: ArrayLike + ) -> Tuple[ArrayLike, ArrayLike]: return x - center[0], y - center[1] # Fit loop functions @@ -79,8 +81,8 @@ def sample(self, window: Optional[Window] = None) -> PSFImage: return working_image - def fit_mask(self) -> Tensor: - return torch.zeros_like(self.target[self.window].mask, dtype=torch.bool) + def fit_mask(self) -> ArrayLike: + return backend.zeros_like(self.target[self.window].mask, dtype=backend.bool) @property def target(self): diff --git a/astrophot/param/module.py b/astrophot/param/module.py index 78e87f65..864de4f5 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -1,5 +1,4 @@ import numpy as np -import torch from math import prod from caskade import ( Module as CModule, @@ -7,6 +6,7 @@ ParamConfigurationError, FillDynamicParamsArrayError, ) +from ..backend_obj import backend class Module(CModule): @@ -23,10 +23,10 @@ def build_params_array_uncertainty(self): uncertainties = [] for param in self.dynamic_params: if param.uncertainty is None: - uncertainties.append(torch.zeros_like(param.value.flatten())) + uncertainties.append(backend.zeros_like(param.value.flatten())) else: uncertainties.append(param.uncertainty.flatten()) - return torch.cat(tuple(uncertainties), dim=-1) + return backend.concatenate(tuple(uncertainties), dim=-1) def build_params_array_names(self): names = [] diff --git a/astrophot/param/param.py b/astrophot/param/param.py index 7d6504e8..2a5f746a 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -1,5 +1,5 @@ from caskade import Param as CParam -import torch +from ..backend_obj import backend class Param(CParam): @@ -24,7 +24,7 @@ def uncertainty(self, uncertainty): if uncertainty is None: self._uncertainty = None else: - self._uncertainty = torch.as_tensor(uncertainty) + self._uncertainty = backend.as_array(uncertainty) @property def prof(self): @@ -35,7 +35,7 @@ def prof(self, prof): if prof is None: self._prof = None else: - self._prof = torch.as_tensor(prof) + self._prof = backend.as_array(prof) @property def initialized(self): @@ -47,9 +47,9 @@ def initialized(self): return False def is_valid(self, value): - if self.valid[0] is not None and torch.any(value <= self.valid[0]): + if self.valid[0] is not None and backend.any(value <= self.valid[0]): return False - if self.valid[1] is not None and torch.any(value >= self.valid[1]): + if self.valid[1] is not None and backend.any(value >= self.valid[1]): return False return True @@ -66,4 +66,4 @@ def soft_valid(self, value): elif self.valid[1] is not None: smin = None smax = self.valid[1] - 0.1 - return torch.clamp(value, min=smin, max=smax) + return backend.clamp(value, min=smin, max=smax) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 96a239ba..f3bb4f97 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -10,6 +10,7 @@ from ..models import GroupModel, PSFModel, PSFGroupModel from ..image import ImageList, WindowList, PSFImage from .. import config +from ..backend_obj import backend from ..utils.conversions.units import flux_to_sb from ..utils.decorators import ignore_numpy_warnings from .visuals import * @@ -47,12 +48,12 @@ def target_image(fig, ax, target, window=None, **kwargs): if window is None: window = target.window target_area = target[window] - dat = np.copy(target_area.data.detach().cpu().numpy()) + dat = np.copy(backend.to_numpy(target_area.data)) if target_area.has_mask: - dat[target_area.mask.detach().cpu().numpy()] = np.nan + dat[backend.to_numpy(target_area.mask)] = np.nan X, Y = target_area.coordinate_corner_meshgrid() - X = X.detach().cpu().numpy() - Y = Y.detach().cpu().numpy() + X = backend.to_numpy(X) + Y = backend.to_numpy(Y) sky = np.nanmedian(dat) noise = iqr(dat[np.isfinite(dat)], rng=(16, 84)) / 2 if noise == 0: @@ -91,7 +92,7 @@ def target_image(fig, ax, target, window=None, **kwargs): clim=[sky + 3 * noise, None], ) - if torch.linalg.det(target.CD.value) < 0: + if np.linalg.det(target.CD.npvalue) < 0: ax.invert_xaxis() ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") @@ -131,9 +132,9 @@ def psf_image( # Evaluate the model image x, y = psf.coordinate_corner_meshgrid() - x = x.detach().cpu().numpy() - y = y.detach().cpu().numpy() - psf = psf.data.detach().cpu().numpy() + x = backend.to_numpy(x) + y = backend.to_numpy(y) + psf = backend.to_numpy(psf.data) # Default kwargs for image kwargs = { @@ -237,9 +238,9 @@ def model_image( # Evaluate the model image X, Y = sample_image.coordinate_corner_meshgrid() - X = X.detach().cpu().numpy() - Y = Y.detach().cpu().numpy() - sample_image = sample_image.data.detach().cpu().numpy() + X = backend.to_numpy(X) + Y = backend.to_numpy(Y) + sample_image = backend.to_numpy(sample_image.data) # Default kwargs for image kwargs = { @@ -269,12 +270,12 @@ def model_image( # Apply the mask if available if target_mask and target.has_mask: - sample_image[target.mask.detach().cpu().numpy()] = np.nan + sample_image[backend.to_numpy(target.mask)] = np.nan # Plot the image im = ax.pcolormesh(X, Y, sample_image, **kwargs) - if torch.linalg.det(target.CD.value) < 0: + if np.linalg.det(target.CD.npvalue) < 0: ax.invert_xaxis() # Enforce equal spacing on x y @@ -356,18 +357,18 @@ def residual_image( sample_image = sample_image[window] target = target[window] X, Y = sample_image.coordinate_corner_meshgrid() - X = X.detach().cpu().numpy() - Y = Y.detach().cpu().numpy() + X = backend.to_numpy(X) + Y = backend.to_numpy(Y) residuals = (target - sample_image).data if normalize_residuals is True: - residuals = residuals / torch.sqrt(target.variance) - elif isinstance(normalize_residuals, torch.Tensor): - residuals = residuals / torch.sqrt(normalize_residuals) + residuals = residuals / backend.sqrt(target.variance) + elif isinstance(normalize_residuals, backend.array_type): + residuals = residuals / backend.sqrt(normalize_residuals) normalize_residuals = True if target.has_mask: residuals[target.mask] = np.nan - residuals = residuals.detach().cpu().numpy() + residuals = backend.to_numpy(residuals) if scaling == "clip": if normalize_residuals is not True: @@ -406,7 +407,7 @@ def residual_image( } imshow_kwargs.update(kwargs) im = ax.pcolormesh(X, Y, residuals, **imshow_kwargs) - if torch.linalg.det(target.CD.value) < 0: + if np.linalg.det(target.CD.npvalue) < 0: ax.invert_xaxis() ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 609b32c0..9e64a33d 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -6,6 +6,7 @@ from scipy.stats import binned_statistic, iqr from .. import config +from ..backend_obj import backend from ..models import Model # from ..models import Warp_Galaxy @@ -44,7 +45,7 @@ def radial_light_profile( - `resolution` (int): The number of points to use in the profile. Default: 1000 - `plot_kwargs` (dict): Additional keyword arguments to pass to the plot function, such as `linewidth`, `color`, etc. """ - xx = torch.linspace( + xx = backend.linspace( R0, max(model.window.shape) * model.target.pixelscale.detach().cpu().numpy() @@ -66,12 +67,11 @@ def radial_light_profile( "label": f"{model.name} profile", } kwargs.update(plot_kwargs) - with torch.no_grad(): - ax.plot( - xx.detach().cpu().numpy(), - yy, - **kwargs, - ) + ax.plot( + backend.to_numpy(xx), + yy, + **kwargs, + ) if model.target.zeropoint is not None: ax.set_ylabel("Surface Brightness [mag/arcsec$^2$]") @@ -125,10 +125,10 @@ def radial_median_profile( image = model.target[model.window] x, y = image.coordinate_center_meshgrid() x, y = model.transform_coordinates(x, y, params=()) - R = (x**2 + y**2).sqrt() - R = R.detach().cpu().numpy() + R = backend.sqrt(x**2 + y**2) + R = backend.to_numpy(R) - dat = image.data.detach().cpu().numpy() + dat = backend.to_numpy(image.data) count, bins, binnum = binned_statistic( R.ravel(), dat.ravel(), @@ -202,7 +202,7 @@ def ray_light_profile( - `extend_profile` (float): The factor by which to extend the profile beyond the maximum radius of the model's window. Default: 1.0 - `resolution` (int): The number of points to use in the profile. Default: 1000 """ - xx = torch.linspace( + xx = backend.linspace( 0, max(model.window.shape) * model.target.pixelscale * extend_profile / 2, int(resolution), @@ -216,8 +216,8 @@ def ray_light_profile( col = cmap_grad(r / model.segments) with torch.no_grad(): ax.plot( - xx.detach().cpu().numpy(), - np.log10(model.iradial_model(r, xx, params=()).detach().cpu().numpy()), + backend.to_numpy(xx), + np.log10(backend.to_numpy(model.iradial_model(r, xx, params=()))), linewidth=2, color=col, label=f"{model.name} profile {r}", @@ -231,14 +231,14 @@ def ray_light_profile( def warp_phase_profile(fig, ax, model: Model, rad_unit="arcsec"): """Used to plot the phase profile of a warp model. This gives the axis ratio and position angle as a function of radius.""" ax.plot( - model.q_R.prof.detach().cpu().numpy(), + backend.to_numpy(model.q_R.prof), model.q_R.npvalue, linewidth=2, color=main_pallet["primary1"], label=f"{model.name} axis ratio", ) ax.plot( - model.PA_R.prof.detach().cpu().numpy(), + backend.to_numpy(model.PA_R.prof), model.PA_R.npvalue / np.pi, linewidth=2, color=main_pallet["primary2"], diff --git a/astrophot/utils/conversions/functions.py b/astrophot/utils/conversions/functions.py index 7cb36a35..21a19144 100644 --- a/astrophot/utils/conversions/functions.py +++ b/astrophot/utils/conversions/functions.py @@ -1,8 +1,8 @@ from typing import Union import numpy as np -import torch from scipy.special import gamma from torch.special import gammaln +from ...backend_obj import backend, ArrayLike __all__ = ( "sersic_n_to_b", @@ -21,8 +21,8 @@ def sersic_n_to_b( - n: Union[float, np.ndarray, torch.Tensor], -) -> Union[float, np.ndarray, torch.Tensor]: + n: Union[float, np.ndarray, ArrayLike], +) -> Union[float, np.ndarray, ArrayLike]: """Compute the `b(n)` for a sersic model. This factor ensures that the $R_e$ and $I_e$ parameters do in fact correspond to the half light values and not some other scale @@ -132,9 +132,7 @@ def sersic_inv_np(I: np.ndarray, n: np.ndarray, Re: np.ndarray, Ie: np.ndarray) return Re * ((1 - (1 / bn) * np.log(I / Ie)) ** (n)) -def sersic_I0_to_flux_torch( - I0: torch.Tensor, n: torch.Tensor, R: torch.Tensor, q: torch.Tensor -) -> torch.Tensor: +def sersic_I0_to_flux_torch(I0: ArrayLike, n: ArrayLike, R: ArrayLike, q: ArrayLike) -> ArrayLike: """Compute the total flux integrated to infinity for a 2D elliptical sersic given the $I_0,n,R_s,q$ parameters which uniquely define the profile ($I_0$ is the central intensity in @@ -152,12 +150,10 @@ def sersic_I0_to_flux_torch( """ - return 2 * np.pi * I0 * q * n * R**2 * torch.exp(gammaln(2 * n)) + return 2 * np.pi * I0 * q * n * R**2 * backend.exp(gammaln(2 * n)) -def sersic_flux_to_I0_torch( - flux: torch.Tensor, n: torch.Tensor, R: torch.Tensor, q: torch.Tensor -) -> torch.Tensor: +def sersic_flux_to_I0_torch(flux: ArrayLike, n: ArrayLike, R: ArrayLike, q: ArrayLike) -> ArrayLike: """Compute the central intensity (flux/arcsec^2) for a 2D elliptical sersic given the $F,n,R_s,q$ parameters which uniquely define the profile ($F$ is the total flux integrated to @@ -174,12 +170,10 @@ def sersic_flux_to_I0_torch( - `q`: axis ratio (b/a) """ - return flux / (2 * np.pi * q * n * R**2 * torch.exp(gammaln(2 * n))) + return flux / (2 * np.pi * q * n * R**2 * backend.exp(gammaln(2 * n))) -def sersic_Ie_to_flux_torch( - Ie: torch.Tensor, n: torch.Tensor, R: torch.Tensor, q: torch.Tensor -) -> torch.Tensor: +def sersic_Ie_to_flux_torch(Ie: ArrayLike, n: ArrayLike, R: ArrayLike, q: ArrayLike) -> ArrayLike: """Compute the total flux integrated to infinity for a 2D elliptical sersic given the $I_e,n,R_e,q$ parameters which uniquely define the profile ($I_e$ is the intensity at $R_e$ in @@ -198,13 +192,18 @@ def sersic_Ie_to_flux_torch( """ bn = sersic_n_to_b(n) return ( - 2 * np.pi * Ie * R**2 * q * n * (torch.exp(bn) * bn ** (-2 * n)) * torch.exp(gammaln(2 * n)) + 2 + * np.pi + * Ie + * R**2 + * q + * n + * (backend.exp(bn) * bn ** (-2 * n)) + * backend.exp(gammaln(2 * n)) ) -def sersic_flux_to_Ie_torch( - flux: torch.Tensor, n: torch.Tensor, R: torch.Tensor, q: torch.Tensor -) -> torch.Tensor: +def sersic_flux_to_Ie_torch(flux: ArrayLike, n: ArrayLike, R: ArrayLike, q: ArrayLike) -> ArrayLike: """Compute the intensity at $R_e$ (flux/arcsec^2) for a 2D elliptical sersic given the $F,n,R_e,q$ parameters which uniquely define the profile ($F$ is the total flux @@ -223,19 +222,17 @@ def sersic_flux_to_Ie_torch( """ bn = sersic_n_to_b(n) return flux / ( - 2 * np.pi * R**2 * q * n * (torch.exp(bn) * bn ** (-2 * n)) * torch.exp(gammaln(2 * n)) + 2 * np.pi * R**2 * q * n * (backend.exp(bn) * bn ** (-2 * n)) * backend.exp(gammaln(2 * n)) ) -def sersic_inv_torch( - I: torch.Tensor, n: torch.Tensor, Re: torch.Tensor, Ie: torch.Tensor -) -> torch.Tensor: +def sersic_inv_torch(I: ArrayLike, n: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: """Invert the sersic profile. Compute the radius corresponding to a given intensity for a pure sersic profile. """ bn = sersic_n_to_b(n) - return Re * ((1 - (1 / bn) * torch.log(I / Ie)) ** (n)) + return Re * ((1 - (1 / bn) * backend.log(I / Ie)) ** (n)) def moffat_I0_to_flux(I0: float, n: float, rd: float, q: float) -> float: diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index 7bc2cc21..b88d2331 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -2,8 +2,8 @@ from typing import Optional, Union import numpy as np -import torch from astropy.io import fits +from ...backend_obj import backend __all__ = ( "centroids_from_segmentation_map", @@ -53,9 +53,9 @@ def centroids_from_segmentation_map( seg_map = seg_map.T if sky_level is None: - sky_level = np.nanmedian(image.data) + sky_level = np.nanmedian(backend.to_numpy(image.data)) - data = image.data.detach().cpu().numpy() - sky_level + data = backend.to_numpy(image.data) - sky_level centroids = {} II, JJ = np.meshgrid(np.arange(seg_map.shape[0]), np.arange(seg_map.shape[1]), indexing="ij") @@ -67,8 +67,8 @@ def centroids_from_segmentation_map( icentroid = np.sum(II[N] * data[N]) / np.sum(data[N]) jcentroid = np.sum(JJ[N] * data[N]) / np.sum(data[N]) xcentroid, ycentroid = image.pixel_to_plane( - torch.tensor(icentroid, dtype=image.data.dtype, device=image.data.device), - torch.tensor(jcentroid, dtype=image.data.dtype, device=image.data.device), + backend.as_array(icentroid, dtype=image.data.dtype, device=image.data.device), + backend.as_array(jcentroid, dtype=image.data.dtype, device=image.data.device), params=(), ) centroids[index] = [xcentroid.item(), ycentroid.item()] @@ -91,9 +91,9 @@ def PA_from_segmentation_map( # reverse to match numpy indexing seg_map = seg_map.T if sky_level is None: - sky_level = np.nanmedian(image.data) + sky_level = np.nanmedian(backend.to_numpy(image.data)) - data = image.data.detach().cpu().numpy() - sky_level + data = backend.to_numpy(image.data) - sky_level if centroids is None: centroids = centroids_from_segmentation_map( @@ -101,8 +101,8 @@ def PA_from_segmentation_map( ) x, y = image.coordinate_center_meshgrid() - x = x.detach().cpu().numpy() - y = y.detach().cpu().numpy() + x = backend.to_numpy(x) + y = backend.to_numpy(y) PAs = {} for index in np.unique(seg_map): if index is None or index in skip_index: @@ -138,9 +138,9 @@ def q_from_segmentation_map( seg_map = seg_map.T if sky_level is None: - sky_level = np.nanmedian(image.data) + sky_level = np.nanmedian(backend.to_numpy(image.data)) - data = image.data.detach().cpu().numpy() - sky_level + data = backend.to_numpy(image.data) - sky_level if centroids is None: centroids = centroids_from_segmentation_map( @@ -148,8 +148,8 @@ def q_from_segmentation_map( ) x, y = image.coordinate_center_meshgrid() - x = x.detach().cpu().numpy() - y = y.detach().cpu().numpy() + x = backend.to_numpy(x) + y = backend.to_numpy(y) qs = {} for index in np.unique(seg_map): if index is None or index in skip_index: @@ -295,7 +295,7 @@ def filter_windows( if min_flux is not None: if ( np.sum( - image.data[ + backend.to_numpy(image.data)[ windows[w][0][0] : windows[w][1][0], windows[w][0][1] : windows[w][1][1], ] @@ -306,7 +306,7 @@ def filter_windows( if max_flux is not None: if ( np.sum( - image.data[ + backend.to_numpy(image.data)[ windows[w][0][0] : windows[w][1][0], windows[w][0][1] : windows[w][1][1], ] @@ -331,7 +331,7 @@ def transfer_windows(windows, base_image, new_image): """ new_windows = {} for w in list(windows.keys()): - four_corners_base = torch.tensor( + four_corners_base = backend.as_array( [ windows[w][0], windows[w][1], @@ -341,13 +341,10 @@ def transfer_windows(windows, base_image, new_image): dtype=base_image.data.dtype, device=base_image.data.device, ) # (4,2) - four_corners_new = ( - torch.stack( + four_corners_new = backend.to_numpy( + backend.stack( new_image.plane_to_pixel(*base_image.pixel_to_plane(*four_corners_base.T)), dim=-1 ) - .detach() - .cpu() - .numpy() ) # (4,2) bottom_corner = np.floor(np.min(four_corners_new, axis=0)).astype(int) diff --git a/astrophot/utils/initialize/variance.py b/astrophot/utils/initialize/variance.py index 16ae21cc..68f881bd 100644 --- a/astrophot/utils/initialize/variance.py +++ b/astrophot/utils/initialize/variance.py @@ -2,16 +2,15 @@ from scipy.ndimage import gaussian_filter from scipy.stats import binned_statistic import torch -from ...errors import InvalidData -import matplotlib.pyplot as plt +from ...backend_obj import backend, ArrayLike def auto_variance(data, mask=None): - if isinstance(data, torch.Tensor): - data = data.detach().cpu().numpy() - if isinstance(mask, torch.Tensor): - mask = mask.detach().cpu().numpy() + if isinstance(data, backend.array_type): + data = backend.to_numpy(data) + if isinstance(mask, backend.array_type): + mask = backend.to_numpy(mask) if mask is None: mask = np.zeros(data.shape, dtype=int) diff --git a/astrophot/utils/integration.py b/astrophot/utils/integration.py index c72dc3da..e765a3c8 100644 --- a/astrophot/utils/integration.py +++ b/astrophot/utils/integration.py @@ -2,6 +2,7 @@ from scipy.special import roots_legendre import torch +from ..backend_obj import backend __all__ = ("quad_table",) @@ -27,9 +28,9 @@ def quad_table(order, dtype, device): """ abscissa, weights = roots_legendre(order) - w = torch.tensor(weights, dtype=dtype, device=device) - a = torch.tensor(abscissa, dtype=dtype, device=device) / 2.0 - di, dj = torch.meshgrid(a, a, indexing="ij") + w = backend.as_array(weights, dtype=dtype, device=device) + a = backend.as_array(abscissa, dtype=dtype, device=device) / 2.0 + di, dj = backend.meshgrid(a, a, indexing="ij") - w = torch.outer(w, w) / 4.0 + w = backend.outer(w, w) / 4.0 return di, dj, w diff --git a/astrophot/utils/interpolate.py b/astrophot/utils/interpolate.py index 3f498b29..b142e66d 100644 --- a/astrophot/utils/interpolate.py +++ b/astrophot/utils/interpolate.py @@ -1,6 +1,8 @@ import torch import numpy as np +from ..backend_obj import backend, ArrayLike + __all__ = ("default_prof", "interp2d") @@ -15,11 +17,11 @@ def default_prof( def interp2d( - im: torch.Tensor, - i: torch.Tensor, - j: torch.Tensor, + im: ArrayLike, + i: ArrayLike, + j: ArrayLike, padding_mode: str = "zeros", -) -> torch.Tensor: +) -> ArrayLike: """ Interpolates a 2D image at specified coordinates. Similar to `torch.nn.functional.grid_sample` with `align_corners=False`. @@ -44,11 +46,11 @@ def interp2d( # valid valid = (i >= -0.5) & (i <= (h - 0.5)) & (j >= -0.5) & (j <= (w - 0.5)) - i0 = i.floor().long() - j0 = j.floor().long() - i0 = i0.clamp(0, h - 2) + i0 = backend.long(backend.floor(i)) + j0 = backend.long(backend.floor(j)) + i0 = backend.clamp(i0, 0, h - 2) i1 = i0 + 1 - j0 = j0.clamp(0, w - 2) + j0 = backend.clamp(j0, 0, w - 2) j1 = j0 + 1 fa = im[i0, j0] diff --git a/tests/test_model.py b/tests/test_model.py index e0add2c0..4c8b288f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -153,6 +153,7 @@ def test_all_model_sample(model_type): "exponential warp galaxy model", "ferrer warp galaxy model", "ferrer ray galaxy model", + "isothermal sech2 edgeon model", ] ): assert res.loss_history[0] > res.loss_history[-1], ( From eeffeba78faa12b04ac143ca6f7fc5f3b2c8b0e5 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 13 Aug 2025 20:33:43 -0400 Subject: [PATCH 121/191] jax working on first tests --- astrophot/backend_obj.py | 49 +++++++++---------- astrophot/image/image_object.py | 8 ++-- astrophot/image/mixins/data_mixin.py | 10 ++-- astrophot/image/target_image.py | 2 +- astrophot/models/basis.py | 2 +- astrophot/models/func/integration.py | 70 ++++++++++++++++------------ astrophot/models/mixins/sample.py | 54 ++++++++++++--------- tests/test_cmos_image.py | 13 +++--- 8 files changed, 110 insertions(+), 98 deletions(-) diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index 9d3e337c..cf6e999e 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -60,8 +60,6 @@ def setup_torch(self): self.to_numpy = self._to_numpy_torch self.logit = self._logit_torch self.sigmoid = self._sigmoid_torch - self.arange = self._arange_torch - self.meshgrid = self._meshgrid_torch self.repeat = self._repeat_torch self.stack = self._stack_torch self.transpose = self._transpose_torch @@ -79,6 +77,7 @@ def setup_torch(self): self.lgamma = self._lgamma_torch self.hessian = self._hessian_torch self.long = self._long_torch + self.fill_at_indices = self._fill_at_indices_torch def setup_jax(self): self.jax = importlib.import_module("jax") @@ -96,14 +95,12 @@ def setup_jax(self): self.to_numpy = self._to_numpy_jax self.logit = self._logit_jax self.sigmoid = self._sigmoid_jax - self.arange = self._arange_jax - self.meshgrid = self._meshgrid_jax self.repeat = self._repeat_jax self.stack = self._stack_jax self.transpose = self._transpose_jax self.upsample2d = self._upsample2d_jax self.pad = self._pad_jax - self.LinAlgErr = self.module.linalg.LinAlgError + self.LinAlgErr = Exception self.roll = self._roll_jax self.clamp = self._clamp_jax self.conv2d = self._conv2d_jax @@ -115,6 +112,7 @@ def setup_jax(self): self.lgamma = self._lgamma_jax self.hessian = self._hessian_jax self.long = self._long_jax + self.fill_at_indices = self._fill_at_indices_jax @property def array_type(self): @@ -174,35 +172,23 @@ def _to_numpy_torch(self, array): def _to_numpy_jax(self, array): return np.array(array.block_until_ready()) - def _arange_torch(self, *args, dtype=None, device=None): - return self.module.arange(*args, dtype=dtype, device=device) - - def _arange_jax(self, *args, dtype=None, device=None): - return self.jax.arange(*args, dtype=dtype, device=device) - - def _meshgrid_torch(self, *arrays, indexing="ij"): - return self.module.meshgrid(*arrays, indexing=indexing) - - def _meshgrid_jax(self, *arrays, indexing="ij"): - return self.jax.meshgrid(*arrays, indexing=indexing) - def _repeat_torch(self, a, repeats, axis=None): return self.module.repeat_interleave(a, repeats, dim=axis) def _repeat_jax(self, a, repeats, axis=None): - return self.jax.repeat(a, repeats, axis=axis) + return self.module.repeat(a, repeats, axis=axis) def _stack_torch(self, arrays, dim=0): return self.module.stack(arrays, dim=dim) def _stack_jax(self, arrays, dim=0): - return self.jax.stack(arrays, axis=dim) + return self.module.stack(arrays, axis=dim) def _transpose_torch(self, array, *args): return self.module.transpose(array, *args) def _transpose_jax(self, array, *args): - return self.jax.transpose(array, args) + return self.module.transpose(array, args) def _sigmoid_torch(self, array): return self.module.sigmoid(array) @@ -280,11 +266,11 @@ def _sum_torch(self, array, dim=None): def _sum_jax(self, array, dim=None): return self.jax.numpy.sum(array, axis=dim) - def _topk_torch(self, array, k, dim=None): - return self.module.topk(array, k=k, dim=dim) + def _topk_torch(self, array, k): + return self.module.topk(array, k=k) - def _topk_jax(self, array, k, dim=None): - return self.jax.lax.top_k(array, k=k, axis=dim) + def _topk_jax(self, array, k): + return self.jax.lax.top_k(array, k=k) def _bessel_j1_torch(self, array): return self.module.special.bessel_j1(array) @@ -310,11 +296,22 @@ def _hessian_torch(self, func): def _hessian_jax(self, func): return self.jax.hessian(func) + def _fill_at_indices_torch(self, array, indices, values): + array[indices] = values + return array + + def _fill_at_indices_jax(self, array, indices, values): + array = array.at[indices].set(values) + return array + + def arange(self, *args, dtype=None, device=None): + return self.module.arange(*args, dtype=dtype, device=device) + def linspace(self, start, end, steps, dtype=None, device=None): return self.module.linspace(start, end, steps, dtype=dtype, device=device) - def arange(self, start, end=None, step=1, dtype=None, device=None): - return self.module.arange(start, end, step=step, dtype=dtype, device=device) + def meshgrid(self, *arrays, indexing="ij"): + return self.module.meshgrid(*arrays, indexing=indexing) def searchsorted(self, array, value): return self.module.searchsorted(array, value) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 8aab67e8..0a6d67f7 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -140,7 +140,7 @@ def data(self, value: Optional[ArrayLike]): else: # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates self._data = backend.transpose( - backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE), 0, 1 + backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE), 1, 0 ) @property @@ -183,7 +183,7 @@ def shape(self): @forward def pixel_area(self, CD): """The area inside a pixel in arcsec^2""" - return backend.linalg.det(CD).abs() + return backend.abs(backend.linalg.det(CD)) @property @forward @@ -196,7 +196,7 @@ def pixelscale(self): and instead sets a size scale within an image. """ - return self.pixel_area.sqrt() + return backend.sqrt(self.pixel_area) @forward def pixel_to_plane( @@ -429,7 +429,7 @@ def fits_info(self) -> dict: def fits_images(self): return [ fits.PrimaryHDU( - backend.to_numpy(backend.transpose(self.data, 0, 1)), + backend.to_numpy(backend.transpose(self.data, 1, 0)), header=fits.Header(self.fits_info()), ) ] diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 51aabccd..93e61674 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -177,7 +177,7 @@ def weight(self, weight): if isinstance(weight, str) and weight == "auto": weight = 1 / auto_variance(self.data, self.mask).T self._weight = backend.transpose( - backend.as_array(weight, dtype=config.DTYPE, device=config.DEVICE), 0, 1 + backend.as_array(weight, dtype=config.DTYPE, device=config.DEVICE), 1, 0 ) if self._weight.shape != self.data.shape: self._weight = None @@ -223,7 +223,7 @@ def mask(self, mask): self._mask = None return self._mask = backend.transpose( - backend.as_array(mask, dtype=backend.bool, device=config.DEVICE), 0, 1 + backend.as_array(mask, dtype=backend.bool, device=config.DEVICE), 1, 0 ) if self._mask.shape != self.data.shape: self._mask = None @@ -283,14 +283,12 @@ def fits_images(self): images = super().fits_images() if self.has_weight: images.append( - fits.ImageHDU( - backend.transpose(self.weight, 0, 1).detach().cpu().numpy(), name="WEIGHT" - ) + fits.ImageHDU(backend.to_numpy(backend.transpose(self.weight, 1, 0)), name="WEIGHT") ) if self.has_mask: images.append( fits.ImageHDU( - backend.transpose(self.mask, 0, 1).detach().cpu().numpy().astype(int), + backend.to_numpy(backend.transpose(self.mask, 1, 0)).astype(int), name="MASK", ) ) diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 1fd4a652..5258c470 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -150,7 +150,7 @@ def fits_images(self): if isinstance(self.psf, PSFImage): images.append( fits.ImageHDU( - backend.transpose(self.psf.data, 0, 1).detach().cpu().numpy(), + backend.to_numpy(backend.transpose(self.psf.data, 1, 0)), name="PSF", header=fits.Header(self.psf.fits_info()), ) diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index e702984d..55a376d4 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -61,7 +61,7 @@ def basis(self, value: Union[str, ArrayLike]): else: # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates self._basis = backend.transpose( - backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE), 1, 2 + backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE), 2, 1 ) @torch.no_grad() diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index 0b622c2c..fcfb2912 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -37,7 +37,7 @@ def pixel_quad_integrator(Z: ArrayLike, w: ArrayLike = None, order: int = 3) -> if w is None: _, _, w = quad_table(order, Z.dtype, Z.device) Z = Z * w - return Z.sum(dim=(-1)) + return backend.sum(Z, dim=-1) def upsample(i: ArrayLike, j: ArrayLike, order: int, scale: float) -> Tuple[ArrayLike, ArrayLike]: @@ -46,8 +46,8 @@ def upsample(i: ArrayLike, j: ArrayLike, order: int, scale: float) -> Tuple[Arra ) di, dj = backend.meshgrid(dp, dp, indexing="xy") - si = backend.repeat(i.unsqueeze(-1), order**2, -1) + scale * di.flatten() - sj = backend.repeat(j.unsqueeze(-1), order**2, -1) + scale * dj.flatten() + si = backend.repeat(i[..., None], order**2, -1) + scale * di.flatten() + sj = backend.repeat(j[..., None], order**2, -1) + scale * dj.flatten() return si, sj @@ -55,8 +55,8 @@ def single_quad_integrate( i: ArrayLike, j: ArrayLike, brightness_ij, scale: float, quad_order: int = 3 ) -> Tuple[ArrayLike, ArrayLike]: di, dj, w = quad_table(quad_order, i.dtype, i.device) - qi = backend.repeat(i.unsqueeze(-1), quad_order**2, -1) + scale * di.flatten() - qj = backend.repeat(j.unsqueeze(-1), quad_order**2, -1) + scale * dj.flatten() + qi = backend.repeat(i[..., None], quad_order**2, -1) + scale * di.flatten() + qj = backend.repeat(j[..., None], quad_order**2, -1) + scale * dj.flatten() z = brightness_ij(qi, qj) z0 = backend.mean(z, dim=-1) z = backend.sum(z * w.flatten(), dim=-1) @@ -80,25 +80,29 @@ def recursive_quad_integrate( return z N = max(1, int(np.prod(z.shape) * curve_frac)) - select = backend.topk(backend.abs(z - z0).flatten(), N, dim=-1).indices + select = backend.topk(backend.abs(z - z0).flatten(), N)[1] integral_flat = z.flatten() si, sj = upsample(i.flatten()[select], j.flatten()[select], quad_order, scale) - integral_flat[select] = backend.mean( - recursive_quad_integrate( - si, - sj, - brightness_ij, - curve_frac=curve_frac, - scale=scale / gridding, - quad_order=quad_order, - gridding=gridding, - _current_depth=_current_depth + 1, - max_depth=max_depth, + integral_flat = backend.fill_at_indices( + integral_flat, + select, + backend.mean( + recursive_quad_integrate( + si, + sj, + brightness_ij, + curve_frac=curve_frac, + scale=scale / gridding, + quad_order=quad_order, + gridding=gridding, + _current_depth=_current_depth + 1, + max_depth=max_depth, + ), + dim=-1, ), - dim=-1, ) return integral_flat.reshape(z.shape) @@ -123,23 +127,27 @@ def recursive_bright_integrate( N = max(1, int(np.prod(z.shape) * bright_frac)) z_flat = z.flatten() - select = backend.topk(z_flat, N, dim=-1).indices + select = backend.topk(z_flat, N)[1] si, sj = upsample(i.flatten()[select], j.flatten()[select], quad_order, scale) - z_flat[select] = backend.mean( - recursive_bright_integrate( - si, - sj, - brightness_ij, - bright_frac, - scale=scale / gridding, - quad_order=quad_order, - gridding=gridding, - _current_depth=_current_depth + 1, - max_depth=max_depth, + z_flat = backend.fill_at_indices( + z_flat, + select, + backend.mean( + recursive_bright_integrate( + si, + sj, + brightness_ij, + bright_frac, + scale=scale / gridding, + quad_order=quad_order, + gridding=gridding, + _current_depth=_current_depth + 1, + max_depth=max_depth, + ), + dim=-1, ), - dim=-1, ) return z_flat.reshape(z.shape) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 0cbb8863..214fbf05 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -61,17 +61,21 @@ class SampleMixin: def _bright_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: i, j = image.pixel_center_meshgrid() N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) - sample_flat = sample.flatten(-2) - select = backend.topk(sample_flat, N, dim=-1).indices - sample_flat[select] = func.recursive_bright_integrate( - i.flatten(-2)[select], - j.flatten(-2)[select], - lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), - scale=image.base_scale, - bright_frac=self.integrate_fraction, - quad_order=self.integrate_quad_order, - gridding=self.integrate_gridding, - max_depth=self.integrate_max_depth, + sample_flat = sample.flatten() + select = backend.topk(sample_flat, N)[1] + sample_flat = backend.fill_at_indices( + sample_flat, + select, + func.recursive_bright_integrate( + i.flatten()[select], + j.flatten()[select], + lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), + scale=image.base_scale, + bright_frac=self.integrate_fraction, + quad_order=self.integrate_quad_order, + gridding=self.integrate_gridding, + max_depth=self.integrate_max_depth, + ), ) return sample_flat.reshape(sample.shape) @@ -95,18 +99,22 @@ def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: .squeeze(0) ) N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) - select = backend.topk(curvature.flatten(-2), N, dim=-1).indices - - sample_flat = sample.flatten(-2) - sample_flat[select] = func.recursive_quad_integrate( - i.flatten(-2)[select], - j.flatten(-2)[select], - lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), - scale=image.base_scale, - curve_frac=self.integrate_fraction, - quad_order=self.integrate_quad_order, - gridding=self.integrate_gridding, - max_depth=self.integrate_max_depth, + select = backend.topk(curvature.flatten(), N)[1] + + sample_flat = sample.flatten() + sample_flat = backend.fill_at_indices( + sample_flat, + select, + func.recursive_quad_integrate( + i.flatten()[select], + j.flatten()[select], + lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), + scale=image.base_scale, + curve_frac=self.integrate_fraction, + quad_order=self.integrate_quad_order, + gridding=self.integrate_gridding, + max_depth=self.integrate_max_depth, + ), ) return sample_flat.reshape(sample.shape) diff --git a/tests/test_cmos_image.py b/tests/test_cmos_image.py index bc2876cd..4cfb5123 100644 --- a/tests/test_cmos_image.py +++ b/tests/test_cmos_image.py @@ -11,13 +11,13 @@ @pytest.fixture() def cmos_target(): - arr = torch.zeros((10, 15)) + arr = ap.backend.zeros((10, 15)) return ap.CMOSTargetImage( data=arr, pixelscale=0.7, zeropoint=1.0, - variance=torch.ones_like(arr), - mask=torch.zeros_like(arr), + variance=ap.backend.ones_like(arr), + mask=ap.backend.zeros_like(arr), subpixel_loc=(-0.25, -0.25), subpixel_scale=0.5, ) @@ -32,6 +32,7 @@ def test_cmos_image_creation(cmos_target): assert cmos_copy.subpixel_loc == (-0.25, -0.25), "image should track subpixel location" assert cmos_copy.subpixel_scale == 0.5, "image should track subpixel scale" + print(cmos_target.data.shape) i, j = cmos_target.pixel_center_meshgrid() assert i.shape == (15, 10), "meshgrid should have correct shape" assert j.shape == (15, 10), "meshgrid should have correct shape" @@ -74,13 +75,13 @@ def test_cmos_image_save_load(cmos_target): loaded_image = ap.CMOSTargetImage(filename="cmos_image.fits") # Check if the loaded image matches the original - assert torch.allclose( + assert ap.backend.allclose( cmos_target.data, loaded_image.data ), "Loaded image data should match original" - assert torch.allclose( + assert ap.backend.allclose( cmos_target.pixelscale, loaded_image.pixelscale ), "Loaded image pixelscale should match original" - assert torch.allclose( + assert ap.backend.allclose( cmos_target.zeropoint, loaded_image.zeropoint ), "Loaded image zeropoint should match original" assert np.allclose( From 44ab61981b7fbe5c22b5861d07fc6bd94b113f44 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 14 Aug 2025 15:13:55 -0400 Subject: [PATCH 122/191] updating unit tests to backend --- astrophot/backend_obj.py | 42 ++++++++++- astrophot/image/image_object.py | 12 ++- astrophot/image/jacobian_image.py | 8 +- astrophot/models/base.py | 4 +- astrophot/models/func/integration.py | 20 +++-- astrophot/models/mixins/sample.py | 7 +- tests/test_fit.py | 30 +++++--- tests/test_group_models.py | 22 +++--- tests/test_image.py | 107 ++++++++++++++------------- tests/test_image_list.py | 81 ++++++++++---------- tests/test_model.py | 44 +++++------ tests/test_notebooks.py | 4 + tests/test_param.py | 17 ++--- tests/test_psfmodel.py | 9 +-- tests/test_sip_image.py | 19 +++-- tests/test_utils.py | 41 +++++----- tests/test_window_list.py | 2 - tests/utils.py | 4 +- 18 files changed, 267 insertions(+), 206 deletions(-) diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index cf6e999e..d9dbbbff 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -76,8 +76,11 @@ def setup_torch(self): self.bessel_k1 = self._bessel_k1_torch self.lgamma = self._lgamma_torch self.hessian = self._hessian_torch + self.jacobian = self._jacobian_torch + self.grad = self._grad_torch self.long = self._long_torch self.fill_at_indices = self._fill_at_indices_torch + self.add_at_indices = self._add_at_indices_torch def setup_jax(self): self.jax = importlib.import_module("jax") @@ -111,8 +114,11 @@ def setup_jax(self): self.bessel_k1 = self._bessel_k1_jax self.lgamma = self._lgamma_jax self.hessian = self._hessian_jax + self.jacobian = self._jacobian_jax + self.grad = self._grad_jax self.long = self._long_jax self.fill_at_indices = self._fill_at_indices_jax + self.add_at_indices = self._add_at_indices_jax @property def array_type(self): @@ -249,9 +255,9 @@ def _conv2d_torch(self, input, kernel, padding, stride=1): stride=stride, ) - def _conv2d_jax(self, input, kernel, padding, stride=(1, 1)): + def _conv2d_jax(self, input, kernel, padding, stride=1): return self.jax.lax.conv_general_dilated( - input, kernel, window_strides=stride, padding=padding + input, kernel, window_strides=(stride, stride), padding=padding ) def _mean_torch(self, array, dim=None): @@ -290,6 +296,22 @@ def _lgamma_torch(self, array): def _lgamma_jax(self, array): return self.jax.lax.lgamma(array) + def _grad_torch(self, func): + return self.module.func.grad(func) + + def _grad_jax(self, func): + return self.jax.grad(func) + + def _jacobian_torch(self, func, x, strategy="forward-mode", vectorize=True, create_graph=False): + return self.module.autograd.functional.jacobian( + func, x, strategy=strategy, vectorize=vectorize, create_graph=create_graph + ) + + def _jacobian_jax(self, func, x, strategy="forward-mode", vectorize=True, create_graph=False): + if "forward" in strategy: + return self.jax.jacfwd(func)(x) + return self.jax.jacrev(func)(x) + def _hessian_torch(self, func): return self.module.func.hessian(func) @@ -304,6 +326,14 @@ def _fill_at_indices_jax(self, array, indices, values): array = array.at[indices].set(values) return array + def _add_at_indices_torch(self, array, indices, values): + array[indices] += values + return array + + def _add_at_indices_jax(self, array, indices, values): + array = array.at[indices].add(values) + return array + def arange(self, *args, dtype=None, device=None): return self.module.arange(*args, dtype=dtype, device=device) @@ -429,5 +459,13 @@ def bool(self): def int32(self): return self.module.int32 + @property + def float32(self): + return self.module.float32 + + @property + def float64(self): + return self.module.float64 + backend = Backend() diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 0a6d67f7..96997ae0 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -559,14 +559,22 @@ def __add__(self, other): def __iadd__(self, other): if isinstance(other, Image): - self._data[self.get_indices(other.window)] += other.data[other.get_indices(self.window)] + backend.add_at_indices( + self._data, + self.get_indices(other.window), + other.data[other.get_indices(self.window)], + ) else: self._data = self.data + other return self def __isub__(self, other): if isinstance(other, Image): - self._data[self.get_indices(other.window)] -= other.data[other.get_indices(self.window)] + backend.add_at_indices( + self._data, + self.get_indices(other.window), + -other.data[other.get_indices(self.window)], + ) else: self._data = self.data - other return self diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 8e494429..b733fb9a 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -49,9 +49,11 @@ def __iadd__(self, other: "JacobianImage"): self_indices = self.get_indices(other.window) other_indices = other.get_indices(self.window) for self_i, other_i in zip(*self.match_parameters(other)): - self._data[self_indices[0], self_indices[1], self_i] += other.data[ - other_indices[0], other_indices[1], other_i - ] + backend.add_at_indices( + self._data, + self_indices + (self_i,), + other.data[other_indices[0], other_indices[1], other_i], + ) return self def plane_to_world(self, x, y): diff --git a/astrophot/models/base.py b/astrophot/models/base.py index daee9d89..ebd79ab3 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -163,12 +163,12 @@ def poisson_log_likelihood( if isinstance(data, tuple): nll = sum( - backend.sum((mo - da * (mo + 1e-10).log() + backend.lgamma(da + 1))[~ma]) + backend.sum((mo - da * backend.log(mo + 1e-10) + backend.lgamma(da + 1))[~ma]) for mo, da, ma in zip(model, data, mask) ) else: nll = backend.sum( - (model - data * (model + 1e-10).log() + backend.lgamma(data + 1))[~mask] + (model - data * backend.log(model + 1e-10) + backend.lgamma(data + 1))[~mask] ) return -nll diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index fcfb2912..c06343d2 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -1,9 +1,9 @@ from typing import Tuple -import torch import numpy as np from ...utils.integration import quad_table from ...backend_obj import backend, ArrayLike +from ... import config def pixel_center_integrator(Z: ArrayLike) -> ArrayLike: @@ -11,17 +11,19 @@ def pixel_center_integrator(Z: ArrayLike) -> ArrayLike: def pixel_corner_integrator(Z: ArrayLike) -> ArrayLike: - kernel = backend.ones((1, 1, 2, 2), dtype=Z.dtype, device=Z.device) / 4.0 - Z = backend.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid") + kernel = backend.ones((1, 1, 2, 2), dtype=config.DTYPE, device=config.DEVICE) / 4.0 + Z = backend.conv2d(Z.reshape(1, 1, *Z.shape), kernel, padding="valid") return Z.squeeze(0).squeeze(0) def pixel_simpsons_integrator(Z: ArrayLike) -> ArrayLike: kernel = ( - backend.as_array([[[[1, 4, 1], [4, 16, 4], [1, 4, 1]]]], dtype=Z.dtype, device=Z.device) + backend.as_array( + [[[[1, 4, 1], [4, 16, 4], [1, 4, 1]]]], dtype=config.DTYPE, device=config.DEVICE + ) / 36.0 ) - Z = backend.conv2d(Z.view(1, 1, *Z.shape), kernel, padding="valid", stride=2) + Z = backend.conv2d(Z.reshape(1, 1, *Z.shape), kernel, padding="valid", stride=2) return Z.squeeze(0).squeeze(0) @@ -35,14 +37,16 @@ def pixel_quad_integrator(Z: ArrayLike, w: ArrayLike = None, order: int = 3) -> - `order`: The order of the quadrature. """ if w is None: - _, _, w = quad_table(order, Z.dtype, Z.device) + _, _, w = quad_table(order, config.DTYPE, config.DEVICE) Z = Z * w return backend.sum(Z, dim=-1) def upsample(i: ArrayLike, j: ArrayLike, order: int, scale: float) -> Tuple[ArrayLike, ArrayLike]: dp = ( - backend.linspace(-1, 1, order, dtype=i.dtype, device=i.device) * (order - 1) / (2.0 * order) + backend.linspace(-1, 1, order, dtype=config.DTYPE, device=config.DEVICE) + * (order - 1) + / (2.0 * order) ) di, dj = backend.meshgrid(dp, dp, indexing="xy") @@ -54,7 +58,7 @@ def upsample(i: ArrayLike, j: ArrayLike, order: int, scale: float) -> Tuple[Arra def single_quad_integrate( i: ArrayLike, j: ArrayLike, brightness_ij, scale: float, quad_order: int = 3 ) -> Tuple[ArrayLike, ArrayLike]: - di, dj, w = quad_table(quad_order, i.dtype, i.device) + di, dj, w = quad_table(quad_order, config.DTYPE, config.DEVICE) qi = backend.repeat(i[..., None], quad_order**2, -1) + scale * di.flatten() qj = backend.repeat(j[..., None], quad_order**2, -1) + scale * dj.flatten() z = brightness_ij(qi, qj) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 214fbf05..36ab1721 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -2,8 +2,6 @@ import numpy as np from torch.autograd.functional import jacobian -import torch -from torch import Tensor from ...param import forward from ...backend_obj import backend, ArrayLike @@ -166,14 +164,11 @@ def _jacobian( # window=window, params=torch.cat((params_pre, x, params_post), dim=-1) # ).data # )(params) - return jacobian( + return backend.jacobian( lambda x: self.sample( window=window, params=backend.concatenate((params_pre, x, params_post), dim=-1) ).data, params, - strategy="forward-mode", - vectorize=True, - create_graph=False, ) def jacobian( diff --git a/tests/test_fit.py b/tests/test_fit.py index 1c9a91f6..ad1e34fe 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -35,7 +35,7 @@ def test_chunk_jacobian(center, PA, q, n, Re): model.jacobian_maxparams = 3 Jchunked = model.jacobian() - assert torch.allclose( + assert ap.backend.allclose( Jtrue.data, Jchunked.data ), "Param chunked Jacobian should match full Jacobian" @@ -44,7 +44,7 @@ def test_chunk_jacobian(center, PA, q, n, Re): Jchunked = model.jacobian() - assert torch.allclose( + assert ap.backend.allclose( Jtrue.data, Jchunked.data ), "Pixel chunked Jacobian should match full Jacobian" @@ -132,18 +132,26 @@ def test_fitters_iter(): # test hessian Hgauss = model.hessian(likelihood="gaussian") - assert torch.all(torch.isfinite(Hgauss)), "Hessian should be finite for Gaussian likelihood" + assert ap.backend.all( + ap.backend.isfinite(Hgauss) + ), "Hessian should be finite for Gaussian likelihood" Hpoisson = model.hessian(likelihood="poisson") - assert torch.all(torch.isfinite(Hpoisson)), "Hessian should be finite for Poisson likelihood" + assert ap.backend.all( + ap.backend.isfinite(Hpoisson) + ), "Hessian should be finite for Poisson likelihood" def test_hessian(sersic_model): model = sersic_model model.initialize() Hgauss = model.hessian(likelihood="gaussian") - assert torch.all(torch.isfinite(Hgauss)), "Hessian should be finite for Gaussian likelihood" + assert ap.backend.all( + ap.backend.isfinite(Hgauss) + ), "Hessian should be finite for Gaussian likelihood" Hpoisson = model.hessian(likelihood="poisson") - assert torch.all(torch.isfinite(Hpoisson)), "Hessian should be finite for Poisson likelihood" + assert ap.backend.all( + ap.backend.isfinite(Hpoisson) + ), "Hessian should be finite for Poisson likelihood" assert Hgauss is not None, "Hessian should be computed for Gaussian likelihood" assert Hpoisson is not None, "Hessian should be computed for Poisson likelihood" with pytest.raises(ValueError): @@ -157,16 +165,18 @@ def test_gradient(sersic_model): model.initialize() x = model.build_params_array() grad = model.gradient() - assert torch.all(torch.isfinite(grad)), "Gradient should be finite" + assert ap.backend.all(ap.backend.isfinite(grad)), "Gradient should be finite" assert grad.shape == x.shape, "Gradient shape should match parameters shape" x.requires_grad = True ll = model.gaussian_log_likelihood(x) ll.backward() autograd = x.grad - assert torch.allclose(grad, autograd, rtol=1e-4), "Gradient should match autograd gradient" + assert ap.backend.allclose(grad, autograd, rtol=1e-4), "Gradient should match autograd gradient" - funcgrad = torch.func.grad(model.gaussian_log_likelihood)(x) - assert torch.allclose(grad, funcgrad, rtol=1e-4), "Gradient should match functional gradient" + funcgrad = ap.backend.grad(model.gaussian_log_likelihood)(x) + assert ap.backend.allclose( + grad, funcgrad, rtol=1e-4 + ), "Gradient should match functional gradient" # class TestHMC(unittest.TestCase): diff --git a/tests/test_group_models.py b/tests/test_group_models.py index a6e7c54d..9285c0ac 100644 --- a/tests/test_group_models.py +++ b/tests/test_group_models.py @@ -1,7 +1,5 @@ import astrophot as ap -import torch import numpy as np -import torch import astrophot as ap from utils import make_basic_sersic, make_basic_gaussian_psf @@ -43,11 +41,13 @@ def test_jointmodel_creation(): ) smod.initialize() - assert torch.all(torch.isfinite(smod().flatten("data"))).item(), "model_image should be real" + assert ap.backend.all( + ap.backend.isfinite(smod().flatten("data")) + ).item(), "model_image should be real" fm = smod.fit_mask() for fmi in fm: - assert torch.sum(fmi).item() == 0, "this fit_mask should not mask any pixels" + assert ap.backend.sum(fmi).item() == 0, "this fit_mask should not mask any pixels" def test_psfgroupmodel_creation(): @@ -74,7 +74,9 @@ def test_psfgroupmodel_creation(): smod.initialize() - assert torch.all(smod().data >= 0), "PSF group sample should be greater than or equal to zero" + assert ap.backend.all( + smod().data >= 0 + ), "PSF group sample should be greater than or equal to zero" def test_joint_multi_band_multi_object(): @@ -109,17 +111,19 @@ def test_joint_multi_band_multi_object(): mask = model.fit_mask() assert len(mask) == 4, "There should be 4 fit masks for the 4 targets" for m in mask: - assert torch.all(torch.isfinite(m)), "this fit_mask should be finite" + assert ap.backend.all(ap.backend.isfinite(m)), "this fit_mask should be finite" sample = model.sample(window=ap.WindowList([target1.window, target2.window, target3.window])) assert isinstance(sample, ap.ImageList), "Sample should be an ImageList" for image in sample: - assert torch.all(torch.isfinite(image.data)), "Sample image data should be finite" - assert torch.all(image.data >= 0), "Sample image data should be non-negative" + assert ap.backend.all(ap.backend.isfinite(image.data)), "Sample image data should be finite" + assert ap.backend.all(image.data >= 0), "Sample image data should be non-negative" jacobian = model.jacobian() assert isinstance(jacobian, ap.ImageList), "Jacobian should be an ImageList" for image in jacobian: - assert torch.all(torch.isfinite(image.data)), "Jacobian image data should be finite" + assert ap.backend.all( + ap.backend.isfinite(image.data) + ), "Jacobian image data should be finite" window = model.window assert isinstance(window, ap.WindowList), "Window should be a WindowList" diff --git a/tests/test_image.py b/tests/test_image.py index 82b2d41f..92065d96 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,5 +1,4 @@ import astrophot as ap -import torch import numpy as np from utils import make_basic_sersic, get_astropy_wcs @@ -12,7 +11,7 @@ @pytest.fixture() def base_image(): - arr = torch.zeros((10, 15)) + arr = np.zeros((10, 15)) return ap.Image( data=arr, pixelscale=1.0, @@ -27,7 +26,7 @@ def test_image_creation(base_image): assert base_image.crpix[0] == 0, "image should track crpix" assert base_image.crpix[1] == 0, "image should track crpix" - base_image.to(dtype=torch.float64) + base_image.to(dtype=ap.backend.float64) slicer = ap.Window((7, 13, 4, 7), base_image) sliced_image = base_image[slicer] assert sliced_image.crpix[0] == -7, "crpix of subimage should give relative position" @@ -70,7 +69,7 @@ def test_image_arithmetic(base_image): assert base_image.data[5][5] == 0, "slice should not update base image" second_image = ap.Image( - data=torch.ones((5, 5)), + data=np.ones((5, 5)), pixelscale=1.0, zeropoint=1.0, crpix=(-1, 1), @@ -85,14 +84,14 @@ def test_image_arithmetic(base_image): # Test isubtract base_image -= second_image - assert torch.all( - torch.isclose(base_image.data, torch.zeros_like(base_image.data)) + assert ap.backend.allclose( + base_image.data, ap.backend.zeros_like(base_image.data) ), "image subtraction should only update its region" def test_image_manipulation(): new_image = ap.Image( - data=torch.ones((16, 32)), + data=np.ones((16, 32)), pixelscale=1.0, zeropoint=1.0, ) @@ -119,7 +118,7 @@ def test_image_manipulation(): def test_image_save_load(): new_image = ap.Image( - data=torch.ones((16, 32)), + data=np.ones((16, 32)), pixelscale=0.76, zeropoint=21.4, crtan=(8.0, 1.2), @@ -131,22 +130,22 @@ def test_image_save_load(): loaded_image = ap.Image(filename="Test_AstroPhot.fits") - assert torch.all( - new_image.data == loaded_image.data + assert ap.backend.allclose( + new_image.data, loaded_image.data ), "Loaded image should have same pixel values" - assert torch.all( - new_image.crtan.value == loaded_image.crtan.value + assert ap.backend.allclose( + new_image.crtan.value, loaded_image.crtan.value ), "Loaded image should have same tangent plane origin" assert np.all( new_image.crpix == loaded_image.crpix ), "Loaded image should have same reference pixel" - assert torch.all( - new_image.crval.value == loaded_image.crval.value + assert ap.backend.allclose( + new_image.crval.value, loaded_image.crval.value ), "Loaded image should have same reference world coordinates" - assert torch.allclose( + assert ap.backend.allclose( new_image.pixelscale, loaded_image.pixelscale ), "Loaded image should have same pixel scale" - assert torch.allclose( + assert ap.backend.allclose( new_image.CD.value, loaded_image.CD.value ), "Loaded image should have same pixel scale" assert new_image.zeropoint == loaded_image.zeropoint, "Loaded image should have same zeropoint" @@ -155,7 +154,7 @@ def test_image_save_load(): def test_image_wcs_roundtrip(): # Minimal input I = ap.Image( - data=torch.zeros((21, 21)), + data=np.zeros((21, 21)), zeropoint=22.5, crpix=(10, 10), crtan=(1.0, -10.0), @@ -166,25 +165,25 @@ def test_image_wcs_roundtrip(): ), ) - assert torch.allclose( - torch.stack(I.world_to_plane(*I.plane_to_world(*I.center))), + assert ap.backend.allclose( + ap.backend.stack(I.world_to_plane(*I.plane_to_world(*I.center))), I.center, ), "WCS world/plane roundtrip should return input value" - assert torch.allclose( - torch.stack(I.pixel_to_plane(*I.plane_to_pixel(*I.center))), + assert ap.backend.allclose( + ap.backend.stack(I.pixel_to_plane(*I.plane_to_pixel(*I.center))), I.center, ), "WCS pixel/plane roundtrip should return input value" - assert torch.allclose( - torch.stack(I.world_to_pixel(*I.pixel_to_world(*torch.zeros_like(I.center)))), - torch.zeros_like(I.center), + assert ap.backend.allclose( + ap.backend.stack(I.world_to_pixel(*I.pixel_to_world(*ap.backend.zeros_like(I.center)))), + ap.backend.zeros_like(I.center), atol=1e-6, ), "WCS world/pixel roundtrip should return input value" def test_target_image_variance(): new_image = ap.TargetImage( - data=torch.ones((16, 32)), - variance=torch.ones((16, 32)), + data=np.ones((16, 32)), + variance=np.ones((16, 32)), pixelscale=1.0, zeropoint=1.0, ) @@ -200,8 +199,8 @@ def test_target_image_variance(): def test_target_image_mask(): new_image = ap.TargetImage( - data=torch.ones((16, 32)), - mask=torch.arange(16 * 32).reshape((16, 32)) % 4 == 0, + data=np.ones((16, 32)), + mask=np.arange(16 * 32).reshape((16, 32)) % 4 == 0, pixelscale=1.0, zeropoint=1.0, ) @@ -214,9 +213,9 @@ def test_target_image_mask(): new_image.mask = None assert not new_image.has_mask, "target image update to no mask" - data = torch.ones((16, 32)) - data[1, 1] = torch.nan - data[5, 5] = torch.nan + data = np.ones((16, 32)) + data[1, 1] = np.nan + data[5, 5] = np.nan new_image = ap.TargetImage( data=data, @@ -230,8 +229,8 @@ def test_target_image_mask(): def test_target_image_psf(): new_image = ap.TargetImage( - data=torch.ones((15, 33)), - psf=torch.ones((9, 9)), + data=np.ones((15, 33)), + psf=np.ones((9, 9)), pixelscale=1.0, zeropoint=1.0, ) @@ -247,8 +246,8 @@ def test_target_image_psf(): def test_target_image_reduce(): new_image = ap.TargetImage( - data=torch.ones((30, 36)), - psf=torch.ones((9, 9)), + data=np.ones((30, 36)), + psf=np.ones((9, 9)), variance="auto", pixelscale=1.0, zeropoint=1.0, @@ -260,10 +259,10 @@ def test_target_image_reduce(): def test_target_image_save_load(): new_image = ap.TargetImage( - data=torch.ones((16, 32)), - variance=torch.ones((16, 32)), - mask=torch.zeros((16, 32)), - psf=torch.ones((9, 9)), + data=np.ones((16, 32)), + variance=np.ones((16, 32)), + mask=np.zeros((16, 32)), + psf=np.ones((9, 9)), CD=[[1.0, 0.0], [0.0, 1.5]], zeropoint=1.0, ) @@ -272,17 +271,19 @@ def test_target_image_save_load(): loaded_image = ap.TargetImage(filename="Test_target_AstroPhot.fits") - assert torch.all( - new_image.data == loaded_image.data + assert ap.backend.allclose( + new_image.data, loaded_image.data ), "Loaded image should have same pixel values" - assert torch.all(new_image.mask == loaded_image.mask), "Loaded image should have same mask" - assert torch.all( - new_image.variance == loaded_image.variance + assert ap.backend.allclose( + new_image.mask, loaded_image.mask + ), "Loaded image should have same mask" + assert ap.backend.allclose( + new_image.variance, loaded_image.variance ), "Loaded image should have same variance" - assert torch.all( - new_image.psf.data == loaded_image.psf.data + assert ap.backend.allclose( + new_image.psf.data, loaded_image.psf.data ), "Loaded image should have same psf" - assert torch.allclose( + assert ap.backend.allclose( new_image.CD.value, loaded_image.CD.value ), "Loaded image should have same pixel scale" @@ -294,7 +295,7 @@ def test_target_image_auto_var(): def test_target_image_errors(): new_image = ap.TargetImage( - data=torch.ones((16, 32)), + data=np.ones((16, 32)), pixelscale=1.0, zeropoint=1.0, ) @@ -310,24 +311,24 @@ def test_target_image_errors(): def test_psf_image_copying(): psf_image = ap.PSFImage( - data=torch.ones((15, 15)), + data=np.ones((15, 15)), ) assert psf_image.psf_pad == 7, "psf image should have correct psf_pad" psf_image.normalize() assert np.allclose( - psf_image.data.detach().cpu().numpy(), 1 / 15**2 + ap.backend.to_numpy(psf_image.data), 1 / 15**2 ), "psf image should normalize to sum to 1" def test_jacobian_add(): new_image = ap.JacobianImage( parameters=["a", "b", "c"], - data=torch.ones((16, 32, 3)), + data=np.ones((16, 32, 3)), ) other_image = ap.JacobianImage( parameters=["b", "d"], - data=5 * torch.ones((4, 4, 2)), + data=5 * np.ones((4, 4, 2)), ) new_image += other_image @@ -360,5 +361,5 @@ def test_image_with_wcs(): image.crpix, WCS.wcs.crpix[::-1] - 1 ), "Image should have correct CRPIX from WCS" assert np.allclose( - image.crval.value.detach().cpu().numpy(), WCS.wcs.crval + image.crval.npvalue, WCS.wcs.crval ), "Image should have correct CRVAL from WCS" diff --git a/tests/test_image_list.py b/tests/test_image_list.py index 0f1edb8f..eae5eb68 100644 --- a/tests/test_image_list.py +++ b/tests/test_image_list.py @@ -1,6 +1,5 @@ import astrophot as ap import numpy as np -import torch import pytest ###################################################################### @@ -9,9 +8,9 @@ def test_image_creation(): - arr1 = torch.zeros((10, 15)) + arr1 = ap.backend.zeros((10, 15)) base_image1 = ap.Image(data=arr1, pixelscale=1.0, zeropoint=1.0, name="image1") - arr2 = torch.ones((15, 10)) + arr2 = ap.backend.ones((15, 10)) base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0, name="image2") test_image = ap.ImageList((base_image1, base_image2)) @@ -28,9 +27,9 @@ def test_image_creation(): def test_copy(): - arr1 = torch.zeros((10, 15)) + 2 + arr1 = np.zeros((10, 15)) + 2 base_image1 = ap.Image(data=arr1, pixelscale=1.0, zeropoint=1.0, name="image1") - arr2 = torch.ones((15, 10)) + arr2 = np.ones((15, 10)) base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0, name="image2") test_image = ap.ImageList((base_image1, base_image2)) @@ -42,7 +41,7 @@ def test_copy(): for ti, ci in zip(test_image, copy_image): assert ti.pixelscale == ci.pixelscale, "copied image should have same pixelscale" assert ti.zeropoint == ci.zeropoint, "copied image should have same zeropoint" - assert torch.all(ti.data != ci.data), "copied image should not modify original data" + assert ap.backend.all(ti.data != ci.data), "copied image should not modify original data" blank_copy_image = test_image.blank_copy() for ti, ci in zip(test_image, blank_copy_image): @@ -51,9 +50,9 @@ def test_copy(): def test_image_arithmetic(): - arr1 = torch.zeros((10, 15)) + arr1 = np.zeros((10, 15)) base_image1 = ap.Image(data=arr1, pixelscale=1.0, zeropoint=1.0, name="image1") - arr2 = torch.ones((15, 10)) + arr2 = np.ones((15, 10)) base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0, name="image2") test_image = ap.ImageList((base_image1, base_image2)) @@ -66,38 +65,38 @@ def test_image_arithmetic(): # Test iadd test_image += second_image - assert torch.allclose( - test_image[0].data, torch.ones_like(base_image1.data) + assert ap.backend.allclose( + test_image[0].data, ap.backend.ones_like(base_image1.data) ), "image addition should update its region" - assert torch.allclose( - base_image1.data, torch.ones_like(base_image1.data) + assert ap.backend.allclose( + base_image1.data, ap.backend.ones_like(base_image1.data) ), "image addition should update its region" - assert torch.allclose( - test_image[1].data, torch.zeros_like(base_image2.data) + assert ap.backend.allclose( + test_image[1].data, ap.backend.zeros_like(base_image2.data) ), "image addition should update its region" - assert torch.allclose( - base_image2.data, torch.zeros_like(base_image2.data) + assert ap.backend.allclose( + base_image2.data, ap.backend.zeros_like(base_image2.data) ), "image addition should update its region" # Test isub test_image -= second_image - assert torch.allclose( - test_image[0].data, torch.zeros_like(base_image1.data) + assert ap.backend.allclose( + test_image[0].data, ap.backend.zeros_like(base_image1.data) ), "image addition should update its region" - assert torch.allclose( - base_image1.data, torch.zeros_like(base_image1.data) + assert ap.backend.allclose( + base_image1.data, ap.backend.zeros_like(base_image1.data) ), "image addition should update its region" - assert torch.allclose( - test_image[1].data, torch.ones_like(base_image2.data) + assert ap.backend.allclose( + test_image[1].data, ap.backend.ones_like(base_image2.data) ), "image addition should update its region" - assert torch.allclose( - base_image2.data, torch.ones_like(base_image2.data) + assert ap.backend.allclose( + base_image2.data, ap.backend.ones_like(base_image2.data) ), "image addition should update its region" new_image = test_image + second_image new_image = test_image - second_image - new_image = new_image.to(dtype=torch.float32, device="cpu") + new_image = new_image.to(dtype=ap.backend.float32, device="cpu") assert isinstance(new_image, ap.ImageList), "new image should be an ImageList" new_image += base_image1 @@ -105,9 +104,9 @@ def test_image_arithmetic(): def test_model_image_list_error(): - arr1 = torch.zeros((10, 15)) + arr1 = np.zeros((10, 15)) base_image1 = ap.ModelImage(data=arr1, pixelscale=1.0, zeropoint=1.0) - arr2 = torch.ones((15, 10)) + arr2 = np.ones((15, 10)) base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0) with pytest.raises(ap.errors.InvalidImage): @@ -115,22 +114,22 @@ def test_model_image_list_error(): def test_target_image_list_creation(): - arr1 = torch.zeros((10, 15)) + arr1 = np.zeros((10, 15)) base_image1 = ap.TargetImage( data=arr1, pixelscale=1.0, zeropoint=1.0, - variance=torch.ones_like(arr1), - mask=torch.zeros_like(arr1), + variance=np.ones_like(arr1), + mask=np.zeros_like(arr1), name="image1", ) - arr2 = torch.ones((15, 10)) + arr2 = np.ones((15, 10)) base_image2 = ap.TargetImage( data=arr2, pixelscale=0.5, zeropoint=2.0, - variance=torch.ones_like(arr2), - mask=torch.zeros_like(arr2), + variance=np.ones_like(arr2), + mask=np.zeros_like(arr2), name="image2", ) @@ -145,24 +144,24 @@ def test_target_image_list_creation(): test_image += second_image test_image -= second_image - assert torch.all( + assert ap.backend.all( test_image[0].data == save_image[0].data ), "adding then subtracting should give the same image" - assert torch.all( + assert ap.backend.all( test_image[1].data == save_image[1].data ), "adding then subtracting should give the same image" def test_targetlist_errors(): - arr1 = torch.zeros((10, 15)) + arr1 = np.zeros((10, 15)) base_image1 = ap.TargetImage( data=arr1, pixelscale=1.0, zeropoint=1.0, - variance=torch.ones_like(arr1), - mask=torch.zeros_like(arr1), + variance=np.ones_like(arr1), + mask=np.zeros_like(arr1), ) - arr2 = torch.ones((15, 10)) + arr2 = np.ones((15, 10)) base_image2 = ap.Image( data=arr2, pixelscale=0.5, @@ -173,11 +172,11 @@ def test_targetlist_errors(): def test_jacobian_image_list_error(): - arr1 = torch.zeros((10, 15, 3)) + arr1 = np.zeros((10, 15, 3)) base_image1 = ap.JacobianImage( parameters=["a", "1", "zz"], data=arr1, pixelscale=1.0, zeropoint=1.0 ) - arr2 = torch.ones((15, 10)) + arr2 = np.ones((15, 10)) base_image2 = ap.Image(data=arr2, pixelscale=0.5, zeropoint=2.0) with pytest.raises(ap.errors.InvalidImage): diff --git a/tests/test_model.py b/tests/test_model.py index 4c8b288f..5cc7f02b 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,6 +1,4 @@ -import unittest import astrophot as ap -import torch import numpy as np from utils import make_basic_sersic, make_basic_gaussian_psf import pytest @@ -28,14 +26,14 @@ def test_model_sampling_modes(): # With subpixel integration model.integrate_mode = "bright" - auto = model().data.detach().cpu().numpy() + auto = ap.backend.to_numpy(model().data) model.sampling_mode = "midpoint" - midpoint = model().data.detach().cpu().numpy() + midpoint = ap.backend.to_numpy(model().data) midpoint_bright = midpoint.copy() model.sampling_mode = "simpsons" - simpsons = model().data.detach().cpu().numpy() + simpsons = ap.backend.to_numpy(model().data) model.sampling_mode = "quad:5" - quad5 = model().data.detach().cpu().numpy() + quad5 = ap.backend.to_numpy(model().data) assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" @@ -43,13 +41,13 @@ def test_model_sampling_modes(): # Without subpixel integration model.integrate_mode = "none" - auto = model().data.detach().cpu().numpy() + auto = ap.backend.to_numpy(model().data) model.sampling_mode = "midpoint" - midpoint = model().data.detach().cpu().numpy() + midpoint = ap.backend.to_numpy(model().data) model.sampling_mode = "simpsons" - simpsons = model().data.detach().cpu().numpy() + simpsons = ap.backend.to_numpy(model().data) model.sampling_mode = "quad:5" - quad5 = model().data.detach().cpu().numpy() + quad5 = ap.backend.to_numpy(model().data) assert np.allclose( midpoint, midpoint_bright, rtol=1e-2 ), "no integrate sampling should match bright sampling" @@ -60,13 +58,13 @@ def test_model_sampling_modes(): # curvature based subpixel integration model.integrate_mode = "curvature" - auto = model().data.detach().cpu().numpy() + auto = ap.backend.to_numpy(model().data) model.sampling_mode = "midpoint" - midpoint = model().data.detach().cpu().numpy() + midpoint = ap.backend.to_numpy(model().data) model.sampling_mode = "simpsons" - simpsons = model().data.detach().cpu().numpy() + simpsons = ap.backend.to_numpy(model().data) model.sampling_mode = "quad:5" - quad5 = model().data.detach().cpu().numpy() + quad5 = ap.backend.to_numpy(model().data) assert np.allclose( midpoint, midpoint_bright, rtol=1e-2 ), "curvature integrate sampling should match bright sampling" @@ -94,7 +92,7 @@ def test_model_sampling_modes(): def test_model_errors(): # Target that is not a target image - arr = torch.zeros((10, 15)) + arr = np.zeros((10, 15)) target = ap.image.Image(data=arr, pixelscale=1.0, zeropoint=1.0) with pytest.raises(ap.errors.InvalidTarget): @@ -133,8 +131,8 @@ def test_all_model_sample(model_type): P.value is not None ), f"Model type {model_type} parameter {P.name} should not be None after initialization" img = MODEL() - assert torch.all( - torch.isfinite(img.data) + assert ap.backend.all( + ap.backend.isfinite(img.data) ), "Model should evaluate a real number for the full image" res = ap.fit.LM(MODEL, max_iter=10, verbose=1).fit() @@ -167,15 +165,17 @@ def test_all_model_sample(model_type): ) F = MODEL.total_flux() - assert torch.isfinite(F), "Model total flux should be finite after fitting" + assert ap.backend.isfinite(F), "Model total flux should be finite after fitting" assert F > 0, "Model total flux should be positive after fitting" U = MODEL.total_flux_uncertainty() - assert torch.isfinite(U), "Model total flux uncertainty should be finite after fitting" + assert ap.backend.isfinite(U), "Model total flux uncertainty should be finite after fitting" assert U >= 0, "Model total flux uncertainty should be non-negative after fitting" M = MODEL.total_magnitude() - assert torch.isfinite(M), "Model total magnitude should be finite after fitting" + assert ap.backend.isfinite(M), "Model total magnitude should be finite after fitting" U_M = MODEL.total_magnitude_uncertainty() - assert torch.isfinite(U_M), "Model total magnitude uncertainty should be finite after fitting" + assert ap.backend.isfinite( + U_M + ), "Model total magnitude uncertainty should be finite after fitting" assert U_M >= 0, "Model total magnitude uncertainty should be non-negative after fitting" allnames = set() @@ -250,6 +250,6 @@ def test_chunk_sample(center, PA, q, n, Re): sample = model.sample(window=chunk) chunk_img += sample - assert torch.allclose( + assert ap.backend.allclose( full_img.data, chunk_img.data ), "Chunked sample should match full sample within tolerance" diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index 80730a75..c24cc06e 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -10,6 +10,10 @@ reason="Graphviz not installed on Windows runner", ) +pytestbackend = pytest.mark.skipif( + os.environ.get("CASKADE_BACKEND") != "torch", reason="Requires torch backend" +) + notebooks = glob.glob( os.path.join( os.path.split(os.path.dirname(__file__))[0], "docs", "source", "tutorials", "*.ipynb" diff --git a/tests/test_param.py b/tests/test_param.py index cdef1376..0bcfa10b 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -1,6 +1,5 @@ import astrophot as ap from astrophot.param import Param -import torch from utils import make_basic_sersic @@ -9,22 +8,22 @@ def test_param(): a = Param("a", value=1.0, uncertainty=0.1, valid=(0, 2), prof=1.0) assert a.is_valid(1.5), "value should be valid" - assert isinstance(a.uncertainty, torch.Tensor), "uncertainty should be a tensor" - assert isinstance(a.prof, torch.Tensor), "prof should be a tensor" + assert isinstance(a.uncertainty, ap.backend.array_type), "uncertainty should be a tensor" + assert isinstance(a.prof, ap.backend.array_type), "prof should be a tensor" assert a.initialized, "parameter should be marked as initialized" assert a.soft_valid(a.value) == a.value, "soft valid should return the value if not near limits" assert ( - a.soft_valid(-1 * torch.ones_like(a.value)) > a.valid[0] + a.soft_valid(-1 * ap.backend.ones_like(a.value)) > a.valid[0] ), "soft valid should push values inside the limits" assert ( - a.soft_valid(3 * torch.ones_like(a.value)) < a.valid[1] + a.soft_valid(3 * ap.backend.ones_like(a.value)) < a.valid[1] ), "soft valid should push values inside the limits" b = Param("b", value=[2.0, 3.0], uncertainty=[0.1, 0.1], valid=(1, None)) assert not b.is_valid(0.5), "value should not be valid" assert b.is_valid(10.5), "value should be valid" - assert torch.all( - b.soft_valid(-1 * torch.ones_like(b.value)) > b.valid[0] + assert ap.backend.all( + b.soft_valid(-1 * ap.backend.ones_like(b.value)) > b.valid[0] ), "soft valid should push values inside the limits" assert b.prof is None @@ -43,11 +42,11 @@ def test_module(): model = ap.Model(name="test", model_type="group model", target=target, models=[model1, model2]) model.initialize() - U = torch.ones_like(model.build_params_array()) * 0.1 + U = ap.backend.ones_like(model.build_params_array()) * 0.1 model.fill_dynamic_value_uncertainties(U) paramsu = model.build_params_array_uncertainty() - assert torch.all(torch.isfinite(paramsu)), "All parameters should be finite" + assert ap.backend.all(ap.backend.isfinite(paramsu)), "All parameters should be finite" paramsn = model.build_params_array_names() assert all(isinstance(name, str) for name in paramsn), "All parameter names should be strings" diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index b4e5d58a..7a807fe4 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -1,5 +1,4 @@ import astrophot as ap -import torch import numpy as np from utils import make_basic_gaussian_psf import pytest @@ -37,16 +36,16 @@ def test_all_psfmodel_sample(model_type): ) img = MODEL() - assert torch.all( - torch.isfinite(img.data) + assert ap.backend.all( + ap.backend.isfinite(img.data) ), "Model should evaluate a real number for the full image" if model_type == "pixelated psf model": psf = ap.utils.initialize.gaussian_psf(3 * 0.8, 25, 0.8) MODEL.pixels.dynamic_value = psf / np.sum(psf) - assert torch.all( - torch.isfinite(MODEL.jacobian().data) + assert ap.backend.all( + ap.backend.isfinite(MODEL.jacobian().data) ), "Model should evaluate a real number for the jacobian" res = ap.fit.LM(MODEL, max_iter=10).fit() diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py index f01acc72..cafbb394 100644 --- a/tests/test_sip_image.py +++ b/tests/test_sip_image.py @@ -1,5 +1,4 @@ import astrophot as ap -import torch import numpy as np import pytest @@ -11,13 +10,13 @@ @pytest.fixture() def sip_target(): - arr = torch.zeros((10, 15)) + arr = np.zeros((10, 15)) return ap.SIPTargetImage( data=arr, pixelscale=1.0, zeropoint=1.0, - variance=torch.ones_like(arr), - mask=torch.zeros_like(arr), + variance=np.ones_like(arr), + mask=np.zeros_like(arr), sipA={(1, 0): 1e-4, (0, 1): 1e-4, (2, 3): -1e-5}, sipB={(1, 0): -1e-4, (0, 1): 5e-5, (2, 3): 2e-6}, # sipAP={(1, 0): -1e-4, (0, 1): -1e-4, (2, 3): 1e-5}, @@ -85,7 +84,7 @@ def test_sip_image_creation(sip_target): assert sip_model_crop.shape == (29, 15), "cropped model image should have correct shape" sip_model_crop.fluxdensity_to_flux() - assert torch.all( + assert ap.backend.all( sip_model_crop.data >= 0 ), "cropped model image data should be non-negative after flux density to flux conversion" @@ -98,8 +97,8 @@ def test_sip_image_wcs_roundtrip(sip_target): x, y = sip_target.pixel_to_plane(i, j) i2, j2 = sip_target.plane_to_pixel(x, y) - assert torch.allclose(i, i2, atol=0.05), "i coordinates should match after WCS roundtrip" - assert torch.allclose(j, j2, atol=0.05), "j coordinates should match after WCS roundtrip" + assert ap.backend.allclose(i, i2, atol=0.05), "i coordinates should match after WCS roundtrip" + assert ap.backend.allclose(j, j2, atol=0.05), "j coordinates should match after WCS roundtrip" def test_sip_image_save_load(sip_target): @@ -113,13 +112,13 @@ def test_sip_image_save_load(sip_target): loaded_image = ap.SIPTargetImage(filename="test_sip_image.fits") # Check that the loaded image matches the original - assert torch.allclose( + assert ap.backend.allclose( sip_target.data, loaded_image.data ), "Loaded image data should match original" - assert torch.allclose( + assert ap.backend.allclose( sip_target.pixelscale, loaded_image.pixelscale ), "Loaded image pixelscale should match original" - assert torch.allclose( + assert ap.backend.allclose( sip_target.zeropoint, loaded_image.zeropoint ), "Loaded image zeropoint should match original" print(loaded_image.sipA) diff --git a/tests/test_utils.py b/tests/test_utils.py index b4e0d964..20571f74 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,4 @@ import numpy as np -import torch from scipy.special import gamma import astrophot as ap from utils import make_basic_sersic, make_basic_gaussian @@ -15,7 +14,7 @@ def test_make_psf(): target += make_basic_gaussian(x=40, y=40, rand=54321) assert np.all( - np.isfinite(target.data.detach().cpu().numpy()) + np.isfinite(ap.backend.to_numpy(target.data)) ), "Target image should be finite after creation" @@ -119,50 +118,52 @@ def test_conversion_functions(): ), "Error computing inverse sersic function (np)" # sersic I0 to flux - torch - tv = torch.tensor([[1.0]], dtype=torch.float64) - assert torch.allclose( - torch.round( + tv = ap.backend.as_array([[1.0]], dtype=ap.backend.float64) + assert ap.backend.allclose( + ap.backend.round( ap.utils.conversions.functions.sersic_I0_to_flux_np(tv, tv, tv, tv), decimals=7, ), - torch.round(torch.tensor([[2 * np.pi * gamma(2)]]), decimals=7), + ap.backend.round(ap.backend.as_array([[2 * np.pi * gamma(2)]]), decimals=7), ), "Error converting sersic central intensity to flux (torch)" # sersic flux to I0 - torch - assert torch.allclose( - torch.round( + assert ap.backend.allclose( + ap.backend.round( ap.utils.conversions.functions.sersic_flux_to_I0_np(tv, tv, tv, tv), decimals=7, ), - torch.round(torch.tensor([[1.0 / (2 * np.pi * gamma(2))]]), decimals=7), + ap.backend.round(ap.backend.as_array([[1.0 / (2 * np.pi * gamma(2))]]), decimals=7), ), "Error converting sersic flux to central intensity (torch)" # sersic Ie to flux - torch - assert torch.allclose( - torch.round( + assert ap.backend.allclose( + ap.backend.round( ap.utils.conversions.functions.sersic_Ie_to_flux_np(tv, tv, tv, tv), decimals=7, ), - torch.round( - torch.tensor([[2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2)]]), + ap.backend.round( + ap.backend.as_array([[2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2)]]), decimals=7, ), ), "Error converting sersic effective intensity to flux (torch)" # sersic flux to Ie - torch - assert torch.allclose( - torch.round( + assert ap.backend.allclose( + ap.backend.round( ap.utils.conversions.functions.sersic_flux_to_Ie_np(tv, tv, tv, tv), decimals=7, ), - torch.round( - torch.tensor([[1 / (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2))]]), + ap.backend.round( + ap.backend.as_array( + [[1 / (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2))]] + ), decimals=7, ), ), "Error converting sersic flux to effective intensity (torch)" # inverse sersic - torch - assert torch.allclose( - torch.round(ap.utils.conversions.functions.sersic_inv_np(tv, tv, tv, tv), decimals=7), - torch.round(torch.tensor([[1.0 - (1.0 / sersic_n) * np.log(1.0)]]), decimals=7), + assert ap.backend.allclose( + ap.backend.round(ap.utils.conversions.functions.sersic_inv_np(tv, tv, tv, tv), decimals=7), + ap.backend.round(ap.backend.as_array([[1.0 - (1.0 / sersic_n) * np.log(1.0)]]), decimals=7), ), "Error computing inverse sersic function (torch)" diff --git a/tests/test_window_list.py b/tests/test_window_list.py index d00b928f..7c983e73 100644 --- a/tests/test_window_list.py +++ b/tests/test_window_list.py @@ -1,7 +1,5 @@ -import unittest import astrophot as ap import numpy as np -import torch ###################################################################### diff --git a/tests/utils.py b/tests/utils.py index 1eee826d..53bad295 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -64,7 +64,7 @@ def make_basic_sersic( sampling_mode="quad:5", ) - img = MODEL().data.T.detach().cpu().numpy() + img = ap.backend.to_numpy(MODEL().data.T) target.data = ( img + np.random.normal(scale=0.5, size=img.shape) @@ -104,7 +104,7 @@ def make_basic_gaussian( q=0.99, ) - img = MODEL().data.detach().cpu().numpy() + img = ap.backend.to_numpy(MODEL().data.T) target.data = ( img + np.random.normal(scale=0.1, size=img.shape) From c4497dfe63afc35bf9a2aa795232498e2d133d98 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 14 Aug 2025 17:34:28 -0400 Subject: [PATCH 123/191] now passing plenty of tests --- astrophot/backend_obj.py | 27 + astrophot/fit/base.py | 2 +- astrophot/fit/func/lm.py | 8 +- astrophot/fit/gradient.py | 23 +- astrophot/fit/iterative.py | 2 +- astrophot/fit/scipy_fit.py | 2 +- astrophot/image/image_object.py | 6 +- astrophot/image/jacobian_image.py | 2 +- astrophot/image/mixins/data_mixin.py | 13 +- astrophot/models/basis.py | 2 +- astrophot/models/model_object.py | 2 +- astrophot/param/module.py | 2 +- astrophot/plots/profile.py | 7 +- astrophot/utils/conversions/functions.py | 15 +- docs/source/tutorials/GettingStartedJAX.ipynb | 718 ++++++++++++++++++ tests/test_fit.py | 2 + 16 files changed, 792 insertions(+), 41 deletions(-) create mode 100644 docs/source/tutorials/GettingStartedJAX.ipynb diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index d9dbbbff..1ad7d601 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -58,6 +58,7 @@ def setup_torch(self): self.as_array = self._as_array_torch self.to = self._to_torch self.to_numpy = self._to_numpy_torch + self.gammaln = self._gammaln_torch self.logit = self._logit_torch self.sigmoid = self._sigmoid_torch self.repeat = self._repeat_torch @@ -68,9 +69,11 @@ def setup_torch(self): self.LinAlgErr = self.module._C._LinAlgError self.roll = self._roll_torch self.clamp = self._clamp_torch + self.flatten = self._flatten_torch self.conv2d = self._conv2d_torch self.mean = self._mean_torch self.sum = self._sum_torch + self.max = self._max_torch self.topk = self._topk_torch self.bessel_j1 = self._bessel_j1_torch self.bessel_k1 = self._bessel_k1_torch @@ -96,6 +99,7 @@ def setup_jax(self): self.as_array = self._as_array_jax self.to = self._to_jax self.to_numpy = self._to_numpy_jax + self.gammaln = self._gammaln_jax self.logit = self._logit_jax self.sigmoid = self._sigmoid_jax self.repeat = self._repeat_jax @@ -106,9 +110,11 @@ def setup_jax(self): self.LinAlgErr = Exception self.roll = self._roll_jax self.clamp = self._clamp_jax + self.flatten = self._flatten_jax self.conv2d = self._conv2d_jax self.mean = self._mean_jax self.sum = self._sum_jax + self.max = self._max_jax self.topk = self._topk_jax self.bessel_j1 = self._bessel_j1_jax self.bessel_k1 = self._bessel_k1_jax @@ -196,6 +202,12 @@ def _transpose_torch(self, array, *args): def _transpose_jax(self, array, *args): return self.module.transpose(array, args) + def _gammaln_torch(self, array): + return self.module.special.gammaln(array) + + def _gammaln_jax(self, array): + return self.jax.scipy.special.gammaln(array) + def _sigmoid_torch(self, array): return self.module.sigmoid(array) @@ -272,6 +284,12 @@ def _sum_torch(self, array, dim=None): def _sum_jax(self, array, dim=None): return self.jax.numpy.sum(array, axis=dim) + def _max_torch(self, array, dim=None): + return self.module.max(array, dim=dim).values + + def _max_jax(self, array, dim=None): + return self.module.max(array, axis=dim) + def _topk_torch(self, array, k): return self.module.topk(array, k=k) @@ -334,6 +352,15 @@ def _add_at_indices_jax(self, array, indices, values): array = array.at[indices].add(values) return array + def _flatten_torch(self, array, start_dim=0, end_dim=-1): + return array.flatten(start_dim, end_dim) + + def _flatten_jax(self, array, start_dim=0, end_dim=-1): + shape = tuple(array.shape) + end_dim = (end_dim % len(shape)) + 1 + new_shape = shape[:start_dim] + (-1,) + shape[end_dim:] + return self.module.reshape(array, new_shape) + def arange(self, *args, dtype=None, device=None): return self.module.arange(*args, dtype=dtype, device=device) diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index d90d0b07..98a5d474 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -85,7 +85,7 @@ def res(self) -> np.ndarray: config.logger.warning( "Getting optimizer res with no real loss history, using current state" ) - return self.current_state.detach().cpu().numpy() + return backend.to_numpy(self.current_state) return np.array(self.lambda_history)[N][np.argmin(np.array(self.loss_history)[N])] def res_loss(self): diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index d06c9a46..3648375d 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -24,19 +24,19 @@ def nll_poisson(D, M): def gradient(J, W, D, M): - return J.T @ (W * (D - M)).unsqueeze(1) + return J.T @ (W * (D - M))[:, None] def gradient_poisson(J, D, M): - return J.T @ (D / M - 1).unsqueeze(1) + return J.T @ (D / M - 1)[:, None] def hessian(J, W): - return J.T @ (W.unsqueeze(1) * J) + return J.T @ (W[:, None] * J) def hessian_poisson(J, D, M): - return J.T @ ((D / (M**2 + 1e-10)).unsqueeze(1) * J) + return J.T @ ((D / (M**2 + 1e-10))[:, None] * J) def damp_hessian(hess, L): diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 98363f8b..996e3ad8 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -7,6 +7,7 @@ from .base import BaseOptimizer from .. import config +from ..backend_obj import backend, ArrayLike from ..models import Model from ..errors import OptimizeStopFail, OptimizeStopSuccess from . import func @@ -94,8 +95,8 @@ def step(self) -> None: loss.backward() - self.loss_history.append(loss.detach().cpu().item()) - self.lambda_history.append(np.copy(self.current_state.detach().cpu().numpy())) + self.loss_history.append(backend.to_numpy(loss)) + self.lambda_history.append(np.copy(backend.to_numpy(self.current_state))) if ( self.iteration % int(self.max_iter / self.report_freq) == 0 ) or self.iteration == self.max_iter: @@ -195,7 +196,7 @@ def __init__( self.report_freq = report_freq self.momentum = momentum - def density(self, state: torch.Tensor) -> torch.Tensor: + def density(self, state: ArrayLike) -> ArrayLike: """Calculate the density of the model at the given state. Based on ``self.likelihood``, will be either the Gaussian or Poisson negative log likelihood.""" @@ -209,11 +210,11 @@ def density(self, state: torch.Tensor) -> torch.Tensor: def fit(self) -> BaseOptimizer: """Perform the Slalom optimization.""" - grad_func = torch.func.grad(self.density) - momentum = torch.zeros_like(self.current_state) + grad_func = backend.grad(self.density) + momentum = backend.zeros_like(self.current_state) self.S_history = [self.S] self.loss_history = [self.density(self.current_state).item()] - self.lambda_history = [self.current_state.detach().cpu().numpy()] + self.lambda_history = [backend.to_numpy(self.current_state)] self.start_fit = time() for i in range(self.max_iter): @@ -226,22 +227,22 @@ def fit(self) -> BaseOptimizer: self.density, grad_func, vstate, m=momentum, S=self.S ) self.current_state = self.model.from_valid( - vstate - self.S * (grad + momentum) / torch.linalg.norm(grad + momentum) + vstate - self.S * (grad + momentum) / backend.linalg.norm(grad + momentum) ) momentum = self.momentum * (momentum + grad) except OptimizeStopSuccess as e: self.message = self.message + str(e) break except OptimizeStopFail as e: - if torch.allclose(momentum, torch.zeros_like(momentum)): + if backend.allclose(momentum, backend.zeros_like(momentum)): self.message = self.message + str(e) break - momentum = torch.zeros_like(self.current_state) + momentum = backend.zeros_like(self.current_state) continue # Log the loss self.S_history.append(self.S) self.loss_history.append(loss) - self.lambda_history.append(self.current_state.detach().cpu().numpy()) + self.lambda_history.append(backend.to_numpy(self.current_state)) if self.verbose > 0 and (i % int(self.report_freq) == 0 or i == self.max_iter - 1): config.logger.info( @@ -260,7 +261,7 @@ def fit(self) -> BaseOptimizer: # Set the model parameters to the best values from the fit self.model.fill_dynamic_values( - torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) + backend.as_array(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if self.verbose > 0: config.logger.info( diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 9625e89e..bd0da0a2 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -54,7 +54,7 @@ def __init__( self.lm_kwargs["relative_tolerance"] = 1e-3 self.lm_kwargs["max_iter"] = 15 # # pixels # parameters - self.ndf = self.model.target[self.model.window].flatten("data").size(0) - len( + self.ndf = self.model.target[self.model.window].flatten("data").shape[0] - len( self.current_state ) if self.model.target.has_mask: diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py index 5b6c7e45..6673fcee 100644 --- a/astrophot/fit/scipy_fit.py +++ b/astrophot/fit/scipy_fit.py @@ -65,7 +65,7 @@ def numpy_bounds(self): bound[1] = backend.to_numpy(param.valid[1]) bounds.append(tuple(bound)) else: - for i in range(param.value.numel()): + for i in range(np.prod(param.value.shape)): bound = [None, None] if param.valid[0] is not None: bound[0] = backend.to_numpy(param.valid[0].flatten()[i]) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 96997ae0..02c84a13 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -406,7 +406,7 @@ def to(self, dtype=None, device=None): return self def flatten(self, attribute: str = "data") -> ArrayLike: - return getattr(self, attribute).flatten(end_dim=1) + return backend.flatten(getattr(self, attribute), end_dim=1) def fits_info(self) -> dict: return { @@ -559,7 +559,7 @@ def __add__(self, other): def __iadd__(self, other): if isinstance(other, Image): - backend.add_at_indices( + self._data = backend.add_at_indices( self._data, self.get_indices(other.window), other.data[other.get_indices(self.window)], @@ -570,7 +570,7 @@ def __iadd__(self, other): def __isub__(self, other): if isinstance(other, Image): - backend.add_at_indices( + self._data = backend.add_at_indices( self._data, self.get_indices(other.window), -other.data[other.get_indices(self.window)], diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index b733fb9a..9f130e49 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -49,7 +49,7 @@ def __iadd__(self, other: "JacobianImage"): self_indices = self.get_indices(other.window) other_indices = other.get_indices(self.window) for self_i, other_i in zip(*self.match_parameters(other)): - backend.add_at_indices( + self._data = backend.add_at_indices( self._data, self_indices + (self_i,), other.data[other_indices[0], other_indices[1], other_i], diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 93e61674..c681b9ab 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -326,16 +326,17 @@ def reduce(self, scale: int, **kwargs) -> Image: scale=scale, _weight=( 1 - / self.variance[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .sum(axis=(1, 3)) + / backend.sum( + self.variance[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), + dim=(1, 3), + ) if self.has_variance else None ), _mask=( - self.mask[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .amax(axis=(1, 3)) + backend.max( + self.mask[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), dim=(1, 3) + ) if self.has_mask else None ), diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index 55a376d4..cdcfb53a 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -70,7 +70,7 @@ def initialize(self): super().initialize() target_area = self.target[self.window] if not self.PA.initialized: - R, _ = polar_decomposition(self.target.CD.value.detach().cpu().numpy()) + R, _ = polar_decomposition(self.target.CD.npvalue) self.PA.value = np.arccos(np.abs(R[0, 0])) if not self.scale.initialized: self.scale.value = self.target.pixelscale.item() diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index d664f084..eae8ef85 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -140,7 +140,7 @@ def initialize(self): self.center.dynamic_value = COM_center def fit_mask(self): - return backend.zeros_like(self.target[self.window].mask, dtype=torch.bool) + return backend.zeros_like(self.target[self.window].mask, dtype=backend.bool) @forward def transform_coordinates(self, x, y, center): diff --git a/astrophot/param/module.py b/astrophot/param/module.py index 864de4f5..1a4773da 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -65,7 +65,7 @@ def fill_dynamic_value_uncertainties(self, uncertainty): # Handle scalar parameters size = max(1, prod(param.shape)) try: - val = uncertainty[..., pos : pos + size].view(param.shape) + val = uncertainty[..., pos : pos + size].reshape(param.shape) param.uncertainty = val except (RuntimeError, IndexError, ValueError, TypeError): raise FillDynamicParamsArrayError(self.name, uncertainty, dynamic_params) diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 9e64a33d..b66c8a9c 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -47,15 +47,12 @@ def radial_light_profile( """ xx = backend.linspace( R0, - max(model.window.shape) - * model.target.pixelscale.detach().cpu().numpy() - * extend_profile - / 2, + max(model.window.shape) * backend.to_numpy(model.target.pixelscale) * extend_profile / 2, int(resolution), dtype=config.DTYPE, device=config.DEVICE, ) - flux = model.radial_model(xx, params=()).detach().cpu().numpy() + flux = backend.to_numpy(model.radial_model(xx, params=())) if model.target.zeropoint is not None: yy = flux_to_sb(flux, 1.0, model.target.zeropoint.item()) else: diff --git a/astrophot/utils/conversions/functions.py b/astrophot/utils/conversions/functions.py index 21a19144..1a2f1c60 100644 --- a/astrophot/utils/conversions/functions.py +++ b/astrophot/utils/conversions/functions.py @@ -1,7 +1,6 @@ from typing import Union import numpy as np from scipy.special import gamma -from torch.special import gammaln from ...backend_obj import backend, ArrayLike __all__ = ( @@ -150,7 +149,7 @@ def sersic_I0_to_flux_torch(I0: ArrayLike, n: ArrayLike, R: ArrayLike, q: ArrayL """ - return 2 * np.pi * I0 * q * n * R**2 * backend.exp(gammaln(2 * n)) + return 2 * np.pi * I0 * q * n * R**2 * backend.exp(backend.gammaln(2 * n)) def sersic_flux_to_I0_torch(flux: ArrayLike, n: ArrayLike, R: ArrayLike, q: ArrayLike) -> ArrayLike: @@ -170,7 +169,7 @@ def sersic_flux_to_I0_torch(flux: ArrayLike, n: ArrayLike, R: ArrayLike, q: Arra - `q`: axis ratio (b/a) """ - return flux / (2 * np.pi * q * n * R**2 * backend.exp(gammaln(2 * n))) + return flux / (2 * np.pi * q * n * R**2 * backend.exp(backend.gammaln(2 * n))) def sersic_Ie_to_flux_torch(Ie: ArrayLike, n: ArrayLike, R: ArrayLike, q: ArrayLike) -> ArrayLike: @@ -199,7 +198,7 @@ def sersic_Ie_to_flux_torch(Ie: ArrayLike, n: ArrayLike, R: ArrayLike, q: ArrayL * q * n * (backend.exp(bn) * bn ** (-2 * n)) - * backend.exp(gammaln(2 * n)) + * backend.exp(backend.gammaln(2 * n)) ) @@ -222,7 +221,13 @@ def sersic_flux_to_Ie_torch(flux: ArrayLike, n: ArrayLike, R: ArrayLike, q: Arra """ bn = sersic_n_to_b(n) return flux / ( - 2 * np.pi * R**2 * q * n * (backend.exp(bn) * bn ** (-2 * n)) * backend.exp(gammaln(2 * n)) + 2 + * np.pi + * R**2 + * q + * n + * (backend.exp(bn) * bn ** (-2 * n)) + * backend.exp(backend.gammaln(2 * n)) ) diff --git a/docs/source/tutorials/GettingStartedJAX.ipynb b/docs/source/tutorials/GettingStartedJAX.ipynb new file mode 100644 index 00000000..c944db5b --- /dev/null +++ b/docs/source/tutorials/GettingStartedJAX.ipynb @@ -0,0 +1,718 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Using AstroPhot with JAX\n", + "\n", + "In this notebook we will run through the same \"getting started\" tutorial, except this time using JAX!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "import astrophot as ap\n", + "import numpy as np\n", + "import jax\n", + "from astropy.io import fits\n", + "from astropy.wcs import WCS\n", + "import matplotlib.pyplot as plt\n", + "import socket\n", + "\n", + "socket.setdefaulttimeout(120)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setting the backend to JAX\n", + "\n", + "The first thing we need to do is tell AstroPhot to start using JAX. The easiest way to do this is by setting the environment variable `CASAKDE_BACKEND=\"jax\"` which will update the caskade parameter manager and AstroPhot to now use JAX. If you want to control the backend inside a script so that you can easily mix and match between scripts, then just make sure to set the backend at the beginning and don't change it within one script!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import caskade as ck\n", + "\n", + "ck.backend.backend = \"jax\"\n", + "ap.backend.backend = \"jax\"\n", + "# and that's it!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Your first model\n", + "\n", + "The basic format for making an AstroPhot model is given below. Once a model object is constructed, it can be manipulated and updated in various ways." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model1 = ap.Model(\n", + " name=\"model1\",\n", + " model_type=\"sersic galaxy model\", # this specifies the kind of model\n", + " # here we set initial values for each parameter\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=2,\n", + " Re=10,\n", + " Ie=1,\n", + " # every model needs a target, more on this later\n", + " target=ap.TargetImage(data=np.zeros((100, 100)), zeropoint=22.5),\n", + ")\n", + "\n", + "# models must/should be initialized before doing anything with them.\n", + "# This makes sure all the parameters and metadata are ready to go.\n", + "model1.initialize()\n", + "\n", + "# We can print the model's current state\n", + "print(model1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# AstroPhot has built in methods to plot relevant information. This plots the model\n", + "# as projected into the \"target\" image. Thus it has the same pixelscale, orientation\n", + "# and (optionally) PSF as the model's target.\n", + "fig, ax = plt.subplots(figsize=(8, 7))\n", + "ap.plots.model_image(fig, ax, model1)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Giving the model a Target\n", + "\n", + "Typically, the main goal when constructing an AstroPhot model is to fit to an image. We need to give the model access to the image and some information about it to get started." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# first let's download an image to play with\n", + "hdu = fits.open(\n", + " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + ")\n", + "target_data = np.array(hdu[0].data, dtype=np.float64)\n", + "\n", + "target = ap.TargetImage(\n", + " data=target_data,\n", + " pixelscale=0.262,\n", + " zeropoint=22.5, # optionally, a zeropoint tells AstroPhot the pixel flux units\n", + " variance=\"auto\", # Automatic variance estimate for testing and demo purposes only! In real analysis use weight maps, counts, gain, etc to compute variance!\n", + ")\n", + "\n", + "# The default AstroPhot target plotting method uses log scaling in bright areas and histogram scaling in faint areas\n", + "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", + "ap.plots.target_image(fig3, ax3, target)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# This model now has a target that it will attempt to match\n", + "model2 = ap.Model(\n", + " name=\"model with target\",\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + ")\n", + "\n", + "# Instead of giving initial values for all the parameters, it is possible to\n", + "# simply call \"initialize\" and AstroPhot will try to guess initial values for\n", + "# every parameter. It is also possible to set just a few parameters and let\n", + "# AstroPhot try to figure out the rest. For example you could give it an initial\n", + "# Guess for the center and it will work from there.\n", + "model2.initialize()\n", + "\n", + "# Plotting the initial parameters and residuals, we see it gets the rough shape\n", + "# of the galaxy right, but still has some fitting to do\n", + "fig4, ax4 = plt.subplots(1, 2, figsize=(16, 6))\n", + "ap.plots.model_image(fig4, ax4[0], model2)\n", + "ap.plots.residual_image(fig4, ax4[1], model2)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Now that the model has been set up with a target and initialized with parameter values, it is time to fit the image\n", + "result = ap.fit.LM(model2, verbose=1).fit()\n", + "\n", + "# See that we use ap.fit.LM, this is the Levenberg-Marquardt Chi^2 minimization method, it is the recommended technique\n", + "# for most least-squares problems. See the Fitting Methods tutorial for more on fitters!\n", + "print(\"Fit message:\", result.message) # the fitter will store a message about its convergence" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(model2)\n", + "# we now plot the fitted model and the image residuals\n", + "fig5, ax5 = plt.subplots(1, 2, figsize=(16, 6))\n", + "ap.plots.model_image(fig5, ax5[0], model2)\n", + "ap.plots.residual_image(fig5, ax5[1], model2, normalize_residuals=True)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Plot surface brightness profile\n", + "\n", + "# we now plot the model profile and a data profile. The model profile is determined from the model parameters\n", + "# the data profile is determined by taking the median of pixel values at a given radius. Notice that the model\n", + "# profile is slightly higher than the data profile? This is because there are other objects in the image which\n", + "# are not being modelled, the data profile uses a median so they are ignored, but for the model we fit all pixels.\n", + "fig10, ax10 = plt.subplots(figsize=(8, 8))\n", + "ap.plots.radial_light_profile(fig10, ax10, model2)\n", + "ap.plots.radial_median_profile(fig10, ax10, model2)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Update uncertainty estimates\n", + "\n", + "After running a fit, the `ap.fit.LM` optimizer can update the uncertainty for each parameter. In fact it can return the full covariance matrix if needed. For a demo of what can be done with the covariance matrix see the `FittingMethods` tutorial. One important note is that the variance image needs to be correct for the uncertainties to be meaningful!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result.update_uncertainty()\n", + "print(model2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that these uncertainties are pure statistical uncertainties that come from evaluating the structure of the $\\chi^2$ minimum. Systematic uncertainties are not included and these often significantly outweigh the standard errors. As can be seen in the residual plot above, there is certainly plenty of unmodelled structure there. Use caution when interpreting the errors from these fits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Plot the uncertainty matrix\n", + "\n", + "# While the scale of the uncertainty may not be meaningful if the image variance is not accurate, we\n", + "# can still see how the covariance of the parameters plays out in a given fit.\n", + "fig, ax = ap.plots.covariance_matrix(\n", + " result.covariance_matrix,\n", + " model2.build_params_array(),\n", + " model2.build_params_array_names(),\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Record the total flux/magnitude\n", + "\n", + "Often the parameter of interest is the total flux or magnitude, even if this isn't one of the core parameters of the model, it can be computed. For Sersic and Moffat models with analytic total fluxes it will be integrated to infinity, for most other models it will simply be the total flux in the window." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\n", + " f\"Total Flux: {model2.total_flux().item():.1f} +- {model2.total_flux_uncertainty().item():.1f}\"\n", + ")\n", + "print(\n", + " f\"Total Magnitude: {model2.total_magnitude().item():.4f} +- {model2.total_magnitude_uncertainty().item():.4f}\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Giving the model a specific target window\n", + "\n", + "Sometimes an object isn't nicely centered in the image, and may not even be the dominant object in the image. It is therefore nice to be able to specify what part of the image we should analyze." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# note, we don't provide a name here. A unique name will automatically be generated using the model type\n", + "model3 = ap.Model(\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + " window=[480, 595, 555, 665], # this is a region in pixel coordinates (imin,imax,jmin,jmax)\n", + ")\n", + "print(f\"automatically generated name: '{model3.name}'\")\n", + "\n", + "# We can plot the \"model window\" to show us what part of the image will be analyzed by that model\n", + "fig6, ax6 = plt.subplots(figsize=(8, 8))\n", + "ap.plots.target_image(fig6, ax6, model3.target)\n", + "ap.plots.model_window(fig6, ax6, model3)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model3.initialize()\n", + "result = ap.fit.LM(model3, verbose=1).fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Note that when only a window is fit, the default plotting methods will only show that window\n", + "print(model3)\n", + "fig7, ax7 = plt.subplots(1, 2, figsize=(16, 6))\n", + "ap.plots.model_image(fig7, ax7[0], model3)\n", + "ap.plots.residual_image(fig7, ax7[1], model3, normalize_residuals=True)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setting parameter constraints\n", + "\n", + "A common feature of fitting parameters is that they have some constraint on their behaviour and cannot be sampled at any value from (-inf, inf). AstroPhot circumvents this by remapping any constrained parameter to a space where it can take any real value, at least for the sake of fitting. For most parameters these constraints are applied by default; for example the axis ratio q is required to be in the range (0,1). Other parameters, such as the position angle (PA) are cyclic, they can be in the range (0,pi) but also can wrap around. It is possible to manually set these constraints while constructing a model.\n", + "\n", + "In general adding constraints makes fitting more difficult. There is a chance that the fitting process runs up against a constraint boundary and gets stuck. However, sometimes adding constraints is necessary and so the capability is included." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# here we make a sersic model that can only have q and n in a narrow range\n", + "# Also, we give PA and initial value and lock that so it does not change during fitting\n", + "constrained_param_model = ap.Model(\n", + " name=\"constrained parameters\",\n", + " model_type=\"sersic galaxy model\",\n", + " q={\"valid\": (0.4, 0.6)},\n", + " n={\"valid\": (2, 3)},\n", + " PA={\"value\": 60 * np.pi / 180},\n", + " target=target,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Aside from constraints on an individual parameter, it is sometimes desirable to have different models share parameter values. For example you may wish to combine multiple simple models into a more complex model (more on that in a different tutorial), and you may wish for them all to have the same center. This can be accomplished with \"equality constraints\" as shown below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# model 1 is a sersic model\n", + "model_1 = ap.Model(model_type=\"sersic galaxy model\", center=[50, 50], PA=np.pi / 4, target=target)\n", + "# model 2 is an exponential model\n", + "model_2 = ap.Model(model_type=\"exponential galaxy model\", target=target)\n", + "\n", + "# Here we add the constraint for \"PA\" to be the same for each model.\n", + "# In doing so we provide the model and parameter name which should\n", + "# be connected.\n", + "model_2.PA = model_1.PA\n", + "\n", + "# Here we can see how the two models now both can modify this parameter\n", + "print(\n", + " \"initial values: model_1 PA\",\n", + " model_1.PA.value.item(),\n", + " \"model_2 PA\",\n", + " model_2.PA.value.item(),\n", + ")\n", + "# Now we modify the PA for model_1\n", + "model_1.PA.value = np.pi / 3\n", + "print(\n", + " \"change model_1: model_1 PA\",\n", + " model_1.PA.value.item(),\n", + " \"model_2 PA\",\n", + " model_2.PA.value.item(),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Basic things to do with a model\n", + "\n", + "Now that we know how to create a model and fit it to an image, lets get to know the model a bit better." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Save the model state to a file\n", + "\n", + "model2.save_state(\"current_spot.hdf5\", appendable=True) # save as it is\n", + "model2.q = 0.1 # do some updates to the model\n", + "model2.PA = 0.1\n", + "model2.n = 0.9\n", + "model2.Re = 0.1\n", + "model2.append_state(\"current_spot.hdf5\") # save the updated model state as often as you like" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# load a model state from a file\n", + "\n", + "model2.load_state(\"current_spot.hdf5\", index=0) # load the first state from the file\n", + "print(model2) # see that the values are back to where they started" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Save the model image to a file\n", + "\n", + "model_image_sample = model2()\n", + "model_image_sample.save(\"model2.fits\")\n", + "\n", + "saved_image_hdu = fits.open(\"model2.fits\")\n", + "fig, ax = plt.subplots(figsize=(8, 8))\n", + "ax.imshow(\n", + " np.log10(saved_image_hdu[0].data),\n", + " origin=\"lower\",\n", + " cmap=\"viridis\",\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Plot model image with discrete levels\n", + "\n", + "# this is very useful for visualizing subtle features and for eyeballing the brightness at a given location.\n", + "# just add the \"cmap_levels\" keyword to the model_image call and tell it how many levels you want\n", + "fig11, ax11 = plt.subplots(figsize=(8, 8))\n", + "ap.plots.model_image(fig11, ax11, model2, cmap_levels=15)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Save and load a target image\n", + "\n", + "target.save(\"target.fits\")\n", + "\n", + "# Note that it is often also possible to load from regular FITS files\n", + "new_target = ap.TargetImage(filename=\"target.fits\")\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 8))\n", + "ap.plots.target_image(fig, ax, new_target)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Access the model image pixels directly\n", + "\n", + "fig2, ax2 = plt.subplots(figsize=(8, 8))\n", + "\n", + "# Transpose because AstroPhot indexes with (i,j) while numpy uses (j,i)\n", + "pixels = model2().data.T\n", + "\n", + "im = plt.imshow(\n", + " np.log10(pixels), # take log10 for better dynamic range\n", + " origin=\"lower\",\n", + " cmap=ap.plots.visuals.cmap_grad, # gradient colourmap default for AstroPhot\n", + ")\n", + "plt.colorbar(im)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load target with WCS information" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# first let's download an image to play with\n", + "filename = \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "hdu = fits.open(filename)\n", + "target_data = np.array(hdu[0].data, dtype=np.float64)\n", + "\n", + "wcs = WCS(hdu[0].header)\n", + "\n", + "# Create a target object with WCS which will specify the pixelscale and origin for us!\n", + "target = ap.TargetImage(\n", + " data=target_data,\n", + " zeropoint=22.5,\n", + " wcs=wcs,\n", + ")\n", + "\n", + "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", + "ap.plots.target_image(fig3, ax3, target)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Even better, just load directly from a FITS file\n", + "\n", + "AstroPhot recognizes standard FITS keywords to extract a target image. Note that this wont work for all FITS files, just ones that define the following keywords: `CTYPE1`, `CTYPE2`, `CRVAL1`, `CRVAL2`, `CRPIX1`, `CRPIX2`, `CD1_1`, `CD1_2`, `CD2_1`, `CD2_2`, and `MAGZP` with the usual meanings. AstroPhot can also handle SIP, see the SIP tutorial for details there.\n", + "\n", + "Further keywords specific to AstroPhot that it uses for some advanced features like multi-band fitting are: `CRTAN1`, `CRTAN2` used for aligning images, and `IDNTY` used for identifying when two images are actually cutouts of the same image. And AstroPhot also will store the `PSF`, `WEIGHT`, and `MASK` in extra extensions of the FITS file when it makes one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "target = ap.TargetImage(filename=filename)\n", + "\n", + "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", + "ap.plots.target_image(fig3, ax3, target)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# List all the available model names\n", + "\n", + "# AstroPhot keeps track of all the subclasses of the AstroPhot Model object, this list will\n", + "# include all models even ones added by the user\n", + "print(ap.Model.List_Models(usable=True, types=True))\n", + "print(\"---------------------------\")\n", + "# It is also possible to get all sub models of a specific Type\n", + "print(\"only galaxy models: \", ap.models.GalaxyModel.List_Models(types=True))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Using GPU acceleration\n", + "\n", + "This one is easy! If you have a cuda enabled GPU available, AstroPhot will just automatically detect it and use that device. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# check if AstroPhot has detected your GPU\n", + "print(ap.config.DEVICE) # most likely this will say \"cpu\" unless you already have a cuda GPU,\n", + "# in which case it should say \"cuda:0\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# If you have a GPU but want to use the cpu for some reason, just set:\n", + "ap.config.DEVICE = jax.devices(\"cpu\")\n", + "# BEFORE creating anything else (models, images, etc.)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Boost GPU acceleration with single precision float32\n", + "\n", + "If you are using a GPU you can get significant performance increases in both memory and speed by switching from double precision (the AstroPhot default) to single precision floating point numbers. The trade off is reduced precision, this can cause some unexpected behaviors. For example an optimizer may keep iterating forever if it is trying to optimize down to a precision below what the float32 will track. Typically, numbers with float32 are good down to 6 places and AstroPhot by default only attempts to minimize the Chi^2 to 3 places. However, to ensure the fit is secure to 3 places it often checks what is happenening down at 4 or 5 places. Hence, issues can arise. For the most part you can go ahead with float32 and if you run into a weird bug, try on float64 before looking further." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Again do this BEFORE creating anything else\n", + "ap.config.DTYPE = jax.numpy.float32\n", + "\n", + "# Now new AstroPhot objects will be made with single bit precision\n", + "T1 = ap.TargetImage(data=np.zeros((100, 100)))\n", + "T1.to()\n", + "print(\"now a single:\", T1.data.dtype)\n", + "\n", + "# Here we switch back to double precision\n", + "ap.config.DTYPE = jax.numpy.float64\n", + "T2 = ap.TargetImage(data=np.zeros((100, 100)))\n", + "T2.to()\n", + "print(\"back to double:\", T2.data.dtype)\n", + "print(\"old image is still single!:\", T1.data.dtype)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "See how the window created as a float32 stays that way? That's really bad to have lying around! Make sure to change the data type before creating anything! " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tracking output\n", + "\n", + "The AstroPhot optimizers, and occasionally the other AstroPhot objects, will provide status updates about themselves which can be very useful for debugging problems or just keeping tabs on progress. There are a number of use cases for AstroPhot, each having different desired output behaviors. To accommodate all users, AstroPhot implements a general logging system. The object `ap.config.logger` is a logging object which by default writes to AstroPhot.log in the local directory. As the user, you can set that logger to be any logging object you like for arbitrary complexity. Most users will, however, simply want to control the filename, or have it output to screen instead of a file. Below you can see examples of how to do that." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# note that the log file will be where these tutorial notebooks are in your filesystem\n", + "\n", + "# Here we change the settings so AstroPhot only prints to a log file\n", + "ap.config.set_logging_output(stdout=False, filename=\"AstroPhot.log\")\n", + "ap.config.logger.info(\"message 1: this should only appear in the AstroPhot log file\")\n", + "\n", + "# Here we change the settings so AstroPhot only prints to console\n", + "ap.config.set_logging_output(stdout=True, filename=None)\n", + "ap.config.logger.info(\"message 2: this should only print to the console\")\n", + "\n", + "# Here we change the settings so AstroPhot prints to both, which is the default\n", + "ap.config.set_logging_output(stdout=True, filename=\"AstroPhot.log\")\n", + "ap.config.logger.info(\"message 3: this should appear in both the console and the log file\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can also change the logging level and/or formatter for the stdout and filename options (see `help(ap.config.set_logging_output)` for details). However, at that point you may want to simply make your own logger object and assign it to the `ap.config.logger` variable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/test_fit.py b/tests/test_fit.py index ad1e34fe..80ccbe63 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -80,6 +80,8 @@ def sersic_model(): ], ) def test_fitters(fitter, sersic_model): + if ap.backend.backend == "jax" and fitter in [ap.fit.Grad, ap.fit.HMC]: + pytest.skip("Grad and HMC not implemented for JAX backend") model = sersic_model model.initialize() ll_init = model.gaussian_log_likelihood() From c5a92b081c3d71808523a7efd92a23c9b0314662 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 14 Aug 2025 21:53:43 -0400 Subject: [PATCH 124/191] most tests working a few tricky things left --- astrophot/backend_obj.py | 20 ++++++++-- astrophot/image/image_object.py | 4 +- astrophot/image/mixins/data_mixin.py | 4 +- astrophot/image/mixins/sip_mixin.py | 11 ++++++ astrophot/image/sip_image.py | 37 +++++++++++-------- astrophot/models/_shared_methods.py | 2 +- astrophot/models/group_model_object.py | 10 +++-- astrophot/models/mixins/brightness.py | 8 ++-- astrophot/models/mixins/sample.py | 8 ++-- astrophot/models/mixins/transform.py | 7 ++-- docs/source/tutorials/GettingStartedJAX.ipynb | 27 +++++--------- docs/source/tutorials/index.rst | 1 + tests/test_fit.py | 2 + 13 files changed, 86 insertions(+), 55 deletions(-) diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index 1ad7d601..848abbe0 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -84,11 +84,12 @@ def setup_torch(self): self.long = self._long_torch self.fill_at_indices = self._fill_at_indices_torch self.add_at_indices = self._add_at_indices_torch + self.and_at_indices = self._and_at_indices_torch def setup_jax(self): self.jax = importlib.import_module("jax") self.jax.config.update("jax_enable_x64", True) - config.DTYPE = self.jax.numpy.float64 + config.DTYPE = None config.DEVICE = None self.make_array = self._make_array_jax self._array_type = self._array_type_jax @@ -125,6 +126,7 @@ def setup_jax(self): self.long = self._long_jax self.fill_at_indices = self._fill_at_indices_jax self.add_at_indices = self._add_at_indices_jax + self.and_at_indices = self._and_at_indices_jax @property def array_type(self): @@ -200,7 +202,9 @@ def _transpose_torch(self, array, *args): return self.module.transpose(array, *args) def _transpose_jax(self, array, *args): - return self.module.transpose(array, args) + permutation = np.arange(array.ndim) + permutation[np.sort(args)] = args + return self.module.transpose(array, permutation) def _gammaln_torch(self, array): return self.module.special.gammaln(array) @@ -245,13 +249,13 @@ def _roll_torch(self, array, shifts, dims): return self.module.roll(array, shifts, dims=dims) def _roll_jax(self, array, shifts, dims): - return self.jax.roll(array, shifts, axis=dims) + return self.module.roll(array, shifts, axis=dims) def _clamp_torch(self, array, min, max): return self.module.clamp(array, min, max) def _clamp_jax(self, array, min, max): - return self.jax.clip(array, min, max) + return self.module.clip(array, min, max) def _long_torch(self, array): return array.long() @@ -352,6 +356,14 @@ def _add_at_indices_jax(self, array, indices, values): array = array.at[indices].add(values) return array + def _and_at_indices_torch(self, array, indices, values): + array[indices] &= values + return array + + def _and_at_indices_jax(self, array, indices, values): + array = array.at[indices].set(array[indices] & values) + return array + def _flatten_torch(self, array, start_dim=0, end_dim=-1): return array.flatten(start_dim, end_dim) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 02c84a13..6f5fa351 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -400,9 +400,9 @@ def to(self, dtype=None, device=None): if device is None: device = config.DEVICE super().to(dtype=dtype, device=device) - self._data = self._data.to(dtype=dtype, device=device) + self._data = backend.to(self._data, dtype=dtype, device=device) if self.zeropoint is not None: - self.zeropoint = self.zeropoint.to(dtype=dtype, device=device) + self.zeropoint = backend.to(self.zeropoint, dtype=dtype, device=device) return self def flatten(self, attribute: str = "data") -> ArrayLike: diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index c681b9ab..a6db60f1 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -253,9 +253,9 @@ def to(self, dtype=None, device=None): super().to(dtype=dtype, device=device) if self.has_weight: - self._weight = self._weight.to(dtype=dtype, device=device) + self._weight = backend.to(self._weight, dtype=dtype, device=device) if self.has_mask: - self._mask = self._mask.to(dtype=backend.bool, device=device) + self._mask = backend.to(self._mask, dtype=backend.bool, device=device) return self def copy_kwargs(self, **kwargs): diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index fb40ae6f..4b1f6d38 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -3,6 +3,7 @@ from ..image_object import Image from ..window import Window from .. import func +from ... import config from ...backend_obj import backend, ArrayLike from ...utils.interpolate import interp2d from ...param import forward @@ -154,6 +155,16 @@ def update_distortion_model( ) self._pixel_area_map = A.abs() + def to(self, dtype=None, device=None): + if dtype is None: + dtype = config.DTYPE + if device is None: + device = config.DEVICE + super().to(dtype=dtype, device=device) + self._pixel_area_map = backend.to(self._pixel_area_map, dtype=dtype, device=device) + self.distortion_ij = backend.to(self.distortion_ij, dtype=dtype, device=device) + self.distortion_IJ = backend.to(self.distortion_IJ, dtype=dtype, device=device) + def copy_kwargs(self, **kwargs): kwargs = { "sipA": self.sipA, diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index e463d9b8..52cbf8ed 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -66,19 +66,26 @@ def reduce(self, scale: int, **kwargs): kwargs = { "pixel_area_map": ( - self.pixel_area_map[: MS * scale, : NS * scale] - .reshape(MS, scale, NS, scale) - .sum(axis=(1, 3)) + backend.sum( + self.pixel_area_map[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), + dim=(1, 3), + ) ), "distortion_ij": ( - self.distortion_ij[:, : MS * scale, : NS * scale] - .reshape(2, MS, scale, NS, scale) - .mean(axis=(2, 4)) + backend.mean( + self.distortion_ij[:, : MS * scale, : NS * scale].reshape( + 2, MS, scale, NS, scale + ), + dim=(2, 4), + ) ), "distortion_IJ": ( - self.distortion_IJ[:, : MS * scale, : NS * scale] - .reshape(2, MS, scale, NS, scale) - .mean(axis=(2, 4)) + backend.mean( + self.distortion_IJ[:, : MS * scale, : NS * scale].reshape( + 2, MS, scale, NS, scale + ), + dim=(2, 4), + ) ), **kwargs, } @@ -104,20 +111,20 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> SIPModelImag new_distortion_IJ = self.distortion_IJ if upsample > 1: new_area_map = ( - backend.upsample2d(new_area_map.unsqueeze(0).unsqueeze(0), upsample, "nearest") + backend.upsample2d(new_area_map[None, None], upsample, "nearest") .squeeze(0) .squeeze(0) ) new_distortion_ij = backend.upsample2d( - new_distortion_ij.unsqueeze(1), upsample, "bilinear" + new_distortion_ij[:, None], upsample, "bilinear" ).squeeze(1) new_distortion_IJ = backend.upsample2d( - new_distortion_IJ.unsqueeze(1), upsample, "bilinear" + new_distortion_IJ[:, None], upsample, "bilinear" ).squeeze(1) if pad > 0: new_area_map = ( backend.pad( - new_area_map.unsqueeze(0).unsqueeze(0), + new_area_map[None, None], (pad, pad, pad, pad), mode="replicate", ) @@ -125,10 +132,10 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> SIPModelImag .squeeze(0) ) new_distortion_ij = backend.pad( - new_distortion_ij.unsqueeze(1), (pad, pad, pad, pad), mode="replicate" + new_distortion_ij[:, None], (pad, pad, pad, pad), mode="replicate" ).squeeze(1) new_distortion_IJ = backend.pad( - new_distortion_IJ.unsqueeze(1), (pad, pad, pad, pad), mode="replicate" + new_distortion_IJ[:, None], (pad, pad, pad, pad), mode="replicate" ).squeeze(1) kwargs = { "pixel_area_map": new_area_map, diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 7f7e231c..7e090c47 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -17,7 +17,7 @@ def _sample_image( angle_range=None, cycle=2 * np.pi, ): - dat = backend.copy(image.data) + dat = backend.to_numpy(image.data).copy() # Fill masked pixels if image.has_mask: mask = backend.to_numpy(image.mask) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 257e1a9c..3e41ff12 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -129,19 +129,23 @@ def fit_mask(self) -> torch.Tensor: index = subtarget.index(target) group_indices = subtarget.images[index].get_indices(target.window) model_indices = target.get_indices(subtarget.images[index].window) - mask[index][group_indices] &= submask[model_indices] + mask[index] = backend.and_at_indices( + mask[index], group_indices, submask[model_indices] + ) else: index = subtarget.index(model_subtarget) group_indices = subtarget.images[index].get_indices(model_subtarget.window) model_indices = model_subtarget.get_indices(subtarget.images[index].window) - mask[index][group_indices] &= model_fit_mask[model_indices] + mask[index] = backend.and_at_indices( + mask[index], group_indices, model_fit_mask[model_indices] + ) else: mask = backend.ones_like(subtarget.mask) for model in self.models: model_subtarget = model.target[model.window] group_indices = subtarget.get_indices(model.window) model_indices = model_subtarget.get_indices(subtarget.window) - mask[group_indices] &= model.fit_mask()[model_indices] + mask = backend.and_at_indices(mask, group_indices, model.fit_mask()[model_indices]) return mask def match_window(self, image: Union[Image, ImageList], window: Window, model: Model) -> Window: diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index 020533c1..a7561f77 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -62,7 +62,7 @@ def polar_model(self, R: ArrayLike, T: ArrayLike) -> ArrayLike: v = w * np.arange(self.segments) for s in range(self.segments): indices = (angles >= v[s]) & (angles < (v[s] + w)) - model[indices] += self.iradial_model(s, R[indices]) + model = backend.add_at_indices(model, indices, self.iradial_model(s, R[indices])) return model def brightness(self, x: Tensor, y: Tensor) -> Tensor: @@ -110,8 +110,10 @@ def polar_model(self, R: ArrayLike, T: ArrayLike) -> ArrayLike: angles = (T + cycle / 2 - v[s]) % cycle - cycle / 2 indices = (angles >= -w) & (angles < w) weights = (backend.cos(angles[indices] * self.segments) + 1) / 2 - model[indices] += weights * self.iradial_model(s, R[indices]) - weight[indices] += weights + model = backend.add_at_indices( + model, indices, weights * self.iradial_model(s, R[indices]) + ) + weight = backend.add_at_indices(weight, indices, weights) return model / weight def brightness(self, x: ArrayLike, y: ArrayLike) -> ArrayLike: diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 36ab1721..e1ea15b8 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -85,8 +85,8 @@ def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: backend.abs( backend.pad( backend.conv2d( - sample.view(1, 1, *sample.shape), - kernel.view(1, 1, *kernel.shape), + sample.reshape(1, 1, *sample.shape), + kernel.reshape(1, 1, *kernel.shape), padding="valid", ), (1, 1, 1, 1), @@ -239,11 +239,11 @@ def gradient( if likelihood == "gaussian": weight = self.target[window].weight gradient = backend.sum( - jacobian_image.data * ((data - model) * weight).unsqueeze(-1), dim=(0, 1) + jacobian_image.data * ((data - model) * weight)[..., None], dim=(0, 1) ) elif likelihood == "poisson": gradient = backend.sum( - jacobian_image.data * (1 - data / model).unsqueeze(-1), + jacobian_image.data * (1 - data / model)[..., None], dim=(0, 1), ) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 3672e4bd..8ddffa14 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -1,7 +1,6 @@ from typing import Tuple import numpy as np import torch -from torch import Tensor from ...utils.decorators import ignore_numpy_warnings from ...utils.interpolate import default_prof @@ -119,7 +118,7 @@ class SuperEllipseMixin: @forward def radius_metric(self, x: ArrayLike, y: ArrayLike, C: ArrayLike) -> ArrayLike: - return (x.abs().pow(C) + y.abs().pow(C) + self.softening**C) ** (1.0 / C) + return (backend.abs(x) ** C + backend.abs(y) ** C + self.softening**C) ** (1.0 / C) class FourierEllipseMixin: @@ -181,8 +180,8 @@ def radius_metric( theta = self.angular_metric(x, y) return R * backend.exp( backend.sum( - am.unsqueeze(-1) - * backend.cos(self.modes.unsqueeze(-1) * theta.flatten() + phim.unsqueeze(-1)), + am[..., None] + * backend.cos(self.modes[..., None] * theta.flatten() + phim[..., None]), 0, ).reshape(x.shape) ) diff --git a/docs/source/tutorials/GettingStartedJAX.ipynb b/docs/source/tutorials/GettingStartedJAX.ipynb index c944db5b..68662af0 100644 --- a/docs/source/tutorials/GettingStartedJAX.ipynb +++ b/docs/source/tutorials/GettingStartedJAX.ipynb @@ -6,7 +6,9 @@ "source": [ "# Using AstroPhot with JAX\n", "\n", - "In this notebook we will run through the same \"getting started\" tutorial, except this time using JAX!" + "In this notebook we will run through the same \"getting started\" tutorial, except this time using JAX!\n", + "\n", + "You'll notice right away that basically everything is the same. The only difference is that now all the data and parameters are stored as JAX numpy arrays. So if that's how you prefer to interact with AstroPhot then forge on! AstroPhot should integrate with a JAX workflow very easily. One note though, JAX has a reputation for being fast, this is true of JIT compiled JAX but not necessarily \"eager\" JAX where we simply define functions and evaluate them. This is the mode that AstroPhot mostly works in since it is so dynamic in the number of options it has and the freedom users have to change them. For this reason, you will find that AstroPhot is often faster in PyTorch than JAX. It's still fast either way, in a future update we may implement some JAX speed optimizations." ] }, { @@ -592,18 +594,7 @@ "source": [ "## Using GPU acceleration\n", "\n", - "This one is easy! If you have a cuda enabled GPU available, AstroPhot will just automatically detect it and use that device. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# check if AstroPhot has detected your GPU\n", - "print(ap.config.DEVICE) # most likely this will say \"cpu\" unless you already have a cuda GPU,\n", - "# in which case it should say \"cuda:0\"" + "This one is easy! If you have a cuda enabled GPU available, JAX will just automatically detect it and use that device. " ] }, { @@ -612,9 +603,9 @@ "metadata": {}, "outputs": [], "source": [ - "# If you have a GPU but want to use the cpu for some reason, just set:\n", - "ap.config.DEVICE = jax.devices(\"cpu\")\n", - "# BEFORE creating anything else (models, images, etc.)" + "# this is different for the JAX version, JAX automatically handles device placement\n", + "# So AstroPhot just gives None as the device to let JAX to its thing\n", + "print(ap.config.DEVICE)" ] }, { @@ -623,7 +614,9 @@ "source": [ "## Boost GPU acceleration with single precision float32\n", "\n", - "If you are using a GPU you can get significant performance increases in both memory and speed by switching from double precision (the AstroPhot default) to single precision floating point numbers. The trade off is reduced precision, this can cause some unexpected behaviors. For example an optimizer may keep iterating forever if it is trying to optimize down to a precision below what the float32 will track. Typically, numbers with float32 are good down to 6 places and AstroPhot by default only attempts to minimize the Chi^2 to 3 places. However, to ensure the fit is secure to 3 places it often checks what is happenening down at 4 or 5 places. Hence, issues can arise. For the most part you can go ahead with float32 and if you run into a weird bug, try on float64 before looking further." + "If you are using a GPU you can get significant performance increases in both memory and speed by switching from double precision (float64, the AstroPhot default) to single precision (float32) floating point numbers. The trade off is reduced precision, this can cause some unexpected behaviors. For example an optimizer may keep iterating forever if it is trying to optimize down to a precision below what the float32 will track. Typically, numbers with float32 are good down to 6 places and AstroPhot by default only attempts to minimize the Chi^2 to 3 places. However, to ensure the fit is secure to 3 places it often checks what is happening down at 4 or 5 places. Hence, issues can arise. For the most part you can go ahead with float32 and if you run into a weird bug, try on float64 before looking further.\n", + "\n", + "JAX has a global automatic type, so its not always a good idea to try and specify the type. By default, AstroPhot enables the ``jax.config.update(\"jax_enable_x64\", True)`` option so JAX will automatically use float64. You can switch this flag in the JAX config if you's like to use float32. That said, it is still possible to use the global AstroPhot config to set the data type." ] }, { diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index bd710a35..ddfe854b 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -10,6 +10,7 @@ version of each tutorial is available here. :maxdepth: 1 GettingStarted + GettingStartedJAX GroupModels FittingMethods ModelZoo diff --git a/tests/test_fit.py b/tests/test_fit.py index 80ccbe63..529c1b2c 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -161,6 +161,8 @@ def test_hessian(sersic_model): def test_gradient(sersic_model): + if ap.backend.backend == "jax": + pytest.skip("JAX backend does not support backward function") model = sersic_model target = model.target target.weight = 1 / (10 + target.variance.T) From ad18c2fb6b2313c96239d41b0e764bdfb34e6659 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 16 Aug 2025 21:15:50 -0400 Subject: [PATCH 125/191] run jax tests in CI --- .github/workflows/coverage.yaml | 12 ++++- astrophot/backend_obj.py | 24 ++++++++-- astrophot/fit/__init__.py | 4 +- astrophot/fit/lm.py | 10 +++- astrophot/image/func/wcs.py | 2 +- astrophot/image/mixins/sip_mixin.py | 2 +- astrophot/image/sip_image.py | 6 +-- astrophot/models/func/__init__.py | 1 + astrophot/models/func/integration.py | 41 ++++++++++++++-- astrophot/models/func/spline.py | 20 +++++--- astrophot/models/gaussian_ellipsoid.py | 4 +- astrophot/models/group_model_object.py | 3 +- astrophot/models/mixins/sample.py | 48 ++++++++++++------- astrophot/models/multi_gaussian_expansion.py | 11 +++-- astrophot/plots/image.py | 4 +- docs/source/tutorials/GettingStarted.ipynb | 4 +- docs/source/tutorials/GettingStartedJAX.ipynb | 2 +- tests/test_model.py | 12 +++++ tests/test_utils.py | 45 ++++++----------- 19 files changed, 173 insertions(+), 82 deletions(-) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 687150e3..4cc09a03 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -49,7 +49,17 @@ jobs: shell: bash - name: Test with pytest run: | - pytest -vvv --cov=${{ env.PROJECT_NAME }} --cov-report=xml --cov-report=term tests/ + coverage run --source=${{ env.PROJECT_NAME }} -m pytest tests/ + shell: bash + - name: Extra coverage report for jax checks + run: | + echo "Running extra coverage report for jax checks" + pip install jax jaxlib + coverage run --append --source=${{ env.PROJECT_NAME }} -m pytest tests/ + shell: bash + env: + JAX_ENABLE_X64: True + CASKADE_BACKEND: jax - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index 848abbe0..3b294ffd 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -80,6 +80,7 @@ def setup_torch(self): self.lgamma = self._lgamma_torch self.hessian = self._hessian_torch self.jacobian = self._jacobian_torch + self.jacfwd = self._jacfwd_torch self.grad = self._grad_torch self.long = self._long_torch self.fill_at_indices = self._fill_at_indices_torch @@ -122,6 +123,7 @@ def setup_jax(self): self.lgamma = self._lgamma_jax self.hessian = self._hessian_jax self.jacobian = self._jacobian_jax + self.jacfwd = self._jacfwd_jax self.grad = self._grad_jax self.long = self._long_jax self.fill_at_indices = self._fill_at_indices_jax @@ -243,6 +245,7 @@ def _pad_torch(self, array, padding, mode): def _pad_jax(self, array, padding, mode): if mode == "replicate": mode = "edge" + padding = np.array(padding).reshape(-1, 2) return self.module.pad(array, padding, mode=mode) def _roll_torch(self, array, shifts, dims): @@ -304,7 +307,7 @@ def _bessel_j1_torch(self, array): return self.module.special.bessel_j1(array) def _bessel_j1_jax(self, array): - return self.jax.scipy.special.bessel_jn(array, 1) + return self.jax.scipy.special.bessel_jn(array, v=1) def _bessel_k1_torch(self, array): return self.module.special.modified_bessel_k1(array) @@ -331,15 +334,31 @@ def _jacobian_torch(self, func, x, strategy="forward-mode", vectorize=True, crea def _jacobian_jax(self, func, x, strategy="forward-mode", vectorize=True, create_graph=False): if "forward" in strategy: + # n = x.size + # eye = self.module.eye(n) + # Jt = self.jax.vmap(lambda s: self.jax.jvp(func, (x,), (s,))[1])(eye) + # return self.module.moveaxis(Jt, 0, -1) return self.jax.jacfwd(func)(x) return self.jax.jacrev(func)(x) + def _jacfwd_torch(self, func): + return self.module.func.jacfwd(func) + + def _jacfwd_jax(self, func): + return self.jax.jacfwd(func) + def _hessian_torch(self, func): return self.module.func.hessian(func) def _hessian_jax(self, func): return self.jax.hessian(func) + def _vmap_torch(self, *args, **kwargs): + return self.module.vmap(*args, **kwargs) + + def _vmap_jax(self, *args, **kwargs): + return self.jax.vmap(*args, **kwargs) + def _fill_at_indices_torch(self, array, indices, values): array[indices] = values return array @@ -475,9 +494,6 @@ def where(self, condition, x, y): def allclose(self, a, b, rtol=1e-5, atol=1e-8): return self.module.allclose(a, b, rtol=rtol, atol=atol) - def vmap(self, *args, **kwargs): - return self.module.vmap(*args, **kwargs) - @property def linalg(self): return self.module.linalg diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 70998cfd..f4ca342c 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,4 +1,4 @@ -from .lm import LM +from .lm import LM, LMfast from .gradient import Grad, Slalom from .iterative import Iter from .scipy_fit import ScipyFit @@ -7,4 +7,4 @@ from .mhmcmc import MHMCMC from . import func -__all__ = ["LM", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC", "Slalom", "func"] +__all__ = ["LM", "LMfast", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC", "Slalom", "func"] diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index dd2748c8..1b895275 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -11,7 +11,7 @@ from ..errors import OptimizeStopFail, OptimizeStopSuccess from ..param import ValidContext -__all__ = ("LM",) +__all__ = ("LM", "LMfast") class LM(BaseOptimizer): @@ -382,3 +382,11 @@ def update_uncertainty(self) -> None: config.logger.warning( "Unable to update uncertainty due to non finite covariance matrix" ) + + +class LMfast(LM): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.jacobian = backend.jacfwd( + lambda x: self.model(window=self.fit_window, params=x).flatten("data")[self.mask] + ) diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 70547b3a..2f531843 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -109,7 +109,7 @@ def sip_coefs(order): def sip_matrix(u, v, order): M = backend.zeros((len(u), (order + 1) * (order + 2) // 2), dtype=u.dtype, device=u.device) for i, (p, q) in enumerate(sip_coefs(order)): - M[:, i] = u**p * v**q + M = backend.fill_at_indices(M, (slice(None), i), u**p * v**q) return M diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 4b1f6d38..0acc0457 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -153,7 +153,7 @@ def update_distortion_model( + x[:-1, :-1] * y[1:, :-1] ) ) - self._pixel_area_map = A.abs() + self._pixel_area_map = backend.abs(A) def to(self, dtype=None, device=None): if dtype is None: diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index 52cbf8ed..c90cd45a 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -125,17 +125,17 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> SIPModelImag new_area_map = ( backend.pad( new_area_map[None, None], - (pad, pad, pad, pad), + (0, 0, 0, 0, pad, pad, pad, pad), mode="replicate", ) .squeeze(0) .squeeze(0) ) new_distortion_ij = backend.pad( - new_distortion_ij[:, None], (pad, pad, pad, pad), mode="replicate" + new_distortion_ij[:, None], (0, 0, 0, 0, pad, pad, pad, pad), mode="replicate" ).squeeze(1) new_distortion_IJ = backend.pad( - new_distortion_IJ[:, None], (pad, pad, pad, pad), mode="replicate" + new_distortion_IJ[:, None], (0, 0, 0, 0, pad, pad, pad, pad), mode="replicate" ).squeeze(1) kwargs = { "pixel_area_map": new_area_map, diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 63527b31..2d5cb3b8 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -8,6 +8,7 @@ single_quad_integrate, recursive_quad_integrate, upsample, + bright_integrate, recursive_bright_integrate, ) from .convolution import ( diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index c06343d2..2c6523e1 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -88,7 +88,7 @@ def recursive_quad_integrate( integral_flat = z.flatten() - si, sj = upsample(i.flatten()[select], j.flatten()[select], quad_order, scale) + si, sj = upsample(i.flatten()[select], j.flatten()[select], gridding, scale) integral_flat = backend.fill_at_indices( integral_flat, @@ -112,6 +112,41 @@ def recursive_quad_integrate( return integral_flat.reshape(z.shape) +def bright_integrate( + z: ArrayLike, + i: ArrayLike, + j: ArrayLike, + brightness_ij: callable, + bright_frac: float, + scale: float = 1.0, + quad_order: int = 3, + gridding: int = 5, + max_depth: int = 2, +): + # Work in progress, somehow this is slower + trace = [] + for d in range(max_depth): + N = max(1, int(np.prod(z.shape) * bright_frac)) + z_flat = z.flatten() + select = backend.topk(z_flat, N)[1] + trace.append([z_flat, select, z.shape]) + if d > 0: + i, j = upsample(i.flatten()[select], j.flatten()[select], gridding, scale) + scale = scale / gridding + else: + i, j = i.flatten()[select].reshape(-1, 1), j.flatten()[select].reshape(-1, 1) + z, _ = single_quad_integrate(i, j, brightness_ij, scale, quad_order) + trace.append([z, None, z.shape]) + + for _ in reversed(range(1, max_depth + 1)): + T = trace.pop(-1) + trace[-1][0] = backend.fill_at_indices( + trace[-1][0], trace[-1][1], backend.mean(T[0].reshape(T[2]), dim=-1) + ) + + return trace[0][0].reshape(trace[0][2]) + + def recursive_bright_integrate( i: ArrayLike, j: ArrayLike, @@ -124,7 +159,7 @@ def recursive_bright_integrate( max_depth: int = 1, ) -> ArrayLike: z, _ = single_quad_integrate(i, j, brightness_ij, scale, quad_order) - + print(z.shape) if _current_depth >= max_depth: return z @@ -133,7 +168,7 @@ def recursive_bright_integrate( select = backend.topk(z_flat, N)[1] - si, sj = upsample(i.flatten()[select], j.flatten()[select], quad_order, scale) + si, sj = upsample(i.flatten()[select], j.flatten()[select], gridding, scale) z_flat = backend.fill_at_indices( z_flat, diff --git a/astrophot/models/func/spline.py b/astrophot/models/func/spline.py index 3ebe5d19..0fdb344b 100644 --- a/astrophot/models/func/spline.py +++ b/astrophot/models/func/spline.py @@ -1,4 +1,5 @@ from ...backend_obj import backend, ArrayLike +from ... import config def _h_poly(t: ArrayLike) -> ArrayLike: @@ -13,11 +14,16 @@ def _h_poly(t: ArrayLike) -> ArrayLike: """ - tt = t[None, :] ** (backend.arange(4, device=t.device)[:, None]) + tt = t[None, :] ** (backend.arange(4, device=config.DEVICE)[:, None]) A = backend.as_array( - [[1, 0, -3, 2], [0, 1, -2, 1], [0, 0, 3, -2], [0, 0, -1, 1]], - dtype=t.dtype, - device=t.device, + [ + [1.0, 0.0, -3.0, 2.0], + [0.0, 1.0, -2.0, 1.0], + [0.0, 0.0, 3.0, -2.0], + [0.0, 0.0, -1.0, 1.0], + ], + dtype=config.DTYPE, + device=config.DEVICE, ) return A @ tt @@ -33,7 +39,7 @@ def cubic_spline_torch(x: ArrayLike, y: ArrayLike, xs: ArrayLike) -> ArrayLike: the cubic spline function should be evaluated. """ m = (y[1:] - y[:-1]) / (x[1:] - x[:-1]) - m = backend.concatenate([m[[0]], (m[1:] + m[:-1]) / 2, m[[-1]]]) + m = backend.concatenate([m[0].flatten(), (m[1:] + m[:-1]) / 2, m[-1].flatten()]) idxs = backend.searchsorted(x[:-1], xs) - 1 dx = x[idxs + 1] - x[idxs] hh = _h_poly((xs - x[idxs]) / dx) @@ -53,9 +59,9 @@ def spline(R: ArrayLike, profR: ArrayLike, profI: ArrayLike, extend: str = "zero """ I = cubic_spline_torch(profR, profI, R.flatten()).reshape(*R.shape) if extend == "zeros": - I[R > profR[-1]] = 0 + backend.fill_at_indices(I, R > profR[-1], 0) elif extend == "const": - I[R > profR[-1]] = profI[-1] + backend.fill_at_indices(I, R > profR[-1], profI[-1]) else: raise ValueError(f"Unknown extend option: {extend}. Use 'zeros' or 'const'.") return I diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index 250e52f8..b11fe939 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -130,6 +130,6 @@ def brightness( v = backend.stack(self.transform_coordinates(x, y), dim=0).reshape(2, -1) return ( flux - * backend.exp(-0.5 * (v * (inv_Sigma @ v)).sum(dim=0)) - / (2 * np.pi * backend.linalg.det(Sigma2D).sqrt()) + * backend.sum(backend.exp(-0.5 * (v * (inv_Sigma @ v))), dim=0) + / (2 * np.pi * backend.sqrt(backend.linalg.det(Sigma2D))) ).reshape(x.shape) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 3e41ff12..8b65906d 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -120,7 +120,7 @@ def fit_mask(self) -> torch.Tensor: """ subtarget = self.target[self.window] if isinstance(subtarget, ImageList): - mask = tuple(backend.ones_like(submask) for submask in subtarget.mask) + mask = list(backend.ones_like(submask) for submask in subtarget.mask) for model in self.models: model_subtarget = model.target[model.window] model_fit_mask = model.fit_mask() @@ -139,6 +139,7 @@ def fit_mask(self) -> torch.Tensor: mask[index] = backend.and_at_indices( mask[index], group_indices, model_fit_mask[model_indices] ) + mask = tuple(mask) else: mask = backend.ones_like(subtarget.mask) for model in self.models: diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index e1ea15b8..dd5861e9 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -58,24 +58,36 @@ class SampleMixin: @forward def _bright_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: i, j = image.pixel_center_meshgrid() - N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) - sample_flat = sample.flatten() - select = backend.topk(sample_flat, N)[1] - sample_flat = backend.fill_at_indices( - sample_flat, - select, - func.recursive_bright_integrate( - i.flatten()[select], - j.flatten()[select], - lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), - scale=image.base_scale, - bright_frac=self.integrate_fraction, - quad_order=self.integrate_quad_order, - gridding=self.integrate_gridding, - max_depth=self.integrate_max_depth, - ), + sample = func.bright_integrate( + sample, + i, + j, + lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), + scale=image.base_scale, + bright_frac=self.integrate_fraction, + quad_order=self.integrate_quad_order, + gridding=self.integrate_gridding, + max_depth=self.integrate_max_depth, ) - return sample_flat.reshape(sample.shape) + return sample + # N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) + # sample_flat = sample.flatten() + # select = backend.topk(sample_flat, N)[1] + # sample_flat = backend.fill_at_indices( + # sample_flat, + # select, + # func.recursive_bright_integrate( + # i.flatten()[select], + # j.flatten()[select], + # lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), + # scale=image.base_scale, + # bright_frac=self.integrate_fraction, + # quad_order=self.integrate_quad_order, + # gridding=self.integrate_gridding, + # max_depth=self.integrate_max_depth, + # ), + # ) + # return sample_flat.reshape(sample.shape) @forward def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: @@ -89,7 +101,7 @@ def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: kernel.reshape(1, 1, *kernel.shape), padding="valid", ), - (1, 1, 1, 1), + (0, 0, 0, 0, 1, 1, 1, 1), mode="replicate", ) ) diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 29dd8e8c..9d78262c 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -5,6 +5,7 @@ from .model_object import ComponentModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from . import func +from .. import config from ..backend_obj import backend, ArrayLike from ..param import forward @@ -103,10 +104,14 @@ def transform_coordinates( self, x: ArrayLike, y: ArrayLike, q: ArrayLike, PA: ArrayLike ) -> Tuple[ArrayLike, ArrayLike]: x, y = super().transform_coordinates(x, y) - if PA.numel() == 1: + if np.prod(PA.shape) == 1: x, y = func.rotate(-(PA + np.pi / 2), x, y) - x = x.repeat(q.shape[0], *[1] * x.ndim) - y = y.repeat(q.shape[0], *[1] * y.ndim) + x = x * backend.ones( + q.shape[0], *[1] * x.ndim, dtype=config.DTYPE, device=config.DEVICE + ) + y = y * backend.ones( + q.shape[0], *[1] * y.ndim, dtype=config.DTYPE, device=config.DEVICE + ) else: x, y = backend.vmap(lambda pa: func.rotate(-(pa + np.pi / 2), x, y))(PA) y = backend.vmap(lambda q, y: y / q)(q, y) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index f3bb4f97..cd78879c 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -366,9 +366,9 @@ def residual_image( elif isinstance(normalize_residuals, backend.array_type): residuals = residuals / backend.sqrt(normalize_residuals) normalize_residuals = True - if target.has_mask: - residuals[target.mask] = np.nan residuals = backend.to_numpy(residuals) + if target.has_mask: + residuals[backend.to_numpy(target.mask)] = np.nan if scaling == "clip": if normalize_residuals is not True: diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index daa4188c..91632848 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -150,7 +150,7 @@ "outputs": [], "source": [ "# Now that the model has been set up with a target and initialized with parameter values, it is time to fit the image\n", - "result = ap.fit.LM(model2, verbose=1).fit()\n", + "result = ap.fit.LMfast(model2, verbose=1).fit()\n", "\n", "# See that we use ap.fit.LM, this is the Levenberg-Marquardt Chi^2 minimization method, it is the recommended technique\n", "# for most least-squares problems. See the Fitting Methods tutorial for more on fitters!\n", @@ -293,7 +293,7 @@ "outputs": [], "source": [ "model3.initialize()\n", - "result = ap.fit.LM(model3, verbose=1).fit()" + "result = ap.fit.LMfast(model3, verbose=1).fit()" ] }, { diff --git a/docs/source/tutorials/GettingStartedJAX.ipynb b/docs/source/tutorials/GettingStartedJAX.ipynb index 68662af0..faf94f87 100644 --- a/docs/source/tutorials/GettingStartedJAX.ipynb +++ b/docs/source/tutorials/GettingStartedJAX.ipynb @@ -8,7 +8,7 @@ "\n", "In this notebook we will run through the same \"getting started\" tutorial, except this time using JAX!\n", "\n", - "You'll notice right away that basically everything is the same. The only difference is that now all the data and parameters are stored as JAX numpy arrays. So if that's how you prefer to interact with AstroPhot then forge on! AstroPhot should integrate with a JAX workflow very easily. One note though, JAX has a reputation for being fast, this is true of JIT compiled JAX but not necessarily \"eager\" JAX where we simply define functions and evaluate them. This is the mode that AstroPhot mostly works in since it is so dynamic in the number of options it has and the freedom users have to change them. For this reason, you will find that AstroPhot is often faster in PyTorch than JAX. It's still fast either way, in a future update we may implement some JAX speed optimizations." + "You'll notice right away that basically everything is the same. The only difference is that now all the data and parameters are stored as ``jax.numpy`` arrays. So if that's how you prefer to interact with AstroPhot then forge on! AstroPhot should integrate with a JAX workflow very easily. If you want to treat AstroPhot in a functional way, then simply build the model you want then use ``f = lambda x: model(x).data`` and now ``f(x)`` returns the model image and you can do all the usual, vmap, autograd, etc stuff of JAX on this. Similarly, making ``l = lambda x: model.gaussian_log_likelihood(x)`` will return a scalar log likelihood function (Poisson also works). One note though, JAX has a reputation for being fast, this is true of JIT compiled JAX but not necessarily \"eager\" JAX where we simply define functions and evaluate them. This is the mode that AstroPhot mostly works in since it is so dynamic in the number of options it has and the freedom users have to change them. For this reason, you will find that AstroPhot is faster in PyTorch than JAX (uncompiled). For now we provide this API so JAX users can take advantage of AstroPhot in their workflow. So long as you work in a JAX-oriented way (JIT compile before expecting anything to be fast) then everything should work well and fast. There are only a handful of AstroPhot models that don't work yet in JAX (notably the isothermal edgeon galaxy model since JAX doesn't have the K1 Bessel function)." ] }, { diff --git a/tests/test_model.py b/tests/test_model.py index 5cc7f02b..e395c483 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -117,12 +117,24 @@ def test_model_errors(): ) def test_all_model_sample(model_type): + if model_type == "isothermal sech2 edgeon model" and ap.backend.backend == "jax": + pytest.skip("JAX doesnt have bessel function k1 yet") + + if ( + model_type in ["ferrer warp galaxy model", "king warp galaxy model"] + and ap.backend.backend == "jax" + ): + pytest.skip("JAX version doesnt support these models yet, difficulty with gradients") + target = make_basic_sersic() target.zeropoint = 22.5 MODEL = ap.Model( name="test model", model_type=model_type, target=target, + integrate_mode=( + "none" if ap.backend.backend == "jax" else "bright" + ), # JAX JIT is reallly slow for any integration ) MODEL.initialize() MODEL.to() diff --git a/tests/test_utils.py b/tests/test_utils.py index 20571f74..79c1c43a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -120,50 +120,35 @@ def test_conversion_functions(): # sersic I0 to flux - torch tv = ap.backend.as_array([[1.0]], dtype=ap.backend.float64) assert ap.backend.allclose( - ap.backend.round( - ap.utils.conversions.functions.sersic_I0_to_flux_np(tv, tv, tv, tv), - decimals=7, - ), - ap.backend.round(ap.backend.as_array([[2 * np.pi * gamma(2)]]), decimals=7), + ap.utils.conversions.functions.sersic_I0_to_flux_np(tv, tv, tv, tv), + ap.backend.as_array([[2 * np.pi * gamma(2)]]), + rtol=1e-7, ), "Error converting sersic central intensity to flux (torch)" # sersic flux to I0 - torch assert ap.backend.allclose( - ap.backend.round( - ap.utils.conversions.functions.sersic_flux_to_I0_np(tv, tv, tv, tv), - decimals=7, - ), - ap.backend.round(ap.backend.as_array([[1.0 / (2 * np.pi * gamma(2))]]), decimals=7), + ap.utils.conversions.functions.sersic_flux_to_I0_np(tv, tv, tv, tv), + ap.backend.as_array([[1.0 / (2 * np.pi * gamma(2))]]), + rtol=1e-7, ), "Error converting sersic flux to central intensity (torch)" # sersic Ie to flux - torch assert ap.backend.allclose( - ap.backend.round( - ap.utils.conversions.functions.sersic_Ie_to_flux_np(tv, tv, tv, tv), - decimals=7, - ), - ap.backend.round( - ap.backend.as_array([[2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2)]]), - decimals=7, - ), + ap.utils.conversions.functions.sersic_Ie_to_flux_np(tv, tv, tv, tv), + ap.backend.as_array([[2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2)]]), + rtol=1e-7, ), "Error converting sersic effective intensity to flux (torch)" # sersic flux to Ie - torch assert ap.backend.allclose( - ap.backend.round( - ap.utils.conversions.functions.sersic_flux_to_Ie_np(tv, tv, tv, tv), - decimals=7, - ), - ap.backend.round( - ap.backend.as_array( - [[1 / (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2))]] - ), - decimals=7, - ), + ap.utils.conversions.functions.sersic_flux_to_Ie_np(tv, tv, tv, tv), + ap.backend.as_array([[1 / (2 * np.pi * gamma(2) * np.exp(sersic_n) * sersic_n ** (-2))]]), + rtol=1e-7, ), "Error converting sersic flux to effective intensity (torch)" # inverse sersic - torch assert ap.backend.allclose( - ap.backend.round(ap.utils.conversions.functions.sersic_inv_np(tv, tv, tv, tv), decimals=7), - ap.backend.round(ap.backend.as_array([[1.0 - (1.0 / sersic_n) * np.log(1.0)]]), decimals=7), + ap.utils.conversions.functions.sersic_inv_np(tv, tv, tv, tv), + ap.backend.as_array([[1.0 - (1.0 / sersic_n) * np.log(1.0)]]), + rtol=1e-7, ), "Error computing inverse sersic function (torch)" From 1f0120048cad214e1d364634625a7025db4a2167 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Sat, 16 Aug 2025 22:09:46 -0400 Subject: [PATCH 126/191] Get pytorch tests to pass --- astrophot/backend_obj.py | 8 +++++--- astrophot/models/multi_gaussian_expansion.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index 3b294ffd..98389f7b 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -82,6 +82,7 @@ def setup_torch(self): self.jacobian = self._jacobian_torch self.jacfwd = self._jacfwd_torch self.grad = self._grad_torch + self.vmap = self._vmap_torch self.long = self._long_torch self.fill_at_indices = self._fill_at_indices_torch self.add_at_indices = self._add_at_indices_torch @@ -125,6 +126,7 @@ def setup_jax(self): self.jacobian = self._jacobian_jax self.jacfwd = self._jacfwd_jax self.grad = self._grad_jax + self.vmap = self._vmap_jax self.long = self._long_jax self.fill_at_indices = self._fill_at_indices_jax self.add_at_indices = self._add_at_indices_jax @@ -240,7 +242,7 @@ def _upsample2d_jax(self, array, scale_factor, method): return self.jax.image.resize(array, new_shape, method=method) def _pad_torch(self, array, padding, mode): - return self.module.nn.functional.pad(array, padding, mode=mode) + return self.module.nn.functional.pad(array, padding[-4:], mode=mode) def _pad_jax(self, array, padding, mode): if mode == "replicate": @@ -289,10 +291,10 @@ def _sum_torch(self, array, dim=None): return self.module.sum(array, dim=dim) def _sum_jax(self, array, dim=None): - return self.jax.numpy.sum(array, axis=dim) + return self.module.sum(array, axis=dim) def _max_torch(self, array, dim=None): - return self.module.max(array, dim=dim).values + return array.amax(dim=dim) def _max_jax(self, array, dim=None): return self.module.max(array, axis=dim) diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 9d78262c..b6097363 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -107,10 +107,10 @@ def transform_coordinates( if np.prod(PA.shape) == 1: x, y = func.rotate(-(PA + np.pi / 2), x, y) x = x * backend.ones( - q.shape[0], *[1] * x.ndim, dtype=config.DTYPE, device=config.DEVICE + (q.shape[0], *[1] * x.ndim), dtype=config.DTYPE, device=config.DEVICE ) y = y * backend.ones( - q.shape[0], *[1] * y.ndim, dtype=config.DTYPE, device=config.DEVICE + (q.shape[0], *[1] * y.ndim), dtype=config.DTYPE, device=config.DEVICE ) else: x, y = backend.vmap(lambda pa: func.rotate(-(pa + np.pi / 2), x, y))(PA) From 8247609d859bbb7aabfa6f1a8f771b91f0a9ea2b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:16:53 +0000 Subject: [PATCH 127/191] Bump actions/checkout from 4 to 5 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cd.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index b408cd4e..1ae8515a 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 From 12468822af640fee95723835e4d09e6d67971098 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 18 Aug 2025 09:55:12 -0400 Subject: [PATCH 128/191] ensure jax requirement --- .github/workflows/coverage.yaml | 1 - .github/workflows/testing.yaml | 1 + docs/requirements.txt | 1 + pyproject.toml | 2 +- 4 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 4cc09a03..b9677ef4 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -54,7 +54,6 @@ jobs: - name: Extra coverage report for jax checks run: | echo "Running extra coverage report for jax checks" - pip install jax jaxlib coverage run --append --source=${{ env.PROJECT_NAME }} -m pytest tests/ shell: bash env: diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index e7b76de7..bb528638 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -35,6 +35,7 @@ jobs: python -m pip install --upgrade pip pip install pytest pip install wheel + pip install jax if [ -f requirements.txt ]; then pip install -r requirements.txt; fi shell: bash - name: Install AstroPhot diff --git a/docs/requirements.txt b/docs/requirements.txt index 527e75ac..3d303810 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,6 +2,7 @@ caustics emcee graphviz ipywidgets +jax jupyter-book matplotlib nbformat diff --git a/pyproject.toml b/pyproject.toml index 4d8ff2d5..c86d8db5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "jax"] [project.scripts] astrophot = "astrophot:run_from_terminal" From 2ec24f52e0b06f45c79ecf2fdb3f68a839e9bbae Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 18 Aug 2025 10:42:59 -0400 Subject: [PATCH 129/191] ensure backend is torch after notebooks --- astrophot/backend_obj.py | 2 +- docs/source/tutorials/AdvancedPSFModels.py | 284 ++++++++++++++++ docs/source/tutorials/ImageAlignment.py | 191 +++++++++++ docs/source/tutorials/JointModels.py | 371 +++++++++++++++++++++ tests/test_notebooks.py | 4 + tests/test_psfmodel.py | 4 + 6 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 docs/source/tutorials/AdvancedPSFModels.py create mode 100644 docs/source/tutorials/ImageAlignment.py create mode 100644 docs/source/tutorials/JointModels.py diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index 98389f7b..3574aab3 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -309,7 +309,7 @@ def _bessel_j1_torch(self, array): return self.module.special.bessel_j1(array) def _bessel_j1_jax(self, array): - return self.jax.scipy.special.bessel_jn(array, v=1) + return self.jax.scipy.special.bessel_jn(array, v=1)[-1] def _bessel_k1_torch(self, array): return self.module.special.modified_bessel_k1(array) diff --git a/docs/source/tutorials/AdvancedPSFModels.py b/docs/source/tutorials/AdvancedPSFModels.py new file mode 100644 index 00000000..891593f4 --- /dev/null +++ b/docs/source/tutorials/AdvancedPSFModels.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python +# coding: utf-8 + +# # Advanced PSF modeling +# +# Ideally we always have plenty of well separated bright, but not oversaturated, stars to use to construct a PSF model. These models are incredibly important for certain science objectives that rely on precise shape measurements and not just total light measures. Here we demonstrate some of the special capabilities AstroPhot has to handle challenging scenarios where a good PSF model is needed but there are only very faint stars, poorly placed stars, or even no stars to work with! + +# In[ ]: + + +import astrophot as ap +import numpy as np +import torch +import matplotlib.pyplot as plt + + +# ## Making a PSF model +# +# Before we can optimize a PSF model, we need to make the model and get some starting parameters. If you already have a good guess at some starting parameters then you can just enter them yourself, however if you don't then AstroPhot provides another option; if you have an empirical PSF estimate (a stack of a few stars from the field), then you can have a PSF model initialize itself on the empirical PSF just like how other AstroPhot models can initialize themselves on target images. Let's see how that works! + +# In[ ]: + + +# First make a mock empirical PSF image +np.random.seed(124) +psf = ap.utils.initialize.moffat_psf(2.0, 3.0, 101, 0.5) +variance = psf**2 / 100 +psf += np.random.normal(scale=np.sqrt(variance)) + +psf_target = ap.PSFImage( + data=psf, + pixelscale=0.5, + variance=variance, +) + +# To ensure the PSF has a normalized flux of 1, we call +psf_target.normalize() + +fig, ax = plt.subplots() +ap.plots.psf_image(fig, ax, psf_target) +ax.set_title("mock empirical PSF") +plt.show() + + +# In[ ]: + + +# Now we initialize on the image +psf_model = ap.Model( + name="init psf", + model_type="moffat psf model", + target=psf_target, +) + +psf_model.initialize() + +# PSF model can be fit to it's own target for good initial values +# Note we provide the weight map (1/variance) since a PSF_Image can't store that information. +ap.fit.LM(psf_model, verbose=1).fit() + +fig, ax = plt.subplots(1, 2, figsize=(13, 5)) +ap.plots.psf_image(fig, ax[0], psf_model) +ax[0].set_title("PSF model fit to mock empirical PSF") +ap.plots.residual_image(fig, ax[1], psf_model, normalize_residuals=True) +ax[1].set_title("residuals") +plt.show() + + +# That's pretty good! it doesn't need to be perfect, so this is already in the right ballpark, just based on the size of the main light concentration. For the examples below, we will just start with some simple given initial parameters, but for real analysis this is quite handy. + +# ## Group PSF Model +# +# Just like group models for regular models, it is possible to make a `psf group model` to combine multiple psf models. + +# In[ ]: + + +psf_model1 = ap.Model( + name="psf1", + model_type="moffat psf model", + n=2, + Rd=10, + I0=20, # essentially controls relative flux of this component + normalize_psf=False, # sub components shouldnt be individually normalized + target=psf_target, +) +psf_model2 = ap.Model( + name="psf2", + model_type="sersic psf model", + n=4, + Re=5, + Ie=1, + normalize_psf=False, + target=psf_target, +) +psf_group_model = ap.Model( + name="psf group", + model_type="psf group model", + target=psf_target, + models=[psf_model1, psf_model2], + normalize_psf=True, # group model should normalize the combined PSF +) +psf_group_model.initialize() +fig, ax = plt.subplots(1, 3, figsize=(15, 5)) +ap.plots.psf_image(fig, ax[0], psf_group_model) +ax[0].set_title("PSF group model with two PSF models") +ap.plots.psf_image(fig, ax[1], psf_group_model.models[0]) +ax[1].set_title("PSF model component 1") +ap.plots.psf_image(fig, ax[2], psf_group_model.models[1]) +ax[2].set_title("PSF model component 2") +plt.show() + + +# ## PSF modeling without stars +# +# Can it be done? Let's see! + +# In[ ]: + + +# Lets make some data that we need to fit +psf_target = ap.PSFImage( + data=np.zeros((51, 51)), + pixelscale=1.0, +) + +true_psf_model = ap.Model( + name="true psf", + model_type="moffat psf model", + target=psf_target, + n=2, + Rd=3, +) +true_psf = true_psf_model().data + +target = ap.TargetImage( + data=torch.zeros(100, 100), + pixelscale=1.0, + psf=true_psf, +) + +true_model = ap.Model( + name="true model", + model_type="sersic galaxy model", + target=target, + center=[50.0, 50.0], + q=0.4, + PA=np.pi / 3, + n=2, + Re=25, + Ie=10, + psf_convolve=True, +) + +# use the true model to make some data +sample = true_model() +torch.manual_seed(61803398) +target._data = sample.data + torch.normal(torch.zeros_like(sample.data), 0.1) +target.variance = 0.01 * torch.ones_like(sample.data.T) + +fig, ax = plt.subplots(1, 2, figsize=(16, 7)) +ap.plots.model_image(fig, ax[0], true_model) +ap.plots.target_image(fig, ax[1], target) +ax[0].set_title("true sersic+psf model") +ax[1].set_title("mock observed data") +plt.show() + + +# In[ ]: + + +# Now we will try and fit the data using just a plain sersic + +# Here we set up a sersic model for the galaxy +plain_galaxy_model = ap.Model( + name="galaxy model", + model_type="sersic galaxy model", + target=target, +) + +# Let AstroPhot determine its own initial parameters, so it has to start with whatever it decides automatically, +# just like a real fit. +plain_galaxy_model.initialize() + +result = ap.fit.LM(plain_galaxy_model, verbose=1).fit() +print(result.message) + + +# In[ ]: + + +# The shape of the residuals here shows that there is still missing information; this is of course +# from the missing PSF convolution to blur the model. In fact, the shape of those residuals is very +# commonly seen in real observed data (ground based) when it is fit without accounting for PSF blurring. +fig, ax = plt.subplots(1, 2, figsize=(16, 7)) +ap.plots.model_image(fig, ax[0], plain_galaxy_model) +ap.plots.residual_image(fig, ax[1], plain_galaxy_model) +ax[0].set_title("fitted sersic only model") +ax[1].set_title("residuals") +plt.show() + + +# In[ ]: + + +# Now we will try and fit the data with a sersic model and a "live" psf + +# Here we create a target psf model which will determine the specs of our live psf model +psf_target = ap.PSFImage( + data=np.zeros((51, 51)), + pixelscale=target.pixelscale, +) + +live_psf_model = ap.Model( + name="psf", + model_type="moffat psf model", + target=psf_target, + n=1.0, # True value is 2. + Rd=2.0, # True value is 3. +) + +# Here we set up a sersic model for the galaxy +live_galaxy_model = ap.Model( + name="galaxy model", + model_type="sersic galaxy model", + target=target, + psf_convolve=True, + psf=live_psf_model, # Here we bind the PSF model to the galaxy model, this will add the psf_model parameters to the galaxy_model +) +live_galaxy_model.initialize() + +result = ap.fit.LM(live_galaxy_model, verbose=3).fit() + + +# In[ ]: + + +print( + f"fitted n for moffat PSF: {live_psf_model.n.value.item():.6f} +- {live_psf_model.n.uncertainty.item():.6f} we were hoping to get 2!" +) +print( + f"fitted Rd for moffat PSF: {live_psf_model.Rd.value.item():.6f} +- {live_psf_model.Rd.uncertainty.item():.6f} we were hoping to get 3!" +) +fig, ax = ap.plots.covariance_matrix( + result.covariance_matrix.detach().cpu().numpy(), + live_galaxy_model.build_params_array().detach().cpu().numpy(), + live_galaxy_model.build_params_array_names(), +) +plt.show() + + +# This is truly remarkable! With no stars available we were still able to extract an accurate PSF from the image! To be fair, this example is essentially perfect for this kind of fitting and we knew the true model types (sersic and moffat) from the start. Still, this is a powerful capability in certain scenarios. For many applications (e.g. weak lensing) it is essential to get the absolute best PSF model possible. Here we have shown that not only stars, but galaxies in the field can be useful tools for measuring the PSF! + +# In[ ]: + + +fig, ax = plt.subplots(1, 2, figsize=(16, 7)) +ap.plots.model_image(fig, ax[0], live_galaxy_model) +ap.plots.residual_image(fig, ax[1], live_galaxy_model) +ax[0].set_title("fitted sersic + psf model") +ax[1].set_title("residuals") +plt.show() + + +# There are regions of parameter space that are degenerate and so even in this idealized scenario the PSF model can get stuck. If you rerun the notebook with different random number seeds for pytorch you may find some where the optimizer "fails by immobility" this is when it gets stuck in the parameter space and can't find any way to improve the likelihood. In fact most of these "fail" fits do return really good values for the PSF model, so keep in mind that the "fail" flag only means the possibility of a truly failed fit. Unfortunately, detecting convergence is hard. + +# ## PSF fitting for faint stars +# +# Sometimes there are stars available, but they are faint and it is hard to see how a reliable fit could be obtained. We have already seen how faint stars next to galaxies are still viable for PSF fitting. Now we will consider the case of isolated but faint stars. The trick here is that we have a second high resolution image, perhaps in a different band. To perform this fitting we will link up the two bands using joint modelling to constrain the star centers, this will constrain some of the parameters making it easier to fit a PSF model. + +# In[ ]: + + +# Coming soon + + +# ## PSF fitting for saturated stars +# +# A saturated star is a bright star, and it's just begging to be used for modelling a PSF. There's just one catch, the highest signal to noise region is completely messed up and can't be used! Traditionally these stars are either ignored, or a two stage fit is performed to get an "inner psf" and an "outer psf" which are then merged. Why not fit the inner and outer PSFs all at once! This can be done with AstroPhot using parameter constraints and masking. + +# In[ ]: + + +# Coming soon diff --git a/docs/source/tutorials/ImageAlignment.py b/docs/source/tutorials/ImageAlignment.py new file mode 100644 index 00000000..621a08e6 --- /dev/null +++ b/docs/source/tutorials/ImageAlignment.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# coding: utf-8 + +# # Aligning Images +# +# In AstroPhot, the image WCS is part of the model and so can be optimized alongside other model parameters. Here we will demonstrate a basic example of image alignment, but the sky is the limit, you can perform highly detailed image alignment with AstroPhot! + +# In[ ]: + + +import astrophot as ap +import matplotlib.pyplot as plt +import numpy as np +import torch +import socket + +socket.setdefaulttimeout(120) + + +# ## Relative shift +# +# Often the WCS solution is already really good, we just need a local shift in x and/or y to get things just right. Lets start by optimizing a translation in the WCS that improves the fit for our models! + +# In[ ]: + + +target_r = ap.TargetImage( + filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=r", + name="target_r", + variance="auto", +) +target_g = ap.TargetImage( + filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=g", + name="target_g", + variance="auto", +) + +# Uh-oh! our images are misaligned by 1 pixel, this will cause problems! +target_g.crpix = target_g.crpix + 1 + +fig, axarr = plt.subplots(1, 2, figsize=(15, 7)) +ap.plots.target_image(fig, axarr[0], target_r) +axarr[0].set_title("Target Image (r-band)") +ap.plots.target_image(fig, axarr[1], target_g) +axarr[1].set_title("Target Image (g-band)") +plt.show() + + +# In[ ]: + + +# fmt: off +# r-band model +psfr = ap.Model(name="psfr", model_type="moffat psf model", n=2, Rd=1.0, target=target_r.psf_image(data=np.zeros((51, 51)))) +star1r = ap.Model(name="star1-r", model_type="point model", window=[0, 60, 80, 135], center=[12, 9], psf=psfr, target=target_r) +star2r = ap.Model(name="star2-r", model_type="point model", window=[40, 90, 20, 70], center=[3, -7], psf=psfr, target=target_r) +star3r = ap.Model(name="star3-r", model_type="point model", window=[109, 150, 40, 90], center=[-15, -3], psf=psfr, target=target_r) +modelr = ap.Model(name="model-r", model_type="group model", models=[star1r, star2r, star3r], target=target_r) + +# g-band model +psfg = ap.Model(name="psfg", model_type="moffat psf model", n=2, Rd=1.0, target=target_g.psf_image(data=np.zeros((51, 51)))) +star1g = ap.Model(name="star1-g", model_type="point model", window=[0, 60, 80, 135], center=star1r.center, psf=psfg, target=target_g) +star2g = ap.Model(name="star2-g", model_type="point model", window=[40, 90, 20, 70], center=star2r.center, psf=psfg, target=target_g) +star3g = ap.Model(name="star3-g", model_type="point model", window=[109, 150, 40, 90], center=star3r.center, psf=psfg, target=target_g) +modelg = ap.Model(name="model-g", model_type="group model", models=[star1g, star2g, star3g], target=target_g) + +# total model +target_full = ap.TargetImageList([target_r, target_g]) +model = ap.Model(name="model", model_type="group model", models=[modelr, modelg], target=target_full) + +# fmt: on +fig, axarr = plt.subplots(1, 2, figsize=(15, 7)) +ap.plots.target_image(fig, axarr, target_full) +axarr[0].set_title("Target Image (r-band)") +axarr[1].set_title("Target Image (g-band)") +ap.plots.model_window(fig, axarr[0], modelr) +ap.plots.model_window(fig, axarr[1], modelg) +plt.show() + + +# In[ ]: + + +model.initialize() +res = ap.fit.LM(model, verbose=1).fit() +fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) +ap.plots.model_image(fig, axarr[0], model) +axarr[0, 0].set_title("Model Image (r-band)") +axarr[0, 1].set_title("Model Image (g-band)") +ap.plots.residual_image(fig, axarr[1], model) +axarr[1, 0].set_title("Residual Image (r-band)") +axarr[1, 1].set_title("Residual Image (g-band)") +plt.show() + + +# Here we see a clear signal of an image misalignment, in the g-band all of the residuals have a dipole in the same direction! Lets free up the position of the g-band image and optimize a shift. This only requires a single line of code! + +# In[ ]: + + +target_g.crtan.to_dynamic() + + +# Now we can optimize the model again, notice how it now has two more parameters. These are the x,y position of the image in the tangent plane. See the AstroPhot coordinate description on the website for more details on why this works. + +# In[ ]: + + +res = ap.fit.LM(model, verbose=1).fit() +fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) +ap.plots.model_image(fig, axarr[0], model) +axarr[0, 0].set_title("Model Image (r-band)") +axarr[0, 1].set_title("Model Image (g-band)") +ap.plots.residual_image(fig, axarr[1], model) +axarr[1, 0].set_title("Residual Image (r-band)") +axarr[1, 1].set_title("Residual Image (g-band)") +plt.show() + + +# Yay! no more dipole. The fits aren't the best, clearly these objects aren't super well described by a single moffat model. But the main goal today was to show that we could align the images very easily. Note, its probably best to start with a reasonably good WCS from the outset, and this two stage approach where we optimize the models and then optimize the models plus a shift might be more stable than just fitting everything at once from the outset. Often for more complex models it is best to start with a simpler model and fit each time you introduce more complexity. + +# ## Shift and rotation +# +# Lets say we really don't trust our WCS, we think something has gone wrong and we want freedom to fully shift and rotate the relative positions of the images relative to each other. How can we do this? + +# In[ ]: + + +def rotate(phi): + """Create a 2D rotation matrix for a given angle in radians.""" + return torch.stack( + [ + torch.stack([torch.cos(phi), -torch.sin(phi)]), + torch.stack([torch.sin(phi), torch.cos(phi)]), + ] + ) + + +# Uh-oh! Our image is misaligned by some small angle +target_g.CD = target_g.CD.value @ rotate(torch.tensor(np.pi / 32, dtype=torch.float64)) +# Uh-oh! our alignment from before has been erased +target_g.crtan.value = (0, 0) + + +# In[ ]: + + +fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) +ap.plots.model_image(fig, axarr[0], model) +axarr[0, 0].set_title("Model Image (r-band)") +axarr[0, 1].set_title("Model Image (g-band)") +ap.plots.residual_image(fig, axarr[1], model) +axarr[1, 0].set_title("Residual Image (r-band)") +axarr[1, 1].set_title("Residual Image (g-band)") +plt.show() + + +# Notice that there is not a universal dipole like in the shift example. Most of the offset is caused by the rotation in this example. + +# In[ ]: + + +# this will control the relative rotation of the g-band image +phi = ap.Param(name="phi", dynamic_value=0.0, dtype=torch.float64) + +# Set the target_g CD matrix to be a function of the rotation angle +# The CD matrix can encode rotation, skew, and rectangular pixels. We +# are only interested in the rotation here. +init_CD = target_g.CD.value.clone() +target_g.CD = lambda p: init_CD @ rotate(p.phi.value) +target_g.CD.link(phi) + +# also optimize the shift of the g-band image +target_g.crtan.to_dynamic() + + +# In[ ]: + + +res = ap.fit.LM(model, verbose=1).fit() +fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) +ap.plots.model_image(fig, axarr[0], model) +axarr[0, 0].set_title("Model Image (r-band)") +axarr[0, 1].set_title("Model Image (g-band)") +ap.plots.residual_image(fig, axarr[1], model) +axarr[1, 0].set_title("Residual Image (r-band)") +axarr[1, 1].set_title("Residual Image (g-band)") +plt.show() + + +# In[ ]: diff --git a/docs/source/tutorials/JointModels.py b/docs/source/tutorials/JointModels.py new file mode 100644 index 00000000..2116bb84 --- /dev/null +++ b/docs/source/tutorials/JointModels.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python +# coding: utf-8 + +# # Joint Modelling +# +# In this tutorial you will learn how to set up a joint modelling fit which encoporates the data from multiple images. These use `GroupModel` objects just like in the `GroupModels.ipynb` tutorial, the main difference being how the `TargetImage` object is constructed and that more care must be taken when assigning targets to models. +# +# It is, of course, more work to set up a fit across multiple target images. However, the tradeoff can be well worth it. Perhaps there is space-based data with high resolution, but groundbased data has better S/N. Or perhaps each band individually does not have enough signal for a confident fit, but all three together just might. Perhaps colour information is of paramount importance for a science goal, one would hope that both bands could be treated on equal footing but in a consistent way when extracting profile information. There are a number of reasons why one might wish to try and fit a multi image picture of a galaxy simultaneously. +# +# When fitting multiple bands one often resorts to forced photometry, sometimes also blurring each image to the same approximate PSF. With AstroPhot this is entirely unnecessary as one can fit each image in its native PSF simultaneously. The final fits are more meaningful and can encorporate all of the available structure information. + +# In[ ]: + + +import astrophot as ap +import matplotlib.pyplot as plt +import socket + +socket.setdefaulttimeout(120) + + +# In[ ]: + + +# First we need some data to work with, let's use LEDA 41136 as our example galaxy + +# The images must be aligned to a common coordinate system. From the DESI Legacy survey we are extracting +# each image using its RA and DEC coordinates, the WCS in the FITS header will ensure a common coordinate system. + +# It is also important to have a good estimate of the variance and the PSF for each image since these +# affect the relative weight of each image. For the tutorial we use simple approximations, but in +# science level analysis one should endeavor to get the best measure available for these. + +# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel and is 500 pixels across +target_r = ap.TargetImage( + filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=500&layer=ls-dr9&pixscale=0.262&bands=r", + zeropoint=22.5, + variance="auto", # auto variance gets it roughly right, use better estimate for science! + psf=ap.utils.initialize.gaussian_psf(1.12 / 2.355, 51, 0.262), + name="rband", +) + + +# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel and is 52 pixels across +target_W1 = ap.TargetImage( + filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1", + zeropoint=25.199, + variance="auto", + psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75), + name="W1band", +) + +# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel and is 90 pixels across +target_NUV = ap.TargetImage( + filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=90&layer=galex&pixscale=1.5&bands=n", + zeropoint=20.08, + variance="auto", + psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5), + name="NUVband", +) + +fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6)) +ap.plots.target_image(fig1, ax1[0], target_r) +ax1[0].set_title("r-band image") +ap.plots.target_image(fig1, ax1[1], target_W1) +ax1[1].set_title("W1-band image") +ap.plots.target_image(fig1, ax1[2], target_NUV) +ax1[2].set_title("NUV-band image") +plt.show() + + +# In[ ]: + + +# The joint model will need a target to try and fit, but now that we have multiple images the "target" is +# a Target_Image_List object which points to all three. +target_full = ap.TargetImageList((target_r, target_W1, target_NUV)) +# It doesn't really need any other information since everything is already available in the individual targets + + +# In[ ]: + + +# To make things easy to start, lets just fit a sersic model to all three. In principle one can use arbitrary +# group models designed for each band individually, but that would be unnecessarily complex for a tutorial + +model_r = ap.Model( + name="rband model", + model_type="sersic galaxy model", + target=target_r, + psf_convolve=True, +) + +model_W1 = ap.Model( + name="W1band model", + model_type="sersic galaxy model", + target=target_W1, + center=[0, 0], + PA=-2.3, + psf_convolve=True, +) + +model_NUV = ap.Model( + name="NUVband model", + model_type="sersic galaxy model", + target=target_NUV, + center=[0, 0], + PA=-2.3, + psf_convolve=True, +) + +# At this point we would just be fitting three separate models at the same time, not very interesting. Next +# we add constraints so that some parameters are shared between all the models. It makes sense to fix +# structure parameters while letting brightness parameters vary between bands so that's what we do here. +for p in ["center", "q", "PA", "n", "Re"]: + model_W1[p].value = model_r[p] + model_NUV[p].value = model_r[p] +# Now every model will have a unique Ie, but every other parameter is shared + + +# In[ ]: + + +# We can now make the joint model object + +model_full = ap.Model( + name="LEDA 41136", + model_type="group model", + models=[model_r, model_W1, model_NUV], + target=target_full, +) + +model_full.initialize() +model_full.graphviz() + + +# In[ ]: + + +result = ap.fit.LM(model_full, verbose=1).fit() +print(result.message) + + +# In[ ]: + + +# here we plot the results of the fitting, notice that each band has a different PSF and pixelscale. Also, notice +# that the colour bars represent significantly different ranges since each model was allowed to fit its own Ie. +# meanwhile the center, PA, q, and Re is the same for every model. +fig1, ax1 = plt.subplots(2, 3, figsize=(18, 12)) +ap.plots.model_image(fig1, ax1[0], model_full) +ax1[0][0].set_title("r-band model image") +ax1[0][1].set_title("W1-band model image") +ax1[0][2].set_title("NUV-band model image") +ap.plots.residual_image(fig1, ax1[1], model_full, normalize_residuals=True) +ax1[1][0].set_title("r-band residual image") +ax1[1][1].set_title("W1-band residual image") +ax1[1][2].set_title("NUV-band residual image") +plt.show() + + +# ## Joint models with multiple models +# +# If you want to analyze more than a single astronomical object, you will need to combine many models for each image in a reasonable structure. There are a number of ways to do this that will work, though may not be as scalable. For small images, just about any arrangement is fine when using the LM optimizer. But as images and number of models scales very large, it may be necessary to sub divide the problem to save memory. To do this you should arrange your models in a hierarchy so that AstroPhot has some information about the structure of your problem. There are two ways to do this. First, you can create a group of models where each sub-model is a group which holds all the objects for one image. Second, you can create a group of models where each sub-model is a group which holds all the representations of a single astronomical object across each image. The second method is preferred. See the diagram below to help clarify what this means. +# +# __[JointGroupModels](https://raw.githubusercontent.com/Autostronomy/AstroPhot/main/media/groupjointmodels.png)__ +# +# Here we will see an example of a multiband fit of an image which has multiple astronomical objects. + +# In[ ]: + + +# First we need some data to work with, let's use another LEDA object, this time a group of galaxies: LEDA 389779, 389797, 389681 + +RA = 156.7283 +DEC = 15.5512 +# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel +rsize = 90 + +# Now we make our targets +target_r = ap.image.TargetImage( + filename=f"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={rsize}&layer=ls-dr9&pixscale=0.262&bands=r", + zeropoint=22.5, + variance="auto", + psf=ap.utils.initialize.gaussian_psf(1.12 / 2.355, 51, 0.262), + name="rband", +) + +# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel +wsize = int(rsize * 0.262 / 2.75) +target_W1 = ap.image.TargetImage( + filename=f"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={wsize}&layer=unwise-neo7&pixscale=2.75&bands=1", + zeropoint=25.199, + variance="auto", + psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75), + name="W1band", +) + +# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel +gsize = int(rsize * 0.262 / 1.5) +target_NUV = ap.image.TargetImage( + filename=f"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={gsize}&layer=galex&pixscale=1.5&bands=n", + zeropoint=20.08, + variance="auto", + psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5), + name="NUVband", +) +target_full = ap.image.TargetImageList((target_r, target_W1, target_NUV)) + +fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6)) +ap.plots.target_image(fig1, ax1, target_full) +ax1[0].set_title("r-band image") +ax1[1].set_title("W1-band image") +ax1[2].set_title("NUV-band image") +plt.show() + + +# In[ ]: + + +######################################### +# NOTE: photutils is not a dependency of AstroPhot, make sure you run: pip install photutils +# if you dont already have that package. Also note that you can use any segmentation map +# code, we just use photutils here because it is very easy. +######################################### +from photutils.segmentation import detect_sources, deblend_sources + +rdata = target_r.data.T.detach().cpu().numpy() +initsegmap = detect_sources(rdata, threshold=0.01, npixels=10) +segmap = deblend_sources(rdata, initsegmap, npixels=5).data +fig8, ax8 = plt.subplots(figsize=(8, 8)) +ax8.imshow(segmap, origin="lower", cmap="inferno") +plt.show() +# This will convert the segmentation map into boxes that enclose the identified pixels +rwindows = ap.utils.initialize.windows_from_segmentation_map(segmap) +# Next we scale up the windows so that AstroPhot can fit the faint parts of each object as well +rwindows = ap.utils.initialize.scale_windows( + rwindows, image=target_r, expand_scale=1.5, expand_border=10 +) +w1windows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_W1) +w1windows = ap.utils.initialize.scale_windows(w1windows, image=target_W1, expand_border=1) +nuvwindows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_NUV) +# Here we get some basic starting parameters for the galaxies (center, position angle, axis ratio) +centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, target_r) +PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, target_r, centers) +qs = ap.utils.initialize.q_from_segmentation_map(segmap, target_r, centers) + + +# There is barely any signal in the GALEX data and it would be entirely impossible to analyze on its own. With simultaneous multiband fitting it is a breeze to get relatively robust results! +# +# Next we need to construct models for each galaxy. This is understandably more complex than in the single band case, since now we have three times the amount of data to keep track of. Recall that we will create a number of joint models to represent each astronomical object, then put them all together in a larger group model. + +# In[ ]: + + +model_list = [] + +for i, window in enumerate(rwindows): + # create the submodels for this object + sub_list = [] + sub_list.append( + ap.Model( + name=f"rband model {i}", + model_type="sersic galaxy model", # we could use spline models for the r-band since it is well resolved + target=target_r, + window=rwindows[window], + psf_convolve=True, + center=centers[window], + PA=PAs[window], + q=qs[window], + ) + ) + sub_list.append( + ap.Model( + name=f"W1band model {i}", + model_type="sersic galaxy model", + target=target_W1, + window=w1windows[window], + psf_convolve=True, + ) + ) + sub_list.append( + ap.Model( + name=f"NUVband model {i}", + model_type="sersic galaxy model", + target=target_NUV, + window=nuvwindows[window], + psf_convolve=True, + ) + ) + # ensure equality constraints + # across all bands, same center, q, PA, n, Re + for p in ["center", "q", "PA", "n", "Re"]: + sub_list[1][p].value = sub_list[0][p] + sub_list[2][p].value = sub_list[0][p] + + # Make the multiband model for this object + model_list.append( + ap.Model( + name=f"model {i}", + model_type="group model", + target=target_full, + models=sub_list, + ) + ) +# Make the full model for this system of objects +MODEL = ap.Model( + name=f"full model", + model_type="group model", + target=target_full, + models=model_list, +) +fig, ax = plt.subplots(1, 3, figsize=(16, 5)) +ap.plots.target_image(fig, ax, MODEL.target) +ap.plots.model_window(fig, ax, MODEL) +ax[0].set_title("r-band image") +ax[1].set_title("W1-band image") +ax[2].set_title("NUV-band image") +plt.show() + + +# In[ ]: + + +MODEL.initialize() +MODEL.graphviz() + + +# In[ ]: + + +# We give it only one iteration for runtime/demo purposes, you should let these algorithms run to convergence +result = ap.fit.Iter(MODEL, verbose=1, max_iter=1).fit() + + +# In[ ]: + + +fig1, ax1 = plt.subplots(2, 3, figsize=(18, 11)) +ap.plots.model_image(fig1, ax1[0], MODEL, vmax=30) +ax1[0][0].set_title("r-band model image") +ax1[0][1].set_title("W1-band model image") +ax1[0][2].set_title("NUV-band model image") +ap.plots.residual_image(fig1, ax1[1], MODEL, normalize_residuals=True) +ax1[1][0].set_title("r-band residual image") +ax1[1][1].set_title("W1-band residual image") +ax1[1][2].set_title("NUV-band residual image") +plt.show() + + +# The models look pretty good! The power of multiband fitting lets us know that we have extracted all the available information here, no forced photometry required! Some notes though, since we didn't fit a sky model, the colourbars are quite extreme. +# +# An important note here is that the SB levels for the W1 and NUV data are quire reasonable. While the structure (center, PA, q, n, Re) was shared between bands and therefore mostly driven by the r-band, the brightness is entirely independent between bands meaning the Ie (and therefore SB) values are right from the W1 and NUV data! + +# These residuals mostly look like just noise! The only feature remaining is the row on the bottom of the W1 image. This could likely be fixed by running the fit to convergence and/or taking a larger FOV. + +# ### Dithered images +# +# Note that it is not necessary to use images from different bands. Using dithered images one can effectively achieve higher resolution. It is possible to simultaneously fit dithered images with AstroPhot instead of postprocessing the two images together. This will of course be slower, but may be worthwhile for cases where extra care is needed. +# +# ### Stacked images +# +# Like dithered images, one may wish to combine the statistical power of multiple images but for some reason it is not clear how to add them (for example they are at different rotations). In this case one can simply have AstroPhot fit the images simultaneously. Again this is slower than if the image could be combined, but should extract all the statistical power from the data! +# +# ### Time series +# +# Some objects change over time. For example they may get brighter and dimmer, or may have a transient feature appear. However, the structure of an object may remain constant. An example of this is a supernova and its host galaxy. The host galaxy likely doesn't change across images, but the supernova does. It is possible to fit a time series dataset with a shared galaxy model across multiple images, and a shared position for the supernova, but a variable brightness for the supernova over each image. +# +# It is possible to get quite creative with joint models as they allow one to fix selective features of a model over a wide range of data. If you have a situation which may benefit from joint modelling but are having a hard time determining how to format everything, please do contact us! + +# In[ ]: diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index c24cc06e..cf4a3639 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -4,6 +4,8 @@ import runpy import subprocess import os +import caskade as ck +import astrophot as ap pytestmark = pytest.mark.skipif( platform.system() in ["Windows", "Darwin"], @@ -49,4 +51,6 @@ def cleanup_py_scripts(nbpath): def test_notebook(nb_path): convert_notebook_to_py(nb_path) runpy.run_path(nb_path.replace(".ipynb", ".py"), run_name="__main__") + ck.backend.backend = "torch" + ap.backend.backend = "torch" cleanup_py_scripts(nb_path) diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index 7a807fe4..6b6e1497 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -11,6 +11,10 @@ @pytest.mark.parametrize("model_type", ap.models.PSFModel.List_Models(usable=True, types=True)) def test_all_psfmodel_sample(model_type): + if model_type == "airy psf model": + pytest.skip( + "Skipping airy psf model, JAX does not support bessel_j1 with finite derivatives it seems" + ) if "nuker" in model_type: kwargs = {"Ib": None} From 368f3d5a62cec5bd5bc09e7d5f695181ff335ce9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:16:24 -0400 Subject: [PATCH 130/191] Bump actions/download-artifact from 4 to 5 (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
Release notes

Sourced from actions/download-artifact's releases.

v5.0.0

What's Changed

v5.0.0

🚨 Breaking Change

This release fixes an inconsistency in path behavior for single artifact downloads by ID. If you're downloading single artifacts by ID, the output path may change.

What Changed

Previously, single artifact downloads behaved differently depending on how you specified the artifact:

  • By name: name: my-artifact → extracted to path/ (direct)
  • By ID: artifact-ids: 12345 → extracted to path/my-artifact/ (nested)

Now both methods are consistent:

  • By name: name: my-artifact → extracted to path/ (unchanged)
  • By ID: artifact-ids: 12345 → extracted to path/ (fixed - now direct)

Migration Guide

✅ No Action Needed If:
  • You download artifacts by name
  • You download multiple artifacts by ID
  • You already use merge-multiple: true as a workaround
⚠️ Action Required If:

You download single artifacts by ID and your workflows expect the nested directory structure.

Before v5 (nested structure):

- uses: actions/download-artifact@v4
  with:
    artifact-ids: 12345
    path: dist
# Files were in: dist/my-artifact/

Where my-artifact is the name of the artifact you previously uploaded

To maintain old behavior (if needed):

</tr></table>

... (truncated)

Commits
  • 634f93c Merge pull request #416 from actions/single-artifact-id-download-path
  • b19ff43 refactor: resolve download path correctly in artifact download tests (mainly ...
  • e262cbe bundle dist
  • bff23f9 update docs
  • fff8c14 fix download path logic when downloading a single artifact by id
  • 448e3f8 Merge pull request #407 from actions/nebuk89-patch-1
  • 47225c4 Update README.md
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Connor Stone, PhD --- .github/workflows/cd.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 1ae8515a..8e937ae6 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -49,7 +49,7 @@ jobs: name: Install Python with: python-version: "3.10" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: name: artifact path: dist @@ -91,7 +91,7 @@ jobs: if: github.event_name == 'release' && github.event.action == 'published' steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: name: artifact path: dist From 748baf1779dec458c1a49668ce0da660c12117ac Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 18 Aug 2025 11:33:37 -0400 Subject: [PATCH 131/191] skip notebook tests skip random model tests --- tests/test_model.py | 2 ++ tests/test_notebooks.py | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_model.py b/tests/test_model.py index e395c483..a349e137 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -117,6 +117,8 @@ def test_model_errors(): ) def test_all_model_sample(model_type): + if ap.backend.backend == "jax" and np.random.randint(0, 3) > 0: + pytest.skip("JAX is very slow, randomly reducing the number of tests") if model_type == "isothermal sech2 edgeon model" and ap.backend.backend == "jax": pytest.skip("JAX doesnt have bessel function k1 yet") diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index cf4a3639..7c08d138 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -12,9 +12,6 @@ reason="Graphviz not installed on Windows runner", ) -pytestbackend = pytest.mark.skipif( - os.environ.get("CASKADE_BACKEND") != "torch", reason="Requires torch backend" -) notebooks = glob.glob( os.path.join( @@ -49,6 +46,8 @@ def cleanup_py_scripts(nbpath): @pytest.mark.parametrize("nb_path", notebooks) def test_notebook(nb_path): + if os.environ.get("CASKADE_BACKEND") != "torch": + pytest.skip("Requires torch backend") convert_notebook_to_py(nb_path) runpy.run_path(nb_path.replace(".ipynb", ".py"), run_name="__main__") ck.backend.backend = "torch" From 04fdb26f9372ddaa9cb9315c157c2ba3c9eafbd1 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 18 Aug 2025 14:11:23 -0400 Subject: [PATCH 132/191] better coverage --- astrophot/fit/base.py | 21 ----------- astrophot/models/func/__init__.py | 5 +-- astrophot/models/func/integration.py | 52 ---------------------------- astrophot/models/mixins/sample.py | 18 ---------- tests/test_fit.py | 1 + tests/test_psfmodel.py | 2 +- 6 files changed, 3 insertions(+), 96 deletions(-) diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index 98a5d474..726ba231 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -92,24 +92,3 @@ def res_loss(self): """returns the minimum value from the loss history.""" N = np.isfinite(self.loss_history) return np.min(np.array(self.loss_history)[N]) - - @staticmethod - def chi2contour(n_params: int, confidence: float = 0.682689492137) -> float: - """ - Calculates the chi^2 contour for the given number of parameters. - - **Args:** - - `n_params` (int): The number of parameters. - - `confidence` (float, optional): The confidence interval (default is 0.682689492137). - """ - - def _f(x: float, nu: int) -> float: - """Helper function for calculating chi^2 contour.""" - return (gammainc(nu / 2, x / 2) - confidence) ** 2 - - for method in ["L-BFGS-B", "Powell", "Nelder-Mead"]: - res = minimize(_f, x0=n_params, args=(n_params,), method=method, tol=1e-8) - - if res.success: - return res.x[0] - raise RuntimeError(f"Unable to compute Chi^2 contour for n params: {n_params}") diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 2d5cb3b8..79e7e8e6 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -2,14 +2,12 @@ from .integration import ( quad_table, pixel_center_integrator, - pixel_corner_integrator, pixel_simpsons_integrator, pixel_quad_integrator, single_quad_integrate, recursive_quad_integrate, upsample, bright_integrate, - recursive_bright_integrate, ) from .convolution import ( convolve, @@ -31,7 +29,6 @@ "all_subclasses", "quad_table", "pixel_center_integrator", - "pixel_corner_integrator", "pixel_simpsons_integrator", "pixel_quad_integrator", "convolve", @@ -49,7 +46,7 @@ "single_quad_integrate", "recursive_quad_integrate", "upsample", - "recursive_bright_integrate", + "bright_integrate", "rotate", "zernike_n_m_list", "zernike_n_m_modes", diff --git a/astrophot/models/func/integration.py b/astrophot/models/func/integration.py index 2c6523e1..b5009ba8 100644 --- a/astrophot/models/func/integration.py +++ b/astrophot/models/func/integration.py @@ -10,12 +10,6 @@ def pixel_center_integrator(Z: ArrayLike) -> ArrayLike: return Z -def pixel_corner_integrator(Z: ArrayLike) -> ArrayLike: - kernel = backend.ones((1, 1, 2, 2), dtype=config.DTYPE, device=config.DEVICE) / 4.0 - Z = backend.conv2d(Z.reshape(1, 1, *Z.shape), kernel, padding="valid") - return Z.squeeze(0).squeeze(0) - - def pixel_simpsons_integrator(Z: ArrayLike) -> ArrayLike: kernel = ( backend.as_array( @@ -123,7 +117,6 @@ def bright_integrate( gridding: int = 5, max_depth: int = 2, ): - # Work in progress, somehow this is slower trace = [] for d in range(max_depth): N = max(1, int(np.prod(z.shape) * bright_frac)) @@ -145,48 +138,3 @@ def bright_integrate( ) return trace[0][0].reshape(trace[0][2]) - - -def recursive_bright_integrate( - i: ArrayLike, - j: ArrayLike, - brightness_ij: callable, - bright_frac: float, - scale: float = 1.0, - quad_order: int = 3, - gridding: int = 5, - _current_depth: int = 0, - max_depth: int = 1, -) -> ArrayLike: - z, _ = single_quad_integrate(i, j, brightness_ij, scale, quad_order) - print(z.shape) - if _current_depth >= max_depth: - return z - - N = max(1, int(np.prod(z.shape) * bright_frac)) - z_flat = z.flatten() - - select = backend.topk(z_flat, N)[1] - - si, sj = upsample(i.flatten()[select], j.flatten()[select], gridding, scale) - - z_flat = backend.fill_at_indices( - z_flat, - select, - backend.mean( - recursive_bright_integrate( - si, - sj, - brightness_ij, - bright_frac, - scale=scale / gridding, - quad_order=quad_order, - gridding=gridding, - _current_depth=_current_depth + 1, - max_depth=max_depth, - ), - dim=-1, - ), - ) - - return z_flat.reshape(z.shape) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index dd5861e9..46defb91 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -70,24 +70,6 @@ def _bright_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: max_depth=self.integrate_max_depth, ) return sample - # N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) - # sample_flat = sample.flatten() - # select = backend.topk(sample_flat, N)[1] - # sample_flat = backend.fill_at_indices( - # sample_flat, - # select, - # func.recursive_bright_integrate( - # i.flatten()[select], - # j.flatten()[select], - # lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), - # scale=image.base_scale, - # bright_frac=self.integrate_fraction, - # quad_order=self.integrate_quad_order, - # gridding=self.integrate_gridding, - # max_depth=self.integrate_max_depth, - # ), - # ) - # return sample_flat.reshape(sample.shape) @forward def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: diff --git a/tests/test_fit.py b/tests/test_fit.py index 529c1b2c..bc263492 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -71,6 +71,7 @@ def sersic_model(): "fitter", [ ap.fit.LM, + ap.fit.LMfast, ap.fit.Grad, ap.fit.ScipyFit, ap.fit.MHMCMC, diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index 6b6e1497..34602be1 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -11,7 +11,7 @@ @pytest.mark.parametrize("model_type", ap.models.PSFModel.List_Models(usable=True, types=True)) def test_all_psfmodel_sample(model_type): - if model_type == "airy psf model": + if model_type == "airy psf model" and ap.backend.backend == "jax": pytest.skip( "Skipping airy psf model, JAX does not support bessel_j1 with finite derivatives it seems" ) From b65bc2a71fd0f20d09046c9ffdda362b7d602b41 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 18 Aug 2025 14:40:18 -0400 Subject: [PATCH 133/191] stronger config based device maangement --- astrophot/fit/base.py | 2 +- astrophot/fit/func/lm.py | 5 +++-- astrophot/image/cmos_image.py | 5 ++--- astrophot/image/func/wcs.py | 5 ++++- astrophot/image/sip_image.py | 5 +++-- astrophot/image/target_image.py | 4 ++-- astrophot/utils/initialize/segmentation_map.py | 5 +++-- 7 files changed, 18 insertions(+), 13 deletions(-) diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index 726ba231..4b161064 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -48,7 +48,7 @@ def __init__( self.current_state = model.build_params_array() else: self.current_state = backend.as_array( - initial_state, dtype=model.dtype, device=model.device + initial_state, dtype=config.DTYPE, device=config.DEVICE ) if fit_window is None: diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 3648375d..67ddbd43 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -2,6 +2,7 @@ from ...errors import OptimizeStopFail, OptimizeStopSuccess from ...backend_obj import backend +from ... import config def nll(D, M, W): @@ -40,7 +41,7 @@ def hessian_poisson(J, D, M): def damp_hessian(hess, L): - I = backend.eye(len(hess), dtype=hess.dtype, device=hess.device) + I = backend.eye(len(hess), dtype=config.DTYPE, device=config.DEVICE) D = backend.ones_like(hess) - I return hess * (I + D / (1 + L)) + L * I * backend.diag(hess) @@ -52,7 +53,7 @@ def solve(hess, grad, L): h = backend.linalg.solve(hessD, grad) break except backend.LinAlgErr: - hessD = hessD + L * backend.eye(len(hessD), dtype=hessD.dtype, device=hessD.device) + hessD = hessD + L * backend.eye(len(hessD), dtype=config.DTYPE, device=config.DEVICE) L = L * 2 return hessD, h diff --git a/astrophot/image/cmos_image.py b/astrophot/image/cmos_image.py index 2083c724..8c36d726 100644 --- a/astrophot/image/cmos_image.py +++ b/astrophot/image/cmos_image.py @@ -2,6 +2,7 @@ from .mixins import CMOSMixin from .model_image import ModelImage from ..backend_obj import backend +from .. import config class CMOSModelImage(CMOSMixin, ModelImage): @@ -27,9 +28,7 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> CMOSModelIma kwargs = { "subpixel_loc": self.subpixel_loc, "subpixel_scale": self.subpixel_scale, - "_data": backend.zeros( - self.data.shape[:2], dtype=self.data.dtype, device=self.data.device - ), + "_data": backend.zeros(self.data.shape[:2], dtype=config.DTYPE, device=config.DEVICE), "CD": self.CD.value, "crpix": self.crpix, "crtan": self.crtan.value, diff --git a/astrophot/image/func/wcs.py b/astrophot/image/func/wcs.py index 2f531843..8d811256 100644 --- a/astrophot/image/func/wcs.py +++ b/astrophot/image/func/wcs.py @@ -1,5 +1,6 @@ import numpy as np from ...backend_obj import backend +from ... import config deg_to_rad = np.pi / 180 rad_to_deg = 180 / np.pi @@ -107,7 +108,9 @@ def sip_coefs(order): def sip_matrix(u, v, order): - M = backend.zeros((len(u), (order + 1) * (order + 2) // 2), dtype=u.dtype, device=u.device) + M = backend.zeros( + (len(u), (order + 1) * (order + 2) // 2), dtype=config.DTYPE, device=config.DEVICE + ) for i, (p, q) in enumerate(sip_coefs(order)): M = backend.fill_at_indices(M, (slice(None), i), u**p * v**q) return M diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index c90cd45a..ab0265cc 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -4,6 +4,7 @@ from .model_image import ModelImage from .mixins import SIPMixin from ..backend_obj import backend, ArrayLike +from .. import config class SIPModelImage(SIPMixin, ModelImage): @@ -147,8 +148,8 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> SIPModelImag "distortion_IJ": new_distortion_IJ, "_data": backend.zeros( (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), - dtype=self.data.dtype, - device=self.data.device, + dtype=config.DTYPE, + device=config.DEVICE, ), "CD": self.CD.value / upsample, "crpix": (self.crpix + 0.5) * upsample + pad - 0.5, diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 5258c470..fd8e38d4 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -209,8 +209,8 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> ModelImage: kwargs = { "_data": backend.zeros( (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), - dtype=self.data.dtype, - device=self.data.device, + dtype=config.DTYPE, + device=config.DEVICE, ), "CD": self.CD.value / upsample, "crpix": (self.crpix + 0.5) * upsample + pad - 0.5, diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index b88d2331..32d3fdbc 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -4,6 +4,7 @@ import numpy as np from astropy.io import fits from ...backend_obj import backend +from ... import config __all__ = ( "centroids_from_segmentation_map", @@ -67,8 +68,8 @@ def centroids_from_segmentation_map( icentroid = np.sum(II[N] * data[N]) / np.sum(data[N]) jcentroid = np.sum(JJ[N] * data[N]) / np.sum(data[N]) xcentroid, ycentroid = image.pixel_to_plane( - backend.as_array(icentroid, dtype=image.data.dtype, device=image.data.device), - backend.as_array(jcentroid, dtype=image.data.dtype, device=image.data.device), + backend.as_array(icentroid, dtype=config.DTYPE, device=config.DEVICE), + backend.as_array(jcentroid, dtype=config.DTYPE, device=config.DEVICE), params=(), ) centroids[index] = [xcentroid.item(), ycentroid.item()] From 4c66d5122a2984c24baface3e91890bb4b0d73e4 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 18 Aug 2025 15:06:16 -0400 Subject: [PATCH 134/191] read the docs now use python 3.12 --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1c4c322b..3989c638 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -20,7 +20,7 @@ sphinx: build: os: "ubuntu-20.04" tools: - python: "3.9" + python: "3.12" apt_packages: - pandoc # Specify pandoc to be installed via apt-get - graphviz From 408089486bad1aac262e20007fb19d352b38968d Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 18 Aug 2025 15:11:20 -0400 Subject: [PATCH 135/191] fixing coverage notebook tests --- .github/workflows/coverage.yaml | 4 +- docs/source/tutorials/AdvancedPSFModels.py | 284 --------------------- tests/test_notebooks.py | 2 +- 3 files changed, 4 insertions(+), 286 deletions(-) delete mode 100644 docs/source/tutorials/AdvancedPSFModels.py diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index b9677ef4..f28422c2 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -47,10 +47,12 @@ jobs: pip install -e ".[dev]" pip show ${{ env.PROJECT_NAME }} shell: bash - - name: Test with pytest + - name: Test with pytest [torch] run: | coverage run --source=${{ env.PROJECT_NAME }} -m pytest tests/ shell: bash + env: + CASKADE_BACKEND: torch - name: Extra coverage report for jax checks run: | echo "Running extra coverage report for jax checks" diff --git a/docs/source/tutorials/AdvancedPSFModels.py b/docs/source/tutorials/AdvancedPSFModels.py deleted file mode 100644 index 891593f4..00000000 --- a/docs/source/tutorials/AdvancedPSFModels.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 - -# # Advanced PSF modeling -# -# Ideally we always have plenty of well separated bright, but not oversaturated, stars to use to construct a PSF model. These models are incredibly important for certain science objectives that rely on precise shape measurements and not just total light measures. Here we demonstrate some of the special capabilities AstroPhot has to handle challenging scenarios where a good PSF model is needed but there are only very faint stars, poorly placed stars, or even no stars to work with! - -# In[ ]: - - -import astrophot as ap -import numpy as np -import torch -import matplotlib.pyplot as plt - - -# ## Making a PSF model -# -# Before we can optimize a PSF model, we need to make the model and get some starting parameters. If you already have a good guess at some starting parameters then you can just enter them yourself, however if you don't then AstroPhot provides another option; if you have an empirical PSF estimate (a stack of a few stars from the field), then you can have a PSF model initialize itself on the empirical PSF just like how other AstroPhot models can initialize themselves on target images. Let's see how that works! - -# In[ ]: - - -# First make a mock empirical PSF image -np.random.seed(124) -psf = ap.utils.initialize.moffat_psf(2.0, 3.0, 101, 0.5) -variance = psf**2 / 100 -psf += np.random.normal(scale=np.sqrt(variance)) - -psf_target = ap.PSFImage( - data=psf, - pixelscale=0.5, - variance=variance, -) - -# To ensure the PSF has a normalized flux of 1, we call -psf_target.normalize() - -fig, ax = plt.subplots() -ap.plots.psf_image(fig, ax, psf_target) -ax.set_title("mock empirical PSF") -plt.show() - - -# In[ ]: - - -# Now we initialize on the image -psf_model = ap.Model( - name="init psf", - model_type="moffat psf model", - target=psf_target, -) - -psf_model.initialize() - -# PSF model can be fit to it's own target for good initial values -# Note we provide the weight map (1/variance) since a PSF_Image can't store that information. -ap.fit.LM(psf_model, verbose=1).fit() - -fig, ax = plt.subplots(1, 2, figsize=(13, 5)) -ap.plots.psf_image(fig, ax[0], psf_model) -ax[0].set_title("PSF model fit to mock empirical PSF") -ap.plots.residual_image(fig, ax[1], psf_model, normalize_residuals=True) -ax[1].set_title("residuals") -plt.show() - - -# That's pretty good! it doesn't need to be perfect, so this is already in the right ballpark, just based on the size of the main light concentration. For the examples below, we will just start with some simple given initial parameters, but for real analysis this is quite handy. - -# ## Group PSF Model -# -# Just like group models for regular models, it is possible to make a `psf group model` to combine multiple psf models. - -# In[ ]: - - -psf_model1 = ap.Model( - name="psf1", - model_type="moffat psf model", - n=2, - Rd=10, - I0=20, # essentially controls relative flux of this component - normalize_psf=False, # sub components shouldnt be individually normalized - target=psf_target, -) -psf_model2 = ap.Model( - name="psf2", - model_type="sersic psf model", - n=4, - Re=5, - Ie=1, - normalize_psf=False, - target=psf_target, -) -psf_group_model = ap.Model( - name="psf group", - model_type="psf group model", - target=psf_target, - models=[psf_model1, psf_model2], - normalize_psf=True, # group model should normalize the combined PSF -) -psf_group_model.initialize() -fig, ax = plt.subplots(1, 3, figsize=(15, 5)) -ap.plots.psf_image(fig, ax[0], psf_group_model) -ax[0].set_title("PSF group model with two PSF models") -ap.plots.psf_image(fig, ax[1], psf_group_model.models[0]) -ax[1].set_title("PSF model component 1") -ap.plots.psf_image(fig, ax[2], psf_group_model.models[1]) -ax[2].set_title("PSF model component 2") -plt.show() - - -# ## PSF modeling without stars -# -# Can it be done? Let's see! - -# In[ ]: - - -# Lets make some data that we need to fit -psf_target = ap.PSFImage( - data=np.zeros((51, 51)), - pixelscale=1.0, -) - -true_psf_model = ap.Model( - name="true psf", - model_type="moffat psf model", - target=psf_target, - n=2, - Rd=3, -) -true_psf = true_psf_model().data - -target = ap.TargetImage( - data=torch.zeros(100, 100), - pixelscale=1.0, - psf=true_psf, -) - -true_model = ap.Model( - name="true model", - model_type="sersic galaxy model", - target=target, - center=[50.0, 50.0], - q=0.4, - PA=np.pi / 3, - n=2, - Re=25, - Ie=10, - psf_convolve=True, -) - -# use the true model to make some data -sample = true_model() -torch.manual_seed(61803398) -target._data = sample.data + torch.normal(torch.zeros_like(sample.data), 0.1) -target.variance = 0.01 * torch.ones_like(sample.data.T) - -fig, ax = plt.subplots(1, 2, figsize=(16, 7)) -ap.plots.model_image(fig, ax[0], true_model) -ap.plots.target_image(fig, ax[1], target) -ax[0].set_title("true sersic+psf model") -ax[1].set_title("mock observed data") -plt.show() - - -# In[ ]: - - -# Now we will try and fit the data using just a plain sersic - -# Here we set up a sersic model for the galaxy -plain_galaxy_model = ap.Model( - name="galaxy model", - model_type="sersic galaxy model", - target=target, -) - -# Let AstroPhot determine its own initial parameters, so it has to start with whatever it decides automatically, -# just like a real fit. -plain_galaxy_model.initialize() - -result = ap.fit.LM(plain_galaxy_model, verbose=1).fit() -print(result.message) - - -# In[ ]: - - -# The shape of the residuals here shows that there is still missing information; this is of course -# from the missing PSF convolution to blur the model. In fact, the shape of those residuals is very -# commonly seen in real observed data (ground based) when it is fit without accounting for PSF blurring. -fig, ax = plt.subplots(1, 2, figsize=(16, 7)) -ap.plots.model_image(fig, ax[0], plain_galaxy_model) -ap.plots.residual_image(fig, ax[1], plain_galaxy_model) -ax[0].set_title("fitted sersic only model") -ax[1].set_title("residuals") -plt.show() - - -# In[ ]: - - -# Now we will try and fit the data with a sersic model and a "live" psf - -# Here we create a target psf model which will determine the specs of our live psf model -psf_target = ap.PSFImage( - data=np.zeros((51, 51)), - pixelscale=target.pixelscale, -) - -live_psf_model = ap.Model( - name="psf", - model_type="moffat psf model", - target=psf_target, - n=1.0, # True value is 2. - Rd=2.0, # True value is 3. -) - -# Here we set up a sersic model for the galaxy -live_galaxy_model = ap.Model( - name="galaxy model", - model_type="sersic galaxy model", - target=target, - psf_convolve=True, - psf=live_psf_model, # Here we bind the PSF model to the galaxy model, this will add the psf_model parameters to the galaxy_model -) -live_galaxy_model.initialize() - -result = ap.fit.LM(live_galaxy_model, verbose=3).fit() - - -# In[ ]: - - -print( - f"fitted n for moffat PSF: {live_psf_model.n.value.item():.6f} +- {live_psf_model.n.uncertainty.item():.6f} we were hoping to get 2!" -) -print( - f"fitted Rd for moffat PSF: {live_psf_model.Rd.value.item():.6f} +- {live_psf_model.Rd.uncertainty.item():.6f} we were hoping to get 3!" -) -fig, ax = ap.plots.covariance_matrix( - result.covariance_matrix.detach().cpu().numpy(), - live_galaxy_model.build_params_array().detach().cpu().numpy(), - live_galaxy_model.build_params_array_names(), -) -plt.show() - - -# This is truly remarkable! With no stars available we were still able to extract an accurate PSF from the image! To be fair, this example is essentially perfect for this kind of fitting and we knew the true model types (sersic and moffat) from the start. Still, this is a powerful capability in certain scenarios. For many applications (e.g. weak lensing) it is essential to get the absolute best PSF model possible. Here we have shown that not only stars, but galaxies in the field can be useful tools for measuring the PSF! - -# In[ ]: - - -fig, ax = plt.subplots(1, 2, figsize=(16, 7)) -ap.plots.model_image(fig, ax[0], live_galaxy_model) -ap.plots.residual_image(fig, ax[1], live_galaxy_model) -ax[0].set_title("fitted sersic + psf model") -ax[1].set_title("residuals") -plt.show() - - -# There are regions of parameter space that are degenerate and so even in this idealized scenario the PSF model can get stuck. If you rerun the notebook with different random number seeds for pytorch you may find some where the optimizer "fails by immobility" this is when it gets stuck in the parameter space and can't find any way to improve the likelihood. In fact most of these "fail" fits do return really good values for the PSF model, so keep in mind that the "fail" flag only means the possibility of a truly failed fit. Unfortunately, detecting convergence is hard. - -# ## PSF fitting for faint stars -# -# Sometimes there are stars available, but they are faint and it is hard to see how a reliable fit could be obtained. We have already seen how faint stars next to galaxies are still viable for PSF fitting. Now we will consider the case of isolated but faint stars. The trick here is that we have a second high resolution image, perhaps in a different band. To perform this fitting we will link up the two bands using joint modelling to constrain the star centers, this will constrain some of the parameters making it easier to fit a PSF model. - -# In[ ]: - - -# Coming soon - - -# ## PSF fitting for saturated stars -# -# A saturated star is a bright star, and it's just begging to be used for modelling a PSF. There's just one catch, the highest signal to noise region is completely messed up and can't be used! Traditionally these stars are either ignored, or a two stage fit is performed to get an "inner psf" and an "outer psf" which are then merged. Why not fit the inner and outer PSFs all at once! This can be done with AstroPhot using parameter constraints and masking. - -# In[ ]: - - -# Coming soon diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index 7c08d138..b1099de4 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -46,7 +46,7 @@ def cleanup_py_scripts(nbpath): @pytest.mark.parametrize("nb_path", notebooks) def test_notebook(nb_path): - if os.environ.get("CASKADE_BACKEND") != "torch": + if ap.backend.backend == "jax": pytest.skip("Requires torch backend") convert_notebook_to_py(nb_path) runpy.run_path(nb_path.replace(".ipynb", ".py"), run_name="__main__") From 7d335e909aa64b8a7808367e88661c1aef257db4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 07:24:21 +0000 Subject: [PATCH 136/191] build(deps): bump pypa/gh-action-pypi-publish from 1.12.4 to 1.13.0 Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.12.4 to 1.13.0. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.12.4...v1.13.0) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/cd.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 8e937ae6..c046d292 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -58,7 +58,7 @@ jobs: ls -ltrh ls -ltrh dist - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@v1.12.4 + uses: pypa/gh-action-pypi-publish@v1.13.0 with: repository-url: https://test.pypi.org/legacy/ verbose: true @@ -96,5 +96,5 @@ jobs: name: artifact path: dist - - uses: pypa/gh-action-pypi-publish@v1.12.4 + - uses: pypa/gh-action-pypi-publish@v1.13.0 if: startsWith(github.ref, 'refs/tags') From 6fc57a4ee0482191ef919e0404e7b65e2e818306 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 07:24:49 +0000 Subject: [PATCH 137/191] build(deps): bump actions/setup-python from 5 to 6 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cd.yaml | 2 +- .github/workflows/coverage.yaml | 2 +- .github/workflows/testing.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 8e937ae6..755cd14b 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -45,7 +45,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 name: Install Python with: python-version: "3.10" diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index f28422c2..b14db8cf 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@master - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.10" - name: Record State diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index bb528638..eeffce2e 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@master - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Record State From a011c9fbe396f644ba95c9b0deebfabcfef0cdd3 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 16 Sep 2025 16:40:17 -0400 Subject: [PATCH 138/191] iter param fitter online --- astrophot/backend_obj.py | 8 +- astrophot/fit/__init__.py | 16 +- astrophot/fit/func/lm.py | 6 +- astrophot/fit/iterative.py | 526 ++++++++++++++------- docs/source/tutorials/FittingMethods.ipynb | 69 ++- tests/test_fit.py | 23 +- 6 files changed, 465 insertions(+), 183 deletions(-) diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index 3574aab3..f903e725 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -343,11 +343,11 @@ def _jacobian_jax(self, func, x, strategy="forward-mode", vectorize=True, create return self.jax.jacfwd(func)(x) return self.jax.jacrev(func)(x) - def _jacfwd_torch(self, func): - return self.module.func.jacfwd(func) + def _jacfwd_torch(self, func, argnums=0): + return self.module.func.jacfwd(func, argnums=argnums) - def _jacfwd_jax(self, func): - return self.jax.jacfwd(func) + def _jacfwd_jax(self, func, argnums=0): + return self.jax.jacfwd(func, argnums=argnums) def _hessian_torch(self, func): return self.module.func.hessian(func) diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index f4ca342c..207860ee 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,10 +1,22 @@ from .lm import LM, LMfast from .gradient import Grad, Slalom -from .iterative import Iter +from .iterative import Iter, IterParam from .scipy_fit import ScipyFit from .minifit import MiniFit from .hmc import HMC from .mhmcmc import MHMCMC from . import func -__all__ = ["LM", "LMfast", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC", "Slalom", "func"] +__all__ = [ + "LM", + "LMfast", + "Grad", + "Iter", + "IterParam", + "ScipyFit", + "MiniFit", + "HMC", + "MHMCMC", + "Slalom", + "func", +] diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 67ddbd43..b5967760 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -92,7 +92,7 @@ def lm_step( scary = {"x": None, "nll": np.inf, "L": None, "rho": np.inf} nostep = True improving = None - for _ in range(10): + for i in range(10): hessD, h = solve(hess, grad, L) # (N, N), (N, 1) M1 = model(x + h.squeeze(1)) # (M,) if likelihood == "gaussian": @@ -109,7 +109,9 @@ def lm_step( continue if backend.allclose(h, backend.zeros_like(h)) and L < 0.1: - raise OptimizeStopSuccess("Step with zero length means optimization complete.") + if i == 0: + raise OptimizeStopSuccess("Step with zero length means optimization complete.") + break # actual nll improvement vs expected from linearization rho = (nll0 - nll1) / backend.abs(h.T @ hessD @ h - 2 * grad.T @ h).item() diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index bd0da0a2..0b9f4e02 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -1,7 +1,9 @@ # Apply a different optimizer iteratively -from typing import Dict, Any +from typing import Dict, Any, Union, Sequence, Literal from time import time +from functools import partial +from caskade import ValidContext import numpy as np import torch @@ -10,6 +12,8 @@ from .lm import LM from .. import config from ..backend_obj import backend +from ..errors import OptimizeStopSuccess, OptimizeStopFail +from . import func __all__ = [ "Iter", @@ -153,165 +157,361 @@ def fit(self) -> BaseOptimizer: return self -# class IterParam(BaseOptimizer): -# """Optimization wrapper that call LM optimizer on subsets of variables. - -# IterParam takes the full set of parameters for a model and breaks -# them down into chunks as specified by the user. It then calls -# Levenberg-Marquardt optimization on the subset of parameters, and -# iterates through all subsets until every parameter has been -# optimized. It cycles through these chunks until convergence. This -# method is very powerful in situations where the full optimization -# problem cannot fit in memory, or where the optimization problem is -# too complex to tackle as a single large problem. In full LM -# optimization a single problematic parameter can ripple into issues -# with every other parameter, so breaking the problem down can -# sometimes make an otherwise intractable problem easier. For small -# problems with only a few models, it is likely better to optimize -# the full problem with LM as, when it works, LM is faster than the -# IterParam method. - -# Args: -# chunks (Union[int, tuple]): Specify how to break down the model parameters. If an integer, at each iteration the algorithm will break the parameters into groups of that size. If a tuple, should be a tuple of tuples of strings which give an explicit pairing of parameters to optimize, note that it is allowed to have variable size chunks this way. Default: 50 -# method (str): How to iterate through the chunks. Should be one of: random, sequential. Default: random -# """ - -# def __init__( -# self, -# model: Model, -# initial_state: Sequence = None, -# chunks: Union[int, tuple] = 50, -# max_iter: int = 100, -# method: str = "random", -# LM_kwargs: dict = {}, -# **kwargs: Dict[str, Any], -# ) -> None: -# super().__init__(model, initial_state, max_iter=max_iter, **kwargs) - -# self.chunks = chunks -# self.method = method -# self.LM_kwargs = LM_kwargs - -# # # pixels # parameters -# self.ndf = self.model.target[self.model.window].flatten("data").numel() - len( -# self.current_state -# ) -# if self.model.target.has_mask: -# # subtract masked pixels from degrees of freedom -# self.ndf -= torch.sum(self.model.target[self.model.window].flatten("mask")).item() - -# def step(self): -# # These store the chunking information depending on which chunk mode is selected -# param_ids = list(self.model.parameters.vector_identities()) -# init_param_ids = list(self.model.parameters.vector_identities()) -# _chunk_index = 0 -# _chunk_choices = None -# res = None - -# if self.verbose > 0: -# config.logger.info("--------iter-------") - -# # Loop through all the chunks -# while True: -# chunk = torch.zeros(len(init_param_ids), dtype=torch.bool, device=config.DEVICE) -# if isinstance(self.chunks, int): -# if len(param_ids) == 0: -# break -# if self.method == "random": -# # Draw a random chunk of ids -# for pid in random.sample(param_ids, min(len(param_ids), self.chunks)): -# chunk[init_param_ids.index(pid)] = True -# else: -# # Draw the next chunk of ids -# for pid in param_ids[: self.chunks]: -# chunk[init_param_ids.index(pid)] = True -# # Remove the selected ids from the list -# for p in np.array(init_param_ids)[chunk.detach().cpu().numpy()]: -# param_ids.pop(param_ids.index(p)) -# elif isinstance(self.chunks, (tuple, list)): -# if _chunk_choices is None: -# # Make a list of the chunks as given explicitly -# _chunk_choices = list(range(len(self.chunks))) -# if self.method == "random": -# if len(_chunk_choices) == 0: -# break -# # Select a random chunk from the given groups -# sub_index = random.choice(_chunk_choices) -# _chunk_choices.pop(_chunk_choices.index(sub_index)) -# for pid in self.chunks[sub_index]: -# chunk[param_ids.index(pid)] = True -# else: -# if _chunk_index >= len(self.chunks): -# break -# # Select the next chunk in order -# for pid in self.chunks[_chunk_index]: -# chunk[param_ids.index(pid)] = True -# _chunk_index += 1 -# else: -# raise ValueError( -# "Unrecognized chunks value, should be one of int, tuple. not: {type(self.chunks)}" -# ) -# if self.verbose > 1: -# config.logger.info(str(chunk)) -# del res -# with Param_Mask(self.model.parameters, chunk): -# res = LM( -# self.model, -# ndf=self.ndf, -# **self.LM_kwargs, -# ).fit() -# if self.verbose > 0: -# config.logger.info(f"chunk loss: {res.res_loss()}") -# if self.verbose > 1: -# config.logger.info(f"chunk message: {res.message}") - -# self.loss_history.append(res.res_loss()) -# self.lambda_history.append( -# self.model.parameters.vector_representation().detach().cpu().numpy() -# ) -# if self.verbose > 0: -# config.logger.info(f"Loss: {self.loss_history[-1]}") - -# # test for convergence -# if self.iteration >= 2 and ( -# (-self.relative_tolerance * 1e-3) -# < ((self.loss_history[-2] - self.loss_history[-1]) / self.loss_history[-1]) -# < (self.relative_tolerance / 10) -# ): -# self._count_finish += 1 -# else: -# self._count_finish = 0 - -# self.iteration += 1 - -# def fit(self): -# self.iteration = 0 - -# start_fit = time() -# try: -# while True: -# self.step() -# if self.save_steps is not None: -# self.model.save( -# os.path.join( -# self.save_steps, -# f"{self.model.name}_Iteration_{self.iteration:03d}.yaml", -# ) -# ) -# if self.iteration > 2 and self._count_finish >= 2: -# self.message = self.message + "success" -# break -# elif self.iteration >= self.max_iter: -# self.message = self.message + f"fail max iterations reached: {self.iteration}" -# break - -# except KeyboardInterrupt: -# self.message = self.message + "fail interrupted" - -# self.model.parameters.vector_set_representation(self.res()) -# if self.verbose > 1: -# config.logger.info( -# f"Iter Fitting complete in {time() - start_fit} sec with message: {self.message}" -# ) - -# return self +class IterParam(BaseOptimizer): + """Optimization wrapper that call LM optimizer on subsets of variables. + + IterParam takes the full set of parameters for a model and breaks them down + into chunks as specified by the user. It then calls Levenberg-Marquardt + optimization on the subset of parameters, and iterates through all subsets + until every parameter has been optimized. It cycles through these chunks + until convergence. This method is very powerful in situations where the full + optimization problem cannot fit in memory, or where the optimization problem + is too complex to tackle as a single large problem. In full LM optimization + a single problematic parameter can ripple into issues with every other + parameter, so breaking the problem down can sometimes make an otherwise + intractable problem easier. For small problems with only a few models, it is + likely better to optimize the full problem with LM as, when it works, LM is + faster than the IterParam method. + + Args: + chunks (Union[int, tuple]): Specify how to break down the model + parameters. If an integer, at each iteration the algorithm will break the + parameters into groups of that size. If a tuple, should be a tuple of + arrays of length num_dimensions which act as selectors for the parameters + to fit (1 to include, 0 to exclude). Default: 50 + chunk_order (str): How to iterate through the chunks. Should be one of: random, + sequential. Default: sequential + """ + + def __init__( + self, + model: Model, + initial_state: Sequence = None, + chunks: Union[int, tuple] = 50, + chunk_order: Literal["random", "sequential"] = "sequential", + max_iter: int = 100, + relative_tolerance: float = 1e-5, + Lup=11.0, + Ldn=9.0, + L0=1.0, + max_step_iter: int = 10, + ndf=None, + likelihood="gaussian", + **kwargs, + ): + + super().__init__( + model, + initial_state, + max_iter=max_iter, + relative_tolerance=relative_tolerance, + **kwargs, + ) + # Maximum number of iterations of the algorithm + self.max_iter = max_iter + # Maximum number of steps while searching for chi^2 improvement on a single jacobian evaluation + self.max_step_iter = max_step_iter + self.Lup = Lup + self.Ldn = Ldn + self.L = L0 + self.likelihood = likelihood + if self.likelihood not in ["gaussian", "poisson"]: + raise ValueError(f"Unsupported likelihood: {self.likelihood}") + self.chunks = self.make_chunks(chunks) + self.chunk_order = chunk_order + + # mask + fit_mask = self.model.fit_mask() + if isinstance(fit_mask, tuple): + fit_mask = backend.concatenate(tuple(FM.flatten() for FM in fit_mask)) + else: + fit_mask = fit_mask.flatten() + if backend.sum(fit_mask).item() == 0: + fit_mask = None + + if model.target.has_mask: + mask = self.model.target[self.fit_window].flatten("mask") + if fit_mask is not None: + mask = mask | fit_mask + self.mask = ~mask + elif fit_mask is not None: + self.mask = ~fit_mask + else: + self.mask = backend.ones_like( + self.model.target[self.fit_window].flatten("data"), dtype=backend.bool + ) + if self.mask is not None and backend.sum(self.mask).item() == 0: + raise OptimizeStopSuccess("No data to fit. All pixels are masked") + + # Initialize optimizer attributes + self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] + + # 1 / (sigma^2) + kW = kwargs.get("W", None) + if kW is not None: + self.W = backend.as_array(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ + self.mask + ] + elif model.target.has_weight: + self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] + else: + self.W = backend.ones_like(self.Y) + + # The forward model which computes the output image given input parameters + self.full_forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[ + self.mask + ] + self.forward = [] + # Compute the jacobian + self.jacobian = [] + + f = lambda c, state, x: model( + window=self.fit_window, + params=backend.fill_at_indices(backend.copy(state), self.chunks[c], x), + ).flatten("data")[self.mask] + j = backend.jacfwd( + lambda c, state, x: self.model( + window=self.fit_window, + params=backend.fill_at_indices(backend.copy(state), self.chunks[c], x), + ).flatten("data")[self.mask], + argnums=2, + ) + for c in range(len(self.chunks)): + self.forward.append(partial(f, c)) + self.jacobian.append(partial(j, c)) + + # variable to store covariance matrix if it is ever computed + self._covariance_matrix = None + + # Degrees of freedom + if ndf is None: + self.ndf = max(1.0, len(self.Y) - len(self.current_state)) + else: + self.ndf = ndf + + def make_chunks(self, chunks): + if isinstance(chunks, int): + new_chunks = [] + for i in range(0, len(self.current_state), chunks): + chunk = np.zeros(len(self.current_state), dtype=bool) + chunk[i : i + chunks] = True + new_chunks.append(chunk) + chunks = new_chunks + return chunks + + def iter_chunks(self): + if self.chunk_order == "random": + chunk_ids = list(range(len(self.chunks))) + np.random.shuffle(chunk_ids) + elif self.chunk_order == "sequential": + chunk_ids = list(range(len(self.chunks))) + else: + raise ValueError( + f"Unrecognized chunk_order: {self.chunk_order}. Should be one of: random, sequential" + ) + return chunk_ids + + def chi2_ndf(self): + return ( + backend.sum(self.W * (self.Y - self.full_forward(self.current_state)) ** 2) / self.ndf + ) + + def poisson_2nll_ndf(self): + M = self.full_forward(self.current_state) + return 2 * backend.sum(M - self.Y * backend.log(M + 1e-10)) / self.ndf + + @torch.no_grad() + def fit(self, update_uncertainty=True) -> BaseOptimizer: + """This performs the fitting operation. It iterates the LM step + function until convergence is reached. Includes a message + after fitting to indicate how the fitting exited. Typically if + the message returns a "success" then the algorithm found a + minimum. This may be the desired solution, or a pathological + local minimum, this often depends on the initial conditions. + + """ + + if len(self.current_state) == 0: + if self.verbose > 0: + config.logger.warning("No parameters to optimize. Exiting fit") + self.message = "No parameters to optimize. Exiting fit" + return self + + if self.likelihood == "gaussian": + quantity = "Chi^2/DoF" + self.loss_history = [self.chi2_ndf().item()] + elif self.likelihood == "poisson": + quantity = "2NLL/DoF" + self.loss_history = [self.poisson_2nll_ndf().item()] + self._covariance_matrix = None + self.L_history = [self.L] + self.lambda_history = [backend.to_numpy(backend.copy(self.current_state))] + if self.verbose > 0: + config.logger.info( + f"==Starting LM fit for '{self.model.name}' with {len(self.current_state)} dynamic parameters and {len(self.Y)} pixels==" + ) + + for _ in range(self.max_iter): + # Report status + if self.verbose > 0: + config.logger.info(f"{quantity}: {self.loss_history[-1]:.6g}, L: {self.L:.3g}") + + # Perform fitting + chunk_L = [] + for c in self.iter_chunks(): + try: + if self.fit_valid: + with ValidContext(self.model): + valid_state = self.model.to_valid(self.current_state) + res = func.lm_step( + x=valid_state[self.chunks[c]], + data=self.Y, + model=partial(self.forward[c], valid_state), + weight=self.W, + jacobian=partial(self.jacobian[c], valid_state), + L=self.L, + Lup=self.Lup, + Ldn=self.Ldn, + likelihood=self.likelihood, + ) + self.current_state = self.model.from_valid( + backend.fill_at_indices( + valid_state, self.chunks[c], backend.copy(res["x"]) + ) + ) + else: + res = func.lm_step( + x=self.current_state[self.chunks[c]], + data=self.Y, + model=partial(self.forward[c], self.current_state), + weight=self.W, + jacobian=partial(self.jacobian[c], self.current_state), + L=self.L, + Lup=self.Lup, + Ldn=self.Ldn, + likelihood=self.likelihood, + ) + self.current_state = backend.fill_at_indices( + self.current_state, self.chunks[c], backend.copy(res["x"]) + ) + except OptimizeStopFail: + if self.verbose > 0: + config.logger.warning( + f"Could not find step to improve Chi^2 on chunk {c}, moving to next chunk" + ) + continue + except OptimizeStopSuccess as e: + continue # success on individual chunk is not enough to stop overall fit + chunk_L.append(res["L"]) + + # Record progress + self.L = np.clip(np.max(chunk_L), 1e-9, 1e9) + self.L_history.append(self.L) + self.loss_history.append(2 * res["nll"] / self.ndf) + self.lambda_history.append(backend.to_numpy(backend.copy(self.current_state))) + if self.check_convergence(): + break + + else: + self.message = self.message + "fail. Maximum iterations" + + if self.verbose > 0: + config.logger.info( + f"Final {quantity}: {np.nanmin(self.loss_history):.6g}, L: {self.L_history[np.nanargmin(self.loss_history)]:.3g}. Converged: {self.message}" + ) + + self.model.fill_dynamic_values( + backend.as_array(self.res(), dtype=config.DTYPE, device=config.DEVICE) + ) + if update_uncertainty: + self.update_uncertainty() + + return self + + def check_convergence(self) -> bool: + """Check if the optimization has converged based on the last + iteration's chi^2 and the relative tolerance. + """ + if len(self.loss_history) < 3: + return False + good_history = [self.loss_history[0]] + for l in self.loss_history[1:]: + if good_history[-1] > l: + good_history.append(l) + if len(self.loss_history) - len(good_history) >= 10: + self.message = self.message + "success by immobility. Convergence not guaranteed" + return True + if len(good_history) < 3: + return False + if (good_history[-2] - good_history[-1]) / good_history[ + -1 + ] < self.relative_tolerance and self.L < 0.1: + self.message = self.message + "success" + return True + if len(good_history) < 10: + return False + if (good_history[-10] - good_history[-1]) / good_history[-1] < self.relative_tolerance: + self.message = self.message + "success by immobility. Convergence not guaranteed" + return True + return False + + @property + @torch.no_grad() + def covariance_matrix(self): + """The covariance matrix for the model at the current + parameters. This can be used to construct a full Gaussian PDF for the + parameters using: $\\mathcal{N}(\\mu,\\Sigma)$ where $\\mu$ is the + optimized parameters and $\\Sigma$ is the covariance matrix. + + """ + + if self._covariance_matrix is not None: + return self._covariance_matrix + + N = len(self.current_state) + self._covariance_matrix = backend.zeros((N, N), dtype=config.DTYPE, device=config.DEVICE) + for c in self.iter_chunks(): + J = self.jacobian[c](self.current_state, self.current_state[self.chunks[c]]) + if self.likelihood == "gaussian": + hess = func.hessian(J, self.W) + elif self.likelihood == "poisson": + hess = func.hessian_poisson(J, self.Y, self.full_forward(self.current_state)) + try: + sub_covariance_matrix = backend.linalg.inv(hess) + except: + config.logger.warning( + "WARNING: Hessian is singular, likely at least one parameter is non-physical. Will use pseudo-inverse of Hessian to continue but results should be inspected." + ) + sub_covariance_matrix = backend.linalg.pinv(hess) + + ids = backend.meshgrid( + backend.as_array(np.arange(N)[self.chunks[c]], dtype=int, device=config.DEVICE), + backend.as_array(np.arange(N)[self.chunks[c]], dtype=int, device=config.DEVICE), + indexing="ij", + ) + self._covariance_matrix = backend.fill_at_indices( + self._covariance_matrix, (ids[0], ids[1]), sub_covariance_matrix + ) + return self._covariance_matrix + + @torch.no_grad() + def update_uncertainty(self) -> None: + """Call this function after optimization to set the uncertainties for + the parameters. This will use the diagonal of the covariance + matrix to update the uncertainties. See the covariance_matrix + function for the full representation of the uncertainties. + + """ + # set the uncertainty for each parameter + cov = self.covariance_matrix + if backend.all(backend.isfinite(cov)): + try: + self.model.fill_dynamic_value_uncertainties( + backend.sqrt(backend.abs(backend.diag(cov))) + ) + except RuntimeError as e: + config.logger.warning(f"Unable to update uncertainty due to: {e}") + else: + config.logger.warning( + "Unable to update uncertainty due to non finite covariance matrix" + ) diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index 8df2d0b5..b0f0525f 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -394,7 +394,7 @@ "source": [ "## Iterative Fit (models)\n", "\n", - "An iterative fitter is identified as `ap.fit.Iter`, this method is generally employed for large models where it is not feasible to hold all the relevant data in memory at once. The iterative fitter will cycle through the models in a `GroupModel` object and fit them one at a time to the image, using the residuals from the previous cycle. This can be a very robust way to deal with some fits, especially if the overlap between models is not too strong. It is however more dependent on good initialization than other methods like the Levenberg-Marquardt. Also, it is possible for the Iter method to get stuck in a local minimum under certain circumstances.\n", + "This iterative fitter is identified as `ap.fit.Iter`, this method is generally employed for large models where it is not feasible to hold all the relevant data in memory at once. The iterative fitter will cycle through the models in a `GroupModel` object and fit them one at a time to the image, using the residuals from the previous cycle. This can be a very robust way to deal with some fits, especially if the overlap between models is not too strong. It is however more dependent on good initialization than other methods like the Levenberg-Marquardt. Also, it is possible for the Iter method to get stuck in a local minimum under certain circumstances.\n", "\n", "Note that while the Iterative fitter needs a `GroupModel` object to iterate over, it is not necessarily true that the sub models are `ComponentModel` objects, they could be `GroupModel` objects as well. In this way it is possible to cycle through and fit \"clusters\" of objects that are nearby, so long as it doesn't consume too much memory.\n", "\n", @@ -441,6 +441,73 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Iterative Fit (Param)\n", + "\n", + "This iterative fitter is identified as `ap.fit.IterParam`, this is generally employed for large and interconnected models where it is not feasible to hold all the relevant data in memory at once. Unlike `ap.fit.Iter` which is intended to cycle through the sub models in a group model, this fitter iterates through parameters. The set of parameters which make up the model is broken into chunks and then fitting proceeds only on those chunks, rather than on all parameters simultaneously. For large models that have lots of interconnected/shared parameters, it doesn't really make sense to cycle through one sub-model at a time as optimizing that model may throw another model that is sharing a parameter into a bad part of parameter space. Thus `ap.fit.IterParam` is safe to use on any AstroPhot model without concern for this issue, the fitter will industriously proceed to high likelihood solutions monotonically. \n", + "\n", + "The tradeoff for this fitter is the same as for the other iterative fitter, if there are strong covariances in the likelihood structure then this fitter can take a long time to converge. The advantage here is that as the user you may take greater control over the combinations if you wish. The `chunks` argument can be set to an integer like `6` in which case, `6` parameters at a time will be fit (the last chunk may be smaller). Alternatively, the `chunks` parameter may be set to a tuple of numpy arrays, these should be boolean arrays that select the parameters for each chunk. For example, here is a possible `chunks` setup for a 7 parameter sersic model: `([1,1,0,0,0,0,0], [0,0,1,1,0,0,0], [0,0,0,0,1,1,1])` which makes three chunks to fit the `x,y` then `q, PA` then `n, Re, Ie` parameters. Note that you do not need to make the chunks exclusive, it is totally fine to have a parameter pop up in multiple chunks! Finally, there's the order the chunks are fit in. This can either `chunk_order=\"sequential\"` the default the chunks are fit in the order given, or `chunk_order=\"random\"` where each iteration a new random order is decided for the chunks to be evaluated." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "MODEL = initialize_model(target, False)\n", + "\n", + "res_iterparam = ap.fit.IterParam(MODEL, chunks=5, verbose=1).fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "MODEL_init = initialize_model(target, False)\n", + "fig, axarr = plt.subplots(1, 4, figsize=(24, 5))\n", + "plt.subplots_adjust(wspace=0.1)\n", + "ap.plots.model_image(fig, axarr[0], MODEL_init)\n", + "axarr[0].set_title(\"Model before optimization\")\n", + "ap.plots.residual_image(fig, axarr[1], MODEL_init, normalize_residuals=True)\n", + "axarr[1].set_title(\"Residuals before optimization\")\n", + "\n", + "ap.plots.model_image(fig, axarr[2], MODEL)\n", + "axarr[2].set_title(\"Model after optimization\")\n", + "ap.plots.residual_image(fig, axarr[3], MODEL, normalize_residuals=True)\n", + "axarr[3].set_title(\"Residuals after optimization\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `ap.fit.IterParam` fitter can also generate a covariance matrix of uncertainties, just keep in mind that it only evaluates the covariances for parameters in the same chunk." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "param_names = list(MODEL.build_params_array_names())\n", + "set, sky = true_params()\n", + "corner_plot_covariance(\n", + " res_iterparam.covariance_matrix.detach().cpu().numpy(),\n", + " MODEL.build_params_array().detach().cpu().numpy(),\n", + " labels=param_names,\n", + " figsize=(20, 20),\n", + " true_values=np.concatenate((sky, set.ravel())),\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/tests/test_fit.py b/tests/test_fit.py index bc263492..73a2eb33 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -68,26 +68,27 @@ def sersic_model(): @pytest.mark.parametrize( - "fitter", + "fitter,extra", [ - ap.fit.LM, - ap.fit.LMfast, - ap.fit.Grad, - ap.fit.ScipyFit, - ap.fit.MHMCMC, - ap.fit.HMC, - ap.fit.MiniFit, - ap.fit.Slalom, + (ap.fit.LM, {}), + (ap.fit.LMfast, {}), + (ap.fit.IterParam, {"chunks": 3, "method": "sequential", "verbose": 2}), + (ap.fit.Grad, {}), + (ap.fit.ScipyFit, {}), + (ap.fit.MHMCMC, {}), + (ap.fit.HMC, {}), + (ap.fit.MiniFit, {}), + (ap.fit.Slalom, {}), ], ) -def test_fitters(fitter, sersic_model): +def test_fitters(fitter, extra, sersic_model): if ap.backend.backend == "jax" and fitter in [ap.fit.Grad, ap.fit.HMC]: pytest.skip("Grad and HMC not implemented for JAX backend") model = sersic_model model.initialize() ll_init = model.gaussian_log_likelihood() pll_init = model.poisson_log_likelihood() - result = fitter(model, max_iter=100).fit() + result = fitter(model, max_iter=100, **extra).fit() ll_final = model.gaussian_log_likelihood() pll_final = model.poisson_log_likelihood() assert ll_final > ll_init, f"{fitter.__name__} should improve the log likelihood" From 9c0ea861164246588ed39be8d6c84764f67de53e Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 17 Sep 2025 20:15:27 -0400 Subject: [PATCH 139/191] add dynamic params array index function --- astrophot/param/module.py | 8 ++++++++ astrophot/plots/image.py | 1 + 2 files changed, 9 insertions(+) diff --git a/astrophot/param/module.py b/astrophot/param/module.py index 1a4773da..a6e0a9d2 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -73,3 +73,11 @@ def fill_dynamic_value_uncertainties(self, uncertainty): pos += size if pos != uncertainty.shape[-1]: raise FillDynamicParamsArrayError(self.name, uncertainty, dynamic_params) + + def dynamic_params_array_index(self, param): + i = 0 + for p in self.dynamic_params: + if p is param: + return list(range(i, i + max(1, prod(p.shape)))) + i += max(1, prod(p.shape)) + raise ValueError(f"Param {param.name} not found in dynamic_params of Module {self.name}") diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index cd78879c..d1484a31 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -350,6 +350,7 @@ def residual_image( showcbar=showcbar, clb_label=clb_label, normalize_residuals=normalize_residuals, + scaling=scaling, **kwargs, ) return fig, ax From 0e1bb4f7a386ed1b1f5a02e8164d15798d83274a Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 22 Oct 2025 12:23:48 -0400 Subject: [PATCH 140/191] quick fix --- astrophot/fit/func/lm.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index b5967760..2c5f1371 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -71,8 +71,8 @@ def lm_step( likelihood="gaussian", ): L0 = L - M0 = model(x) # (M,) - J = jacobian(x) # (M, N) + M0 = model(x).detach() # (M,) # fixme detach to backend + J = jacobian(x).detach() # (M, N) if likelihood == "gaussian": nll0 = nll(data, M0, weight).item() # torch.sum(weight * R**2).item() / ndf @@ -85,6 +85,8 @@ def lm_step( else: raise ValueError(f"Unsupported likelihood: {likelihood}") + del J + if backend.allclose(grad, backend.zeros_like(grad)): raise OptimizeStopSuccess("Gradient is zero, optimization converged.") From 977248a6d24f4ae9a6fbba72bce51f7d063e5dc2 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 23 Oct 2025 12:45:25 -0400 Subject: [PATCH 141/191] fix radial median profile --- astrophot/plots/profile.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index b66c8a9c..6221edbb 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -126,6 +126,11 @@ def radial_median_profile( R = backend.to_numpy(R) dat = backend.to_numpy(image.data) + if image.has_mask: # remove masked pixels + mask = backend.to_numpy(image.mask) + dat = dat[~mask] + R = R[~mask] + count, bins, binnum = binned_statistic( R.ravel(), dat.ravel(), From 1e9f98527c881f7952058a87033f2675b5f11421 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 23 Oct 2025 12:47:27 -0400 Subject: [PATCH 142/191] remove unneeded comment --- astrophot/plots/profile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 6221edbb..48c4a8e4 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -9,7 +9,6 @@ from ..backend_obj import backend from ..models import Model -# from ..models import Warp_Galaxy from ..utils.conversions.units import flux_to_sb from .visuals import * From b08905a8f9312a028cfacc032cab05ebf242ce1f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 07:24:59 +0000 Subject: [PATCH 143/191] build(deps): bump actions/download-artifact from 5 to 6 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 6. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cd.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 8e937ae6..aa70238a 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -49,7 +49,7 @@ jobs: name: Install Python with: python-version: "3.10" - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: name: artifact path: dist @@ -91,7 +91,7 @@ jobs: if: github.event_name == 'release' && github.event.action == 'published' steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: name: artifact path: dist From 8079f93f4ca6ee4ee9b6af478820cddd18a5c26c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 29 Oct 2025 14:21:16 -0400 Subject: [PATCH 144/191] changing data transpose to be invisible to user --- astrophot/fit/minifit.py | 2 +- astrophot/image/cmos_image.py | 4 +- astrophot/image/image_object.py | 104 ++++++++++--------- astrophot/image/jacobian_image.py | 2 +- astrophot/image/mixins/cmos_mixin.py | 2 +- astrophot/image/mixins/data_mixin.py | 36 ++++--- astrophot/image/model_image.py | 2 +- astrophot/image/psf_image.py | 14 +-- astrophot/image/sip_image.py | 11 +- astrophot/image/target_image.py | 21 +++- astrophot/models/_shared_methods.py | 4 +- astrophot/models/airy.py | 2 +- astrophot/models/basis.py | 2 +- astrophot/models/bilinear_sky.py | 6 +- astrophot/models/edgeon.py | 5 +- astrophot/models/flatsky.py | 6 +- astrophot/models/gaussian_ellipsoid.py | 4 +- astrophot/models/group_model_object.py | 18 ++-- astrophot/models/mixins/sample.py | 14 +-- astrophot/models/mixins/transform.py | 4 +- astrophot/models/model_object.py | 11 +- astrophot/models/multi_gaussian_expansion.py | 4 +- astrophot/models/pixelated_psf.py | 2 +- astrophot/models/planesky.py | 5 +- astrophot/models/point_source.py | 13 ++- astrophot/models/psf_model_object.py | 2 +- astrophot/plots/image.py | 14 +-- astrophot/plots/profile.py | 4 +- tests/test_fit.py | 4 +- tests/test_image.py | 52 +++++----- tests/test_image_list.py | 6 +- tests/utils.py | 4 +- 32 files changed, 216 insertions(+), 168 deletions(-) diff --git a/astrophot/fit/minifit.py b/astrophot/fit/minifit.py index 350697ea..fe46921e 100644 --- a/astrophot/fit/minifit.py +++ b/astrophot/fit/minifit.py @@ -53,7 +53,7 @@ def fit(self) -> BaseOptimizer: target_area = self.model.target[self.model.window] while True: small_target = target_area.reduce(self.downsample_factor) - if np.prod(small_target.shape) < self.max_pixels: + if np.prod(small_target._data.shape) < self.max_pixels: break self.downsample_factor += 1 diff --git a/astrophot/image/cmos_image.py b/astrophot/image/cmos_image.py index 8c36d726..cc9e6766 100644 --- a/astrophot/image/cmos_image.py +++ b/astrophot/image/cmos_image.py @@ -10,7 +10,7 @@ class CMOSModelImage(CMOSMixin, ModelImage): def fluxdensity_to_flux(self): # CMOS pixels only sensitive in sub area, so scale the flux density - self._data = self.data * self.pixel_area * self.subpixel_scale**2 + self._data = self._data * self.pixel_area * self.subpixel_scale**2 class CMOSTargetImage(CMOSMixin, TargetImage): @@ -28,7 +28,7 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> CMOSModelIma kwargs = { "subpixel_loc": self.subpixel_loc, "subpixel_scale": self.subpixel_scale, - "_data": backend.zeros(self.data.shape[:2], dtype=config.DTYPE, device=config.DEVICE), + "_data": backend.zeros(self._data.shape[:2], dtype=config.DTYPE, device=config.DEVICE), "CD": self.CD.value, "crpix": self.crpix, "crtan": self.crtan.value, diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 6f5fa351..b8b05e26 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -130,7 +130,7 @@ def __init__( @property def data(self): """The image data, which is a tensor of pixel values.""" - return self._data + return backend.transpose(self._data, 1, 0) @data.setter def data(self, value: Optional[ArrayLike]): @@ -167,17 +167,17 @@ def zeropoint(self, value): @property def window(self) -> Window: - return Window(window=((0, 0), self.data.shape[:2]), image=self) + return Window(window=((0, 0), self._data.shape[:2]), image=self) @property def center(self): - shape = backend.as_array(self.data.shape[:2], dtype=config.DTYPE, device=config.DEVICE) + shape = backend.as_array(self._data.shape[:2], dtype=config.DTYPE, device=config.DEVICE) return backend.stack(self.pixel_to_plane(*((shape - 1) / 2))) - @property - def shape(self): - """The shape of the image data.""" - return self.data.shape + # @property + # def shape(self): + # """The shape of the image data.""" + # return self.data.shape @property @forward @@ -250,19 +250,19 @@ def pixel_to_world(self, i: ArrayLike, j: ArrayLike) -> Tuple[ArrayLike, ArrayLi def pixel_center_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" - return func.pixel_center_meshgrid(self.shape, config.DTYPE, config.DEVICE) + return func.pixel_center_meshgrid(self._data.shape, config.DTYPE, config.DEVICE) def pixel_corner_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" - return func.pixel_corner_meshgrid(self.shape, config.DTYPE, config.DEVICE) + return func.pixel_corner_meshgrid(self._data.shape, config.DTYPE, config.DEVICE) def pixel_simpsons_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" - return func.pixel_simpsons_meshgrid(self.shape, config.DTYPE, config.DEVICE) + return func.pixel_simpsons_meshgrid(self._data.shape, config.DTYPE, config.DEVICE) def pixel_quad_meshgrid(self, order=3) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" - return func.pixel_quad_meshgrid(self.shape, config.DTYPE, config.DEVICE, order=order) + return func.pixel_quad_meshgrid(self._data.shape, config.DTYPE, config.DEVICE, order=order) @forward def coordinate_center_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: @@ -290,7 +290,7 @@ def coordinate_quad_meshgrid(self, order=3) -> Tuple[ArrayLike, ArrayLike]: def copy_kwargs(self, **kwargs) -> dict: kwargs = { - "_data": backend.copy(self.data), + "_data": backend.copy(self._data), "CD": self.CD.value, "crpix": self.crpix, "crval": self.crval.value, @@ -316,7 +316,7 @@ def blank_copy(self, **kwargs): """ kwargs = { - "_data": backend.zeros_like(self.data), + "_data": backend.zeros_like(self._data), **kwargs, } return self.copy(**kwargs) @@ -332,28 +332,28 @@ def crop(self, pixels: Union[int, Tuple[int, int], Tuple[int, int, int, int]], * crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) """ if isinstance(pixels, int): - data = self.data[ - pixels : self.data.shape[0] - pixels, - pixels : self.data.shape[1] - pixels, + data = self._data[ + pixels : self._data.shape[0] - pixels, + pixels : self._data.shape[1] - pixels, ] crpix = self.crpix - pixels elif len(pixels) == 1: # same crop in all dimension crop = pixels if isinstance(pixels, int) else pixels[0] - data = self.data[ - crop : self.data.shape[0] - crop, - crop : self.data.shape[1] - crop, + data = self._data[ + crop : self._data.shape[0] - crop, + crop : self._data.shape[1] - crop, ] crpix = self.crpix - crop elif len(pixels) == 2: # different crop in each dimension - data = self.data[ - pixels[0] : self.data.shape[0] - pixels[0], - pixels[1] : self.data.shape[1] - pixels[1], + data = self._data[ + pixels[0] : self._data.shape[0] - pixels[0], + pixels[1] : self._data.shape[1] - pixels[1], ] crpix = self.crpix - pixels elif len(pixels) == 4: # different crop on all sides - data = self.data[ - pixels[0] : self.data.shape[0] - pixels[1], - pixels[2] : self.data.shape[1] - pixels[3], + data = self._data[ + pixels[0] : self._data.shape[0] - pixels[1], + pixels[2] : self._data.shape[1] - pixels[3], ] crpix = self.crpix - pixels[0::2] else: @@ -381,10 +381,10 @@ def reduce(self, scale: int, **kwargs): if scale == 1: return self - MS = self.data.shape[0] // scale - NS = self.data.shape[1] // scale + MS = self._data.shape[0] // scale + NS = self._data.shape[1] // scale - data = self.data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) + data = self._data[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) CD = self.CD.value * scale crpix = (self.crpix + 0.5) / scale - 0.5 return self.copy( @@ -429,7 +429,7 @@ def fits_info(self) -> dict: def fits_images(self): return [ fits.PrimaryHDU( - backend.to_numpy(backend.transpose(self.data, 1, 0)), + backend.to_numpy(backend.transpose(self._data, 1, 0)), header=fits.Header(self.fits_info()), ) ] @@ -481,13 +481,13 @@ def corners( ) -> Tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike]: pixel_lowleft = backend.make_array((-0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE) pixel_lowright = backend.make_array( - (self.data.shape[0] - 0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE + (self._data.shape[0] - 0.5, -0.5), dtype=config.DTYPE, device=config.DEVICE ) pixel_upleft = backend.make_array( - (-0.5, self.data.shape[1] - 0.5), dtype=config.DTYPE, device=config.DEVICE + (-0.5, self._data.shape[1] - 0.5), dtype=config.DTYPE, device=config.DEVICE ) pixel_upright = backend.make_array( - (self.data.shape[0] - 0.5, self.data.shape[1] - 0.5), + (self._data.shape[0] - 0.5, self._data.shape[1] - 0.5), dtype=config.DTYPE, device=config.DEVICE, ) @@ -500,25 +500,25 @@ def corners( @torch.no_grad() def get_indices(self, other: Window): if other.image is self: - return slice(max(0, other.i_low), min(self.shape[0], other.i_high)), slice( - max(0, other.j_low), min(self.shape[1], other.j_high) + return slice(max(0, other.i_low), min(self._data.shape[0], other.i_high)), slice( + max(0, other.j_low), min(self._data.shape[1], other.j_high) ) shift = np.round(self.crpix - other.crpix).astype(int) return slice( - min(max(0, other.i_low + shift[0]), self.shape[0]), - max(0, min(other.i_high + shift[0], self.shape[0])), + min(max(0, other.i_low + shift[0]), self._data.shape[0]), + max(0, min(other.i_high + shift[0], self._data.shape[0])), ), slice( - min(max(0, other.j_low + shift[1]), self.shape[1]), - max(0, min(other.j_high + shift[1], self.shape[1])), + min(max(0, other.j_low + shift[1]), self._data.shape[1]), + max(0, min(other.j_high + shift[1], self._data.shape[1])), ) @torch.no_grad() def get_other_indices(self, other: Window): if other.image == self: shape = other.shape - return slice(max(0, -other.i_low), min(self.shape[0] - other.i_low, shape[0])), slice( - max(0, -other.j_low), min(self.shape[1] - other.j_low, shape[1]) - ) + return slice( + max(0, -other.i_low), min(self._data.shape[0] - other.i_low, shape[0]) + ), slice(max(0, -other.j_low), min(self._data.shape[1] - other.j_low, shape[1])) raise ValueError() def get_window(self, other: Union[Window, "Image"], indices=None, **kwargs): @@ -531,7 +531,7 @@ def get_window(self, other: Union[Window, "Image"], indices=None, **kwargs): if indices is None: indices = self.get_indices(other if isinstance(other, Window) else other.window) new_img = self.copy( - _data=self.data[indices], + _data=self._data[indices], crpix=self.crpix - np.array((indices[0].start, indices[1].start)), **kwargs, ) @@ -540,21 +540,21 @@ def get_window(self, other: Union[Window, "Image"], indices=None, **kwargs): def __sub__(self, other): if isinstance(other, Image): new_img = self[other] - new_img._data = new_img.data - other[self].data + new_img._data = new_img._data - other[self]._data return new_img else: new_img = self.copy() - new_img._data = new_img.data - other + new_img._data = new_img._data - other return new_img def __add__(self, other): if isinstance(other, Image): new_img = self[other] - new_img._data = new_img.data + other[self].data + new_img._data = new_img._data + other[self]._data return new_img else: new_img = self.copy() - new_img._data = new_img.data + other + new_img._data = new_img._data + other return new_img def __iadd__(self, other): @@ -562,10 +562,10 @@ def __iadd__(self, other): self._data = backend.add_at_indices( self._data, self.get_indices(other.window), - other.data[other.get_indices(self.window)], + other._data[other.get_indices(self.window)], ) else: - self._data = self.data + other + self._data = self._data + other return self def __isub__(self, other): @@ -573,10 +573,10 @@ def __isub__(self, other): self._data = backend.add_at_indices( self._data, self.get_indices(other.window), - -other.data[other.get_indices(self.window)], + -other._data[other.get_indices(self.window)], ) else: - self._data = self.data - other + self._data = self._data - other return self def __getitem__(self, *args): @@ -598,6 +598,10 @@ def __init__(self, images, name=None): def data(self): return tuple(image.data for image in self.images) + @property + def _data(self): + return tuple(image._data for image in self.images) + def copy(self): return self.__class__( tuple(image.copy() for image in self.images), diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 9f130e49..caaef243 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -52,7 +52,7 @@ def __iadd__(self, other: "JacobianImage"): self._data = backend.add_at_indices( self._data, self_indices + (self_i,), - other.data[other_indices[0], other_indices[1], other_i], + other._data[other_indices[0], other_indices[1], other_i], ) return self diff --git a/astrophot/image/mixins/cmos_mixin.py b/astrophot/image/mixins/cmos_mixin.py index c3029de2..f3ac2c05 100644 --- a/astrophot/image/mixins/cmos_mixin.py +++ b/astrophot/image/mixins/cmos_mixin.py @@ -32,7 +32,7 @@ def base_scale(self): def pixel_center_meshgrid(self): """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" return func.cmos_pixel_center_meshgrid( - self.shape, self.subpixel_loc, config.DTYPE, config.DEVICE + self._data.shape, self.subpixel_loc, config.DTYPE, config.DEVICE ) def copy(self, **kwargs): diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index a6db60f1..9b4e4486 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -59,8 +59,8 @@ def __init__( self.weight = weight # Set nan pixels to be masked automatically - if backend.any(backend.isnan(self.data)).item(): - self._mask = self.mask | backend.isnan(self.data) + if backend.any(backend.isnan(self._data)).item(): + self._mask = self.mask | backend.isnan(self._data) @property def std(self): @@ -114,9 +114,15 @@ def variance(self): """ if self.has_variance: - return backend.where(self._weight == 0, backend.inf, 1 / self._weight) + return backend.where(self.weight == 0, backend.inf, 1 / self.weight) return backend.ones_like(self.data) + @property + def _variance(self): + if self.has_variance: + return backend.where(self._weight == 0, backend.inf, 1 / self._weight) + return backend.ones_like(self._data) + @variance.setter def variance(self, variance): if variance is None: @@ -166,7 +172,7 @@ def weight(self): """ if self.has_weight: - return self._weight + return backend.transpose(self._weight, 1, 0) return backend.ones_like(self.data) @weight.setter @@ -175,11 +181,11 @@ def weight(self, weight): self._weight = None return if isinstance(weight, str) and weight == "auto": - weight = 1 / auto_variance(self.data, self.mask).T + weight = 1 / auto_variance(self.data, self.mask) self._weight = backend.transpose( backend.as_array(weight, dtype=config.DTYPE, device=config.DEVICE), 1, 0 ) - if self._weight.shape != self.data.shape: + if self._weight.shape != self._data.shape: self._weight = None raise SpecificationConflict( f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" @@ -214,7 +220,7 @@ def mask(self): """ if self.has_mask: - return self._mask + return backend.transpose(self._mask, 1, 0) return backend.zeros_like(self.data, dtype=backend.bool) @mask.setter @@ -225,7 +231,7 @@ def mask(self, mask): self._mask = backend.transpose( backend.as_array(mask, dtype=backend.bool, device=config.DEVICE), 1, 0 ) - if self._mask.shape != self.data.shape: + if self._mask.shape != self._data.shape: self._mask = None raise SpecificationConflict( f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" @@ -282,13 +288,11 @@ def get_window(self, other: Union[Image, Window], indices=None, **kwargs): def fits_images(self): images = super().fits_images() if self.has_weight: - images.append( - fits.ImageHDU(backend.to_numpy(backend.transpose(self.weight, 1, 0)), name="WEIGHT") - ) + images.append(fits.ImageHDU(backend.to_numpy(self.weight), name="WEIGHT")) if self.has_mask: images.append( fits.ImageHDU( - backend.to_numpy(backend.transpose(self.mask, 1, 0)).astype(int), + backend.to_numpy(self.mask).astype(int), name="MASK", ) ) @@ -319,15 +323,15 @@ def reduce(self, scale: int, **kwargs) -> Image: across and the pixelscale will be 3. """ - MS = self.data.shape[0] // scale - NS = self.data.shape[1] // scale + MS = self._data.shape[0] // scale + NS = self._data.shape[1] // scale return super().reduce( scale=scale, _weight=( 1 / backend.sum( - self.variance[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), + self._variance[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), dim=(1, 3), ) if self.has_variance @@ -335,7 +339,7 @@ def reduce(self, scale: int, **kwargs) -> Image: ), _mask=( backend.max( - self.mask[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), dim=(1, 3) + self._mask[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), dim=(1, 3) ) if self.has_mask else None diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 3a2d0fdf..3d969338 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -15,7 +15,7 @@ class ModelImage(Image): """ def fluxdensity_to_flux(self): - self._data = self.data * self.pixel_area + self._data = self._data * self.pixel_area ###################################################################### diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 95aeec0c..aba5ebc6 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -23,18 +23,18 @@ class PSFImage(DataMixin, Image): def __init__(self, *args, **kwargs): kwargs.update({"crval": (0, 0), "crpix": (0, 0), "crtan": (0, 0)}) super().__init__(*args, **kwargs) - self.crpix = (np.array(self.data.shape, dtype=np.float64) - 1.0) / 2 + self.crpix = (np.array(self._data.shape[:2], dtype=np.float64) - 1.0) / 2 def normalize(self): """Normalizes the PSF image to have a sum of 1.""" - norm = backend.sum(self.data) - self._data = self.data / norm + norm = backend.sum(self._data) + self._data = self._data / norm if self.has_weight: self._weight = self.weight * norm**2 @property def psf_pad(self) -> int: - return max(self.data.shape) // 2 + return max(self._data.shape[:2]) // 2 def jacobian_image( self, @@ -50,7 +50,7 @@ def jacobian_image( parameters = [] elif data is None: data = backend.zeros( - (*self.data.shape, len(parameters)), + (*self._data.shape, len(parameters)), dtype=config.DTYPE, device=config.DEVICE, ) @@ -63,14 +63,14 @@ def jacobian_image( "identity": self.identity, **kwargs, } - return JacobianImage(parameters=parameters, data=data, **kwargs) + return JacobianImage(parameters=parameters, _data=data, **kwargs) def model_image(self, **kwargs) -> "PSFImage": """ Construct a blank `ModelImage` object formatted like this current `TargetImage` object. Mostly used internally. """ kwargs = { - "data": backend.zeros_like(self.data), + "_data": backend.zeros_like(self._data), "CD": self.CD.value, "crpix": self.crpix, "crtan": self.crtan.value, diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index ab0265cc..8e921be7 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -62,8 +62,8 @@ def reduce(self, scale: int, **kwargs): if scale == 1: return self - MS = self.data.shape[0] // scale - NS = self.data.shape[1] // scale + MS = self._data.shape[0] // scale + NS = self._data.shape[1] // scale kwargs = { "pixel_area_map": ( @@ -96,7 +96,7 @@ def reduce(self, scale: int, **kwargs): ) def fluxdensity_to_flux(self): - self._data = self.data * self.pixel_area_map + self._data = self._data * self.pixel_area_map class SIPTargetImage(SIPMixin, TargetImage): @@ -147,7 +147,10 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> SIPModelImag "distortion_ij": new_distortion_ij, "distortion_IJ": new_distortion_IJ, "_data": backend.zeros( - (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), + ( + self._data.shape[0] * upsample + 2 * pad, + self._data.shape[1] * upsample + 2 * pad, + ), dtype=config.DTYPE, device=config.DEVICE, ), diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index fd8e38d4..4be6780b 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -150,7 +150,7 @@ def fits_images(self): if isinstance(self.psf, PSFImage): images.append( fits.ImageHDU( - backend.to_numpy(backend.transpose(self.psf.data, 1, 0)), + backend.to_numpy(self.psf.data), name="PSF", header=fits.Header(self.psf.fits_info()), ) @@ -186,7 +186,7 @@ def jacobian_image( """ if data is None: data = backend.zeros( - (*self.data.shape, len(parameters)), + (*self._data.shape, len(parameters)), dtype=config.DTYPE, device=config.DEVICE, ) @@ -208,7 +208,10 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> ModelImage: """ kwargs = { "_data": backend.zeros( - (self.data.shape[0] * upsample + 2 * pad, self.data.shape[1] * upsample + 2 * pad), + ( + self._data.shape[0] * upsample + 2 * pad, + self._data.shape[1] * upsample + 2 * pad, + ), dtype=config.DTYPE, device=config.DEVICE, ), @@ -264,6 +267,10 @@ def __init__(self, *args, **kwargs): def variance(self): return tuple(image.variance for image in self.images) + @property + def _variance(self): + return tuple(image._variance for image in self.images) + @variance.setter def variance(self, variance): for image, var in zip(self.images, variance): @@ -277,6 +284,10 @@ def has_variance(self): def weight(self): return tuple(image.weight for image in self.images) + @property + def _weight(self): + return tuple(image._weight for image in self.images) + @weight.setter def weight(self, weight): for image, wgt in zip(self.images, weight): @@ -302,6 +313,10 @@ def model_image(self) -> ModelImageList: def mask(self): return tuple(image.mask for image in self.images) + @property + def _mask(self): + return tuple(image._mask for image in self.images) + @mask.setter def mask(self, mask): for image, M in zip(self.images, mask): diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 7e090c47..f4bd2b2e 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -17,10 +17,10 @@ def _sample_image( angle_range=None, cycle=2 * np.pi, ): - dat = backend.to_numpy(image.data).copy() + dat = backend.to_numpy(image._data).copy() # Fill masked pixels if image.has_mask: - mask = backend.to_numpy(image.mask) + mask = backend.to_numpy(image._mask) dat[mask] = np.median(dat[~mask]) # Subtract median of edge pixels to avoid effect of nearby sources edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index 115e0acb..b1211afa 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -60,7 +60,7 @@ def initialize(self): icenter = self.target.plane_to_pixel(*self.center.value) if not self.I0.initialized: - mid_chunk = self.target.data[ + mid_chunk = self.target._data[ int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index cdcfb53a..6b2d11bc 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -78,7 +78,7 @@ def initialize(self): order = int(self.basis.split(":")[1]) nm = func.zernike_n_m_list(order) N = int( - target_area.data.shape[0] * self.target.pixelscale.item() / self.scale.value.item() + target_area._data.shape[0] * self.target.pixelscale.item() / self.scale.value.item() ) X, Y = np.meshgrid( np.linspace(-1, 1, N) * (N - 1) / N, diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py index c63c400f..22562e20 100644 --- a/astrophot/models/bilinear_sky.py +++ b/astrophot/models/bilinear_sky.py @@ -51,16 +51,16 @@ def initialize(self): self.PA.value = np.arccos(np.abs(R[0, 0])) if not self.scale.initialized: self.scale.value = ( - self.target.pixelscale.item() * self.target.data.shape[0] / self.nodes[0] + self.target.pixelscale.item() * self.target._data.shape[0] / self.nodes[0] ) if self.I.initialized: return target_dat = self.target[self.window] - dat = backend.to_numpy(target_dat.data).copy() + dat = backend.to_numpy(target_dat._data).copy() if self.target.has_mask: - mask = backend.to_numpy(target_dat.mask).copy() + mask = backend.to_numpy(target_dat._mask).copy() dat[mask] = np.nanmedian(dat) iS = dat.shape[0] // self.nodes[0] jS = dat.shape[1] // self.nodes[1] diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index 3415fe9f..a746d530 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -35,7 +35,10 @@ def initialize(self): if self.PA.initialized: return target_area = self.target[self.window] - dat = backend.to_numpy(target_area.data).copy() + dat = backend.to_numpy(target_area._data).copy() + if target_area.has_mask: + mask = backend.to_numpy(target_area._mask) + dat[mask] = np.median(dat[~mask]) edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) dat = dat - edge_average diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 19b07a58..5b4363cf 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -33,7 +33,11 @@ def initialize(self): if self.I.initialized: return - dat = backend.to_numpy(self.target[self.window].data).copy() + target_area = self.target[self.window] + dat = backend.to_numpy(target_area._data).copy() + if target_area.has_mask: + mask = backend.to_numpy(target_area._mask) + dat[mask] = np.median(dat[~mask]) self.I.dynamic_value = np.median(dat) / self.target.pixel_area.item() @forward diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index b11fe939..c948ac56 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -76,9 +76,9 @@ def initialize(self): self.alpha = 0.0 target_area = self.target[self.window] - dat = backend.to_numpy(target_area.data).copy() + dat = backend.to_numpy(target_area._data).copy() if target_area.has_mask: - mask = backend.to_numpy(target_area.mask).copy() + mask = backend.to_numpy(target_area._mask).copy() dat[mask] = np.median(dat[~mask]) edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 8b65906d..0cc1bf0b 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -110,7 +110,7 @@ def initialize(self): config.logger.info(f"Initializing model {model.name}") model.initialize() - def fit_mask(self) -> torch.Tensor: + def _fit_mask(self) -> torch.Tensor: """Returns a mask for the target image which is the combination of all the fit masks of the sub models. This mask is used when the multiple models in the group model do not completely overlap with each other, thus @@ -120,10 +120,10 @@ def fit_mask(self) -> torch.Tensor: """ subtarget = self.target[self.window] if isinstance(subtarget, ImageList): - mask = list(backend.ones_like(submask) for submask in subtarget.mask) + mask = list(backend.ones_like(submask) for submask in subtarget._mask) for model in self.models: model_subtarget = model.target[model.window] - model_fit_mask = model.fit_mask() + model_fit_mask = model._fit_mask() if isinstance(model_subtarget, ImageList): for target, submask in zip(model_subtarget, model_fit_mask): index = subtarget.index(target) @@ -141,14 +141,20 @@ def fit_mask(self) -> torch.Tensor: ) mask = tuple(mask) else: - mask = backend.ones_like(subtarget.mask) + mask = backend.ones_like(subtarget._mask) for model in self.models: model_subtarget = model.target[model.window] group_indices = subtarget.get_indices(model.window) model_indices = model_subtarget.get_indices(subtarget.window) - mask = backend.and_at_indices(mask, group_indices, model.fit_mask()[model_indices]) + mask = backend.and_at_indices(mask, group_indices, model._fit_mask()[model_indices]) return mask + def fit_mask(self) -> torch.Tensor: + mask = self._fit_mask() + if isinstance(mask, tuple): + return tuple(backend.transpose(m, 1, 0) for m in mask) + return backend.transpose(mask, 1, 0) + def match_window(self, image: Union[Image, ImageList], window: Window, model: Model) -> Window: if isinstance(image, ImageList) and isinstance(model.target, ImageList): indices = image.match_indices(model.target) @@ -189,7 +195,7 @@ def _ensure_vmap_compatible( self._ensure_vmap_compatible(image, img) return if image.identity == other.identity: - image += backend.zeros_like(other.data[0, 0]) + image += backend.zeros_like(other._data[0, 0]) @forward def sample( diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 46defb91..331356ce 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -113,7 +113,7 @@ def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: @forward def sample_image(self, image: Image) -> ArrayLike: if self.sampling_mode == "auto": - N = np.prod(image.data.shape) + N = np.prod(image._data.shape[:2]) if N <= 100: sampling_mode = "quad:5" elif N <= 10000: @@ -161,7 +161,7 @@ def _jacobian( return backend.jacobian( lambda x: self.sample( window=window, params=backend.concatenate((params_pre, x, params_post), dim=-1) - ).data, + )._data, params, ) @@ -228,16 +228,16 @@ def gradient( jacobian_image = self.jacobian(window=window, params=params) - data = self.target[window].data - model = self.sample(window=window).data + data = self.target[window]._data + model = self.sample(window=window)._data if likelihood == "gaussian": - weight = self.target[window].weight + weight = self.target[window]._weight gradient = backend.sum( - jacobian_image.data * ((data - model) * weight)[..., None], dim=(0, 1) + jacobian_image._data * ((data - model) * weight)[..., None], dim=(0, 1) ) elif likelihood == "poisson": gradient = backend.sum( - jacobian_image.data * (1 - data / model)[..., None], + jacobian_image._data * (1 - data / model)[..., None], dim=(0, 1), ) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 8ddffa14..6bbffc8e 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -50,9 +50,9 @@ def initialize(self): if self.PA.initialized and self.q.initialized: return target_area = self.target[self.window] - dat = backend.to_numpy(backend.copy(target_area.data)) + dat = backend.to_numpy(backend.copy(target_area._data)) if target_area.has_mask: - mask = backend.to_numpy(backend.copy(target_area.mask)) + mask = backend.to_numpy(backend.copy(target_area._mask)) dat[mask] = np.median(dat[~mask]) edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index eae8ef85..6d6b3e40 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -14,7 +14,7 @@ from ..utils.initialize import recursive_center_of_mass from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from .. import config -from ..backend_obj import backend, ArrayLike +from ..backend_obj import backend from ..errors import InvalidTarget from .mixins import SampleMixin @@ -126,9 +126,9 @@ def initialize(self): return target_area = self.target[self.window] - dat = np.copy(backend.to_numpy(target_area.data)) + dat = np.copy(backend.to_numpy(target_area._data)) if target_area.has_mask: - mask = backend.to_numpy(target_area.mask) + mask = backend.to_numpy(target_area._mask) dat[mask] = np.nanmedian(dat[~mask]) COM = recursive_center_of_mass(dat) @@ -142,6 +142,9 @@ def initialize(self): def fit_mask(self): return backend.zeros_like(self.target[self.window].mask, dtype=backend.bool) + def _fit_mask(self): + return backend.zeros_like(self.target[self.window]._mask, dtype=backend.bool) + @forward def transform_coordinates(self, x, y, center): return x - center[0], y - center[1] @@ -182,7 +185,7 @@ def sample( upsample=self.psf_upscale, pad=psf.psf_pad ) sample = self.sample_image(working_image) - working_image._data = func.convolve(sample, psf.data) + working_image._data = func.convolve(sample, psf._data) working_image = working_image.crop(psf.psf_pad).reduce(self.psf_upscale) else: diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index b6097363..2ab3e0bb 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -55,9 +55,9 @@ def initialize(self): super().initialize() target_area = self.target[self.window] - dat = backend.to_numpy(target_area.data).copy() + dat = backend.to_numpy(target_area._data).copy() if target_area.has_mask: - mask = backend.to_numpy(target_area.mask) + mask = backend.to_numpy(target_area._mask) dat[mask] = np.median(dat[~mask]) edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index 98ac14de..bb83292e 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -52,7 +52,7 @@ def initialize(self): if self.pixels.initialized: return target_area = self.target[self.window] - self.pixels.dynamic_value = backend.copy(target_area.data) / target_area.pixel_area + self.pixels.dynamic_value = backend.copy(target_area._data) / target_area.pixel_area @forward def brightness( diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index e2eed950..614b39e7 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -38,7 +38,10 @@ def initialize(self): super().initialize() if not self.I0.initialized: - dat = backend.to_numpy(self.target[self.window].data).copy() + dat = backend.to_numpy(self.target[self.window]._data).copy() + if self.target[self.window].has_mask: + mask = backend.to_numpy(self.target[self.window]._mask) + dat[mask] = np.median(dat[~mask]) self.I0.dynamic_value = np.median(dat) / self.target.pixel_area.item() if not self.delta.initialized: self.delta.dynamic_value = [0.0, 0.0] diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index a3feac66..70ba3c56 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -50,7 +50,10 @@ def initialize(self): if self.flux.initialized: return target_area = self.target[self.window] - dat = backend.to_numpy(target_area.data).copy() + dat = backend.to_numpy(target_area._data).copy() + if target_area.has_mask: + mask = backend.to_numpy(target_area._mask) + dat[mask] = np.median(dat[~mask]) edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) self.flux.dynamic_value = np.abs(np.sum(dat - edge_average)) @@ -109,9 +112,9 @@ def sample( window = self.window if isinstance(self.psf, PSFImage): - psf = self.psf.data + psf = self.psf._data elif isinstance(self.psf, Model): - psf = self.psf().data + psf = self.psf()._data else: raise TypeError( f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." @@ -122,11 +125,11 @@ def sample( i, j = working_image.pixel_center_meshgrid() i0, j0 = working_image.plane_to_pixel(*center) - working_image.data = interp2d( + working_image._data = interp2d( psf, i - i0 + (psf.shape[0] // 2), j - j0 + (psf.shape[1] // 2) ) - working_image.data = flux * working_image.data + working_image._data = flux * working_image._data working_image = working_image.reduce(self.psf_upscale) diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index e86645b8..5554b5ca 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -73,7 +73,7 @@ def sample(self, window: Optional[Window] = None) -> PSFImage: """ # Create an image to store pixel samples working_image = self.target[self.window].model_image() - working_image.data = self.sample_image(working_image) + working_image._data = self.sample_image(working_image) # normalize to total flux 1 if self.normalize_psf: diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index d1484a31..12979f75 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -48,9 +48,9 @@ def target_image(fig, ax, target, window=None, **kwargs): if window is None: window = target.window target_area = target[window] - dat = np.copy(backend.to_numpy(target_area.data)) + dat = np.copy(backend.to_numpy(target_area._data)) if target_area.has_mask: - dat[backend.to_numpy(target_area.mask)] = np.nan + dat[backend.to_numpy(target_area._mask)] = np.nan X, Y = target_area.coordinate_corner_meshgrid() X = backend.to_numpy(X) Y = backend.to_numpy(Y) @@ -134,7 +134,7 @@ def psf_image( x, y = psf.coordinate_corner_meshgrid() x = backend.to_numpy(x) y = backend.to_numpy(y) - psf = backend.to_numpy(psf.data) + psf = backend.to_numpy(psf._data) # Default kwargs for image kwargs = { @@ -240,7 +240,7 @@ def model_image( X, Y = sample_image.coordinate_corner_meshgrid() X = backend.to_numpy(X) Y = backend.to_numpy(Y) - sample_image = backend.to_numpy(sample_image.data) + sample_image = backend.to_numpy(sample_image._data) # Default kwargs for image kwargs = { @@ -270,7 +270,7 @@ def model_image( # Apply the mask if available if target_mask and target.has_mask: - sample_image[backend.to_numpy(target.mask)] = np.nan + sample_image[backend.to_numpy(target._mask)] = np.nan # Plot the image im = ax.pcolormesh(X, Y, sample_image, **kwargs) @@ -360,7 +360,7 @@ def residual_image( X, Y = sample_image.coordinate_corner_meshgrid() X = backend.to_numpy(X) Y = backend.to_numpy(Y) - residuals = (target - sample_image).data + residuals = (target - sample_image)._data if normalize_residuals is True: residuals = residuals / backend.sqrt(target.variance) @@ -369,7 +369,7 @@ def residual_image( normalize_residuals = True residuals = backend.to_numpy(residuals) if target.has_mask: - residuals[backend.to_numpy(target.mask)] = np.nan + residuals[backend.to_numpy(target._mask)] = np.nan if scaling == "clip": if normalize_residuals is not True: diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 48c4a8e4..56137789 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -124,9 +124,9 @@ def radial_median_profile( R = backend.sqrt(x**2 + y**2) R = backend.to_numpy(R) - dat = backend.to_numpy(image.data) + dat = backend.to_numpy(image._data) if image.has_mask: # remove masked pixels - mask = backend.to_numpy(image.mask) + mask = backend.to_numpy(image._mask) dat = dat[~mask] R = R[~mask] diff --git a/tests/test_fit.py b/tests/test_fit.py index 73a2eb33..79c72095 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -72,7 +72,7 @@ def sersic_model(): [ (ap.fit.LM, {}), (ap.fit.LMfast, {}), - (ap.fit.IterParam, {"chunks": 3, "method": "sequential", "verbose": 2}), + (ap.fit.IterParam, {"chunks": 3, "chunk_order": "sequential", "verbose": 2}), (ap.fit.Grad, {}), (ap.fit.ScipyFit, {}), (ap.fit.MHMCMC, {}), @@ -167,7 +167,7 @@ def test_gradient(sersic_model): pytest.skip("JAX backend does not support backward function") model = sersic_model target = model.target - target.weight = 1 / (10 + target.variance.T) + target.weight = 1 / (10 + target.variance) model.initialize() x = model.build_params_array() grad = model.gradient() diff --git a/tests/test_image.py b/tests/test_image.py index 92065d96..4e66a27a 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -31,7 +31,7 @@ def test_image_creation(base_image): sliced_image = base_image[slicer] assert sliced_image.crpix[0] == -7, "crpix of subimage should give relative position" assert sliced_image.crpix[1] == -4, "crpix of subimage should give relative position" - assert sliced_image.shape == (6, 3), "sliced image should have correct shape" + assert sliced_image._data.shape == (6, 3), "sliced image should have correct shape" def test_copy(base_image): @@ -44,7 +44,7 @@ def test_copy(base_image): base_image.window.extent == copy_image.window.extent ), "copied image should have same window" copy_image += 1 - assert base_image.data[0][0] == 0.0, "copied image should not share data with original" + assert base_image._data[0][0] == 0.0, "copied image should not share data with original" blank_copy_image = base_image.blank_copy() assert ( @@ -57,7 +57,7 @@ def test_copy(base_image): base_image.window.extent == blank_copy_image.window.extent ), "copied image should have same window" blank_copy_image += 1 - assert base_image.data[0][0] == 0.0, "copied image should not share data with original" + assert base_image._data[0][0] == 0.0, "copied image should not share data with original" def test_image_arithmetic(base_image): @@ -65,8 +65,8 @@ def test_image_arithmetic(base_image): sliced_image = base_image[slicer] sliced_image += 1 - assert base_image.data[1][8] == 0, "slice should not update base image" - assert base_image.data[5][5] == 0, "slice should not update base image" + assert base_image._data[1][8] == 0, "slice should not update base image" + assert base_image._data[5][5] == 0, "slice should not update base image" second_image = ap.Image( data=np.ones((5, 5)), @@ -77,10 +77,10 @@ def test_image_arithmetic(base_image): # Test iadd base_image += second_image - assert base_image.data[0][0] == 0, "image addition should only update its region" - assert base_image.data[3][3] == 1, "image addition should update its region" - assert base_image.data[3][4] == 0, "image addition should only update its region" - assert base_image.data[5][3] == 1, "image addition should update its region" + assert base_image._data[0][0] == 0, "image addition should only update its region" + assert base_image._data[3][3] == 1, "image addition should update its region" + assert base_image._data[3][4] == 0, "image addition should only update its region" + assert base_image._data[5][3] == 1, "image addition should update its region" # Test isubtract base_image -= second_image @@ -100,19 +100,19 @@ def test_image_manipulation(): for scale in [2, 4, 8, 16]: reduced_image = new_image.reduce(scale) - assert reduced_image.data[0][0] == scale**2, "reduced image should sum sub pixels" + assert reduced_image._data[0][0] == scale**2, "reduced image should sum sub pixels" assert reduced_image.pixelscale == scale, "pixelscale should increase with reduced image" # image cropping crop_image = new_image.crop([1]) - assert crop_image.shape[1] == 14, "crop should cut 1 pixel from both sides here" + assert crop_image._data.shape[1] == 14, "crop should cut 1 pixel from both sides here" crop_image = new_image.crop([3, 2]) assert ( - crop_image.data.shape[0] == 26 + crop_image._data.shape[0] == 26 ), "crop should have cut 3 pixels from both sides of this axis" crop_image = new_image.crop([3, 2, 1, 0]) assert ( - crop_image.data.shape[0] == 27 + crop_image._data.shape[0] == 27 ), "crop should have cut 3 pixels from left, 2 from right, 1 from top, and 0 from bottom" @@ -207,8 +207,8 @@ def test_target_image_mask(): assert new_image.has_mask, "target image should store mask" reduced_image = new_image.reduce(2) - assert reduced_image.mask[0][0] == 1, "reduced image should mask appropriately" - assert reduced_image.mask[1][0] == 0, "reduced image should mask appropriately" + assert reduced_image._mask[0][0] == 1, "reduced image should mask appropriately" + assert reduced_image._mask[1][0] == 0, "reduced image should mask appropriately" new_image.mask = None assert not new_image.has_mask, "target image update to no mask" @@ -223,8 +223,8 @@ def test_target_image_mask(): zeropoint=1.0, ) assert new_image.has_mask, "target image with nans should create mask" - assert new_image.mask[1][1].item() == True, "nan should be masked" - assert new_image.mask[5][5].item() == True, "nan should be masked" + assert new_image._mask[1][1].item() == True, "nan should be masked" + assert new_image._mask[5][5].item() == True, "nan should be masked" def test_target_image_psf(): @@ -238,7 +238,7 @@ def test_target_image_psf(): assert new_image.psf.psf_pad == 4, "psf border should be half psf size" reduced_image = new_image.reduce(3) - assert reduced_image.psf.data[0][0] == 9, "reduced image should sum sub pixels in psf" + assert reduced_image.psf._data[0][0] == 9, "reduced image should sum sub pixels in psf" new_image.psf = None assert not new_image.has_psf, "target image update to no variance" @@ -253,8 +253,8 @@ def test_target_image_reduce(): zeropoint=1.0, ) smaller_image = new_image.reduce(3) - assert smaller_image.data[0][0] == 9, "reduction should sum flux" - assert tuple(smaller_image.data.shape) == (12, 10), "reduction should decrease image size" + assert smaller_image._data[0][0] == 9, "reduction should sum flux" + assert tuple(smaller_image._data.shape) == (12, 10), "reduction should decrease image size" def test_target_image_save_load(): @@ -317,7 +317,7 @@ def test_psf_image_copying(): assert psf_image.psf_pad == 7, "psf image should have correct psf_pad" psf_image.normalize() assert np.allclose( - ap.backend.to_numpy(psf_image.data), 1 / 15**2 + ap.backend.to_numpy(psf_image._data), 1 / 15**2 ), "psf image should normalize to sum to 1" @@ -333,7 +333,7 @@ def test_jacobian_add(): new_image += other_image - assert tuple(new_image.data.shape) == ( + assert tuple(new_image._data.shape) == ( 32, 16, 3, @@ -342,8 +342,8 @@ def test_jacobian_add(): 512, 3, ), "Jacobian should flatten to Npix*Nparams tensor" - assert new_image.data[0, 0, 0].item() == 1, "Jacobian addition should not change original data" - assert new_image.data[0, 0, 1].item() == 6, " Jacobian addition should add correctly" + assert new_image._data[0, 0, 0].item() == 1, "Jacobian addition should not change original data" + assert new_image._data[0, 0, 1].item() == 6, " Jacobian addition should add correctly" def test_image_with_wcs(): @@ -352,8 +352,8 @@ def test_image_with_wcs(): data=np.ones((170, 180)), wcs=WCS, ) - assert image.shape[0] == WCS.pixel_shape[0], "Image should have correct shape from WCS" - assert image.shape[1] == WCS.pixel_shape[1], "Image should have correct shape from WCS" + assert image._data.shape[0] == WCS.pixel_shape[0], "Image should have correct shape from WCS" + assert image._data.shape[1] == WCS.pixel_shape[1], "Image should have correct shape from WCS" assert np.allclose( image.CD.value * ap.utils.conversions.units.arcsec_to_deg, WCS.pixel_scale_matrix ), "Image should have correct CD from WCS" diff --git a/tests/test_image_list.py b/tests/test_image_list.py index eae5eb68..fa9c0c88 100644 --- a/tests/test_image_list.py +++ b/tests/test_image_list.py @@ -19,9 +19,9 @@ def test_image_creation(): (ap.Window((3, 12, 5, 8), base_image1), ap.Window((4, 8, 3, 13), base_image2)) ) sliced_image = test_image[slicer] - print(sliced_image[0].shape, sliced_image[1].shape) - assert sliced_image[0].shape == (9, 3), "image slice incorrect shape" - assert sliced_image[1].shape == (4, 10), "image slice incorrect shape" + print(sliced_image[0]._data.shape, sliced_image[1]._data.shape) + assert sliced_image[0]._data.shape == (9, 3), "image slice incorrect shape" + assert sliced_image[1]._data.shape == (4, 10), "image slice incorrect shape" assert np.all(sliced_image[0].crpix == np.array([-3, -5])), "image should track origin" assert np.all(sliced_image[1].crpix == np.array([-4, -3])), "image should track origin" diff --git a/tests/utils.py b/tests/utils.py index 53bad295..7bbbb9df 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -64,7 +64,7 @@ def make_basic_sersic( sampling_mode="quad:5", ) - img = ap.backend.to_numpy(MODEL().data.T) + img = ap.backend.to_numpy(MODEL().data) target.data = ( img + np.random.normal(scale=0.5, size=img.shape) @@ -104,7 +104,7 @@ def make_basic_gaussian( q=0.99, ) - img = ap.backend.to_numpy(MODEL().data.T) + img = ap.backend.to_numpy(MODEL().data) target.data = ( img + np.random.normal(scale=0.1, size=img.shape) From 0c433d02cc9ce15e901d590feb71ffbf9d92abf8 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 11 Nov 2025 10:21:09 -0500 Subject: [PATCH 145/191] Adding deblending and segmentation --- astrophot/models/gaussian_ellipsoid.py | 1 - astrophot/models/group_model_object.py | 68 +++++++++++++++++++++- docs/source/tutorials/GroupModels.ipynb | 77 ++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 5 deletions(-) diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index b11fe939..68fd6579 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -1,6 +1,5 @@ import torch import numpy as np -from torch import Tensor from .model_object import ComponentModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 8b65906d..95f8de00 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -1,6 +1,7 @@ from typing import Optional, Sequence, Union import torch +import numpy as np from caskade import forward from .base import Model @@ -17,7 +18,7 @@ JacobianImageList, ) from .. import config -from ..backend_obj import backend +from ..backend_obj import backend, ArrayLike from ..utils.decorators import ignore_numpy_warnings from ..errors import InvalidTarget, InvalidWindow @@ -321,3 +322,68 @@ def window(self, window): self._window = Window(window, image=self.target) else: raise InvalidWindow(f"Unrecognized window format: {str(window)}") + + def segmentation_map(self) -> ArrayLike: + """Generate a segmentation map for this group model. Each pixel in the + segmentation map is assigned an integer value corresponding to the index + of the sub-model that corresponds to that pixel. The pixels are assigned + based on "relative importance", meaning that for each pixel, the + sub-model which contributes the largest fraction of its own total flux to that + pixel is assigned to it. + + Returns: + ArrayLike: Segmentation map with the same shape as the target image as windowed by the group model window. + + """ + subtarget = self.target[self.window] + if isinstance(subtarget, ImageList): + raise NotImplementedError( + "Segmentation maps are not currently supported for ImageList targets. Please apply one target at a time." + ) + else: + seg_map = backend.zeros_like(subtarget.data, dtype=backend.int32) - 1 + max_flux_frac = 0.0 * backend.ones_like(subtarget.data) / np.prod(subtarget.data.shape) + for idx, model in enumerate(self.models): + model_image = model() + model_flux_frac = backend.abs(model_image.data) / backend.sum( + backend.abs(model_image.data) + ) + indices = subtarget.get_indices(model.window) + model_flux_frac_full = backend.zeros_like(subtarget.data) + model_flux_frac_full = backend.fill_at_indices( + model_flux_frac_full, indices, model_flux_frac + ) + update_mask = model_flux_frac_full >= max_flux_frac + seg_map = backend.where(update_mask, idx, seg_map) + max_flux_frac = backend.where(update_mask, model_flux_frac_full, max_flux_frac) + return seg_map + + def deblend(self) -> Sequence[TargetImage]: + """Generate deblended images for each sub-model in this group model. + Each deblended image contains for each pixel, the fraction of the total + flux at that pixel which is contributed by that sub-model. + + Returns: + Sequence[TargetImage]: List of deblended TargetImage objects for each sub-model. + + """ + deblended_images = [] + subtarget = self.target[self.window] + full_model = self() + if isinstance(subtarget, ImageList): + raise NotImplementedError( + "Deblending is not currently supported for ImageList targets. Please apply one target at a time." + ) + else: + for model in self.models: + model_image = model() + subfull_model = full_model[model.window] + subsubtarget = subtarget[model.window].copy( + name=f"deblend_{model.name}_{subtarget.name}" + ) + deblend_data = subsubtarget._data * model_image._data / subfull_model._data + deblend_variance = subsubtarget.variance * model_image._data / subfull_model._data + subsubtarget._data = deblend_data + subsubtarget.variance = deblend_variance.T + deblended_images.append(subsubtarget) + return deblended_images diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index 24b7df40..8698e213 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -76,7 +76,7 @@ "source": [ "pixelscale = 0.262\n", "target = ap.TargetImage(\n", - " data=target_data,\n", + " data=target_data + 0.01, # add fake sky level back in\n", " pixelscale=pixelscale,\n", " zeropoint=22.5,\n", " variance=\"auto\", # this will estimate the variance from the data\n", @@ -191,7 +191,8 @@ "source": [ "# This is now a very complex model composed of 9 sub-models! In total 57 parameters!\n", "# Here we will limit it to 1 iteration so that it runs quickly. In general you should let it run to convergence\n", - "result = ap.fit.Iter(groupmodel, verbose=1, max_iter=2).fit()" + "result = ap.fit.Iter(groupmodel, verbose=1, max_iter=2).fit()\n", + "result = ap.fit.LM(groupmodel, verbose=0, max_iter=2).fit()" ] }, { @@ -202,7 +203,7 @@ "source": [ "# Now we can see what the fitting has produced\n", "fig10, ax10 = plt.subplots(1, 2, figsize=(16, 7))\n", - "ap.plots.model_image(fig10, ax10[0], groupmodel, vmax=30)\n", + "ap.plots.model_image(fig10, ax10[0], groupmodel, vmax=25)\n", "ap.plots.residual_image(fig10, ax10[1], groupmodel, normalize_residuals=True)\n", "plt.show()" ] @@ -213,6 +214,76 @@ "source": [ "Which is a pretty good fit! We haven't accounted for the PSF yet, so some of the central regions are not very well fit. It is very easy to add a PSF model to AstroPhot for fitting. Check out the Basic PSF Models tutorial for more information." ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Segmentation maps\n", + "\n", + "AstroPhot can produce a model based segmentation map. Essentially, once the models are fit it can compute the \"importance\" of each pixel to a given model. For each pixel and for each model it is possible to compute what fraction of the model's total flux is placed in that pixel. Whichever model assigns the highest fraction of all its flux to a given pixel, is the \"winner\" for that pixel and so the segmentation map assigns the pixel to its index. Note that this is only done at the first level of a group model, since group models can contain group models, it is possible to have a complex multi-component model still act as one index in the segmentation map. \n", + "\n", + "Also note that this means AstroPhot can perform segmentation even for images with non-zero sky levels, there is no need to do background subtraction before segmenting (though you do need to fit the models)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.imshow(groupmodel.segmentation_map().T, origin=\"lower\", cmap=\"inferno\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Deblending\n", + "\n", + "AstroPhot can perform a basic deblending based on the fitted model. A new target image is created for each object which for each pixel holds the fraction of signal from the original target corresponding to the fraction of light coming from that individual model (compared to the full group model). This can create some patches of zero pixel values where the model falls to zero in its own window, or where other models are much brighter. \n", + "\n", + "Note that this works even when the sky level is not subtracted. Though for very bright sky levels, the deblended objects tend to just look like their model images.\n", + "\n", + "AstroPhot doesn't use deblending, it's forward modelling approach means that it simultaneously models all objects using a principled Gaussian (or Poisson) likelihood. That said, other analyses may make use of deblended stamps. It is also a good systematic check of the flux estimates. A flux estimate that varies wildly from the deblend total flux might be cause for concern." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "subtargets = groupmodel.deblend()\n", + "fig, axarr = plt.subplots(2, int(np.ceil(len(subtargets) / 2)), figsize=(16, 7))\n", + "for i, subtarget in enumerate(subtargets):\n", + " ax = axarr.flatten()[i]\n", + " ap.plots.target_image(fig, ax, subtarget)\n", + " ax.set_title(subtarget.name, fontsize=10)\n", + " ax.axis(\"off\")\n", + "axarr.flatten()[-1].axis(\"off\")\n", + "plt.show()\n", + "\n", + "for submodel, subtarget in zip(groupmodel.models, subtargets):\n", + " print(\n", + " f\"{submodel.name}: total model flux = {submodel.total_flux().item():.2f} ± {submodel.total_flux_uncertainty().item():.2f}, deblend total flux = {subtarget.data.sum().item():.2f}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Observe that all the models (except the sky, which we fudged anyway) are within one sigma between the model flux and the deblended flux. This is a good sign! If there had been any major deviations that would be very suspicious." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { From 2dd0b89559abe434990d3e16c17cbeb5dfc6c4cd Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 11 Nov 2025 10:51:51 -0500 Subject: [PATCH 146/191] add nodejs dependency --- .readthedocs.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 3989c638..5870fa5f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -24,6 +24,7 @@ build: apt_packages: - pandoc # Specify pandoc to be installed via apt-get - graphviz + - nodejs jobs: pre_build: # Build docstring jupyter notebooks From f72f93fef71b0e8da3b67ca7fbc7604b001ee89e Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 11 Nov 2025 10:58:54 -0500 Subject: [PATCH 147/191] add npm dependency for jupyter book --- .readthedocs.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 5870fa5f..28d526d7 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -25,6 +25,7 @@ build: - pandoc # Specify pandoc to be installed via apt-get - graphviz - nodejs + - npm jobs: pre_build: # Build docstring jupyter notebooks From 01bfa2201f0f1a74743dfd8a2dfec61141b06bc9 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 11 Nov 2025 11:06:42 -0500 Subject: [PATCH 148/191] fix nodejs requirement version --- .readthedocs.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 28d526d7..773eca2b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -21,11 +21,10 @@ build: os: "ubuntu-20.04" tools: python: "3.12" + nodejs: "20" apt_packages: - pandoc # Specify pandoc to be installed via apt-get - graphviz - - nodejs - - npm jobs: pre_build: # Build docstring jupyter notebooks From 5e7fe86bc308bd8d46918510f849d0332b7eade6 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 11 Nov 2025 11:15:09 -0500 Subject: [PATCH 149/191] explicit downgrade to jupyter-book version 1 --- .readthedocs.yaml | 1 - docs/requirements.txt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 773eca2b..3989c638 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -21,7 +21,6 @@ build: os: "ubuntu-20.04" tools: python: "3.12" - nodejs: "20" apt_packages: - pandoc # Specify pandoc to be installed via apt-get - graphviz diff --git a/docs/requirements.txt b/docs/requirements.txt index 3d303810..4002008d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,7 +3,7 @@ emcee graphviz ipywidgets jax -jupyter-book +jupyter-book<2.0 matplotlib nbformat nbsphinx From 5e75b839ac5f0d8a26fa45dd4706f7446faaa8fe Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 11 Nov 2025 11:36:39 -0500 Subject: [PATCH 150/191] simplify deblend data call --- astrophot/models/group_model_object.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 95f8de00..e40e9ea1 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -381,9 +381,9 @@ def deblend(self) -> Sequence[TargetImage]: subsubtarget = subtarget[model.window].copy( name=f"deblend_{model.name}_{subtarget.name}" ) - deblend_data = subsubtarget._data * model_image._data / subfull_model._data - deblend_variance = subsubtarget.variance * model_image._data / subfull_model._data - subsubtarget._data = deblend_data + deblend_data = subsubtarget.data * model_image.data / subfull_model.data + deblend_variance = subsubtarget.variance * model_image.data / subfull_model.data + subsubtarget.data = deblend_data.T subsubtarget.variance = deblend_variance.T deblended_images.append(subsubtarget) return deblended_images From 2392ee9d87066d9146a2c956ffb0c0c2a4951f5f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 18:31:13 +0000 Subject: [PATCH 151/191] build(deps): bump actions/upload-artifact from 4 to 5 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cd.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 14d8b2b5..04c1d0bd 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -30,7 +30,7 @@ jobs: - name: Build sdist and wheel run: pipx run build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: path: dist From 9c07eb08f57610173636868dd19b5c903f3e6927 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 12 Nov 2025 13:43:55 -0500 Subject: [PATCH 152/191] Made functional fitting tutorial --- astrophot/backend_obj.py | 5 +- astrophot/fit/__init__.py | 15 +- astrophot/fit/func/__init__.py | 11 +- astrophot/fit/func/mala.py | 69 +++ astrophot/fit/mala.py | 99 ++++ astrophot/fit/mhmcmc.py | 2 +- astrophot/models/mixins/spline.py | 1 - astrophot/models/point_source.py | 9 +- .../tutorials/FunctionalInterface.ipynb | 483 ++++++++++++++++++ docs/source/tutorials/GettingStartedJAX.ipynb | 3 - docs/source/tutorials/index.rst | 1 + 11 files changed, 684 insertions(+), 14 deletions(-) create mode 100644 astrophot/fit/func/mala.py create mode 100644 astrophot/fit/mala.py create mode 100644 docs/source/tutorials/FunctionalInterface.ipynb diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index 3574aab3..1d1ffee8 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -3,8 +3,10 @@ from typing import Annotated from torch import Tensor, dtype, device -import numpy as np import torch +import numpy as np +import caskade as ck + from . import config ArrayLike = Annotated[ @@ -33,6 +35,7 @@ def backend(self): def backend(self, backend): if backend is None: backend = os.getenv("CASKADE_BACKEND", "torch") + ck.backend.backend = backend self._load_backend(backend) self._backend = backend diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index f4ca342c..dc3b5fab 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -4,7 +4,18 @@ from .scipy_fit import ScipyFit from .minifit import MiniFit from .hmc import HMC +from .mala import MALA from .mhmcmc import MHMCMC -from . import func -__all__ = ["LM", "LMfast", "Grad", "Iter", "ScipyFit", "MiniFit", "HMC", "MHMCMC", "Slalom", "func"] +__all__ = [ + "LM", + "LMfast", + "Grad", + "Iter", + "ScipyFit", + "MiniFit", + "HMC", + "MALA", + "MHMCMC", + "Slalom", +] diff --git a/astrophot/fit/func/__init__.py b/astrophot/fit/func/__init__.py index dd4ba512..58da703e 100644 --- a/astrophot/fit/func/__init__.py +++ b/astrophot/fit/func/__init__.py @@ -1,4 +1,13 @@ from .lm import lm_step, hessian, gradient, hessian_poisson, gradient_poisson from .slalom import slalom_step +from .mala import mala -__all__ = ["lm_step", "hessian", "gradient", "slalom_step", "hessian_poisson", "gradient_poisson"] +__all__ = [ + "lm_step", + "hessian", + "gradient", + "slalom_step", + "hessian_poisson", + "gradient_poisson", + "mala", +] diff --git a/astrophot/fit/func/mala.py b/astrophot/fit/func/mala.py new file mode 100644 index 00000000..2f9c4532 --- /dev/null +++ b/astrophot/fit/func/mala.py @@ -0,0 +1,69 @@ +import numpy as np +from tqdm import tqdm + + +def mala( + initial_state, # (num_chains, D) + log_prob, # (num_chains, D) -> (num_chains,) + log_prob_grad, # (num_chains, D) -> (num_chains, D) + num_samples, + epsilon, + mass_matrix, # covariance + progress=True, + desc="MALA", +): + x = np.array(initial_state, copy=True) + C, D = x.shape + + # mass, inv_mass, L + mass = np.array(mass_matrix, copy=False) # (D, D) + inv_mass = np.linalg.inv(mass) # (D, D) + L = np.linalg.cholesky(mass) # (D, D) + + samples = np.zeros((num_samples, C, D), dtype=x.dtype) # (N, C, D) + + # Cache current state + logp_cur = log_prob(x) # (C,) + grad_cur = log_prob_grad(x) # (C, D) + + # Random number generator + rng = np.random.default_rng(np.random.randint(1e10)) + + it = range(num_samples) + if progress: + it = tqdm(it, desc=desc, position=0, leave=True) + + for t in it: + # proposal using current grad + mu_x = 0.5 * (epsilon**2) * (grad_cur @ mass) # (C, D) + noise = rng.standard_normal((C, D)) @ L.T # (C, D) + x_prop = x + mu_x + epsilon * noise # (C, D) + + # Evaluate proposal + logp_prop = log_prob(x_prop) # (C,) + grad_prop = log_prob_grad(x_prop) # (C, D) + + mu_xprop = 0.5 * (epsilon**2) * (grad_prop @ mass) # (C, D) + + # q(x|x') \propto \exp(-0.5|x - x' - mu(x')|^2 / \epsilon^2) + d1 = x - x_prop - mu_xprop # for q(x | x') + d2 = x_prop - x - mu_x # for q(x'| x) + + logq1 = -0.5 * np.einsum("bi,ij,bj->b", d1, inv_mass, d1) / epsilon**2 # (C,) + logq2 = -0.5 * np.einsum("bi,ij,bj->b", d2, inv_mass, d2) / epsilon**2 # (C,) + + log_alpha = (logp_prop - logp_cur) + (logq1 - logq2) # (C,) + + accept = np.log(rng.random(C)) < log_alpha # (C,) + + # Update all three pieces in-place where accepted + x[accept] = x_prop[accept] # (C, D) + logp_cur[accept] = logp_prop[accept] # (C,) + grad_cur[accept] = grad_prop[accept] # (C, D) + + samples[t] = x + + if progress: + it.set_postfix(acc_rate=f"{accept.mean():0.2f}") + + return samples diff --git a/astrophot/fit/mala.py b/astrophot/fit/mala.py new file mode 100644 index 00000000..b83723bc --- /dev/null +++ b/astrophot/fit/mala.py @@ -0,0 +1,99 @@ +# Metropolis-Adjusted Langevin Algorithm sampler +from typing import Optional, Sequence + +import numpy as np + +from .base import BaseOptimizer +from ..models import Model +from .. import config +from ..backend_obj import backend +from . import func + +__all__ = ("MALA",) + + +class MALA(BaseOptimizer): + def __init__( + self, + model: Model, + initial_state: Optional[Sequence] = None, + chains=4, + epsilon: float = 1e-2, + mass_matrix: Optional[np.ndarray] = None, + max_iter: int = 1000, + progress_bar: bool = True, + likelihood="gaussian", + **kwargs, + ): + super().__init__(model, initial_state, max_iter=max_iter, **kwargs) + self.chain = [] + if len(self.current_state.shape) == 2: + self.chains = self.current_state.shape[0] + else: + self.chains = chains + self.likelihood = likelihood + self.epsilon = epsilon + self.mass_matrix = mass_matrix + self.progress_bar = progress_bar + + def density_func(self): + """ + Returns the density of the model at the given state vector. + This is used to calculate the likelihood of the model at the given state. + """ + if self.likelihood == "gaussian": + vll = backend.vmap(self.model.gaussian_log_likelihood) + elif self.likelihood == "poisson": + vll = backend.vmap(self.model.poisson_log_likelihood) + else: + raise ValueError(f"Unknown likelihood type: {self.likelihood}") + + def dens(state: np.ndarray) -> np.ndarray: + state = backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) + return backend.to_numpy(vll(state)) + + return dens + + def density_grad_func(self): + """ + Returns the gradient of the density of the model at the given state vector. + This is used to calculate the gradient of the likelihood of the model at the given state. + """ + if self.likelihood == "gaussian": + vll_grad = backend.vmap(backend.grad(self.model.gaussian_log_likelihood)) + elif self.likelihood == "poisson": + vll_grad = backend.vmap(backend.grad(self.model.poisson_log_likelihood)) + else: + raise ValueError(f"Unknown likelihood type: {self.likelihood}") + + def grad(state: np.ndarray) -> np.ndarray: + state = backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) + return backend.to_numpy(vll_grad(state)) + + return grad + + def fit(self): + + Px = self.density_func() + dPdx = self.density_grad_func() + + initial_state = backend.to_numpy(self.current_state) + if len(initial_state.shape) == 1: + initial_state = np.repeat(initial_state[None, :], self.chains, axis=0) + + if self.mass_matrix is None: + D = initial_state.shape[1] + self.mass_matrix = np.eye(D, dtype=initial_state.dtype) + + self.chain = func.mala( + initial_state, + Px, + dPdx, + self.max_iter, + self.epsilon, + self.mass_matrix, + progress=self.progress_bar, + desc="MALA", + ) + + return self.chain diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index 3f3db269..a23e6fb6 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -13,7 +13,7 @@ from .. import config from ..backend_obj import backend -__all__ = ["MHMCMC"] +__all__ = ("MHMCMC",) class MHMCMC(BaseOptimizer): diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 3a21c11b..721bc376 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -1,5 +1,4 @@ import torch -from torch import Tensor import numpy as np from ...param import forward diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index a3feac66..0a3d7116 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -12,6 +12,7 @@ from ..errors import SpecificationConflict from ..param import forward from ..backend_obj import backend, ArrayLike +from . import func __all__ = ("PointSource",) @@ -120,13 +121,11 @@ def sample( # Make the image object to which the samples will be tracked working_image = self.target[window].model_image(upsample=self.psf_upscale) - i, j = working_image.pixel_center_meshgrid() + i, j, w = working_image.pixel_quad_meshgrid() i0, j0 = working_image.plane_to_pixel(*center) - working_image.data = interp2d( - psf, i - i0 + (psf.shape[0] // 2), j - j0 + (psf.shape[1] // 2) - ) + z = interp2d(psf, i - i0 + (psf.shape[0] // 2), j - j0 + (psf.shape[1] // 2)) - working_image.data = flux * working_image.data + working_image._data = flux * func.pixel_quad_integrator(z, w) working_image = working_image.reduce(self.psf_upscale) diff --git a/docs/source/tutorials/FunctionalInterface.ipynb b/docs/source/tutorials/FunctionalInterface.ipynb new file mode 100644 index 00000000..d41490a3 --- /dev/null +++ b/docs/source/tutorials/FunctionalInterface.ipynb @@ -0,0 +1,483 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Functional AstroPhot interface\n", + "\n", + "AstroPhot is an object oriented code, meaning that it is build on python objects that behave in intuitively meaningful ways. For example it is possible to add two model images together to get a new model image, even if one of them only fills a subwindow of pixels, this is because the model images are aware of what part of the scene they represent and can behave accordingly. This is all very nice so long as you are building the kinds of models that AstroPhot is designed for, and when you are not trying to squeeze out every last bit of performance. For most cases, AstroPhot objects can handle complex configurations and perform very quickly. Still, you may need to push things with highly specific customization. Let's consider a case where some specialization can give a big performance boost, a supernova light curve." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "import astrophot as ap\n", + "import numpy as np\n", + "import jax\n", + "import jax.numpy as jnp\n", + "import matplotlib.pyplot as plt\n", + "import socket\n", + "from corner import corner\n", + "\n", + "socket.setdefaulttimeout(120)\n", + "ap.backend.backend = \"jax\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": { + "tags": [ + "hide-cell" + ] + }, + "outputs": [], + "source": [ + "def CD_rot(theta):\n", + " return np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])\n", + "\n", + "\n", + "def sn_flux(t):\n", + " return 5 * np.exp(-0.5 * ((t - 10) / 5) ** 2)" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## Generate Mock data\n", + "\n", + "Here we will use the usual AstroPhot object oriented interface to generate some mock SN data. There is a fixed host Sersic galaxy, and a Gaussian point source with variable flux as the SN. Every observation is a new pointing of the telescope, so the images are not all aligned and are rotated randomly. The AstroPhot object oriented framework handles this by having target images aware of the WCS that connects the pixels to their location on the sky. We will see in the functional version that everything has to be more explicit, but is more or less the same." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "np.random.seed(42)\n", + "psf = jnp.array(ap.utils.initialize.gaussian_psf(0.1, 21, 0.1))\n", + "target = ap.TargetImageList(\n", + " list(\n", + " ap.TargetImage(\n", + " name=f\"epoch_{i}\",\n", + " data=np.zeros((32, 32)),\n", + " crpix=(16, 16),\n", + " crtan=0.1 * np.random.normal(size=(2,)),\n", + " CD=0.1 * CD_rot(2 * np.pi * np.random.normal()),\n", + " psf=psf,\n", + " )\n", + " for i in range(10)\n", + " )\n", + ")\n", + "T = np.linspace(-10, 30, 10)\n", + "dataset = {\n", + " \"image\": jnp.zeros((10, 32, 32)),\n", + " \"variance\": jnp.zeros((10, 32, 32)),\n", + " \"crpix\": jnp.zeros((10, 2)),\n", + " \"crtan\": jnp.zeros((10, 2)),\n", + " \"CD\": jnp.zeros((10, 2, 2)),\n", + "}\n", + "models = []\n", + "for i, img in enumerate(target.images):\n", + " host = ap.Model(\n", + " name=f\"host_{i}\",\n", + " target=img,\n", + " model_type=\"sersic galaxy model\",\n", + " center=(0.0, 0.0),\n", + " q=0.7,\n", + " PA=np.pi / 4,\n", + " n=2,\n", + " Re=1,\n", + " Ie=1,\n", + " psf_convolve=True,\n", + " )\n", + " host.initialize()\n", + " models.append(host)\n", + " sn = ap.Model(\n", + " name=f\"supernova_{i}\",\n", + " target=img,\n", + " model_type=\"point model\",\n", + " psf=psf,\n", + " center=(0.4, 0.0),\n", + " flux=sn_flux(T[i]),\n", + " )\n", + " sn.initialize()\n", + " models.append(sn)\n", + " sky = ap.Model(name=f\"sky_{i}\", target=img, model_type=\"flat sky model\", I=0.1 / 0.1**2)\n", + " sky.initialize()\n", + " models.append(sky)\n", + " img.data = np.array(host().data + sn().data + sky().data).T\n", + " img.variance = 0.0001 * np.array(img.data).T\n", + " img.data = img.data.T + np.random.normal(scale=0.01 * np.sqrt(np.array(img.data))).T\n", + " dataset[\"image\"] = dataset[\"image\"].at[i].set(img.data.T)\n", + " dataset[\"variance\"] = dataset[\"variance\"].at[i].set(img.variance.T)\n", + " dataset[\"crpix\"] = dataset[\"crpix\"].at[i].set(jnp.array(img.crpix))\n", + " dataset[\"crtan\"] = dataset[\"crtan\"].at[i].set(img.crtan.value)\n", + " dataset[\"CD\"] = dataset[\"CD\"].at[i].set(img.CD.value)\n", + "apmodel = ap.Model(name=\"AstroPhotModel\", model_type=\"group model\", target=target, models=models)\n", + "fig, axarr = plt.subplots(2, 5, figsize=(15, 6))\n", + "for ax, img in zip(axarr.flatten(), target.images):\n", + " ap.plots.target_image(fig, ax, img)\n", + " ax.set_title(img.name)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## Build the functional model\n", + "\n", + "Below we build a functional version of the AstroPhot model which generated the data. The end result is an identical sampling algorithm which strips away all the object oriented layers of the AstroPhot model to give a pure function to compute pixel values. This is a very insightful exercise to learn exactly what AstroPhot does under the hood. As you can see, there are a number of subtle effects to account for which AstroPhot does automatically, but at a high level it is all very straightforward." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "def model_img(\n", + " sersic_x,\n", + " sersic_y,\n", + " sersic_q,\n", + " sersic_PA,\n", + " sersic_n,\n", + " sersic_Re,\n", + " sersic_Ie,\n", + " psf,\n", + " sn_x,\n", + " sn_y,\n", + " sn_flux,\n", + " sky,\n", + " crpix,\n", + " crtan,\n", + " CD,\n", + "):\n", + " # Sample sersic\n", + " pixel_area = 0.1 * 0.1\n", + " # Pad by 20 pixels to avoid edge effects from convolution\n", + " i, j, w = ap.image.func.pixel_quad_meshgrid(\n", + " (32 + 20, 32 + 20), ap.config.DTYPE, ap.config.DEVICE, order=3\n", + " )\n", + " #\n", + " x, y = ap.image.func.pixel_to_plane_linear(j, i, *(crpix + 10), CD, *crtan)\n", + " sx, sy = x - sersic_x, y - sersic_y\n", + " sx, sy = ap.models.func.rotate(-sersic_PA + np.pi / 2, sx, sy)\n", + " sy = sy / sersic_q\n", + " sr = jnp.sqrt(sx**2 + sy**2)\n", + " z = ap.models.func.sersic(sr, n=sersic_n, Re=sersic_Re, Ie=sersic_Ie)\n", + " sample = ap.models.func.pixel_quad_integrator(z, w)\n", + " sample = ap.models.func.convolve(sample, psf)\n", + " sample = sample[10:-10, 10:-10] * pixel_area\n", + "\n", + " # Sample point source (empirical PSF)\n", + " i, j, w = ap.image.func.pixel_quad_meshgrid(\n", + " (32, 32), ap.config.DTYPE, ap.config.DEVICE, order=3\n", + " )\n", + " gj, gi = ap.image.func.plane_to_pixel_linear(sn_x, sn_y, *crpix, CD, *crtan)\n", + " z = ap.utils.interpolate.interp2d(\n", + " psf, j - gj + (psf.shape[1] // 2), i - gi + (psf.shape[0] // 2)\n", + " )\n", + " sample = sample + sn_flux * ap.models.func.pixel_quad_integrator(z, w)\n", + "\n", + " # add sky level\n", + " return sample + sky\n", + "\n", + "\n", + "# fixed: sersic_x, sersic_y, psf, crpix, CD\n", + "# global: sersic_q, sersic_PA, sersic_n, sersic_Re, sersic_Ie, sn_x, sn_y\n", + "# per image: sky, sn_sigma, sn_flux, crtan\n", + "\n", + "\n", + "@jax.jit\n", + "def full_model(\n", + " sersic_x,\n", + " sersic_y,\n", + " sersic_q,\n", + " sersic_PA,\n", + " sersic_n,\n", + " sersic_Re,\n", + " sersic_Ie,\n", + " psf,\n", + " sn_x,\n", + " sn_y,\n", + " sn_flux,\n", + " sky,\n", + " crpix,\n", + " crtan,\n", + " CD,\n", + "):\n", + " return jax.vmap(\n", + " model_img,\n", + " in_axes=(None, None, None, None, None, None, None, None, None, None, 0, 0, 0, 0, 0),\n", + " )(\n", + " sersic_x,\n", + " sersic_y,\n", + " sersic_q,\n", + " sersic_PA,\n", + " sersic_n,\n", + " sersic_Re,\n", + " sersic_Ie,\n", + " psf,\n", + " sn_x,\n", + " sn_y,\n", + " sn_flux,\n", + " sky,\n", + " crpix,\n", + " crtan,\n", + " CD,\n", + " )\n", + "\n", + "\n", + "def model(params, sersic_x, sersic_y, psf, crpix, CD):\n", + " return full_model(\n", + " sersic_x,\n", + " sersic_y,\n", + " params[0],\n", + " params[1],\n", + " params[2],\n", + " params[3],\n", + " params[4],\n", + " psf,\n", + " params[5],\n", + " params[6],\n", + " params[7:17],\n", + " params[17:27],\n", + " crpix,\n", + " params[27:47].reshape(10, 2),\n", + " CD,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "And to see the model in action we can sample it using the true parameter values. As expected, this produces a perfect set of residuals which look like pure random noise." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "params_true = jnp.array(\n", + " np.concatenate(\n", + " [\n", + " [0.7], # sersic_q\n", + " [np.pi / 4], # sersic_PA\n", + " [2.0], # sersic_n\n", + " [1.0], # sersic_Re\n", + " [1.0], # sersic_Ie\n", + " [0.4], # sn_x\n", + " [0.0], # sn_y\n", + " sn_flux(T), # sn_flux\n", + " np.array([0.1] * 10), # sky\n", + " np.array(dataset[\"crtan\"].flatten()), # crtan\n", + " ]\n", + " )\n", + ")\n", + "extra = (jnp.array(0.0), jnp.array(0.0), psf, dataset[\"crpix\"], dataset[\"CD\"])\n", + "sample = model(params_true, *extra)\n", + "residuals = (dataset[\"image\"] - sample) / jnp.sqrt(dataset[\"variance\"])\n", + "fig, axarr = plt.subplots(3, 10, figsize=(18, 6))\n", + "for i, (img, samp, resid) in enumerate(zip(dataset[\"image\"], sample, residuals)):\n", + " axarr[0, i].imshow(img.T, origin=\"lower\", cmap=\"viridis\")\n", + " axarr[0, i].set_title(f\"obs {i}\")\n", + " axarr[1, i].imshow(samp.T, origin=\"lower\", cmap=\"viridis\")\n", + " axarr[1, i].set_title(f\"model {i}\")\n", + " axarr[2, i].imshow(resid.T, origin=\"lower\", cmap=\"seismic\", vmin=-5, vmax=5)\n", + " axarr[2, i].set_title(f\"residual {i}\")\n", + "for ax in axarr.flatten():\n", + " ax.axis(\"off\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axarr = plt.subplots(3, 10, figsize=(18, 6))\n", + "ap.plots.target_image(fig, axarr[0], apmodel.target)\n", + "ap.plots.model_image(fig, axarr[1], apmodel, showcbar=False)\n", + "ap.plots.residual_image(\n", + " fig, axarr[2], apmodel, scaling=\"clip\", normalize_residuals=True, showcbar=False\n", + ")\n", + "for ax in axarr.flatten():\n", + " ax.axis(\"off\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "# Let's compare how fast the two code are\n", + "print(\"Functional interface timings:\")\n", + "%timeit model(params_true, *extra)\n", + "print(\"AstroPhot model timings:\")\n", + "%timeit apmodel()" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "This is quite a striking result, the functional implementation is ~100x faster than the AstroPhot model! However, it is important to put this speed comparison in context. The AstroPhot model is much easier, less error prone, and more intuitive to put together. If we are only going to run the model a few times then we will save much more than 500ms by getting the code written faster. The cutout size of 32x32 is very small, while AstroPhot is built to scale to very large images. For larger images, the Python overhead is negligible and the two codes will have near identical runtime. In fact, if the images get a lot larger the functional version as written will run out of memory while the AstroPhot model could carry on easily because of how it chunks the data. Also, note that the plots are quite different, AstroPhot plots all the images properly oriented in the sky, while for the functional version we don't have that capability. AstroPhot has a more complete understanding of the data and can perform a lot more operations on the results. AstroPhot could also combine in data at different resolutions and sizes, while our functional version is predicated on the idea that all the images will be 32x32 pixels, we would need to completely rewrite it to change that. If we wanted to change the model to fix some parameter or to turn one of the fixed parameters into a free parameter, we would have to trace it through the whole functional implementation and make updates accordingly. This goes for any change really, what if we needed to add in a mask, a second sersic model, or start modelling the PSF (rather than taking it as fixed); all of these would require painful changes to the functional version while they would be trivial additions to the AstroPhot model.\n", + "\n", + "For these reasons and more, it is highly recommended to do lots of prototyping with object oriented AstroPhot models **before** ever considering the functional interface." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "# Make 8 chains, starting at the true parameters\n", + "params = np.stack(list(np.array(params_true) for _ in range(4)))\n", + "\n", + "# Compute a mass matrix using the Fisher information matrix\n", + "J = jax.jacfwd(model, argnums=0)(params_true, *extra).reshape(-1, params_true.shape[-1])\n", + "V = dataset[\"variance\"].reshape(-1)\n", + "H = J.T @ (J / V[:, None])\n", + "M = jnp.linalg.inv(H)\n", + "\n", + "\n", + "def log_likelihood(params, sersic_x, sersic_y, psf, crpix, CD):\n", + " model_sample = model(params, sersic_x, sersic_y, psf, crpix, CD)\n", + " residuals = (dataset[\"image\"] - model_sample) ** 2 / dataset[\"variance\"]\n", + " return -0.5 * jnp.sum(residuals)\n", + "\n", + "\n", + "# Vectorized log likelihood and gradient functions\n", + "vmodel = jax.jit(jax.vmap(log_likelihood, in_axes=(0, None, None, None, None, None)))\n", + "vgmodel = jax.jit(\n", + " jax.vmap(jax.grad(log_likelihood, argnums=0), in_axes=(0, None, None, None, None, None))\n", + ")\n", + "\n", + "# Run MALA sampling\n", + "chain = ap.fit.func.mala(\n", + " params,\n", + " lambda p: np.array(vmodel(jnp.array(p), *extra)),\n", + " lambda p: np.array(vgmodel(jnp.array(p), *extra)),\n", + " num_samples=400,\n", + " epsilon=5e-1,\n", + " mass_matrix=np.array(M),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "Now lets plot the likelihood distributions for the flux parameters compared to their true value. As you can see, the distributions do a good job of covering the ground truth! This means we have accurately extracted the light curve for the supernova data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "figure = corner(\n", + " chain.reshape(-1, chain.shape[-1])[:, 7:17],\n", + " labels=list(f\"flux at epoch {i}\" for i in range(10)),\n", + " truths=params_true[7:17],\n", + ")\n", + "figure.suptitle(\"Likelihood distributions for supernova fluxes at each epoch\", fontsize=20)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "Below we show the likelihood distribution for the sersic host parameters. We can see that there is some non-linearity and certainly lots of correlation in these parameters. This makes the sampling a bit trickier, but MALA is up to the task." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "figure = corner(\n", + " chain.reshape(-1, chain.shape[-1])[:, :5],\n", + " labels=[\"sersic_q\", \"sersic_PA\", \"sersic_n\", \"sersic_Re\", \"sersic_Ie\"],\n", + " truths=params_true[:5],\n", + ")\n", + "figure.suptitle(\"Likelihood distributions for host parameters\", fontsize=20)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/GettingStartedJAX.ipynb b/docs/source/tutorials/GettingStartedJAX.ipynb index faf94f87..c256ed2f 100644 --- a/docs/source/tutorials/GettingStartedJAX.ipynb +++ b/docs/source/tutorials/GettingStartedJAX.ipynb @@ -47,9 +47,6 @@ "metadata": {}, "outputs": [], "source": [ - "import caskade as ck\n", - "\n", - "ck.backend.backend = \"jax\"\n", "ap.backend.backend = \"jax\"\n", "# and that's it!" ] diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index ddfe854b..2d6deef0 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -19,6 +19,7 @@ version of each tutorial is available here. ImageAlignment PoissonLikelihood CustomModels + FunctionalInterface GravitationalLensing AdvancedPSFModels ImageTypes From 2138ff47d0c3fea372f915ffbb301708e2f0cf5d Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 12 Nov 2025 13:52:08 -0500 Subject: [PATCH 153/191] remove unused import --- astrophot/models/func/sersic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrophot/models/func/sersic.py b/astrophot/models/func/sersic.py index 3553ef14..79165fd7 100644 --- a/astrophot/models/func/sersic.py +++ b/astrophot/models/func/sersic.py @@ -1,4 +1,3 @@ -import torch from ...backend_obj import backend, ArrayLike From ea2997415d472813f553761232125e62c389098a Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 12 Nov 2025 14:14:09 -0500 Subject: [PATCH 154/191] remove has_mask and has_variance, now always have those --- astrophot/fit/iterative.py | 41 +++----- astrophot/fit/lm.py | 22 ++-- astrophot/image/mixins/data_mixin.py | 101 ++++--------------- astrophot/image/psf_image.py | 3 +- astrophot/image/target_image.py | 12 --- astrophot/models/_shared_methods.py | 5 +- astrophot/models/edgeon.py | 6 +- astrophot/models/flatsky.py | 10 +- astrophot/models/gaussian_ellipsoid.py | 7 +- astrophot/models/mixins/transform.py | 7 +- astrophot/models/model_object.py | 5 +- astrophot/models/multi_gaussian_expansion.py | 6 +- astrophot/models/planesky.py | 5 +- astrophot/models/point_source.py | 6 +- astrophot/plots/image.py | 9 +- astrophot/plots/profile.py | 8 +- 16 files changed, 70 insertions(+), 183 deletions(-) diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 0b9f4e02..9cee2d04 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -61,9 +61,8 @@ def __init__( self.ndf = self.model.target[self.model.window].flatten("data").shape[0] - len( self.current_state ) - if self.model.target.has_mask: - # subtract masked pixels from degrees of freedom - self.ndf -= backend.sum(self.model.target[self.model.window].flatten("mask")).item() + # subtract masked pixels from degrees of freedom + self.ndf -= backend.sum(self.model.target[self.model.window].flatten("mask")).item() def sub_step(self, model: Model, update_uncertainty=False): """ @@ -99,16 +98,9 @@ def step(self): config.logger.info("Update Chi^2 with new parameters") self.Y = self.model(params=self.current_state) D = self.model.target[self.model.window].flatten("data") - V = ( - self.model.target[self.model.window].flatten("variance") - if self.model.target.has_variance - else 1.0 - ) - if self.model.target.has_mask: - M = self.model.target[self.model.window].flatten("mask") - loss = backend.sum((((D - self.Y.flatten("data")) ** 2) / V)[~M]) / self.ndf - else: - loss = backend.sum(((D - self.Y.flatten("data")) ** 2 / V)) / self.ndf + V = self.model.target[self.model.window].flatten("variance") + M = self.model.target[self.model.window].flatten("mask") + loss = backend.sum((((D - self.Y.flatten("data")) ** 2) / V)[~M]) / self.ndf if self.verbose > 0: config.logger.info(f"Loss: {loss.item()}") self.lambda_history.append(np.copy(backend.to_numpy(self.current_state))) @@ -229,18 +221,12 @@ def __init__( if backend.sum(fit_mask).item() == 0: fit_mask = None - if model.target.has_mask: - mask = self.model.target[self.fit_window].flatten("mask") - if fit_mask is not None: - mask = mask | fit_mask - self.mask = ~mask - elif fit_mask is not None: - self.mask = ~fit_mask - else: - self.mask = backend.ones_like( - self.model.target[self.fit_window].flatten("data"), dtype=backend.bool - ) - if self.mask is not None and backend.sum(self.mask).item() == 0: + mask = self.model.target[self.fit_window].flatten("mask") + if fit_mask is not None: + mask = mask | fit_mask + self.mask = ~mask + + if backend.sum(self.mask).item() == 0: raise OptimizeStopSuccess("No data to fit. All pixels are masked") # Initialize optimizer attributes @@ -252,10 +238,7 @@ def __init__( self.W = backend.as_array(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ self.mask ] - elif model.target.has_weight: - self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] - else: - self.W = backend.ones_like(self.Y) + self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] # The forward model which computes the output image given input parameters self.full_forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[ diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 1b895275..803a5f3e 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -157,18 +157,11 @@ def __init__( if backend.sum(fit_mask).item() == 0: fit_mask = None - if model.target.has_mask: - mask = self.model.target[self.fit_window].flatten("mask") - if fit_mask is not None: - mask = mask | fit_mask - self.mask = ~mask - elif fit_mask is not None: - self.mask = ~fit_mask - else: - self.mask = backend.ones_like( - self.model.target[self.fit_window].flatten("data"), dtype=backend.bool - ) - if self.mask is not None and backend.sum(self.mask).item() == 0: + mask = self.model.target[self.fit_window].flatten("mask") + if fit_mask is not None: + mask = mask | fit_mask + self.mask = ~mask + if backend.sum(self.mask).item() == 0: raise OptimizeStopSuccess("No data to fit. All pixels are masked") # Initialize optimizer attributes @@ -180,10 +173,7 @@ def __init__( self.W = backend.as_array(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ self.mask ] - elif model.target.has_weight: - self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] - else: - self.W = backend.ones_like(self.Y) + self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] # The forward model which computes the output image given input parameters self.forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[self.mask] diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 9b4e4486..ff6828bd 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -74,9 +74,7 @@ def std(self): computed as $\\sqrt{1/W}$ where $W$ is the weights. """ - if self.has_variance: - return backend.sqrt(self.variance) - return backend.ones_like(self.data) + return backend.sqrt(self.variance) @std.setter def std(self, std): @@ -88,18 +86,6 @@ def std(self, std): return self.weight = 1 / std**2 - @property - def has_std(self) -> bool: - """Returns True when the image object has stored standard deviation values. If - this is False and the std property is called then a - tensor of ones will be returned. - - """ - try: - return self._weight is not None - except AttributeError: - return False - @property def variance(self): """Stores the variance of the image pixels. This represents the @@ -113,15 +99,11 @@ def variance(self): weights. """ - if self.has_variance: - return backend.where(self.weight == 0, backend.inf, 1 / self.weight) - return backend.ones_like(self.data) + return backend.where(self.weight == 0, backend.inf, 1 / self.weight) @property def _variance(self): - if self.has_variance: - return backend.where(self._weight == 0, backend.inf, 1 / self._weight) - return backend.ones_like(self._data) + return backend.where(self._weight == 0, backend.inf, 1 / self._weight) @variance.setter def variance(self, variance): @@ -133,18 +115,6 @@ def variance(self, variance): return self.weight = 1 / variance - @property - def has_variance(self) -> bool: - """Returns True when the image object has stored variance values. If - this is False and the variance property is called then a - tensor of ones will be returned. - - """ - try: - return self._weight is not None - except AttributeError: - return False - @property def weight(self): """Stores the weight of the image pixels. This represents the @@ -171,14 +141,12 @@ def weight(self): $$H \\approx J^TWJ$$ """ - if self.has_weight: - return backend.transpose(self._weight, 1, 0) - return backend.ones_like(self.data) + return backend.transpose(self._weight, 1, 0) @weight.setter def weight(self, weight): if weight is None: - self._weight = None + self._weight = backend.ones_like(self._data) return if isinstance(weight, str) and weight == "auto": weight = 1 / auto_variance(self.data, self.mask) @@ -186,24 +154,10 @@ def weight(self, weight): backend.as_array(weight, dtype=config.DTYPE, device=config.DEVICE), 1, 0 ) if self._weight.shape != self._data.shape: - self._weight = None raise SpecificationConflict( f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" ) - @property - def has_weight(self) -> bool: - """Returns True when the image object has stored weight values. If - this is False and the weight property is called then a - tensor of ones will be returned. - - """ - try: - return self._weight is not None - except AttributeError: - self._weight = None - return False - @property def mask(self): """The mask stores a tensor of boolean values which indicate any @@ -219,34 +173,21 @@ def mask(self): If no mask is provided, all pixels are assumed valid. """ - if self.has_mask: - return backend.transpose(self._mask, 1, 0) - return backend.zeros_like(self.data, dtype=backend.bool) + return backend.transpose(self._mask, 1, 0) @mask.setter def mask(self, mask): if mask is None: - self._mask = None + self._mask = backend.zeros_like(self._data, dtype=backend.bool) return self._mask = backend.transpose( backend.as_array(mask, dtype=backend.bool, device=config.DEVICE), 1, 0 ) if self._mask.shape != self._data.shape: - self._mask = None raise SpecificationConflict( f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" ) - @property - def has_mask(self) -> bool: - """ - Single boolean to indicate if a mask has been provided by the user. - """ - try: - return self._mask is not None - except AttributeError: - return False - def to(self, dtype=None, device=None): """Converts the stored `Target_Image` data, variance, psf, etc to a given data type and device. @@ -258,10 +199,8 @@ def to(self, dtype=None, device=None): device = config.DEVICE super().to(dtype=dtype, device=device) - if self.has_weight: - self._weight = backend.to(self._weight, dtype=dtype, device=device) - if self.has_mask: - self._mask = backend.to(self._mask, dtype=backend.bool, device=device) + self._weight = backend.to(self._weight, dtype=dtype, device=device) + self._mask = backend.to(self._mask, dtype=backend.bool, device=device) return self def copy_kwargs(self, **kwargs): @@ -279,23 +218,21 @@ def get_window(self, other: Union[Image, Window], indices=None, **kwargs): indices = self.get_indices(other if isinstance(other, Window) else other.window) return super().get_window( other, - _weight=self._weight[indices] if self.has_weight else None, - _mask=self._mask[indices] if self.has_mask else None, + _weight=self._weight[indices], + _mask=self._mask[indices], indices=indices, **kwargs, ) def fits_images(self): images = super().fits_images() - if self.has_weight: - images.append(fits.ImageHDU(backend.to_numpy(self.weight), name="WEIGHT")) - if self.has_mask: - images.append( - fits.ImageHDU( - backend.to_numpy(self.mask).astype(int), - name="MASK", - ) + images.append(fits.ImageHDU(backend.to_numpy(self.weight), name="WEIGHT")) + images.append( + fits.ImageHDU( + backend.to_numpy(self.mask).astype(int), + name="MASK", ) + ) return images def load(self, filename: str, hduext: int = 0): @@ -334,15 +271,11 @@ def reduce(self, scale: int, **kwargs) -> Image: self._variance[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), dim=(1, 3), ) - if self.has_variance - else None ), _mask=( backend.max( self._mask[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale), dim=(1, 3) ) - if self.has_mask - else None ), **kwargs, ) diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index aba5ebc6..3b4141dd 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -29,8 +29,7 @@ def normalize(self): """Normalizes the PSF image to have a sum of 1.""" norm = backend.sum(self._data) self._data = self._data / norm - if self.has_weight: - self._weight = self.weight * norm**2 + self._weight = self.weight * norm**2 @property def psf_pad(self) -> int: diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index 4be6780b..cb047d37 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -276,10 +276,6 @@ def variance(self, variance): for image, var in zip(self.images, variance): image.variance = var - @property - def has_variance(self): - return any(image.has_variance for image in self.images) - @property def weight(self): return tuple(image.weight for image in self.images) @@ -293,10 +289,6 @@ def weight(self, weight): for image, wgt in zip(self.images, weight): image.weight = wgt - @property - def has_weight(self): - return any(image.has_weight for image in self.images) - def jacobian_image( self, parameters: List[str], data: Optional[List[ArrayLike]] = None ) -> JacobianImageList: @@ -322,10 +314,6 @@ def mask(self, mask): for image, M in zip(self.images, mask): image.mask = M - @property - def has_mask(self) -> bool: - return any(image.has_mask for image in self.images) - @property def psf(self): return tuple(image.psf for image in self.images) diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index f4bd2b2e..6cb39689 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -19,9 +19,8 @@ def _sample_image( ): dat = backend.to_numpy(image._data).copy() # Fill masked pixels - if image.has_mask: - mask = backend.to_numpy(image._mask) - dat[mask] = np.median(dat[~mask]) + mask = backend.to_numpy(image._mask) + dat[mask] = np.median(dat[~mask]) # Subtract median of edge pixels to avoid effect of nearby sources edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) dat -= np.median(edge) diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index a746d530..812fce5a 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -36,9 +36,9 @@ def initialize(self): return target_area = self.target[self.window] dat = backend.to_numpy(target_area._data).copy() - if target_area.has_mask: - mask = backend.to_numpy(target_area._mask) - dat[mask] = np.median(dat[~mask]) + mask = backend.to_numpy(target_area._mask) + dat[mask] = np.median(dat[~mask]) + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) dat = dat - edge_average diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 5b4363cf..21e23638 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -20,9 +20,7 @@ class FlatSky(SkyModel): """ _model_type = "flat" - _parameter_specs = { - "I": {"units": "flux/arcsec^2"}, - } + _parameter_specs = {"I": {"units": "flux/arcsec^2"}} usable = True @torch.no_grad() @@ -35,9 +33,9 @@ def initialize(self): target_area = self.target[self.window] dat = backend.to_numpy(target_area._data).copy() - if target_area.has_mask: - mask = backend.to_numpy(target_area._mask) - dat[mask] = np.median(dat[~mask]) + mask = backend.to_numpy(target_area._mask) + dat[mask] = np.median(dat[~mask]) + self.I.dynamic_value = np.median(dat) / self.target.pixel_area.item() @forward diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index 9e3fa958..02cd54bd 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -76,12 +76,13 @@ def initialize(self): target_area = self.target[self.window] dat = backend.to_numpy(target_area._data).copy() - if target_area.has_mask: - mask = backend.to_numpy(target_area._mask).copy() - dat[mask] = np.median(dat[~mask]) + mask = backend.to_numpy(target_area._mask).copy() + dat[mask] = np.median(dat[~mask]) + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) dat -= edge_average + x, y = target_area.coordinate_center_meshgrid() center = self.center.value x = x - center[0] diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 6bbffc8e..73f32f30 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -51,12 +51,13 @@ def initialize(self): return target_area = self.target[self.window] dat = backend.to_numpy(backend.copy(target_area._data)) - if target_area.has_mask: - mask = backend.to_numpy(backend.copy(target_area._mask)) - dat[mask] = np.median(dat[~mask]) + mask = backend.to_numpy(backend.copy(target_area._mask)) + dat[mask] = np.median(dat[~mask]) + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) dat -= edge_average + x, y = target_area.coordinate_center_meshgrid() x = backend.to_numpy(x - self.center.value[0]) y = backend.to_numpy(y - self.center.value[1]) diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 6d6b3e40..13e32970 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -127,9 +127,8 @@ def initialize(self): target_area = self.target[self.window] dat = np.copy(backend.to_numpy(target_area._data)) - if target_area.has_mask: - mask = backend.to_numpy(target_area._mask) - dat[mask] = np.nanmedian(dat[~mask]) + mask = backend.to_numpy(target_area._mask) + dat[mask] = np.nanmedian(dat[~mask]) COM = recursive_center_of_mass(dat) if not np.all(np.isfinite(COM)): diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 2ab3e0bb..f4d5cb48 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -56,9 +56,9 @@ def initialize(self): target_area = self.target[self.window] dat = backend.to_numpy(target_area._data).copy() - if target_area.has_mask: - mask = backend.to_numpy(target_area._mask) - dat[mask] = np.median(dat[~mask]) + mask = backend.to_numpy(target_area._mask) + dat[mask] = np.median(dat[~mask]) + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.nanmedian(edge) dat -= edge_average diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index 614b39e7..0868a064 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -39,9 +39,8 @@ def initialize(self): if not self.I0.initialized: dat = backend.to_numpy(self.target[self.window]._data).copy() - if self.target[self.window].has_mask: - mask = backend.to_numpy(self.target[self.window]._mask) - dat[mask] = np.median(dat[~mask]) + mask = backend.to_numpy(self.target[self.window]._mask) + dat[mask] = np.median(dat[~mask]) self.I0.dynamic_value = np.median(dat) / self.target.pixel_area.item() if not self.delta.initialized: self.delta.dynamic_value = [0.0, 0.0] diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 70ba3c56..db049cb3 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -51,9 +51,9 @@ def initialize(self): return target_area = self.target[self.window] dat = backend.to_numpy(target_area._data).copy() - if target_area.has_mask: - mask = backend.to_numpy(target_area._mask) - dat[mask] = np.median(dat[~mask]) + mask = backend.to_numpy(target_area._mask) + dat[mask] = np.median(dat[~mask]) + edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) self.flux.dynamic_value = np.abs(np.sum(dat - edge_average)) diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 12979f75..71e8861d 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -49,8 +49,7 @@ def target_image(fig, ax, target, window=None, **kwargs): window = target.window target_area = target[window] dat = np.copy(backend.to_numpy(target_area._data)) - if target_area.has_mask: - dat[backend.to_numpy(target_area._mask)] = np.nan + dat[backend.to_numpy(target_area._mask)] = np.nan X, Y = target_area.coordinate_corner_meshgrid() X = backend.to_numpy(X) Y = backend.to_numpy(Y) @@ -269,8 +268,7 @@ def model_image( } # Apply the mask if available - if target_mask and target.has_mask: - sample_image[backend.to_numpy(target._mask)] = np.nan + sample_image[backend.to_numpy(target._mask)] = np.nan # Plot the image im = ax.pcolormesh(X, Y, sample_image, **kwargs) @@ -368,8 +366,7 @@ def residual_image( residuals = residuals / backend.sqrt(normalize_residuals) normalize_residuals = True residuals = backend.to_numpy(residuals) - if target.has_mask: - residuals[backend.to_numpy(target._mask)] = np.nan + residuals[backend.to_numpy(target._mask)] = np.nan if scaling == "clip": if normalize_residuals is not True: diff --git a/astrophot/plots/profile.py b/astrophot/plots/profile.py index 56137789..adcdd83b 100644 --- a/astrophot/plots/profile.py +++ b/astrophot/plots/profile.py @@ -125,10 +125,10 @@ def radial_median_profile( R = backend.to_numpy(R) dat = backend.to_numpy(image._data) - if image.has_mask: # remove masked pixels - mask = backend.to_numpy(image._mask) - dat = dat[~mask] - R = R[~mask] + # remove masked pixels + mask = backend.to_numpy(image._mask) + dat = dat[~mask] + R = R[~mask] count, bins, binnum = binned_statistic( R.ravel(), From b74c9733b0d9c0211464dc1b99116b9a4f495b63 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 12 Nov 2025 14:26:03 -0500 Subject: [PATCH 155/191] remove unused import --- astrophot/image/psf_image.py | 2 +- astrophot/models/psf_model_object.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index 3b4141dd..d0cc05b5 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -29,7 +29,7 @@ def normalize(self): """Normalizes the PSF image to have a sum of 1.""" norm = backend.sum(self._data) self._data = self._data / norm - self._weight = self.weight * norm**2 + self._weight = self._weight * norm**2 @property def psf_pad(self) -> int: diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 5554b5ca..37492422 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -1,6 +1,4 @@ from typing import Optional, Tuple -import torch -from torch import Tensor from caskade import forward from .base import Model From 454c32770e681efe69816fb36ff618848aa1b495 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 12 Nov 2025 16:43:48 -0500 Subject: [PATCH 156/191] getting most tests to run --- astrophot/backend_obj.py | 2 + astrophot/fit/func/lm.py | 8 +- astrophot/fit/mhmcmc.py | 15 +- astrophot/image/mixins/data_mixin.py | 22 +- astrophot/models/bilinear_sky.py | 5 +- astrophot/models/group_model_object.py | 18 +- astrophot/models/multi_gaussian_expansion.py | 2 +- astrophot/plots/image.py | 5 +- .../utils/initialize/segmentation_map.py | 4 +- docs/requirements.txt | 1 + docs/source/tutorials/FittingMethods.ipynb | 126 ++---- docs/source/tutorials/GettingStarted.ipynb | 3 +- docs/source/tutorials/ImageAlignment.py | 191 --------- docs/source/tutorials/JointModels.py | 371 ------------------ tests/test_image.py | 16 +- tests/test_notebooks.py | 10 +- 16 files changed, 98 insertions(+), 701 deletions(-) delete mode 100644 docs/source/tutorials/ImageAlignment.py delete mode 100644 docs/source/tutorials/JointModels.py diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index f903e725..cd32dc23 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -84,6 +84,7 @@ def setup_torch(self): self.grad = self._grad_torch self.vmap = self._vmap_torch self.long = self._long_torch + self.detach = lambda x: x.detach() self.fill_at_indices = self._fill_at_indices_torch self.add_at_indices = self._add_at_indices_torch self.and_at_indices = self._and_at_indices_torch @@ -128,6 +129,7 @@ def setup_jax(self): self.grad = self._grad_jax self.vmap = self._vmap_jax self.long = self._long_jax + self.detach = lambda x: x self.fill_at_indices = self._fill_at_indices_jax self.add_at_indices = self._add_at_indices_jax self.and_at_indices = self._and_at_indices_jax diff --git a/astrophot/fit/func/lm.py b/astrophot/fit/func/lm.py index 2c5f1371..2e88c816 100644 --- a/astrophot/fit/func/lm.py +++ b/astrophot/fit/func/lm.py @@ -71,11 +71,11 @@ def lm_step( likelihood="gaussian", ): L0 = L - M0 = model(x).detach() # (M,) # fixme detach to backend - J = jacobian(x).detach() # (M, N) + M0 = backend.detach(model(x)) # (M,) + J = backend.detach(jacobian(x)) # (M, N) if likelihood == "gaussian": - nll0 = nll(data, M0, weight).item() # torch.sum(weight * R**2).item() / ndf + nll0 = nll(data, M0, weight).item() grad = gradient(J, weight, data, M0) # (N, 1) hess = hessian(J, weight) # (N, N) elif likelihood == "poisson": @@ -98,7 +98,7 @@ def lm_step( hessD, h = solve(hess, grad, L) # (N, N), (N, 1) M1 = model(x + h.squeeze(1)) # (M,) if likelihood == "gaussian": - nll1 = nll(data, M1, weight).item() # torch.sum(weight * (data - M1) ** 2).item() / ndf + nll1 = nll(data, M1, weight).item() elif likelihood == "poisson": nll1 = nll_poisson(data, M1).item() diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index 3f3db269..7ab36066 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -48,19 +48,24 @@ def __init__( self.chain = [] - def density(self, state: np.ndarray) -> np.ndarray: + def density(self): """ Returns the density of the model at the given state vector. This is used to calculate the likelihood of the model at the given state. """ - state = backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) if self.likelihood == "gaussian": - return np.array(list(self.model.gaussian_log_likelihood(s).item() for s in state)) + vll = backend.vmap(self.model.gaussian_log_likelihood) elif self.likelihood == "poisson": - return np.array(list(self.model.poisson_log_likelihood(s).item() for s in state)) + vll = backend.vmap(self.model.poisson_log_likelihood) else: raise ValueError(f"Unknown likelihood type: {self.likelihood}") + def dens(state: np.ndarray) -> np.ndarray: + state = backend.as_array(state, dtype=config.DTYPE, device=config.DEVICE) + return backend.to_numpy(vll(state)) + + return dens + def fit( self, state: Optional[np.ndarray] = None, @@ -85,7 +90,7 @@ def fit( else: nwalkers = state.shape[0] ndim = state.shape[1] - sampler = emcee.EnsembleSampler(nwalkers, ndim, self.density, vectorize=True) + sampler = emcee.EnsembleSampler(nwalkers, ndim, self.density(), vectorize=True) state = sampler.run_mcmc(state, nsamples, skip_initial_state_check=skip_initial_state_check) if restart_chain: self.chain = sampler.get_chain(flat=flat_chain) diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index ff6828bd..88bfde35 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -60,7 +60,7 @@ def __init__( # Set nan pixels to be masked automatically if backend.any(backend.isnan(self._data)).item(): - self._mask = self.mask | backend.isnan(self._data) + self._mask = self._mask | backend.isnan(self._data) @property def std(self): @@ -158,6 +158,16 @@ def weight(self, weight): f"weight/variance must have same shape as data ({weight.shape} vs {self.data.shape})" ) + @property + def _weight(self): + return self.__weight + + @_weight.setter + def _weight(self, value): + if value is None: + value = backend.ones_like(self._data) + self.__weight = value + @property def mask(self): """The mask stores a tensor of boolean values which indicate any @@ -188,6 +198,16 @@ def mask(self, mask): f"mask must have same shape as data ({mask.shape} vs {self.data.shape})" ) + @property + def _mask(self): + return self.__mask + + @_mask.setter + def _mask(self, value): + if value is None: + value = backend.zeros_like(self._data, dtype=backend.bool) + self.__mask = value + def to(self, dtype=None, device=None): """Converts the stored `Target_Image` data, variance, psf, etc to a given data type and device. diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py index 22562e20..16242b1c 100644 --- a/astrophot/models/bilinear_sky.py +++ b/astrophot/models/bilinear_sky.py @@ -59,9 +59,8 @@ def initialize(self): target_dat = self.target[self.window] dat = backend.to_numpy(target_dat._data).copy() - if self.target.has_mask: - mask = backend.to_numpy(target_dat._mask).copy() - dat[mask] = np.nanmedian(dat) + mask = backend.to_numpy(target_dat._mask).copy() + dat[mask] = np.nanmedian(dat) iS = dat.shape[0] // self.nodes[0] jS = dat.shape[1] // self.nodes[1] diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 6da1ff96..66dcc21e 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -347,22 +347,24 @@ def segmentation_map(self) -> ArrayLike: "Segmentation maps are not currently supported for ImageList targets. Please apply one target at a time." ) else: - seg_map = backend.zeros_like(subtarget.data, dtype=backend.int32) - 1 - max_flux_frac = 0.0 * backend.ones_like(subtarget.data) / np.prod(subtarget.data.shape) + seg_map = backend.zeros_like(subtarget._data, dtype=backend.int32) - 1 + max_flux_frac = ( + 0.0 * backend.ones_like(subtarget._data) / np.prod(subtarget._data.shape) + ) for idx, model in enumerate(self.models): model_image = model() - model_flux_frac = backend.abs(model_image.data) / backend.sum( - backend.abs(model_image.data) + model_flux_frac = backend.abs(model_image._data) / backend.sum( + backend.abs(model_image._data) ) indices = subtarget.get_indices(model.window) - model_flux_frac_full = backend.zeros_like(subtarget.data) + model_flux_frac_full = backend.zeros_like(subtarget._data) model_flux_frac_full = backend.fill_at_indices( model_flux_frac_full, indices, model_flux_frac ) update_mask = model_flux_frac_full >= max_flux_frac seg_map = backend.where(update_mask, idx, seg_map) max_flux_frac = backend.where(update_mask, model_flux_frac_full, max_flux_frac) - return seg_map + return seg_map.T def deblend(self) -> Sequence[TargetImage]: """Generate deblended images for each sub-model in this group model. @@ -389,7 +391,7 @@ def deblend(self) -> Sequence[TargetImage]: ) deblend_data = subsubtarget.data * model_image.data / subfull_model.data deblend_variance = subsubtarget.variance * model_image.data / subfull_model.data - subsubtarget.data = deblend_data.T - subsubtarget.variance = deblend_variance.T + subsubtarget.data = deblend_data + subsubtarget.variance = deblend_variance deblended_images.append(subsubtarget) return deblended_images diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index f4d5cb48..89669558 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -66,7 +66,7 @@ def initialize(self): if not self.sigma.initialized: self.sigma.dynamic_value = np.logspace( np.log10(target_area.pixelscale.item() * 3), - max(target_area.shape) * target_area.pixelscale.item() * 0.7, + max(target_area.data.shape) * target_area.pixelscale.item() * 0.7, self.n_components, ) if not self.flux.initialized: diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index 71e8861d..fc0aba8a 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -48,6 +48,7 @@ def target_image(fig, ax, target, window=None, **kwargs): if window is None: window = target.window target_area = target[window] + dat = np.copy(backend.to_numpy(target_area._data)) dat[backend.to_numpy(target_area._mask)] = np.nan X, Y = target_area.coordinate_corner_meshgrid() @@ -268,7 +269,7 @@ def model_image( } # Apply the mask if available - sample_image[backend.to_numpy(target._mask)] = np.nan + sample_image[backend.to_numpy(target[window]._mask)] = np.nan # Plot the image im = ax.pcolormesh(X, Y, sample_image, **kwargs) @@ -361,7 +362,7 @@ def residual_image( residuals = (target - sample_image)._data if normalize_residuals is True: - residuals = residuals / backend.sqrt(target.variance) + residuals = residuals / backend.sqrt(target._variance) elif isinstance(normalize_residuals, backend.array_type): residuals = residuals / backend.sqrt(normalize_residuals) normalize_residuals = True diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index 32d3fdbc..6364ba40 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -349,9 +349,9 @@ def transfer_windows(windows, base_image, new_image): ) # (4,2) bottom_corner = np.floor(np.min(four_corners_new, axis=0)).astype(int) - bottom_corner = np.clip(bottom_corner, 0, np.array(new_image.shape)) + bottom_corner = np.clip(bottom_corner, 0, np.array(new_image._data.shape)) top_corner = np.ceil(np.max(four_corners_new, axis=0)).astype(int) - top_corner = np.clip(top_corner, 0, np.array(new_image.shape)) + top_corner = np.clip(top_corner, 0, np.array(new_image._data.shape)) new_windows[w] = [ [int(bottom_corner[0]), int(bottom_corner[1])], [int(top_corner[0]), int(top_corner[1])], diff --git a/docs/requirements.txt b/docs/requirements.txt index 4002008d..d2b0ecc0 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ caustics +corner emcee graphviz ipywidgets diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index b0f0525f..1aa8627e 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -26,6 +26,7 @@ "from scipy.stats import gaussian_kde as kde\n", "from scipy.stats import norm\n", "from tqdm import tqdm\n", + "from corner import corner\n", "\n", "import astrophot as ap" ] @@ -151,7 +152,7 @@ " MODEL = initialize_model(target, True)\n", "\n", " # Sample the model with the true values to make a mock image\n", - " img = MODEL().data.T.detach().cpu().numpy()\n", + " img = MODEL().data.detach().cpu().numpy()\n", " # Add poisson noise\n", " target.data = torch.Tensor(img + rng.normal(scale=np.sqrt(img) / 2))\n", " target.variance = torch.Tensor(img / 4)\n", @@ -232,81 +233,6 @@ " plt.show()\n", "\n", "\n", - "def corner_plot_covariance(\n", - " cov_matrix, mean, labels=None, figsize=(10, 10), true_values=None, ellipse_colors=\"g\"\n", - "):\n", - " num_params = cov_matrix.shape[0]\n", - " fig, axes = plt.subplots(num_params, num_params, figsize=figsize)\n", - " plt.subplots_adjust(wspace=0.0, hspace=0.0)\n", - "\n", - " for i in range(num_params):\n", - " for j in range(num_params):\n", - " ax = axes[i, j]\n", - "\n", - " if i == j:\n", - " x = np.linspace(\n", - " mean[i] - 3 * np.sqrt(cov_matrix[i, i]),\n", - " mean[i] + 3 * np.sqrt(cov_matrix[i, i]),\n", - " 100,\n", - " )\n", - " y = norm.pdf(x, mean[i], np.sqrt(cov_matrix[i, i]))\n", - " ax.plot(x, y, color=\"g\")\n", - " ax.set_xlim(\n", - " mean[i] - 3 * np.sqrt(cov_matrix[i, i]), mean[i] + 3 * np.sqrt(cov_matrix[i, i])\n", - " )\n", - " if true_values is not None:\n", - " ax.axvline(true_values[i], color=\"red\", linestyle=\"-\", lw=1)\n", - " elif j < i:\n", - " cov = cov_matrix[np.ix_([j, i], [j, i])]\n", - " lambda_, v = np.linalg.eig(cov)\n", - " lambda_ = np.sqrt(lambda_)\n", - " angle = np.rad2deg(np.arctan2(v[1, 0], v[0, 0]))\n", - " for k in [1, 2]:\n", - " ellipse = Ellipse(\n", - " xy=(mean[j], mean[i]),\n", - " width=lambda_[0] * k * 2,\n", - " height=lambda_[1] * k * 2,\n", - " angle=angle,\n", - " edgecolor=ellipse_colors,\n", - " facecolor=\"none\",\n", - " )\n", - " ax.add_artist(ellipse)\n", - "\n", - " # Set axis limits\n", - " margin = 3\n", - " ax.set_xlim(\n", - " mean[j] - margin * np.sqrt(cov_matrix[j, j]),\n", - " mean[j] + margin * np.sqrt(cov_matrix[j, j]),\n", - " )\n", - " ax.set_ylim(\n", - " mean[i] - margin * np.sqrt(cov_matrix[i, i]),\n", - " mean[i] + margin * np.sqrt(cov_matrix[i, i]),\n", - " )\n", - "\n", - " if true_values is not None:\n", - " ax.axvline(true_values[j], color=\"red\", linestyle=\"-\", lw=1)\n", - " ax.axhline(true_values[i], color=\"red\", linestyle=\"-\", lw=1)\n", - "\n", - " if j > i:\n", - " ax.axis(\"off\")\n", - "\n", - " if i < num_params - 1:\n", - " ax.set_xticklabels([])\n", - " else:\n", - " if labels is not None:\n", - " ax.set_xlabel(labels[j])\n", - " ax.yaxis.set_major_locator(plt.NullLocator())\n", - "\n", - " if j > 0:\n", - " ax.set_yticklabels([])\n", - " else:\n", - " if labels is not None:\n", - " ax.set_ylabel(labels[i])\n", - " ax.xaxis.set_major_locator(plt.NullLocator())\n", - "\n", - " plt.show()\n", - "\n", - "\n", "target = generate_target()" ] }, @@ -379,12 +305,12 @@ "source": [ "param_names = list(MODEL.build_params_array_names())\n", "set, sky = true_params()\n", - "corner_plot_covariance(\n", + "fig, ax = ap.plots.covariance_matrix(\n", " res_lm.covariance_matrix.detach().cpu().numpy(),\n", " MODEL.build_params_array().detach().cpu().numpy(),\n", " labels=param_names,\n", " figsize=(20, 20),\n", - " true_values=np.concatenate((sky, set.ravel())),\n", + " reference_values=np.concatenate((sky, set.ravel())),\n", ")" ] }, @@ -499,12 +425,12 @@ "source": [ "param_names = list(MODEL.build_params_array_names())\n", "set, sky = true_params()\n", - "corner_plot_covariance(\n", + "fig, ax = ap.plots.covariance_matrix(\n", " res_iterparam.covariance_matrix.detach().cpu().numpy(),\n", " MODEL.build_params_array().detach().cpu().numpy(),\n", " labels=param_names,\n", " figsize=(20, 20),\n", - " true_values=np.concatenate((sky, set.ravel())),\n", + " reference_values=np.concatenate((sky, set.ravel())),\n", ")" ] }, @@ -589,7 +515,7 @@ "source": [ "MODEL = initialize_model(target, False)\n", "\n", - "res_grad = ap.fit.Slalom(MODEL, verbose=1).fit()" + "res_grad = ap.fit.Slalom(MODEL, verbose=1, momentum=0.1).fit()" ] }, { @@ -697,7 +623,7 @@ "MODEL = initialize_model(target, False)\n", "\n", "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", - "res1 = ap.fit.LM(MODEL).fit()\n", + "res1 = ap.fit.LM(MODEL, verbose=0).fit()\n", "\n", "\n", "def density(x):\n", @@ -740,12 +666,13 @@ "param_names = list(MODEL.build_params_array_names())\n", "\n", "set, sky = true_params()\n", - "corner_plot(\n", - " chain_mala,\n", - " labels=param_names,\n", - " figsize=(20, 20),\n", - " true_values=np.concatenate((sky, set.ravel())),\n", - ")" + "fig = corner(chain_mala, labels=param_names, truths=np.concatenate((sky, set.ravel())))\n", + "# corner_plot(\n", + "# chain_mala,\n", + "# labels=param_names,\n", + "# figsize=(20, 20),\n", + "# true_values=np.concatenate((sky, set.ravel())),\n", + "# )" ] }, { @@ -770,7 +697,7 @@ "MODEL = initialize_model(target, False)\n", "\n", "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", - "res1 = ap.fit.LM(MODEL).fit()\n", + "res1 = ap.fit.LM(MODEL, verbose=0).fit()\n", "\n", "# Run the HMC sampler\n", "res_hmc = ap.fit.HMC(\n", @@ -797,11 +724,12 @@ "param_names = list(MODEL.build_params_array_names())\n", "\n", "set, sky = true_params()\n", - "corner_plot(\n", + "fig = corner(\n", " res_hmc.chain.detach().cpu().numpy(),\n", " labels=param_names,\n", - " figsize=(20, 20),\n", - " true_values=np.concatenate((sky, set.ravel())),\n", + " truths=np.concatenate((sky, set.ravel())),\n", + " plot_contours=False,\n", + " smooth=0.8,\n", ")" ] }, @@ -811,7 +739,7 @@ "source": [ "## Metropolis Hastings\n", "\n", - "This is the more standard MCMC algorithm using the Metropolis Hastngs accept step identified with `ap.fit.MHMCMC`. Under the hood, this is just a wrapper for the excellent `emcee` package, if you want to take advantage of more `emcee` features you can very easily use `ap.fit.MHMCMC` as a starting point. However, one should keep in mind that for large models it can take exceedingly long to actually converge to the posterior. Instead of waiting that long, we demonstrate the functionality with 100 steps (and 30 chains), but suggest using MALA for any real world problem. Still, if there is something NUTS can't handle (a function that isn't differentiable) then MHMCMC can save the day (even if it takes all day to do it)." + "This is the more standard MCMC algorithm using the Metropolis Hastngs accept step identified with `ap.fit.MHMCMC`. Under the hood, this is just a wrapper for the excellent `emcee` package, if you want to take advantage of more `emcee` features you can very easily use `ap.fit.MHMCMC` as a starting point. However, one should keep in mind that for large models it can take exceedingly long to actually converge to the posterior. Instead of waiting that long, we demonstrate the functionality with 100 steps (and 30 chains), but suggest using MALA for any real world problem. Still, if there is something MALA can't handle (a function that isn't differentiable) then MHMCMC can save the day (even if it takes all day to do it)." ] }, { @@ -824,9 +752,9 @@ "\n", "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", "print(\"running LM fit\")\n", - "res1 = ap.fit.LM(MODEL).fit()\n", + "res1 = ap.fit.LM(MODEL, verbose=0).fit()\n", "\n", - "# Run the HMC sampler\n", + "# Run the MHMCMC sampler\n", "print(\"running MHMCMC sampling\")\n", "res_mh = ap.fit.MHMCMC(MODEL, verbose=1, max_iter=100).fit()" ] @@ -842,11 +770,11 @@ "param_names = list(MODEL.build_params_array_names())\n", "\n", "set, sky = true_params()\n", - "corner_plot(\n", - " res_mh.chain[::10], # thin by a factor 10 so the plot works in reasonable time\n", + "fig = corner(\n", + " res_mh.chain,\n", " labels=param_names,\n", - " figsize=(20, 20),\n", - " true_values=np.concatenate((sky, set.ravel())),\n", + " truths=np.concatenate((sky, set.ravel())),\n", + " smooth=0.8,\n", ")" ] }, diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 91632848..fdfb73e3 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -480,8 +480,7 @@ "\n", "fig2, ax2 = plt.subplots(figsize=(8, 8))\n", "\n", - "# Transpose because AstroPhot indexes with (i,j) while numpy uses (j,i)\n", - "pixels = model2().data.T.detach().cpu().numpy()\n", + "pixels = model2().data.detach().cpu().numpy()\n", "\n", "im = plt.imshow(\n", " np.log10(pixels), # take log10 for better dynamic range\n", diff --git a/docs/source/tutorials/ImageAlignment.py b/docs/source/tutorials/ImageAlignment.py deleted file mode 100644 index 621a08e6..00000000 --- a/docs/source/tutorials/ImageAlignment.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 - -# # Aligning Images -# -# In AstroPhot, the image WCS is part of the model and so can be optimized alongside other model parameters. Here we will demonstrate a basic example of image alignment, but the sky is the limit, you can perform highly detailed image alignment with AstroPhot! - -# In[ ]: - - -import astrophot as ap -import matplotlib.pyplot as plt -import numpy as np -import torch -import socket - -socket.setdefaulttimeout(120) - - -# ## Relative shift -# -# Often the WCS solution is already really good, we just need a local shift in x and/or y to get things just right. Lets start by optimizing a translation in the WCS that improves the fit for our models! - -# In[ ]: - - -target_r = ap.TargetImage( - filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=r", - name="target_r", - variance="auto", -) -target_g = ap.TargetImage( - filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=g", - name="target_g", - variance="auto", -) - -# Uh-oh! our images are misaligned by 1 pixel, this will cause problems! -target_g.crpix = target_g.crpix + 1 - -fig, axarr = plt.subplots(1, 2, figsize=(15, 7)) -ap.plots.target_image(fig, axarr[0], target_r) -axarr[0].set_title("Target Image (r-band)") -ap.plots.target_image(fig, axarr[1], target_g) -axarr[1].set_title("Target Image (g-band)") -plt.show() - - -# In[ ]: - - -# fmt: off -# r-band model -psfr = ap.Model(name="psfr", model_type="moffat psf model", n=2, Rd=1.0, target=target_r.psf_image(data=np.zeros((51, 51)))) -star1r = ap.Model(name="star1-r", model_type="point model", window=[0, 60, 80, 135], center=[12, 9], psf=psfr, target=target_r) -star2r = ap.Model(name="star2-r", model_type="point model", window=[40, 90, 20, 70], center=[3, -7], psf=psfr, target=target_r) -star3r = ap.Model(name="star3-r", model_type="point model", window=[109, 150, 40, 90], center=[-15, -3], psf=psfr, target=target_r) -modelr = ap.Model(name="model-r", model_type="group model", models=[star1r, star2r, star3r], target=target_r) - -# g-band model -psfg = ap.Model(name="psfg", model_type="moffat psf model", n=2, Rd=1.0, target=target_g.psf_image(data=np.zeros((51, 51)))) -star1g = ap.Model(name="star1-g", model_type="point model", window=[0, 60, 80, 135], center=star1r.center, psf=psfg, target=target_g) -star2g = ap.Model(name="star2-g", model_type="point model", window=[40, 90, 20, 70], center=star2r.center, psf=psfg, target=target_g) -star3g = ap.Model(name="star3-g", model_type="point model", window=[109, 150, 40, 90], center=star3r.center, psf=psfg, target=target_g) -modelg = ap.Model(name="model-g", model_type="group model", models=[star1g, star2g, star3g], target=target_g) - -# total model -target_full = ap.TargetImageList([target_r, target_g]) -model = ap.Model(name="model", model_type="group model", models=[modelr, modelg], target=target_full) - -# fmt: on -fig, axarr = plt.subplots(1, 2, figsize=(15, 7)) -ap.plots.target_image(fig, axarr, target_full) -axarr[0].set_title("Target Image (r-band)") -axarr[1].set_title("Target Image (g-band)") -ap.plots.model_window(fig, axarr[0], modelr) -ap.plots.model_window(fig, axarr[1], modelg) -plt.show() - - -# In[ ]: - - -model.initialize() -res = ap.fit.LM(model, verbose=1).fit() -fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) -ap.plots.model_image(fig, axarr[0], model) -axarr[0, 0].set_title("Model Image (r-band)") -axarr[0, 1].set_title("Model Image (g-band)") -ap.plots.residual_image(fig, axarr[1], model) -axarr[1, 0].set_title("Residual Image (r-band)") -axarr[1, 1].set_title("Residual Image (g-band)") -plt.show() - - -# Here we see a clear signal of an image misalignment, in the g-band all of the residuals have a dipole in the same direction! Lets free up the position of the g-band image and optimize a shift. This only requires a single line of code! - -# In[ ]: - - -target_g.crtan.to_dynamic() - - -# Now we can optimize the model again, notice how it now has two more parameters. These are the x,y position of the image in the tangent plane. See the AstroPhot coordinate description on the website for more details on why this works. - -# In[ ]: - - -res = ap.fit.LM(model, verbose=1).fit() -fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) -ap.plots.model_image(fig, axarr[0], model) -axarr[0, 0].set_title("Model Image (r-band)") -axarr[0, 1].set_title("Model Image (g-band)") -ap.plots.residual_image(fig, axarr[1], model) -axarr[1, 0].set_title("Residual Image (r-band)") -axarr[1, 1].set_title("Residual Image (g-band)") -plt.show() - - -# Yay! no more dipole. The fits aren't the best, clearly these objects aren't super well described by a single moffat model. But the main goal today was to show that we could align the images very easily. Note, its probably best to start with a reasonably good WCS from the outset, and this two stage approach where we optimize the models and then optimize the models plus a shift might be more stable than just fitting everything at once from the outset. Often for more complex models it is best to start with a simpler model and fit each time you introduce more complexity. - -# ## Shift and rotation -# -# Lets say we really don't trust our WCS, we think something has gone wrong and we want freedom to fully shift and rotate the relative positions of the images relative to each other. How can we do this? - -# In[ ]: - - -def rotate(phi): - """Create a 2D rotation matrix for a given angle in radians.""" - return torch.stack( - [ - torch.stack([torch.cos(phi), -torch.sin(phi)]), - torch.stack([torch.sin(phi), torch.cos(phi)]), - ] - ) - - -# Uh-oh! Our image is misaligned by some small angle -target_g.CD = target_g.CD.value @ rotate(torch.tensor(np.pi / 32, dtype=torch.float64)) -# Uh-oh! our alignment from before has been erased -target_g.crtan.value = (0, 0) - - -# In[ ]: - - -fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) -ap.plots.model_image(fig, axarr[0], model) -axarr[0, 0].set_title("Model Image (r-band)") -axarr[0, 1].set_title("Model Image (g-band)") -ap.plots.residual_image(fig, axarr[1], model) -axarr[1, 0].set_title("Residual Image (r-band)") -axarr[1, 1].set_title("Residual Image (g-band)") -plt.show() - - -# Notice that there is not a universal dipole like in the shift example. Most of the offset is caused by the rotation in this example. - -# In[ ]: - - -# this will control the relative rotation of the g-band image -phi = ap.Param(name="phi", dynamic_value=0.0, dtype=torch.float64) - -# Set the target_g CD matrix to be a function of the rotation angle -# The CD matrix can encode rotation, skew, and rectangular pixels. We -# are only interested in the rotation here. -init_CD = target_g.CD.value.clone() -target_g.CD = lambda p: init_CD @ rotate(p.phi.value) -target_g.CD.link(phi) - -# also optimize the shift of the g-band image -target_g.crtan.to_dynamic() - - -# In[ ]: - - -res = ap.fit.LM(model, verbose=1).fit() -fig, axarr = plt.subplots(2, 2, figsize=(15, 10)) -ap.plots.model_image(fig, axarr[0], model) -axarr[0, 0].set_title("Model Image (r-band)") -axarr[0, 1].set_title("Model Image (g-band)") -ap.plots.residual_image(fig, axarr[1], model) -axarr[1, 0].set_title("Residual Image (r-band)") -axarr[1, 1].set_title("Residual Image (g-band)") -plt.show() - - -# In[ ]: diff --git a/docs/source/tutorials/JointModels.py b/docs/source/tutorials/JointModels.py deleted file mode 100644 index 2116bb84..00000000 --- a/docs/source/tutorials/JointModels.py +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 - -# # Joint Modelling -# -# In this tutorial you will learn how to set up a joint modelling fit which encoporates the data from multiple images. These use `GroupModel` objects just like in the `GroupModels.ipynb` tutorial, the main difference being how the `TargetImage` object is constructed and that more care must be taken when assigning targets to models. -# -# It is, of course, more work to set up a fit across multiple target images. However, the tradeoff can be well worth it. Perhaps there is space-based data with high resolution, but groundbased data has better S/N. Or perhaps each band individually does not have enough signal for a confident fit, but all three together just might. Perhaps colour information is of paramount importance for a science goal, one would hope that both bands could be treated on equal footing but in a consistent way when extracting profile information. There are a number of reasons why one might wish to try and fit a multi image picture of a galaxy simultaneously. -# -# When fitting multiple bands one often resorts to forced photometry, sometimes also blurring each image to the same approximate PSF. With AstroPhot this is entirely unnecessary as one can fit each image in its native PSF simultaneously. The final fits are more meaningful and can encorporate all of the available structure information. - -# In[ ]: - - -import astrophot as ap -import matplotlib.pyplot as plt -import socket - -socket.setdefaulttimeout(120) - - -# In[ ]: - - -# First we need some data to work with, let's use LEDA 41136 as our example galaxy - -# The images must be aligned to a common coordinate system. From the DESI Legacy survey we are extracting -# each image using its RA and DEC coordinates, the WCS in the FITS header will ensure a common coordinate system. - -# It is also important to have a good estimate of the variance and the PSF for each image since these -# affect the relative weight of each image. For the tutorial we use simple approximations, but in -# science level analysis one should endeavor to get the best measure available for these. - -# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel and is 500 pixels across -target_r = ap.TargetImage( - filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=500&layer=ls-dr9&pixscale=0.262&bands=r", - zeropoint=22.5, - variance="auto", # auto variance gets it roughly right, use better estimate for science! - psf=ap.utils.initialize.gaussian_psf(1.12 / 2.355, 51, 0.262), - name="rband", -) - - -# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel and is 52 pixels across -target_W1 = ap.TargetImage( - filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1", - zeropoint=25.199, - variance="auto", - psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75), - name="W1band", -) - -# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel and is 90 pixels across -target_NUV = ap.TargetImage( - filename="https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=90&layer=galex&pixscale=1.5&bands=n", - zeropoint=20.08, - variance="auto", - psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5), - name="NUVband", -) - -fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6)) -ap.plots.target_image(fig1, ax1[0], target_r) -ax1[0].set_title("r-band image") -ap.plots.target_image(fig1, ax1[1], target_W1) -ax1[1].set_title("W1-band image") -ap.plots.target_image(fig1, ax1[2], target_NUV) -ax1[2].set_title("NUV-band image") -plt.show() - - -# In[ ]: - - -# The joint model will need a target to try and fit, but now that we have multiple images the "target" is -# a Target_Image_List object which points to all three. -target_full = ap.TargetImageList((target_r, target_W1, target_NUV)) -# It doesn't really need any other information since everything is already available in the individual targets - - -# In[ ]: - - -# To make things easy to start, lets just fit a sersic model to all three. In principle one can use arbitrary -# group models designed for each band individually, but that would be unnecessarily complex for a tutorial - -model_r = ap.Model( - name="rband model", - model_type="sersic galaxy model", - target=target_r, - psf_convolve=True, -) - -model_W1 = ap.Model( - name="W1band model", - model_type="sersic galaxy model", - target=target_W1, - center=[0, 0], - PA=-2.3, - psf_convolve=True, -) - -model_NUV = ap.Model( - name="NUVband model", - model_type="sersic galaxy model", - target=target_NUV, - center=[0, 0], - PA=-2.3, - psf_convolve=True, -) - -# At this point we would just be fitting three separate models at the same time, not very interesting. Next -# we add constraints so that some parameters are shared between all the models. It makes sense to fix -# structure parameters while letting brightness parameters vary between bands so that's what we do here. -for p in ["center", "q", "PA", "n", "Re"]: - model_W1[p].value = model_r[p] - model_NUV[p].value = model_r[p] -# Now every model will have a unique Ie, but every other parameter is shared - - -# In[ ]: - - -# We can now make the joint model object - -model_full = ap.Model( - name="LEDA 41136", - model_type="group model", - models=[model_r, model_W1, model_NUV], - target=target_full, -) - -model_full.initialize() -model_full.graphviz() - - -# In[ ]: - - -result = ap.fit.LM(model_full, verbose=1).fit() -print(result.message) - - -# In[ ]: - - -# here we plot the results of the fitting, notice that each band has a different PSF and pixelscale. Also, notice -# that the colour bars represent significantly different ranges since each model was allowed to fit its own Ie. -# meanwhile the center, PA, q, and Re is the same for every model. -fig1, ax1 = plt.subplots(2, 3, figsize=(18, 12)) -ap.plots.model_image(fig1, ax1[0], model_full) -ax1[0][0].set_title("r-band model image") -ax1[0][1].set_title("W1-band model image") -ax1[0][2].set_title("NUV-band model image") -ap.plots.residual_image(fig1, ax1[1], model_full, normalize_residuals=True) -ax1[1][0].set_title("r-band residual image") -ax1[1][1].set_title("W1-band residual image") -ax1[1][2].set_title("NUV-band residual image") -plt.show() - - -# ## Joint models with multiple models -# -# If you want to analyze more than a single astronomical object, you will need to combine many models for each image in a reasonable structure. There are a number of ways to do this that will work, though may not be as scalable. For small images, just about any arrangement is fine when using the LM optimizer. But as images and number of models scales very large, it may be necessary to sub divide the problem to save memory. To do this you should arrange your models in a hierarchy so that AstroPhot has some information about the structure of your problem. There are two ways to do this. First, you can create a group of models where each sub-model is a group which holds all the objects for one image. Second, you can create a group of models where each sub-model is a group which holds all the representations of a single astronomical object across each image. The second method is preferred. See the diagram below to help clarify what this means. -# -# __[JointGroupModels](https://raw.githubusercontent.com/Autostronomy/AstroPhot/main/media/groupjointmodels.png)__ -# -# Here we will see an example of a multiband fit of an image which has multiple astronomical objects. - -# In[ ]: - - -# First we need some data to work with, let's use another LEDA object, this time a group of galaxies: LEDA 389779, 389797, 389681 - -RA = 156.7283 -DEC = 15.5512 -# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel -rsize = 90 - -# Now we make our targets -target_r = ap.image.TargetImage( - filename=f"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={rsize}&layer=ls-dr9&pixscale=0.262&bands=r", - zeropoint=22.5, - variance="auto", - psf=ap.utils.initialize.gaussian_psf(1.12 / 2.355, 51, 0.262), - name="rband", -) - -# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel -wsize = int(rsize * 0.262 / 2.75) -target_W1 = ap.image.TargetImage( - filename=f"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={wsize}&layer=unwise-neo7&pixscale=2.75&bands=1", - zeropoint=25.199, - variance="auto", - psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75), - name="W1band", -) - -# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel -gsize = int(rsize * 0.262 / 1.5) -target_NUV = ap.image.TargetImage( - filename=f"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={gsize}&layer=galex&pixscale=1.5&bands=n", - zeropoint=20.08, - variance="auto", - psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5), - name="NUVband", -) -target_full = ap.image.TargetImageList((target_r, target_W1, target_NUV)) - -fig1, ax1 = plt.subplots(1, 3, figsize=(18, 6)) -ap.plots.target_image(fig1, ax1, target_full) -ax1[0].set_title("r-band image") -ax1[1].set_title("W1-band image") -ax1[2].set_title("NUV-band image") -plt.show() - - -# In[ ]: - - -######################################### -# NOTE: photutils is not a dependency of AstroPhot, make sure you run: pip install photutils -# if you dont already have that package. Also note that you can use any segmentation map -# code, we just use photutils here because it is very easy. -######################################### -from photutils.segmentation import detect_sources, deblend_sources - -rdata = target_r.data.T.detach().cpu().numpy() -initsegmap = detect_sources(rdata, threshold=0.01, npixels=10) -segmap = deblend_sources(rdata, initsegmap, npixels=5).data -fig8, ax8 = plt.subplots(figsize=(8, 8)) -ax8.imshow(segmap, origin="lower", cmap="inferno") -plt.show() -# This will convert the segmentation map into boxes that enclose the identified pixels -rwindows = ap.utils.initialize.windows_from_segmentation_map(segmap) -# Next we scale up the windows so that AstroPhot can fit the faint parts of each object as well -rwindows = ap.utils.initialize.scale_windows( - rwindows, image=target_r, expand_scale=1.5, expand_border=10 -) -w1windows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_W1) -w1windows = ap.utils.initialize.scale_windows(w1windows, image=target_W1, expand_border=1) -nuvwindows = ap.utils.initialize.transfer_windows(rwindows, target_r, target_NUV) -# Here we get some basic starting parameters for the galaxies (center, position angle, axis ratio) -centers = ap.utils.initialize.centroids_from_segmentation_map(segmap, target_r) -PAs = ap.utils.initialize.PA_from_segmentation_map(segmap, target_r, centers) -qs = ap.utils.initialize.q_from_segmentation_map(segmap, target_r, centers) - - -# There is barely any signal in the GALEX data and it would be entirely impossible to analyze on its own. With simultaneous multiband fitting it is a breeze to get relatively robust results! -# -# Next we need to construct models for each galaxy. This is understandably more complex than in the single band case, since now we have three times the amount of data to keep track of. Recall that we will create a number of joint models to represent each astronomical object, then put them all together in a larger group model. - -# In[ ]: - - -model_list = [] - -for i, window in enumerate(rwindows): - # create the submodels for this object - sub_list = [] - sub_list.append( - ap.Model( - name=f"rband model {i}", - model_type="sersic galaxy model", # we could use spline models for the r-band since it is well resolved - target=target_r, - window=rwindows[window], - psf_convolve=True, - center=centers[window], - PA=PAs[window], - q=qs[window], - ) - ) - sub_list.append( - ap.Model( - name=f"W1band model {i}", - model_type="sersic galaxy model", - target=target_W1, - window=w1windows[window], - psf_convolve=True, - ) - ) - sub_list.append( - ap.Model( - name=f"NUVband model {i}", - model_type="sersic galaxy model", - target=target_NUV, - window=nuvwindows[window], - psf_convolve=True, - ) - ) - # ensure equality constraints - # across all bands, same center, q, PA, n, Re - for p in ["center", "q", "PA", "n", "Re"]: - sub_list[1][p].value = sub_list[0][p] - sub_list[2][p].value = sub_list[0][p] - - # Make the multiband model for this object - model_list.append( - ap.Model( - name=f"model {i}", - model_type="group model", - target=target_full, - models=sub_list, - ) - ) -# Make the full model for this system of objects -MODEL = ap.Model( - name=f"full model", - model_type="group model", - target=target_full, - models=model_list, -) -fig, ax = plt.subplots(1, 3, figsize=(16, 5)) -ap.plots.target_image(fig, ax, MODEL.target) -ap.plots.model_window(fig, ax, MODEL) -ax[0].set_title("r-band image") -ax[1].set_title("W1-band image") -ax[2].set_title("NUV-band image") -plt.show() - - -# In[ ]: - - -MODEL.initialize() -MODEL.graphviz() - - -# In[ ]: - - -# We give it only one iteration for runtime/demo purposes, you should let these algorithms run to convergence -result = ap.fit.Iter(MODEL, verbose=1, max_iter=1).fit() - - -# In[ ]: - - -fig1, ax1 = plt.subplots(2, 3, figsize=(18, 11)) -ap.plots.model_image(fig1, ax1[0], MODEL, vmax=30) -ax1[0][0].set_title("r-band model image") -ax1[0][1].set_title("W1-band model image") -ax1[0][2].set_title("NUV-band model image") -ap.plots.residual_image(fig1, ax1[1], MODEL, normalize_residuals=True) -ax1[1][0].set_title("r-band residual image") -ax1[1][1].set_title("W1-band residual image") -ax1[1][2].set_title("NUV-band residual image") -plt.show() - - -# The models look pretty good! The power of multiband fitting lets us know that we have extracted all the available information here, no forced photometry required! Some notes though, since we didn't fit a sky model, the colourbars are quite extreme. -# -# An important note here is that the SB levels for the W1 and NUV data are quire reasonable. While the structure (center, PA, q, n, Re) was shared between bands and therefore mostly driven by the r-band, the brightness is entirely independent between bands meaning the Ie (and therefore SB) values are right from the W1 and NUV data! - -# These residuals mostly look like just noise! The only feature remaining is the row on the bottom of the W1 image. This could likely be fixed by running the fit to convergence and/or taking a larger FOV. - -# ### Dithered images -# -# Note that it is not necessary to use images from different bands. Using dithered images one can effectively achieve higher resolution. It is possible to simultaneously fit dithered images with AstroPhot instead of postprocessing the two images together. This will of course be slower, but may be worthwhile for cases where extra care is needed. -# -# ### Stacked images -# -# Like dithered images, one may wish to combine the statistical power of multiple images but for some reason it is not clear how to add them (for example they are at different rotations). In this case one can simply have AstroPhot fit the images simultaneously. Again this is slower than if the image could be combined, but should extract all the statistical power from the data! -# -# ### Time series -# -# Some objects change over time. For example they may get brighter and dimmer, or may have a transient feature appear. However, the structure of an object may remain constant. An example of this is a supernova and its host galaxy. The host galaxy likely doesn't change across images, but the supernova does. It is possible to fit a time series dataset with a shared galaxy model across multiple images, and a shared position for the supernova, but a variable brightness for the supernova over each image. -# -# It is possible to get quite creative with joint models as they allow one to fix selective features of a model over a wide range of data. If you have a situation which may benefit from joint modelling but are having a hard time determining how to format everything, please do contact us! - -# In[ ]: diff --git a/tests/test_image.py b/tests/test_image.py index 4e66a27a..50e03415 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -183,18 +183,18 @@ def test_image_wcs_roundtrip(): def test_target_image_variance(): new_image = ap.TargetImage( data=np.ones((16, 32)), - variance=np.ones((16, 32)), + variance=2 * np.ones((16, 32)), pixelscale=1.0, zeropoint=1.0, ) - assert new_image.has_variance, "target image should store variance" + assert new_image.variance[0][0] == 2, "target image should store variance" reduced_image = new_image.reduce(2) - assert reduced_image.variance[0][0] == 4, "reduced image should sum sub pixels" + assert reduced_image.variance[0][0] == 8, "reduced image should sum sub pixels" new_image.variance = None - assert not new_image.has_variance, "target image update to no variance" + assert new_image.variance[0][0] == 1, "target image update to neutral variance" def test_target_image_mask(): @@ -204,14 +204,14 @@ def test_target_image_mask(): pixelscale=1.0, zeropoint=1.0, ) - assert new_image.has_mask, "target image should store mask" + assert ap.backend.sum(new_image.mask) > 0, "target image should store mask" reduced_image = new_image.reduce(2) assert reduced_image._mask[0][0] == 1, "reduced image should mask appropriately" assert reduced_image._mask[1][0] == 0, "reduced image should mask appropriately" new_image.mask = None - assert not new_image.has_mask, "target image update to no mask" + assert ap.backend.sum(new_image.mask) == 0, "target image update to no mask" data = np.ones((16, 32)) data[1, 1] = np.nan @@ -222,7 +222,7 @@ def test_target_image_mask(): pixelscale=1.0, zeropoint=1.0, ) - assert new_image.has_mask, "target image with nans should create mask" + assert ap.backend.sum(new_image.mask) > 0, "target image with nans should create mask" assert new_image._mask[1][1].item() == True, "nan should be masked" assert new_image._mask[5][5].item() == True, "nan should be masked" @@ -241,7 +241,7 @@ def test_target_image_psf(): assert reduced_image.psf._data[0][0] == 9, "reduced image should sum sub pixels in psf" new_image.psf = None - assert not new_image.has_psf, "target image update to no variance" + assert not new_image.has_psf, "target image update to no psf" def test_target_image_reduce(): diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index b1099de4..aaa7d40a 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -49,7 +49,9 @@ def test_notebook(nb_path): if ap.backend.backend == "jax": pytest.skip("Requires torch backend") convert_notebook_to_py(nb_path) - runpy.run_path(nb_path.replace(".ipynb", ".py"), run_name="__main__") - ck.backend.backend = "torch" - ap.backend.backend = "torch" - cleanup_py_scripts(nb_path) + try: + runpy.run_path(nb_path.replace(".ipynb", ".py"), run_name="__main__") + finally: + ck.backend.backend = "torch" + ap.backend.backend = "torch" + cleanup_py_scripts(nb_path) From 480f1a5904cb58f403fe387318dc321a0edf0de1 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 14 Nov 2025 13:15:17 -0500 Subject: [PATCH 157/191] typo --- astrophot/models/mixins/transform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 8ddffa14..5772578d 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -19,7 +19,7 @@ class InclinedMixin: $$x', y' = {\\rm rotate}(-PA + \\pi/2, x, y)$$ $$y'' = y' / q$$ - where x' and y'' are the final transformed coordinates. The $\pi/2$ is included + where x' and y'' are the final transformed coordinates. The $\\pi/2$ is included such that the position angle is defined with 0 at north. The -PA is such that the position angle increases to the East. Thus, the position angle is a standard East of North definition assuming the WCS of the image is correct. From caea6f6f0a4f422b2094341367c97fe0227713f8 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 14 Nov 2025 13:58:54 -0500 Subject: [PATCH 158/191] get sip tests working --- tests/test_sip_image.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py index cafbb394..f79570be 100644 --- a/tests/test_sip_image.py +++ b/tests/test_sip_image.py @@ -34,7 +34,7 @@ def test_sip_image_creation(sip_target): sliced_image = sip_target[slicer] assert sliced_image.crpix[0] == -7, "crpix of subimage should give relative position" assert sliced_image.crpix[1] == -4, "crpix of subimage should give relative position" - assert sliced_image.shape == (6, 3), "sliced image should have correct shape" + assert sliced_image._data.shape == (6, 3), "sliced image should have correct shape" assert sliced_image.pixel_area_map.shape == ( 6, 3, @@ -51,7 +51,7 @@ def test_sip_image_creation(sip_target): ), "sliced image should have correct distortion shape" sip_model_image = sip_target.model_image(upsample=2, pad=1) - assert sip_model_image.shape == (32, 22), "model image should have correct shape" + assert sip_model_image._data.shape == (32, 22), "model image should have correct shape" assert sip_model_image.pixel_area_map.shape == ( 32, 22, @@ -71,17 +71,17 @@ def test_sip_image_creation(sip_target): sip_model_reduce = sip_model_image.reduce(scale=1) assert sip_model_reduce is sip_model_image, "reduce should return the same image if scale is 1" sip_model_reduce = sip_model_image.reduce(scale=2) - assert sip_model_reduce.shape == (16, 11), "reduced model image should have correct shape" + assert sip_model_reduce._data.shape == (16, 11), "reduced model image should have correct shape" # crop sip_model_crop = sip_model_image.crop(1) - assert sip_model_crop.shape == (30, 20), "cropped model image should have correct shape" + assert sip_model_crop._data.shape == (30, 20), "cropped model image should have correct shape" sip_model_crop = sip_model_image.crop([1]) - assert sip_model_crop.shape == (30, 20), "cropped model image should have correct shape" + assert sip_model_crop._data.shape == (30, 20), "cropped model image should have correct shape" sip_model_crop = sip_model_image.crop([1, 2]) - assert sip_model_crop.shape == (30, 18), "cropped model image should have correct shape" + assert sip_model_crop._data.shape == (30, 18), "cropped model image should have correct shape" sip_model_crop = sip_model_image.crop([1, 2, 3, 4]) - assert sip_model_crop.shape == (29, 15), "cropped model image should have correct shape" + assert sip_model_crop._data.shape == (29, 15), "cropped model image should have correct shape" sip_model_crop.fluxdensity_to_flux() assert ap.backend.all( From 9569aac66c311455dd9c9c15a3c9f4aec80abcff Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 14 Nov 2025 14:09:04 -0500 Subject: [PATCH 159/191] add corner dependency for dev version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c86d8db5..7bf255b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "jax"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax"] [project.scripts] astrophot = "astrophot:run_from_terminal" From 2333d4d531cad3e45f767e4e7c2d2a83277db2ee Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Nov 2025 09:58:12 -0500 Subject: [PATCH 160/191] More tests, better coverage for fitters --- docs/source/tutorials/GroupModels.ipynb | 2 +- docs/source/tutorials/JointModels.ipynb | 2 +- docs/source/tutorials/PoissonLikelihood.ipynb | 15 ++++++++++----- tests/test_fit.py | 10 ++++++++-- tests/test_param.py | 8 ++++++++ 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index 8698e213..b4a719eb 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -232,7 +232,7 @@ "metadata": {}, "outputs": [], "source": [ - "plt.imshow(groupmodel.segmentation_map().T, origin=\"lower\", cmap=\"inferno\")\n", + "plt.imshow(groupmodel.segmentation_map(), origin=\"lower\", cmap=\"inferno\")\n", "plt.show()" ] }, diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 5b95dd54..8b1eee03 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -261,7 +261,7 @@ "#########################################\n", "from photutils.segmentation import detect_sources, deblend_sources\n", "\n", - "rdata = target_r.data.T.detach().cpu().numpy()\n", + "rdata = target_r.data.detach().cpu().numpy()\n", "initsegmap = detect_sources(rdata, threshold=0.01, npixels=10)\n", "segmap = deblend_sources(rdata, initsegmap, npixels=5).data\n", "fig8, ax8 = plt.subplots(figsize=(8, 8))\n", diff --git a/docs/source/tutorials/PoissonLikelihood.ipynb b/docs/source/tutorials/PoissonLikelihood.ipynb index dabe7636..a0dec516 100644 --- a/docs/source/tutorials/PoissonLikelihood.ipynb +++ b/docs/source/tutorials/PoissonLikelihood.ipynb @@ -18,7 +18,6 @@ "outputs": [], "source": [ "import astrophot as ap\n", - "import torch\n", "import numpy as np\n", "import matplotlib.pyplot as plt" ] @@ -47,15 +46,16 @@ " model_type=\"sersic galaxy model\",\n", " center=(64, 64),\n", " q=0.7,\n", - " PA=0,\n", + " PA=0.5,\n", " n=1,\n", " Re=32,\n", " Ie=1,\n", " target=target,\n", ")\n", - "img = true_model().data.T.detach().cpu().numpy()\n", + "img = true_model().data.detach().cpu().numpy()\n", "np.random.seed(42) # for reproducibility\n", "target.data = np.random.poisson(img) # sample poisson distribution\n", + "true_params = true_model.build_params_array()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(10, 5))\n", "ap.plots.model_image(fig, ax[0], true_model)\n", @@ -116,7 +116,7 @@ "id": "8", "metadata": {}, "source": [ - "Printing the model and its parameters, we see that we have indeed recovered very close to the true values for all parameters!" + "Plotting the model parameters and uncertainty, we see that we have indeed recovered very close to the true values for all parameters! Note that the true values are, however, not where we expect with respect to the 1-2 sigma uncertainty contours. There are two reasons for this, one is that this is a Poisson likelihood and so a Gaussian approximation is only so good, the other is that the model is non-linear so again the Gaussian approximation at the maximum likelihood will not exactly describe the PDF (which actually affects model uncertainties even for a Gaussian likelihood)." ] }, { @@ -126,7 +126,12 @@ "metadata": {}, "outputs": [], "source": [ - "print(model)" + "fig, ax = ap.plots.covariance_matrix(\n", + " res.covariance_matrix.detach().cpu().numpy(),\n", + " model.build_params_array().detach().cpu().numpy(),\n", + " reference_values=true_params.detach().cpu().numpy(),\n", + ")\n", + "plt.show()" ] }, { diff --git a/tests/test_fit.py b/tests/test_fit.py index 79c72095..2c4deba4 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -71,8 +71,13 @@ def sersic_model(): "fitter,extra", [ (ap.fit.LM, {}), + (ap.fit.LM, {"likelihood": "poisson"}), (ap.fit.LMfast, {}), (ap.fit.IterParam, {"chunks": 3, "chunk_order": "sequential", "verbose": 2}), + ( + ap.fit.IterParam, + {"chunks": 3, "chunk_order": "random", "verbose": 2, "likelihood": "poisson"}, + ), (ap.fit.Grad, {}), (ap.fit.ScipyFit, {}), (ap.fit.MHMCMC, {}), @@ -81,14 +86,15 @@ def sersic_model(): (ap.fit.Slalom, {}), ], ) -def test_fitters(fitter, extra, sersic_model): +@pytest.mark.parametrize("fit_valid", [True, False]) +def test_fitters(fitter, extra, sersic_model, fit_valid): if ap.backend.backend == "jax" and fitter in [ap.fit.Grad, ap.fit.HMC]: pytest.skip("Grad and HMC not implemented for JAX backend") model = sersic_model model.initialize() ll_init = model.gaussian_log_likelihood() pll_init = model.poisson_log_likelihood() - result = fitter(model, max_iter=100, **extra).fit() + result = fitter(model, max_iter=100, fit_valid=fit_valid, **extra).fit() ll_final = model.gaussian_log_likelihood() pll_final = model.poisson_log_likelihood() assert ll_final > ll_init, f"{fitter.__name__} should improve the log likelihood" diff --git a/tests/test_param.py b/tests/test_param.py index 0bcfa10b..b3accc68 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -1,3 +1,5 @@ +import pytest + import astrophot as ap from astrophot.param import Param @@ -53,3 +55,9 @@ def test_module(): paramsun = model.build_params_array_units() assert all(isinstance(unit, str) for unit in paramsun), "All parameter units should be strings" + + index = model.dynamic_params_array_index(model2.q) + assert index == 7, "Parameter index should be correct" + + with pytest.raises(ValueError): + model.dynamic_params_array_index(5.0) # Not a Param instance From aa5821bf2e2d8a434d11ac4bd8e935213ae0f2a3 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Nov 2025 10:08:12 -0500 Subject: [PATCH 161/191] fix test bug --- astrophot/param/module.py | 7 ++++++- tests/test_param.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/astrophot/param/module.py b/astrophot/param/module.py index a6e0a9d2..76457225 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -80,4 +80,9 @@ def dynamic_params_array_index(self, param): if p is param: return list(range(i, i + max(1, prod(p.shape)))) i += max(1, prod(p.shape)) - raise ValueError(f"Param {param.name} not found in dynamic_params of Module {self.name}") + try: + raise ValueError( + f"Param {param.name} not found in dynamic_params of Module {self.name}" + ) + except: + raise ValueError(f"Param {param} not found in dynamic_params of Module {self.name}") diff --git a/tests/test_param.py b/tests/test_param.py index b3accc68..96637be6 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -57,7 +57,7 @@ def test_module(): assert all(isinstance(unit, str) for unit in paramsun), "All parameter units should be strings" index = model.dynamic_params_array_index(model2.q) - assert index == 7, "Parameter index should be correct" + assert index == [9], "Parameter index should be correct" with pytest.raises(ValueError): model.dynamic_params_array_index(5.0) # Not a Param instance From d9c7788342c473ed1d89f0b9b35b90af0ba566ed Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Nov 2025 11:09:24 -0500 Subject: [PATCH 162/191] more unit tests for better coverage of iterparam --- astrophot/fit/iterative.py | 14 ++++++-------- tests/test_fit.py | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 9cee2d04..c3821884 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -188,6 +188,7 @@ def __init__( L0=1.0, max_step_iter: int = 10, ndf=None, + W=None, likelihood="gaussian", **kwargs, ): @@ -218,12 +219,9 @@ def __init__( fit_mask = backend.concatenate(tuple(FM.flatten() for FM in fit_mask)) else: fit_mask = fit_mask.flatten() - if backend.sum(fit_mask).item() == 0: - fit_mask = None mask = self.model.target[self.fit_window].flatten("mask") - if fit_mask is not None: - mask = mask | fit_mask + mask = mask | fit_mask self.mask = ~mask if backend.sum(self.mask).item() == 0: @@ -233,12 +231,12 @@ def __init__( self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] # 1 / (sigma^2) - kW = kwargs.get("W", None) - if kW is not None: - self.W = backend.as_array(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ + if W is not None: + self.W = backend.as_array(W, dtype=config.DTYPE, device=config.DEVICE).flatten()[ self.mask ] - self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] + else: + self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] # The forward model which computes the output image given input parameters self.full_forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[ diff --git a/tests/test_fit.py b/tests/test_fit.py index 2c4deba4..777bebfd 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -191,6 +191,33 @@ def test_gradient(sersic_model): ), "Gradient should match functional gradient" +def test_options(sersic_model): + model = sersic_model + model.initialize() + + with pytest.raises(ValueError): + ap.fit.LM(model, likelihood="unknown") + with pytest.raises(ValueError): + ap.fit.IterParam(model, likelihood="unknown") + with pytest.raises(ap.errors.OptimizeStopSuccess): + model.target.mask = ap.backend.ones_like(model.target.mask, dtype=bool) + ap.fit.IterParam(model) + model.target.mask = ap.backend.zeros_like(model.target.mask, dtype=bool) + + fitter = ap.fit.IterParam( + model=model, + W=model.target.weight, + ndf=np.prod(model.target.data.shape), + chunk_order="invalid", + ) + with pytest.raises(ValueError): + fitter.fit() + + model.to_static(False) + res = ap.fit.IterParam(model).fit() + assert "No parameters to optimize" in res.message, "Should exit if no dynamic parameters" + + # class TestHMC(unittest.TestCase): # def test_hmc_sample(self): # np.random.seed(12345) From 9a7bd594fa2d030ff403802f28f51e51e29421e6 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Nov 2025 12:14:13 -0500 Subject: [PATCH 163/191] Nicer MALA example in fitting tutorial --- astrophot/fit/func/mala.py | 4 +- astrophot/fit/mala.py | 2 +- docs/source/tutorials/FittingMethods.ipynb | 101 ++------------------- 3 files changed, 14 insertions(+), 93 deletions(-) diff --git a/astrophot/fit/func/mala.py b/astrophot/fit/func/mala.py index 2f9c4532..13ca3a48 100644 --- a/astrophot/fit/func/mala.py +++ b/astrophot/fit/func/mala.py @@ -21,6 +21,7 @@ def mala( L = np.linalg.cholesky(mass) # (D, D) samples = np.zeros((num_samples, C, D), dtype=x.dtype) # (N, C, D) + acceptance_rate = np.zeros([0]) # (0,) # Cache current state logp_cur = log_prob(x) # (C,) @@ -55,6 +56,7 @@ def mala( log_alpha = (logp_prop - logp_cur) + (logq1 - logq2) # (C,) accept = np.log(rng.random(C)) < log_alpha # (C,) + acceptance_rate = np.concatenate([acceptance_rate, accept]) # Update all three pieces in-place where accepted x[accept] = x_prop[accept] # (C, D) @@ -64,6 +66,6 @@ def mala( samples[t] = x if progress: - it.set_postfix(acc_rate=f"{accept.mean():0.2f}") + it.set_postfix(acc_rate=f"{acceptance_rate.mean():0.2f}") return samples diff --git a/astrophot/fit/mala.py b/astrophot/fit/mala.py index b83723bc..997e7a1e 100644 --- a/astrophot/fit/mala.py +++ b/astrophot/fit/mala.py @@ -96,4 +96,4 @@ def fit(self): desc="MALA", ) - return self.chain + return self diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index 1aa8627e..d9ef3259 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -549,65 +549,7 @@ "source": [ "## Metropolis Adjusted Langevin Algorithm (MALA)\n", "\n", - "This is one of the simplest gradient based samplers, and is very powerful. The standard Metropolis Hastings algorithm will use a gaussian proposal distribution then use the Metropolis Hastings accept/reject stage. MALA uses gradient information to determine a better proposal distribution locally (while maintaining detailed balance) and then uses the Metropolis Hastings accept/reject stage. We have not integrated this algorithm directly into AstroPhot, instead we write it all out below to show the simplicity and power of the method. Expand the cell below if you are interested!" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [ - "hide-cell" - ] - }, - "outputs": [], - "source": [ - "def mala_sampler(initial_state, log_prob, log_prob_grad, num_samples, epsilon, mass_matrix):\n", - " \"\"\"Metropolis Adjusted Langevin Algorithm (MALA) sampler with batch dimension.\n", - "\n", - " Args:\n", - " - initial_state (numpy array): Initial states of the chains, shape (num_chains, dim).\n", - " - log_prob (function): Function to compute the log probabilities of the current states.\n", - " - log_prob_grad (function): Function to compute the gradients of the log probabilities.\n", - " - num_samples (int): Number of samples to generate.\n", - " - epsilon (float): Step size for the Langevin dynamics.\n", - " - mass_matrix (numpy array): Mass matrix, shape (dim, dim), used to scale the dynamics.\n", - "\n", - "\n", - " Returns:\n", - " - samples (numpy array): Array of sampled values, shape (num_samples, num_chains, dim).\n", - " \"\"\"\n", - " num_chains, dim = initial_state.shape\n", - " samples = np.zeros((num_samples, num_chains, dim))\n", - " x_current = np.array(initial_state)\n", - " current_log_prob = log_prob(x_current)\n", - " inv_mass_matrix = np.linalg.inv(mass_matrix)\n", - " chol_inv_mass_matrix = np.linalg.cholesky(inv_mass_matrix)\n", - "\n", - " pbar = tqdm(range(num_samples))\n", - " acceptance_rate = np.zeros([0])\n", - " for i in pbar:\n", - " gradients = log_prob_grad(x_current)\n", - " noise = np.dot(np.random.randn(num_chains, dim), chol_inv_mass_matrix.T)\n", - " proposal = (\n", - " x_current + 0.5 * epsilon**2 * np.dot(gradients, inv_mass_matrix) + epsilon * noise\n", - " )\n", - "\n", - " # proposal = x_current + 0.5 * epsilon**2 * gradients + epsilon * np.random.randn(num_chains, *dim)\n", - " proposal_log_prob = log_prob(proposal)\n", - " # Metropolis-Hastings acceptance criterion, computed for each chain\n", - " acceptance_log_prob = proposal_log_prob - current_log_prob\n", - " accept = np.log(np.random.rand(num_chains)) < acceptance_log_prob\n", - " acceptance_rate = np.concatenate([acceptance_rate, accept])\n", - " pbar.set_description(f\"Acceptance rate: {acceptance_rate.mean():.2f}\")\n", - "\n", - " # Update states where accepted\n", - " x_current[accept] = proposal[accept]\n", - " current_log_prob[accept] = proposal_log_prob[accept]\n", - "\n", - " samples[i] = x_current\n", - "\n", - " return samples" + "This is one of the simplest gradient based samplers, and is very powerful. The standard Metropolis Hastings algorithm will use a gaussian proposal distribution then use the Metropolis Hastings accept/reject stage. MALA uses gradient information to determine a better proposal distribution locally (while maintaining detailed balance) and then uses the Metropolis Hastings accept/reject stage. The `ap.fit.MALA` fitter object is just a basic wrapper over the `ap.fit.func.mala` function, so feel free to check it out if you want more details on this simple and powerful sampler!" ] }, { @@ -625,31 +567,14 @@ "# Use LM to start the sampler at a high likelihood location, no burn-in needed!\n", "res1 = ap.fit.LM(MODEL, verbose=0).fit()\n", "\n", - "\n", - "def density(x):\n", - " x = torch.as_tensor(x, dtype=ap.config.DTYPE)\n", - " return torch.vmap(MODEL.gaussian_log_likelihood)(x).detach().cpu().numpy()\n", - "\n", - "\n", - "sim_grad = torch.vmap(torch.func.grad(MODEL.gaussian_log_likelihood))\n", - "\n", - "\n", - "def density_grad(x):\n", - " x = torch.as_tensor(x, dtype=ap.config.DTYPE)\n", - " return sim_grad(x).numpy()\n", - "\n", - "\n", - "x0 = MODEL.build_params_array().detach().cpu().numpy()\n", - "x0 = x0 + np.random.normal(scale=0.001, size=(8, x0.shape[0]))\n", - "chain_mala = mala_sampler(\n", - " initial_state=x0,\n", - " log_prob=density,\n", - " log_prob_grad=density_grad,\n", - " num_samples=300,\n", - " epsilon=2e-1,\n", - " mass_matrix=torch.linalg.inv(res1.covariance_matrix).detach().cpu().numpy(),\n", - ")\n", - "chain_mala = chain_mala.reshape(-1, chain_mala.shape[-1])" + "res_mala = ap.fit.MALA(\n", + " model=MODEL,\n", + " chains=4,\n", + " max_iter=300,\n", + " epsilon=8e-1,\n", + " mass_matrix=res1.covariance_matrix.detach().cpu().numpy(),\n", + ").fit()\n", + "chain_mala = res_mala.chain.reshape(-1, res_mala.chain.shape[-1])" ] }, { @@ -666,13 +591,7 @@ "param_names = list(MODEL.build_params_array_names())\n", "\n", "set, sky = true_params()\n", - "fig = corner(chain_mala, labels=param_names, truths=np.concatenate((sky, set.ravel())))\n", - "# corner_plot(\n", - "# chain_mala,\n", - "# labels=param_names,\n", - "# figsize=(20, 20),\n", - "# true_values=np.concatenate((sky, set.ravel())),\n", - "# )" + "fig = corner(chain_mala, labels=param_names, truths=np.concatenate((sky, set.ravel())))" ] }, { From 1d77f4c385c42946a22cf1ba1af3367b439d8945 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Nov 2025 15:16:57 -0500 Subject: [PATCH 164/191] mala set params based on best params found --- astrophot/fit/func/mala.py | 6 +++-- astrophot/fit/mala.py | 26 +++++++++++++++++++++- docs/source/tutorials/FittingMethods.ipynb | 1 + tests/test_fit.py | 9 ++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/astrophot/fit/func/mala.py b/astrophot/fit/func/mala.py index 13ca3a48..c454786c 100644 --- a/astrophot/fit/func/mala.py +++ b/astrophot/fit/func/mala.py @@ -22,6 +22,7 @@ def mala( samples = np.zeros((num_samples, C, D), dtype=x.dtype) # (N, C, D) acceptance_rate = np.zeros([0]) # (0,) + logp = np.zeros((num_samples, C), dtype=x.dtype) # (N, C) # Cache current state logp_cur = log_prob(x) # (C,) @@ -63,9 +64,10 @@ def mala( logp_cur[accept] = logp_prop[accept] # (C,) grad_cur[accept] = grad_prop[accept] # (C, D) - samples[t] = x + samples[t] = x.copy() + logp[t] = logp_cur.copy() if progress: it.set_postfix(acc_rate=f"{acceptance_rate.mean():0.2f}") - return samples + return samples, logp diff --git a/astrophot/fit/mala.py b/astrophot/fit/mala.py index 997e7a1e..069cb701 100644 --- a/astrophot/fit/mala.py +++ b/astrophot/fit/mala.py @@ -13,6 +13,24 @@ class MALA(BaseOptimizer): + """Metropolis-Adjusted Langevin Algorithm (MALA) sampler, based on: + https://en.wikipedia.org/wiki/Metropolis-adjusted_Langevin_algorithm . This + is a gradient-based MCMC sampler that uses the gradient of the + log-likelihood to propose new samples. These gradient based proposals can + lead to more efficient sampling of the parameter space. This is especially + true when the mass_matrix is set well. A good guess for the mass matrix is + the covariance matrix of the likelihood at the maximum likelihood point. + Which can be found fairly easily with the LM optimizer (see the fitting + methods tutorial). + + **Args:** + - `chains`: The number of MCMC chains to run in parallel. Default is 4. + - `epsilon`: The step size for the MALA sampler. Default is 1e-2. + - `mass_matrix`: The mass matrix for the MALA sampler. If None, the identity matrix is used. + - `progress_bar`: Whether to show a progress bar during sampling. Default is True. + - `likelihood`: The likelihood function to use for the MCMC sampling. Can be "gaussian" or "poisson". Default is "gaussian". + """ + def __init__( self, model: Model, @@ -85,7 +103,7 @@ def fit(self): D = initial_state.shape[1] self.mass_matrix = np.eye(D, dtype=initial_state.dtype) - self.chain = func.mala( + self.chain, self.logp = func.mala( initial_state, Px, dPdx, @@ -95,5 +113,11 @@ def fit(self): progress=self.progress_bar, desc="MALA", ) + # Fill model with max logp sample + max_logp_index = np.argmax(self.logp) + max_logp_index = np.unravel_index(max_logp_index, self.logp.shape) + self.model.fill_dynamic_values( + backend.as_array(self.chain[max_logp_index], dtype=config.DTYPE, device=config.DEVICE) + ) return self diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index d9ef3259..ec004048 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -572,6 +572,7 @@ " chains=4,\n", " max_iter=300,\n", " epsilon=8e-1,\n", + " likelihood=\"poisson\",\n", " mass_matrix=res1.covariance_matrix.detach().cpu().numpy(),\n", ").fit()\n", "chain_mala = res_mala.chain.reshape(-1, res_mala.chain.shape[-1])" diff --git a/tests/test_fit.py b/tests/test_fit.py index 777bebfd..f5a1fbf9 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -82,6 +82,15 @@ def sersic_model(): (ap.fit.ScipyFit, {}), (ap.fit.MHMCMC, {}), (ap.fit.HMC, {}), + (ap.fit.MALA, {"epsilon": 1e-3}), + ( + ap.fit.MALA, + { + "epsilon": 1e-3, + "likelihood": "poisson", + "initial_state": [[20, 20, 0.7, np.pi, 2, 15, 10]], + }, + ), (ap.fit.MiniFit, {}), (ap.fit.Slalom, {}), ], From ef688211282e1ed6367acbc6a6c5a92a206b88f0 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Nov 2025 15:25:24 -0500 Subject: [PATCH 165/191] smaller random number initialization for rng in mala because windows --- astrophot/fit/func/mala.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrophot/fit/func/mala.py b/astrophot/fit/func/mala.py index c454786c..e6ae0b30 100644 --- a/astrophot/fit/func/mala.py +++ b/astrophot/fit/func/mala.py @@ -29,7 +29,7 @@ def mala( grad_cur = log_prob_grad(x) # (C, D) # Random number generator - rng = np.random.default_rng(np.random.randint(1e10)) + rng = np.random.default_rng(np.random.randint(1e9)) it = range(num_samples) if progress: From bc80061a6f73fde70a8a26000d890dae5aff0622 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Nov 2025 15:42:00 -0500 Subject: [PATCH 166/191] fix mala interface in functional example --- docs/source/tutorials/FunctionalInterface.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/FunctionalInterface.ipynb b/docs/source/tutorials/FunctionalInterface.ipynb index d41490a3..7cc69e31 100644 --- a/docs/source/tutorials/FunctionalInterface.ipynb +++ b/docs/source/tutorials/FunctionalInterface.ipynb @@ -390,7 +390,7 @@ ")\n", "\n", "# Run MALA sampling\n", - "chain = ap.fit.func.mala(\n", + "chain, logp = ap.fit.func.mala(\n", " params,\n", " lambda p: np.array(vmodel(jnp.array(p), *extra)),\n", " lambda p: np.array(vgmodel(jnp.array(p), *extra)),\n", From b96691335e56ad551d125ba54f93ac8584563442 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 17 Nov 2025 15:42:50 -0500 Subject: [PATCH 167/191] add func path in fit all --- astrophot/fit/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index ddab50f1..987035bc 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -6,6 +6,7 @@ from .hmc import HMC from .mala import MALA from .mhmcmc import MHMCMC +from . import func __all__ = [ "LM", From 3b64d5c623e1220bb80ecb3537ef95d4de7342dc Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 18 Nov 2025 13:33:59 -0500 Subject: [PATCH 168/191] set jax requirement --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index d2b0ecc0..6f262c2c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,7 +3,7 @@ corner emcee graphviz ipywidgets -jax +jax<0.8.0 jupyter-book<2.0 matplotlib nbformat From ba6747f4ff019f9f2cc23f9dd7e32e2217bb5d51 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 18 Nov 2025 14:00:01 -0500 Subject: [PATCH 169/191] fix gaussian ellipsoid --- astrophot/models/func/gaussian_ellipsoid.py | 2 +- astrophot/models/gaussian_ellipsoid.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/astrophot/models/func/gaussian_ellipsoid.py b/astrophot/models/func/gaussian_ellipsoid.py index 2a989f61..4b07e9cf 100644 --- a/astrophot/models/func/gaussian_ellipsoid.py +++ b/astrophot/models/func/gaussian_ellipsoid.py @@ -17,7 +17,7 @@ def euler_rotation_matrix(alpha: ArrayLike, beta: ArrayLike, gamma: ArrayLike) - ( backend.stack((ca * cg - cb * sa * sg, -ca * sg - cb * cg * sa, sb * sa)), backend.stack((cg * sa + ca * cb * sg, ca * cb * cg - sa * sg, -ca * sb)), - backend.stack((sb * cg, sb * cg, cb)), + backend.stack((sb * sg, sb * cg, cb)), ), dim=-1, ) diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index 02cd54bd..2d14da51 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -130,6 +130,6 @@ def brightness( v = backend.stack(self.transform_coordinates(x, y), dim=0).reshape(2, -1) return ( flux - * backend.sum(backend.exp(-0.5 * (v * (inv_Sigma @ v))), dim=0) + * backend.exp(-0.5 * backend.sum(v * (inv_Sigma @ v), dim=0)) / (2 * np.pi * backend.sqrt(backend.linalg.det(Sigma2D))) ).reshape(x.shape) From b536fcbc656fac4bb4f0dedb19cff610fa389518 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 18 Nov 2025 14:02:43 -0500 Subject: [PATCH 170/191] set jax limit in toml file too --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7bf255b0..ca6367a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax<0.8.0"] [project.scripts] astrophot = "astrophot:run_from_terminal" From 4585c4969297c1495c825fa77e128fadf8cb6920 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 18 Nov 2025 14:46:05 -0500 Subject: [PATCH 171/191] hard fix jax version see if that works --- docs/requirements.txt | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6f262c2c..aee8317d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,7 +3,7 @@ corner emcee graphviz ipywidgets -jax<0.8.0 +jax=0.7.0 jupyter-book<2.0 matplotlib nbformat diff --git a/pyproject.toml b/pyproject.toml index ca6367a7..a7ed09a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax<0.8.0"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax=0.7.0"] [project.scripts] astrophot = "astrophot:run_from_terminal" From 7b2665dbe4d59b6ea7ae70b5a5c1aef76b047870 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 18 Nov 2025 14:49:30 -0500 Subject: [PATCH 172/191] my bad --- docs/requirements.txt | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index aee8317d..7b147e6f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,7 +3,7 @@ corner emcee graphviz ipywidgets -jax=0.7.0 +jax==0.7.0 jupyter-book<2.0 matplotlib nbformat diff --git a/pyproject.toml b/pyproject.toml index a7ed09a2..8d4a1896 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax=0.7.0"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax==0.7.0"] [project.scripts] astrophot = "astrophot:run_from_terminal" From fad3febc5b85fc39a567b3fadf086c1d1b992190 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 18 Nov 2025 21:01:10 -0500 Subject: [PATCH 173/191] set max jax version 0.7.0 as 0.7.2 has breaking change --- docs/requirements.txt | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 7b147e6f..6a5e0c1b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,7 +3,7 @@ corner emcee graphviz ipywidgets -jax==0.7.0 +jax<=0.7.0 jupyter-book<2.0 matplotlib nbformat diff --git a/pyproject.toml b/pyproject.toml index 8d4a1896..1ccadbe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax==0.7.0"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax<=0.7.0"] [project.scripts] astrophot = "astrophot:run_from_terminal" From 00a3f85b6732b64001fd68a51b94fcdcf784235c Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 18 Nov 2025 21:20:19 -0500 Subject: [PATCH 174/191] fix segmap auto init --- astrophot/utils/initialize/segmentation_map.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/astrophot/utils/initialize/segmentation_map.py b/astrophot/utils/initialize/segmentation_map.py index 6364ba40..053d257b 100644 --- a/astrophot/utils/initialize/segmentation_map.py +++ b/astrophot/utils/initialize/segmentation_map.py @@ -56,7 +56,7 @@ def centroids_from_segmentation_map( if sky_level is None: sky_level = np.nanmedian(backend.to_numpy(image.data)) - data = backend.to_numpy(image.data) - sky_level + data = backend.to_numpy(image._data) - sky_level centroids = {} II, JJ = np.meshgrid(np.arange(seg_map.shape[0]), np.arange(seg_map.shape[1]), indexing="ij") @@ -94,7 +94,7 @@ def PA_from_segmentation_map( if sky_level is None: sky_level = np.nanmedian(backend.to_numpy(image.data)) - data = backend.to_numpy(image.data) - sky_level + data = backend.to_numpy(image._data) - sky_level if centroids is None: centroids = centroids_from_segmentation_map( @@ -141,7 +141,7 @@ def q_from_segmentation_map( if sky_level is None: sky_level = np.nanmedian(backend.to_numpy(image.data)) - data = backend.to_numpy(image.data) - sky_level + data = backend.to_numpy(image._data) - sky_level if centroids is None: centroids = centroids_from_segmentation_map( @@ -232,8 +232,8 @@ def scale_windows(windows, image: "Image" = None, expand_scale=1.0, expand_borde new_window = [ [max(0, new_window[0][0]), max(0, new_window[0][1])], [ - min(image.data.shape[0], new_window[1][0]), - min(image.data.shape[1], new_window[1][1]), + min(image._data.shape[0], new_window[1][0]), + min(image._data.shape[1], new_window[1][1]), ], ] new_windows[index] = new_window @@ -296,7 +296,7 @@ def filter_windows( if min_flux is not None: if ( np.sum( - backend.to_numpy(image.data)[ + backend.to_numpy(image._data)[ windows[w][0][0] : windows[w][1][0], windows[w][0][1] : windows[w][1][1], ] @@ -307,7 +307,7 @@ def filter_windows( if max_flux is not None: if ( np.sum( - backend.to_numpy(image.data)[ + backend.to_numpy(image._data)[ windows[w][0][0] : windows[w][1][0], windows[w][0][1] : windows[w][1][1], ] From 507564bf540ce60a14c7c579750a6ae13a173c28 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 19 Nov 2025 13:54:27 -0500 Subject: [PATCH 175/191] add function to collect legacy survey cutouts --- astrophot/image/image_object.py | 7 +- astrophot/utils/__init__.py | 2 + astrophot/utils/fitsopen.py | 108 ++++++++++++++++++++++++ docs/source/tutorials/GroupModels.ipynb | 7 +- 4 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 astrophot/utils/fitsopen.py diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index b8b05e26..6926877a 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -448,13 +448,16 @@ def save(self, filename: str): hdulist = fits.HDUList(self.fits_images()) hdulist.writeto(filename, overwrite=True) - def load(self, filename: str, hduext: int = 0): + def load(self, filename: Union[str, fits.HDUList], hduext: int = 0): """Load an image from a FITS file. This will load the primary HDU and set the data, CD, crpix, crval, and crtan attributes accordingly. If the WCS is not tangent plane, it will warn the user. """ - hdulist = fits.open(filename) + if isinstance(filename, str): + hdulist = fits.open(filename) + else: + hdulist = filename self.data = np.array(hdulist[hduext].data, dtype=np.float64) self.CD = ( diff --git a/astrophot/utils/__init__.py b/astrophot/utils/__init__.py index b66971a3..33925367 100644 --- a/astrophot/utils/__init__.py +++ b/astrophot/utils/__init__.py @@ -6,6 +6,7 @@ interpolate, parametric_profiles, ) +from .fitsopen import ls_open __all__ = [ "decorators", @@ -14,4 +15,5 @@ "parametric_profiles", "initialize", "conversions", + "ls_open", ] diff --git a/astrophot/utils/fitsopen.py b/astrophot/utils/fitsopen.py new file mode 100644 index 00000000..0d1b8a80 --- /dev/null +++ b/astrophot/utils/fitsopen.py @@ -0,0 +1,108 @@ +import numpy as np +import warnings +from astropy.utils.data import download_file +from astropy.io import fits +from astropy.utils.exceptions import AstropyWarning +from numpy.core.defchararray import startswith +from pyvo.dal import sia +import os + +# Suppress common Astropy warnings that can clutter CI logs +warnings.simplefilter("ignore", category=AstropyWarning) + + +def flip_hdu(hdu): + """ + Flips the image data in the FITS HDU on the RA axis to match the expected orientation. + + Args: + hdu (astropy.io.fits.HDUList): The FITS HDU to be flipped. + + Returns: + astropy.io.fits.HDUList: The flipped FITS HDU. + """ + assert "CD1_1" in hdu[0].header, "HDU does not contain WCS information." + assert "CD2_1" in hdu[0].header, "HDU does not contain WCS information." + assert "CRPIX1" in hdu[0].header, "HDU does not contain WCS information." + assert "NAXIS1" in hdu[0].header, "HDU does not contain WCS information." + hdu[0].data = hdu[0].data[:, ::-1].copy() + hdu[0].header["CD1_1"] = -hdu[0].header["CD1_1"] + hdu[0].header["CD2_1"] = -hdu[0].header["CD2_1"] + hdu[0].header["CRPIX1"] = int(hdu[0].header["NAXIS1"] / 2) + 1 + hdu[0].header["CRPIX2"] = int(hdu[0].header["NAXIS2"] / 2) + 1 + return hdu + + +def ls_open(ra, dec, size_arcsec, band="r", release="ls_dr9"): + """ + Retrieves and opens a FITS cutout from the deepest stacked image in the + specified Legacy Survey data release using the Astro Data Lab SIA service. + + Args: + ra (float): Right Ascension in decimal degrees. + dec (float): Declination in decimal degrees. + size_arcsec (float): Size of the square cutout (side length) in arcseconds. + band (str): The filter band (e.g., 'g', 'r', 'z'). Case-insensitive. + release (str): The Legacy Survey Data Release (e.g., 'DR9'). + + Returns: + astropy.io.fits.HDUList: The opened FITS file object. + """ + + # 1. Set the specific SIA service endpoint for the desired release + # SIA endpoints for specific surveys are listed in the notebook. + service_url = f"https://datalab.noirlab.edu/sia/{release.lower()}" + svc = sia.SIAService(service_url) + + # 2. Convert size from arcseconds to degrees (FOV) for the SIA query + # and apply the cosine correction for RA. + fov_deg = size_arcsec / 3600.0 + + # The search method takes the position (RA, Dec) and the square FOV. + imgTable = svc.search( + (ra, dec), (fov_deg / np.cos(dec * np.pi / 180.0), fov_deg), verbosity=2 + ).to_table() + + # 3. Filter the table for stacked images in the specified band + target_band = band.lower() + + sel = ( + (imgTable["proctype"] == "Stack") + & (imgTable["prodtype"] == "image") + & (startswith(imgTable["obs_bandpass"].astype(str), target_band)) + ) + + Table = imgTable[sel] + + if len(Table) == 0: + raise ValueError( + f"No stacked FITS image found for {release} band '{band}' at the requested RA {ra} and Dec {dec}." + ) + + # 4. Pick the "deepest" image (longest exposure time) + # Note: 'exptime' data needs explicit float conversion for np.argmax + max_exptime_index = np.argmax(Table["exptime"].data.data.astype("float")) + row = Table[max_exptime_index] + + # 5. Download the file and open it + url = row["access_url"] # get the download URL + + # Use astropy's download_file, which handles the large data transfer + # and automatically uses a long timeout (120s in the notebook example) + filename = download_file(url, cache=False, show_progress=False, timeout=120) + + # Open the downloaded FITS file + hdu = fits.open(filename) + + try: + hdu = flip_hdu(hdu) + except AssertionError: + pass # If WCS info is missing, skip flipping + + # Clean up the temporary file created by download_file + try: + os.remove(filename) + except OSError: + pass # Ignore if cleanup fails + + return hdu diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index b4a719eb..f442b811 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -37,11 +37,8 @@ "outputs": [], "source": [ "# first let's download an image to play with\n", - "hdu = fits.open(\n", - " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=155.7720&dec=15.1494&size=150&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - ")\n", + "hdu = ap.utils.ls_open(155.7720, 15.1494, 150 * 0.262, band=\"r\")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", - "\n", "fig1, ax1 = plt.subplots(figsize=(8, 8))\n", "plt.imshow(np.arctan(target_data / 0.05), origin=\"lower\", cmap=\"inferno\")\n", "plt.axis(\"off\")\n", @@ -61,7 +58,7 @@ "#########################################\n", "from photutils.segmentation import detect_sources, deblend_sources\n", "\n", - "initsegmap = detect_sources(target_data, threshold=0.02, npixels=5)\n", + "initsegmap = detect_sources(target_data, threshold=0.02, npixels=6)\n", "segmap = deblend_sources(target_data, initsegmap, npixels=5).data\n", "fig8, ax8 = plt.subplots(figsize=(8, 8))\n", "ax8.imshow(segmap, origin=\"lower\", cmap=\"inferno\")\n", From ac7b90a6f80122dc4d81c120223f22aa080bbe41 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Wed, 19 Nov 2025 13:59:27 -0500 Subject: [PATCH 176/191] add pyvo requirement --- astrophot/utils/fitsopen.py | 11 ++++++++++- docs/requirements.txt | 1 + pyproject.toml | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/astrophot/utils/fitsopen.py b/astrophot/utils/fitsopen.py index 0d1b8a80..5c9a8d70 100644 --- a/astrophot/utils/fitsopen.py +++ b/astrophot/utils/fitsopen.py @@ -4,7 +4,11 @@ from astropy.io import fits from astropy.utils.exceptions import AstropyWarning from numpy.core.defchararray import startswith -from pyvo.dal import sia + +try: + from pyvo.dal import sia +except: + sia = None import os # Suppress common Astropy warnings that can clutter CI logs @@ -49,6 +53,11 @@ def ls_open(ra, dec, size_arcsec, band="r", release="ls_dr9"): astropy.io.fits.HDUList: The opened FITS file object. """ + if sia is None: + raise ImportError( + "Cannot use ls_open without pyvo. Please install pyvo (pip install pyvo) before continuing." + ) + # 1. Set the specific SIA service endpoint for the desired release # SIA endpoints for specific surveys are listed in the notebook. service_url = f"https://datalab.noirlab.edu/sia/{release.lower()}" diff --git a/docs/requirements.txt b/docs/requirements.txt index 6a5e0c1b..07b09906 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -9,6 +9,7 @@ matplotlib nbformat nbsphinx photutils +pyvo scikit-image sphinx sphinx-rtd-theme diff --git a/pyproject.toml b/pyproject.toml index 1ccadbe8..faaf81cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax<=0.7.0"] +dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax<=0.7.0", "pyvo"] [project.scripts] astrophot = "astrophot:run_from_terminal" From e7c2575ba4b0d81027c2946f793bb84cc9aca936 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 2 Dec 2025 16:22:48 -0500 Subject: [PATCH 177/191] update to new version of caskade --- astrophot/image/image_object.py | 32 ++++++++-------- astrophot/models/_shared_methods.py | 9 ++--- astrophot/models/airy.py | 8 ++-- astrophot/models/base.py | 10 ++--- astrophot/models/basis.py | 8 ++-- astrophot/models/bilinear_sky.py | 8 ++-- astrophot/models/edgeon.py | 20 ++++++---- astrophot/models/exponential.py | 2 +- astrophot/models/ferrer.py | 2 +- astrophot/models/flatsky.py | 4 +- astrophot/models/gaussian.py | 2 +- astrophot/models/gaussian_ellipsoid.py | 40 ++++++++++++++------ astrophot/models/king.py | 2 +- astrophot/models/mixins/brightness.py | 1 - astrophot/models/mixins/exponential.py | 8 ++-- astrophot/models/mixins/ferrer.py | 16 ++++---- astrophot/models/mixins/gaussian.py | 8 ++-- astrophot/models/mixins/king.py | 27 ++++++++----- astrophot/models/mixins/moffat.py | 13 +++---- astrophot/models/mixins/nuker.py | 20 +++++----- astrophot/models/mixins/sample.py | 1 - astrophot/models/mixins/sersic.py | 12 +++--- astrophot/models/mixins/spline.py | 8 ++-- astrophot/models/mixins/transform.py | 40 +++++++++++--------- astrophot/models/model_object.py | 4 +- astrophot/models/moffat.py | 4 +- astrophot/models/multi_gaussian_expansion.py | 20 +++++----- astrophot/models/nuker.py | 2 +- astrophot/models/pixelated_psf.py | 4 +- astrophot/models/planesky.py | 8 ++-- astrophot/models/point_source.py | 4 +- astrophot/models/psf_model_object.py | 2 +- astrophot/models/sersic.py | 2 +- astrophot/models/sky_model_object.py | 2 +- astrophot/param/param.py | 7 ---- docs/source/tutorials/CustomModels.ipynb | 12 +++--- docs/source/tutorials/ImageAlignment.ipynb | 2 +- docs/source/tutorials/ModelZoo.ipynb | 4 +- tests/test_psfmodel.py | 2 +- 39 files changed, 200 insertions(+), 180 deletions(-) diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 6926877a..7d989342 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -69,9 +69,6 @@ def __init__( self.data = data # units: flux else: self._data = _data - self.crval = Param( - "crval", shape=(2,), units="deg", dtype=config.DTYPE, device=config.DEVICE - ) self.crtan = Param( "crtan", crtan, @@ -80,19 +77,8 @@ def __init__( dtype=config.DTYPE, device=config.DEVICE, ) - self.CD = Param( - "CD", - shape=(2, 2), - units="arcsec/pixel", - dtype=config.DTYPE, - device=config.DEVICE, - ) self.zeropoint = zeropoint - if filename is not None: - self.load(filename, hduext=hduext) - return - if identity is None: self.identity = id(self) else: @@ -116,7 +102,9 @@ def __init__( CD = deg_to_arcsec * wcs.pixel_scale_matrix # set the data - self.crval = crval + self.crval = Param( + "crval", crval, shape=(2,), units="deg", dtype=config.DTYPE, device=config.DEVICE + ) self.crpix = crpix if isinstance(CD, (float, int)): @@ -125,7 +113,19 @@ def __init__( CD = np.array([[pixelscale, 0.0], [0.0, pixelscale]], dtype=np.float64) elif CD is None: CD = self.default_CD - self.CD = CD + + self.CD = Param( + "CD", + CD, + shape=(2, 2), + units="arcsec/pixel", + dtype=config.DTYPE, + device=config.DEVICE, + ) + + if filename is not None: + self.load(filename, hduext=hduext) + return @property def data(self): diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 6cb39689..5a81c017 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -104,11 +104,10 @@ def optim(x, r, f, u): for param, x0x in zip(params, x0): if not model[param].initialized: + x0x = backend.as_array(x0x, dtype=config.DTYPE, device=config.DEVICE) if not model[param].is_valid(x0x): - x0x = model[param].soft_valid( - backend.as_array(x0x, dtype=config.DTYPE, device=config.DEVICE) - ) - model[param].dynamic_value = x0x + x0x = model[param].soft_valid(x0x) + model[param].value = x0x @torch.no_grad() @@ -155,4 +154,4 @@ def optim(x, r, f, u): values = np.stack(values).T for param, v in zip(params, values): if not model[param].initialized: - model[param].dynamic_value = v + model[param].value = v diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index b1211afa..ffc8d70e 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -45,8 +45,8 @@ class AiryPSF(RadialMixin, PSFModel): _model_type = "airy" _parameter_specs = { - "I0": {"units": "flux/arcsec^2", "value": 1.0, "shape": ()}, - "aRL": {"units": "a/(R lambda)", "shape": ()}, + "I0": {"units": "flux/arcsec^2", "value": 1.0, "shape": (), "dynamic": False}, + "aRL": {"units": "a/(R lambda)", "shape": (), "dynamic": True}, } usable = True @@ -64,9 +64,9 @@ def initialize(self): int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] - self.I0.dynamic_value = backend.mean(mid_chunk) / self.target.pixel_area + self.I0.value = backend.mean(mid_chunk) / self.target.pixel_area if not self.aRL.initialized: - self.aRL.dynamic_value = (5.0 / 8.0) * 2 * self.target.pixelscale + self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixelscale @forward def radial_model(self, R: ArrayLike, I0: ArrayLike, aRL: ArrayLike) -> ArrayLike: diff --git a/astrophot/models/base.py b/astrophot/models/base.py index ebd79ab3..9d88d6ca 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -110,13 +110,11 @@ def build_parameter_specs(self, kwargs, parameter_specs) -> dict: if isinstance(kwargs[p], dict): parameter_specs[p].update(kwargs.pop(p)) else: - parameter_specs[p]["dynamic_value"] = kwargs.pop(p) - parameter_specs[p].pop("value", None) - if isinstance(parameter_specs[p].get("dynamic_value", None), CParam) or callable( - parameter_specs[p].get("dynamic_value", None) + parameter_specs[p]["value"] = kwargs.pop(p) + if isinstance(parameter_specs[p].get("value", None), CParam) or callable( + parameter_specs[p].get("value", None) ): - parameter_specs[p]["value"] = parameter_specs[p]["dynamic_value"] - parameter_specs[p].pop("dynamic_value", None) + parameter_specs[p]["dynamic"] = False return parameter_specs diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index 6b2d11bc..1064943a 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -33,9 +33,9 @@ class PixelBasisPSF(PSFModel): _model_type = "basis" _parameter_specs = { - "weights": {"units": "flux"}, - "PA": {"units": "radians", "shape": ()}, - "scale": {"units": "arcsec/grid-unit", "shape": ()}, + "weights": {"units": "flux", "dynamic": True}, + "PA": {"units": "radians", "shape": (), "dynamic": True}, + "scale": {"units": "arcsec/grid-unit", "shape": (), "dynamic": True}, } usable = True @@ -95,7 +95,7 @@ def initialize(self): if not self.weights.initialized: w = np.zeros(self.basis.shape[0]) w[0] = 1.0 - self.weights.dynamic_value = w + self.weights.value = w @forward def transform_coordinates( diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py index 16242b1c..0d4873a3 100644 --- a/astrophot/models/bilinear_sky.py +++ b/astrophot/models/bilinear_sky.py @@ -26,9 +26,9 @@ class BilinearSky(SkyModel): _model_type = "bilinear" _parameter_specs = { - "I": {"units": "flux/arcsec^2"}, - "PA": {"units": "radians", "shape": ()}, - "scale": {"units": "arcsec/grid-unit", "shape": ()}, + "I": {"units": "flux/arcsec^2", "dynamic": True}, + "PA": {"units": "radians", "shape": (), "dynamic": True}, + "scale": {"units": "arcsec/grid-unit", "shape": (), "dynamic": True}, } sampling_mode = "midpoint" usable = True @@ -64,7 +64,7 @@ def initialize(self): iS = dat.shape[0] // self.nodes[0] jS = dat.shape[1] // self.nodes[1] - self.I.dynamic_value = ( + self.I.value = ( np.median( dat[: iS * self.nodes[0], : jS * self.nodes[1]].reshape( iS, self.nodes[0], jS, self.nodes[1] diff --git a/astrophot/models/edgeon.py b/astrophot/models/edgeon.py index 812fce5a..115e2334 100644 --- a/astrophot/models/edgeon.py +++ b/astrophot/models/edgeon.py @@ -24,7 +24,13 @@ class EdgeonModel(ComponentModel): _model_type = "edgeon" _parameter_specs = { - "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "shape": ()}, + "PA": { + "units": "radians", + "valid": (0, np.pi), + "cyclic": True, + "shape": (), + "dynamic": True, + }, } usable = False @@ -51,9 +57,9 @@ def initialize(self): mu11 = np.median(dat * x * y / np.sqrt(np.abs(x * y))) M = np.array([[mu20, mu11], [mu11, mu02]]) if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): - self.PA.dynamic_value = np.pi / 2 + self.PA.value = np.pi / 2 else: - self.PA.dynamic_value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi + self.PA.value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02)) % np.pi @forward def transform_coordinates( @@ -74,8 +80,8 @@ class EdgeonSech(EdgeonModel): _model_type = "sech2" _parameter_specs = { - "I0": {"units": "flux/arcsec^2", "shape": ()}, - "hs": {"units": "arcsec", "valid": (0, None), "shape": ()}, + "I0": {"units": "flux/arcsec^2", "shape": (), "dynamic": True}, + "hs": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, } usable = False @@ -93,7 +99,7 @@ def initialize(self): int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] - self.I0.dynamic_value = backend.mean(chunk) / self.target.pixel_area + self.I0.value = backend.mean(chunk) / self.target.pixel_area if not self.hs.initialized: self.hs.value = max(self.window.shape) * target_area.pixelscale * 0.1 @@ -113,7 +119,7 @@ class EdgeonIsothermal(EdgeonSech): """ _model_type = "isothermal" - _parameter_specs = {"rs": {"units": "arcsec", "valid": (0, None), "shape": ()}} + _parameter_specs = {"rs": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}} usable = True @torch.no_grad() diff --git a/astrophot/models/exponential.py b/astrophot/models/exponential.py index 237d79d3..84cb82ef 100644 --- a/astrophot/models/exponential.py +++ b/astrophot/models/exponential.py @@ -30,7 +30,7 @@ class ExponentialGalaxy(ExponentialMixin, RadialMixin, GalaxyModel): @combine_docstrings class ExponentialPSF(ExponentialMixin, RadialMixin, PSFModel): - _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0}} + _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} usable = True diff --git a/astrophot/models/ferrer.py b/astrophot/models/ferrer.py index b59f5c18..39c87d70 100644 --- a/astrophot/models/ferrer.py +++ b/astrophot/models/ferrer.py @@ -31,7 +31,7 @@ class FerrerGalaxy(FerrerMixin, RadialMixin, GalaxyModel): @combine_docstrings class FerrerPSF(FerrerMixin, RadialMixin, PSFModel): - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} usable = True diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 21e23638..75170e81 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -20,7 +20,7 @@ class FlatSky(SkyModel): """ _model_type = "flat" - _parameter_specs = {"I": {"units": "flux/arcsec^2"}} + _parameter_specs = {"I": {"units": "flux/arcsec^2", "dynamic": True}} usable = True @torch.no_grad() @@ -36,7 +36,7 @@ def initialize(self): mask = backend.to_numpy(target_area._mask) dat[mask] = np.median(dat[~mask]) - self.I.dynamic_value = np.median(dat) / self.target.pixel_area.item() + self.I.value = np.median(dat) / self.target.pixel_area.item() @forward def brightness(self, x: ArrayLike, y: ArrayLike, I: ArrayLike) -> ArrayLike: diff --git a/astrophot/models/gaussian.py b/astrophot/models/gaussian.py index 1dcdcb08..900c8241 100644 --- a/astrophot/models/gaussian.py +++ b/astrophot/models/gaussian.py @@ -32,7 +32,7 @@ class GaussianGalaxy(GaussianMixin, RadialMixin, GalaxyModel): @combine_docstrings class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): - _parameter_specs = {"flux": {"units": "flux", "value": 1.0}} + _parameter_specs = {"flux": {"units": "flux", "value": 1.0, "dynamic": False}} usable = True diff --git a/astrophot/models/gaussian_ellipsoid.py b/astrophot/models/gaussian_ellipsoid.py index 2d14da51..23fab669 100644 --- a/astrophot/models/gaussian_ellipsoid.py +++ b/astrophot/models/gaussian_ellipsoid.py @@ -51,13 +51,31 @@ class GaussianEllipsoid(ComponentModel): _model_type = "gaussianellipsoid" _parameter_specs = { - "sigma_a": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "sigma_b": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "sigma_c": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "alpha": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "shape": ()}, - "beta": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "shape": ()}, - "gamma": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "shape": ()}, - "flux": {"units": "flux", "shape": ()}, + "sigma_a": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "sigma_b": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "sigma_c": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "alpha": { + "units": "radians", + "valid": (0, 2 * np.pi), + "cyclic": True, + "shape": (), + "dynamic": True, + }, + "beta": { + "units": "radians", + "valid": (0, 2 * np.pi), + "cyclic": True, + "shape": (), + "dynamic": True, + }, + "gamma": { + "units": "radians", + "valid": (0, 2 * np.pi), + "cyclic": True, + "shape": (), + "dynamic": True, + }, + "flux": {"units": "flux", "shape": (), "dynamic": True}, } usable = True @@ -88,7 +106,7 @@ def initialize(self): x = x - center[0] y = y - center[1] r = backend.to_numpy(self.radius_metric(x, y, params=())) - self.sigma_a.dynamic_value = np.sqrt(np.sum((r * dat) ** 2) / np.sum(r**2)) + self.sigma_a.value = np.sqrt(np.sum((r * dat) ** 2) / np.sum(r**2)) x = backend.to_numpy(x) y = backend.to_numpy(y) @@ -104,9 +122,9 @@ def initialize(self): PA = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi l = np.sort(np.linalg.eigvals(M)) q = np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) - self.beta.dynamic_value = np.arccos(q) - self.gamma.dynamic_value = PA - self.flux.dynamic_value = np.sum(dat) + self.beta.value = np.arccos(q) + self.gamma.value = PA + self.flux.value = np.sum(dat) @forward def brightness( diff --git a/astrophot/models/king.py b/astrophot/models/king.py index f3f4149c..a565d406 100644 --- a/astrophot/models/king.py +++ b/astrophot/models/king.py @@ -31,7 +31,7 @@ class KingGalaxy(KingMixin, RadialMixin, GalaxyModel): @combine_docstrings class KingPSF(KingMixin, RadialMixin, PSFModel): - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} usable = True diff --git a/astrophot/models/mixins/brightness.py b/astrophot/models/mixins/brightness.py index a7561f77..168ab77c 100644 --- a/astrophot/models/mixins/brightness.py +++ b/astrophot/models/mixins/brightness.py @@ -1,4 +1,3 @@ -import torch from torch import Tensor from ...backend_obj import backend, ArrayLike import numpy as np diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 833660c5..3e578d0e 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -30,8 +30,8 @@ class ExponentialMixin: _model_type = "exponential" _parameter_specs = { - "Re": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, + "Re": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (), "dynamic": True}, } @torch.no_grad() @@ -73,8 +73,8 @@ class iExponentialMixin: _model_type = "exponential" _parameter_specs = { - "Re": {"units": "arcsec", "valid": (0, None)}, - "Ie": {"units": "flux/arcsec^2", "valid": (0, None)}, + "Re": {"units": "arcsec", "valid": (0, None), "dynamic": True}, + "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, } @torch.no_grad() diff --git a/astrophot/models/mixins/ferrer.py b/astrophot/models/mixins/ferrer.py index d4fac0b6..47cb87f3 100644 --- a/astrophot/models/mixins/ferrer.py +++ b/astrophot/models/mixins/ferrer.py @@ -34,10 +34,10 @@ class FerrerMixin: _model_type = "ferrer" _parameter_specs = { - "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, - "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, - "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, + "rout": {"units": "arcsec", "valid": (0.0, None), "shape": (), "dynamic": True}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": (), "dynamic": True}, + "beta": {"units": "unitless", "valid": (0, 2), "shape": (), "dynamic": True}, + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (), "dynamic": True}, } @torch.no_grad() @@ -85,10 +85,10 @@ class iFerrerMixin: _model_type = "ferrer" _parameter_specs = { - "rout": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "alpha": {"units": "unitless", "valid": (0, 10), "shape": ()}, - "beta": {"units": "unitless", "valid": (0, 2), "shape": ()}, - "I0": {"units": "flux/arcsec^2", "valid": (0.0, None), "shape": ()}, + "rout": {"units": "arcsec", "valid": (0.0, None), "shape": (), "dynamic": True}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": (), "dynamic": True}, + "beta": {"units": "unitless", "valid": (0, 2), "shape": (), "dynamic": True}, + "I0": {"units": "flux/arcsec^2", "valid": (0.0, None), "shape": (), "dynamic": True}, } @torch.no_grad() diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index 18c8d534..f6b57921 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -30,8 +30,8 @@ class GaussianMixin: _model_type = "gaussian" _parameter_specs = { - "sigma": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "flux": {"units": "flux", "valid": (0, None), "shape": ()}, + "sigma": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "flux": {"units": "flux", "valid": (0, None), "shape": (), "dynamic": True}, } @torch.no_grad() @@ -74,8 +74,8 @@ class iGaussianMixin: _model_type = "gaussian" _parameter_specs = { - "sigma": {"units": "arcsec", "valid": (0, None)}, - "flux": {"units": "flux", "valid": (0, None)}, + "sigma": {"units": "arcsec", "valid": (0, None), "dynamic": True}, + "flux": {"units": "flux", "valid": (0, None), "dynamic": True}, } @torch.no_grad() diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index bf672a79..8964dc74 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -35,10 +35,16 @@ class KingMixin: _model_type = "king" _parameter_specs = { - "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": ()}, - "alpha": {"units": "unitless", "valid": (0, 10), "shape": (), "value": 2.0}, - "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, + "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": (), "dynamic": True}, + "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": (), "dynamic": True}, + "alpha": { + "units": "unitless", + "valid": (0, 10), + "shape": (), + "value": 2.0, + "dynamic": False, + }, + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (), "dynamic": True}, } @torch.no_grad() @@ -47,7 +53,7 @@ def initialize(self): super().initialize() if not self.alpha.initialized: - self.alpha.dynamic_value = 2.0 + self.alpha.value = 2.0 parametric_initialize( self, @@ -89,10 +95,10 @@ class iKingMixin: _model_type = "king" _parameter_specs = { - "Rc": {"units": "arcsec", "valid": (0.0, None)}, - "Rt": {"units": "arcsec", "valid": (0.0, None)}, - "alpha": {"units": "unitless", "valid": (0, 10)}, - "I0": {"units": "flux/arcsec^2", "valid": (0, None)}, + "Rc": {"units": "arcsec", "valid": (0.0, None), "dynamic": True}, + "Rt": {"units": "arcsec", "valid": (0.0, None), "dynamic": True}, + "alpha": {"units": "unitless", "valid": (0, 10), "dynamic": False}, + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, } @torch.no_grad() @@ -101,7 +107,8 @@ def initialize(self): super().initialize() if not self.alpha.initialized: - self.alpha.value = 2.0 * np.ones(self.segments) + self.alpha.static_value(2.0 * np.ones(self.segments)) + parametric_segment_initialize( model=self, target=self.target[self.window], diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index 64712f52..eef7f2b6 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -1,5 +1,4 @@ import torch -from torch import Tensor from ...param import forward from ...backend_obj import ArrayLike @@ -32,9 +31,9 @@ class MoffatMixin: _model_type = "moffat" _parameter_specs = { - "n": {"units": "none", "valid": (0.1, 10), "shape": ()}, - "Rd": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, + "n": {"units": "none", "valid": (0.1, 10), "shape": (), "dynamic": True}, + "Rd": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (), "dynamic": True}, } @torch.no_grad() @@ -77,9 +76,9 @@ class iMoffatMixin: _model_type = "moffat" _parameter_specs = { - "n": {"units": "none", "valid": (0.1, 10)}, - "Rd": {"units": "arcsec", "valid": (0, None)}, - "I0": {"units": "flux/arcsec^2", "valid": (0, None)}, + "n": {"units": "none", "valid": (0.1, 10), "dynamic": True}, + "Rd": {"units": "arcsec", "valid": (0, None), "dynamic": True}, + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, } @torch.no_grad() diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 0c7007b4..36d26994 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -34,11 +34,11 @@ class NukerMixin: _model_type = "nuker" _parameter_specs = { - "Rb": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "Ib": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, - "alpha": {"units": "none", "valid": (0, None), "shape": ()}, - "beta": {"units": "none", "valid": (0, None), "shape": ()}, - "gamma": {"units": "none", "shape": ()}, + "Rb": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "Ib": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (), "dynamic": True}, + "alpha": {"units": "none", "valid": (0, None), "shape": (), "dynamic": True}, + "beta": {"units": "none", "valid": (0, None), "shape": (), "dynamic": True}, + "gamma": {"units": "none", "shape": (), "dynamic": True}, } @torch.no_grad() @@ -92,11 +92,11 @@ class iNukerMixin: _model_type = "nuker" _parameter_specs = { - "Rb": {"units": "arcsec", "valid": (0, None)}, - "Ib": {"units": "flux/arcsec^2", "valid": (0, None)}, - "alpha": {"units": "none", "valid": (0, None)}, - "beta": {"units": "none", "valid": (0, None)}, - "gamma": {"units": "none"}, + "Rb": {"units": "arcsec", "valid": (0, None), "dynamic": True}, + "Ib": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, + "alpha": {"units": "none", "valid": (0, None), "dynamic": True}, + "beta": {"units": "none", "valid": (0, None), "dynamic": True}, + "gamma": {"units": "none", "dynamic": True}, } @torch.no_grad() diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 331356ce..1b5e1b14 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -1,7 +1,6 @@ from typing import Optional, Literal import numpy as np -from torch.autograd.functional import jacobian from ...param import forward from ...backend_obj import backend, ArrayLike diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index fad8ab4c..11730f1e 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -34,9 +34,9 @@ class SersicMixin: _model_type = "sersic" _parameter_specs = { - "n": {"units": "none", "valid": (0.36, 8), "shape": ()}, - "Re": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "shape": ()}, + "n": {"units": "none", "valid": (0.36, 8), "shape": (), "dynamic": True}, + "Re": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (), "dynamic": True}, } @torch.no_grad() @@ -78,9 +78,9 @@ class iSersicMixin: _model_type = "sersic" _parameter_specs = { - "n": {"units": "none", "valid": (0.36, 8)}, - "Re": {"units": "arcsec", "valid": (0, None)}, - "Ie": {"units": "flux/arcsec^2", "valid": (0, None)}, + "n": {"units": "none", "valid": (0.36, 8), "dynamic": True}, + "Re": {"units": "arcsec", "valid": (0, None), "dynamic": True}, + "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, } @torch.no_grad() diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 721bc376..4b95dffb 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -22,7 +22,7 @@ class SplineMixin: """ _model_type = "spline" - _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None)}} + _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}} @torch.no_grad() @ignore_numpy_warnings @@ -47,7 +47,7 @@ def initialize(self): self.radius_metric, rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], ) - self.I_R.dynamic_value = 10**I + self.I_R.value = 10**I @forward def radial_model(self, R: ArrayLike, I_R: ArrayLike) -> ArrayLike: @@ -72,7 +72,7 @@ class iSplineMixin: """ _model_type = "spline" - _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None)}} + _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}} @torch.no_grad() @ignore_numpy_warnings @@ -109,7 +109,7 @@ def initialize(self): ) value[s] = I - self.I_R.dynamic_value = 10**value + self.I_R.value = 10**value @forward def iradial_model(self, i: int, R: ArrayLike, I_R: ArrayLike) -> ArrayLike: diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 39a47833..278fc90d 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -38,8 +38,14 @@ class InclinedMixin: """ _parameter_specs = { - "q": {"units": "b/a", "valid": (0.01, 1), "shape": ()}, - "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "shape": ()}, + "q": {"units": "b/a", "valid": (0.01, 1), "shape": (), "dynamic": True}, + "PA": { + "units": "radians", + "valid": (0, np.pi), + "cyclic": True, + "shape": (), + "dynamic": True, + }, } @torch.no_grad() @@ -67,17 +73,15 @@ def initialize(self): M = np.array([[mu20, mu11], [mu11, mu02]]) if not self.PA.initialized: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): - self.PA.dynamic_value = np.pi / 2 + self.PA.value = np.pi / 2 else: - self.PA.dynamic_value = ( - 0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2 - ) % np.pi + self.PA.value = (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi if not self.q.initialized: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): l = (0.7, 1.0) else: l = np.sort(np.linalg.eigvals(M)) - self.q.dynamic_value = np.clip(np.sqrt(np.abs(l[0] / l[1])), 0.1, 0.9) + self.q.value = np.clip(np.sqrt(np.abs(l[0] / l[1])), 0.1, 0.9) @forward def transform_coordinates( @@ -114,7 +118,7 @@ class SuperEllipseMixin: _model_type = "superellipse" _parameter_specs = { - "C": {"units": "none", "dynamic_value": 2.0, "valid": (0, 10)}, + "C": {"units": "none", "value": 2.0, "valid": (0, 10), "dynamic": True}, } @forward @@ -164,8 +168,8 @@ class FourierEllipseMixin: _model_type = "fourier" _parameter_specs = { - "am": {"units": "none"}, - "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True}, + "am": {"units": "none", "dynamic": True}, + "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "dynamic": True}, } _options = ("modes",) @@ -193,7 +197,7 @@ def initialize(self): super().initialize() if not self.am.initialized: - self.am.dynamic_value = np.zeros(len(self.modes)) + self.am.value = np.zeros(len(self.modes)) if not self.phim.initialized: self.phim.value = np.zeros(len(self.modes)) @@ -224,8 +228,8 @@ class WarpMixin: _model_type = "warp" _parameter_specs = { - "q_R": {"units": "b/a", "valid": (0, 1)}, - "PA_R": {"units": "radians", "valid": (0, np.pi), "cyclic": True}, + "q_R": {"units": "b/a", "valid": (0, 1), "dynamic": True}, + "PA_R": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "dynamic": True}, } @torch.no_grad() @@ -236,11 +240,11 @@ def initialize(self): if not self.PA_R.initialized: if self.PA_R.prof is None: self.PA_R.prof = default_prof(self.window.shape, self.target.pixelscale, 2, 0.2) - self.PA_R.dynamic_value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 + self.PA_R.value = np.zeros(len(self.PA_R.prof)) + np.pi / 2 if not self.q_R.initialized: if self.q_R.prof is None: self.q_R.prof = default_prof(self.window.shape, self.target.pixelscale, 2, 0.2) - self.q_R.dynamic_value = np.ones(len(self.q_R.prof)) * 0.8 + self.q_R.value = np.ones(len(self.q_R.prof)) * 0.8 @forward def transform_coordinates( @@ -281,8 +285,8 @@ class TruncationMixin: _model_type = "truncated" _parameter_specs = { - "Rt": {"units": "arcsec", "valid": (0, None), "shape": ()}, - "St": {"units": "none", "valid": (0, None), "shape": (), "value": 1.0}, + "Rt": {"units": "arcsec", "valid": (0, None), "shape": (), "dynamic": True}, + "St": {"units": "none", "valid": (0, None), "shape": (), "value": 1.0, "dynamic": False}, } _options = ("outer_truncation",) @@ -296,7 +300,7 @@ def initialize(self): super().initialize() if not self.Rt.initialized: prof = default_prof(self.window.shape, self.target.pixelscale, 2, 0.2) - self.Rt.dynamic_value = prof[len(prof) // 2] + self.Rt.value = prof[len(prof) // 2] @forward def radial_model(self, R: ArrayLike, Rt: ArrayLike, St: ArrayLike) -> ArrayLike: diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 13e32970..8c17a94b 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -36,7 +36,7 @@ class ComponentModel(SampleMixin, Model): """ - _parameter_specs = {"center": {"units": "arcsec", "shape": (2,)}} + _parameter_specs = {"center": {"units": "arcsec", "shape": (2,), "dynamic": True}} _options = ("psf_convolve",) @@ -136,7 +136,7 @@ def initialize(self): COM_center = target_area.pixel_to_plane( *backend.as_array(COM, dtype=config.DTYPE, device=config.DEVICE) ) - self.center.dynamic_value = COM_center + self.center.value = COM_center def fit_mask(self): return backend.zeros_like(self.target[self.window].mask, dtype=backend.bool) diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py index 1cff5e0d..65be477c 100644 --- a/astrophot/models/moffat.py +++ b/astrophot/models/moffat.py @@ -33,14 +33,14 @@ class MoffatGalaxy(MoffatMixin, RadialMixin, GalaxyModel): @combine_docstrings class MoffatPSF(MoffatMixin, RadialMixin, PSFModel): - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} usable = True @combine_docstrings class Moffat2DPSF(MoffatMixin, InclinedMixin, RadialMixin, PSFModel): _model_type = "2d" - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0}} + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} usable = True diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 89669558..5f50980c 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -30,10 +30,10 @@ class MultiGaussianExpansion(ComponentModel): _model_type = "mge" _parameter_specs = { - "q": {"units": "b/a", "valid": (0, 1)}, - "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True}, - "sigma": {"units": "arcsec", "valid": (0, None)}, - "flux": {"units": "flux"}, + "q": {"units": "b/a", "valid": (0, 1), "dynamic": True}, + "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "dynamic": True}, + "sigma": {"units": "arcsec", "valid": (0, None), "dynamic": True}, + "flux": {"units": "flux", "dynamic": True}, } usable = True @@ -64,13 +64,13 @@ def initialize(self): dat -= edge_average if not self.sigma.initialized: - self.sigma.dynamic_value = np.logspace( + self.sigma.value = np.logspace( np.log10(target_area.pixelscale.item() * 3), max(target_area.data.shape) * target_area.pixelscale.item() * 0.7, self.n_components, ) if not self.flux.initialized: - self.flux.dynamic_value = (np.sum(dat) / self.n_components) * np.ones(self.n_components) + self.flux.value = (np.sum(dat) / self.n_components) * np.ones(self.n_components) if self.PA.initialized or self.q.initialized: return @@ -88,16 +88,14 @@ def initialize(self): ones = np.ones(self.n_components) if not self.PA.initialized: if np.any(np.iscomplex(M)) or np.any(~np.isfinite(M)): - self.PA.dynamic_value = ones * np.pi / 2 + self.PA.value = ones * np.pi / 2 else: - self.PA.dynamic_value = ( - ones * (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi - ) + self.PA.value = ones * (0.5 * np.arctan2(2 * mu11, mu20 - mu02) - np.pi / 2) % np.pi if not self.q.initialized: l = np.sort(np.linalg.eigvals(M)) if np.any(np.iscomplex(l)) or np.any(~np.isfinite(l)): l = (0.7, 1.0) - self.q.dynamic_value = ones * np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) + self.q.value = ones * np.clip(np.sqrt(l[0] / l[1]), 0.1, 0.9) @forward def transform_coordinates( diff --git a/astrophot/models/nuker.py b/astrophot/models/nuker.py index dfcbce71..6e9f55f6 100644 --- a/astrophot/models/nuker.py +++ b/astrophot/models/nuker.py @@ -31,7 +31,7 @@ class NukerGalaxy(NukerMixin, RadialMixin, GalaxyModel): @combine_docstrings class NukerPSF(NukerMixin, RadialMixin, PSFModel): - _parameter_specs = {"Ib": {"units": "flux/arcsec^2", "value": 1.0}} + _parameter_specs = {"Ib": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} usable = True diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index bb83292e..e0821aed 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -40,7 +40,7 @@ class PixelatedPSF(PSFModel): """ _model_type = "pixelated" - _parameter_specs = {"pixels": {"units": "flux/arcsec^2"}} + _parameter_specs = {"pixels": {"units": "flux/arcsec^2", "dynamic": True}} usable = True sampling_mode = "midpoint" integrate_mode = "none" @@ -52,7 +52,7 @@ def initialize(self): if self.pixels.initialized: return target_area = self.target[self.window] - self.pixels.dynamic_value = backend.copy(target_area._data) / target_area.pixel_area + self.pixels.value = backend.copy(target_area._data) / target_area.pixel_area @forward def brightness( diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index 0868a064..b8d4f251 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -27,8 +27,8 @@ class PlaneSky(SkyModel): _model_type = "plane" _parameter_specs = { - "I0": {"units": "flux/arcsec^2"}, - "delta": {"units": "flux/arcsec"}, + "I0": {"units": "flux/arcsec^2", "dynamic": True}, + "delta": {"units": "flux/arcsec", "dynamic": True}, } usable = True @@ -41,9 +41,9 @@ def initialize(self): dat = backend.to_numpy(self.target[self.window]._data).copy() mask = backend.to_numpy(self.target[self.window]._mask) dat[mask] = np.median(dat[~mask]) - self.I0.dynamic_value = np.median(dat) / self.target.pixel_area.item() + self.I0.value = np.median(dat) / self.target.pixel_area.item() if not self.delta.initialized: - self.delta.dynamic_value = [0.0, 0.0] + self.delta.value = [0.0, 0.0] @forward def brightness(self, x: ArrayLike, y: ArrayLike, I0: ArrayLike, delta: ArrayLike) -> ArrayLike: diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 101d1e0c..90faec52 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -32,7 +32,7 @@ class PointSource(ComponentModel): _model_type = "point" _parameter_specs = { - "flux": {"units": "flux", "valid": (0, None), "shape": ()}, + "flux": {"units": "flux", "valid": (0, None), "shape": (), "dynamic": True}, } usable = True @@ -57,7 +57,7 @@ def initialize(self): edge = np.concatenate((dat[:, 0], dat[:, -1], dat[0, :], dat[-1, :])) edge_average = np.median(edge) - self.flux.dynamic_value = np.abs(np.sum(dat - edge_average)) + self.flux.value = np.abs(np.sum(dat - edge_average)) # Psf convolution should be on by default since this is a delta function @property diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 37492422..3ba42dfc 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -24,7 +24,7 @@ class PSFModel(SampleMixin, Model): """ _parameter_specs = { - "center": {"units": "arcsec", "value": (0.0, 0.0), "shape": (2,)}, + "center": {"units": "arcsec", "value": (0.0, 0.0), "shape": (2,), "dynamic": False}, } _model_type = "psf" usable = False diff --git a/astrophot/models/sersic.py b/astrophot/models/sersic.py index 7bd30fd4..6d68f1a8 100644 --- a/astrophot/models/sersic.py +++ b/astrophot/models/sersic.py @@ -43,7 +43,7 @@ class TSersicGalaxy(TruncationMixin, SersicMixin, RadialMixin, GalaxyModel): @combine_docstrings class SersicPSF(SersicMixin, RadialMixin, PSFModel): - _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0}} + _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} usable = True @forward diff --git a/astrophot/models/sky_model_object.py b/astrophot/models/sky_model_object.py index f684768b..a46a28c6 100644 --- a/astrophot/models/sky_model_object.py +++ b/astrophot/models/sky_model_object.py @@ -24,7 +24,7 @@ def initialize(self): """ if not self.center.initialized: target_area = self.target[self.window] - self.center.value = target_area.center + self.center.static_value(target_area.center) super().initialize() self.center.to_static() diff --git a/astrophot/param/param.py b/astrophot/param/param.py index 2a5f746a..b36201d5 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -46,13 +46,6 @@ def initialized(self): return True return False - def is_valid(self, value): - if self.valid[0] is not None and backend.any(value <= self.valid[0]): - return False - if self.valid[1] is not None and backend.any(value >= self.valid[1]): - return False - return True - def soft_valid(self, value): if self.valid[0] is None and self.valid[1] is None: return value diff --git a/docs/source/tutorials/CustomModels.ipynb b/docs/source/tutorials/CustomModels.ipynb index 760f3fb0..9ff0dfd6 100644 --- a/docs/source/tutorials/CustomModels.ipynb +++ b/docs/source/tutorials/CustomModels.ipynb @@ -87,9 +87,9 @@ " # this case a scalar. This isn't necessary but it gives AstroPhot more\n", " # information to work with. if e.g. you accidentaly provide multiple\n", " # values, you'll now get an error rather than confusing behavior later.\n", - " \"my_n\": {\"valid\": (0.36, 8), \"shape\": ()},\n", - " \"my_Re\": {\"units\": \"arcsec\", \"valid\": (0, None), \"shape\": ()},\n", - " \"my_Ie\": {\"units\": \"flux/arcsec^2\"},\n", + " \"my_n\": {\"valid\": (0.36, 8), \"shape\": (), \"dynamic\": True},\n", + " \"my_Re\": {\"units\": \"arcsec\", \"valid\": (0, None), \"shape\": (), \"dynamic\": True},\n", + " \"my_Ie\": {\"units\": \"flux/arcsec^2\", \"dynamic\": True},\n", " }\n", "\n", " # a GalaxyModel object will determine the radius for each pixel then call radial_model to determine the brightness\n", @@ -226,17 +226,17 @@ " # only initialize if the user didn't already provide a value\n", " if not self.my_n.initialized:\n", " # make an initial value for my_n. It's a \"dynamic_value\" so it can be optimized later\n", - " self.my_n.dynamic_value = 2.0\n", + " self.my_n.value = 2.0\n", "\n", " if not self.my_Re.initialized:\n", - " self.my_Re.dynamic_value = 20.0\n", + " self.my_Re.value = 20.0\n", "\n", " # lets try to be a bit clever here. This will be an average in the\n", " # window, should at least get us within an order of magnitude\n", " if not self.my_Ie.initialized:\n", " center = target_area.plane_to_pixel(*self.center.value)\n", " i, j = int(center[0].item()), int(center[1].item())\n", - " self.my_Ie.dynamic_value = (\n", + " self.my_Ie.value = (\n", " torch.median(target_area.data[i - 100 : i + 100, j - 100 : j + 100])\n", " / target_area.pixel_area\n", " )" diff --git a/docs/source/tutorials/ImageAlignment.ipynb b/docs/source/tutorials/ImageAlignment.ipynb index d30f326e..84aa9f12 100644 --- a/docs/source/tutorials/ImageAlignment.ipynb +++ b/docs/source/tutorials/ImageAlignment.ipynb @@ -238,7 +238,7 @@ "outputs": [], "source": [ "# this will control the relative rotation of the g-band image\n", - "phi = ap.Param(name=\"phi\", dynamic_value=0.0, dtype=torch.float64)\n", + "phi = ap.Param(name=\"phi\", value=0.0, dynamic=True, dtype=torch.float64)\n", "\n", "# Set the target_g CD matrix to be a function of the rotation angle\n", "# The CD matrix can encode rotation, skew, and rectangular pixels. We\n", diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index fef82261..0dbaec62 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -927,8 +927,8 @@ " center=[50, 50],\n", " q=0.6,\n", " PA=60 * np.pi / 180,\n", - " q_R={\"dynamic_value\": warp_q, \"prof\": prof},\n", - " PA_R={\"dynamic_value\": warp_pa, \"prof\": prof},\n", + " q_R={\"value\": warp_q, \"dynamic\": True, \"prof\": prof},\n", + " PA_R={\"value\": warp_pa, \"dynamic\": True, \"prof\": prof},\n", " n=3,\n", " Re=10,\n", " Ie=1,\n", diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index 34602be1..cb29c8a9 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -46,7 +46,7 @@ def test_all_psfmodel_sample(model_type): if model_type == "pixelated psf model": psf = ap.utils.initialize.gaussian_psf(3 * 0.8, 25, 0.8) - MODEL.pixels.dynamic_value = psf / np.sum(psf) + MODEL.pixels.value = psf / np.sum(psf) assert ap.backend.all( ap.backend.isfinite(MODEL.jacobian().data) From e7dfd4040e8acf1e26b5c0942c114410b51b4035 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 2 Dec 2025 21:25:41 -0500 Subject: [PATCH 178/191] fix is valid test --- tests/test_param.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_param.py b/tests/test_param.py index 96637be6..d0fe4c25 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -9,7 +9,6 @@ def test_param(): a = Param("a", value=1.0, uncertainty=0.1, valid=(0, 2), prof=1.0) - assert a.is_valid(1.5), "value should be valid" assert isinstance(a.uncertainty, ap.backend.array_type), "uncertainty should be a tensor" assert isinstance(a.prof, ap.backend.array_type), "prof should be a tensor" assert a.initialized, "parameter should be marked as initialized" @@ -22,8 +21,6 @@ def test_param(): ), "soft valid should push values inside the limits" b = Param("b", value=[2.0, 3.0], uncertainty=[0.1, 0.1], valid=(1, None)) - assert not b.is_valid(0.5), "value should not be valid" - assert b.is_valid(10.5), "value should be valid" assert ap.backend.all( b.soft_valid(-1 * ap.backend.ones_like(b.value)) > b.valid[0] ), "soft valid should push values inside the limits" @@ -32,7 +29,6 @@ def test_param(): c = Param("c", value=lambda P: P.a.value, valid=(None, 4.0)) c.link(a) assert c.initialized, "pointer should be marked as initialized" - assert c.is_valid(0.5), "value should be valid" assert c.uncertainty is None From f088b30a045ecec73dc906e0ff1fd0e941dd5848 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 4 Dec 2025 14:49:19 -0500 Subject: [PATCH 179/191] handle new caskade static none capability --- astrophot/models/mixins/king.py | 5 +---- astrophot/models/sky_model_object.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index 8964dc74..eea81306 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -52,9 +52,6 @@ class KingMixin: def initialize(self): super().initialize() - if not self.alpha.initialized: - self.alpha.value = 2.0 - parametric_initialize( self, self.target[self.window], @@ -107,7 +104,7 @@ def initialize(self): super().initialize() if not self.alpha.initialized: - self.alpha.static_value(2.0 * np.ones(self.segments)) + self.alpha.value = 2.0 * np.ones(self.segments) parametric_segment_initialize( model=self, diff --git a/astrophot/models/sky_model_object.py b/astrophot/models/sky_model_object.py index a46a28c6..6d9f5cca 100644 --- a/astrophot/models/sky_model_object.py +++ b/astrophot/models/sky_model_object.py @@ -24,7 +24,7 @@ def initialize(self): """ if not self.center.initialized: target_area = self.target[self.window] - self.center.static_value(target_area.center) + self.center.to_static(target_area.center) super().initialize() self.center.to_static() From 5c75c8fb162559c2d71a999eec7c332d75c5a1a3 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Thu, 4 Dec 2025 16:25:23 -0500 Subject: [PATCH 180/191] ensure dynamic for some params --- tests/test_psfmodel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index cb29c8a9..586672ed 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -17,11 +17,11 @@ def test_all_psfmodel_sample(model_type): ) if "nuker" in model_type: - kwargs = {"Ib": None} + kwargs = {"Ib": {"value": None, "dynamic": True}} elif "gaussian" in model_type: - kwargs = {"flux": None} + kwargs = {"flux": {"value": None, "dynamic": True}} elif "exponential" in model_type: - kwargs = {"Ie": None} + kwargs = {"Ie": {"value": None, "dynamic": True}} else: kwargs = {} target = make_basic_gaussian_psf(pixelscale=0.8) From 551b2ad4340d0e6eac5d855c2a666da1ef4a5de3 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Fri, 5 Dec 2025 16:37:36 -0500 Subject: [PATCH 181/191] updates for caskade v0140 --- astrophot/fit/base.py | 2 +- astrophot/fit/gradient.py | 6 ++---- astrophot/fit/hmc.py | 4 ++-- astrophot/fit/iterative.py | 12 ++++++------ astrophot/fit/lm.py | 6 +++--- astrophot/fit/mala.py | 2 +- astrophot/fit/mhmcmc.py | 2 +- astrophot/fit/scipy_fit.py | 2 +- astrophot/models/base.py | 4 ++-- astrophot/models/group_model_object.py | 2 +- astrophot/models/mixins/sample.py | 2 +- astrophot/param/module.py | 6 +++--- astrophot/param/param.py | 11 +++++++++++ docs/source/tutorials/AdvancedPSFModels.ipynb | 2 +- docs/source/tutorials/FittingMethods.ipynb | 4 ++-- docs/source/tutorials/GettingStarted.ipynb | 2 +- docs/source/tutorials/GettingStartedJAX.ipynb | 2 +- docs/source/tutorials/GroupModels.ipynb | 2 +- docs/source/tutorials/PoissonLikelihood.ipynb | 4 ++-- tests/test_fit.py | 2 +- tests/test_param.py | 4 ++-- 21 files changed, 46 insertions(+), 37 deletions(-) diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index 4b161064..b9152f9f 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -45,7 +45,7 @@ def __init__( self.verbose = verbose if initial_state is None: - self.current_state = model.build_params_array() + self.current_state = model.get_values() else: self.current_state = backend.as_array( initial_state, dtype=config.DTYPE, device=config.DEVICE diff --git a/astrophot/fit/gradient.py b/astrophot/fit/gradient.py index 996e3ad8..11ae29a3 100644 --- a/astrophot/fit/gradient.py +++ b/astrophot/fit/gradient.py @@ -136,9 +136,7 @@ def fit(self) -> BaseOptimizer: self.message = self.message + " fail interrupted" # Set the model parameters to the best values from the fit and clear any previous model sampling - self.model.fill_dynamic_values( - torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE) - ) + self.model.set_values(torch.tensor(self.res(), dtype=config.DTYPE, device=config.DEVICE)) if self.verbose > 1: config.logger.info( f"Grad Fitting complete in {time() - start_fit} sec with message: {self.message}" @@ -260,7 +258,7 @@ def fit(self) -> BaseOptimizer: self.message = self.message + " fail. max iteration reached" # Set the model parameters to the best values from the fit - self.model.fill_dynamic_values( + self.model.set_values( backend.as_array(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if self.verbose > 0: diff --git a/astrophot/fit/hmc.py b/astrophot/fit/hmc.py index 106e657e..f726f8ee 100644 --- a/astrophot/fit/hmc.py +++ b/astrophot/fit/hmc.py @@ -158,7 +158,7 @@ def step(model, prior): hmc_kernel.mass_matrix_adapter.inverse_mass_matrix = {("x",): self.inv_mass} # Provide an initial guess for the parameters - init_params = {"x": self.model.build_params_array()} + init_params = {"x": self.model.get_values()} # Run MCMC with the HMC sampler and the initial guess mcmc_kwargs = { @@ -177,7 +177,7 @@ def step(model, prior): chain = mcmc.get_samples()["x"] self.chain = chain - self.model.fill_dynamic_values( + self.model.set_values( torch.as_tensor(self.chain[-1], dtype=config.DTYPE, device=config.DEVICE) ) return self diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index c3821884..2e9330ca 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -51,7 +51,7 @@ def __init__( ): super().__init__(model, initial_state, max_iter=max_iter, **kwargs) - self.current_state = model.build_params_array() + self.current_state = model.get_values() self.lm_kwargs = lm_kwargs if "relative_tolerance" not in lm_kwargs: # Lower tolerance since it's not worth fine tuning a model when its neighbors will be shifting soon anyway @@ -90,7 +90,7 @@ def step(self): config.logger.info(model.name) self.sub_step(model) # Update the current state - self.current_state = self.model.build_params_array() + self.current_state = self.model.get_values() # Update the loss value with torch.no_grad(): @@ -138,7 +138,7 @@ def fit(self) -> BaseOptimizer: except KeyboardInterrupt: self.message = self.message + "fail interrupted" - self.model.fill_dynamic_values( + self.model.set_values( backend.as_array(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if self.verbose > 1: @@ -401,7 +401,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: f"Final {quantity}: {np.nanmin(self.loss_history):.6g}, L: {self.L_history[np.nanargmin(self.loss_history)]:.3g}. Converged: {self.message}" ) - self.model.fill_dynamic_values( + self.model.set_values( backend.as_array(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if update_uncertainty: @@ -487,8 +487,8 @@ def update_uncertainty(self) -> None: cov = self.covariance_matrix if backend.all(backend.isfinite(cov)): try: - self.model.fill_dynamic_value_uncertainties( - backend.sqrt(backend.abs(backend.diag(cov))) + self.model.set_values( + backend.sqrt(backend.abs(backend.diag(cov))), attribute="uncertainty" ) except RuntimeError as e: config.logger.warning(f"Unable to update uncertainty due to: {e}") diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index 803a5f3e..a6aeb6ec 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -290,7 +290,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: f"Final {quantity}: {np.nanmin(self.loss_history):.6g}, L: {self.L_history[np.nanargmin(self.loss_history)]:.3g}. Converged: {self.message}" ) - self.model.fill_dynamic_values( + self.model.set_values( backend.as_array(self.res(), dtype=config.DTYPE, device=config.DEVICE) ) if update_uncertainty: @@ -363,8 +363,8 @@ def update_uncertainty(self) -> None: cov = self.covariance_matrix if backend.all(backend.isfinite(cov)): try: - self.model.fill_dynamic_value_uncertainties( - backend.sqrt(backend.abs(backend.diag(cov))) + self.model.set_values( + backend.sqrt(backend.abs(backend.diag(cov))), attribute="uncertainty" ) except RuntimeError as e: config.logger.warning(f"Unable to update uncertainty due to: {e}") diff --git a/astrophot/fit/mala.py b/astrophot/fit/mala.py index 069cb701..fe2b7cce 100644 --- a/astrophot/fit/mala.py +++ b/astrophot/fit/mala.py @@ -116,7 +116,7 @@ def fit(self): # Fill model with max logp sample max_logp_index = np.argmax(self.logp) max_logp_index = np.unravel_index(max_logp_index, self.logp.shape) - self.model.fill_dynamic_values( + self.model.set_values( backend.as_array(self.chain[max_logp_index], dtype=config.DTYPE, device=config.DEVICE) ) diff --git a/astrophot/fit/mhmcmc.py b/astrophot/fit/mhmcmc.py index 74922eb2..0ef9506b 100644 --- a/astrophot/fit/mhmcmc.py +++ b/astrophot/fit/mhmcmc.py @@ -96,7 +96,7 @@ def fit( self.chain = sampler.get_chain(flat=flat_chain) else: self.chain = np.append(self.chain, sampler.get_chain(flat=flat_chain), axis=0) - self.model.fill_dynamic_values( + self.model.set_values( backend.as_array(self.chain[-1], dtype=config.DTYPE, device=config.DEVICE) ) return self diff --git a/astrophot/fit/scipy_fit.py b/astrophot/fit/scipy_fit.py index 6673fcee..41031631 100644 --- a/astrophot/fit/scipy_fit.py +++ b/astrophot/fit/scipy_fit.py @@ -104,6 +104,6 @@ def fit(self): config.logger.info( f"Final 2NLL/DoF: {2*self.density(res.x)/self.ndf:.6g}. Converged: {self.message}" ) - self.model.fill_dynamic_values(self.current_state) + self.model.set_values(self.current_state) return self diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 9d88d6ca..04a3b99e 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -173,9 +173,9 @@ def poisson_log_likelihood( def hessian(self, likelihood="gaussian"): if likelihood == "gaussian": - return backend.hessian(self.gaussian_log_likelihood)(self.build_params_array()) + return backend.hessian(self.gaussian_log_likelihood)(self.get_values()) elif likelihood == "poisson": - return backend.hessian(self.poisson_log_likelihood)(self.build_params_array()) + return backend.hessian(self.poisson_log_likelihood)(self.get_values()) else: raise ValueError(f"Unknown likelihood type: {likelihood}") diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 66dcc21e..9a85ed38 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -253,7 +253,7 @@ def jacobian( window = self.window if params is not None: - self.fill_dynamic_values(params) + self.set_values(params) if pass_jacobian is None: jac_img = self.target[window].jacobian_image( diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index 1b5e1b14..c33e9dcf 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -182,7 +182,7 @@ def jacobian( # No dynamic params if params is None: - params = self.build_params_array() + params = self.get_values() if params.shape[-1] == 0: return jac_img diff --git a/astrophot/param/module.py b/astrophot/param/module.py index 76457225..a29e0337 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -4,7 +4,7 @@ Module as CModule, ActiveStateError, ParamConfigurationError, - FillDynamicParamsArrayError, + FillParamsArrayError, ) from ..backend_obj import backend @@ -68,11 +68,11 @@ def fill_dynamic_value_uncertainties(self, uncertainty): val = uncertainty[..., pos : pos + size].reshape(param.shape) param.uncertainty = val except (RuntimeError, IndexError, ValueError, TypeError): - raise FillDynamicParamsArrayError(self.name, uncertainty, dynamic_params) + raise FillParamsArrayError(self.name, uncertainty, dynamic_params) pos += size if pos != uncertainty.shape[-1]: - raise FillDynamicParamsArrayError(self.name, uncertainty, dynamic_params) + raise FillParamsArrayError(self.name, uncertainty, dynamic_params) def dynamic_params_array_index(self, param): i = 0 diff --git a/astrophot/param/param.py b/astrophot/param/param.py index b36201d5..4df33cdf 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -1,3 +1,6 @@ +from math import prod +import numpy as np + from caskade import Param as CParam from ..backend_obj import backend @@ -37,6 +40,14 @@ def prof(self, prof): else: self._prof = backend.as_array(prof) + @property + def name_array(self): + numel = max(1, prod(self.shape)) + if numel == 1: + return np.array(self.name) + names = [f"{self.name}_{i}" for i in range(numel)] + return np.array(names).reshape(self.shape) + @property def initialized(self): """Check if the parameter is initialized.""" diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index f594a818..287b7f7d 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -309,7 +309,7 @@ ")\n", "fig, ax = ap.plots.covariance_matrix(\n", " result.covariance_matrix.detach().cpu().numpy(),\n", - " live_galaxy_model.build_params_array().detach().cpu().numpy(),\n", + " live_galaxy_model.get_values().detach().cpu().numpy(),\n", " live_galaxy_model.build_params_array_names(),\n", ")\n", "plt.show()" diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index ec004048..7795fa18 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -307,7 +307,7 @@ "set, sky = true_params()\n", "fig, ax = ap.plots.covariance_matrix(\n", " res_lm.covariance_matrix.detach().cpu().numpy(),\n", - " MODEL.build_params_array().detach().cpu().numpy(),\n", + " MODEL.get_values().detach().cpu().numpy(),\n", " labels=param_names,\n", " figsize=(20, 20),\n", " reference_values=np.concatenate((sky, set.ravel())),\n", @@ -427,7 +427,7 @@ "set, sky = true_params()\n", "fig, ax = ap.plots.covariance_matrix(\n", " res_iterparam.covariance_matrix.detach().cpu().numpy(),\n", - " MODEL.build_params_array().detach().cpu().numpy(),\n", + " MODEL.get_values().detach().cpu().numpy(),\n", " labels=param_names,\n", " figsize=(20, 20),\n", " reference_values=np.concatenate((sky, set.ravel())),\n", diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index fdfb73e3..c1e680ac 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -227,7 +227,7 @@ "# can still see how the covariance of the parameters plays out in a given fit.\n", "fig, ax = ap.plots.covariance_matrix(\n", " result.covariance_matrix.detach().cpu().numpy(),\n", - " model2.build_params_array().detach().cpu().numpy(),\n", + " model2.get_values().detach().cpu().numpy(),\n", " model2.build_params_array_names(),\n", ")\n", "plt.show()" diff --git a/docs/source/tutorials/GettingStartedJAX.ipynb b/docs/source/tutorials/GettingStartedJAX.ipynb index c256ed2f..717cabcd 100644 --- a/docs/source/tutorials/GettingStartedJAX.ipynb +++ b/docs/source/tutorials/GettingStartedJAX.ipynb @@ -248,7 +248,7 @@ "# can still see how the covariance of the parameters plays out in a given fit.\n", "fig, ax = ap.plots.covariance_matrix(\n", " result.covariance_matrix,\n", - " model2.build_params_array(),\n", + " model2.get_values(),\n", " model2.build_params_array_names(),\n", ")\n", "plt.show()" diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index f442b811..d43feb28 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -153,7 +153,7 @@ "source": [ "import torch\n", "\n", - "x = groupmodel.build_params_array()\n", + "x = groupmodel.get_values()\n", "x = x.repeat(5, 1)\n", "imgs = torch.vmap(lambda x: groupmodel(x).data)(x)\n", "print(imgs.shape)" diff --git a/docs/source/tutorials/PoissonLikelihood.ipynb b/docs/source/tutorials/PoissonLikelihood.ipynb index a0dec516..d271b9d5 100644 --- a/docs/source/tutorials/PoissonLikelihood.ipynb +++ b/docs/source/tutorials/PoissonLikelihood.ipynb @@ -55,7 +55,7 @@ "img = true_model().data.detach().cpu().numpy()\n", "np.random.seed(42) # for reproducibility\n", "target.data = np.random.poisson(img) # sample poisson distribution\n", - "true_params = true_model.build_params_array()\n", + "true_params = true_model.get_values()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(10, 5))\n", "ap.plots.model_image(fig, ax[0], true_model)\n", @@ -128,7 +128,7 @@ "source": [ "fig, ax = ap.plots.covariance_matrix(\n", " res.covariance_matrix.detach().cpu().numpy(),\n", - " model.build_params_array().detach().cpu().numpy(),\n", + " model.get_values().detach().cpu().numpy(),\n", " reference_values=true_params.detach().cpu().numpy(),\n", ")\n", "plt.show()" diff --git a/tests/test_fit.py b/tests/test_fit.py index f5a1fbf9..bfb2ad13 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -184,7 +184,7 @@ def test_gradient(sersic_model): target = model.target target.weight = 1 / (10 + target.variance) model.initialize() - x = model.build_params_array() + x = model.get_values() grad = model.gradient() assert ap.backend.all(ap.backend.isfinite(grad)), "Gradient should be finite" assert grad.shape == x.shape, "Gradient shape should match parameters shape" diff --git a/tests/test_param.py b/tests/test_param.py index d0fe4c25..7740dc1b 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -40,10 +40,10 @@ def test_module(): model = ap.Model(name="test", model_type="group model", target=target, models=[model1, model2]) model.initialize() - U = ap.backend.ones_like(model.build_params_array()) * 0.1 + U = ap.backend.ones_like(model.get_values()) * 0.1 model.fill_dynamic_value_uncertainties(U) - paramsu = model.build_params_array_uncertainty() + paramsu = model.get_values(attribute="uncertainty") assert ap.backend.all(ap.backend.isfinite(paramsu)), "All parameters should be finite" paramsn = model.build_params_array_names() From 91233bce0be68763baf5ef098bdbf6f0a1ef18ba Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 9 Dec 2025 09:48:14 -0500 Subject: [PATCH 182/191] slight change to fourier ellipse model to align with previous iteration --- astrophot/models/mixins/transform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 278fc90d..7d335cc5 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -169,7 +169,7 @@ class FourierEllipseMixin: _model_type = "fourier" _parameter_specs = { "am": {"units": "none", "dynamic": True}, - "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "dynamic": True}, + "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "dynamic": False}, } _options = ("modes",) From 63704d485e3cae0ccb6345a1b4f1589ebee964f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:50:36 -0500 Subject: [PATCH 183/191] build(deps): bump actions/checkout from 5 to 6 (#285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
Release notes

Sourced from actions/checkout's releases.

v6.0.0

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0

v6-beta

What's Changed

Updated persist-credentials to store the credentials under $RUNNER_TEMP instead of directly in the local git config.

This requires a minimum Actions Runner version of v2.329.0 to access the persisted credentials for Docker container action scenarios.

v5.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1

Changelog

Sourced from actions/checkout's changelog.

Changelog

V6.0.0

V5.0.1

V5.0.0

V4.3.1

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=5&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Connor Stone, PhD --- .github/workflows/cd.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 78bd6f48..d70914a0 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 From c6d18ccb0f17925ea61a9d2b2d5e2aec2f520a84 Mon Sep 17 00:00:00 2001 From: "Connor Stone, PhD" Date: Tue, 3 Feb 2026 15:17:36 -0500 Subject: [PATCH 184/191] update to next caskade version (#288) --- .readthedocs.yaml | 3 +- astrophot/__init__.py | 109 +----------------- astrophot/fit/hmc.py | 3 +- astrophot/models/base.py | 1 - astrophot/models/mixins/ferrer.py | 8 +- astrophot/models/model_object.py | 1 + docs/source/tutorials/AdvancedPSFModels.ipynb | 12 +- docs/source/tutorials/ConstrainedModels.ipynb | 4 +- docs/source/tutorials/CustomModels.ipynb | 4 +- docs/source/tutorials/FittingMethods.ipynb | 2 +- docs/source/tutorials/GettingStarted.ipynb | 4 +- docs/source/tutorials/GettingStartedJAX.ipynb | 4 +- docs/source/tutorials/GroupModels.ipynb | 4 +- docs/source/tutorials/ImageAlignment.ipynb | 16 +-- docs/source/tutorials/JointModels.ipynb | 18 +-- pyproject.toml | 39 +++++-- requirements.txt | 11 -- tests/conftest.py | 46 ++++++++ tests/test_cmos_image.py | 2 +- tests/test_fit.py | 6 +- tests/test_group_models.py | 14 +-- tests/test_model.py | 12 +- tests/test_param.py | 4 +- tests/test_plots.py | 10 +- tests/test_psfmodel.py | 2 +- tests/utils.py | 4 +- 26 files changed, 145 insertions(+), 198 deletions(-) delete mode 100644 requirements.txt diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 3989c638..9ff01961 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -37,7 +37,6 @@ build: python: install: - - requirements: requirements.txt # Path to your requirements.txt file - requirements: docs/requirements.txt # Path to your requirements.txt file - method: pip - path: . # Install the package itself + path: .[dev] # Install the package itself diff --git a/astrophot/__init__.py b/astrophot/__init__.py index f863ad11..7fc1e3e2 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -1,6 +1,3 @@ -import argparse -import requests -import torch from . import config, models, plots, utils, fit, image, errors from .param import forward, Param, Module @@ -38,110 +35,6 @@ __author__ = "Connor Stone" __email__ = "connorstone628@gmail.com" - -def run_from_terminal() -> None: - """ - Running from terminal no longer supported. This is only used for convenience to download the tutorials. - - """ - config.logger.debug("running from the terminal, not sure if it will catch me.") - parser = argparse.ArgumentParser( - prog="astrophot", - description="Fast and flexible astronomical image photometry package. For the documentation go to: https://astrophot.readthedocs.io", - epilog="Please see the documentation or contact connor stone (connorstone628@gmail.com) for further assistance.", - ) - parser.add_argument( - "filename", - nargs="?", - metavar="configfile", - help="the path to the configuration file. Or just 'tutorial' to download tutorials.", - ) - # parser.add_argument( - # "--config", - # type=str, - # default="astrophot", - # choices=["astrophot", "galfit"], - # metavar="format", - # help="The type of configuration file being being provided. One of: astrophot, galfit.", - # ) - parser.add_argument( - "-v", - "--version", - action="version", - version=f"%(prog)s {__version__}", - help="print the current AstroPhot version to screen", - ) - # parser.add_argument( - # "--log", - # type=str, - # metavar="logfile.log", - # help="set the log file name for AstroPhot. use 'none' to suppress the log file.", - # ) - # parser.add_argument( - # "-q", - # action="store_true", - # help="quiet flag to stop command line output, only print to log file", - # ) - # parser.add_argument( - # "--dtype", - # type=str, - # choices=["float64", "float32"], - # metavar="datatype", - # help="set the float point precision. Must be one of: float64, float32", - # ) - # parser.add_argument( - # "--device", - # type=str, - # choices=["cpu", "gpu"], - # metavar="device", - # help="set the device for AstroPhot to use for computations. Must be one of: cpu, gpu", - # ) - - args = parser.parse_args() - - if args.log is not None: - config.set_logging_output( - stdout=not args.q, filename=None if args.log == "none" else args.log - ) - elif args.q: - config.set_logging_output(stdout=not args.q, filename="AstroPhot.log") - - if args.dtype is not None: - config.DTYPE = torch.float64 if args.dtype == "float64" else torch.float32 - if args.device is not None: - config.DEVICE = "cpu" if args.device == "cpu" else "cuda:0" - - if args.filename is None: - raise RuntimeError( - "Please pass a config file to astrophot. See 'astrophot --help' for more information, or go to https://astrophot.readthedocs.io" - ) - if args.filename in ["tutorial", "tutorials"]: - tutorials = [ - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/GettingStarted.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/GroupModels.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/ModelZoo.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/JointModels.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/FittingMethods.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/CustomModels.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/BasicPSFModels.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/AdvancedPSFModels.ipynb", - "https://raw.github.com/Autostronomy/AstroPhot/main/docs/source/tutorials/ConstrainedModels.ipynb", - ] - for url in tutorials: - try: - R = requests.get(url) - with open(url[url.rfind("/") + 1 :], "w") as f: - f.write(R.text) - except: - print( - f"WARNING: couldn't find tutorial: {url[url.rfind('/')+1:]} check internet connection" - ) - - config.logger.info("collected the tutorials") - else: - raise ValueError(f"Unrecognized request") - - __all__ = ( "models", "image", @@ -170,7 +63,7 @@ def run_from_terminal() -> None: "Module", "config", "backend", - "run_from_terminal", + "ArrayLike", "__version__", "__author__", "__email__", diff --git a/astrophot/fit/hmc.py b/astrophot/fit/hmc.py index f726f8ee..bdb54fb3 100644 --- a/astrophot/fit/hmc.py +++ b/astrophot/fit/hmc.py @@ -52,7 +52,8 @@ def new_configure(self, mass_matrix_shape, adapt_mass_matrix=True, options={}): self.inverse_mass_matrix = inverse_mass_matrix -BlockMassMatrix.configure = new_configure +if pyro is not None: + BlockMassMatrix.configure = new_configure ############################################ diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 04a3b99e..06e166d5 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -63,7 +63,6 @@ def __init__(self, *, name=None, target=None, window=None, mask=None, filename=N setattr(self, key, param) self.saveattrs.update(self.options) - self.saveattrs.add("window.extent") kwargs.pop("model_type", None) # model_type is set by __new__ if len(kwargs) > 0: diff --git a/astrophot/models/mixins/ferrer.py b/astrophot/models/mixins/ferrer.py index 47cb87f3..ff18208a 100644 --- a/astrophot/models/mixins/ferrer.py +++ b/astrophot/models/mixins/ferrer.py @@ -85,10 +85,10 @@ class iFerrerMixin: _model_type = "ferrer" _parameter_specs = { - "rout": {"units": "arcsec", "valid": (0.0, None), "shape": (), "dynamic": True}, - "alpha": {"units": "unitless", "valid": (0, 10), "shape": (), "dynamic": True}, - "beta": {"units": "unitless", "valid": (0, 2), "shape": (), "dynamic": True}, - "I0": {"units": "flux/arcsec^2", "valid": (0.0, None), "shape": (), "dynamic": True}, + "rout": {"units": "arcsec", "valid": (0.0, None), "dynamic": True}, + "alpha": {"units": "unitless", "valid": (0, 10), "dynamic": True}, + "beta": {"units": "unitless", "valid": (0, 2), "dynamic": True}, + "I0": {"units": "flux/arcsec^2", "valid": (0.0, None), "dynamic": True}, } @torch.no_grad() diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 8c17a94b..8e6af051 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -46,6 +46,7 @@ def __init__(self, *args, psf=None, psf_convolve: bool = False, **kwargs): super().__init__(*args, **kwargs) self.psf = psf self.psf_convolve = psf_convolve + self.saveattrs.add("window.extent") @property def psf(self): diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index 287b7f7d..c1cddabd 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -71,7 +71,7 @@ "source": [ "# Now we initialize on the image\n", "psf_model = ap.Model(\n", - " name=\"init psf\",\n", + " name=\"init_psf\",\n", " model_type=\"moffat psf model\",\n", " target=psf_target,\n", ")\n", @@ -134,7 +134,7 @@ " target=psf_target,\n", ")\n", "psf_group_model = ap.Model(\n", - " name=\"psf group\",\n", + " name=\"psf_group\",\n", " model_type=\"psf group model\",\n", " target=psf_target,\n", " models=[psf_model1, psf_model2],\n", @@ -175,7 +175,7 @@ ")\n", "\n", "true_psf_model = ap.Model(\n", - " name=\"true psf\",\n", + " name=\"true_psf\",\n", " model_type=\"moffat psf model\",\n", " target=psf_target,\n", " n=2,\n", @@ -190,7 +190,7 @@ ")\n", "\n", "true_model = ap.Model(\n", - " name=\"true model\",\n", + " name=\"true_model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", " center=[50.0, 50.0],\n", @@ -227,7 +227,7 @@ "\n", "# Here we set up a sersic model for the galaxy\n", "plain_galaxy_model = ap.Model(\n", - " name=\"galaxy model\",\n", + " name=\"galaxy_model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", ")\n", @@ -283,7 +283,7 @@ "\n", "# Here we set up a sersic model for the galaxy\n", "live_galaxy_model = ap.Model(\n", - " name=\"galaxy model\",\n", + " name=\"galaxy_model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", " psf_convolve=True,\n", diff --git a/docs/source/tutorials/ConstrainedModels.ipynb b/docs/source/tutorials/ConstrainedModels.ipynb index 599df83e..812cb1f6 100644 --- a/docs/source/tutorials/ConstrainedModels.ipynb +++ b/docs/source/tutorials/ConstrainedModels.ipynb @@ -195,7 +195,7 @@ " psf.Rd = allstars[0].psf.Rd\n", " allstars.append(\n", " ap.Model(\n", - " name=f\"star {x} {y}\",\n", + " name=f\"star_{x}_{y}\".replace(\"-\", \"n\"),\n", " model_type=\"point model\",\n", " center=[x, y],\n", " flux=1,\n", @@ -211,7 +211,7 @@ "# A group model holds all the stars together\n", "sky = ap.Model(name=\"sky\", model_type=\"flat sky model\", I=1e-5, target=target)\n", "MODEL = ap.Model(\n", - " name=\"spatial PSF\",\n", + " name=\"spatial_PSF\",\n", " model_type=\"group model\",\n", " models=[sky] + allstars,\n", " target=target,\n", diff --git a/docs/source/tutorials/CustomModels.ipynb b/docs/source/tutorials/CustomModels.ipynb index 9ff0dfd6..154046db 100644 --- a/docs/source/tutorials/CustomModels.ipynb +++ b/docs/source/tutorials/CustomModels.ipynb @@ -131,7 +131,7 @@ "outputs": [], "source": [ "my_model = My_Sersic( # notice we are now using the custom class\n", - " name=\"wow I made a model\",\n", + " name=\"wow_I_made_a_model\",\n", " target=target, # now the model knows what its trying to match\n", " # note we have to give initial values for our new parameters. AstroPhot doesn't know how to auto-initialize them because they are custom\n", " my_n=1.0,\n", @@ -249,7 +249,7 @@ "outputs": [], "source": [ "my_super_model = ap.Model(\n", - " name=\"goodness I made another one\",\n", + " name=\"goodness_I_made_another_one\",\n", " model_type=\"super mysersic galaxy model\", # this is the type we defined above\n", " target=target,\n", ")\n", diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index 7795fa18..797d823d 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -108,7 +108,7 @@ " for i, params in enumerate(sersic_params):\n", " model_list.append(\n", " ap.Model(\n", - " name=f\"sersic {i}\",\n", + " name=f\"sersic_{i}\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", " center=[params[0], params[1]],\n", diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index c1e680ac..00c6de63 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -123,7 +123,7 @@ "source": [ "# This model now has a target that it will attempt to match\n", "model2 = ap.Model(\n", - " name=\"model with target\",\n", + " name=\"model_with_target\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", ")\n", @@ -330,7 +330,7 @@ "# here we make a sersic model that can only have q and n in a narrow range\n", "# Also, we give PA and initial value and lock that so it does not change during fitting\n", "constrained_param_model = ap.Model(\n", - " name=\"constrained parameters\",\n", + " name=\"constrained_parameters\",\n", " model_type=\"sersic galaxy model\",\n", " q={\"valid\": (0.4, 0.6)},\n", " n={\"valid\": (2, 3)},\n", diff --git a/docs/source/tutorials/GettingStartedJAX.ipynb b/docs/source/tutorials/GettingStartedJAX.ipynb index 717cabcd..f7f1c769 100644 --- a/docs/source/tutorials/GettingStartedJAX.ipynb +++ b/docs/source/tutorials/GettingStartedJAX.ipynb @@ -144,7 +144,7 @@ "source": [ "# This model now has a target that it will attempt to match\n", "model2 = ap.Model(\n", - " name=\"model with target\",\n", + " name=\"model_with_target\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", ")\n", @@ -351,7 +351,7 @@ "# here we make a sersic model that can only have q and n in a narrow range\n", "# Also, we give PA and initial value and lock that so it does not change during fitting\n", "constrained_param_model = ap.Model(\n", - " name=\"constrained parameters\",\n", + " name=\"constrained_parameters\",\n", " model_type=\"sersic galaxy model\",\n", " q={\"valid\": (0.4, 0.6)},\n", " n={\"valid\": (2, 3)},\n", diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index d43feb28..fb92ada5 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -121,7 +121,7 @@ "for win in windows:\n", " seg_models.append(\n", " ap.Model(\n", - " name=f\"object {win:02d}\",\n", + " name=f\"object_{win:02d}\",\n", " window=windows[win],\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", @@ -131,7 +131,7 @@ " )\n", " )\n", "sky = ap.Model(\n", - " name=f\"sky level\",\n", + " name=f\"sky_level\",\n", " model_type=\"flat sky model\",\n", " target=target,\n", " I={\"valid\": (0, None)},\n", diff --git a/docs/source/tutorials/ImageAlignment.ipynb b/docs/source/tutorials/ImageAlignment.ipynb index 84aa9f12..0f8c58ff 100644 --- a/docs/source/tutorials/ImageAlignment.ipynb +++ b/docs/source/tutorials/ImageAlignment.ipynb @@ -75,17 +75,17 @@ "# fmt: off\n", "# r-band model\n", "psfr = ap.Model(name=\"psfr\", model_type=\"moffat psf model\", n=2, Rd=1.0, target=target_r.psf_image(data=np.zeros((51, 51))))\n", - "star1r = ap.Model(name=\"star1-r\", model_type=\"point model\", window=[0, 60, 80, 135], center=[12, 9], psf=psfr, target=target_r)\n", - "star2r = ap.Model(name=\"star2-r\", model_type=\"point model\", window=[40, 90, 20, 70], center=[3, -7], psf=psfr, target=target_r)\n", - "star3r = ap.Model(name=\"star3-r\", model_type=\"point model\", window=[109, 150, 40, 90], center=[-15, -3], psf=psfr, target=target_r)\n", - "modelr = ap.Model(name=\"model-r\", model_type=\"group model\", models=[star1r, star2r, star3r], target=target_r)\n", + "star1r = ap.Model(name=\"star1_r\", model_type=\"point model\", window=[0, 60, 80, 135], center=[12, 9], psf=psfr, target=target_r)\n", + "star2r = ap.Model(name=\"star2_r\", model_type=\"point model\", window=[40, 90, 20, 70], center=[3, -7], psf=psfr, target=target_r)\n", + "star3r = ap.Model(name=\"star3_r\", model_type=\"point model\", window=[109, 150, 40, 90], center=[-15, -3], psf=psfr, target=target_r)\n", + "modelr = ap.Model(name=\"model_r\", model_type=\"group model\", models=[star1r, star2r, star3r], target=target_r)\n", "\n", "# g-band model\n", "psfg = ap.Model(name=\"psfg\", model_type=\"moffat psf model\", n=2, Rd=1.0, target=target_g.psf_image(data=np.zeros((51, 51))))\n", - "star1g = ap.Model(name=\"star1-g\", model_type=\"point model\", window=[0, 60, 80, 135], center=star1r.center, psf=psfg, target=target_g)\n", - "star2g = ap.Model(name=\"star2-g\", model_type=\"point model\", window=[40, 90, 20, 70], center=star2r.center, psf=psfg, target=target_g)\n", - "star3g = ap.Model(name=\"star3-g\", model_type=\"point model\", window=[109, 150, 40, 90], center=star3r.center, psf=psfg, target=target_g)\n", - "modelg = ap.Model(name=\"model-g\", model_type=\"group model\", models=[star1g, star2g, star3g], target=target_g)\n", + "star1g = ap.Model(name=\"star1_g\", model_type=\"point model\", window=[0, 60, 80, 135], center=star1r.center, psf=psfg, target=target_g)\n", + "star2g = ap.Model(name=\"star2_g\", model_type=\"point model\", window=[40, 90, 20, 70], center=star2r.center, psf=psfg, target=target_g)\n", + "star3g = ap.Model(name=\"star3_g\", model_type=\"point model\", window=[109, 150, 40, 90], center=star3r.center, psf=psfg, target=target_g)\n", + "modelg = ap.Model(name=\"model_g\", model_type=\"group model\", models=[star1g, star2g, star3g], target=target_g)\n", "\n", "# total model\n", "target_full = ap.TargetImageList([target_r, target_g])\n", diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index 8b1eee03..e211be9d 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -101,14 +101,14 @@ "# group models designed for each band individually, but that would be unnecessarily complex for a tutorial\n", "\n", "model_r = ap.Model(\n", - " name=\"rband model\",\n", + " name=\"rband_model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_r,\n", " psf_convolve=True,\n", ")\n", "\n", "model_W1 = ap.Model(\n", - " name=\"W1band model\",\n", + " name=\"W1band_model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", " center=[0, 0],\n", @@ -117,7 +117,7 @@ ")\n", "\n", "model_NUV = ap.Model(\n", - " name=\"NUVband model\",\n", + " name=\"NUVband_model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", " center=[0, 0],\n", @@ -143,7 +143,7 @@ "# We can now make the joint model object\n", "\n", "model_full = ap.Model(\n", - " name=\"LEDA 41136\",\n", + " name=\"LEDA41136\",\n", " model_type=\"group model\",\n", " models=[model_r, model_W1, model_NUV],\n", " target=target_full,\n", @@ -304,7 +304,7 @@ " sub_list = []\n", " sub_list.append(\n", " ap.Model(\n", - " name=f\"rband model {i}\",\n", + " name=f\"rband_model_{i}\",\n", " model_type=\"sersic galaxy model\", # we could use spline models for the r-band since it is well resolved\n", " target=target_r,\n", " window=rwindows[window],\n", @@ -316,7 +316,7 @@ " )\n", " sub_list.append(\n", " ap.Model(\n", - " name=f\"W1band model {i}\",\n", + " name=f\"W1band_model_{i}\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_W1,\n", " window=w1windows[window],\n", @@ -325,7 +325,7 @@ " )\n", " sub_list.append(\n", " ap.Model(\n", - " name=f\"NUVband model {i}\",\n", + " name=f\"NUVband_model_{i}\",\n", " model_type=\"sersic galaxy model\",\n", " target=target_NUV,\n", " window=nuvwindows[window],\n", @@ -341,7 +341,7 @@ " # Make the multiband model for this object\n", " model_list.append(\n", " ap.Model(\n", - " name=f\"model {i}\",\n", + " name=f\"model_{i}\",\n", " model_type=\"group model\",\n", " target=target_full,\n", " models=sub_list,\n", @@ -349,7 +349,7 @@ " )\n", "# Make the full model for this system of objects\n", "MODEL = ap.Model(\n", - " name=f\"full model\",\n", + " name=f\"full_model\",\n", " model_type=\"group model\",\n", " target=target_full,\n", " models=model_list,\n", diff --git a/pyproject.toml b/pyproject.toml index faaf81cf..a998410e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,6 @@ build-backend = "hatchling.build" [project] name = "astrophot" dynamic = [ - "dependencies", "version" ] authors = [ @@ -13,7 +12,7 @@ authors = [ ] description = "A fast, flexible, automated, and differentiable astronomical image 2D forward modelling tool for precise parallel multi-wavelength photometry." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {file = "LICENSE"} keywords = [ "astrophot", @@ -25,12 +24,22 @@ keywords = [ "pytorch" ] classifiers=[ - "Development Status :: 1 - Planning", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Programming Language :: Python :: 3" ] +dependencies=[ + "astropy>=5.3", + "caskade~=0.15.0", + "h5py>=3.8.0", + "matplotlib>=3.7", + "numpy>=1.24.0,<2.0.0", + "scipy>=1.10.0", + "torch>=2.0.0", + "tqdm>=4.65.0", +] [project.urls] Homepage = "https://autostronomy.github.io/AstroPhot/" @@ -39,13 +48,23 @@ Repository = "https://github.com/Autostronomy/AstroPhot" Issues = "https://github.com/Autostronomy/AstroPhot/issues" [project.optional-dependencies] -dev = ["pre-commit", "nbval", "nbconvert", "graphviz", "ipywidgets", "jupyter-book", "matplotlib", "photutils", "scikit-image", "caustics", "emcee", "corner", "jax<=0.7.0", "pyvo"] - -[project.scripts] -astrophot = "astrophot:run_from_terminal" - -[tool.hatch.metadata.hooks.requirements_txt] -files = ["requirements.txt"] +dev = [ + "pre-commit", + "nbval", + "nbconvert", + "graphviz", + "ipywidgets", + "jupyter-book<2.0", + "matplotlib", + "photutils", + "scikit-image", + "caustics", + "pyro-ppl>=1.8.0", + "emcee", + "corner", + "jax<=0.7.0", + "pyvo" +] [tool.hatch.version] source = "vcs" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1a4dfb24..00000000 --- a/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -astropy>=5.3 -caskade>=0.6.0 -h5py>=3.8.0 -matplotlib>=3.7 -numpy>=1.24.0,<2.0.0 -pyro-ppl>=1.8.0 -pyyaml>=6.0 -requests>=2.30.0 -scipy>=1.10.0 -torch>=2.0.0 -tqdm>=4.65.0 diff --git a/tests/conftest.py b/tests/conftest.py index 92081514..6690744f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ import matplotlib import matplotlib.pyplot as plt import pytest +import numpy as np +import astrophot as ap @pytest.fixture(autouse=True) @@ -13,3 +15,47 @@ def close_show(*args, **kwargs): # Also ensure we are in a non-GUI backend matplotlib.use("Agg") + + +@pytest.fixture() +def sersic(request): + + np.random.seed(request.param.get("seed", 12345)) + shape = request.param.get("shape", (52, 50)) + mask = request.param.get("mask", None) + if mask is None: + mask = np.zeros(shape, dtype=bool) + mask[0][0] = True + target = request.param.get("target", None) + pixelscale = 0.8 + if target is None: + target = ap.TargetImage( + data=np.zeros(shape), + pixelscale=pixelscale, + psf=ap.utils.initialize.gaussian_psf(2 / pixelscale, 11, pixelscale), + mask=mask, + zeropoint=21.5, + ) + + MODEL = ap.models.SersicGalaxy( + name="basic_sersic_model", + target=target, + center=request.param.get("center", [20.5, 21.4]), + PA=request.param.get("PA", 45 * np.pi / 180), + q=request.param.get("q", 0.7), + n=request.param.get("n", 1.5), + Re=request.param.get("Re", 15.1), + Ie=request.param.get("Ie", 10.0), + sampling_mode="quad:5", + ) + + if request.param.get("target", None) is None: + img = ap.backend.to_numpy(MODEL().data) + target.data = ( + img + + np.random.normal(scale=0.5, size=img.shape) + + np.random.normal(scale=np.sqrt(img) / 10) + ) + target.variance = 0.5**2 + img / 100 + + return MODEL diff --git a/tests/test_cmos_image.py b/tests/test_cmos_image.py index 4cfb5123..16e6555d 100644 --- a/tests/test_cmos_image.py +++ b/tests/test_cmos_image.py @@ -44,7 +44,7 @@ def test_cmos_image_creation(cmos_target): def test_cmos_model_sample(cmos_target): model = ap.Model( - name="test cmos", + name="test_cmos", model_type="sersic galaxy model", target=cmos_target, center=(3, 5), diff --git a/tests/test_fit.py b/tests/test_fit.py index bfb2ad13..1a53449e 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -18,7 +18,7 @@ def test_chunk_jacobian(center, PA, q, n, Re): target = make_basic_sersic() model = ap.Model( - name="test sersic", + name="test_sersic", model_type="sersic galaxy model", center=center, PA=PA, @@ -53,7 +53,7 @@ def test_chunk_jacobian(center, PA, q, n, Re): def sersic_model(): target = make_basic_sersic() model = ap.Model( - name="test sersic", + name="test_sersic", model_type="sersic galaxy model", center=[20, 20], PA=np.pi, @@ -135,7 +135,7 @@ def test_fitters_iter(): target=target, ) model = ap.Model( - name="test group", + name="test_group", model_type="group model", models=[model1, model2], target=target, diff --git a/tests/test_group_models.py b/tests/test_group_models.py index 9285c0ac..bc0d2949 100644 --- a/tests/test_group_models.py +++ b/tests/test_group_models.py @@ -25,16 +25,16 @@ def test_jointmodel_creation(): tar = ap.TargetImageList([tar1, tar2]) mod1 = ap.models.FlatSky( - name="base model 1", + name="base_model_1", target=tar1, ) mod2 = ap.models.FlatSky( - name="base model 2", + name="base_model_2", target=tar2, ) smod = ap.Model( - name="group model", + name="group_model", model_type="group model", models=[mod1, mod2], target=tar, @@ -54,19 +54,19 @@ def test_psfgroupmodel_creation(): tar = make_basic_gaussian_psf() mod1 = ap.Model( - name="base model 1", + name="base_model_1", model_type="moffat psf model", target=tar, ) mod2 = ap.Model( - name="base model 2", + name="base_model_2", model_type="moffat psf model", target=tar, ) smod = ap.Model( - name="group model", + name="group_model", model_type="psf group model", models=[mod1, mod2], target=tar, @@ -104,7 +104,7 @@ def test_joint_multi_band_multi_object(): model52 = ap.Model(name="model52", model_type="sersic galaxy model", window=(0, 49, 0, 60), target=target3) model5 = ap.Model(name="model5", model_type="group model", models=[model51, model52], target=ap.TargetImageList([target2, target3])) - model = ap.Model(name="joint model", model_type="group model", models=[model1, model2, model3, model4, model5], target=ap.TargetImageList([target1, target2, target3, target4])) + model = ap.Model(name="joint_model", model_type="group model", models=[model1, model2, model3, model4, model5], target=ap.TargetImageList([target1, target2, target3, target4])) # fmt: on model.initialize() diff --git a/tests/test_model.py b/tests/test_model.py index a349e137..ac9dd4d1 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -13,7 +13,7 @@ def test_model_sampling_modes(): target = make_basic_sersic(90, 100) model = ap.Model( - name="test sersic", + name="test_sersic", model_type="sersic galaxy model", center=[40, 41.9], PA=60 * np.pi / 180, @@ -97,7 +97,7 @@ def test_model_errors(): with pytest.raises(ap.errors.InvalidTarget): ap.Model( - name="test model", + name="test_model", model_type="sersic galaxy model", target=target, ) @@ -106,7 +106,7 @@ def test_model_errors(): target = make_basic_sersic() with pytest.raises(ap.errors.UnrecognizedModel): ap.Model( - name="test model", + name="test_model", model_type="sersic gaaxy model", target=target, ) @@ -131,7 +131,7 @@ def test_all_model_sample(model_type): target = make_basic_sersic() target.zeropoint = 22.5 MODEL = ap.Model( - name="test model", + name="test_model", model_type=model_type, target=target, integrate_mode=( @@ -202,7 +202,7 @@ def test_sersic_save_load(): target = make_basic_sersic() model = ap.Model( - name="test sersic", + name="test_sersic", model_type="sersic galaxy model", center=[20, 20], PA=60 * np.pi / 180, @@ -244,7 +244,7 @@ def test_sersic_save_load(): def test_chunk_sample(center, PA, q, n, Re): target = make_basic_sersic() model = ap.Model( - name="test sersic", + name="test_sersic", model_type="sersic galaxy model", center=center, PA=PA, diff --git a/tests/test_param.py b/tests/test_param.py index 7740dc1b..d3bd4156 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -35,8 +35,8 @@ def test_param(): def test_module(): target = make_basic_sersic() - model1 = ap.Model(name="test model 1", model_type="sersic galaxy model", target=target) - model2 = ap.Model(name="test model 2", model_type="sersic galaxy model", target=target) + model1 = ap.Model(name="test_model_1", model_type="sersic galaxy model", target=target) + model2 = ap.Model(name="test_model_2", model_type="sersic galaxy model", target=target) model = ap.Model(name="test", model_type="group model", target=target, models=[model1, model2]) model.initialize() diff --git a/tests/test_plots.py b/tests/test_plots.py index 4d6a59c7..35d8f2e8 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -46,7 +46,7 @@ def test_target_image_list(): def test_model_image(): target = make_basic_sersic() new_model = ap.Model( - name="constrained sersic", + name="constrained_sersic", model_type="sersic galaxy model", center=[20, 20], PA=60 * np.pi / 180, @@ -68,7 +68,7 @@ def test_model_image(): def test_residual_image(): target = make_basic_sersic() new_model = ap.Model( - name="constrained sersic", + name="constrained_sersic", model_type="sersic galaxy model", center=[20, 20], PA=60 * np.pi / 180, @@ -90,7 +90,7 @@ def test_residual_image(): def test_model_windows(): target = make_basic_sersic() new_model = ap.Model( - name="constrained sersic", + name="constrained_sersic", model_type="sersic galaxy model", center=[20, 20], PA=60 * np.pi / 180, @@ -124,7 +124,7 @@ def test_covariance_matrix(): def test_radial_profile(): target = make_basic_sersic() new_model = ap.Model( - name="constrained sersic", + name="constrained_sersic", model_type="sersic galaxy model", center=[20, 20], PA=60 * np.pi / 180, @@ -146,7 +146,7 @@ def test_radial_profile(): def test_radial_median_profile(): target = make_basic_sersic() new_model = ap.Model( - name="constrained sersic", + name="constrained_sersic", model_type="sersic galaxy model", center=[20, 20], PA=60 * np.pi / 180, diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index 586672ed..9c6c7ca3 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -26,7 +26,7 @@ def test_all_psfmodel_sample(model_type): kwargs = {} target = make_basic_gaussian_psf(pixelscale=0.8) MODEL = ap.Model( - name="test model", + name="test_model", model_type=model_type, target=target, normalize_psf=False, diff --git a/tests/utils.py b/tests/utils.py index 7bbbb9df..038bb747 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -53,7 +53,7 @@ def make_basic_sersic( ) MODEL = ap.models.SersicGalaxy( - name="basic sersic model", + name="basic_sersic_model", target=target, center=[x, y], PA=PA, @@ -95,7 +95,7 @@ def make_basic_gaussian( ) MODEL = ap.models.GaussianGalaxy( - name="basic gaussian source", + name="basic_gaussian_source", target=target, center=[x, y], sigma=sigma, From 1ebd60b89e18c2db6938ae9dc60266ec259870d6 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 3 Feb 2026 15:31:31 -0500 Subject: [PATCH 185/191] trying to fix import error no pyro --- astrophot/fit/hmc.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrophot/fit/hmc.py b/astrophot/fit/hmc.py index bdb54fb3..a072bc44 100644 --- a/astrophot/fit/hmc.py +++ b/astrophot/fit/hmc.py @@ -6,12 +6,14 @@ try: import pyro import pyro.distributions as dist + from pyro.distributions import Distribution from pyro.infer import MCMC as pyro_MCMC from pyro.infer import HMC as pyro_HMC from pyro.infer.mcmc.adaptation import BlockMassMatrix from pyro.ops.welford import WelfordCovariance except ImportError: pyro = None + Distribution = None from .base import BaseOptimizer from ..models import Model @@ -90,7 +92,7 @@ def __init__( epsilon: float = 1e-4, leapfrog_steps: int = 10, progress_bar: bool = True, - prior: Optional[dist.Distribution] = None, + prior: Optional["Distribution"] = None, warmup: int = 100, hmc_kwargs: dict = {}, mcmc_kwargs: dict = {}, From 0b3249c1fbbca02b588fa033fa09d9e6a938b89b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:36:52 -0400 Subject: [PATCH 186/191] build(deps): bump actions/download-artifact from 6 to 8 (#291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 8.
Release notes

Sourced from actions/download-artifact's releases.

v8.0.0

v8 - What's new

Direct downloads

To support direct uploads in actions/upload-artifact, the action will no longer attempt to unzip all downloaded files. Instead, the action checks the Content-Type header ahead of unzipping and skips non-zipped files. Callers wishing to download a zipped file as-is can also set the new skip-decompress parameter to false.

Enforced checks (breaking)

A previous release introduced digest checks on the download. If a download hash didn't match the expected hash from the server, the action would log a warning. Callers can now configure the behavior on mismatch with the digest-mismatch parameter. To be secure by default, we are now defaulting the behavior to error which will fail the workflow run.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

Full Changelog: https://github.com/actions/download-artifact/compare/v7...v8.0.0

v7.0.0

v7 - What's new

[!IMPORTANT] actions/download-artifact@v7 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v6 had preliminary support for Node 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

New Contributors

Full Changelog: https://github.com/actions/download-artifact/compare/v6.0.0...v7.0.0

Commits
  • 70fc10c Merge pull request #461 from actions/danwkennedy/digest-mismatch-behavior
  • f258da9 Add change docs
  • ccc058e Fix linting issues
  • bd7976b Add a setting to specify what to do on hash mismatch and default it to error
  • ac21fcf Merge pull request #460 from actions/danwkennedy/download-no-unzip
  • 15999bf Add note about package bumps
  • 974686e Bump the version to v8 and add release notes
  • fbe48b1 Update test names to make it clearer what they do
  • 96bf374 One more test fix
  • b8c4819 Fix skip decompress test
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=6&new-version=8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cd.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index d70914a0..364f815e 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -49,7 +49,7 @@ jobs: name: Install Python with: python-version: "3.10" - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v8 with: name: artifact path: dist @@ -91,7 +91,7 @@ jobs: if: github.event_name == 'release' && github.event.action == 'published' steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v8 with: name: artifact path: dist From 5886452cdd8faa3f3e476e10ebc22539712b95f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:37:43 -0400 Subject: [PATCH 187/191] build(deps): bump actions/upload-artifact from 5 to 7 (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 7.
Release notes

Sourced from actions/upload-artifact's releases.

v7.0.0

v7 What's new

Direct Uploads

Adds support for uploading single files directly (unzipped). Callers can set the new archive parameter to false to skip zipping the file during upload. Right now, we only support single files. The action will fail if the glob passed resolves to multiple files. The name parameter is also ignored with this setting. Instead, the name of the artifact will be the name of the uploaded file.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v6...v7.0.0

v6.0.0

v6 - What's new

[!IMPORTANT] actions/upload-artifact@v6 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v5 had preliminary support for Node.js 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0

Commits
  • bbbca2d Support direct file uploads (#764)
  • 589182c Upgrade the module to ESM and bump dependencies (#762)
  • 47309c9 Merge pull request #754 from actions/Link-/add-proxy-integration-tests
  • 02a8460 Add proxy integration test
  • b7c566a Merge pull request #745 from actions/upload-artifact-v6-release
  • e516bc8 docs: correct description of Node.js 24 support in README
  • ddc45ed docs: update README to correct action name for Node.js 24 support
  • 615b319 chore: release v6.0.0 for Node.js 24 support
  • 017748b Merge pull request #744 from actions/fix-storage-blob
  • 38d4c79 chore: rebuild dist
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=5&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Connor Stone, PhD --- .github/workflows/cd.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 364f815e..7f683250 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -30,7 +30,7 @@ jobs: - name: Build sdist and wheel run: pipx run build - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v7 with: path: dist From 2bd4f10f01958c8afd3a34542dc03912c3aed73e Mon Sep 17 00:00:00 2001 From: "Connor Stone, PhD" Date: Mon, 23 Mar 2026 21:21:56 -0400 Subject: [PATCH 188/191] Add Batch model, revamp PSF models, add priors (#292) Substantial updates to the modelling system to allow for internal vectorization. Batch model now available for stacking many similar models. Revamp PSF model system so now PSF models operate in pixel space. Added an option for LM to use priors --- .github/workflows/testing.yaml | 2 +- astrophot/__init__.py | 10 + astrophot/backend_obj.py | 19 +- astrophot/fit/__init__.py | 6 +- astrophot/fit/base.py | 7 - astrophot/fit/iterative.py | 19 +- astrophot/fit/lm.py | 96 ++++-- astrophot/fit/minifit.py | 70 ---- astrophot/image/__init__.py | 15 +- astrophot/image/cmos_image.py | 17 +- astrophot/image/func/image.py | 63 ++-- astrophot/image/image_batch.py | 0 astrophot/image/image_object.py | 166 +++++++-- astrophot/image/jacobian_image.py | 6 +- astrophot/image/mixins/cmos_mixin.py | 12 +- astrophot/image/mixins/data_mixin.py | 6 +- astrophot/image/mixins/sip_mixin.py | 12 +- astrophot/image/model_image.py | 9 +- astrophot/image/psf_image.py | 319 ++++++++++++++++-- astrophot/image/sip_image.py | 3 - astrophot/image/target_image.py | 139 +++++--- astrophot/image/window.py | 20 +- astrophot/models/__init__.py | 126 +++++-- astrophot/models/_shared_methods.py | 2 +- astrophot/models/airy.py | 6 +- astrophot/models/base.py | 82 ++--- astrophot/models/basis.py | 89 +++-- astrophot/models/basis_psf.py | 81 +++++ astrophot/models/batch_model_object.py | 182 ++++++++++ astrophot/models/bilinear_sky.py | 3 +- astrophot/models/exponential.py | 59 ---- astrophot/models/ferrer.py | 60 ---- astrophot/models/flatsky.py | 12 +- astrophot/models/func/__init__.py | 7 +- astrophot/models/func/base.py | 21 ++ astrophot/models/func/zernike.py | 15 + astrophot/models/gaussian.py | 61 ---- astrophot/models/group_model_object.py | 87 +---- astrophot/models/group_psf_model.py | 12 +- astrophot/models/king.py | 60 ---- astrophot/models/mixins/__init__.py | 27 +- astrophot/models/mixins/exponential.py | 50 ++- astrophot/models/mixins/ferrer.py | 62 +++- astrophot/models/mixins/gaussian.py | 44 ++- astrophot/models/mixins/king.py | 68 +++- astrophot/models/mixins/moffat.py | 54 ++- astrophot/models/mixins/nuker.py | 71 +++- astrophot/models/mixins/sample.py | 203 ++++++----- astrophot/models/mixins/sersic.py | 54 ++- astrophot/models/mixins/spline.py | 61 +++- astrophot/models/mixins/transform.py | 26 +- astrophot/models/model_object.py | 212 ++++++------ astrophot/models/moffat.py | 69 ---- astrophot/models/multi_gaussian_expansion.py | 13 +- astrophot/models/nuker.py | 60 ---- astrophot/models/pixelated_model.py | 81 +++++ astrophot/models/pixelated_psf.py | 19 +- astrophot/models/planesky.py | 4 +- astrophot/models/point_source.py | 114 +++---- astrophot/models/psf_model_object.py | 94 ++++-- astrophot/models/radial.py | 99 ++++++ astrophot/models/radial_psf.py | 57 ++++ astrophot/models/sersic.py | 76 ----- astrophot/models/sky_model_object.py | 15 +- astrophot/models/spline.py | 59 ---- astrophot/param/__init__.py | 2 +- astrophot/param/module.py | 46 +-- astrophot/param/param.py | 5 + astrophot/plots/image.py | 14 +- astrophot/utils/decorators.py | 5 + astrophot/utils/initialize/PA.py | 5 + astrophot/utils/initialize/__init__.py | 3 +- astrophot/utils/integration.py | 4 - docs/source/prebuilt/segmap_models_fit.py | 12 +- docs/source/prebuilt/single_model_fit.py | 10 +- docs/source/tutorials/AdvancedPSFModels.ipynb | 77 ++--- docs/source/tutorials/BasicPSFModels.ipynb | 27 +- docs/source/tutorials/ConstrainedModels.ipynb | 11 +- docs/source/tutorials/CustomModels.ipynb | 13 +- docs/source/tutorials/FittingMethods.ipynb | 2 +- docs/source/tutorials/FittingPriors.ipynb | 238 +++++++++++++ .../tutorials/FunctionalInterface.ipynb | 55 +-- docs/source/tutorials/GettingStarted.ipynb | 57 +++- docs/source/tutorials/GettingStartedJAX.ipynb | 40 ++- .../tutorials/GravitationalLensing.ipynb | 11 +- docs/source/tutorials/GroupModels.ipynb | 15 +- docs/source/tutorials/ImageAlignment.ipynb | 18 +- docs/source/tutorials/ImageTypes.ipynb | 10 +- docs/source/tutorials/JointModels.ipynb | 47 ++- docs/source/tutorials/ModelZoo.ipynb | 168 ++++++++- .../tutorials/align_target_image_g.fits | Bin 0 -> 95040 bytes .../tutorials/align_target_image_r.fits | Bin 0 -> 95040 bytes docs/source/tutorials/group_target_image.fits | Bin 0 -> 95040 bytes docs/source/tutorials/index.rst | 1 + .../joint_group_target_image_NUV.fits | Bin 0 -> 5760 bytes .../joint_group_target_image_W1.fits | Bin 0 -> 5760 bytes .../tutorials/joint_group_target_image_r.fits | Bin 0 -> 37440 bytes .../tutorials/joint_target_image_NUV.fits | Bin 0 -> 37440 bytes .../tutorials/joint_target_image_W1.fits | Bin 0 -> 14400 bytes .../tutorials/joint_target_image_r.fits | Bin 0 -> 1005120 bytes .../source/tutorials/lensed_target_image.fits | Bin 0 -> 95040 bytes docs/source/tutorials/target_image.fits | Bin 0 -> 1964160 bytes pyproject.toml | 2 +- tests/conftest.py | 2 + tests/test_batch_model.py | 38 +++ tests/test_cmos_image.py | 2 +- tests/test_fit.py | 21 +- tests/test_group_models.py | 10 +- tests/test_image.py | 6 +- tests/test_model.py | 120 ++++--- tests/test_notebooks.py | 6 + tests/test_param.py | 8 +- tests/test_psfmodel.py | 44 +-- tests/test_sip_image.py | 5 +- tests/test_utils.py | 2 +- tests/utils.py | 5 +- 116 files changed, 3127 insertions(+), 1713 deletions(-) delete mode 100644 astrophot/fit/minifit.py create mode 100644 astrophot/image/image_batch.py create mode 100644 astrophot/models/basis_psf.py create mode 100644 astrophot/models/batch_model_object.py delete mode 100644 astrophot/models/exponential.py delete mode 100644 astrophot/models/ferrer.py delete mode 100644 astrophot/models/gaussian.py delete mode 100644 astrophot/models/king.py delete mode 100644 astrophot/models/moffat.py delete mode 100644 astrophot/models/nuker.py create mode 100644 astrophot/models/pixelated_model.py create mode 100644 astrophot/models/radial.py create mode 100644 astrophot/models/radial_psf.py delete mode 100644 astrophot/models/sersic.py delete mode 100644 astrophot/models/spline.py create mode 100644 docs/source/tutorials/FittingPriors.ipynb create mode 100644 docs/source/tutorials/align_target_image_g.fits create mode 100644 docs/source/tutorials/align_target_image_r.fits create mode 100644 docs/source/tutorials/group_target_image.fits create mode 100644 docs/source/tutorials/joint_group_target_image_NUV.fits create mode 100644 docs/source/tutorials/joint_group_target_image_W1.fits create mode 100644 docs/source/tutorials/joint_group_target_image_r.fits create mode 100644 docs/source/tutorials/joint_target_image_NUV.fits create mode 100644 docs/source/tutorials/joint_target_image_W1.fits create mode 100644 docs/source/tutorials/joint_target_image_r.fits create mode 100644 docs/source/tutorials/lensed_target_image.fits create mode 100644 docs/source/tutorials/target_image.fits create mode 100644 tests/test_batch_model.py diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index eeffce2e..bff1725c 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -48,5 +48,5 @@ jobs: run: | cd $GITHUB_WORKSPACE/tests/ pwd - pytest + pytest -v shell: bash diff --git a/astrophot/__init__.py b/astrophot/__init__.py index 7fc1e3e2..592ce0df 100644 --- a/astrophot/__init__.py +++ b/astrophot/__init__.py @@ -4,19 +4,24 @@ from .image import ( Image, ImageList, + ImageBatchMixin, TargetImage, TargetImageList, + TargetImageBatch, SIPModelImage, SIPTargetImage, CMOSModelImage, CMOSTargetImage, JacobianImage, JacobianImageList, + JacobianImageBatch, PSFImage, ModelImage, ModelImageList, + ModelImageBatch, Window, WindowList, + WindowBatch, ) from .models import Model from .backend_obj import backend, ArrayLike @@ -41,19 +46,24 @@ "Model", "Image", "ImageList", + "ImageBatchMixin", "TargetImage", "TargetImageList", + "TargetImageBatch", "SIPModelImage", "SIPTargetImage", "CMOSModelImage", "CMOSTargetImage", "JacobianImage", "JacobianImageList", + "JacobianImageBatch", "PSFImage", "ModelImage", "ModelImageList", + "ModelImageBatch", "Window", "WindowList", + "WindowBatch", "plots", "utils", "fit", diff --git a/astrophot/backend_obj.py b/astrophot/backend_obj.py index dc12fec7..df070970 100644 --- a/astrophot/backend_obj.py +++ b/astrophot/backend_obj.py @@ -292,11 +292,11 @@ def _mean_torch(self, array, dim=None): def _mean_jax(self, array, dim=None): return self.module.mean(array, axis=dim) - def _sum_torch(self, array, dim=None): - return self.module.sum(array, dim=dim) + def _sum_torch(self, array, dim=None, keepdim=False): + return self.module.sum(array, dim=dim, keepdim=keepdim) - def _sum_jax(self, array, dim=None): - return self.module.sum(array, axis=dim) + def _sum_jax(self, array, dim=None, keepdim=False): + return self.module.sum(array, axis=dim, keepdims=keepdim) def _max_torch(self, array, dim=None): return array.amax(dim=dim) @@ -360,11 +360,11 @@ def _hessian_torch(self, func): def _hessian_jax(self, func): return self.jax.hessian(func) - def _vmap_torch(self, *args, **kwargs): - return self.module.vmap(*args, **kwargs) + def _vmap_torch(self, *args, in_dims=0, **kwargs): + return self.module.vmap(*args, in_dims=in_dims, **kwargs) - def _vmap_jax(self, *args, **kwargs): - return self.jax.vmap(*args, **kwargs) + def _vmap_jax(self, *args, in_dims=0, **kwargs): + return self.jax.vmap(*args, in_axes=in_dims, **kwargs) def _fill_at_indices_torch(self, array, indices, values): array[indices] = values @@ -495,6 +495,9 @@ def isnan(self, array): def isfinite(self, array): return self.module.isfinite(array) + def nan_to_num(self, array, nan=0.0, posinf=None, neginf=None): + return self.module.nan_to_num(array, nan=nan, posinf=posinf, neginf=neginf) + def where(self, condition, x, y): return self.module.where(condition, x, y) diff --git a/astrophot/fit/__init__.py b/astrophot/fit/__init__.py index 987035bc..b788aa7a 100644 --- a/astrophot/fit/__init__.py +++ b/astrophot/fit/__init__.py @@ -1,8 +1,7 @@ -from .lm import LM, LMfast +from .lm import LM, LMConstraint from .gradient import Grad, Slalom from .iterative import Iter, IterParam from .scipy_fit import ScipyFit -from .minifit import MiniFit from .hmc import HMC from .mala import MALA from .mhmcmc import MHMCMC @@ -10,13 +9,12 @@ __all__ = [ "LM", - "LMfast", + "LMConstraint", "Grad", "Iter", "MALA", "IterParam", "ScipyFit", - "MiniFit", "HMC", "MHMCMC", "Slalom", diff --git a/astrophot/fit/base.py b/astrophot/fit/base.py index b9152f9f..d95446dc 100644 --- a/astrophot/fit/base.py +++ b/astrophot/fit/base.py @@ -21,7 +21,6 @@ class BaseOptimizer: - `model`: an AstroPhot_Model object that will have its (unlocked) parameters optimized [AstroPhot_Model] - `initial_state`: optional initialization for the parameters as a 1D tensor [tensor] - `relative_tolerance`: tolerance for counting success steps as: $0 < (\\chi_2^2 - \\chi_1^2)/\\chi_1^2 < \\text{tol}$ [float] - - `fit_window`: optional window to fit the model on [Window] - `verbose`: verbosity level for the optimizer [int] - `max_iter`: maximum allowed number of iterations [int] - `save_steps`: optional string for path to save the model at each step (fitter dependent), e.g. "model_step_{step}.hdf5" [str] @@ -34,7 +33,6 @@ def __init__( model: Model, initial_state: Sequence = None, relative_tolerance: float = 1e-3, - fit_window: Optional[Window] = None, verbose: int = 1, max_iter: int = None, save_steps: Optional[str] = None, @@ -51,11 +49,6 @@ def __init__( initial_state, dtype=config.DTYPE, device=config.DEVICE ) - if fit_window is None: - self.fit_window = self.model.window - else: - self.fit_window = fit_window & self.model.window - self.max_iter = max_iter if max_iter is not None else 100 * len(self.current_state) self.iteration = 0 self.save_steps = save_steps diff --git a/astrophot/fit/iterative.py b/astrophot/fit/iterative.py index 2e9330ca..a52384f3 100644 --- a/astrophot/fit/iterative.py +++ b/astrophot/fit/iterative.py @@ -214,21 +214,14 @@ def __init__( self.chunk_order = chunk_order # mask - fit_mask = self.model.fit_mask() - if isinstance(fit_mask, tuple): - fit_mask = backend.concatenate(tuple(FM.flatten() for FM in fit_mask)) - else: - fit_mask = fit_mask.flatten() - - mask = self.model.target[self.fit_window].flatten("mask") - mask = mask | fit_mask + mask = self.model.target[self.model.window].flatten("mask") self.mask = ~mask if backend.sum(self.mask).item() == 0: raise OptimizeStopSuccess("No data to fit. All pixels are masked") # Initialize optimizer attributes - self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] + self.Y = self.model.target[self.model.window].flatten("data")[self.mask] # 1 / (sigma^2) if W is not None: @@ -236,23 +229,19 @@ def __init__( self.mask ] else: - self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] + self.W = self.model.target[self.model.window].flatten("weight")[self.mask] # The forward model which computes the output image given input parameters - self.full_forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[ - self.mask - ] + self.full_forward = lambda x: model(params=x).flatten("data")[self.mask] self.forward = [] # Compute the jacobian self.jacobian = [] f = lambda c, state, x: model( - window=self.fit_window, params=backend.fill_at_indices(backend.copy(state), self.chunks[c], x), ).flatten("data")[self.mask] j = backend.jacfwd( lambda c, state, x: self.model( - window=self.fit_window, params=backend.fill_at_indices(backend.copy(state), self.chunks[c], x), ).flatten("data")[self.mask], argnums=2, diff --git a/astrophot/fit/lm.py b/astrophot/fit/lm.py index a6aeb6ec..7eeeb158 100644 --- a/astrophot/fit/lm.py +++ b/astrophot/fit/lm.py @@ -1,5 +1,5 @@ # Levenberg-Marquardt algorithm -from typing import Sequence +from typing import Sequence, Optional import torch import numpy as np @@ -9,9 +9,28 @@ from ..backend_obj import backend, ArrayLike from . import func from ..errors import OptimizeStopFail, OptimizeStopSuccess -from ..param import ValidContext +from ..param import ValidContext, Module, forward -__all__ = ("LM", "LMfast") +__all__ = ("LM", "LMConstraint") + + +class LMConstraint(Module): + def __init__(self, model, constraint, sigma, name=None): + super().__init__(name=name) + self.model = model + self.constraint = constraint + self.weight = 1 / sigma**2 + + def jacobian(self, model, params): + return backend.jacobian(lambda p: self(model, params=p), params) + + @forward + def __call__(self, model): + return self.constraint(model) + + +class DummyConstraint: + valid_context = False class LM(BaseOptimizer): @@ -127,6 +146,9 @@ def __init__( max_step_iter: int = 10, ndf=None, likelihood="gaussian", + constraint: Optional[LMConstraint] = None, + forward=None, + jacobian=None, **kwargs, ): @@ -147,25 +169,16 @@ def __init__( self.likelihood = likelihood if self.likelihood not in ["gaussian", "poisson"]: raise ValueError(f"Unsupported likelihood: {self.likelihood}") + self.constraint = constraint # mask - fit_mask = self.model.fit_mask() - if isinstance(fit_mask, tuple): - fit_mask = backend.concatenate(tuple(FM.flatten() for FM in fit_mask)) - else: - fit_mask = fit_mask.flatten() - if backend.sum(fit_mask).item() == 0: - fit_mask = None - - mask = self.model.target[self.fit_window].flatten("mask") - if fit_mask is not None: - mask = mask | fit_mask + mask = self.model.target[self.model.window].flatten("mask") self.mask = ~mask if backend.sum(self.mask).item() == 0: raise OptimizeStopSuccess("No data to fit. All pixels are masked") # Initialize optimizer attributes - self.Y = self.model.target[self.fit_window].flatten("data")[self.mask] + self.Y = self.model.target[self.model.window].flatten("data")[self.mask] # 1 / (sigma^2) kW = kwargs.get("W", None) @@ -173,14 +186,41 @@ def __init__( self.W = backend.as_array(kW, dtype=config.DTYPE, device=config.DEVICE).flatten()[ self.mask ] - self.W = self.model.target[self.fit_window].flatten("weight")[self.mask] - - # The forward model which computes the output image given input parameters - self.forward = lambda x: model(window=self.fit_window, params=x).flatten("data")[self.mask] - # Compute the jacobian - self.jacobian = lambda x: model.jacobian(window=self.fit_window, params=x).flatten("data")[ - self.mask - ] + else: + self.W = self.model.target[self.model.window].flatten("weight")[self.mask] + if forward is None: + forward = lambda x: self.model(params=x).flatten("data") + if jacobian is None: + jacobian = lambda x: self.model.jacobian(params=x).flatten("data") + if self.constraint is None: + # The forward model which computes the output image given input parameters + self.forward = lambda x: forward(x)[self.mask] + # Compute the jacobian + self.jacobian = lambda x: jacobian(x)[self.mask] + self.constraint = DummyConstraint() + else: + self.Y = backend.concatenate( + ( + self.Y, + backend.zeros( + self.constraint.weight.shape, dtype=config.DTYPE, device=config.DEVICE + ), + ) + ) + self.W = backend.concatenate( + ( + self.W, + backend.as_array( + self.constraint.weight, dtype=config.DTYPE, device=config.DEVICE + ), + ) + ) + self.forward = lambda x: backend.concatenate( + (forward(x)[self.mask], self.constraint(model, params=x)) + ) + self.jacobian = lambda x: backend.concatenate( + (jacobian(x)[self.mask], self.constraint.jacobian(model, params=x)) + ) # variable to store covariance matrix if it is ever computed self._covariance_matrix = None @@ -234,7 +274,7 @@ def fit(self, update_uncertainty=True) -> BaseOptimizer: config.logger.info(f"{quantity}: {self.loss_history[-1]:.6g}, L: {self.L:.3g}") try: if self.fit_valid: - with ValidContext(self.model): + with ValidContext(self.model), ValidContext(self.constraint): res = func.lm_step( x=self.model.to_valid(self.current_state), data=self.Y, @@ -372,11 +412,3 @@ def update_uncertainty(self) -> None: config.logger.warning( "Unable to update uncertainty due to non finite covariance matrix" ) - - -class LMfast(LM): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.jacobian = backend.jacfwd( - lambda x: self.model(window=self.fit_window, params=x).flatten("data")[self.mask] - ) diff --git a/astrophot/fit/minifit.py b/astrophot/fit/minifit.py deleted file mode 100644 index fe46921e..00000000 --- a/astrophot/fit/minifit.py +++ /dev/null @@ -1,70 +0,0 @@ -# Apply an optimizer toa downsampled version of an image -from typing import Dict, Any - -import numpy as np - -from .base import BaseOptimizer -from ..models import Model -from .lm import LM -from .. import config - -__all__ = ["MiniFit"] - - -class MiniFit(BaseOptimizer): - """MiniFit optimizer that applies a fitting method to a downsampled version - of the model's target image. - - This is useful for quickly optimizing parameters on a smaller scale before - applying them to the full resolution image. With fewer pixels, the optimization - can be faster and more efficient, especially for large images. - - This Optimizer can wrap any optimizer that follows the BaseOptimizer interface. - - **Args:** - - `downsample_factor`: Factor by which to downsample the target image. Default is 2. - - `max_pixels`: Maximum number of pixels in the downsampled image. Default is 10000. - - `method`: The optimizer method to use, e.g., `LM` for Levenberg-Marquardt. Default is `LM`. - - `method_kwargs`: Additional keyword arguments to pass to the optimizer method. - """ - - def __init__( - self, - model: Model, - downsample_factor: int = 2, - max_pixels: int = 10000, - method: BaseOptimizer = LM, - initial_state: np.ndarray = None, - method_kwargs: Dict[str, Any] = {}, - **kwargs: Dict[str, Any], - ) -> None: - super().__init__(model, initial_state, **kwargs) - - self.method = method - self.method_kwargs = method_kwargs - if "verbose" not in self.method_kwargs: - self.method_kwargs["verbose"] = self.verbose - - self.downsample_factor = downsample_factor - self.max_pixels = max_pixels - - def fit(self) -> BaseOptimizer: - initial_target = self.model.target - target_area = self.model.target[self.model.window] - while True: - small_target = target_area.reduce(self.downsample_factor) - if np.prod(small_target._data.shape) < self.max_pixels: - break - self.downsample_factor += 1 - - if self.verbose > 0: - config.logger.info(f"Downsampling target by {self.downsample_factor}x") - - self.small_target = small_target - self.model.target = small_target - res = self.method(self.model, **self.method_kwargs).fit() - self.model.target = initial_target - - self.message = res.message - - return self diff --git a/astrophot/image/__init__.py b/astrophot/image/__init__.py index cc3615f8..c29dbaec 100644 --- a/astrophot/image/__init__.py +++ b/astrophot/image/__init__.py @@ -1,28 +1,33 @@ -from .image_object import Image, ImageList -from .target_image import TargetImage, TargetImageList +from .image_object import Image, ImageList, ImageBatchMixin +from .target_image import TargetImage, TargetImageList, TargetImageBatch from .sip_image import SIPModelImage, SIPTargetImage from .cmos_image import CMOSModelImage, CMOSTargetImage -from .jacobian_image import JacobianImage, JacobianImageList +from .jacobian_image import JacobianImage, JacobianImageList, JacobianImageBatch from .psf_image import PSFImage -from .model_image import ModelImage, ModelImageList -from .window import Window, WindowList +from .model_image import ModelImage, ModelImageList, ModelImageBatch +from .window import Window, WindowList, WindowBatch from . import func __all__ = ( "Image", "ImageList", + "ImageBatchMixin", "TargetImage", "TargetImageList", + "TargetImageBatch", "SIPModelImage", "SIPTargetImage", "CMOSModelImage", "CMOSTargetImage", "JacobianImage", "JacobianImageList", + "JacobianImageBatch", "PSFImage", "ModelImage", "ModelImageList", + "ModelImageBatch", "Window", "WindowList", + "WindowBatch", "func", ) diff --git a/astrophot/image/cmos_image.py b/astrophot/image/cmos_image.py index cc9e6766..fac27d21 100644 --- a/astrophot/image/cmos_image.py +++ b/astrophot/image/cmos_image.py @@ -1,17 +1,15 @@ +import numpy as np + from .target_image import TargetImage +from .window import Window from .mixins import CMOSMixin from .model_image import ModelImage from ..backend_obj import backend -from .. import config class CMOSModelImage(CMOSMixin, ModelImage): """A ModelImage with CMOS-specific functionality.""" - def fluxdensity_to_flux(self): - # CMOS pixels only sensitive in sub area, so scale the flux density - self._data = self._data * self.pixel_area * self.subpixel_scale**2 - class CMOSTargetImage(CMOSMixin, TargetImage): """ @@ -20,17 +18,16 @@ class CMOSTargetImage(CMOSMixin, TargetImage): It inherits from TargetImage and CMOSMixin. """ - def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> CMOSModelImage: + def model_image(self, window: Window, **kwargs) -> CMOSModelImage: """Model the image with CMOS-specific features.""" - if upsample > 1 or pad > 0: - raise NotImplementedError("Upsampling and padding are not implemented for CMOS images.") + si, sj = self.get_indices(window) kwargs = { "subpixel_loc": self.subpixel_loc, "subpixel_scale": self.subpixel_scale, - "_data": backend.zeros(self._data.shape[:2], dtype=config.DTYPE, device=config.DEVICE), + "_data": backend.zeros_like(self._data[si, sj]), "CD": self.CD.value, - "crpix": self.crpix, + "crpix": self.crpix - np.array((si.start, sj.start)), "crtan": self.crtan.value, "crval": self.crval.value, "zeropoint": self.zeropoint, diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index 74737a1f..d4ef3296 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -2,37 +2,62 @@ from ...backend_obj import backend, ArrayLike -def pixel_center_meshgrid(shape: tuple[int, int], dtype, device) -> tuple: - i = backend.arange(shape[0], dtype=dtype, device=device) - j = backend.arange(shape[1], dtype=dtype, device=device) +def pixel_center_meshgrid( + extent: tuple[int, int, int, int], pad: int, upsample: int, dtype: type, device: str +) -> tuple: + i = backend.linspace( + extent[0] - 0.5 + 0.5 / upsample - pad / upsample, + extent[1] - 0.5 - 0.5 / upsample + pad / upsample, + (extent[1] - extent[0]) * upsample + 2 * pad, + dtype=dtype, + device=device, + ) + j = backend.linspace( + extent[2] - 0.5 + 0.5 / upsample - pad / upsample, + extent[3] - 0.5 - 0.5 / upsample + pad / upsample, + (extent[3] - extent[2]) * upsample + 2 * pad, + dtype=dtype, + device=device, + ) return backend.meshgrid(i, j, indexing="ij") def cmos_pixel_center_meshgrid( - shape: tuple[int, int], loc: tuple[float, float], dtype, device + extent: tuple[int, int, int, int], + pad: int, + upsample: int, + loc: tuple[float, float], + dtype, + device, ) -> tuple: - i = backend.arange(shape[0], dtype=dtype, device=device) + loc[0] - j = backend.arange(shape[1], dtype=dtype, device=device) + loc[1] - return backend.meshgrid(i, j, indexing="ij") + if upsample > 1: + raise NotImplementedError("CMOS pixel meshgrid does not currently support upsampling.") + i, j = pixel_center_meshgrid(extent, pad, upsample, dtype, device) + return i + loc[0], j + loc[1] -def pixel_corner_meshgrid(shape: tuple[int, int], dtype, device) -> tuple: - i = backend.arange(shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = backend.arange(shape[1] + 1, dtype=dtype, device=device) - 0.5 - return backend.meshgrid(i, j, indexing="ij") +def pixel_corner_meshgrid( + extent: tuple[int, int, int, int], pad: int, upsample: int, dtype, device +) -> tuple: + i, j = pixel_center_meshgrid( + (extent[0], extent[1] + 1, extent[2], extent[3] + 1), pad, upsample, dtype, device + ) + return i - 0.5 / upsample, j - 0.5 / upsample -def pixel_simpsons_meshgrid(shape: tuple[int, int], dtype, device) -> tuple: - i = 0.5 * backend.arange(2 * shape[0] + 1, dtype=dtype, device=device) - 0.5 - j = 0.5 * backend.arange(2 * shape[1] + 1, dtype=dtype, device=device) - 0.5 - return backend.meshgrid(i, j, indexing="ij") +def pixel_simpsons_meshgrid( + extent: tuple[int, int, int, int], pad: int, upsample: int, dtype, device +) -> tuple: + return pixel_corner_meshgrid(extent, 2 * pad, 2 * upsample, dtype, device) -def pixel_quad_meshgrid(shape: tuple[int, int], dtype, device, order=3) -> tuple: - i, j = pixel_center_meshgrid(shape, dtype, device) +def pixel_quad_meshgrid( + extent: tuple[int, int, int, int], pad: int, upsample: int, dtype, device, order=3 +) -> tuple: + i, j = pixel_center_meshgrid(extent, pad, upsample, dtype, device) di, dj, w = quad_table(order, dtype, device) - i = backend.repeat(i[..., None], order**2, -1) + di.flatten() - j = backend.repeat(j[..., None], order**2, -1) + dj.flatten() + i = backend.repeat(i[..., None], order**2, -1) + di.flatten() / upsample + j = backend.repeat(j[..., None], order**2, -1) + dj.flatten() / upsample return i, j, w.flatten() diff --git a/astrophot/image/image_batch.py b/astrophot/image/image_batch.py new file mode 100644 index 00000000..e69de29b diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index 7d989342..c094bdd2 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -1,6 +1,5 @@ from typing import Optional, Tuple, Union -import torch import numpy as np from astropy.wcs import WCS as AstropyWCS from astropy.io import fits @@ -9,7 +8,7 @@ from .. import config from ..backend_obj import backend, ArrayLike from ..utils.conversions.units import deg_to_arcsec, arcsec_to_deg -from .window import Window, WindowList +from .window import Window, WindowList, WindowBatch from ..errors import InvalidImage, SpecificationConflict # from .base import BaseImage @@ -43,7 +42,6 @@ class Image(Module): - `CD`: The coordinate transformation matrix in arcseconds/pixel. """ - default_CD = ((1.0, 0.0), (0.0, 1.0)) expect_ctype = (("RA---TAN",), ("DEC--TAN",)) base_scale = 1.0 @@ -56,7 +54,7 @@ def __init__( crpix: Union[ArrayLike, tuple] = (0.0, 0.0), crtan: Union[ArrayLike, tuple] = (0.0, 0.0), crval: Union[ArrayLike, tuple] = (0.0, 0.0), - pixelscale: Optional[Union[ArrayLike, float]] = None, + pixelscale: Optional[Union[ArrayLike, float]] = 1.0, wcs: Optional[AstropyWCS] = None, filename: Optional[str] = None, hduext: int = 0, @@ -80,9 +78,9 @@ def __init__( self.zeropoint = zeropoint if identity is None: - self.identity = id(self) + self._identity = id(self) else: - self.identity = identity + self._identity = identity if wcs is not None: if wcs.wcs.ctype[0] not in self.expect_ctype[0]: @@ -109,10 +107,8 @@ def __init__( if isinstance(CD, (float, int)): CD = np.array([[CD, 0.0], [0.0, CD]], dtype=np.float64) - elif CD is None and pixelscale is not None: - CD = np.array([[pixelscale, 0.0], [0.0, pixelscale]], dtype=np.float64) elif CD is None: - CD = self.default_CD + CD = np.array([[pixelscale, 0.0], [0.0, pixelscale]], dtype=np.float64) self.CD = Param( "CD", @@ -127,6 +123,10 @@ def __init__( self.load(filename, hduext=hduext) return + @property + def identity(self): + return self._identity + @property def data(self): """The image data, which is a tensor of pixel values.""" @@ -144,13 +144,13 @@ def data(self, value: Optional[ArrayLike]): ) @property - def crpix(self) -> np.ndarray: + def crpix(self) -> ArrayLike: """The reference pixel coordinates in the image, which is used to convert from pixel coordinates to tangent plane coordinates.""" return self._crpix @crpix.setter def crpix(self, value: Union[ArrayLike, tuple]): - self._crpix = np.asarray(value, dtype=np.float64) + self._crpix = np.array(value, dtype=np.float64) @property def zeropoint(self) -> ArrayLike: @@ -198,6 +198,18 @@ def pixelscale(self): """ return backend.sqrt(self.pixel_area) + @forward + def pixel_collecting_area(self, I_, J_, upsample, CD): + """The area of the sky that each pixel collects light from, in arcsec^2. + This is just the pixel area, but can be overridden for certain types of + images (e.g. SIP images) where the pixel collecting area is not the same + as the pixel area.""" + return backend.abs(backend.linalg.det(CD)) / upsample**2 + + @property + def flip_ra_axis(self): + return np.linalg.det(self.CD.npvalue) < 0 + @forward def pixel_to_plane( self, @@ -205,8 +217,10 @@ def pixel_to_plane( j: ArrayLike, crtan: ArrayLike, CD: ArrayLike, + _crpix: Optional[ArrayLike] = None, ) -> Tuple[ArrayLike, ArrayLike]: - return func.pixel_to_plane_linear(i, j, *self.crpix, CD, *crtan) + crpix = self.crpix if _crpix is None else _crpix + return func.pixel_to_plane_linear(i, j, *crpix, CD, *crtan) @forward def plane_to_pixel( @@ -215,8 +229,10 @@ def plane_to_pixel( y: ArrayLike, crtan: ArrayLike, CD: ArrayLike, + _crpix: Optional[ArrayLike] = None, ) -> Tuple[ArrayLike, ArrayLike]: - return func.plane_to_pixel_linear(x, y, *self.crpix, CD, *crtan) + crpix = self.crpix if _crpix is None else _crpix + return func.plane_to_pixel_linear(x, y, *crpix, CD, *crtan) @forward def plane_to_world( @@ -248,21 +264,37 @@ def pixel_to_world(self, i: ArrayLike, j: ArrayLike) -> Tuple[ArrayLike, ArrayLi """ return self.plane_to_world(*self.pixel_to_plane(i, j)) - def pixel_center_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: + def pixel_center_meshgrid(self, window=None, pad=0, upsample=1) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" - return func.pixel_center_meshgrid(self._data.shape, config.DTYPE, config.DEVICE) + if window is None: + window = self.window + return func.pixel_center_meshgrid(window.extent, pad, upsample, config.DTYPE, config.DEVICE) - def pixel_corner_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: + def pixel_corner_meshgrid(self, window=None, pad=0, upsample=1) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" - return func.pixel_corner_meshgrid(self._data.shape, config.DTYPE, config.DEVICE) + if window is None: + window = self.window + return func.pixel_corner_meshgrid(window.extent, pad, upsample, config.DTYPE, config.DEVICE) - def pixel_simpsons_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: + def pixel_simpsons_meshgrid( + self, window=None, pad=0, upsample=1 + ) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" - return func.pixel_simpsons_meshgrid(self._data.shape, config.DTYPE, config.DEVICE) + if window is None: + window = self.window + return func.pixel_simpsons_meshgrid( + window.extent, pad, upsample, config.DTYPE, config.DEVICE + ) - def pixel_quad_meshgrid(self, order=3) -> Tuple[ArrayLike, ArrayLike]: + def pixel_quad_meshgrid( + self, window=None, pad=0, upsample=1, order=3 + ) -> Tuple[ArrayLike, ArrayLike]: """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" - return func.pixel_quad_meshgrid(self._data.shape, config.DTYPE, config.DEVICE, order=order) + if window is None: + window = self.window + return func.pixel_quad_meshgrid( + window.extent, pad, upsample, config.DTYPE, config.DEVICE, order=order + ) @forward def coordinate_center_meshgrid(self) -> Tuple[ArrayLike, ArrayLike]: @@ -331,6 +363,8 @@ def crop(self, pixels: Union[int, Tuple[int, int], Tuple[int, int, int, int]], * crop - (int, int): crop each dimension by the number of pixels given. new shape (N - 2*crop[1], M - 2*crop[0]) crop - (int, int, int, int): crop each side by the number of pixels given assuming (x low, x high, y low, y high). new shape (N - crop[2] - crop[3], M - crop[0] - crop[1]) """ + if np.all(np.array(pixels) == 0): + return self if isinstance(pixels, int): data = self._data[ pixels : self._data.shape[0] - pixels, @@ -476,7 +510,7 @@ def load(self, filename: Union[str, fits.HDUList], hduext: int = 0): self.crtan = (hdulist[hduext].header["CRTAN1"], hdulist[hduext].header["CRTAN2"]) if "MAGZP" in hdulist[hduext].header and hdulist[hduext].header["MAGZP"] > -998: self.zeropoint = hdulist[hduext].header["MAGZP"] - self.identity = hdulist[hduext].header.get("IDNTY", str(id(self))) + self._identity = hdulist[hduext].header.get("IDNTY", str(id(self))) return hdulist def corners( @@ -500,12 +534,15 @@ def corners( upright = self.pixel_to_plane(*pixel_upright) return (lowleft, lowright, upright, upleft) - @torch.no_grad() def get_indices(self, other: Window): if other.image is self: return slice(max(0, other.i_low), min(self._data.shape[0], other.i_high)), slice( max(0, other.j_low), min(self._data.shape[1], other.j_high) ) + if other.image.identity != self.identity: + config.logger.warning( + f"Attempting to match windows with different images! Window image: {other.image.name}, {other.image.identity}, self image: {self.name}, {self.identity}. This may fail unless you are sure the two images are on the same pixel grid." + ) shift = np.round(self.crpix - other.crpix).astype(int) return slice( min(max(0, other.i_low + shift[0]), self._data.shape[0]), @@ -515,9 +552,8 @@ def get_indices(self, other: Window): max(0, min(other.j_high + shift[1], self._data.shape[1])), ) - @torch.no_grad() def get_other_indices(self, other: Window): - if other.image == self: + if other.image == self: # fixme check identity, or check "is"? shape = other.shape return slice( max(0, -other.i_low), min(self._data.shape[0] - other.i_low, shape[0]) @@ -588,9 +624,10 @@ def __getitem__(self, *args): return super().__getitem__(*args) +# fixme, make image lists infinitely nestable, need to merge "index" and "match_indices" in some consistent way class ImageList(Module): - def __init__(self, images, name=None): - super().__init__(name=name) + def __init__(self, images: list[Image], **kwargs): + super().__init__(**kwargs) self.images = list(images) if not all(isinstance(image, Image) for image in self.images): raise InvalidImage( @@ -605,6 +642,19 @@ def data(self): def _data(self): return tuple(image._data for image in self.images) + @_data.setter + def _data(self, value): + if len(value) != len(self.images): + raise ValueError( + f"Expected an object of length {len(self.images)} for _data, but got {type(value)} of length {len(value)}" + ) + for image, data in zip(self.images, value): + image._data = data + + @property + def window(self): + return WindowList(tuple(image.window for image in self.images)) + def copy(self): return self.__class__( tuple(image.copy() for image in self.images), @@ -732,3 +782,65 @@ def __getitem__(self, *args): def __iter__(self): return (img for img in self.images) + + +class ImageBatchMixin: + """Specialized ImageList type where the images are all the same size. + + An ImageBatch has restrictions on the shape of the images it can hold, but + in exchange it allows vectorized operations over a batch of images. + + Some notes to keep in mind: + - All the images must be the regular image type (i.e. not SIP or CMOS images yet). + - All the images must have the same shape, otherwise the batch operations will not work. + - The ImageBatch does not itself accelerate any operations, it facilitates the BatchSceneModel. + - Otherwise the ImageBatch behaves like a regular ImageList. + + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not all(isinstance(image, Image) for image in self.images): + raise InvalidImage( + f"ImageBatch can only hold Image objects, not {tuple(type(image) for image in self.images)}" + ) + if not all(isinstance(image, self.images[0].__class__) for image in self.images): + raise InvalidImage( + f"ImageBatch images must all be of the same type, not {tuple(type(image) for image in self.images)}" + ) + if not all(image.data.shape == self.images[0].data.shape for image in self.images): + raise InvalidImage( + f"All images in an ImageBatch must have the same shape, but got shapes {tuple(image.data.shape for image in self.images)}" + ) + + @property + def data(self): + return backend.stack(tuple(image.data for image in self.images), dim=0) + + @ImageList._data.getter + def _data(self): + return backend.stack(tuple(image._data for image in self.images), dim=0) + + @property + def window(self): + return WindowBatch(tuple(image.window for image in self.images)) + + @property + def crval(self): + return backend.stack(tuple(image.crval.value for image in self.images), dim=0) + + @property + def crtan(self): + return backend.stack(tuple(image.crtan.value for image in self.images), dim=0) + + @property + def CD(self): + return backend.stack(tuple(image.CD.value for image in self.images), dim=0) + + @property + def crpix(self): + return backend.as_array( + np.stack(tuple(image.crpix for image in self.images), axis=0), + dtype=config.DTYPE, + device=config.DEVICE, + ) diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index caaef243..0d64ab1a 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -1,6 +1,6 @@ from typing import List, Union -from .image_object import Image, ImageList +from .image_object import Image, ImageList, ImageBatchMixin from ..errors import SpecificationConflict, InvalidImage from ..backend_obj import backend @@ -113,3 +113,7 @@ def match_parameters(self, other: Union[JacobianImage, "JacobianImageList", List self_i.append(self.parameters.index(other_param)) other_i.append(i) return self_i, other_i + + +class JacobianImageBatch(ImageBatchMixin, JacobianImageList): + pass diff --git a/astrophot/image/mixins/cmos_mixin.py b/astrophot/image/mixins/cmos_mixin.py index f3ac2c05..1093d365 100644 --- a/astrophot/image/mixins/cmos_mixin.py +++ b/astrophot/image/mixins/cmos_mixin.py @@ -2,6 +2,7 @@ from .. import func from ... import config +from ...param import forward class CMOSMixin: @@ -29,10 +30,17 @@ def base_scale(self): """Get the base scale of the image, which is the subpixel scale.""" return self.subpixel_scale - def pixel_center_meshgrid(self): + @forward + def pixel_collecting_area(self, I_, J_, upsample): + # CMOS pixels only sensitive in sub area, so scale the pixel collecting area + return self.pixel_area * self.subpixel_scale**2 / upsample**2 + + def pixel_center_meshgrid(self, window=None, pad=0, upsample=1): """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" + if window is None: + window = self.window return func.cmos_pixel_center_meshgrid( - self._data.shape, self.subpixel_loc, config.DTYPE, config.DEVICE + window.extent, pad, upsample, self.subpixel_loc, config.DTYPE, config.DEVICE ) def copy(self, **kwargs): diff --git a/astrophot/image/mixins/data_mixin.py b/astrophot/image/mixins/data_mixin.py index 88bfde35..feece985 100644 --- a/astrophot/image/mixins/data_mixin.py +++ b/astrophot/image/mixins/data_mixin.py @@ -59,8 +59,8 @@ def __init__( self.weight = weight # Set nan pixels to be masked automatically - if backend.any(backend.isnan(self._data)).item(): - self._mask = self._mask | backend.isnan(self._data) + self._mask = self._mask | backend.isnan(self._data) + self._data = backend.nan_to_num(self._data, nan=0.0) @property def std(self): @@ -230,7 +230,7 @@ def copy_kwargs(self, **kwargs): """ kwargs = {"_mask": self._mask, "_weight": self._weight, **kwargs} - return super().copy_kwargs(**kwargs) + return getattr(super(), "copy_kwargs", lambda **kw: kw)(**kwargs) def get_window(self, other: Union[Image, Window], indices=None, **kwargs): """Get a sub-region of the image as defined by an other image on the sky.""" diff --git a/astrophot/image/mixins/sip_mixin.py b/astrophot/image/mixins/sip_mixin.py index 0acc0457..7a232e04 100644 --- a/astrophot/image/mixins/sip_mixin.py +++ b/astrophot/image/mixins/sip_mixin.py @@ -67,9 +67,15 @@ def plane_to_pixel( dJ = interp2d(self.distortion_IJ[1], I, J, padding_mode="border") return I + dI, J + dJ + @forward + def pixel_collecting_area(self, I_, J_, upsample=1): + # CMOS pixels only sensitive in sub area, so scale the pixel collecting area + return interp2d(self.pixel_area_map, I_, J_, padding_mode="border") / upsample**2 + @property + @forward def pixel_area_map(self): - return self._pixel_area_map + return self._pixel_area_map * self.pixel_area @property def A_ORDER(self) -> int: @@ -131,7 +137,7 @@ def update_distortion_model( # Pixel area map ############################################################# if pixel_area_map is not None: - self._pixel_area_map = pixel_area_map + self._pixel_area_map = pixel_area_map / self.pixel_area return i, j = self.pixel_corner_meshgrid() x, y = self.pixel_to_plane(i, j) @@ -153,7 +159,7 @@ def update_distortion_model( + x[:-1, :-1] * y[1:, :-1] ) ) - self._pixel_area_map = backend.abs(A) + self._pixel_area_map = backend.abs(A) / self.pixel_area def to(self, dtype=None, device=None): if dtype is None: diff --git a/astrophot/image/model_image.py b/astrophot/image/model_image.py index 3d969338..034b08d0 100644 --- a/astrophot/image/model_image.py +++ b/astrophot/image/model_image.py @@ -1,4 +1,4 @@ -from .image_object import Image, ImageList +from .image_object import Image, ImageList, ImageBatchMixin from ..errors import InvalidImage __all__ = ["ModelImage", "ModelImageList"] @@ -14,9 +14,6 @@ class ModelImage(Image): """ - def fluxdensity_to_flux(self): - self._data = self._data * self.pixel_area - ###################################################################### class ModelImageList(ImageList): @@ -28,3 +25,7 @@ def __init__(self, *args, **kwargs): raise InvalidImage( f"Model_Image_List can only hold Model_Image objects, not {tuple(type(image) for image in self.images)}" ) + + +class ModelImageBatch(ImageBatchMixin, ModelImageList): + pass diff --git a/astrophot/image/psf_image.py b/astrophot/image/psf_image.py index d0cc05b5..82a071af 100644 --- a/astrophot/image/psf_image.py +++ b/astrophot/image/psf_image.py @@ -1,17 +1,19 @@ -from typing import List, Optional - +from typing import List, Optional, Union import numpy as np -from .image_object import Image +from ..errors import SpecificationConflict, InvalidImage + from .jacobian_image import JacobianImage from .. import config from ..backend_obj import backend, ArrayLike from .mixins import DataMixin +from .window import Window +from . import func -__all__ = ["PSFImage"] +__all__ = ("PSFImage",) -class PSFImage(DataMixin, Image): +class PSFImage(DataMixin): """Image object which represents a model of PSF (Point Spread Function). PSFImage inherits from the base Image class and represents the model of a point spread function. @@ -20,20 +22,209 @@ class PSFImage(DataMixin, Image): The shape of the PSF data should be odd (for your sanity) but this is not enforced. """ - def __init__(self, *args, **kwargs): - kwargs.update({"crval": (0, 0), "crpix": (0, 0), "crtan": (0, 0)}) - super().__init__(*args, **kwargs) - self.crpix = (np.array(self._data.shape[:2], dtype=np.float64) - 1.0) / 2 + base_scale = 1.0 + + def __init__( + self, + *, + data: Optional[ArrayLike] = None, + upsample: int = 1, + crpix: Optional[tuple[int, int]] = None, + filename: Optional[str] = None, + hduext: int = 0, + identity: str = None, + _data: Optional[ArrayLike] = None, + **kwargs, + ): + if _data is None: + self.data = data + else: + self._data = _data + + super().__init__(**kwargs) + self.upsample = upsample + self.crpix = crpix + + if identity is None: + self._identity = id(self) + else: + self._identity = identity + + if filename is not None: + self.load(filename, hduext=hduext) + return def normalize(self): """Normalizes the PSF image to have a sum of 1.""" - norm = backend.sum(self._data) + norm = backend.sum(self._data, dim=(-2, -1), keepdim=True) self._data = self._data / norm self._weight = self._weight * norm**2 @property - def psf_pad(self) -> int: - return max(self._data.shape[:2]) // 2 + def identity(self): + return self._identity + + @property + def window(self) -> Window: + return Window(window=((0, 0), self._data.shape[:2]), image=self) + + @property + def data(self): + """The image data, which is a tensor of pixel values.""" + return backend.transpose(self._data, 1, 0) + + @data.setter + def data(self, value: Optional[ArrayLike]): + """Set the image data. If value is None, the data is initialized to an empty tensor.""" + if value is None: + self._data = backend.ones((1, 1), dtype=config.DTYPE, device=config.DEVICE) + else: + assert all( + s % 2 == 1 for s in value.shape[-2:] + ), "PSF data shape must be odd in both dimensions." + # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates + self._data = backend.transpose( + backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE), 1, 0 + ) + + @property + def upsample(self) -> int: + return self._upsample + + @upsample.setter + def upsample(self, value: int): + if value < 1: + raise ValueError("upsample factor must be a positive integer.") + self._upsample = int(value) + + @property + def pad(self) -> int: + return max(self._data.shape[-2:]) // 2 + + @property + def crpix(self): + if self._crpix is None: + return np.array([self._data.shape[-2] // 2, self._data.shape[-1] // 2]) + return self._crpix + + @crpix.setter + def crpix(self, value): + if value is None: + self._crpix = None + else: + self._crpix = np.array(value) + + @property + def pixel_area(self): + return backend.as_array(1.0 / self.upsample**2, dtype=config.DTYPE, device=config.DEVICE) + + @property + def pixelscale(self): + return backend.as_array(1.0 / self.upsample, dtype=config.DTYPE, device=config.DEVICE) + + @property + def flip_ra_axis(self): + return False + + @property + def zeropoint(self): + return None + + def flatten(self, attribute: str = "data") -> ArrayLike: + return backend.flatten(getattr(self, attribute), end_dim=1) + + def pixel_collecting_area(self, *args, **kwargs): + return 1.0 + + def targpixel_to_mypixel(self, I_, J_): + """ + Convert between coordinate spaces. "targpixel" refers to the pixel + coordinates of the target of this PSF, which have the origin at the + center of the PSF and a step of 1 corresponds to one target pixel + length. "mypixel" refers to the pixel coordinates of this PSF image, + which have an origin at the center of the [0,0] pixel and a step of 1 + corresponds to one PSF pixel length (which is 1/upsample of a target + pixel length). + """ + return I_ * self.upsample + self.crpix[0], J_ * self.upsample + self.crpix[1] + + def mypixel_to_targpixel(self, i, j): + """ + Convert between coordinate spaces. "targpixel" refers to the pixel + coordinates of the target of this PSF, which have the origin at the + center of the PSF and a step of 1 corresponds to one target pixel + length. "mypixel" refers to the pixel coordinates of this PSF image, + which have an origin at the center of the [0,0] pixel and a step of 1 + corresponds to one PSF pixel length (which is 1/upsample of a target + pixel length). + """ + return (i - self.crpix[0]) / self.upsample, (j - self.crpix[1]) / self.upsample + + def pixel_center_meshgrid(self, window=None, pad=0, upsample=1) -> tuple[ArrayLike, ArrayLike]: + """Get a meshgrid of pixel coordinates in the image, centered on the pixel grid.""" + if window is None: + window = self.window + return func.pixel_center_meshgrid(window.extent, pad, upsample, config.DTYPE, config.DEVICE) + + def pixel_corner_meshgrid(self, window=None, pad=0, upsample=1) -> tuple[ArrayLike, ArrayLike]: + """Get a meshgrid of pixel coordinates in the image, with corners at the pixel grid.""" + if window is None: + window = self.window + return func.pixel_corner_meshgrid(window.extent, pad, upsample, config.DTYPE, config.DEVICE) + + def pixel_simpsons_meshgrid( + self, window=None, pad=0, upsample=1 + ) -> tuple[ArrayLike, ArrayLike]: + """Get a meshgrid of pixel coordinates in the image, with Simpson's rule sampling.""" + if window is None: + window = self.window + return func.pixel_simpsons_meshgrid( + window.extent, pad, upsample, config.DTYPE, config.DEVICE + ) + + def pixel_quad_meshgrid( + self, window=None, pad=0, upsample=1, order=3 + ) -> tuple[ArrayLike, ArrayLike]: + """Get a meshgrid of pixel coordinates in the image, with quadrature sampling.""" + if window is None: + window = self.window + return func.pixel_quad_meshgrid( + window.extent, pad, upsample, config.DTYPE, config.DEVICE, order=order + ) + + def coordinate_center_meshgrid(self) -> tuple[ArrayLike, ArrayLike]: + i, j = self.pixel_center_meshgrid() + return self.mypixel_to_targpixel(i, j) + + def coordinate_corner_meshgrid(self) -> tuple[ArrayLike, ArrayLike]: + i, j = self.pixel_corner_meshgrid() + return self.mypixel_to_targpixel(i, j) + + def coordinate_simpsons_meshgrid(self) -> tuple[ArrayLike, ArrayLike]: + i, j = self.pixel_simpsons_meshgrid() + return self.mypixel_to_targpixel(i, j) + + def coordinate_quad_meshgrid(self, order=3) -> tuple[ArrayLike, ArrayLike, ArrayLike]: + i, j, _ = self.pixel_quad_meshgrid(order=order) + return self.mypixel_to_targpixel(i, j) + + def get_indices(self, other: Window): + if other.image is self: + return slice(max(0, other.i_low), min(self._data.shape[0], other.i_high)), slice( + max(0, other.j_low), min(self._data.shape[1], other.j_high) + ) + if other.image.identity == self.identity: + shift = np.round(self.crpix - other.crpix).astype(int) + return slice( + min(max(0, other.i_low + shift[0]), self._data.shape[0]), + max(0, min(other.i_high + shift[0], self._data.shape[0])), + ), slice( + min(max(0, other.j_low + shift[1]), self._data.shape[1]), + max(0, min(other.j_high + shift[1], self._data.shape[1])), + ) + raise RuntimeError( + f"Cannot get indices for window with different image! Window image: {other.image.name}, self image: {self.name}" + ) def jacobian_image( self, @@ -54,46 +245,102 @@ def jacobian_image( device=config.DEVICE, ) kwargs = { - "CD": self.CD.value, + "CD": ((1, 0), (0, 1)), "crpix": self.crpix, - "crtan": self.crtan.value, - "crval": self.crval.value, - "zeropoint": self.zeropoint, + "crtan": (0, 0), + "crval": (0, 0), "identity": self.identity, + "_data": data, **kwargs, } - return JacobianImage(parameters=parameters, _data=data, **kwargs) + return JacobianImage(parameters=parameters, **kwargs) - def model_image(self, **kwargs) -> "PSFImage": + def model_image(self, window=None, **kwargs) -> "PSFImage": """ Construct a blank `ModelImage` object formatted like this current `TargetImage` object. Mostly used internally. """ + if window is None: + window = self.window + si, sj = self.get_indices(window) kwargs = { - "_data": backend.zeros_like(self._data), - "CD": self.CD.value, - "crpix": self.crpix, - "crtan": self.crtan.value, - "crval": self.crval.value, + "_data": backend.zeros_like(self._data[si, sj]), + "crpix": self.crpix - np.array((si.start, sj.start)), + "upsample": self.upsample, "identity": self.identity, **kwargs, } return PSFImage(**kwargs) - @property - def zeropoint(self): - return None + def fits_info(self) -> dict: + return { + "UPSMPL": self.upsample, + "CRPIX1": self.crpix[0], + "CRPIX2": self.crpix[1], + "IDNTY": self.identity, + } + + def __iadd__(self, other: Union["PSFImage", ArrayLike]): + if isinstance(other, (int, float, backend.array_type)): + self._data = self._data + other + return self + if not isinstance(other, PSFImage): + raise InvalidImage(f"PSF images can only add with each other, not: {type(other)}") - @zeropoint.setter - def zeropoint(self, value): - """PSFImage does not support zeropoint.""" - pass + if self.upsample != other.upsample: + raise SpecificationConflict("Cannot add PSF images with different upsample factors.") - def plane_to_world(self, x, y): - raise NotImplementedError( - "PSFImage does not support plane_to_world conversion. There is no meaningful world position of a PSF image." + islice = slice( + max(0, self.crpix[0] - other.crpix[0]), + min(self._data.shape[-2], self.crpix[0] + other._data.shape[-2] - other.crpix[0]), ) + jslice = slice( + max(0, self.crpix[1] - other.crpix[1]), + min(self._data.shape[-1], self.crpix[1] + other._data.shape[-1] - other.crpix[1]), + ) + self._data = backend.add_at_indices(self._data, (islice, jslice), other._data) + return self + + def __sub__(self, other: "PSFImage"): + return self.copy(_data=self._data - other._data) - def world_to_plane(self, ra, dec): - raise NotImplementedError( - "PSFImage does not support world_to_plane conversion. There is no meaningful world position of a PSF image." + def copy_kwargs(self, **kwargs) -> dict: + kwargs = { + "_data": backend.copy(self._data), + "crpix": self.crpix, + "upsample": self.upsample, + "identity": self.identity, + **kwargs, + } + return super().copy_kwargs(**kwargs) + + def copy(self, **kwargs): + """Produce a copy of this image with all of the same properties. This + can be used when one wishes to make temporary modifications to + an image and then will want the original again. + + """ + return self.__class__(**self.copy_kwargs(**kwargs)) + + def reduce(self, scale: int): + return self.copy(upsample=self.upsample * scale) + + def get_window(self, other: Union[Window, "Image"], indices=None, **kwargs): + """Get a new image object which is a window of this image + corresponding to the other image's window. This will return a + new image object with the same properties as this one, but with + the data cropped to the other image's window. + + """ + if indices is None: + indices = self.get_indices(other if isinstance(other, Window) else other.window) + new_img = self.copy( + _data=self._data[indices], + crpix=self.crpix - np.array((indices[0].start, indices[1].start)), + **kwargs, ) + return new_img + + def __getitem__(self, *args): + if len(args) == 1 and isinstance(args[0], Window): + return self.get_window(args[0]) + return super().__getitem__(*args) diff --git a/astrophot/image/sip_image.py b/astrophot/image/sip_image.py index 8e921be7..32de11b8 100644 --- a/astrophot/image/sip_image.py +++ b/astrophot/image/sip_image.py @@ -95,9 +95,6 @@ def reduce(self, scale: int, **kwargs): **kwargs, ) - def fluxdensity_to_flux(self): - self._data = self._data * self.pixel_area_map - class SIPTargetImage(SIPMixin, TargetImage): """ diff --git a/astrophot/image/target_image.py b/astrophot/image/target_image.py index cb047d37..e3f7db6b 100644 --- a/astrophot/image/target_image.py +++ b/astrophot/image/target_image.py @@ -3,9 +3,12 @@ import numpy as np from astropy.io import fits -from .image_object import Image, ImageList -from .jacobian_image import JacobianImage, JacobianImageList -from .model_image import ModelImage, ModelImageList + +from ..image.window import Window +from ..param import forward +from .image_object import Image, ImageList, ImageBatchMixin +from .jacobian_image import JacobianImage, JacobianImageList, JacobianImageBatch +from .model_image import ModelImage, ModelImageList, ModelImageBatch from .psf_image import PSFImage from .. import config from ..backend_obj import backend, ArrayLike @@ -13,7 +16,7 @@ from .mixins import DataMixin from ..utils.decorators import combine_docstrings -__all__ = ["TargetImage", "TargetImageList"] +__all__ = ("TargetImage", "TargetImageList", "TargetImageBatch") @combine_docstrings @@ -84,33 +87,20 @@ class TargetImage(DataMixin, Image): def __init__(self, *args, psf=None, **kwargs): super().__init__(*args, **kwargs) - - if not self.has_psf: + if not hasattr(self, "_psf"): self.psf = psf @property def has_psf(self) -> bool: """Returns True when the target image object has a PSF model.""" try: - return self._psf is not None + return self.psf is not None except AttributeError: return False @property def psf(self): - """The PSF for the `TargetImage`. This is used to convolve the - model with the PSF before evaluating the likelihood. The PSF - should be a `PSFImage` object or an `AstroPhot` PSFModel. - - If no PSF is provided, then the image will not be convolved - with a PSF and the model will be evaluated directly on the - image pixels. - - """ - try: - return self._psf - except AttributeError: - return None + return self._psf @psf.setter def psf(self, psf): @@ -123,8 +113,6 @@ def psf(self, psf): the psf may have a pixelscale of 1, 1/2, 1/3, 1/4 and so on. """ - if hasattr(self, "_psf"): - del self._psf # remove old psf if it exists from ..models import Model if psf is None: @@ -134,11 +122,7 @@ def psf(self, psf): elif isinstance(psf, Model): self._psf = psf else: - self._psf = PSFImage( - data=psf, - CD=self.CD, - name=self.name + "_psf", - ) + self._psf = PSFImage(data=psf) def copy_kwargs(self, **kwargs): kwargs = {"psf": self.psf, **kwargs} @@ -156,7 +140,7 @@ def fits_images(self): ) ) else: - config.logger.warning("Unable to save PSF to FITS, not a PSF_Image.") + config.logger.warning("Unable to save PSF to FITS, not a PSFImage.") return images def load(self, filename: str, hduext: int = 0): @@ -166,12 +150,15 @@ def load(self, filename: str, hduext: int = 0): """ hdulist = super().load(filename, hduext=hduext) if "PSF" in hdulist: + crpix = ( + hdulist["PSF"].header.get("CRPIX1", None), + hdulist["PSF"].header.get("CRPIX2", None), + ) self.psf = PSFImage( data=np.array(hdulist["PSF"].data, dtype=np.float64), - CD=( - (hdulist["PSF"].header["CD1_1"], hdulist["PSF"].header["CD1_2"]), - (hdulist["PSF"].header["CD2_1"], hdulist["PSF"].header["CD2_2"]), - ), + upsample=hdulist["PSF"].header.get("UPSMPL", 1), + crpix=None if None in crpix else crpix, + identity=hdulist["PSF"].header.get("IDNTY", None), ) return hdulist @@ -195,28 +182,28 @@ def jacobian_image( "crpix": self.crpix, "crtan": self.crtan.value, "crval": self.crval.value, - "zeropoint": self.zeropoint, "identity": self.identity, "name": self.name + "_jacobian", + "_data": data, **kwargs, } - return JacobianImage(parameters=parameters, _data=data, **kwargs) + return JacobianImage(parameters=parameters, **kwargs) - def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> ModelImage: + def model_image( + self, + window: Window = None, + **kwargs, + ) -> ModelImage: """ Construct a blank `ModelImage` object formatted like this current `TargetImage` object. Mostly used internally. """ + if window is None: + window = self.window + si, sj = self.get_indices(window) kwargs = { - "_data": backend.zeros( - ( - self._data.shape[0] * upsample + 2 * pad, - self._data.shape[1] * upsample + 2 * pad, - ), - dtype=config.DTYPE, - device=config.DEVICE, - ), - "CD": self.CD.value / upsample, - "crpix": (self.crpix + 0.5) * upsample + pad - 0.5, + "_data": backend.zeros_like(self._data[si, sj]), + "CD": self.CD.value, + "crpix": self.crpix - np.array((si.start, sj.start)), "crtan": self.crtan.value, "crval": self.crval.value, "zeropoint": self.zeropoint, @@ -226,12 +213,11 @@ def model_image(self, upsample: int = 1, pad: int = 0, **kwargs) -> ModelImage: } return ModelImage(**kwargs) - def psf_image(self, data: ArrayLike, upscale: int = 1, **kwargs) -> PSFImage: + def psf_image(self, data: ArrayLike, upsample: int = 1, **kwargs) -> PSFImage: kwargs = { "data": data, - "CD": self.CD.value / upscale, "identity": self.identity, - "name": self.name + "_psf", + "upsample": upsample, **kwargs, } return PSFImage(**kwargs) @@ -298,8 +284,14 @@ def jacobian_image( list(image.jacobian_image(parameters, dat) for image, dat in zip(self.images, data)) ) - def model_image(self) -> ModelImageList: - return ModelImageList(list(image.model_image() for image in self.images)) + def model_image(self, window=None) -> ModelImageList: + if window is None: + window = self.window + new_list = [] + for other_window in window: + i = self.index(other_window.image) + new_list.append(self.images[i].model_image(other_window)) + return ModelImageList(new_list) @property def mask(self): @@ -326,3 +318,50 @@ def psf(self, psf): @property def has_psf(self) -> bool: return any(image.has_psf for image in self.images) + + +class TargetImageBatch(ImageBatchMixin, TargetImageList): + @property + def variance(self): + return backend.stack(tuple(image.variance for image in self.images), dim=0) + + @property + def weight(self): + return backend.stack(tuple(image.weight for image in self.images), dim=0) + + @property + def mask(self): + return backend.stack(tuple(image.mask for image in self.images), dim=0) + + @property + @forward + def psf_stack(self): + res = [] + for image in self.images: + if image.has_psf: + if isinstance(image.psf, PSFImage): + res.append(image.psf.data) + else: + res.append(image.psf()._data) + else: + return None + + return backend.stack(res, dim=0) + + def model_image(self, window=None) -> ModelImageBatch: + if window is None: + window = self.window + new_list = [] + for other_window in window: + i = self.index(other_window.image) + new_list.append(self.images[i].model_image(other_window)) + return ModelImageBatch(new_list) + + def jacobian_image( + self, parameters: List[str], data: Optional[List[ArrayLike]] = None + ) -> JacobianImageBatch: + if data is None: + data = tuple(None for _ in range(len(self.images))) + return JacobianImageBatch( + list(image.jacobian_image(parameters, dat) for image, dat in zip(self.images, data)) + ) diff --git a/astrophot/image/window.py b/astrophot/image/window.py index 397e3cde..146f0004 100644 --- a/astrophot/image/window.py +++ b/astrophot/image/window.py @@ -4,7 +4,7 @@ from ..errors import InvalidWindow -__all__ = ("Window",) +__all__ = ("Window", "WindowList", "WindowBatch") class Window: @@ -164,3 +164,21 @@ def __len__(self): def __iter__(self): return iter(self.windows) + + +class WindowBatch(WindowList): + def __init__(self, windows: list[Window]): + super().__init__(windows) + if not all(window.shape == self.windows[0].shape for window in self.windows): + raise InvalidWindow("All windows in a WindowBatch must have the same shape.") + + def origin_shifter(self, other: Window) -> np.ndarray: + """Returns the shift in origin between this window batch and another window.""" + if not isinstance(other, Window): + raise TypeError(f"Can only compute origin shift with a Window, not {type(other)}") + shifts = [] + for window in self.windows: + shift_i = other.i_low - window.i_low + shift_j = other.j_low - window.j_low + shifts.append((shift_i, shift_j)) + return np.array(shifts) diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index 6858ddca..f404dcb2 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -13,7 +13,7 @@ from .point_source import PointSource # subtypes of PSFModel -from .basis import PixelBasisPSF +from .basis_psf import PixelBasisPSF from .airy import AiryPSF from .pixelated_psf import PixelatedPSF @@ -26,75 +26,57 @@ from .edgeon import EdgeonModel, EdgeonSech, EdgeonIsothermal from .multi_gaussian_expansion import MultiGaussianExpansion from .gaussian_ellipsoid import GaussianEllipsoid +from .pixelated_model import Pixelated +from .basis import BasisModel + +# Batch model +from .batch_model_object import BatchModel # Standard models based on a core radial profile -from .sersic import ( +from .radial import ( SersicGalaxy, - SersicPSF, SersicFourierEllipse, SersicSuperEllipse, SersicWarp, SersicRay, SersicWedge, -) -from .exponential import ( ExponentialGalaxy, - ExponentialPSF, ExponentialSuperEllipse, ExponentialFourierEllipse, ExponentialWarp, ExponentialRay, ExponentialWedge, -) -from .gaussian import ( GaussianGalaxy, - GaussianPSF, GaussianSuperEllipse, GaussianFourierEllipse, GaussianWarp, GaussianRay, GaussianWedge, -) -from .moffat import ( MoffatGalaxy, - MoffatPSF, - Moffat2DPSF, MoffatFourierEllipse, MoffatRay, MoffatWedge, MoffatWarp, MoffatSuperEllipse, -) -from .ferrer import ( FerrerGalaxy, - FerrerPSF, FerrerSuperEllipse, FerrerFourierEllipse, FerrerWarp, FerrerRay, FerrerWedge, -) -from .king import ( KingGalaxy, - KingPSF, KingSuperEllipse, KingFourierEllipse, KingWarp, KingRay, KingWedge, -) -from .nuker import ( NukerGalaxy, - NukerPSF, NukerFourierEllipse, NukerSuperEllipse, NukerWarp, NukerRay, NukerWedge, -) -from .spline import ( SplineGalaxy, - SplinePSF, SplineFourierEllipse, SplineSuperEllipse, SplineWarp, @@ -102,6 +84,48 @@ SplineWedge, ) +from .radial_psf import ( + SersicPSF, + SersicPSFEllipse, + SersicPSFSuperEllipse, + SersicPSFFourierEllipse, + SersicPSFWarp, + ExponentialPSF, + ExponentialPSFEllipse, + ExponentialPSFSuperEllipse, + ExponentialPSFFourierEllipse, + ExponentialPSFWarp, + GaussianPSF, + GaussianPSFEllipse, + GaussianPSFSuperEllipse, + GaussianPSFFourierEllipse, + GaussianPSFWarp, + MoffatPSF, + MoffatPSFEllipse, + MoffatPSFSuperEllipse, + MoffatPSFFourierEllipse, + MoffatPSFWarp, + FerrerPSF, + FerrerPSFEllipse, + FerrerPSFSuperEllipse, + FerrerPSFFourierEllipse, + FerrerPSFWarp, + KingPSF, + KingPSFEllipse, + KingPSFSuperEllipse, + KingPSFFourierEllipse, + KingPSFWarp, + NukerPSF, + NukerPSFEllipse, + NukerPSFSuperEllipse, + NukerPSFFourierEllipse, + NukerPSFWarp, + SplinePSF, + SplinePSFEllipse, + SplinePSFSuperEllipse, + SplinePSFFourierEllipse, + SplinePSFWarp, +) from .mixins import ( RadialMixin, WedgeMixin, @@ -152,63 +176,97 @@ "EdgeonIsothermal", "MultiGaussianExpansion", "GaussianEllipsoid", + "Pixelated", + "BasisModel", + "BatchModel", "SersicGalaxy", - "SersicPSF", "SersicFourierEllipse", "SersicSuperEllipse", "SersicWarp", "SersicRay", "SersicWedge", "ExponentialGalaxy", - "ExponentialPSF", "ExponentialSuperEllipse", "ExponentialFourierEllipse", "ExponentialWarp", "ExponentialRay", "ExponentialWedge", "GaussianGalaxy", - "GaussianPSF", "GaussianSuperEllipse", "GaussianFourierEllipse", "GaussianWarp", "GaussianRay", "GaussianWedge", "MoffatGalaxy", - "MoffatPSF", - "Moffat2DPSF", "MoffatFourierEllipse", "MoffatRay", "MoffatWedge", "MoffatWarp", "MoffatSuperEllipse", "FerrerGalaxy", - "FerrerPSF", "FerrerSuperEllipse", "FerrerFourierEllipse", "FerrerWarp", "FerrerRay", "FerrerWedge", "KingGalaxy", - "KingPSF", "KingSuperEllipse", "KingFourierEllipse", "KingWarp", "KingRay", "KingWedge", "NukerGalaxy", - "NukerPSF", "NukerFourierEllipse", "NukerSuperEllipse", "NukerWarp", "NukerRay", "NukerWedge", "SplineGalaxy", - "SplinePSF", "SplineFourierEllipse", "SplineWarp", "SplineSuperEllipse", "SplineRay", "SplineWedge", + "SersicPSF", + "SersicPSFEllipse", + "SersicPSFSuperEllipse", + "SersicPSFFourierEllipse", + "SersicPSFWarp", + "ExponentialPSF", + "ExponentialPSFEllipse", + "ExponentialPSFSuperEllipse", + "ExponentialPSFFourierEllipse", + "ExponentialPSFWarp", + "GaussianPSF", + "GaussianPSFEllipse", + "GaussianPSFSuperEllipse", + "GaussianPSFFourierEllipse", + "GaussianPSFWarp", + "MoffatPSF", + "MoffatPSFEllipse", + "MoffatPSFSuperEllipse", + "MoffatPSFFourierEllipse", + "MoffatPSFWarp", + "FerrerPSF", + "FerrerPSFEllipse", + "FerrerPSFSuperEllipse", + "FerrerPSFFourierEllipse", + "FerrerPSFWarp", + "KingPSF", + "KingPSFEllipse", + "KingPSFSuperEllipse", + "KingPSFFourierEllipse", + "KingPSFWarp", + "NukerPSF", + "NukerPSFEllipse", + "NukerPSFSuperEllipse", + "NukerPSFFourierEllipse", + "NukerPSFWarp", + "SplinePSF", + "SplinePSFEllipse", + "SplinePSFSuperEllipse", + "SplinePSFFourierEllipse", + "SplinePSFWarp", "RadialMixin", "WedgeMixin", "RayMixin", diff --git a/astrophot/models/_shared_methods.py b/astrophot/models/_shared_methods.py index 5a81c017..654bc13e 100644 --- a/astrophot/models/_shared_methods.py +++ b/astrophot/models/_shared_methods.py @@ -39,7 +39,7 @@ def _sample_image( # Bin fluxes by radius if rad_bins is None: rad_bins = np.logspace( - np.log10(R.min() * 0.9 + image.pixelscale / 2), np.log10(R.max() * 1.1), 11 + np.log10(R.min() * 0.9 + image.pixelscale.item() / 2), np.log10(R.max() * 1.1), 11 ) else: rad_bins = np.array(rad_bins) diff --git a/astrophot/models/airy.py b/astrophot/models/airy.py index ffc8d70e..0619c2dd 100644 --- a/astrophot/models/airy.py +++ b/astrophot/models/airy.py @@ -57,16 +57,16 @@ def initialize(self): if self.I0.initialized and self.aRL.initialized: return - icenter = self.target.plane_to_pixel(*self.center.value) + icenter = self.target.targpixel_to_mypixel(*self.center.value) if not self.I0.initialized: mid_chunk = self.target._data[ int(icenter[0]) - 2 : int(icenter[0]) + 2, int(icenter[1]) - 2 : int(icenter[1]) + 2, ] - self.I0.value = backend.mean(mid_chunk) / self.target.pixel_area + self.I0.value = backend.mean(mid_chunk) / self.target.upsample**2 if not self.aRL.initialized: - self.aRL.value = (5.0 / 8.0) * 2 * self.target.pixelscale + self.aRL.value = (5.0 / 8.0) * 2 @forward def radial_model(self, R: ArrayLike, I0: ArrayLike, aRL: ArrayLike) -> ArrayLike: diff --git a/astrophot/models/base.py b/astrophot/models/base.py index 06e166d5..c8f82f47 100644 --- a/astrophot/models/base.py +++ b/astrophot/models/base.py @@ -6,7 +6,7 @@ from caskade import Param as CParam from ..param import Module, forward, Param from ..utils.decorators import classproperty -from ..image import Window, ImageList, ModelImage, ModelImageList +from ..image import Window, ModelImage, ModelImageList from ..errors import UnrecognizedModel, InvalidWindow from .. import config from ..backend_obj import backend, ArrayLike @@ -118,55 +118,33 @@ def build_parameter_specs(self, kwargs, parameter_specs) -> dict: return parameter_specs @forward - def gaussian_log_likelihood( - self, - window: Optional[Window] = None, - ) -> ArrayLike: + def gaussian_log_likelihood(self) -> ArrayLike: """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. """ - if window is None: - window = self.window - model = self(window=window).data - data = self.target[window] - weight = data.weight - mask = data.mask - data = data.data - if isinstance(data, tuple): - nll = 0.5 * sum( - backend.sum(((da - mo) ** 2 * wgt)[~ma]) - for mo, da, wgt, ma in zip(model, data, weight, mask) - ) - else: - nll = 0.5 * backend.sum(((data - model) ** 2 * weight)[~mask]) + model = self().flatten("data") + data = self.target[self.window] + weight = data.flatten("weight") + mask = data.flatten("mask") + data = data.flatten("data") + nll = 0.5 * backend.sum((data - model) ** 2 * weight * (~mask)) return -nll @forward - def poisson_log_likelihood( - self, - window: Optional[Window] = None, - ) -> ArrayLike: + def poisson_log_likelihood(self) -> ArrayLike: """ Compute the negative log likelihood of the model wrt the target image in the appropriate window. """ - if window is None: - window = self.window - model = self(window=window).data - data = self.target[window] - mask = data.mask - data = data.data - - if isinstance(data, tuple): - nll = sum( - backend.sum((mo - da * backend.log(mo + 1e-10) + backend.lgamma(da + 1))[~ma]) - for mo, da, ma in zip(model, data, mask) - ) - else: - nll = backend.sum( - (model - data * backend.log(model + 1e-10) + backend.lgamma(data + 1))[~mask] - ) + model = self().flatten("data") + data = self.target[self.window] + mask = data.flatten("mask") + data = data.flatten("data") + + nll = backend.sum( + (model - data * backend.log(model + 1e-10) + backend.lgamma(data + 1)) * (~mask) + ) return -nll @@ -178,25 +156,25 @@ def hessian(self, likelihood="gaussian"): else: raise ValueError(f"Unknown likelihood type: {likelihood}") - def total_flux(self, window=None) -> ArrayLike: - F = self(window=window) - return backend.sum(F.data) + def total_flux(self) -> ArrayLike: + F = self() + return backend.sum(F.flatten("data")) - def total_flux_uncertainty(self, window=None) -> ArrayLike: - jac = self.jacobian(window=window).flatten("data") + def total_flux_uncertainty(self) -> ArrayLike: + jac = self.jacobian().flatten("data") dF = backend.sum(jac, dim=0) # VJP for sum(total_flux) current_uncertainty = self.build_params_array_uncertainty() return backend.sqrt(backend.sum((dF * current_uncertainty) ** 2)) - def total_magnitude(self, window=None) -> ArrayLike: + def total_magnitude(self) -> ArrayLike: """Compute the total magnitude of the model in the given window.""" - F = self.total_flux(window=window) + F = self.total_flux() return -2.5 * backend.log10(F) + self.target.zeropoint - def total_magnitude_uncertainty(self, window=None) -> ArrayLike: + def total_magnitude_uncertainty(self) -> ArrayLike: """Compute the uncertainty in the total magnitude of the model in the given window.""" - F = self.total_flux(window=window) - dF = self.total_flux_uncertainty(window=window) + F = self.total_flux() + dF = self.total_flux_uncertainty() return 2.5 * (dF / F) / np.log(10) @property @@ -251,7 +229,7 @@ def radius_metric(self, x, y): @forward def angular_metric(self, x, y): - return backend.arctan2(y, x) + return backend.arctan2(y, backend.where(x < 0, x - self.softening, x + self.softening)) def to(self, dtype=None, device=None): if dtype is None: @@ -263,8 +241,8 @@ def to(self, dtype=None, device=None): @forward def __call__( self, - window: Optional[Window] = None, + *args, **kwargs, ) -> Union[ModelImage, ModelImageList]: - return self.sample(window=window, **kwargs) + return self.sample(*args, **kwargs) diff --git a/astrophot/models/basis.py b/astrophot/models/basis.py index 1064943a..364c2bea 100644 --- a/astrophot/models/basis.py +++ b/astrophot/models/basis.py @@ -1,61 +1,61 @@ -from typing import Union, Tuple +from typing import Union import torch import numpy as np -from .psf_model_object import PSFModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d from .. import config +from .model_object import ComponentModel from ..backend_obj import backend, ArrayLike from ..errors import SpecificationConflict from ..param import forward from . import func from ..utils.initialize import polar_decomposition -__all__ = ["BasisPSF"] +__all__ = ["BasisModel"] @combine_docstrings -class PixelBasisPSF(PSFModel): - """point source model which uses multiple images as a basis for the - PSF as its representation for point sources. Using bilinear interpolation it - will shift the PSF within a pixel to accurately represent the center - location of a point source. There is no functional form for this object type - as any image can be supplied. Bilinear interpolation is very fast and - accurate for smooth models, so it is possible to do the expensive - interpolation before optimization and save time. +class BasisModel(ComponentModel): + """Model described by a set of basis images. + + This model is composed of a set of basis images (think eigen decomposition + or zernike polynomials) that are linearly combined with some weights to form + the model image. The basis images are defined on a grid of coordinates, + and the brightness at any point is determined by bilinear interpolation of + the basis images. This is a very flexible model that can represent a wide + range of sources, but depending on the number of basis elements it can become + computationally expensive to optimize. **Parameters:** - - `weights`: The weights of the basis set of images in units of flux. - - `PA`: The position angle of the PSF in radians. - - `scale`: The scale of the PSF in arcseconds per grid unit. + - `weights`: The weights of the basis set of images in units of flux. + - `PA`: the position angle of the model, in radians. + - `scale`: the scale of the model, in arcsec per grid unit. """ _model_type = "basis" _parameter_specs = { - "weights": {"units": "flux", "dynamic": True}, - "PA": {"units": "radians", "shape": (), "dynamic": True}, - "scale": {"units": "arcsec/grid-unit", "shape": (), "dynamic": True}, + "weights": {"units": "unitless", "shape": (None,), "dynamic": True}, + "PA": {"units": "radians", "shape": (), "dynamic": False}, + "scale": {"units": "arcsec/grid-unit", "shape": (), "dynamic": False}, } usable = True def __init__(self, *args, basis: Union[str, ArrayLike] = "zernike:3", **kwargs): - """Initialize the PixelBasisPSF model with a basis set of images.""" + """Initialize the BasisModel with a basis set of images.""" super().__init__(*args, **kwargs) self.basis = basis @property def basis(self): - """The basis set of images used to form the eigen point source.""" + """The basis set of images used to form the model.""" return self._basis @basis.setter def basis(self, value: Union[str, ArrayLike]): - """Set the basis set of images. If value is None, the basis is initialized to an empty tensor.""" + """Set the basis set of images.""" if value is None: - raise SpecificationConflict( - "PixelBasisPSF requires a basis set of images to be provided." - ) + raise SpecificationConflict("BasisModel requires a basis set of images to be provided.") elif isinstance(value, str) and value.startswith("zernike:"): self._basis = value else: @@ -68,47 +68,34 @@ def basis(self, value: Union[str, ArrayLike]): @ignore_numpy_warnings def initialize(self): super().initialize() - target_area = self.target[self.window] - if not self.PA.initialized: - R, _ = polar_decomposition(self.target.CD.npvalue) - self.PA.value = np.arccos(np.abs(R[0, 0])) - if not self.scale.initialized: - self.scale.value = self.target.pixelscale.item() if isinstance(self.basis, str) and self.basis.startswith("zernike:"): order = int(self.basis.split(":")[1]) - nm = func.zernike_n_m_list(order) - N = int( - target_area._data.shape[0] * self.target.pixelscale.item() / self.scale.value.item() - ) - X, Y = np.meshgrid( - np.linspace(-1, 1, N) * (N - 1) / N, - np.linspace(-1, 1, N) * (N - 1) / N, - indexing="ij", - ) - R = np.sqrt(X**2 + Y**2) - Phi = np.arctan2(Y, X) - basis = [] - for n, m in nm: - basis.append(func.zernike_n_m_modes(R, Phi, n, m)) - self.basis = np.stack(basis, axis=0) + N = int(max(self.window.shape)) + N = N + 1 - N % 2 + self.basis = func.zernike_basis(order, N) / self.target.pixel_area if not self.weights.initialized: w = np.zeros(self.basis.shape[0]) w[0] = 1.0 self.weights.value = w + if not self.PA.initialized: + R, _ = polar_decomposition(self.target.CD.npvalue) + self.PA.value = np.arccos(np.abs(R[0, 0])) + + if not self.scale.initialized: + self.scale = self.target.pixelscale.item() + @forward def transform_coordinates( self, x: ArrayLike, y: ArrayLike, PA: ArrayLike, scale: ArrayLike - ) -> Tuple[ArrayLike, ArrayLike]: + ) -> tuple[ArrayLike, ArrayLike]: x, y = super().transform_coordinates(x, y) - i, j = func.rotate(-PA, x, y) - pixel_center = (self.basis.shape[1] - 1) / 2, (self.basis.shape[2] - 1) / 2 - return i / scale + pixel_center[0], j / scale + pixel_center[1] + x, y = func.rotate(-PA + np.pi / 2, x, y) + return x / scale, y / scale @forward def brightness(self, x: ArrayLike, y: ArrayLike, weights: ArrayLike) -> ArrayLike: x, y = self.transform_coordinates(x, y) - return backend.sum( - backend.vmap(lambda w, b: w * interp2d(b, x, y))(weights, self.basis), dim=0 - ) + wB = backend.sum(weights[:, None, None] * self.basis, dim=0) + return interp2d(wB, x + wB.shape[0] // 2, y + wB.shape[1] // 2) diff --git a/astrophot/models/basis_psf.py b/astrophot/models/basis_psf.py new file mode 100644 index 00000000..b7ad25c4 --- /dev/null +++ b/astrophot/models/basis_psf.py @@ -0,0 +1,81 @@ +from typing import Union, Tuple +import torch +import numpy as np + +from .psf_model_object import PSFModel +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings +from ..utils.interpolate import interp2d +from .. import config +from ..backend_obj import backend, ArrayLike +from ..errors import SpecificationConflict +from ..param import forward +from . import func +from ..utils.initialize import polar_decomposition + +__all__ = ["PixelBasisPSF"] + + +@combine_docstrings +class PixelBasisPSF(PSFModel): + """point source model which uses multiple images as a basis for the + PSF as its representation for point sources. Using bilinear interpolation it + will shift the PSF within a pixel to accurately represent the center + location of a point source. There is no functional form for this object type + as any image can be supplied. Bilinear interpolation is very fast and + accurate for smooth models, so it is possible to do the expensive + interpolation before optimization and save time. + + **Parameters:** + - `weights`: The weights of the basis set of images in units of flux. + """ + + _model_type = "basis" + _parameter_specs = {"weights": {"units": "unitless", "shape": (None,), "dynamic": True}} + usable = True + + def __init__(self, *args, basis: Union[str, ArrayLike] = "zernike:3", **kwargs): + """Initialize the PixelBasisPSF model with a basis set of images.""" + super().__init__(*args, **kwargs) + self.basis = basis + + @property + def basis(self): + """The basis set of images used to form the eigen point source.""" + return self._basis + + @basis.setter + def basis(self, value: Union[str, ArrayLike]): + """Set the basis set of images. If value is None, the basis is initialized to an empty tensor.""" + if value is None: + raise SpecificationConflict( + "PixelBasisPSF requires a basis set of images to be provided." + ) + elif isinstance(value, str) and value.startswith("zernike:"): + self._basis = value + else: + # Transpose since pytorch uses (j, i) indexing when (i, j) is more natural for coordinates + self._basis = backend.transpose( + backend.as_array(value, dtype=config.DTYPE, device=config.DEVICE), 2, 1 + ) + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + if isinstance(self.basis, str) and self.basis.startswith("zernike:"): + order = int(self.basis.split(":")[1]) + N = int(max(self.window.shape)) + N = N + 1 - N % 2 + self.basis = func.zernike_basis(order, N) / self.target.pixel_area + + if not self.weights.initialized: + w = np.zeros(self.basis.shape[0]) + w[0] = 1.0 + self.weights.value = w + + @forward + def brightness(self, x: ArrayLike, y: ArrayLike, weights: ArrayLike) -> ArrayLike: + x, y = self.transform_coordinates(x, y) + wB = backend.sum(weights[:, None, None] * self.basis, dim=0) + u = self.target.upsample + return interp2d(wB, x * u + wB.shape[0] // 2, y * u + wB.shape[1] // 2) diff --git a/astrophot/models/batch_model_object.py b/astrophot/models/batch_model_object.py new file mode 100644 index 00000000..273aa5a1 --- /dev/null +++ b/astrophot/models/batch_model_object.py @@ -0,0 +1,182 @@ +from typing import Optional +from ..backend_obj import backend +from ..param import forward +from ..errors import SpecificationConflict +from .base import Model +from .mixins import SampleMixin, GradMixin +from .model_object import ComponentModel +from ..image import TargetImage, Window, TargetImageBatch, WindowBatch +from . import func +from .. import config + + +class BatchModel(GradMixin, SampleMixin, Model): + """A batch of models that all share the same window/target. + + This can for example be used to model a crowded area of the sky with many + overlapping sources, or to model a single object that is represented by many + components (consider this a generalization of the Multi-gaussian expansion + model). If you want to model the same object in multiple images, see the + BatchSceneModel instead. + + **Note:** any model parameters that you wish to batch over must be set to + dynamic=True. See [caskade hierarchical + models](https://caskade.readthedocs.io/en/latest/notebooks/HierarchicalModels.html) + for more details. + """ + + usable = True + _model_type = "batch" + + def __init__(self, *, model: ComponentModel = None, **kwargs): + super().__init__(**kwargs) + assert isinstance( + model, ComponentModel + ), "BatchModel must be initialized with a ComponentModel instance." + self.hierarchical_link("model", model) + + def initialize(self): + self.model.initialize() + + @property + def target(self) -> Optional[TargetImage]: + return self.model.target + + @target.setter + def target(self, target: Optional[TargetImage]): + pass + + @property + def window(self) -> Optional[Window]: + """The window defines a region on the sky in which this model will be + optimized and evaluated. Two models with non-overlapping windows are in + effect independent of each other. If there is another model with a + window that spans both of them, then they are tenuously connected. + + If not provided, the model will assume a window equal to the target it + is fitting. Note that in this case the window is not explicitly set to + the target window, so if the model is moved to another target then the + fitting window will also change. + + """ + return self.model.window + + @window.setter + def window(self, window): + pass + + @property + def mask(self): + return self.model.mask + + @mask.setter + def mask(self, mask): + pass + + @forward + def __call__(self, model_params=None, model_dims=None, **kwargs): + psf, upsample, pad = self.model._prep_psf() + working_image = self.target.model_image(self.window) + I, J = self.model._pixel_meshgridder(self.target, self.window, pad, upsample) + Z = backend.vmap( + self.model.sample, + in_dims=(None, None, None, None, None, model_dims), + )( + I, + J, + None, + pad, + upsample, + model_params, + ) + Z = backend.sum(Z, dim=0) + if psf is not None and not self.model.internal_psf: + if isinstance(psf, Model): + psf = psf()._data + if psf.shape != (1, 1): # skip if identity PSF + Z = func.convolve(Z, psf) + Z = Z[pad : Z.shape[0] - pad, pad : Z.shape[1] - pad] + Z = func.downsample(Z, upsample) + working_image._data = Z + return working_image + + +class BatchSceneModel(GradMixin, Model): + """A single model as viewed in multiple images. + + This model is quite restrictive in its use, but can provide a significant + speedup by vectorizing the evaluation of the model. Some key things to keep + in mind: + + - All model parameters that you wish to batch over must be set to + dynamic=True. See [caskade hierarchical + models](https://caskade.readthedocs.io/en/latest/notebooks/HierarchicalModels.html) + for more details. + + - You must use a TargetImageBatch as the target, meaning that all the images + must be the same number of pixels. + + - You must use a WindowBatch as the window (or none to just use the full + images), meaning that the windows must all be the same shape in pixels. + + - The model you provide must have a TargetImage target and Window window, + and these must have the same number of pixels as the batched versions. + + - If the base model has a PSFModel as its PSF then you cannot override it + (that's part of the model), otherwise the PSF from the TargetImageBatch + will override the model PSF for all images. + """ + + usable = True + _model_type = "batch scene" + + def __init__(self, *, model: Model = None, **kwargs): + super().__init__(**kwargs) + if not isinstance(model.target, TargetImage): + raise SpecificationConflict( + f"BatchSceneModel can only be used with models that have a TargetImage as their target, not a {type(model.target).__name__}." + ) + self.hierarchical_link("model", model) + + def initialize(self): + self.model.initialize() + + @property + def target(self) -> Optional[TargetImageBatch]: + return self._target + + @target.setter + def target(self, target: TargetImageBatch): + assert isinstance( + target, TargetImageBatch + ), "BatchSceneModel target must be a TargetImageBatch." + self._target = target + + @property + def window(self) -> WindowBatch: + if self._window is None: + return self._target.window + return self._window + + @window.setter + def window(self, window): + assert window is None or isinstance( + window, WindowBatch + ), "BatchSceneModel window must be a WindowBatch." + self._window = window + + @forward + def __call__(self, model_params=None, model_dims=None, **kwargs): + working_image = self.target.model_image(self.window) + crtan = self.target.crtan + shift = backend.as_array( + self.window.origin_shifter(self.model.window), dtype=config.DTYPE, device=config.DEVICE + ) + crpix = self.target.crpix + shift + CD = self.target.CD + psf = self.target.psf_stack + psf_batch = None if psf is None else 0 + working_image._data = backend.vmap( + lambda *args: self.model(*args)._data, in_dims=(0, 0, 0, psf_batch, model_dims) + )(CD, crtan, crpix, psf, model_params) + return working_image diff --git a/astrophot/models/bilinear_sky.py b/astrophot/models/bilinear_sky.py index 0d4873a3..abf2ad3b 100644 --- a/astrophot/models/bilinear_sky.py +++ b/astrophot/models/bilinear_sky.py @@ -26,11 +26,10 @@ class BilinearSky(SkyModel): _model_type = "bilinear" _parameter_specs = { - "I": {"units": "flux/arcsec^2", "dynamic": True}, + "I": {"units": "flux/arcsec^2", "shape": (None, None), "dynamic": True}, "PA": {"units": "radians", "shape": (), "dynamic": True}, "scale": {"units": "arcsec/grid-unit", "shape": (), "dynamic": True}, } - sampling_mode = "midpoint" usable = True def __init__(self, *args, nodes: Tuple[int, int] = (3, 3), **kwargs): diff --git a/astrophot/models/exponential.py b/astrophot/models/exponential.py deleted file mode 100644 index 84cb82ef..00000000 --- a/astrophot/models/exponential.py +++ /dev/null @@ -1,59 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from ..utils.decorators import combine_docstrings -from .psf_model_object import PSFModel -from .mixins import ( - ExponentialMixin, - iExponentialMixin, - RadialMixin, - WedgeMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, -) - -__all__ = [ - "ExponentialGalaxy", - "ExponentialPSF", - "ExponentialSuperEllipse", - "ExponentialFourierEllipse", - "ExponentialWarp", - "ExponentialRay", - "ExponentialWedge", -] - - -@combine_docstrings -class ExponentialGalaxy(ExponentialMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class ExponentialPSF(ExponentialMixin, RadialMixin, PSFModel): - _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} - usable = True - - -@combine_docstrings -class ExponentialSuperEllipse(ExponentialMixin, RadialMixin, SuperEllipseMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class ExponentialFourierEllipse(ExponentialMixin, RadialMixin, FourierEllipseMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class ExponentialWarp(ExponentialMixin, RadialMixin, WarpMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class ExponentialRay(iExponentialMixin, RayMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class ExponentialWedge(iExponentialMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/models/ferrer.py b/astrophot/models/ferrer.py deleted file mode 100644 index 39c87d70..00000000 --- a/astrophot/models/ferrer.py +++ /dev/null @@ -1,60 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from .mixins import ( - FerrerMixin, - RadialMixin, - WedgeMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, - iFerrerMixin, -) -from ..utils.decorators import combine_docstrings - - -__all__ = ( - "FerrerGalaxy", - "FerrerPSF", - "FerrerSuperEllipse", - "FerrerFourierEllipse", - "FerrerWarp", - "FerrerRay", - "FerrerWedge", -) - - -@combine_docstrings -class FerrerGalaxy(FerrerMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class FerrerPSF(FerrerMixin, RadialMixin, PSFModel): - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} - usable = True - - -@combine_docstrings -class FerrerSuperEllipse(FerrerMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class FerrerFourierEllipse(FerrerMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class FerrerWarp(FerrerMixin, WarpMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class FerrerRay(iFerrerMixin, RayMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class FerrerWedge(iFerrerMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/models/flatsky.py b/astrophot/models/flatsky.py index 75170e81..fdb3e50c 100644 --- a/astrophot/models/flatsky.py +++ b/astrophot/models/flatsky.py @@ -15,12 +15,12 @@ class FlatSky(SkyModel): are the same. **Parameters:** - - `I`: brightness for the sky, represented as the log of the brightness over pixel scale squared, this is proportional to a surface brightness + - `I0`: brightness for the sky, represented as the log of the brightness over pixel scale squared, this is proportional to a surface brightness """ _model_type = "flat" - _parameter_specs = {"I": {"units": "flux/arcsec^2", "dynamic": True}} + _parameter_specs = {"I0": {"units": "flux/arcsec^2", "shape": (), "dynamic": True}} usable = True @torch.no_grad() @@ -28,7 +28,7 @@ class FlatSky(SkyModel): def initialize(self): super().initialize() - if self.I.initialized: + if self.I0.initialized: return target_area = self.target[self.window] @@ -36,8 +36,8 @@ def initialize(self): mask = backend.to_numpy(target_area._mask) dat[mask] = np.median(dat[~mask]) - self.I.value = np.median(dat) / self.target.pixel_area.item() + self.I0.value = np.median(dat) / self.target.pixel_area.item() @forward - def brightness(self, x: ArrayLike, y: ArrayLike, I: ArrayLike) -> ArrayLike: - return backend.ones_like(x) * I + def brightness(self, x: ArrayLike, y: ArrayLike, I0: ArrayLike) -> ArrayLike: + return backend.ones_like(x) * I0 diff --git a/astrophot/models/func/__init__.py b/astrophot/models/func/__init__.py index 79e7e8e6..2f09099e 100644 --- a/astrophot/models/func/__init__.py +++ b/astrophot/models/func/__init__.py @@ -1,4 +1,4 @@ -from .base import all_subclasses +from .base import all_subclasses, downsample, downsample_mean from .integration import ( quad_table, pixel_center_integrator, @@ -23,10 +23,12 @@ from .nuker import nuker from .spline import spline from .transform import rotate -from .zernike import zernike_n_m_list, zernike_n_m_modes +from .zernike import zernike_n_m_list, zernike_n_m_modes, zernike_basis __all__ = ( "all_subclasses", + "downsample", + "downsample_mean", "quad_table", "pixel_center_integrator", "pixel_simpsons_integrator", @@ -50,4 +52,5 @@ "rotate", "zernike_n_m_list", "zernike_n_m_modes", + "zernike_basis", ) diff --git a/astrophot/models/func/base.py b/astrophot/models/func/base.py index de9906ca..06e1463d 100644 --- a/astrophot/models/func/base.py +++ b/astrophot/models/func/base.py @@ -1,4 +1,25 @@ +from ...backend_obj import ArrayLike + + def all_subclasses(cls): return set(cls.__subclasses__()).union( [s for c in cls.__subclasses__() for s in all_subclasses(c)] ) + + +def downsample(img: ArrayLike, scale: int = 1): + if scale == 1: + return img + MS = img.shape[0] // scale + NS = img.shape[1] // scale + + return img[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).sum(axis=(1, 3)) + + +def downsample_mean(img: ArrayLike, scale: int = 1): + if scale == 1: + return img + MS = img.shape[0] // scale + NS = img.shape[1] // scale + + return img[: MS * scale, : NS * scale].reshape(MS, scale, NS, scale).mean(axis=(1, 3)) diff --git a/astrophot/models/func/zernike.py b/astrophot/models/func/zernike.py index 34efa822..9ecaf741 100644 --- a/astrophot/models/func/zernike.py +++ b/astrophot/models/func/zernike.py @@ -36,3 +36,18 @@ def zernike_n_m_modes(rho: np.ndarray, phi: np.ndarray, n: int, m: int) -> np.nd Z = Z + c * R * T return Z * (rho <= 1).astype(np.float64) + + +def zernike_basis(order: int, N: int) -> np.ndarray: + nm = zernike_n_m_list(order) + X, Y = np.meshgrid( + np.linspace(-1, 1, N) * (N - 1) / N, + np.linspace(-1, 1, N) * (N - 1) / N, + indexing="ij", + ) + R = np.sqrt(X**2 + Y**2) + Phi = np.arctan2(Y, X) + basis = [] + for n, m in nm: + basis.append(zernike_n_m_modes(R, Phi, n, m)) + return np.stack(basis, axis=0) diff --git a/astrophot/models/gaussian.py b/astrophot/models/gaussian.py deleted file mode 100644 index 900c8241..00000000 --- a/astrophot/models/gaussian.py +++ /dev/null @@ -1,61 +0,0 @@ -from .galaxy_model_object import GalaxyModel - -from .psf_model_object import PSFModel -from .mixins import ( - GaussianMixin, - RadialMixin, - WedgeMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, - iGaussianMixin, -) -from ..utils.decorators import combine_docstrings - - -__all__ = [ - "GaussianGalaxy", - "GaussianPSF", - "GaussianSuperEllipse", - "GaussianFourierEllipse", - "GaussianWarp", - "GaussianRay", - "GaussianWedge", -] - - -@combine_docstrings -class GaussianGalaxy(GaussianMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class GaussianPSF(GaussianMixin, RadialMixin, PSFModel): - _parameter_specs = {"flux": {"units": "flux", "value": 1.0, "dynamic": False}} - usable = True - - -@combine_docstrings -class GaussianSuperEllipse(GaussianMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class GaussianFourierEllipse(GaussianMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class GaussianWarp(GaussianMixin, WarpMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class GaussianRay(iGaussianMixin, RayMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class GaussianWedge(iGaussianMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/models/group_model_object.py b/astrophot/models/group_model_object.py index 9a85ed38..d7b15063 100644 --- a/astrophot/models/group_model_object.py +++ b/astrophot/models/group_model_object.py @@ -47,11 +47,10 @@ class GroupModel(Model): def __init__( self, *, - name: Optional[str] = None, models: Optional[Sequence[Model]] = None, **kwargs, ): - super().__init__(name=name, **kwargs) + super().__init__(**kwargs) for model in models: if not isinstance(model, Model): raise TypeError(f"Expected a Model instance in 'models', got {type(model)}") @@ -101,7 +100,6 @@ def _update_window(self): new_window |= model.window self.window = new_window - @torch.no_grad() @ignore_numpy_warnings def initialize(self): """ @@ -111,51 +109,6 @@ def initialize(self): config.logger.info(f"Initializing model {model.name}") model.initialize() - def _fit_mask(self) -> torch.Tensor: - """Returns a mask for the target image which is the combination of all - the fit masks of the sub models. This mask is used when the multiple - models in the group model do not completely overlap with each other, thus - there are some pixels which are not covered by any model and have no - reason to be fit. - - """ - subtarget = self.target[self.window] - if isinstance(subtarget, ImageList): - mask = list(backend.ones_like(submask) for submask in subtarget._mask) - for model in self.models: - model_subtarget = model.target[model.window] - model_fit_mask = model._fit_mask() - if isinstance(model_subtarget, ImageList): - for target, submask in zip(model_subtarget, model_fit_mask): - index = subtarget.index(target) - group_indices = subtarget.images[index].get_indices(target.window) - model_indices = target.get_indices(subtarget.images[index].window) - mask[index] = backend.and_at_indices( - mask[index], group_indices, submask[model_indices] - ) - else: - index = subtarget.index(model_subtarget) - group_indices = subtarget.images[index].get_indices(model_subtarget.window) - model_indices = model_subtarget.get_indices(subtarget.images[index].window) - mask[index] = backend.and_at_indices( - mask[index], group_indices, model_fit_mask[model_indices] - ) - mask = tuple(mask) - else: - mask = backend.ones_like(subtarget._mask) - for model in self.models: - model_subtarget = model.target[model.window] - group_indices = subtarget.get_indices(model.window) - model_indices = model_subtarget.get_indices(subtarget.window) - mask = backend.and_at_indices(mask, group_indices, model._fit_mask()[model_indices]) - return mask - - def fit_mask(self) -> torch.Tensor: - mask = self._fit_mask() - if isinstance(mask, tuple): - return tuple(backend.transpose(m, 1, 0) for m in mask) - return backend.transpose(mask, 1, 0) - def match_window(self, image: Union[Image, ImageList], window: Window, model: Model) -> Window: if isinstance(image, ImageList) and isinstance(model.target, ImageList): indices = image.match_indices(model.target) @@ -201,7 +154,10 @@ def _ensure_vmap_compatible( @forward def sample( self, - window: Optional[Window] = None, + _CD: Optional[ArrayLike] = None, + _crtan: Optional[ArrayLike] = None, + _crpix: Optional[ArrayLike] = None, + _psf: Optional[ArrayLike] = None, ) -> Union[ModelImage, ModelImageList]: """Sample the group model on an image. Produces the flux values for each pixel associated with the models in this group. Each @@ -212,31 +168,18 @@ def sample( - `image` (Optional[ModelImage]): Image to sample on, overrides the windows for each sub model, they will all be evaluated over this entire image. If left as none then each sub model will be evaluated in its window. """ - if window is None: - image = self.target[self.window].model_image() - else: - image = self.target[window].model_image() + image = self.target.model_image(self.window) for model in self.models: - if window is None: - use_window = model.window - else: - try: - use_window = self.match_window(image, window, model) - except IndexError: - # If the model target is not in the image, skip it - continue - model_image = model(window=model.window & use_window) + model_image = model(_CD=_CD, _crtan=_crtan, _crpix=_crpix, _psf=_psf) self._ensure_vmap_compatible(image, model_image) image += model_image return image - @torch.no_grad() def jacobian( self, pass_jacobian: Optional[Union[JacobianImage, JacobianImageList]] = None, - window: Optional[Union[Window, WindowList]] = None, params=None, ) -> JacobianImage: """Compute the jacobian for this model. Done by first constructing a @@ -249,29 +192,19 @@ def jacobian( - `params` (Optional[Sequence[Param]]): Parameters to use for the jacobian. If not provided, the model's parameters will be used. """ - if window is None: - window = self.window if params is not None: self.set_values(params) if pass_jacobian is None: - jac_img = self.target[window].jacobian_image( + jac_img = self.target[self.window].jacobian_image( parameters=self.build_params_array_identities() ) else: jac_img = pass_jacobian - for model in reversed(self.models): - try: - use_window = self.match_window(jac_img, window, model) - except IndexError: - # If the model target is not in the image, skip it - continue - jac_img = model.jacobian( - pass_jacobian=jac_img, - window=use_window & model.window, - ) + for model in self.models: + jac_img = model.jacobian(pass_jacobian=jac_img) return jac_img diff --git a/astrophot/models/group_psf_model.py b/astrophot/models/group_psf_model.py index 2d861200..e5d15022 100644 --- a/astrophot/models/group_psf_model.py +++ b/astrophot/models/group_psf_model.py @@ -38,7 +38,13 @@ def target(self, target): @forward def sample(self, *args, **kwargs): """Sample the PSF group model on the target image.""" - psf_img = super().sample(*args, **kwargs) + image = self.target.model_image(self.window) + + for model in self.models: + model_image = model() + self._ensure_vmap_compatible(image, model_image) + image += model_image + if self.normalize_psf: - psf_img.normalize() - return psf_img + image.normalize() + return image diff --git a/astrophot/models/king.py b/astrophot/models/king.py deleted file mode 100644 index a565d406..00000000 --- a/astrophot/models/king.py +++ /dev/null @@ -1,60 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from .mixins import ( - KingMixin, - RadialMixin, - WedgeMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, - iKingMixin, -) -from ..utils.decorators import combine_docstrings - - -__all__ = ( - "KingGalaxy", - "KingPSF", - "KingSuperEllipse", - "KingFourierEllipse", - "KingWarp", - "KingRay", - "KingWedge", -) - - -@combine_docstrings -class KingGalaxy(KingMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class KingPSF(KingMixin, RadialMixin, PSFModel): - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} - usable = True - - -@combine_docstrings -class KingSuperEllipse(KingMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class KingFourierEllipse(KingMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class KingWarp(KingMixin, WarpMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class KingRay(iKingMixin, RayMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class KingWedge(iKingMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/models/mixins/__init__.py b/astrophot/models/mixins/__init__.py index 884033d5..dfc4a50a 100644 --- a/astrophot/models/mixins/__init__.py +++ b/astrophot/models/mixins/__init__.py @@ -6,15 +6,15 @@ WarpMixin, TruncationMixin, ) -from .sersic import SersicMixin, iSersicMixin -from .exponential import ExponentialMixin, iExponentialMixin -from .moffat import MoffatMixin, iMoffatMixin -from .ferrer import FerrerMixin, iFerrerMixin -from .king import KingMixin, iKingMixin -from .gaussian import GaussianMixin, iGaussianMixin -from .nuker import NukerMixin, iNukerMixin -from .spline import SplineMixin, iSplineMixin -from .sample import SampleMixin +from .sersic import SersicMixin, iSersicMixin, SersicPSFMixin +from .exponential import ExponentialMixin, iExponentialMixin, ExponentialPSFMixin +from .moffat import MoffatMixin, iMoffatMixin, MoffatPSFMixin +from .ferrer import FerrerMixin, iFerrerMixin, FerrerPSFMixin +from .king import KingMixin, iKingMixin, KingPSFMixin +from .gaussian import GaussianMixin, iGaussianMixin, GaussianPSFMixin +from .nuker import NukerMixin, iNukerMixin, NukerPSFMixin +from .spline import SplineMixin, iSplineMixin, SplinePSFMixin +from .sample import SampleMixin, GradMixin __all__ = ( "RadialMixin", @@ -27,19 +27,28 @@ "InclinedMixin", "SersicMixin", "iSersicMixin", + "SersicPSFMixin", "ExponentialMixin", "iExponentialMixin", + "ExponentialPSFMixin", "MoffatMixin", "iMoffatMixin", + "MoffatPSFMixin", "FerrerMixin", "iFerrerMixin", + "FerrerPSFMixin", "KingMixin", "iKingMixin", + "KingPSFMixin", "GaussianMixin", "iGaussianMixin", + "GaussianPSFMixin", "NukerMixin", "iNukerMixin", + "NukerPSFMixin", "SplineMixin", "iSplineMixin", + "SplinePSFMixin", "SampleMixin", + "GradMixin", ) diff --git a/astrophot/models/mixins/exponential.py b/astrophot/models/mixins/exponential.py index 3e578d0e..89da767d 100644 --- a/astrophot/models/mixins/exponential.py +++ b/astrophot/models/mixins/exponential.py @@ -73,8 +73,8 @@ class iExponentialMixin: _model_type = "exponential" _parameter_specs = { - "Re": {"units": "arcsec", "valid": (0, None), "dynamic": True}, - "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, + "Re": {"units": "arcsec", "valid": (0, None), "shape": (None,), "dynamic": True}, + "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (None,), "dynamic": True}, } @torch.no_grad() @@ -94,3 +94,49 @@ def initialize(self): @forward def iradial_model(self, i: int, R: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: return func.exponential(R, Re[i], Ie[i]) + + +class ExponentialPSFMixin: + """Exponential radial light profile. + + An exponential is a classical radial model used in many contexts. The + functional form of the exponential profile is defined as: + + $$I(R) = I_e * \\exp\\left(- b_1\\left(\\frac{R}{R_e} - 1\\right)\\right)$$ + + Ie is the brightness at the effective radius, and Re is the effective + radius. $b_1$ is a constant that ensures $I_e$ is the brightness at $R_e$. + + **Parameters:** + - `Re`: effective radius in pixels + - `Ie`: effective surface density in flux/pix^2 + """ + + _model_type = "exponential" + _parameter_specs = { + "Re": {"units": "pix", "valid": (0, None), "shape": (), "dynamic": True}, + "Ie": { + "units": "flux/pix^2", + "valid": (0, None), + "shape": (), + "dynamic": False, + "value": 1.0, + }, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + exponential_np, + ("Re", "Ie"), + _x0_func, + ) + + @forward + def radial_model(self, R: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: + return func.exponential(R, Re, Ie) diff --git a/astrophot/models/mixins/ferrer.py b/astrophot/models/mixins/ferrer.py index ff18208a..27a24932 100644 --- a/astrophot/models/mixins/ferrer.py +++ b/astrophot/models/mixins/ferrer.py @@ -85,10 +85,10 @@ class iFerrerMixin: _model_type = "ferrer" _parameter_specs = { - "rout": {"units": "arcsec", "valid": (0.0, None), "dynamic": True}, - "alpha": {"units": "unitless", "valid": (0, 10), "dynamic": True}, - "beta": {"units": "unitless", "valid": (0, 2), "dynamic": True}, - "I0": {"units": "flux/arcsec^2", "valid": (0.0, None), "dynamic": True}, + "rout": {"units": "arcsec", "valid": (0.0, None), "shape": (None,), "dynamic": True}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": (None,), "dynamic": True}, + "beta": {"units": "unitless", "valid": (0, 2), "shape": (None,), "dynamic": True}, + "I0": {"units": "flux/arcsec^2", "valid": (0.0, None), "shape": (None,), "dynamic": True}, } @torch.no_grad() @@ -116,3 +116,57 @@ def iradial_model( I0: ArrayLike, ) -> ArrayLike: return func.ferrer(R, rout[i], alpha[i], beta[i], I0[i]) + + +class FerrerPSFMixin: + """Modified Ferrer radial light profile (Binney & Tremaine 1987). + + This model has a relatively flat brightness core and then a truncation. It + is used in specialized circumstances such as fitting the bar of a galaxy. + The functional form of the Modified Ferrer profile is defined as: + + $$I(R) = I_0 \\left(1 - \\left(\\frac{R}{r_{\\rm out}}\\right)^{2-\\beta}\\right)^{\\alpha}$$ + + where `rout` is the outer truncation radius, `alpha` controls the steepness + of the truncation, `beta` controls the shape, and `I0` is the intensity at + the center of the profile. + + **Parameters:** + - `rout`: Outer truncation radius in pixels. + - `alpha`: Inner slope parameter. + - `beta`: Outer slope parameter. + - `I0`: Intensity at the center of the profile in flux/pix^2 + """ + + _model_type = "ferrer" + _parameter_specs = { + "rout": {"units": "pix", "valid": (0.0, None), "shape": (), "dynamic": True}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": (), "dynamic": True}, + "beta": {"units": "unitless", "valid": (0, 2), "shape": (), "dynamic": True}, + "I0": { + "units": "flux/pix^2", + "valid": (0, None), + "shape": (), + "dynamic": False, + "value": 1.0, + }, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + ferrer_np, + ("rout", "alpha", "beta", "I0"), + x0_func, + ) + + @forward + def radial_model( + self, R: ArrayLike, rout: ArrayLike, alpha: ArrayLike, beta: ArrayLike, I0: ArrayLike + ) -> ArrayLike: + return func.ferrer(R, rout, alpha, beta, I0) diff --git a/astrophot/models/mixins/gaussian.py b/astrophot/models/mixins/gaussian.py index f6b57921..db34b4f2 100644 --- a/astrophot/models/mixins/gaussian.py +++ b/astrophot/models/mixins/gaussian.py @@ -74,8 +74,8 @@ class iGaussianMixin: _model_type = "gaussian" _parameter_specs = { - "sigma": {"units": "arcsec", "valid": (0, None), "dynamic": True}, - "flux": {"units": "flux", "valid": (0, None), "dynamic": True}, + "sigma": {"units": "arcsec", "valid": (0, None), "shape": (None,), "dynamic": True}, + "flux": {"units": "flux", "valid": (0, None), "shape": (None,), "dynamic": True}, } @torch.no_grad() @@ -95,3 +95,43 @@ def initialize(self): @forward def iradial_model(self, i: int, R: ArrayLike, sigma: ArrayLike, flux: ArrayLike) -> ArrayLike: return func.gaussian(R, sigma[i], flux[i]) + + +class GaussianPSFMixin: + """Gaussian radial light profile. + + The Gaussian profile is a simple and widely used model for extended objects. + The functional form of the Gaussian profile is defined as: + + $$I(R) = \\frac{{\\rm flux}}{\\sqrt{2\\pi}\\sigma} \\exp(-R^2 / (2 \\sigma^2))$$ + + where `I_0` is the intensity at the center of the profile and `sigma` is the + standard deviation which controls the width of the profile. + + **Parameters:** + - `sigma`: Standard deviation of the Gaussian profile in pixels. + - `flux`: Total flux of the Gaussian profile. + """ + + _model_type = "gaussian" + _parameter_specs = { + "sigma": {"units": "pix", "valid": (0, None), "shape": (), "dynamic": True}, + "flux": {"units": "flux", "valid": (0, None), "shape": (), "dynamic": False, "value": 1.0}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + gaussian_np, + ("sigma", "flux"), + _x0_func, + ) + + @forward + def radial_model(self, R: ArrayLike, sigma: ArrayLike, flux: ArrayLike) -> ArrayLike: + return func.gaussian(R, sigma, flux) diff --git a/astrophot/models/mixins/king.py b/astrophot/models/mixins/king.py index eea81306..ccdf2a19 100644 --- a/astrophot/models/mixins/king.py +++ b/astrophot/models/mixins/king.py @@ -92,10 +92,10 @@ class iKingMixin: _model_type = "king" _parameter_specs = { - "Rc": {"units": "arcsec", "valid": (0.0, None), "dynamic": True}, - "Rt": {"units": "arcsec", "valid": (0.0, None), "dynamic": True}, - "alpha": {"units": "unitless", "valid": (0, 10), "dynamic": False}, - "I0": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, + "Rc": {"units": "arcsec", "valid": (0.0, None), "shape": (None,), "dynamic": True}, + "Rt": {"units": "arcsec", "valid": (0.0, None), "shape": (None,), "dynamic": True}, + "alpha": {"units": "unitless", "valid": (0, 10), "shape": (None,), "dynamic": False}, + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (None,), "dynamic": True}, } @torch.no_grad() @@ -120,3 +120,63 @@ def iradial_model( self, i: int, R: ArrayLike, Rc: ArrayLike, Rt: ArrayLike, alpha: ArrayLike, I0: ArrayLike ) -> ArrayLike: return func.king(R, Rc[i], Rt[i], alpha[i], I0[i]) + + +class KingPSFMixin: + """Empirical King radial light profile (Elson 1999). + + Often used for star clusters. By default the profile has `alpha = 2` but we + allow the parameter to vary freely for fitting. The functional form of the + Empirical King profile is defined as: + + $$I(R) = I_0\\left[\\frac{1}{(1 + (R/R_c)^2)^{1/\\alpha}} - \\frac{1}{(1 + (R_t/R_c)^2)^{1/\\alpha}}\\right]^{\\alpha}\\left[1 - \\frac{1}{(1 + (R_t/R_c)^2)^{1/\\alpha}}\\right]^{-\\alpha}$$ + + where `R_c` is the core radius, `R_t` is the truncation radius, and `I_0` is + the intensity at the center of the profile. `alpha` is the concentration + index which controls the shape of the profile. + + **Parameters:** + - `Rc`: core radius [pix] + - `Rt`: truncation radius [pix] + - `alpha`: concentration index which controls the shape of the brightness profile + - `I0`: intensity at the center of the profile [flux/pix^2] + """ + + _model_type = "king" + _parameter_specs = { + "Rc": {"units": "pix", "valid": (0.0, None), "shape": (), "dynamic": True}, + "Rt": {"units": "pix", "valid": (0.0, None), "shape": (), "dynamic": True}, + "alpha": { + "units": "unitless", + "valid": (0, 10), + "shape": (), + "value": 2.0, + "dynamic": False, + }, + "I0": { + "units": "flux/pix^2", + "valid": (0, None), + "shape": (), + "dynamic": False, + "value": 1.0, + }, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + lambda r, *x: king_np(r, x[0], x[1], 2.0, x[2]), + ("Rc", "Rt", "I0"), + x0_func, + ) + + @forward + def radial_model( + self, R: ArrayLike, Rc: ArrayLike, Rt: ArrayLike, alpha: ArrayLike, I0: ArrayLike + ) -> ArrayLike: + return func.king(R, Rc, Rt, alpha, I0) diff --git a/astrophot/models/mixins/moffat.py b/astrophot/models/mixins/moffat.py index eef7f2b6..df80162b 100644 --- a/astrophot/models/mixins/moffat.py +++ b/astrophot/models/mixins/moffat.py @@ -76,9 +76,9 @@ class iMoffatMixin: _model_type = "moffat" _parameter_specs = { - "n": {"units": "none", "valid": (0.1, 10), "dynamic": True}, - "Rd": {"units": "arcsec", "valid": (0, None), "dynamic": True}, - "I0": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, + "n": {"units": "none", "valid": (0.1, 10), "shape": (None,), "dynamic": True}, + "Rd": {"units": "arcsec", "valid": (0, None), "shape": (None,), "dynamic": True}, + "I0": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (None,), "dynamic": True}, } @torch.no_grad() @@ -100,3 +100,51 @@ def iradial_model( self, i: int, R: ArrayLike, n: ArrayLike, Rd: ArrayLike, I0: ArrayLike ) -> ArrayLike: return func.moffat(R, n[i], Rd[i], I0[i]) + + +class MoffatPSFMixin: + """Moffat radial light profile (Moffat 1969). + + The moffat profile gives a good representation of the general structure of + PSF functions for ground based data. It can also be used to fit extended + objects. The functional form of the Moffat profile is defined as: + + $$I(R) = \\frac{I_0}{(1 + (R/R_d)^2)^n}$$ + + `n` is the concentration index which controls the shape of the profile. + + **Parameters:** + - `n`: Concentration index which controls the shape of the brightness profile + - `Rd`: Scale length radius [pix] + - `I0`: Intensity at the center of the profile [flux/pix^2] + """ + + _model_type = "moffat" + _parameter_specs = { + "n": {"units": "none", "valid": (0.1, 10), "shape": (), "dynamic": True}, + "Rd": {"units": "pix", "valid": (0, None), "shape": (), "dynamic": True}, + "I0": { + "units": "flux/pix^2", + "valid": (0, None), + "shape": (), + "dynamic": False, + "value": 1.0, + }, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + moffat_np, + ("n", "Rd", "I0"), + _x0_func, + ) + + @forward + def radial_model(self, R: ArrayLike, n: ArrayLike, Rd: ArrayLike, I0: ArrayLike) -> ArrayLike: + return func.moffat(R, n, Rd, I0) diff --git a/astrophot/models/mixins/nuker.py b/astrophot/models/mixins/nuker.py index 36d26994..d6c5e1a9 100644 --- a/astrophot/models/mixins/nuker.py +++ b/astrophot/models/mixins/nuker.py @@ -92,11 +92,11 @@ class iNukerMixin: _model_type = "nuker" _parameter_specs = { - "Rb": {"units": "arcsec", "valid": (0, None), "dynamic": True}, - "Ib": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, - "alpha": {"units": "none", "valid": (0, None), "dynamic": True}, - "beta": {"units": "none", "valid": (0, None), "dynamic": True}, - "gamma": {"units": "none", "dynamic": True}, + "Rb": {"units": "arcsec", "valid": (0, None), "shape": (None,), "dynamic": True}, + "Ib": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (None,), "dynamic": True}, + "alpha": {"units": "none", "valid": (0, None), "shape": (None,), "dynamic": True}, + "beta": {"units": "none", "valid": (0, None), "shape": (None,), "dynamic": True}, + "gamma": {"units": "none", "shape": (None,), "dynamic": True}, } @torch.no_grad() @@ -125,3 +125,64 @@ def iradial_model( gamma: ArrayLike, ) -> ArrayLike: return func.nuker(R, Rb[i], Ib[i], alpha[i], beta[i], gamma[i]) + + +class NukerPSFMixin: + """Nuker radial light profile (Lauer et al. 1995). + + This is a classic profile used widely in galaxy modelling. The functional + form of the Nuker profile is defined as: + + $$I(R) = I_b2^{\\frac{\\beta - \\gamma}{\\alpha}}\\left(\\frac{R}{R_b}\\right)^{-\\gamma}\\left[1 + \\left(\\frac{R}{R_b}\\right)^{\\alpha}\\right]^{\\frac{\\gamma-\\beta}{\\alpha}}$$ + + It is effectively a double power law profile. $\\gamma$ gives the inner + slope, $\\beta$ gives the outer slope, $\\alpha$ is somewhat degenerate with + the other slopes. + + **Parameters:** + - `Rb`: scale length radius [pix] + - `Ib`: intensity at the scale length [flux/pix^2] + - `alpha`: sharpness of transition between power law slopes + - `beta`: outer power law slope + - `gamma`: inner power law slope + """ + + _model_type = "nuker" + _parameter_specs = { + "Rb": {"units": "pix", "valid": (0, None), "shape": (), "dynamic": True}, + "Ib": { + "units": "flux/pix^2", + "valid": (0, None), + "shape": (), + "dynamic": False, + "value": 1.0, + }, + "alpha": {"units": "none", "valid": (0, None), "shape": (), "dynamic": True}, + "beta": {"units": "none", "valid": (0, None), "shape": (), "dynamic": True}, + "gamma": {"units": "none", "shape": (), "dynamic": True}, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, + self.target[self.window], + nuker_np, + ("Rb", "Ib", "alpha", "beta", "gamma"), + _x0_func, + ) + + @forward + def radial_model( + self, + R: ArrayLike, + Rb: ArrayLike, + Ib: ArrayLike, + alpha: ArrayLike, + beta: ArrayLike, + gamma: ArrayLike, + ) -> ArrayLike: + return func.nuker(R, Rb, Ib, alpha, beta, gamma) diff --git a/astrophot/models/mixins/sample.py b/astrophot/models/mixins/sample.py index c33e9dcf..dc089984 100644 --- a/astrophot/models/mixins/sample.py +++ b/astrophot/models/mixins/sample.py @@ -5,9 +5,10 @@ from ...param import forward from ...backend_obj import backend, ArrayLike from ... import config -from ...image import Image, Window, JacobianImage +from ...image import JacobianImage from .. import func from ...errors import SpecificationConflict +from ...utils.integration import quad_table class SampleMixin: @@ -18,8 +19,6 @@ class SampleMixin: - `midpoint`: Use midpoint sampling, evaluate the brightness at the center of each pixel. - `simpsons`: Use Simpson's rule for sampling integrating each pixel. - `quad:x`: Use quadrature sampling with order x, where x is a positive integer to integrate each pixel. - - `jacobian_maxparams`: The maximum number of parameters before the Jacobian will be broken into smaller chunks. This is helpful for limiting the memory requirements to build a model. - - `jacobian_maxpixels`: The maximum number of pixels before the Jacobian will be broken into smaller chunks. This is helpful for limiting the memory requirements to build a model. - `integrate_mode`: The method used to select pixels to integrate further where the model varies significantly. Options are: - `none`: No extra integration is performed (beyond the sampling_mode). - `bright`: Select the brightest pixels for further integration. @@ -31,13 +30,6 @@ class SampleMixin: - `integrate_quad_order`: The order of the quadrature used for the integration method on the super sampled pixels. """ - # Method for initial sampling of model - sampling_mode = "auto" # auto (choose based on image size), midpoint, simpsons, quad:x (where x is a positive integer) - - # Maximum size of parameter list before jacobian will be broken into smaller chunks, this is helpful for limiting the memory requirements to build a model, lower jacobian_chunksize is slower but uses less memory - jacobian_maxparams = 10 - jacobian_maxpixels = 1000**2 - integrate_mode = "bright" # none, bright, curvature integrate_fraction = 0.05 # fraction of the pixels to super sample integrate_max_depth = 2 integrate_gridding = 5 @@ -45,8 +37,6 @@ class SampleMixin: _options = ( "sampling_mode", - "jacobian_maxparams", - "jacobian_maxpixels", "integrate_mode", "integrate_fraction", "integrate_max_depth", @@ -54,15 +44,26 @@ class SampleMixin: "integrate_quad_order", ) + def __init__(self, *args, sampling_mode="auto", integrate_mode="bright", **kwargs): + super().__init__(*args, **kwargs) + self.sampling_mode = sampling_mode + self.integrate_mode = integrate_mode + @forward - def _bright_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: - i, j = image.pixel_center_meshgrid() + def _bright_integrate( + self, + sample: ArrayLike, + i: ArrayLike, + j: ArrayLike, + upsample: int, + pixel_brightness: callable, + ) -> ArrayLike: sample = func.bright_integrate( sample, i, j, - lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), - scale=image.base_scale, + pixel_brightness, + scale=self.target.base_scale / upsample, bright_frac=self.integrate_fraction, quad_order=self.integrate_quad_order, gridding=self.integrate_gridding, @@ -71,8 +72,14 @@ def _bright_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: return sample @forward - def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: - i, j = image.pixel_center_meshgrid() + def _curvature_integrate( + self, + sample: ArrayLike, + i: ArrayLike, + j: ArrayLike, + upsample: int, + pixel_brightness: callable, + ) -> ArrayLike: kernel = func.curvature_kernel(config.DTYPE, config.DEVICE) curvature = ( backend.abs( @@ -89,7 +96,7 @@ def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: .squeeze(0) .squeeze(0) ) - N = max(1, int(np.prod(image.data.shape) * self.integrate_fraction)) + N = max(1, int(np.prod(i.shape) * self.integrate_fraction)) select = backend.topk(curvature.flatten(), N)[1] sample_flat = sample.flatten() @@ -99,8 +106,8 @@ def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: func.recursive_quad_integrate( i.flatten()[select], j.flatten()[select], - lambda i, j: self.brightness(*image.pixel_to_plane(i, j)), - scale=image.base_scale, + pixel_brightness, + scale=self.target.base_scale / upsample, curve_frac=self.integrate_fraction, quad_order=self.integrate_quad_order, gridding=self.integrate_gridding, @@ -109,48 +116,94 @@ def _curvature_integrate(self, sample: ArrayLike, image: Image) -> ArrayLike: ) return sample_flat.reshape(sample.shape) - @forward - def sample_image(self, image: Image) -> ArrayLike: - if self.sampling_mode == "auto": - N = np.prod(image._data.shape[:2]) - if N <= 100: - sampling_mode = "quad:5" - elif N <= 10000: - sampling_mode = "simpsons" - else: - sampling_mode = "midpoint" - else: - sampling_mode = self.sampling_mode + @property + def sampling_mode(self): + return self._sampling_mode + + @sampling_mode.setter + def sampling_mode(self, sampling_mode): + if sampling_mode == "auto": + sampling_mode = "midpoint" + try: + N = np.prod(self.window.shape) + if N <= 100: + sampling_mode = "quad:5" + elif N <= 10000: + sampling_mode = "simpsons" + except: + pass if sampling_mode == "midpoint": - x, y = image.coordinate_center_meshgrid() - res = self.brightness(x, y) - sample = func.pixel_center_integrator(res) + self._pixel_meshgridder = lambda im, w, p, u: im.pixel_center_meshgrid(w, p, u) + self._pixel_integrator = func.pixel_center_integrator + self._pixel_center_finder = lambda i, j: (i, j) elif sampling_mode == "simpsons": - x, y = image.coordinate_simpsons_meshgrid() - res = self.brightness(x, y) - sample = func.pixel_simpsons_integrator(res) + self._pixel_meshgridder = lambda im, w, p, u: im.pixel_simpsons_meshgrid(w, p, u) + self._pixel_integrator = func.pixel_simpsons_integrator + self._pixel_center_finder = lambda i, j: (i[1:-1:2, 1:-1:2], j[1:-1:2, 1:-1:2]) elif sampling_mode.startswith("quad:"): - order = int(self.sampling_mode.split(":")[1]) - i, j, w = image.pixel_quad_meshgrid(order=order) - x, y = image.pixel_to_plane(i, j) - res = self.brightness(x, y) - sample = func.pixel_quad_integrator(res, w) + order = int(sampling_mode.split(":")[1]) + self._pixel_meshgridder = lambda im, w, p, u: im.pixel_quad_meshgrid( + w, p, u, order=order + )[:2] + _, _, w = quad_table(order, config.DTYPE, config.DEVICE) + w = w.flatten() + self._pixel_integrator = lambda z: func.pixel_quad_integrator(z, w) + self._pixel_center_finder = lambda i, j: (i[..., order**2 // 2], j[..., order**2 // 2]) + elif sampling_mode.startswith("upsample:"): + upsample = int(sampling_mode.split(":")[1]) + if upsample % 2 != 1: + raise SpecificationConflict( + f"Upsample factor for 'sample_mode' must be an odd integer, got {upsample} for model {self.name}" + ) + self._pixel_meshgridder = lambda im, w, p, u: im.pixel_center_meshgrid( + w, upsample * p, upsample * u + ) + self._pixel_integrator = lambda z: func.downsample_mean(z, upsample) + self._pixel_center_finder = lambda i, j: ( + i[upsample // 2 :: upsample, upsample // 2 :: upsample], + j[upsample // 2 :: upsample, upsample // 2 :: upsample], + ) else: raise SpecificationConflict( - f"Unknown sampling mode {self.sampling_mode} for model {self.name}" + f"Unknown sampling mode {sampling_mode} for model {self.name}" ) - if self.integrate_mode == "curvature": - sample = self._curvature_integrate(sample, image) - elif self.integrate_mode == "bright": - sample = self._bright_integrate(sample, image) - elif self.integrate_mode != "none": + self._sampling_mode = sampling_mode + + @property + def integrate_mode(self): + return self._integrate_mode + + @integrate_mode.setter + def integrate_mode(self, integrate_mode): + if integrate_mode == "bright": + self._adaptive_integrator = self._bright_integrate + elif integrate_mode == "curvature": + self._adaptive_integrator = self._curvature_integrate + elif integrate_mode == "none": + self._adaptive_integrator = lambda z, *a, **kw: z + else: raise SpecificationConflict( - f"Unknown integrate mode {self.integrate_mode} for model {self.name}" + f"Unknown integrate mode {integrate_mode} for model {self.name}" ) - return sample + self._integrate_mode = integrate_mode + + +class GradMixin: + """ + **Options:** + - `jacobian_maxparams`: The maximum number of parameters before the Jacobian will be broken into smaller chunks. This is helpful for limiting the memory requirements to fit a model. + """ + + # Maximum size of parameter list before jacobian will be broken into smaller chunks, this is helpful for limiting the memory requirements to build a model, lower jacobian_chunksize is slower but uses less memory + jacobian_maxparams = 10 + + _options = ("jacobian_maxparams",) def _jacobian( - self, window: Window, params_pre: ArrayLike, params: ArrayLike, params_post: ArrayLike + self, + params_pre: ArrayLike, + params: ArrayLike, + params_post: ArrayLike, ) -> ArrayLike: # return jacfwd( # this should be more efficient, but the trace overhead is too high # lambda x: self.sample( @@ -158,23 +211,18 @@ def _jacobian( # ).data # )(params) return backend.jacobian( - lambda x: self.sample( - window=window, params=backend.concatenate((params_pre, x, params_post), dim=-1) - )._data, + lambda x: self(params=backend.concatenate((params_pre, x, params_post), dim=-1))._data, params, ) def jacobian( self, - window: Optional[Window] = None, pass_jacobian: Optional[JacobianImage] = None, params: Optional[ArrayLike] = None, ) -> JacobianImage: - if window is None: - window = self.window if pass_jacobian is None: - jac_img = self.target[window].jacobian_image( + jac_img = self.target[self.window].jacobian_image( parameters=self.build_params_array_identities() ) else: @@ -183,61 +231,52 @@ def jacobian( # No dynamic params if params is None: params = self.get_values() - if params.shape[-1] == 0: - return jac_img - - # handle large images - n_pixels = np.prod(window.shape) - if n_pixels > self.jacobian_maxpixels: - for chunk in window.chunk(self.jacobian_maxpixels): - jac_img = self.jacobian(window=chunk, pass_jacobian=jac_img, params=params) + if len(params.shape) == 0 or params.shape[-1] == 0: return jac_img identities = self.build_params_array_identities() if len(jac_img.match_parameters(identities)[0]) == 0: return jac_img - target = self.target[window] + target = self.target[self.window] if len(params) > self.jacobian_maxparams: # handle large number of parameters chunksize = len(params) // self.jacobian_maxparams + 1 for i in range(0, len(params), chunksize): params_pre = params[:i] params_chunk = params[i : i + chunksize] params_post = params[i + chunksize :] - jac_chunk = self._jacobian(window, params_pre, params_chunk, params_post) + jac_chunk = self._jacobian(params_pre, params_chunk, params_post) jac_img += target.jacobian_image( parameters=identities[i : i + chunksize], data=jac_chunk, ) else: - jac = self._jacobian(window, params[:0], params, params[0:0]) + jac = self._jacobian(params[:0], params, params[0:0]) jac_img += target.jacobian_image(parameters=identities, data=jac) return jac_img def gradient( self, - window: Optional[Window] = None, params: Optional[ArrayLike] = None, likelihood: Literal["gaussian", "poisson"] = "gaussian", ) -> ArrayLike: - """Compute the gradient of the model with respect to its parameters.""" - if window is None: - window = self.window + """Compute the gradient of the model likelihood with respect to its parameters.""" - jacobian_image = self.jacobian(window=window, params=params) + jacobian_image = self.jacobian(params=params).flatten("data") - data = self.target[window]._data - model = self.sample(window=window)._data + data = self.target[self.window].flatten("data") + mask = self.target[self.window].flatten("mask") + model = self().flatten("data") if likelihood == "gaussian": - weight = self.target[window]._weight + weight = self.target[self.window].flatten("weight") gradient = backend.sum( - jacobian_image._data * ((data - model) * weight)[..., None], dim=(0, 1) + jacobian_image * ((data - model) * weight * (~mask))[..., None], dim=0 ) elif likelihood == "poisson": gradient = backend.sum( - jacobian_image._data * (1 - data / model)[..., None], - dim=(0, 1), + jacobian_image * ((1 - data / model) * (~mask))[..., None], + dim=0, ) return gradient diff --git a/astrophot/models/mixins/sersic.py b/astrophot/models/mixins/sersic.py index 11730f1e..96074f4a 100644 --- a/astrophot/models/mixins/sersic.py +++ b/astrophot/models/mixins/sersic.py @@ -78,9 +78,9 @@ class iSersicMixin: _model_type = "sersic" _parameter_specs = { - "n": {"units": "none", "valid": (0.36, 8), "dynamic": True}, - "Re": {"units": "arcsec", "valid": (0, None), "dynamic": True}, - "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}, + "n": {"units": "none", "valid": (0.36, 8), "shape": (None,), "dynamic": True}, + "Re": {"units": "arcsec", "valid": (0, None), "shape": (None,), "dynamic": True}, + "Ie": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (None,), "dynamic": True}, } @torch.no_grad() @@ -102,3 +102,51 @@ def iradial_model( self, i: int, R: ArrayLike, n: ArrayLike, Re: ArrayLike, Ie: ArrayLike ) -> ArrayLike: return func.sersic(R, n[i], Re[i], Ie[i]) + + +class SersicPSFMixin: + """Sersic radial light profile (Sersic 1963). + + This is a classic profile used widely in galaxy modelling, though it + is simply a generalization of a Gaussian. It can be a good starting + point for many objects. The functional form of the Sersic profile is + defined as: + + $$I(R) = I_e * \\exp(- b_n((R/R_e)^{1/n} - 1))$$ + + It includes the gaussian, exponential, and de-Vaucouleurs + profiles. The Sersic index `n` controls the shape of the profile, with `n=1` + being an exponential profile, `n=4` being a de-Vaucouleurs profile, and + `n=0.5` being a Gaussian profile. + + **Parameters:** + - `n`: Sersic index which controls the shape of the brightness profile + - `Re`: half light radius [pix] + - `Ie`: intensity at the half light radius [flux/pix^2] + """ + + _model_type = "sersic" + _parameter_specs = { + "n": {"units": "none", "valid": (0.36, 8), "shape": (), "dynamic": True}, + "Re": {"units": "pix", "valid": (0, None), "shape": (), "dynamic": True}, + "Ie": { + "units": "flux/pix^2", + "valid": (0, None), + "shape": (), + "dynamic": False, + "value": 1.0, + }, + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + parametric_initialize( + self, self.target[self.window], sersic_np, ("n", "Re", "Ie"), _x0_func + ) + + @forward + def radial_model(self, R: ArrayLike, n: ArrayLike, Re: ArrayLike, Ie: ArrayLike) -> ArrayLike: + return func.sersic(R, n, Re, Ie) diff --git a/astrophot/models/mixins/spline.py b/astrophot/models/mixins/spline.py index 4b95dffb..57862578 100644 --- a/astrophot/models/mixins/spline.py +++ b/astrophot/models/mixins/spline.py @@ -22,7 +22,9 @@ class SplineMixin: """ _model_type = "spline" - _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}} + _parameter_specs = { + "I_R": {"units": "flux/arcsec^2", "valid": (0, None), "shape": (None,), "dynamic": True} + } @torch.no_grad() @ignore_numpy_warnings @@ -72,7 +74,14 @@ class iSplineMixin: """ _model_type = "spline" - _parameter_specs = {"I_R": {"units": "flux/arcsec^2", "valid": (0, None), "dynamic": True}} + _parameter_specs = { + "I_R": { + "units": "flux/arcsec^2", + "valid": (0, None), + "shape": (None, None), + "dynamic": True, + } + } @torch.no_grad() @ignore_numpy_warnings @@ -114,3 +123,51 @@ def initialize(self): @forward def iradial_model(self, i: int, R: ArrayLike, I_R: ArrayLike) -> ArrayLike: return func.spline(R, self.I_R.prof[i], I_R[i]) + + +class SplinePSFMixin: + """Spline radial model for brightness. + + The `radial_model` function for this model is defined as a spline + interpolation from the parameter `I_R`. The `I_R` parameter is a tensor + that contains the radial profile of the brightness in units of + flux/pix^2. The radius of each node is determined from `I_R.prof`. + + **Parameters:** + - `I_R`: Tensor of radial brightness values in units of flux/pix^2. + """ + + _model_type = "spline" + _parameter_specs = { + "I_R": {"units": "flux/pix^2", "valid": (0, None), "shape": (None,), "dynamic": True} + } + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + + if self.I_R.initialized: + return + + target_area = self.target[self.window] + # Create the I_R profile radii if needed + if self.I_R.prof is None: + prof = default_prof(self.window.shape, target_area.pixelscale, 2, 0.2) + prof = np.append(prof, prof[-1] * 10) + self.I_R.prof = prof + else: + prof = self.I_R.prof + + R, I, S = _sample_image( + target_area, + self.transform_coordinates, + self.radius_metric, + rad_bins=[0] + list((prof[:-1] + prof[1:]) / 2) + [prof[-1] * 100], + ) + self.I_R.value = 10**I + + @forward + def radial_model(self, R: ArrayLike, I_R: ArrayLike) -> ArrayLike: + ret = func.spline(R, self.I_R.prof, I_R) + return ret diff --git a/astrophot/models/mixins/transform.py b/astrophot/models/mixins/transform.py index 7d335cc5..223bfc40 100644 --- a/astrophot/models/mixins/transform.py +++ b/astrophot/models/mixins/transform.py @@ -118,7 +118,7 @@ class SuperEllipseMixin: _model_type = "superellipse" _parameter_specs = { - "C": {"units": "none", "value": 2.0, "valid": (0, 10), "dynamic": True}, + "C": {"units": "none", "value": 2.0, "valid": (0, 10), "shape": (), "dynamic": True}, } @forward @@ -168,8 +168,14 @@ class FourierEllipseMixin: _model_type = "fourier" _parameter_specs = { - "am": {"units": "none", "dynamic": True}, - "phim": {"units": "radians", "valid": (0, 2 * np.pi), "cyclic": True, "dynamic": False}, + "am": {"units": "none", "shape": (None,), "dynamic": True}, + "phim": { + "units": "radians", + "valid": (0, 2 * np.pi), + "cyclic": True, + "shape": (None,), + "dynamic": False, + }, } _options = ("modes",) @@ -197,9 +203,9 @@ def initialize(self): super().initialize() if not self.am.initialized: - self.am.value = np.zeros(len(self.modes)) + self.am.value = np.zeros(len(self.modes)) + 0.0001 if not self.phim.initialized: - self.phim.value = np.zeros(len(self.modes)) + self.phim.value = np.zeros(len(self.modes)) + 0.0001 class WarpMixin: @@ -228,8 +234,14 @@ class WarpMixin: _model_type = "warp" _parameter_specs = { - "q_R": {"units": "b/a", "valid": (0, 1), "dynamic": True}, - "PA_R": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "dynamic": True}, + "q_R": {"units": "b/a", "valid": (0, 1), "shape": (None,), "dynamic": True}, + "PA_R": { + "units": "radians", + "valid": (0, np.pi), + "cyclic": True, + "shape": (None,), + "dynamic": True, + }, } @torch.no_grad() diff --git a/astrophot/models/model_object.py b/astrophot/models/model_object.py index 8e6af051..7340d111 100644 --- a/astrophot/models/model_object.py +++ b/astrophot/models/model_object.py @@ -1,28 +1,23 @@ from typing import Optional import numpy as np -import torch from ..param import forward from .base import Model from . import func -from ..image import ( - TargetImage, - Window, - PSFImage, -) +from ..image import TargetImage, ModelImage, PSFImage from ..utils.initialize import recursive_center_of_mass from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from .. import config -from ..backend_obj import backend +from ..backend_obj import backend, ArrayLike from ..errors import InvalidTarget -from .mixins import SampleMixin +from .mixins import SampleMixin, GradMixin __all__ = ("ComponentModel",) @combine_docstrings -class ComponentModel(SampleMixin, Model): +class ComponentModel(GradMixin, SampleMixin, Model): """Component of a model for an object in an image. This is a single component of an image model. It has a position on the sky @@ -38,56 +33,17 @@ class ComponentModel(SampleMixin, Model): _parameter_specs = {"center": {"units": "arcsec", "shape": (2,), "dynamic": True}} - _options = ("psf_convolve",) - usable = False + psf_convolve = True + internal_psf = False + + _options = ("psf_convolve",) - def __init__(self, *args, psf=None, psf_convolve: bool = False, **kwargs): + def __init__(self, *args, psf=None, **kwargs): super().__init__(*args, **kwargs) self.psf = psf - self.psf_convolve = psf_convolve self.saveattrs.add("window.extent") - @property - def psf(self): - if self._psf is None: - return self.target.psf - return self._psf - - @psf.setter - def psf(self, val): - try: - del self._psf # Remove old PSF if it exists - except AttributeError: - pass - if val is None: - self._psf = None - elif isinstance(val, PSFImage): - self._psf = val - self.psf_convolve = True - elif isinstance(val, Model): - self._psf = val - self.psf_convolve = True - else: - self._psf = self.target.psf_image(data=val) - self.psf_convolve = True - self._update_psf_upscale() - - def _update_psf_upscale(self): - """Update the PSF upscale factor based on the current target pixel length.""" - if self.psf is None: - self.psf_upscale = 1 - elif isinstance(self.psf, PSFImage): - self.psf_upscale = int(np.round((self.target.pixelscale / self.psf.pixelscale).item())) - elif isinstance(self.psf, Model): - self.psf_upscale = int( - np.round((self.target.pixelscale / self.psf.target.pixelscale).item()) - ) - else: - raise TypeError( - f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." - ) - @property def target(self): return self._target @@ -98,20 +54,52 @@ def target(self, tar): self._target = None return elif not isinstance(tar, TargetImage): - raise InvalidTarget("AstroPhot Model target must be a TargetImage instance.") + raise InvalidTarget( + f"AstroPhot {self.__class__.__name__} target must be a TargetImage instance." + ) try: del self._target # Remove old target if it exists except AttributeError: pass self._target = tar + + @property + def psf(self): + if self._psf is None: + return self.target.psf + return self._psf + + @psf.setter + def psf(self, psf): try: - self._update_psf_upscale() + del self._psf # Remove old psf if it exists except AttributeError: pass + if psf is None: + self._psf = None + elif isinstance(psf, PSFImage): + self._psf = psf + elif isinstance(psf, Model): + self._psf = psf + else: + self._psf = PSFImage(data=psf) + config.logger.warning( + f"PSF provided to {self.__class__.__name__} was not a PSFImage or Model instance, so it was converted to a PSFImage assuming no upsampling." + ) + + def _prep_psf(self): + if not self.psf_convolve: + return None, 1, 0 + psf = self.psf + + if isinstance(psf, PSFImage): + return psf._data, psf.upsample, psf.pad + if isinstance(psf, Model): + return psf, psf.upsample, psf.pad + return None, 1, 0 # Initialization functions ###################################################################### - @torch.no_grad() @ignore_numpy_warnings def initialize(self): """Determine initial values for the center coordinates. This is done @@ -135,64 +123,82 @@ def initialize(self): if not np.all(np.isfinite(COM)): return COM_center = target_area.pixel_to_plane( - *backend.as_array(COM, dtype=config.DTYPE, device=config.DEVICE) + *backend.as_array(COM, dtype=config.DTYPE, device=config.DEVICE), () ) self.center.value = COM_center - def fit_mask(self): - return backend.zeros_like(self.target[self.window].mask, dtype=backend.bool) - - def _fit_mask(self): - return backend.zeros_like(self.target[self.window]._mask, dtype=backend.bool) - @forward def transform_coordinates(self, x, y, center): return x - center[0], y - center[1] + @forward + def pixel_brightness(self, i, j, _CD=None, _crtan=None, _crpix=None): + """Evaluate the model at the pixel coordinates defined by i and j (of the target image).""" + if _CD is None: + x, y = self.target.pixel_to_plane(i, j) + else: + x, y = self.target.pixel_to_plane(i, j, CD=_CD, crtan=_crtan, _crpix=_crpix) + return self.brightness(x, y) + @forward def sample( self, - window: Optional[Window] = None, + I_: ArrayLike, + J_: ArrayLike, + psf: ArrayLike = None, + crop: int = 0, + downsample: int = 1, + _CD: Optional[ArrayLike] = None, + _crtan: Optional[ArrayLike] = None, + _crpix: Optional[ArrayLike] = None, ): - """Evaluate the model on the pixels defined in an image. This - function properly calls integration methods and PSF - convolution. This should not be overloaded except in special - cases. - - This function is designed to compute the model on a given - image or within a specified window. It takes care of sub-pixel - sampling, recursive integration for high curvature regions, - PSF convolution, and proper alignment of the computed model - with the original pixel grid. The final model is then added to - the requested image. - - **Args:** - - `window` (Optional[Window]): A window within which to evaluate the model. - By default this is the model's window. - - **Returns:** - - `Image` (ModelImage): The image with the computed model values. - - """ - # Window within which to evaluate model - if window is None: - window = self.window - - if self.psf_convolve: - psf = self.psf() if isinstance(self.psf, Model) else self.psf - - working_image = self.target[window].model_image( - upsample=self.psf_upscale, pad=psf.psf_pad - ) - sample = self.sample_image(working_image) - working_image._data = func.convolve(sample, psf._data) - working_image = working_image.crop(psf.psf_pad).reduce(self.psf_upscale) - + Z = self.pixel_brightness(I_, J_, _CD=_CD, _crtan=_crtan, _crpix=_crpix) + Z = self._pixel_integrator(Z) + I_, J_ = self._pixel_center_finder(I_, J_) + Z = self._adaptive_integrator( + Z, + I_, + J_, + downsample, + lambda i, j: self.pixel_brightness(i, j, _CD=_CD, _crtan=_crtan, _crpix=_crpix), + ) + if _CD is None: + Z = Z * self.target.pixel_collecting_area(I_, J_, downsample) else: - working_image = self.target[window].model_image() - working_image._data = self.sample_image(working_image) - - # Units from flux/arcsec^2 to flux, multiply by pixel area - working_image.fluxdensity_to_flux() + Z = Z * self.target.pixel_collecting_area(I_, J_, downsample, CD=_CD) + if psf is not None: + if isinstance(psf, Model): + psf = psf()._data + if psf.shape != (1, 1): # skip if identity PSF + Z = func.convolve(Z, psf) + Z = Z[crop : Z.shape[0] - crop, crop : Z.shape[1] - crop] + Z = func.downsample(Z, downsample) + return Z + @forward + def __call__( + self, + _CD: Optional[ArrayLike] = None, + _crtan: Optional[ArrayLike] = None, + _crpix: Optional[ArrayLike] = None, + _psf: Optional[ArrayLike] = None, + ) -> ModelImage: + + psf, upsample, pad = self._prep_psf() + if _psf is not None and not isinstance(psf, Model) and self.psf_convolve: + psf = _psf + working_image = self.target.model_image(self.window) + I, J = self._pixel_meshgridder(self.target, self.window, pad, upsample) + + # pixel_collecting_area: Units from flux/arcsec^2 to flux, multiply by pixel area + working_image._data = self.sample( + I, + J, + psf=psf, + crop=pad, + downsample=upsample, + _CD=_CD, + _crtan=_crtan, + _crpix=_crpix, + ) return working_image diff --git a/astrophot/models/moffat.py b/astrophot/models/moffat.py deleted file mode 100644 index 65be477c..00000000 --- a/astrophot/models/moffat.py +++ /dev/null @@ -1,69 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from .mixins import ( - MoffatMixin, - InclinedMixin, - RadialMixin, - WedgeMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, - iMoffatMixin, -) -from ..utils.decorators import combine_docstrings - - -__all__ = ( - "MoffatGalaxy", - "MoffatPSF", - "Moffat2DPSF", - "MoffatSuperEllipse", - "MoffatFourierEllipse", - "MoffatWarp", - "MoffatRay", - "MoffatWedge", -) - - -@combine_docstrings -class MoffatGalaxy(MoffatMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class MoffatPSF(MoffatMixin, RadialMixin, PSFModel): - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} - usable = True - - -@combine_docstrings -class Moffat2DPSF(MoffatMixin, InclinedMixin, RadialMixin, PSFModel): - _model_type = "2d" - _parameter_specs = {"I0": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} - usable = True - - -@combine_docstrings -class MoffatSuperEllipse(MoffatMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class MoffatFourierEllipse(MoffatMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class MoffatWarp(MoffatMixin, WarpMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class MoffatRay(iMoffatMixin, RayMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class MoffatWedge(iMoffatMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 5f50980c..105c77cb 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -30,10 +30,15 @@ class MultiGaussianExpansion(ComponentModel): _model_type = "mge" _parameter_specs = { - "q": {"units": "b/a", "valid": (0, 1), "dynamic": True}, - "PA": {"units": "radians", "valid": (0, np.pi), "cyclic": True, "dynamic": True}, - "sigma": {"units": "arcsec", "valid": (0, None), "dynamic": True}, - "flux": {"units": "flux", "dynamic": True}, + "q": {"units": "b/a", "valid": (0, 1), "shape": (None,), "dynamic": True}, + "PA": { + "units": "radians", + "valid": (0, np.pi), + "cyclic": True, + "dynamic": True, + }, # No shape for PA since there are two options, use with caution + "sigma": {"units": "arcsec", "valid": (0, None), "shape": (None,), "dynamic": True}, + "flux": {"units": "flux", "shape": (None,), "dynamic": True}, } usable = True diff --git a/astrophot/models/nuker.py b/astrophot/models/nuker.py deleted file mode 100644 index 6e9f55f6..00000000 --- a/astrophot/models/nuker.py +++ /dev/null @@ -1,60 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from .mixins import ( - NukerMixin, - RadialMixin, - iNukerMixin, - RayMixin, - WedgeMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, -) -from ..utils.decorators import combine_docstrings - - -__all__ = [ - "NukerGalaxy", - "NukerPSF", - "NukerSuperEllipse", - "NukerFourierEllipse", - "NukerWarp", - "NukerWedge", - "NukerRay", -] - - -@combine_docstrings -class NukerGalaxy(NukerMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class NukerPSF(NukerMixin, RadialMixin, PSFModel): - _parameter_specs = {"Ib": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} - usable = True - - -@combine_docstrings -class NukerSuperEllipse(NukerMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class NukerFourierEllipse(NukerMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class NukerWarp(NukerMixin, WarpMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class NukerRay(iNukerMixin, RayMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class NukerWedge(iNukerMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/models/pixelated_model.py b/astrophot/models/pixelated_model.py new file mode 100644 index 00000000..7b2599f6 --- /dev/null +++ b/astrophot/models/pixelated_model.py @@ -0,0 +1,81 @@ +import torch +import numpy as np + +from .model_object import ComponentModel +from ..utils.decorators import ignore_numpy_warnings, combine_docstrings +from ..utils.interpolate import interp2d +from ..param import forward +from ..backend_obj import backend, ArrayLike +from ..utils.initialize import polar_decomposition +from . import func + + +__all__ = ["Pixelated"] + + +@combine_docstrings +class Pixelated(ComponentModel): + """Model described by a grid of pixels on the sky (bilinear interpolation) + + This model represents an extended source on the sky as a grid of pixels with + some brightness. The brightness at any point is determined by bilinear + interpolation of the grid values. This is a very flexible model that can + represent any source, but it is also computationally expensive to optimize + the large number of free parameters. + + The PA and scale are also parameters of this model, so one could alternately + fix the pixels to some image and just fit the PA and scale. + + **Parameters:** + - `I`: the total flux within each pixel, represented as the log of the flux. + - `PA`: the position angle of the model, in radians. + - `scale`: the scale of the model, in arcsec per grid unit. + + """ + + _model_type = "pixelated" + _parameter_specs = { + "I": {"units": "flux/arcsec^2", "shape": (None, None), "dynamic": True}, + "PA": {"units": "radians", "shape": (), "dynamic": False}, + "scale": {"units": "arcsec/grid-unit", "shape": (), "dynamic": False}, + } + usable = True + + def __init__( + self, *args, scale=None, sampling_mode="upsample:3", integrate_mode="none", **kwargs + ): + super().__init__( + *args, sampling_mode=sampling_mode, integrate_mode=integrate_mode, **kwargs + ) + self.scale = scale + + @torch.no_grad() + @ignore_numpy_warnings + def initialize(self): + super().initialize() + if not self.scale.initialized: + self.scale = 2 * self.target.pixelscale.item() + + if not self.I.initialized: + target_area = self.target[self.window] + self.I.value = func.downsample(backend.copy(target_area.data), 2) / ( + target_area.pixel_area * 4 + ) + + if not self.PA.initialized: + R, _ = polar_decomposition(self.target.CD.npvalue) + self.PA.value = np.arccos(np.abs(R[0, 0])) + + @forward + def transform_coordinates( + self, x: ArrayLike, y: ArrayLike, PA: ArrayLike, scale: ArrayLike + ) -> tuple[ArrayLike, ArrayLike]: + x, y = super().transform_coordinates(x, y) + x, y = func.rotate(-PA + np.pi / 2, x, y) + return x / scale, y / scale + + @forward + def brightness(self, x: ArrayLike, y: ArrayLike, I: ArrayLike) -> ArrayLike: + x, y = self.transform_coordinates(x, y) + pixel_center = (I.shape[0] - 1) / 2, (I.shape[1] - 1) / 2 + return interp2d(I, x + pixel_center[0], y + pixel_center[1]) diff --git a/astrophot/models/pixelated_psf.py b/astrophot/models/pixelated_psf.py index e0821aed..348670c6 100644 --- a/astrophot/models/pixelated_psf.py +++ b/astrophot/models/pixelated_psf.py @@ -3,7 +3,6 @@ from .psf_model_object import PSFModel from ..utils.decorators import ignore_numpy_warnings, combine_docstrings from ..utils.interpolate import interp2d -from caskade import OverrideParam from ..param import forward from ..backend_obj import backend, ArrayLike @@ -40,10 +39,8 @@ class PixelatedPSF(PSFModel): """ _model_type = "pixelated" - _parameter_specs = {"pixels": {"units": "flux/arcsec^2", "dynamic": True}} + _parameter_specs = {"pixels": {"units": "flux/pix^2", "shape": (None, None), "dynamic": True}} usable = True - sampling_mode = "midpoint" - integrate_mode = "none" @torch.no_grad() @ignore_numpy_warnings @@ -55,10 +52,10 @@ def initialize(self): self.pixels.value = backend.copy(target_area._data) / target_area.pixel_area @forward - def brightness( - self, x: ArrayLike, y: ArrayLike, pixels: ArrayLike, center: ArrayLike - ) -> ArrayLike: - with OverrideParam(self.target.crtan, center): - i, j = self.target.plane_to_pixel(x, y) - result = interp2d(pixels, i, j) - return result + def brightness(self, x: ArrayLike, y: ArrayLike, pixels: ArrayLike) -> ArrayLike: + x, y = self.transform_coordinates(x, y) + return interp2d( + pixels, + x * self.target.upsample + pixels.shape[0] // 2, + y * self.target.upsample + pixels.shape[1] // 2, + ) diff --git a/astrophot/models/planesky.py b/astrophot/models/planesky.py index b8d4f251..f0d572af 100644 --- a/astrophot/models/planesky.py +++ b/astrophot/models/planesky.py @@ -27,8 +27,8 @@ class PlaneSky(SkyModel): _model_type = "plane" _parameter_specs = { - "I0": {"units": "flux/arcsec^2", "dynamic": True}, - "delta": {"units": "flux/arcsec", "dynamic": True}, + "I0": {"units": "flux/arcsec^2", "shape": (), "dynamic": True}, + "delta": {"units": "flux/arcsec", "shape": (2,), "dynamic": True}, } usable = True diff --git a/astrophot/models/point_source.py b/astrophot/models/point_source.py index 90faec52..44707963 100644 --- a/astrophot/models/point_source.py +++ b/astrophot/models/point_source.py @@ -12,6 +12,7 @@ from ..errors import SpecificationConflict from ..param import forward from ..backend_obj import backend, ArrayLike +from .. import config from . import func __all__ = ("PointSource",) @@ -34,19 +35,18 @@ class PointSource(ComponentModel): _parameter_specs = { "flux": {"units": "flux", "valid": (0, None), "shape": (), "dynamic": True}, } + internal_psf = True usable = True - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - if self.psf is None: - raise SpecificationConflict("Point_Source needs a psf!") + def __init__(self, *args, integrate_mode="none", **kwargs): + super().__init__(*args, integrate_mode=integrate_mode, **kwargs) @torch.no_grad() @ignore_numpy_warnings def initialize(self): super().initialize() + if self.psf is None: + raise SpecificationConflict("PointSource needs a psf!") if self.flux.initialized: return @@ -59,6 +59,17 @@ def initialize(self): edge_average = np.median(edge) self.flux.value = np.abs(np.sum(dat - edge_average)) + @property + def integrate_mode(self): + return "none" + + @integrate_mode.setter + def integrate_mode(self, value): + if value != "none": + config.logger.warning( + "PointSource models are restricted to integrate mode of 'none', ignoring integrate_mode setting." + ) + # Psf convolution should be on by default since this is a delta function @property def psf_convolve(self): @@ -68,68 +79,41 @@ def psf_convolve(self): def psf_convolve(self, value): pass - @property - def integrate_mode(self): - return "none" + def _prep_psf(self): + psf = self.psf - @integrate_mode.setter - def integrate_mode(self, value): - pass + if isinstance(psf, PSFImage): + return psf._data, psf.upsample, 0 + if isinstance(psf, Model): + return psf, psf.upsample, 0 + return None, 1, 0 @forward def sample( self, - window: Optional[Window] = None, - center: ArrayLike = None, - flux: ArrayLike = None, - ) -> ModelImage: - """Evaluate the model on the space covered by an image object. This - function properly calls integration methods and PSF - convolution. This should not be overloaded except in special - cases. - - This function is designed to compute the model on a given - image or within a specified window. It takes care of sub-pixel - sampling, recursive integration for high curvature regions, - PSF convolution, and proper alignment of the computed model - with the original pixel grid. The final model is then added to - the requested image. - - Args: - image (Optional[Image]): An AstroPhot Image object (likely a Model_Image) - on which to evaluate the model values. If not - provided, a new Model_Image object will be created. - window (Optional[Window]): A window within which to evaluate the model. - Should only be used if a subset of the full image - is needed. If not provided, the entire image will - be used. - - Returns: - Image: The image with the computed model values. - - """ - # Window within which to evaluate model - if window is None: - window = self.window - - if isinstance(self.psf, PSFImage): - psf = self.psf._data - elif isinstance(self.psf, Model): - psf = self.psf()._data + I_: ArrayLike, + J_: ArrayLike, + psf: ArrayLike = None, + crop: int = 0, + downsample: int = 1, + center=None, + flux=None, + _CD=None, + _crtan=None, + _crpix=None, + ): + if isinstance(psf, Model): + psf = psf()._data + if _CD is None: + i0, j0 = self.target.plane_to_pixel(*center) else: - raise TypeError( - f"PSF must be a PSFImage or Model instance, got {type(self.psf)} instead." - ) - - # Make the image object to which the samples will be tracked - working_image = self.target[window].model_image(upsample=self.psf_upscale) - - i, j, w = working_image.pixel_quad_meshgrid() - i0, j0 = working_image.plane_to_pixel(*center) - z = interp2d(psf, i - i0 + (psf.shape[0] // 2), j - j0 + (psf.shape[1] // 2)) - - working_image._data = flux * func.pixel_quad_integrator(z, w) - - working_image = working_image.reduce(self.psf_upscale) - - return working_image + i0, j0 = self.target.plane_to_pixel(*center, CD=_CD, crtan=_crtan, _crpix=_crpix) + Z = interp2d( + psf, + (I_ - i0) * downsample + (psf.shape[0] // 2), + (J_ - j0) * downsample + (psf.shape[1] // 2), + ) + Z = self._pixel_integrator(Z) + Z = Z * flux + Z = func.downsample(Z, downsample) + return Z diff --git a/astrophot/models/psf_model_object.py b/astrophot/models/psf_model_object.py index 3ba42dfc..04e23acc 100644 --- a/astrophot/models/psf_model_object.py +++ b/astrophot/models/psf_model_object.py @@ -4,13 +4,14 @@ from .base import Model from ..image import ModelImage, PSFImage, Window from ..errors import InvalidTarget -from .mixins import SampleMixin +from .mixins import SampleMixin, GradMixin from ..backend_obj import backend, ArrayLike +from . import func -__all__ = ["PSFModel"] +__all__ = ("PSFModel",) -class PSFModel(SampleMixin, Model): +class PSFModel(GradMixin, SampleMixin, Model): """Prototype point source (typically a star) model, to be subclassed by other point source models which define specific behavior. @@ -24,7 +25,7 @@ class PSFModel(SampleMixin, Model): """ _parameter_specs = { - "center": {"units": "arcsec", "value": (0.0, 0.0), "shape": (2,), "dynamic": False}, + "center": {"units": "pix", "value": (0.0, 0.0), "shape": (2,), "dynamic": False}, } _model_type = "psf" usable = False @@ -38,49 +39,63 @@ class PSFModel(SampleMixin, Model): def initialize(self): pass + @property + def upsample(self): + return self.target.upsample + + @property + def pad(self): + return self.target.pad + @forward def transform_coordinates( self, x: ArrayLike, y: ArrayLike, center: ArrayLike ) -> Tuple[ArrayLike, ArrayLike]: return x - center[0], y - center[1] - # Fit loop functions - ###################################################################### @forward - def sample(self, window: Optional[Window] = None) -> PSFImage: - """Evaluate the model on the space covered by an image object. This - function properly calls integration methods. This should not - be overloaded except in special cases. - - This function is designed to compute the model on a given - image or within a specified window. It takes care of sub-pixel - sampling, recursive integration for high curvature regions, - and proper alignment of the computed model with the original - pixel grid. The final model is then added to the requested - image. - - **Args:** - - `window` (Optional[Window]): A window within which to evaluate the model. - Should only be used if a subset of the full image - is needed. If not provided, the entire image will - be used. + def pixel_brightness(self, i, j): + """Evaluate the model at the pixel coordinates defined by i and j (of + the target image). For a PSF model, this is the same as the brightness + since it is defined in pixel units.""" + return self.brightness(*self.target.mypixel_to_targpixel(i, j)) - **Returns:** - - `PSFImage`: The image with the computed model values. + def _prep_psf(self): + return None, 1, 0 + # Fit loop functions + ###################################################################### + @forward + def sample( + self, + i: ArrayLike, + j: ArrayLike, + *args, + **kwargs, + ) -> PSFImage: """ - # Create an image to store pixel samples - working_image = self.target[self.window].model_image() - working_image._data = self.sample_image(working_image) + Sample the PSF model on the pixel grid defined by i and j. - # normalize to total flux 1 - if self.normalize_psf: - working_image.normalize() + Depending on the model specification, this may involve supersampling for + higher precision, or it may just be a direct evaluation of the model at + the pixel centers. The output is the flux evaluated over the pixel grid + at native resolution (for the PSFImage associated with this model.) - return working_image + **Parameters:** + - `i`: 2D array of x-coordinates of pixel centers (or pre-upsampled + according to the `sampling_mode`) in pixel units. + - `j`: 2D array of y-coordinates of pixel centers (or pre-upsampled + according to the `sampling_mode`) in pixel units. - def fit_mask(self) -> ArrayLike: - return backend.zeros_like(self.target[self.window].mask, dtype=backend.bool) + **Returns:** + - ``Z``: 2D array of flux values at each pixel center, representing the + PSF model evaluated at those coordinates. + """ + Z = self.pixel_brightness(i, j) + Z = self._pixel_integrator(Z) + i, j = self._pixel_center_finder(i, j) + Z = self._adaptive_integrator(Z, i, j, 1, self.pixel_brightness) + return Z * self.target.pixel_area @property def target(self): @@ -94,7 +109,7 @@ def target(self, target): if target is None: self._target = None elif not isinstance(target, PSFImage): - raise InvalidTarget(f"Target for PSF_Model must be a PSF_Image, not {type(target)}") + raise InvalidTarget(f"Target for PSFModel must be a PSFImage, not {type(target)}") try: del self._target # Remove old target if it exists except AttributeError: @@ -103,5 +118,10 @@ def target(self, target): self._target = target @forward - def __call__(self, window: Optional[Window] = None) -> ModelImage: - return self.sample(window=window) + def __call__(self) -> PSFImage: + working_image = self.target.model_image(self.window) + i, j = self._pixel_meshgridder(self.target, self.window, 0, 1) + working_image._data = self.sample(i, j) + if self.normalize_psf: + working_image._data = working_image._data / backend.sum(working_image._data) + return working_image diff --git a/astrophot/models/radial.py b/astrophot/models/radial.py new file mode 100644 index 00000000..ac3c1c33 --- /dev/null +++ b/astrophot/models/radial.py @@ -0,0 +1,99 @@ +from .mixins import ( + SersicMixin, + iSersicMixin, + ExponentialMixin, + iExponentialMixin, + GaussianMixin, + iGaussianMixin, + FerrerMixin, + iFerrerMixin, + KingMixin, + iKingMixin, + MoffatMixin, + iMoffatMixin, + NukerMixin, + iNukerMixin, + SplineMixin, + iSplineMixin, + RadialMixin, + WedgeMixin, + RayMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + TruncationMixin, +) +from .galaxy_model_object import GalaxyModel + +radial_models = ( + SersicMixin, + ExponentialMixin, + GaussianMixin, + FerrerMixin, + KingMixin, + MoffatMixin, + NukerMixin, + SplineMixin, +) + +__all__ = [] +for mixin in radial_models: + # Galaxy Model + g_mixin = type( + mixin.__name__[:-5] + "Galaxy", (mixin, RadialMixin, GalaxyModel), {"usable": True} + ) + globals()[g_mixin.__name__] = g_mixin + __all__.append(g_mixin.__name__) + + # Truncated Galaxy Model + t_mixin = type( + "T" + mixin.__name__[:-5] + "Galaxy", + (TruncationMixin, mixin, RadialMixin, GalaxyModel), + {"usable": True}, + ) + globals()[t_mixin.__name__] = t_mixin + __all__.append(t_mixin.__name__) + + for n, p in zip( + ("SuperEllipse", "FourierEllipse", "Warp"), + (SuperEllipseMixin, FourierEllipseMixin, WarpMixin), + ): + # Galaxy Model with additional perturbation mixin + g_mixin = type( + mixin.__name__[:-5] + n, (mixin, RadialMixin, p, GalaxyModel), {"usable": True} + ) + globals()[g_mixin.__name__] = g_mixin + __all__.append(g_mixin.__name__) + + # Truncated Galaxy Model with additional perturbation mixin + t_mixin = type( + "T" + mixin.__name__[:-5] + n, + (TruncationMixin, mixin, RadialMixin, p, GalaxyModel), + {"usable": True}, + ) + globals()[t_mixin.__name__] = t_mixin + __all__.append(t_mixin.__name__) + +iradial_models = ( + iSersicMixin, + iExponentialMixin, + iGaussianMixin, + iFerrerMixin, + iKingMixin, + iMoffatMixin, + iNukerMixin, + iSplineMixin, +) + +for mixin in iradial_models: + # Ray Galaxy Model + r_mixin = type(mixin.__name__[1:-5] + "Ray", (mixin, RayMixin, GalaxyModel), {"usable": True}) + globals()[r_mixin.__name__] = r_mixin + __all__.append(r_mixin.__name__) + + # Wedge Galaxy Model + w_mixin = type( + mixin.__name__[1:-5] + "Wedge", (mixin, WedgeMixin, GalaxyModel), {"usable": True} + ) + globals()[w_mixin.__name__] = w_mixin + __all__.append(w_mixin.__name__) diff --git a/astrophot/models/radial_psf.py b/astrophot/models/radial_psf.py new file mode 100644 index 00000000..dcebe377 --- /dev/null +++ b/astrophot/models/radial_psf.py @@ -0,0 +1,57 @@ +from .mixins import ( + SersicPSFMixin, + ExponentialPSFMixin, + GaussianPSFMixin, + FerrerPSFMixin, + KingPSFMixin, + MoffatPSFMixin, + NukerPSFMixin, + SplinePSFMixin, + RadialMixin, + SuperEllipseMixin, + FourierEllipseMixin, + WarpMixin, + InclinedMixin, +) +from .psf_model_object import PSFModel + +radial_models = ( + SersicPSFMixin, + ExponentialPSFMixin, + GaussianPSFMixin, + FerrerPSFMixin, + KingPSFMixin, + MoffatPSFMixin, + NukerPSFMixin, + SplinePSFMixin, +) + +EllipseMixin = type("EllipseMixin", (InclinedMixin,), {"usable": False, "_model_type": "ellipse"}) +__all__ = [] +for mixin in radial_models: + # PSF Model + g_mixin = type(mixin.__name__[:-5], (mixin, RadialMixin, PSFModel), {"usable": True}) + globals()[g_mixin.__name__] = g_mixin + __all__.append(g_mixin.__name__) + + # Ellipse PSF Model + g_mixin = type( + mixin.__name__[:-5] + "Ellipse", + (mixin, EllipseMixin, RadialMixin, PSFModel), + {"usable": True}, + ) + globals()[g_mixin.__name__] = g_mixin + __all__.append(g_mixin.__name__) + + for n, p in zip( + ("SuperEllipse", "FourierEllipse", "Warp"), + (SuperEllipseMixin, FourierEllipseMixin, WarpMixin), + ): + # Galaxy Model with additional perturbation mixin + g_mixin = type( + mixin.__name__[:-5] + n, + (mixin, InclinedMixin, RadialMixin, p, PSFModel), + {"usable": True}, + ) + globals()[g_mixin.__name__] = g_mixin + __all__.append(g_mixin.__name__) diff --git a/astrophot/models/sersic.py b/astrophot/models/sersic.py deleted file mode 100644 index 6d68f1a8..00000000 --- a/astrophot/models/sersic.py +++ /dev/null @@ -1,76 +0,0 @@ -from ..param import forward -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from ..utils.conversions.functions import sersic_Ie_to_flux_torch -from ..utils.decorators import combine_docstrings -from .mixins import ( - SersicMixin, - RadialMixin, - WedgeMixin, - iSersicMixin, - RayMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, - TruncationMixin, -) - -__all__ = [ - "SersicGalaxy", - "TSersicGalaxy", - "SersicPSF", - "Sersic_Warp", - "Sersic_SuperEllipse", - "Sersic_FourierEllipse", - "Sersic_Ray", - "Sersic_Wedge", -] - - -@combine_docstrings -class SersicGalaxy(SersicMixin, RadialMixin, GalaxyModel): - usable = True - - @forward - def total_flux(self, Ie, n, Re, q, window=None): - return sersic_Ie_to_flux_torch(Ie, n, Re, q) - - -@combine_docstrings -class TSersicGalaxy(TruncationMixin, SersicMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SersicPSF(SersicMixin, RadialMixin, PSFModel): - _parameter_specs = {"Ie": {"units": "flux/arcsec^2", "value": 1.0, "dynamic": False}} - usable = True - - @forward - def total_flux(self, Ie, n, Re): - return sersic_Ie_to_flux_torch(Ie, n, Re, 1.0) - - -@combine_docstrings -class SersicSuperEllipse(SersicMixin, RadialMixin, SuperEllipseMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SersicFourierEllipse(SersicMixin, RadialMixin, FourierEllipseMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SersicWarp(SersicMixin, RadialMixin, WarpMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SersicRay(iSersicMixin, RayMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SersicWedge(iSersicMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/models/sky_model_object.py b/astrophot/models/sky_model_object.py index 6d9f5cca..86762b2e 100644 --- a/astrophot/models/sky_model_object.py +++ b/astrophot/models/sky_model_object.py @@ -14,9 +14,13 @@ class SkyModel(ComponentModel): """ + _parameter_specs = {"center": {"units": "arcsec", "shape": (2,), "dynamic": False}} _model_type = "sky" usable = False + def __init__(self, *args, integrate_mode="none", **kwargs): + super().__init__(*args, integrate_mode=integrate_mode, **kwargs) + def initialize(self): """Initialize the sky model, this is called after the model is created and before it is used. This is where we can set the @@ -24,9 +28,8 @@ def initialize(self): """ if not self.center.initialized: target_area = self.target[self.window] - self.center.to_static(target_area.center) + self.center = target_area.center super().initialize() - self.center.to_static() @property def psf_convolve(self) -> bool: @@ -35,11 +38,3 @@ def psf_convolve(self) -> bool: @psf_convolve.setter def psf_convolve(self, val: bool): pass - - @property - def integrate_mode(self) -> str: - return "none" - - @integrate_mode.setter - def integrate_mode(self, val: str): - pass diff --git a/astrophot/models/spline.py b/astrophot/models/spline.py deleted file mode 100644 index 0a011e7d..00000000 --- a/astrophot/models/spline.py +++ /dev/null @@ -1,59 +0,0 @@ -from .galaxy_model_object import GalaxyModel -from .psf_model_object import PSFModel -from .mixins import ( - SplineMixin, - RadialMixin, - iSplineMixin, - RayMixin, - WedgeMixin, - SuperEllipseMixin, - FourierEllipseMixin, - WarpMixin, -) -from ..utils.decorators import combine_docstrings - - -__all__ = [ - "SplineGalaxy", - "SplinePSF", - "SplineWarp", - "SplineSuperEllipse", - "SplineFourierEllipse", - "SplineRay", - "SplineWedge", -] - - -@combine_docstrings -class SplineGalaxy(SplineMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SplinePSF(SplineMixin, RadialMixin, PSFModel): - usable = True - - -@combine_docstrings -class SplineSuperEllipse(SplineMixin, SuperEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SplineFourierEllipse(SplineMixin, FourierEllipseMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SplineWarp(SplineMixin, WarpMixin, RadialMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SplineRay(iSplineMixin, RayMixin, GalaxyModel): - usable = True - - -@combine_docstrings -class SplineWedge(iSplineMixin, WedgeMixin, GalaxyModel): - usable = True diff --git a/astrophot/param/__init__.py b/astrophot/param/__init__.py index 1b780893..0f3f7677 100644 --- a/astrophot/param/__init__.py +++ b/astrophot/param/__init__.py @@ -2,4 +2,4 @@ from .module import Module from .param import Param -__all__ = ["Module", "Param", "forward", "ValidContext", "OverrideParam"] +__all__ = ("Module", "Param", "forward", "ValidContext", "OverrideParam") diff --git a/astrophot/param/module.py b/astrophot/param/module.py index a29e0337..cfd4689e 100644 --- a/astrophot/param/module.py +++ b/astrophot/param/module.py @@ -14,7 +14,7 @@ class Module(CModule): def build_params_array_identities(self): identities = [] for param in self.dynamic_params: - numel = max(1, np.prod(param.shape)) + numel = max(1, np.prod(param.batch_shape + param.shape)) for i in range(numel): identities.append(f"{id(param)}_{i}") return identities @@ -31,7 +31,7 @@ def build_params_array_uncertainty(self): def build_params_array_names(self): names = [] for param in self.dynamic_params: - numel = max(1, np.prod(param.shape)) + numel = max(1, np.prod(param.batch_shape + param.shape)) if numel == 1: names.append(param.name) else: @@ -42,47 +42,7 @@ def build_params_array_names(self): def build_params_array_units(self): units = [] for param in self.dynamic_params: - numel = max(1, np.prod(param.shape)) + numel = max(1, np.prod(param.batch_shape + param.shape)) for _ in range(numel): units.append(param.units) return units - - def fill_dynamic_value_uncertainties(self, uncertainty): - if self.active: - raise ActiveStateError(f"Cannot fill dynamic values when Module {self.name} is active") - - dynamic_params = self.dynamic_params - - if uncertainty.shape[-1] == 0: - return # No parameters to fill - # check for batch dimension - pos = 0 - for param in dynamic_params: - if not isinstance(param.shape, tuple): - raise ParamConfigurationError( - f"Param {param.name} has no shape. dynamic parameters must have a shape to use Tensor input." - ) - # Handle scalar parameters - size = max(1, prod(param.shape)) - try: - val = uncertainty[..., pos : pos + size].reshape(param.shape) - param.uncertainty = val - except (RuntimeError, IndexError, ValueError, TypeError): - raise FillParamsArrayError(self.name, uncertainty, dynamic_params) - - pos += size - if pos != uncertainty.shape[-1]: - raise FillParamsArrayError(self.name, uncertainty, dynamic_params) - - def dynamic_params_array_index(self, param): - i = 0 - for p in self.dynamic_params: - if p is param: - return list(range(i, i + max(1, prod(p.shape)))) - i += max(1, prod(p.shape)) - try: - raise ValueError( - f"Param {param.name} not found in dynamic_params of Module {self.name}" - ) - except: - raise ValueError(f"Param {param} not found in dynamic_params of Module {self.name}") diff --git a/astrophot/param/param.py b/astrophot/param/param.py index 4df33cdf..dcbad14d 100644 --- a/astrophot/param/param.py +++ b/astrophot/param/param.py @@ -3,6 +3,7 @@ from caskade import Param as CParam from ..backend_obj import backend +from .. import config class Param(CParam): @@ -57,6 +58,10 @@ def initialized(self): return True return False + @property + def full_shape(self): + return self.batch_shape + self.shape + def soft_valid(self, value): if self.valid[0] is None and self.valid[1] is None: return value diff --git a/astrophot/plots/image.py b/astrophot/plots/image.py index fc0aba8a..2e8d66dd 100644 --- a/astrophot/plots/image.py +++ b/astrophot/plots/image.py @@ -131,9 +131,9 @@ def psf_image( return fig, ax # Evaluate the model image - x, y = psf.coordinate_corner_meshgrid() - x = backend.to_numpy(x) - y = backend.to_numpy(y) + i, j = psf.pixel_corner_meshgrid() + i = backend.to_numpy(i) + j = backend.to_numpy(j) psf = backend.to_numpy(psf._data) # Default kwargs for image @@ -152,12 +152,12 @@ def psf_image( ) # Plot the image - ax.pcolormesh(x, y, psf, **kwargs) + ax.pcolormesh(i, j, psf, **kwargs) # Enforce equal spacing on x y ax.axis("equal") - ax.set_xlabel("PSF X [arcsec]") - ax.set_ylabel("PSF Y [arcsec]") + ax.set_xlabel("PSF I [pix]") + ax.set_ylabel("PSF J [pix]") return fig, ax @@ -406,7 +406,7 @@ def residual_image( } imshow_kwargs.update(kwargs) im = ax.pcolormesh(X, Y, residuals, **imshow_kwargs) - if np.linalg.det(target.CD.npvalue) < 0: + if target.flip_ra_axis: ax.invert_xaxis() ax.axis("equal") ax.set_xlabel("Tangent Plane X [arcsec]") diff --git a/astrophot/utils/decorators.py b/astrophot/utils/decorators.py index ec556f60..0423ebf1 100644 --- a/astrophot/utils/decorators.py +++ b/astrophot/utils/decorators.py @@ -1,6 +1,7 @@ from functools import wraps import warnings from inspect import cleandoc +import caskade as ck import numpy as np @@ -29,9 +30,13 @@ def ignore_numpy_warnings(func): def wrapped(*args, **kwargs): old_settings = np.seterr(all="ignore") warnings.filterwarnings("ignore", category=np.exceptions.VisibleDeprecationWarning) + warnings.filterwarnings("ignore", category=DeprecationWarning) + warnings.filterwarnings("ignore", category=ck.InvalidValueWarning) result = func(*args, **kwargs) np.seterr(**old_settings) warnings.filterwarnings("default", category=np.exceptions.VisibleDeprecationWarning) + warnings.filterwarnings("default", category=DeprecationWarning) + warnings.filterwarnings("default", category=ck.InvalidValueWarning) return result return wrapped diff --git a/astrophot/utils/initialize/PA.py b/astrophot/utils/initialize/PA.py index 59af6acc..8471693b 100644 --- a/astrophot/utils/initialize/PA.py +++ b/astrophot/utils/initialize/PA.py @@ -11,3 +11,8 @@ def polar_decomposition(A): P_inv = np.linalg.inv(P) R = A @ P_inv return R, P + + +def R(theta): + c, s = np.cos(theta), np.sin(theta) + return np.array([[c, -s], [s, c]]) diff --git a/astrophot/utils/initialize/__init__.py b/astrophot/utils/initialize/__init__.py index 9708041a..077ed673 100644 --- a/astrophot/utils/initialize/__init__.py +++ b/astrophot/utils/initialize/__init__.py @@ -10,7 +10,7 @@ from .center import center_of_mass, recursive_center_of_mass from .construct_psf import gaussian_psf, moffat_psf from .variance import auto_variance -from .PA import polar_decomposition +from .PA import polar_decomposition, R __all__ = ( "center_of_mass", @@ -26,4 +26,5 @@ "transfer_windows", "auto_variance", "polar_decomposition", + "R", ) diff --git a/astrophot/utils/integration.py b/astrophot/utils/integration.py index e765a3c8..9032a2e9 100644 --- a/astrophot/utils/integration.py +++ b/astrophot/utils/integration.py @@ -1,13 +1,9 @@ -from functools import lru_cache - from scipy.special import roots_legendre -import torch from ..backend_obj import backend __all__ = ("quad_table",) -@lru_cache(maxsize=32) def quad_table(order, dtype, device): """ Generate a meshgrid for quadrature points using Legendre-Gauss quadrature. diff --git a/docs/source/prebuilt/segmap_models_fit.py b/docs/source/prebuilt/segmap_models_fit.py index ad1d819b..389fe8ae 100644 --- a/docs/source/prebuilt/segmap_models_fit.py +++ b/docs/source/prebuilt/segmap_models_fit.py @@ -36,7 +36,7 @@ primary_key = None # segmentation map id, use None to have no primary object primary_name = "primary object" # name for primary object primary_model_type = "spline galaxy model" -primary_initial_params = {} # {"center": [3, 3], "q": {"value": 0.8, "locked": True}} +primary_initial_params = {} # {"center": [3, 3], "q": 0.8} # Extra parameters ###################################################################### save_model_image = True @@ -115,7 +115,7 @@ name="sky", model_type=sky_model_type, target=target, - I=initial_sky if initial_sky is not None else {}, + I0=initial_sky if initial_sky is not None else {}, ) ) if sky_locked: @@ -145,7 +145,7 @@ else: print(window) model = ap.Model( - name=f"{model_type} {window}", + name=f"{model_type}_{window}", model_type=model_type, target=target, window=windows[window], @@ -153,7 +153,7 @@ ) models.append(model) model = ap.Model( - name=f"{name} model", + name=f"{name}_model", model_type="group model", target=target, models=models, @@ -194,7 +194,7 @@ with open(f"{name}_primary_params.csv", "w") as f: f.write("Name,Total Magnitude," + ",".join(primary_model.build_params_array_names()) + "\n") f.write("string,mag," + ",".join(primary_model.build_params_array_units()) + "\n") - params = primary_model.build_params_array().detach().cpu().numpy() + params = primary_model.get_values().detach().cpu().numpy() f.write(",".join([str(x) for x in params]) + "\n") if print_all_models: @@ -205,7 +205,7 @@ continue totmag = segmodel.total_magnitude().detach().cpu().numpy() segmap_params.append( - [segmodel.name, totmag] + list(segmodel.build_params_array().detach().cpu().numpy()) + [segmodel.name, totmag] + list(segmodel.get_values().detach().cpu().numpy()) ) with open(f"{name}_segmap_params.csv", "w") as f: f.write("Name,Total Magnitude," + ",".join(segmodel.build_params_array_names()) + "\n") diff --git a/docs/source/prebuilt/single_model_fit.py b/docs/source/prebuilt/single_model_fit.py index 6529b011..ffeea84d 100644 --- a/docs/source/prebuilt/single_model_fit.py +++ b/docs/source/prebuilt/single_model_fit.py @@ -24,7 +24,7 @@ target_file = ".fits" # can be a numpy array instead psf_file = None # ".fits" # can be a numpy array instead zeropoint = 22.5 # mag -initial_params = None # e.g. {"center": [3, 3], "q": {"value": 0.8, "locked": True}} +initial_params = {} # e.g. {"center": [3, 3], "q": 0.8} window = None # None to fit whole image, otherwise (xmin,xmax,ymin,ymax) pixels initial_sky = None # If None, sky will be estimated sky_locked = False @@ -73,13 +73,13 @@ name="sky", model_type=sky_model_type, target=target, - I=initial_sky if initial_sky is not None else {}, + I0=initial_sky if initial_sky is not None else {}, window=window, ) if sky_locked: model_sky.to_static() model = ap.Model( - name="astrophot model", + name="astrophot_model", model_type="group model", target=target, models=[model_sky, model_object], @@ -122,8 +122,8 @@ np.save(f"{name}_covariance_matrix.npy", result.covariance_matrix.detach().cpu().numpy()) fig, ax = ap.plots.covariance_matrix( result.covariance_matrix.detach().cpu().numpy(), - model.parameters.vector_values().detach().cpu().numpy(), - model.parameters.vector_names(), + model.get_values().detach().cpu().numpy(), + model.build_params_array_names(), ) fig.suptitle("Parameter Covariance") plt.savefig(f"{name}_covariance_matrix.pdf") diff --git a/docs/source/tutorials/AdvancedPSFModels.ipynb b/docs/source/tutorials/AdvancedPSFModels.ipynb index c1cddabd..cf58558f 100644 --- a/docs/source/tutorials/AdvancedPSFModels.ipynb +++ b/docs/source/tutorials/AdvancedPSFModels.ipynb @@ -7,7 +7,9 @@ "source": [ "# Advanced PSF modeling\n", "\n", - "Ideally we always have plenty of well separated bright, but not oversaturated, stars to use to construct a PSF model. These models are incredibly important for certain science objectives that rely on precise shape measurements and not just total light measures. Here we demonstrate some of the special capabilities AstroPhot has to handle challenging scenarios where a good PSF model is needed but there are only very faint stars, poorly placed stars, or even no stars to work with!" + "Ideally we always have plenty of well separated bright, but not oversaturated, stars to use to construct a PSF model. These models are incredibly important for certain science objectives that rely on precise shape measurements or even just total light measures. Here we demonstrate some of the special capabilities AstroPhot has to handle challenging scenarios where a good PSF model is needed but there are only very faint stars, poorly placed stars, or even no stars to work with!\n", + "\n", + "Something to keep in mind, AstroPhot PSF models operate in pixel space. The PSFImage has no way to transform coordinates between the world, tangent plane, and pixel space, it only understands pixels. For this reason, if you check the units on any PSFModel parameters, they will be in `pix` units which are pixels in the target image. If the PSFModel is in an upsampled space, the units are still pixels in the target image (though everything will be sampled more finely)." ] }, { @@ -49,7 +51,7 @@ "\n", "psf_target = ap.PSFImage(\n", " data=psf,\n", - " pixelscale=0.5,\n", + " upsample=1,\n", " variance=variance,\n", ")\n", "\n", @@ -75,11 +77,9 @@ " model_type=\"moffat psf model\",\n", " target=psf_target,\n", ")\n", - "\n", "psf_model.initialize()\n", "\n", "# PSF model can be fit to it's own target for good initial values\n", - "# Note we provide the weight map (1/variance) since a PSF_Image can't store that information.\n", "ap.fit.LM(psf_model, verbose=1).fit()\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(13, 5))\n", @@ -117,19 +117,23 @@ "source": [ "psf_model1 = ap.Model(\n", " name=\"psf1\",\n", - " model_type=\"moffat psf model\",\n", + " model_type=\"moffat ellipse psf model\",\n", " n=2,\n", " Rd=10,\n", - " I0=20, # essentially controls relative flux of this component\n", + " I0=2, # essentially controls relative flux of this component\n", + " PA=0,\n", + " q=0.2,\n", " normalize_psf=False, # sub components shouldnt be individually normalized\n", " target=psf_target,\n", ")\n", "psf_model2 = ap.Model(\n", " name=\"psf2\",\n", - " model_type=\"sersic psf model\",\n", + " model_type=\"sersic ellipse psf model\",\n", " n=4,\n", " Re=5,\n", " Ie=1,\n", + " PA=np.pi / 2,\n", + " q=0.2,\n", " normalize_psf=False,\n", " target=psf_target,\n", ")\n", @@ -169,10 +173,7 @@ "outputs": [], "source": [ "# Lets make some data that we need to fit\n", - "psf_target = ap.PSFImage(\n", - " data=np.zeros((51, 51)),\n", - " pixelscale=1.0,\n", - ")\n", + "psf_target = ap.PSFImage(data=np.zeros((51, 51)), upsample=1)\n", "\n", "true_psf_model = ap.Model(\n", " name=\"true_psf\",\n", @@ -180,14 +181,11 @@ " target=psf_target,\n", " n=2,\n", " Rd=3,\n", + " I0=1.0,\n", ")\n", "true_psf = true_psf_model().data\n", "\n", - "target = ap.TargetImage(\n", - " data=torch.zeros(100, 100),\n", - " pixelscale=1.0,\n", - " psf=true_psf,\n", - ")\n", + "target = ap.TargetImage(data=torch.zeros(100, 100), psf=true_psf)\n", "\n", "true_model = ap.Model(\n", " name=\"true_model\",\n", @@ -199,14 +197,13 @@ " n=2,\n", " Re=25,\n", " Ie=10,\n", - " psf_convolve=True,\n", ")\n", "\n", "# use the true model to make some data\n", - "sample = true_model()\n", + "sample = true_model()._data\n", "torch.manual_seed(61803398)\n", - "target._data = sample.data + torch.normal(torch.zeros_like(sample.data), 0.1)\n", - "target.variance = 0.01 * torch.ones_like(sample.data.T)\n", + "target._data = sample + torch.normal(torch.zeros_like(sample), 0.1)\n", + "target.variance = 0.01 * torch.ones_like(sample.T)\n", "\n", "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", "ap.plots.model_image(fig, ax[0], true_model)\n", @@ -230,6 +227,7 @@ " name=\"galaxy_model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", + " psf_convolve=False,\n", ")\n", "\n", "# Let AstroPhot determine its own initial parameters, so it has to start with whatever it decides automatically,\n", @@ -268,10 +266,7 @@ "# Now we will try and fit the data with a sersic model and a \"live\" psf\n", "\n", "# Here we create a target psf model which will determine the specs of our live psf model\n", - "psf_target = ap.PSFImage(\n", - " data=np.zeros((51, 51)),\n", - " pixelscale=target.pixelscale,\n", - ")\n", + "psf_target = ap.PSFImage(data=np.zeros((51, 51)))\n", "\n", "live_psf_model = ap.Model(\n", " name=\"psf\",\n", @@ -286,11 +281,11 @@ " name=\"galaxy_model\",\n", " model_type=\"sersic galaxy model\",\n", " target=target,\n", - " psf_convolve=True,\n", - " psf=live_psf_model, # Here we bind the PSF model to the galaxy model, this will add the psf_model parameters to the galaxy_model\n", + " psf=live_psf_model, # Here we bind the PSF model to the galaxy model\n", ")\n", "live_galaxy_model.initialize()\n", - "\n", + "print(live_galaxy_model)\n", + "print(live_galaxy_model.jacobian())\n", "result = ap.fit.LM(live_galaxy_model, verbose=3).fit()" ] }, @@ -353,38 +348,26 @@ "source": [ "## PSF fitting for faint stars\n", "\n", - "Sometimes there are stars available, but they are faint and it is hard to see how a reliable fit could be obtained. We have already seen how faint stars next to galaxies are still viable for PSF fitting. Now we will consider the case of isolated but faint stars. The trick here is that we have a second high resolution image, perhaps in a different band. To perform this fitting we will link up the two bands using joint modelling to constrain the star centers, this will constrain some of the parameters making it easier to fit a PSF model." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18", - "metadata": {}, - "outputs": [], - "source": [ - "# Coming soon" + "Sometimes there are stars available, but they are faint and it is hard to see how a reliable fit could be obtained. We have already seen how faint stars next to galaxies are still viable for PSF fitting. With AstroPhot joint modelling it is possible to share information between images, so a faint UV band can be informed by a well sampled r-band (for example). This will fix the positions of the stars very accurately. Then it is just a matter of combining enough stars to get enough signal for AstroPhot to run the fit, this takes fewer stars than you think because the centers are already essentially perfect." ] }, { "cell_type": "markdown", - "id": "19", + "id": "18", "metadata": {}, "source": [ "## PSF fitting for saturated stars\n", "\n", - "A saturated star is a bright star, and it's just begging to be used for modelling a PSF. There's just one catch, the highest signal to noise region is completely messed up and can't be used! Traditionally these stars are either ignored, or a two stage fit is performed to get an \"inner psf\" and an \"outer psf\" which are then merged. Why not fit the inner and outer PSFs all at once! This can be done with AstroPhot using parameter constraints and masking." + "A saturated star is a bright star, and it's just begging to be used for modelling a PSF. There's just one catch, the highest signal to noise region is completely messed up and can't be used! Traditionally these stars are either ignored, or a two stage fit is performed to get an \"inner psf\" and an \"outer psf\" which are then merged. Why not fit the inner and outer PSFs all at once! This can be done with AstroPhot using parameter constraints and masking. Simply mask the saturated data and fit a PSF model, AstroPhot will use the available information in the outskirts of the bright star. If you can add some faint stars to the fit then that will give AstroPhot a handle on the inner parts of the PSF. With everything fit jointly, the information is shared in one big likelihood calculation and it should converge to something very reasonable (though getting it set up may be finicky). \n", + "\n", + "Keep in mind that it is common for the PSF to vary with brightness, so you may need to add a flux dependent component to the PSF model. This can be achieved using caskade parameter management. If you are having difficulties, just contact me and I'll help you through it!" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "20", + "cell_type": "markdown", + "id": "19", "metadata": {}, - "outputs": [], - "source": [ - "# Coming soon" - ] + "source": [] } ], "metadata": { diff --git a/docs/source/tutorials/BasicPSFModels.ipynb b/docs/source/tutorials/BasicPSFModels.ipynb index 2b328687..66b8c144 100644 --- a/docs/source/tutorials/BasicPSFModels.ipynb +++ b/docs/source/tutorials/BasicPSFModels.ipynb @@ -34,11 +34,11 @@ "source": [ "## PSF Images\n", "\n", - "A `PSFImage` is an AstroPhot object which stores the data for a PSF. It records the pixel values for the PSF as well as meta-data like the pixelscale at which it was taken. The point source function (PSF) is a description of how light is distributed into pixels when the light source is a delta function. In Astronomy we are blessed/cursed with many delta function like sources in our images and so PSF modelling is a major component of astronomical image analysis. Here are some points to keep in mind about a PSF.\n", + "A `PSFImage` is an AstroPhot object which stores the data for a point spread function (PSF). A PSF is fundamentally different from other image types, it only makes sense to describe a PSF in the pixel space of another image and so it has no WCS information to transform between pixel space, tangent plane, and world coordinates. It records the pixel values for the PSF as well as meta-data, mainly how upsampled the PSF is relative to the primary image. The point spread function is a description of how light is distributed into pixels when the light source is a delta function. In Astronomy we are blessed/cursed with many delta function like sources in our images and so PSF modelling is a major component of astronomical image analysis. Here are some points to keep in mind about a PSF.\n", "\n", "- PSF images are always odd in shape (e.g. 25x25 pixels, not 24x24 pixels), at the center pixel, in the center of that pixel is where the delta function point source is located by definition\n", - "- In AstroPhot, the coordinates of the center of the center pixel in a `PSFImage` are always (0,0). \n", - "- The light in each pixel of a PSF image is already integrated. That is to say, the flux value for a pixel does not represent some model evaluated at the center of the pixel, it instead represents an integral over the whole area of the pixel" + "- The light in each pixel of a PSF image is already integrated. That is to say, the flux value for a pixel does not represent some model evaluated at the center of the pixel, it instead represents an integral over the whole area of the pixel\n", + "- PSF images can be assigned to a target image, or to individual models. If assigned to a target image all models acting on that target will use that PSF (unless they have their own which overrides it)" ] }, { @@ -55,10 +55,7 @@ "psf += np.random.normal(scale=psf / 4)\n", "psf[psf < 0] = ap.utils.initialize.gaussian_psf(2.0, 101, 0.5)[psf < 0]\n", "\n", - "psf_target = ap.PSFImage(\n", - " data=psf,\n", - " pixelscale=0.5,\n", - ")\n", + "psf_target = ap.PSFImage(data=psf, upsample=1) # no upsampling\n", "\n", "# To ensure the PSF has a normalized flux of 1, we call\n", "psf_target.normalize()\n", @@ -79,7 +76,7 @@ "source": [ "## Point Sources (star, super nova, distant galaxy, quasar, asteroid, etc.)\n", "\n", - "One of the most common astronomical sources is a point source. This is a source of light which is so small that it is, for all relevant purposes, infinitely small (a delta function). Though the point source may be extremely small, it's footprint in an image is often quite extended, this is because the optics of a telescope, the intervening atmosphere, and a few other factors, work to broaden the light distribution that would otherwise fall entirely in a single pixel of the detector. The characterization of how the light is \"blurred\" is called a point spread function (PSF). In AstroPhot, there is one model `\"point model\"` which describes all point sources, this is a delta function which gets convolved by a PSF to produce an effect in an image. Thus for a point model, the `psf_mode` is always `\"full\"` since otherwise the model would only ever fill a single pixel. A point source is characterized by two measurements, the position (center), and the flux. Here we can see an example of how to set up a point source model." + "One of the most common astronomical sources is a point source. This is a source of light which is so small that it is, for all relevant purposes, infinitely small (a delta function). Though the point source may be extremely small, it's footprint in an image is often quite extended, this is because the optics of a telescope, the intervening atmosphere, and a few other factors, work to broaden the light distribution that would otherwise fall entirely in a single pixel of the detector. The characterization of how the light is \"blurred\" is called a point spread function (PSF). In AstroPhot, there is one model `\"point model\"` which describes all point sources, this is a delta function which gets convolved by a PSF to produce an effect in an image. A point source is characterized by two measurements, the position (center), and the flux. Here we can see an example of how to set up a point source model." ] }, { @@ -119,7 +116,9 @@ "source": [ "## Extended model PSF convolution\n", "\n", - "For extended sources (galaxies mostly), an important part of astronomical image analysis is accounting for PSF effects. To that end, AstroPhot includes a number of approaches to handle PSF convolution. The main concept is that AstroPhot will convolve its model with a PSF before comparing against an image. The PSF behaviour of a model is determined by the `psf_mode` parameter which can be set before fitting." + "For extended sources (galaxies mostly), an important part of astronomical image analysis is accounting for PSF effects. To that end, AstroPhot includes a number of approaches to handle PSF convolution. The main concept is that AstroPhot will convolve its model with a PSF before comparing against an image.\n", + "\n", + "Note that `psf_convolve` is set to `True` by default, so if there is a PSF assigned to the model or its target then AstroPhot will try to convolve the model." ] }, { @@ -150,7 +149,7 @@ " n=3,\n", " Re=10,\n", " Ie=10,\n", - " psf_convolve=True, # now the full window will be PSF convolved using the PSF from the target\n", + " psf_convolve=True, # now the PSF is convolved using the PSF from the target\n", ")\n", "model_psf.initialize()\n", "\n", @@ -159,7 +158,7 @@ "psf[:, 49:51] += 4 * np.mean(psf)\n", "psf_target_2 = ap.PSFImage(\n", " data=psf,\n", - " pixelscale=0.5,\n", + " upsample=1,\n", ")\n", "psf_target_2.normalize()\n", "model_selfpsf = ap.Model(\n", @@ -171,7 +170,6 @@ " n=3,\n", " Re=10,\n", " Ie=10,\n", - " psf_convolve=True,\n", " psf=psf_target_2, # Now this model has its own PSF, instead of using the target psf\n", ")\n", "model_selfpsf.initialize()\n", @@ -194,7 +192,7 @@ "source": [ "## Supersampled PSF models\n", "\n", - "It is generally best practice to use a PSF model that has been determined at a higher resolution than the image you are analyzing. In AstroPhot this can be easily handled by ensuring that the `PSFImage` has an appropriate pixelscale that shows how it is upsampled. For example if our target has a pixelscale of 0.5 and the PSFImage has a pixelscale of 0.25 then AstroPhot will automatically infer that it should work at 2x higher resolution. Note that AstroPhot assumes the PSF has been determined at an integer level of upsampling, so in the example if you set the PSFImage pixelscale to 0.3 then strange things would likely happen to your images!" + "It is generally best practice to use a PSF model that has been determined at a higher resolution than the image you are analyzing. In AstroPhot this can be easily handled by setting the `upsample` value in a `PSFImage` to some integer greater than one. For example if our target has a pixelscale of 0.5 and the PSFImage has a pixelscale of 0.25 then you should set `upsample=2` and AstroPhot model at 2x higher resolution. " ] }, { @@ -206,7 +204,7 @@ "source": [ "upsample_psf_target = ap.PSFImage(\n", " data=ap.utils.initialize.gaussian_psf(2.0, 51, 0.25),\n", - " pixelscale=0.25, # This PSF is at a higher resolution than the target\n", + " upsample=2, # This PSF is at 2x higher resolution than the target\n", ")\n", "target.psf = upsample_psf_target\n", "\n", @@ -219,7 +217,6 @@ " n=3,\n", " Re=10,\n", " Ie=10,\n", - " psf_convolve=True,\n", ")\n", "model_upsamplepsf.initialize()\n", "fig, ax = plt.subplots(1, 1, figsize=(5, 5))\n", diff --git a/docs/source/tutorials/ConstrainedModels.ipynb b/docs/source/tutorials/ConstrainedModels.ipynb index 812cb1f6..348aa3e6 100644 --- a/docs/source/tutorials/ConstrainedModels.ipynb +++ b/docs/source/tutorials/ConstrainedModels.ipynb @@ -168,7 +168,7 @@ "# Here we will demo a spatially varying PSF where the moffat \"n\" parameter changes across the image\n", "target = ap.TargetImage(data=np.zeros((100, 100)), crpix=[49.5, 49.5], pixelscale=1)\n", "\n", - "psf_target = ap.PSFImage(data=np.zeros((55, 55)), pixelscale=1)\n", + "psf_target = ap.PSFImage(data=np.zeros((55, 55)))\n", "\n", "# We make parameters and a function to control the moffat n parameter\n", "intercept = ap.Param(\"intercept\", 3)\n", @@ -209,7 +209,7 @@ "\n", "\n", "# A group model holds all the stars together\n", - "sky = ap.Model(name=\"sky\", model_type=\"flat sky model\", I=1e-5, target=target)\n", + "sky = ap.Model(name=\"sky\", model_type=\"flat sky model\", I0=1e-5, target=target)\n", "MODEL = ap.Model(\n", " name=\"spatial_PSF\",\n", " model_type=\"group model\",\n", @@ -228,13 +228,6 @@ "source": [ "See how the PSF parameters vary across the image, this model could now be optimized to fit some data and the parameters of the plane (`intercept` and `slope`) will be optimized alongside everything else to give the best possible optimized parameter values accounting for everything in the image!" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/docs/source/tutorials/CustomModels.ipynb b/docs/source/tutorials/CustomModels.ipynb index 154046db..ec98893a 100644 --- a/docs/source/tutorials/CustomModels.ipynb +++ b/docs/source/tutorials/CustomModels.ipynb @@ -65,10 +65,7 @@ "import torch\n", "from astropy.io import fits\n", "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "import socket\n", - "\n", - "socket.setdefaulttimeout(120)" + "import matplotlib.pyplot as plt" ] }, { @@ -112,9 +109,11 @@ "metadata": {}, "outputs": [], "source": [ - "hdu = fits.open(\n", - " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - ")\n", + "############# UNCOMMENT IF RUNNING LOCALLY ############\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "# )\n", + "hdu = fits.open(\"target_image.fits\")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", "target = ap.TargetImage(data=target_data, pixelscale=0.262, zeropoint=22.5, variance=\"auto\")\n", diff --git a/docs/source/tutorials/FittingMethods.ipynb b/docs/source/tutorials/FittingMethods.ipynb index 797d823d..e64bae5e 100644 --- a/docs/source/tutorials/FittingMethods.ipynb +++ b/docs/source/tutorials/FittingMethods.ipynb @@ -101,7 +101,7 @@ " name=\"sky\",\n", " model_type=\"flat sky model\",\n", " target=target,\n", - " I=sky_param[0],\n", + " I0=sky_param[0],\n", " )\n", " ]\n", " # Add models to the list\n", diff --git a/docs/source/tutorials/FittingPriors.ipynb b/docs/source/tutorials/FittingPriors.ipynb new file mode 100644 index 00000000..f72588e5 --- /dev/null +++ b/docs/source/tutorials/FittingPriors.ipynb @@ -0,0 +1,238 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Fitting with Priors\n", + "\n", + "AstroPhot is mostly built around likelihood optimization under the assumption that the prior is encoded in the allows model parameter space. However, this is not always enough, sometimes we need to more explicitly provide a prior. The most general way to add a prior is to entirely code it up yourself. Every AstroPhot model gives you access to `model.gaussian_log_likelihood` and `model.poisson_log_likelihood` functions which take as input a vector of parameter values (which you can get for any given model by calling `model.get_values()`). Thus you may create a prior function which takes the same vector of parameters as input and off you go for a fully Bayesian analysis. \n", + "\n", + "But there is a middle ground. The `LM` fitter includes the ability to add a Gaussian prior on your model parameters (this only works for the Gaussian likelihood!). It does this by essentially adding fake \"zero flux pixels\" to the fit where the value in those pixels from the model is up to you. So you can have those \"pixels\" take values from your model parameters and thus you have a prior! The actual system is more general and allows for quite complex constraints on your parameters (for example you could preferentially have one radius larger than another)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "import astrophot as ap\n", + "import numpy as np\n", + "import torch\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "First, lets make some fake data to analyze" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "target = ap.TargetImage(data=torch.zeros(32, 32))\n", + "true_model = ap.Model(\n", + " name=\"true_model\",\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + " center=[16.0, 16.0],\n", + " q=0.4,\n", + " PA=np.pi / 3,\n", + " n=1,\n", + " Re=4,\n", + " Ie=10,\n", + ")\n", + "\n", + "sample = true_model().data\n", + "torch.manual_seed(61803398)\n", + "target.data = sample + torch.normal(torch.zeros_like(sample), torch.sqrt(1 + sample))\n", + "target.variance = 1 + sample\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", + "ap.plots.model_image(fig, ax[0], true_model)\n", + "ap.plots.target_image(fig, ax[1], target)\n", + "ax[0].set_title(\"true sersic model\")\n", + "ax[1].set_title(\"mock observed data\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "This is some pretty noisy data! lets see what happens when we fit it" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "likelihood_model = ap.Model(\n", + " name=\"likelihood_model\",\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + ")\n", + "likelihood_model.initialize()\n", + "print(likelihood_model)\n", + "res = ap.fit.LM(likelihood_model).fit()\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", + "ap.plots.model_image(fig, ax[0], likelihood_model)\n", + "ap.plots.residual_image(fig, ax[1], likelihood_model, normalize_residuals=True)\n", + "ax[0].set_title(\"fitted sersic only model\")\n", + "ax[1].set_title(\"residuals\")\n", + "plt.show()\n", + "print(likelihood_model)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "posterior_model = ap.Model(\n", + " name=\"posterior_model\",\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + " n=1,\n", + ")\n", + "posterior_model.initialize()\n", + "\n", + "# This is the constraint, the first argument is the model to constrain, the\n", + "# second is a function (that takes the model as an argument) for which values\n", + "# closer to zero are better, and the third argument is the sigma of the gaussian\n", + "# constraint/prior. The sigma and the function output need to be a vector (even\n", + "# if its only one element like in this case).\n", + "C = ap.fit.LMConstraint(posterior_model, lambda m: m.n.value[None] - 1.0, np.array([1e-1]))\n", + "\n", + "res = ap.fit.LM(posterior_model, constraint=C).fit()\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", + "ap.plots.model_image(fig, ax[0], posterior_model)\n", + "ap.plots.residual_image(fig, ax[1], posterior_model, normalize_residuals=True)\n", + "ax[0].set_title(\"fitted sersic only model\")\n", + "ax[1].set_title(\"residuals\")\n", + "plt.show()\n", + "print(posterior_model)" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "Here we see the prior has pushed our sersic index closer to an exponential disk, without strictly enforcing a value of 1.\n", + "\n", + "Note: Once you add a prior, the LM fitter update messages that say \"Chi^2/DoF: ...\" are actually now more appropriately called \"two times the log posterior density divided by the number of degrees of freedom\", which is a bit of a mouthful. For the most part you don't need to worry too much, a value near 1 is still good. You can contact me if you have more detailed questions.\n", + "\n", + "## Soft equality constraints\n", + "\n", + "There are tons of applications for these priors/constraints, another one we will consider here is the idea of a two component model, that has a soft constraint that the centers of the two models align. Lets just fit the same data and see what happens!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "model1 = ap.Model(\n", + " name=\"model1\",\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + " n=1,\n", + " Re=3,\n", + ")\n", + "model2 = ap.Model(\n", + " name=\"model2\",\n", + " model_type=\"sersic galaxy model\",\n", + " target=target,\n", + " n=2,\n", + " Re=5,\n", + ")\n", + "joint_model = ap.Model(\n", + " name=\"joint_model\",\n", + " model_type=\"group model\",\n", + " target=target,\n", + " models=[model1, model2],\n", + ")\n", + "joint_model.initialize()\n", + "\n", + "# Constrain both models to have roughly the same position and to have n near 1.0\n", + "C = ap.fit.LMConstraint(\n", + " joint_model,\n", + " lambda m: torch.concatenate(\n", + " [\n", + " m.models.model1.center.value - m.models.model2.center.value,\n", + " m.models.model1.n.value[None] - 1.0,\n", + " m.models.model2.n.value[None] - 1.0,\n", + " ]\n", + " ),\n", + " np.array([1.0, 1.0, 1e-1, 1e-1]),\n", + ")\n", + "\n", + "res = ap.fit.LM(joint_model, constraint=C).fit()\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(16, 7))\n", + "ap.plots.model_image(fig, ax[0], joint_model)\n", + "ap.plots.residual_image(fig, ax[1], joint_model, normalize_residuals=True)\n", + "ax[0].set_title(\"fitted sersic only model\")\n", + "ax[1].set_title(\"residuals\")\n", + "plt.show()\n", + "print(joint_model)" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "Success! Notice how the two models have a similar, but not identical position.\n", + "\n", + "This is a very powerful method to stabilize fitting and avoid bad areas of parameter space. Just keep in mind that adding more constraints can make it difficult for the fitter to find the optimal parameter values. Sometimes a constraint will block off a path through parameter space and so things can get stuck. It's best not to assume anything when the number of parameters gets large since high dimensional spaces can be confusing." + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/FunctionalInterface.ipynb b/docs/source/tutorials/FunctionalInterface.ipynb index 7cc69e31..8676d0a7 100644 --- a/docs/source/tutorials/FunctionalInterface.ipynb +++ b/docs/source/tutorials/FunctionalInterface.ipynb @@ -26,10 +26,8 @@ "import jax\n", "import jax.numpy as jnp\n", "import matplotlib.pyplot as plt\n", - "import socket\n", "from corner import corner\n", "\n", - "socket.setdefaulttimeout(120)\n", "ap.backend.backend = \"jax\"" ] }, @@ -116,20 +114,20 @@ " name=f\"supernova_{i}\",\n", " target=img,\n", " model_type=\"point model\",\n", - " psf=psf,\n", + " psf=ap.PSFImage(data=psf),\n", " center=(0.4, 0.0),\n", " flux=sn_flux(T[i]),\n", " )\n", " sn.initialize()\n", " models.append(sn)\n", - " sky = ap.Model(name=f\"sky_{i}\", target=img, model_type=\"flat sky model\", I=0.1 / 0.1**2)\n", + " sky = ap.Model(name=f\"sky_{i}\", target=img, model_type=\"flat sky model\", I0=0.1 / 0.1**2)\n", " sky.initialize()\n", " models.append(sky)\n", - " img.data = np.array(host().data + sn().data + sky().data).T\n", - " img.variance = 0.0001 * np.array(img.data).T\n", - " img.data = img.data.T + np.random.normal(scale=0.01 * np.sqrt(np.array(img.data))).T\n", - " dataset[\"image\"] = dataset[\"image\"].at[i].set(img.data.T)\n", - " dataset[\"variance\"] = dataset[\"variance\"].at[i].set(img.variance.T)\n", + " img.data = np.array(host().data + sn().data + sky().data)\n", + " img.variance = 0.0001 * np.array(img.data)\n", + " img.data = img.data + np.random.normal(scale=0.01 * np.sqrt(np.array(img.data)))\n", + " dataset[\"image\"] = dataset[\"image\"].at[i].set(img.data)\n", + " dataset[\"variance\"] = dataset[\"variance\"].at[i].set(img.variance)\n", " dataset[\"crpix\"] = dataset[\"crpix\"].at[i].set(jnp.array(img.crpix))\n", " dataset[\"crtan\"] = dataset[\"crtan\"].at[i].set(img.crtan.value)\n", " dataset[\"CD\"] = dataset[\"CD\"].at[i].set(img.CD.value)\n", @@ -177,12 +175,12 @@ "):\n", " # Sample sersic\n", " pixel_area = 0.1 * 0.1\n", - " # Pad by 20 pixels to avoid edge effects from convolution\n", + " # Pad by 20 pixels (10 on each side) to avoid edge effects from convolution\n", " i, j, w = ap.image.func.pixel_quad_meshgrid(\n", - " (32 + 20, 32 + 20), ap.config.DTYPE, ap.config.DEVICE, order=3\n", + " (0, 32, 0, 32), 10, 1, ap.config.DTYPE, ap.config.DEVICE, order=3\n", " )\n", " #\n", - " x, y = ap.image.func.pixel_to_plane_linear(j, i, *(crpix + 10), CD, *crtan)\n", + " x, y = ap.image.func.pixel_to_plane_linear(j, i, *crpix, CD, *crtan)\n", " sx, sy = x - sersic_x, y - sersic_y\n", " sx, sy = ap.models.func.rotate(-sersic_PA + np.pi / 2, sx, sy)\n", " sy = sy / sersic_q\n", @@ -194,7 +192,7 @@ "\n", " # Sample point source (empirical PSF)\n", " i, j, w = ap.image.func.pixel_quad_meshgrid(\n", - " (32, 32), ap.config.DTYPE, ap.config.DEVICE, order=3\n", + " (0, 32, 0, 32), 0, 1, ap.config.DTYPE, ap.config.DEVICE, order=3\n", " )\n", " gj, gi = ap.image.func.plane_to_pixel_linear(sn_x, sn_y, *crpix, CD, *crtan)\n", " z = ap.utils.interpolate.interp2d(\n", @@ -357,7 +355,7 @@ "source": [ "This is quite a striking result, the functional implementation is ~100x faster than the AstroPhot model! However, it is important to put this speed comparison in context. The AstroPhot model is much easier, less error prone, and more intuitive to put together. If we are only going to run the model a few times then we will save much more than 500ms by getting the code written faster. The cutout size of 32x32 is very small, while AstroPhot is built to scale to very large images. For larger images, the Python overhead is negligible and the two codes will have near identical runtime. In fact, if the images get a lot larger the functional version as written will run out of memory while the AstroPhot model could carry on easily because of how it chunks the data. Also, note that the plots are quite different, AstroPhot plots all the images properly oriented in the sky, while for the functional version we don't have that capability. AstroPhot has a more complete understanding of the data and can perform a lot more operations on the results. AstroPhot could also combine in data at different resolutions and sizes, while our functional version is predicated on the idea that all the images will be 32x32 pixels, we would need to completely rewrite it to change that. If we wanted to change the model to fix some parameter or to turn one of the fixed parameters into a free parameter, we would have to trace it through the whole functional implementation and make updates accordingly. This goes for any change really, what if we needed to add in a mask, a second sersic model, or start modelling the PSF (rather than taking it as fixed); all of these would require painful changes to the functional version while they would be trivial additions to the AstroPhot model.\n", "\n", - "For these reasons and more, it is highly recommended to do lots of prototyping with object oriented AstroPhot models **before** ever considering the functional interface." + "For these reasons and more, it is highly recommended to work with the object oriented AstroPhot models **before** ever considering the functional interface. And if you really need speed, `jax.jit` can get you 99.9% of the way there. Take a look at the timing comparison below, the AstroPhot model is now basically identical in speed to the laborious functional model:" ] }, { @@ -366,6 +364,25 @@ "id": "12", "metadata": {}, "outputs": [], + "source": [ + "jmodel = jax.jit(model)\n", + "_ = jmodel(params_true, *extra)\n", + "_ = jmodel(params_true, *extra)\n", + "print(\"JIT-compiled functional interface timings:\")\n", + "%timeit jmodel(params_true, *extra)\n", + "japmodel = jax.jit(lambda: apmodel()._data)\n", + "_ = japmodel()\n", + "_ = japmodel()\n", + "print(\"JIT-compiled AstroPhot model timings:\")\n", + "%timeit japmodel()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], "source": [ "# Make 8 chains, starting at the true parameters\n", "params = np.stack(list(np.array(params_true) for _ in range(4)))\n", @@ -402,7 +419,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "14", "metadata": {}, "source": [ "Now lets plot the likelihood distributions for the flux parameters compared to their true value. As you can see, the distributions do a good job of covering the ground truth! This means we have accurately extracted the light curve for the supernova data." @@ -411,7 +428,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "15", "metadata": { "tags": [ "hide-input" @@ -430,7 +447,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ "Below we show the likelihood distribution for the sersic host parameters. We can see that there is some non-linearity and certainly lots of correlation in these parameters. This makes the sampling a bit trickier, but MALA is up to the task." @@ -439,7 +456,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": { "tags": [ "hide-input" @@ -459,7 +476,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "18", "metadata": {}, "outputs": [], "source": [] diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 00c6de63..1f010326 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -24,10 +24,7 @@ "import torch\n", "from astropy.io import fits\n", "from astropy.wcs import WCS\n", - "import matplotlib.pyplot as plt\n", - "import socket\n", - "\n", - "socket.setdefaulttimeout(120)" + "import matplotlib.pyplot as plt" ] }, { @@ -97,9 +94,12 @@ "outputs": [], "source": [ "# first let's download an image to play with\n", - "hdu = fits.open(\n", - " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - ")\n", + "############ UNCOMMENT IF RUNNING LOCALLY ############\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "# )\n", + "# hdu.writeto(\"target_image.fits\", overwrite=True)\n", + "hdu = fits.open(\"target_image.fits\")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", "target = ap.TargetImage(\n", @@ -150,7 +150,7 @@ "outputs": [], "source": [ "# Now that the model has been set up with a target and initialized with parameter values, it is time to fit the image\n", - "result = ap.fit.LMfast(model2, verbose=1).fit()\n", + "result = ap.fit.LM(model2, verbose=1).fit()\n", "\n", "# See that we use ap.fit.LM, this is the Levenberg-Marquardt Chi^2 minimization method, it is the recommended technique\n", "# for most least-squares problems. See the Fitting Methods tutorial for more on fitters!\n", @@ -293,7 +293,7 @@ "outputs": [], "source": [ "model3.initialize()\n", - "result = ap.fit.LMfast(model3, verbose=1).fit()" + "result = ap.fit.LM(model3, verbose=1).fit()" ] }, { @@ -310,6 +310,27 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Inspect parameters\n", + "\n", + "AstroPhot is all about managing parameters, so there is lots of information that comes with them, lets see some of the meta-data you can access:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Parameter units, sersic Re:\", model3.Re.units)\n", + "print(\"Expected parameter shape, Re:\", model3.Re.shape)\n", + "print(\"and for center it is:\", model3.center.shape)\n", + "print(\"Parameter dynamic state, Re:\", model3.Re.dynamic, \"so it will be optimized by a fitter\")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -362,7 +383,7 @@ "# be connected.\n", "model_2.PA = model_1.PA\n", "\n", - "# Here we can see how the two models now both can modify this parameter\n", + "# Here we can see how the two models now both can access this parameter\n", "print(\n", " \"initial values: model_1 PA\",\n", " model_1.PA.value.item(),\n", @@ -376,6 +397,14 @@ " model_1.PA.value.item(),\n", " \"model_2 PA\",\n", " model_2.PA.value.item(),\n", + ")\n", + "# We cannot easily modify the PA through model_2, to do it we access the real PA parameter through our pointer\n", + "model_2.PA.PA.value = np.pi / 6\n", + "print(\n", + " \"change model_2: model_1 PA\",\n", + " model_1.PA.value.item(),\n", + " \"model_2 PA\",\n", + " model_2.PA.value.item(),\n", ")" ] }, @@ -504,9 +533,9 @@ "metadata": {}, "outputs": [], "source": [ - "# first let's download an image to play with\n", - "filename = \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - "hdu = fits.open(filename)\n", + "# filename = \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "# hdu = fits.open(filename)\n", + "hdu = fits.open(\"target_image.fits\")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", "wcs = WCS(hdu[0].header)\n", @@ -540,7 +569,7 @@ "metadata": {}, "outputs": [], "source": [ - "target = ap.TargetImage(filename=filename)\n", + "target = ap.TargetImage(filename=\"target_image.fits\")\n", "\n", "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", "ap.plots.target_image(fig3, ax3, target)\n", diff --git a/docs/source/tutorials/GettingStartedJAX.ipynb b/docs/source/tutorials/GettingStartedJAX.ipynb index f7f1c769..743633c9 100644 --- a/docs/source/tutorials/GettingStartedJAX.ipynb +++ b/docs/source/tutorials/GettingStartedJAX.ipynb @@ -26,10 +26,7 @@ "import jax\n", "from astropy.io import fits\n", "from astropy.wcs import WCS\n", - "import matplotlib.pyplot as plt\n", - "import socket\n", - "\n", - "socket.setdefaulttimeout(120)" + "import matplotlib.pyplot as plt" ] }, { @@ -118,9 +115,11 @@ "outputs": [], "source": [ "# first let's download an image to play with\n", - "hdu = fits.open(\n", - " \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - ")\n", + "############# UNCOMMENT IF RUNNING LOCALLY ############\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "# )\n", + "hdu = fits.open(\"target_image.fits\")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", "target = ap.TargetImage(\n", @@ -331,6 +330,27 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Inspect parameters\n", + "\n", + "AstroPhot is all about managing parameters, so there is lots of information that comes with them, lets see some of the meta-data you can access:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Parameter units, sersic Re:\", model3.Re.units)\n", + "print(\"Expected parameter shape, Re:\", model3.Re.shape)\n", + "print(\"and for center it is:\", model3.center.shape)\n", + "print(\"Parameter dynamic state, Re:\", model3.Re.dynamic, \"so it will be optimized by a fitter\")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -527,8 +547,8 @@ "outputs": [], "source": [ "# first let's download an image to play with\n", - "filename = \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", - "hdu = fits.open(filename)\n", + "# filename = \"https://www.legacysurvey.org/viewer/fits-cutout?ra=36.3684&dec=-25.6389&size=700&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "hdu = fits.open(\"target_image.fits\")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "\n", "wcs = WCS(hdu[0].header)\n", @@ -562,7 +582,7 @@ "metadata": {}, "outputs": [], "source": [ - "target = ap.TargetImage(filename=filename)\n", + "target = ap.TargetImage(filename=\"target_image.fits\")\n", "\n", "fig3, ax3 = plt.subplots(figsize=(8, 8))\n", "ap.plots.target_image(fig3, ax3, target)\n", diff --git a/docs/source/tutorials/GravitationalLensing.ipynb b/docs/source/tutorials/GravitationalLensing.ipynb index b39f810c..e4a90931 100644 --- a/docs/source/tutorials/GravitationalLensing.ipynb +++ b/docs/source/tutorials/GravitationalLensing.ipynb @@ -26,9 +26,7 @@ "import caustics\n", "import numpy as np\n", "import torch\n", - "import socket\n", - "\n", - "socket.setdefaulttimeout(120)" + "from astropy.io import fits" ] }, { @@ -58,8 +56,13 @@ "metadata": {}, "outputs": [], "source": [ + "############ UNCOMMENT IF RUNNING LOCALLY ############\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=177.1380&dec=19.5008&size=150&layer=ls-dr9&pixscale=0.262&bands=g\"\n", + "# )\n", + "# hdu.writeto(\"lensed_target_image.fits\", overwrite=True)\n", "target = ap.TargetImage(\n", - " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=177.1380&dec=19.5008&size=150&layer=ls-dr9&pixscale=0.262&bands=g\",\n", + " filename=\"lensed_target_image.fits\",\n", " name=\"horseshoe\",\n", " variance=\"auto\",\n", " zeropoint=22.5,\n", diff --git a/docs/source/tutorials/GroupModels.ipynb b/docs/source/tutorials/GroupModels.ipynb index fb92ada5..922ca2c5 100644 --- a/docs/source/tutorials/GroupModels.ipynb +++ b/docs/source/tutorials/GroupModels.ipynb @@ -23,11 +23,8 @@ "source": [ "import astrophot as ap\n", "import numpy as np\n", - "from astropy.io import fits\n", "import matplotlib.pyplot as plt\n", - "import socket\n", - "\n", - "socket.setdefaulttimeout(120)" + "from astropy.io import fits" ] }, { @@ -37,7 +34,13 @@ "outputs": [], "source": [ "# first let's download an image to play with\n", - "hdu = ap.utils.ls_open(155.7720, 15.1494, 150 * 0.262, band=\"r\")\n", + "############ UNCOMMENT IF RUNNING LOCALLY ############\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=155.7720&dec=15.1494&size=150&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "# )\n", + "# hdu.writeto(\"group_target_image.fits\", overwrite=True)\n", + "hdu = fits.open(\"group_target_image.fits\")\n", + "# hdu = ap.utils.ls_open(155.7720, 15.1494, 150 * 0.262, band=\"r\")\n", "target_data = np.array(hdu[0].data, dtype=np.float64)\n", "fig1, ax1 = plt.subplots(figsize=(8, 8))\n", "plt.imshow(np.arctan(target_data / 0.05), origin=\"lower\", cmap=\"inferno\")\n", @@ -134,7 +137,7 @@ " name=f\"sky_level\",\n", " model_type=\"flat sky model\",\n", " target=target,\n", - " I={\"valid\": (0, None)},\n", + " I0={\"valid\": (0, None)},\n", ")\n", "\n", "# We build the group model just like any other, except we pass a list of other models\n", diff --git a/docs/source/tutorials/ImageAlignment.ipynb b/docs/source/tutorials/ImageAlignment.ipynb index 0f8c58ff..985d219c 100644 --- a/docs/source/tutorials/ImageAlignment.ipynb +++ b/docs/source/tutorials/ImageAlignment.ipynb @@ -21,9 +21,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import torch\n", - "import socket\n", - "\n", - "socket.setdefaulttimeout(120)" + "from astropy.io import fits" ] }, { @@ -43,13 +41,23 @@ "metadata": {}, "outputs": [], "source": [ + "############ UNCOMMENT IF RUNNING LOCALLY ############\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "# )\n", + "# hdu.writeto(\"align_target_image_r.fits\", overwrite=True)\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=g\"\n", + "# )\n", + "# hdu.writeto(\"align_target_image_g.fits\", overwrite=True)\n", + "\n", "target_r = ap.TargetImage(\n", - " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=r\",\n", + " filename=\"align_target_image_r.fits\",\n", " name=\"target_r\",\n", " variance=\"auto\",\n", ")\n", "target_g = ap.TargetImage(\n", - " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=329.2715&dec=13.6483&size=150&layer=ls-dr9&pixscale=0.262&bands=g\",\n", + " filename=\"align_target_image_g.fits\",\n", " name=\"target_g\",\n", " variance=\"auto\",\n", ")\n", diff --git a/docs/source/tutorials/ImageTypes.ipynb b/docs/source/tutorials/ImageTypes.ipynb index 229d9e97..f6a71d3e 100644 --- a/docs/source/tutorials/ImageTypes.ipynb +++ b/docs/source/tutorials/ImageTypes.ipynb @@ -118,7 +118,11 @@ "cell_type": "code", "execution_count": null, "id": "9", - "metadata": {}, + "metadata": { + "tags": [ + "hide:input" + ] + }, "outputs": [], "source": [ "fig, ax = plt.subplots(figsize=(5, 5))\n", @@ -126,10 +130,12 @@ "ax.add_patch(r1)\n", "r2 = Rectangle((-0.5, -0.5), 0.8, 0.8, facecolor=\"blue\", label=\"Subpixel Area\")\n", "ax.add_patch(r2)\n", + "ax.scatter([-0.1], [-0.1], color=\"red\", label=\"subpixel_loc\")\n", + "ax.axvline(0.33, 0, 0.8, color=\"orange\", linewidth=2, label=\"subpixel_scale\")\n", "ax.set_xlim(-0.5, 0.5)\n", "ax.set_ylim(-0.5, 0.5)\n", "ax.set_title(\"CMOS Pixel Representation\")\n", - "ax.legend()\n", + "ax.legend(loc=\"upper left\")\n", "plt.show()" ] }, diff --git a/docs/source/tutorials/JointModels.ipynb b/docs/source/tutorials/JointModels.ipynb index e211be9d..5a5b124c 100644 --- a/docs/source/tutorials/JointModels.ipynb +++ b/docs/source/tutorials/JointModels.ipynb @@ -21,9 +21,7 @@ "source": [ "import astrophot as ap\n", "import matplotlib.pyplot as plt\n", - "import socket\n", - "\n", - "socket.setdefaulttimeout(120)" + "from astropy.io import fits" ] }, { @@ -41,9 +39,23 @@ "# affect the relative weight of each image. For the tutorial we use simple approximations, but in\n", "# science level analysis one should endeavor to get the best measure available for these.\n", "\n", + "############ UNCOMMENT IF RUNNING LOCALLY ############\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=500&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "# )\n", + "# hdu.writeto(\"joint_target_image_r.fits\", overwrite=True)\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1\"\n", + "# )\n", + "# hdu.writeto(\"joint_target_image_W1.fits\", overwrite=True)\n", + "# hdu = fits.open(\n", + "# \"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=90&layer=galex&pixscale=1.5&bands=n\"\n", + "# )\n", + "# hdu.writeto(\"joint_target_image_NUV.fits\", overwrite=True)\n", + "\n", "# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel and is 500 pixels across\n", "target_r = ap.TargetImage(\n", - " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=500&layer=ls-dr9&pixscale=0.262&bands=r\",\n", + " filename=\"joint_target_image_r.fits\",\n", " zeropoint=22.5,\n", " variance=\"auto\", # auto variance gets it roughly right, use better estimate for science!\n", " psf=ap.utils.initialize.gaussian_psf(1.12 / 2.355, 51, 0.262),\n", @@ -53,7 +65,7 @@ "\n", "# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel and is 52 pixels across\n", "target_W1 = ap.TargetImage(\n", - " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=52&layer=unwise-neo7&pixscale=2.75&bands=1\",\n", + " filename=\"joint_target_image_W1.fits\",\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", @@ -62,7 +74,7 @@ "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel and is 90 pixels across\n", "target_NUV = ap.TargetImage(\n", - " filename=\"https://www.legacysurvey.org/viewer/fits-cutout?ra=187.3119&dec=12.9783&size=90&layer=galex&pixscale=1.5&bands=n\",\n", + " filename=\"joint_target_image_NUV.fits\",\n", " zeropoint=20.08,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5),\n", @@ -209,10 +221,25 @@ "DEC = 15.5512\n", "# Our first image is from the DESI Legacy-Survey r-band. This image has a pixelscale of 0.262 arcsec/pixel\n", "rsize = 90\n", + "wsize = int(rsize * 0.262 / 2.75)\n", + "gsize = int(rsize * 0.262 / 1.5)\n", "\n", "# Now we make our targets\n", + "############ UNCOMMENT IF RUNNING LOCALLY ############\n", + "# hdu = fits.open(\n", + "# f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={rsize}&layer=ls-dr9&pixscale=0.262&bands=r\"\n", + "# )\n", + "# hdu.writeto(\"joint_group_target_image_r.fits\", overwrite=True)\n", + "# hdu = fits.open(\n", + "# f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={wsize}&layer=unwise-neo7&pixscale=2.75&bands=1\"\n", + "# )\n", + "# hdu.writeto(\"joint_group_target_image_W1.fits\", overwrite=True)\n", + "# hdu = fits.open(\n", + "# f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={gsize}&layer=galex&pixscale=1.5&bands=n\"\n", + "# )\n", + "# hdu.writeto(\"joint_group_target_image_NUV.fits\", overwrite=True)\n", "target_r = ap.image.TargetImage(\n", - " filename=f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={rsize}&layer=ls-dr9&pixscale=0.262&bands=r\",\n", + " filename=\"joint_group_target_image_r.fits\",\n", " zeropoint=22.5,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(1.12 / 2.355, 51, 0.262),\n", @@ -220,9 +247,8 @@ ")\n", "\n", "# The second image is a unWISE W1 band image. This image has a pixelscale of 2.75 arcsec/pixel\n", - "wsize = int(rsize * 0.262 / 2.75)\n", "target_W1 = ap.image.TargetImage(\n", - " filename=f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={wsize}&layer=unwise-neo7&pixscale=2.75&bands=1\",\n", + " filename=\"joint_group_target_image_W1.fits\",\n", " zeropoint=25.199,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(6.1 / 2.355, 21, 2.75),\n", @@ -230,9 +256,8 @@ ")\n", "\n", "# The third image is a GALEX NUV band image. This image has a pixelscale of 1.5 arcsec/pixel\n", - "gsize = int(rsize * 0.262 / 1.5)\n", "target_NUV = ap.image.TargetImage(\n", - " filename=f\"https://www.legacysurvey.org/viewer/fits-cutout?ra={RA}&dec={DEC}&size={gsize}&layer=galex&pixscale=1.5&bands=n\",\n", + " filename=\"joint_group_target_image_NUV.fits\",\n", " zeropoint=20.08,\n", " variance=\"auto\",\n", " psf=ap.utils.initialize.gaussian_psf(5.4 / 2.355, 21, 1.5),\n", diff --git a/docs/source/tutorials/ModelZoo.ipynb b/docs/source/tutorials/ModelZoo.ipynb index 0dbaec62..67a51946 100644 --- a/docs/source/tutorials/ModelZoo.ipynb +++ b/docs/source/tutorials/ModelZoo.ipynb @@ -52,7 +52,7 @@ "metadata": {}, "outputs": [], "source": [ - "M = ap.Model(model_type=\"flat sky model\", center=[50, 50], I=1, target=basic_target)\n", + "M = ap.Model(model_type=\"flat sky model\", center=[50, 50], I0=1, target=basic_target)\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(figsize=(7, 6))\n", @@ -95,7 +95,9 @@ "source": [ "### Bilinear Sky Model\n", "\n", - "This allows for a complex sky model which can vary arbitrarily as a function of position. Here we plot a sky that is just noise, but one would typically make it smoothly varying. The noise sky makes the nature of bilinear interpolation very clear, large flux changes can create sharp edges in the reconstruction." + "This allows for a complex sky model which can vary arbitrarily as a function of position. Here we plot a sky that is just noise, but one would typically make it smoothly varying. The noise sky makes the nature of bilinear interpolation very clear, large flux changes can create sharp edges in the reconstruction.\n", + "\n", + "Here we show how you can set the PA and scale manually, if you don't provide those arguments they will automatically be set to fill the target image (which is usually what you want)." ] }, { @@ -108,12 +110,14 @@ "M = ap.Model(\n", " model_type=\"bilinear sky model\",\n", " I=np.random.uniform(0, 1, (5, 5)) + 1,\n", + " PA=np.pi / 8,\n", + " scale=15,\n", " target=basic_target,\n", ")\n", "M.initialize()\n", "\n", "fig, ax = plt.subplots(figsize=(7, 6))\n", - "ap.plots.model_image(fig, ax, M)\n", + "ap.plots.model_image(fig, ax, M, vmax=20)\n", "ax.set_title(M.name)\n", "plt.show()" ] @@ -150,7 +154,7 @@ "\n", "psf_target = ap.PSFImage(\n", " data=psf / np.sum(psf),\n", - " pixelscale=1,\n", + " upsample=1,\n", ")\n", "fig, ax = plt.subplots()\n", "ap.plots.psf_image(fig, ax, psf_target)\n", @@ -181,7 +185,7 @@ "wgt = np.array((0.0001, 0.01, 1.0, 0.01, 0.0001))\n", "PSF[48:53] += (sinc(x[48:53]) ** 2) * wgt.reshape((-1, 1))\n", "PSF[:, 48:53] += (sinc(x[:, 48:53]) ** 2) * wgt\n", - "PSF = ap.PSFImage(data=PSF, pixelscale=psf_target.pixelscale)\n", + "PSF = ap.PSFImage(data=PSF)\n", "\n", "M = ap.Model(\n", " model_type=\"pixelated psf model\",\n", @@ -262,7 +266,7 @@ "outputs": [], "source": [ "M = ap.Model(\n", - " model_type=\"2d moffat psf model\",\n", + " model_type=\"moffat ellipse psf model\",\n", " n=2.0,\n", " Rd=10.0,\n", " q=0.7,\n", @@ -794,6 +798,37 @@ "HTML(ani.to_jshtml())" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Pixelated Model\n", + "\n", + "This model is simply a 2D grid of pixels which will be bilinearly interpolated to create the model on the sky. This can get out of hand quickly in terms of the number of parameters, so be careful!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "M = ap.Model(\n", + " model_type=\"pixelated model\",\n", + " center=[50, 50],\n", + " I=np.random.uniform(0, 5, (25, 25)) + 1,\n", + " PA=np.pi / 8,\n", + " scale=3,\n", + " target=basic_target,\n", + ")\n", + "M.initialize()\n", + "\n", + "fig, ax = plt.subplots(1, 1, figsize=(6, 6))\n", + "ap.plots.model_image(fig, ax, M)\n", + "ax.set_title(M.name)\n", + "plt.show()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -1037,6 +1072,127 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Batched Models\n", + "\n", + "Batching a model means turning the evaluation of the model itself into a vectorized operation. All AstroPhot models can be batched over their parameters to produce variations on the same model. This is done roughly like this:\n", + "\n", + "```python\n", + "x0 = model.get_values()\n", + "x0_batched = torch.stack([x0, x0*1.1, x0*0.9]) # basically turn into vector of param vectors\n", + "results = torch.vmap(lambda x: model(x).data)(x0_batched)\n", + "```\n", + "\n", + "and now results will be output images from the model, except with an extra dimension for all the parameter variations you tried.\n", + "\n", + "This is super useful for things like running multiple chains in an MCMC, but sometimes we want other variations to allow for vectorization in sampling the model itself. This is where you use batch models.\n", + "\n", + "**Note:** Once you put a model into a batched model, it generally becomes unusable. You should consider it as part of the configuration of the batch model, and now that is all you work with.\n", + "\n", + "**WARNING:** Any model parameter you wish to batch over must be set to `dynamic=True` (by default most already are), see [caskade hierarchical models](https://caskade.readthedocs.io/en/latest/notebooks/HierarchicalModels.html) for more details.\n", + "\n", + "### BatchModel\n", + "\n", + "Any individual component model can be turned into a batch of models by wrapping it in the `BatchModel` class. This lets you represent a collection of similar objects/components in the same window.\n", + "\n", + "This is computationally faster than creating a separate model for each object. Because of the constraints of the `BatchModel` it is able to do PSF convolution just once, on the summed image and so can get considerable performance enhancement." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "M0 = ap.Model(\n", + " model_type=\"sersic galaxy model\",\n", + " center=[50, 50],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=2,\n", + " Re=5,\n", + " Ie=1,\n", + " target=basic_target,\n", + ")\n", + "M0.initialize()\n", + "\n", + "M = ap.Model(model_type=\"batch model\", model=M0)\n", + "# Now any parameters may be given an extra dimension for the batch\n", + "M0.center = [[10, 10], [20, 70], [50, 50], [70, 30], [80, 80]]\n", + "M0.q = [0.7, 0.6, 0.5, 0.4, 0.3]\n", + "M0.PA = np.array([30, 60, 90, 120, 150]) * np.pi / 180\n", + "# Parameters with single values will have the same value for all models\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 6))\n", + "ap.plots.model_image(fig, ax, M)\n", + "ax.set_title(M.name)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### BatchSceneModel\n", + "\n", + "Any AstroPhot component model or group model (but not a batched model) can be turned into a `BatchSceneModel`, which broadly lets you fit a single model over many images. This can be useful for fitting dithered images since it is the same scene being fit to a few slightly shifted images. This can also be useful for fitting transients embedded in a static host. It is possible to select which parameters will vary across all images and which will be fixed for all images." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "target_batch = ap.TargetImageBatch(\n", + " images=[\n", + " ap.TargetImage(\n", + " data=np.zeros((50, 50)),\n", + " CD=ap.utils.initialize.R(np.pi / 3),\n", + " crtan=(10, 0),\n", + " name=\"image1\",\n", + " ),\n", + " ap.TargetImage(data=np.zeros((50, 50)), CD=ap.utils.initialize.R(np.pi / 6), name=\"image2\"),\n", + " ap.TargetImage(\n", + " data=np.zeros((50, 50)),\n", + " CD=ap.utils.initialize.R(np.pi / 16),\n", + " crtan=(-15, 0),\n", + " name=\"image3\",\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "M0_host = ap.Model(\n", + " model_type=\"sersic galaxy model\",\n", + " center=[0, 30],\n", + " q=0.6,\n", + " PA=60 * np.pi / 180,\n", + " n=2,\n", + " Re=3,\n", + " Ie=1,\n", + " target=target_batch.images[0], # can be any of them\n", + ")\n", + "M0_sn = ap.Model(\n", + " model_type=\"point model\",\n", + " center=[10, 40],\n", + " flux=1,\n", + " psf=ap.utils.initialize.gaussian_psf(1.0, 11, 1.0),\n", + " target=target_batch.images[0],\n", + ")\n", + "M0 = ap.Model(model_type=\"group model\", models=[M0_host, M0_sn], target=target_batch.images[0])\n", + "M0.initialize()\n", + "M0_sn.flux = [10, 1, 0.1] # we can give the SN different fluxes in each image\n", + "\n", + "M = ap.Model(model_type=\"batch scene model\", model=M0, target=target_batch)\n", + "\n", + "fig, axarr = plt.subplots(1, 3, figsize=(18, 6))\n", + "ap.plots.model_image(fig, axarr, M)\n", + "plt.show()" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/docs/source/tutorials/align_target_image_g.fits b/docs/source/tutorials/align_target_image_g.fits new file mode 100644 index 0000000000000000000000000000000000000000..d1d153f3f384a7b3d90268376da30490e6d1c2c8 GIT binary patch literal 95040 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

DBW&0j1f6W7}s0FWCXXOdbI&wml(j~ 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 H=L-A}HcY&U literal 0 HcmV?d00001 diff --git a/docs/source/tutorials/group_target_image.fits b/docs/source/tutorials/group_target_image.fits new file mode 100644 index 0000000000000000000000000000000000000000..e7c08be2f544bfcbad04ed369993d67788cce445 GIT binary patch literal 95040 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

R6s9wsHX`|vEZ3Mxvj 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]%rsy0q@0k;gf$2I7K@Ti(Uzt_k~fyEd3qciO9h4 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
y_63o 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Ⓢ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ιacfA+X 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�|`K`G9=ms;hf`348K7XjZt&ItHBu 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%0DZ>4Szm^>#04c^z1ha9Jd9xRanE#DdTY7S$$^G zmCeN_?ZW>1Cd~cM7AAjOS9pHTF?`PR5+=O@DssO@_`KK&4%=kFz6-(7T6>op46(&A zE}Q9*!T=bE|H4)1S3vXD1*9YR06BT$CzXNixNq+uBu5o+gU90NxaLjpO5wXe>EnIi zZk?$9a!0^+)~#o%9}>Broiece+cfSY&kpy@k!KU%KjwrA6u-L%$_u-id~ve{fgNij%J98+7gOu}I)5vpgC?wA5;t z?w6Z*%;qOmPSjye-v$J8>nDWStb~lF7OHf=3LGY^g6`9UT-~cP z*g7|c3eGq}GT+m;&H!%2KR$o^ZYoo)+le7}yK#=yB=S0uUn5!gpnmgw{E!@hV|;%@ z$oku0^tc=o9j{R_??yP4XTUWz%;gk)8ezwiJT~dSo6M%$3i{VS!S>lvju-ZraNjeh zKw12Cy2Uvb>tdeaIfX1Z>Q&BEgg>~g_cjq*vjKcqy^frmx{G|R;b)ddthle)CU8-o z=NWGZz%B(B{5eX2&9^^?OH7Wy%}1NKx4*}5S5x>Ljc^(cuN%WOxfuM_w4Q97{U2s^ zhhqNUuhe7_|2+5=fzylQinBX-sAc+%SveaH$%dASYfzUaa0Bh@S3)sc(JYz`!Gn_wr_YFIeC| zn{8aJSTV|_x^Pp!euB&#f+jqjT&E&}+5fu@+P!nQu3|CLJkJQ{NI$`QpLTJQXMRw{ zDZYY>ReZPT%y*1hV}Q{Y^tgLps=iu{XIyoW*ekDu z-0(5xut65xYUe>ho+As{@tI7%DNj8nzGWdZVwuaFPEc!4fK4_f+_@_c;0z4n+Sn4% zQ_kXUUC<{lPvoIO;!_&4miK3P%HYk=X405$MD@H1;o*F7)bE-q@OC%?<>e0afO#9; zYIuidlg)rZvnqNxypgn3%hP3}*1^NO$xK1y9-JK1b;{Zs0=-`Q0F1|Q{cB!vm!Iv& z(G5S*VB)#EY`xyNnQ|byAl>$7=Xr?6xX1?Q}#eIkstd4Hf*z{Cg+BnjvcMyDu1TIx<3wN z@C}E$Wq0tf@&!YqGy z+*<=OQ4-J)G)$GPRWSL7HdCJY5PK#2*y!+yFgDtVo?e+l)qAc$p7&-NutX8V*&og} z?*Pj2@71-}TWPRU6Mgdh27RD#ls-v+Na4#n`XRW_*@10$9>udvMm@8k_gh!c)d;E>q9462;umhK2V!74H_F5SLMlbfUHw^|Bo9R)2qp6 zkw?Jwx%-zRqPO{jN;!crAYO;%6^n$z*kxD-PUTjPv&Ygk5}g#&OvOr%RJ9 zFor2}7Z+u6&m5-13**PoGqHf3QNGL!WbE+i;m`On|2QTb*acCeT5;}3{*3O>6}Z$f zmdW1Uimm(0Aito3aMoh@ZqqNEU09Dp+Eei2wQ*ei%j1|45suYWZ&9oL6MAoI0#T+0 z#a=RSB(;U9jgWwAa=)mtw+=X+8GxXsYC&vrI~=chNL9x?Ano<-DwgP)$2 zP*~_hUvoz)qS2jp**^Uy4bzrePeg1kKTLUEn4S1s<+aaivDxf^xaOsT`Z+L9`{o@ zTPy6hzsbgt(eTpw1Ng3Kf(3;pf_a&Fc)Iu&n_ZxdY9;$fL}Uqkxx0Wm%ej!|*JH_m z$^+DUZ3@$pgTYW^H~(SS%oMB^ao-2eaHTcx(M7Qv7RB2$6<;|#YSTlvEYqR}6Q<(W zI%z0=sRv8BdVap;MG6)NV{yTCSk)23vniLe<&Uoes0~5lC(2^4g)z~)bMa+!IT5Yg%aGfC54?ZZF)=L*(&%0dJ@gfo{(FfO z@@ylS^q)|-`4CCxyISWroEAi{sibm_cS*#xGVnY36k;`&lYYjaaqVqt(eMHrHfS;V zEgSG{NB}fimgB}bQ_z3SIy5m5#g5DYl$D2?yRZG#@eH|TZm2`+slLW(7&u}E1S1QQB{6=Npgi$m6ImdI#4;PnsI zSUrREY<+Hks}ZE=Inr%wYU$DhJL+j-4Bje1sQl^=%nCh*&-P5lgBsD)Ilde6zL$dK z$3jpnJHQss%t8L5aB|U~x!a07$2asfzs~hz@tH^QM6)Rs^)_aj@A*906frp9ah}?{ zEyufMV)*OLR~&Rz#1TEJZ2ajnOv#StH1l(kBEbVDxz7bA%~Hpd>mitbG=b;;HRI68 z08&#b1lMt|@l#7Jr(0Q&dJ6B zrZ53p@8gY`XIiLj;YduXjbN@4@^IGkEH3ljgL~jNT;$zuDW`mJT;dGuEJ~sgZx@h9 zXa2(^Ii!kAYW16yc<-Ek0@JVN%g2 zF(UpqRq@RvAAV~hcX<$QTOJ{59zVgwSrihf5u|np;DOVG=kFJArD5}-E5^iBa#_%YXh?QfgZt0b@z$LxoZ68Ft1hmC^KN3eFf5pf zJsttsyLI58}Urq_`&C@^^x|pfozX(%m7Q?jR zP59=AFK%%(1WRin++3pv!aGa2{HMPlA{?rRo?gODM>cUrbewd-f za~edu@f$oRJJMq5Sn0!5O5jCTD~Hke6?}hsS`B6gr&E*Z51sgJEB>(O&)c#bxCgh- zat&+VV4D4Qt~75n-0^&b14{46sRjiu{!laoO??A>eVsg$cpUxjdjb`hucNZ(b!hhq zZF>2eBz@s;;XGnr2yL62K`XY2(*1NhjeF}%p?fpBlz0P6SP5G-M#1RZiEs{%Q@#9B zuzh(5j_BCIx8?45RKgW&SIy^sZ;WJd1s*tLe$$!W+J21PpacS zVRH%}CGrY~$MO1b@Mt$vSa%mi#_~L@?)l)dauiq9X28WPoWb|yXJX02-LN{nj?SFa z24bFn$jER_ys{J74o@4dSb)>B)oJ&0! zhP$$n%eZ=sYH2jm`R$g1@(~m&eC(j$+(dj}^9_sP2YTfVFp)C;zG=NlWg}C-V16SW zRdxfp@u75HiZ|356+^+`64ZY26um{);@v;PpnGgIxg=gp4t$QJqpcQ`RJ|iOW2Pk= zIdT?iIUf)_%(R1l$-^Lil>aXOy2E{_KZ=iRKa#FZwoWx8CO~17232poKxUbZCrP0m zxIxVeMADX1{iDNFag8VQ@{qy(iJz%Pe*+CYtpiDpR%~MEW`5SjdlSDVGC7M72#bp& z$3nXCPKy=Bf4qVE*|*`DlO5pd`*1+!2j(sfXFAi5LvQ*?x^dl3PT}WU=-lz19+obl zSMs*ewfr}195;m?%W0t3m-*1lj5=z2t(W?jErMXj8f@dwqSsSra&4#@?|$tB-LyOmER3FyjW~^HWZ^WbE!|kt}1pl78jx|g%;UJs% zsE;)5iGl;$-(%g>W>O)pPF3GL7E~oWq4$zyAkuyi53Vd?2?q{f*}OOSCd?k>LzKwp zddif0UUT=DBJVahgO?6IVCucQVD@#6DmQuK2JLS!e|aasQaa7(m zfY;9+Cg&>3smv7sI{p2zo#u8Wd=)ork_lJ{O1jb<9@I;b?y zp33^qqpC47c+7nkGyU`j>jrJu2+bL|amy9-50l0y82j z@XhacMBhb;vkxC2m0=ykL{kpV=Z=SIfupdg(VsJR@)+CVPc1i*l&goS?uu7nn);6!{e4AV^N#hKMI)G$<8i)wyh~WM;TnN4_(wy~l7E%yoqf)TM(Zkn}Qn~oKi##tFvaP?m~R9C*CTGx&d<-scSUe!luOKgElVY%F!l^GBmKMU*K zPLN4K>Fi1Svss%sP7!IVY73Rof2XH~F_P zBluOKiK=PdASJUXIla>ierUEaiN-guUqu@I?zur~u{^~1%cDx11D$ZapLekO;HKDk zyuzb^6zZ+PJmosos=f!GB|UMuOFI5x(&XLd@9^2e6AtkAOhxxCgza*8e}O-7+Nw{} z>i^@tOGZ>Vb}pED`(pAjTYN9)$0E1AXX?7Q9kWUTsL_NAT9ca=`Big_I`;&&*M~95zC%n-MGQyH&Szt<%i-l&#^gxORm@v=oIB~qVQ!!e zL_IEJq4DD}<=P)qwUh^S-e?!LDU0NH-{#JXYe3hXA&$JpsnWYUQ5P&-i; zFO;XS@f*eQaG@sMz9kcyWoANw=0mCi7vU41-8lKuq57?!EJk#%Anj!hIPcg7I0~EL z+-=Gxmrcch^*m3;T?P^rlR2dvU-Qn?7=xCOXvhHNWS3y%FyUzG0x{8*Y~BJ>sE03_TM2 z;nNv$v}CI=TjVE6E-nVIND=bqniOvJdB$dy@z+RN5ASO~z~cv=QQw1m;Y-LnJb5G? zq9;9q`-k-5bhR>(oqku?boV8Stj*@qn#6H&Z7e>rxyY6FS%Ylm5~f_xz*HAi!0K9C zuJ=?NQ|W&R(WXJr>{Ux$Htg;90+!{!{+2?!TiB9<#_SgI@f#vK&7wIE3oPCZMzT8^-=e<3EEmnL%evLNi!SNJpj zJvUaEinA|jqYt}EJOsHIcmEISeskuobhdzoLl*n9kJw5xZcA9|NZv^ ze=V!QqMsI!_cw%^opr$r$IRL2wF#gvbcXE6!>;qE}rU?l)`aME<)E`B^m>9_`rc6tf3 zcSJJTYD*@Prp`6KxzByKYyb^cZ&FY;idc*&hDx&>$WRQWg1%NNzJPGg`~$(-CY@wQ z?FEayGthZE|BZU~4vj3YFrBP5_*oc;>#Y3A@Pia4)1re`+a350i6LIMea>{n9GQ$l z0`@+#gtjHAFn;m{Zu323xVpfP%g|S4pizdy`Fw8I+Lxa9T1!RCMafRH74&eV2P7U? z#57)Nz|5|*Y^&`8=AtzUPrBR|e6k6FmX+V|m?7fl&&#>aJ~t44?G%1mVuROKZxdLH zkEHtY8erJ>p6bP|=cr7r(B=}NV%lUj`us4Lzj^|7@7)9y3AJF+`5ra2rh)&>wTzmE zvo$|`QB}VL-&nTe)~FaNadn-b=;lkflI$b+F>?<7ygZeSh;Sv%=X;2g!9<+(?JX`; zI}UM|wCH>tH8|Lkjn5sms#hc*WEQrCc>CTmel{M>sTD1vl8r|M)zTf9@os})bn-s1 z6Az(2D?gD6m(Lj7H34;06FKJzpGdCf9Hzb|lDLZpb0*P$dDl@8j+PwHCjMB$R7aIl z+3E4%VL1WiGXCJn`d0Yt*Uzz2Q7APl84i{HfT^3bLA_xX{5N?Slq?B=XuTow%OMf= zJe>wXL=DDIa-bsY6kB&B6FXOS!ts^T+yhx-%$hJ6!-e_ya_BvbZ?EE0F3O-rwgiT%S8>(3%P?f>0(j`$ zfo|L5$l@@e(^)=?gUVXwBTKNOKno_Vh=F;J#8hI991%}jg40gqimNYC};RuyTVNH(LG%PVCuAy;=g{@1an* zvuUpD3;kYlfBy{F8Lo=A2K$pyH9FKVq#O3`xg*TwHQ|N8DOu3BkUJ67aWLC)^`mMC+Dpf zr_((0F?%W$-SUSsp3mV&#Xk5df86=I=N;CzbOD*Kwho%cWkOo5330g+M`Qz9F_Ca) zZexJ?F4Hl5o-F1y5{2YLNVbdWcuUIJBSx|M) z#gN{*6jib_sOg3l44t(EgFfYJ!`f2B=I-)qo9;*oiUYbpMQ*gMY%Bc?ISq3(FDw2d84Aq zLxDpl!7@9Rkk{fZ_{xm)N%Mq0=;CEOI;N4U-+K<0iYc&l@;G?9r;Y3D+X~;~2Jtb; zE|!1c7avmJ$5XP_v3K%$_-tkjSC%`<9mXybd8@>;Zx@boNwYUlVK;#*rTb!s_F7oJ z>pyW}ge(k{cA}aALSHd?63RROCVSUxq_ThF*}b9;t}?p{i|@xndH5!3AGVLXDKKcd zp#!6KdU8$AN;**J?51hO!u}Vl@kGcaYII+ht`hA-m%s#;s`3JkUziA)(+9xwmxVO! z?P@x1rGdbr%4dJiHj?ih-qbUv1;0)8fEf~_>C*V|JivDt56E7CSH4!_BZXpiyiA8Z zPWwZAOc><;K3t_;a|{cJ3VhKS0^4_A5NDhmgeyIAsnyS?prI)PA1mbOgcEn^1fxIT z(mEVV4|KcS{VK!Ce>bqv{jQ=>$FGqSU=8b%%DGzWUoJbr08hT^z^|*|MgL#!+dr^?yHgHxqT4Y+A4FeDI3sdiWAs%y~E?KGx6w` zZLFi^B^^Ff8uAS|7TCG4u8$$))KGa+7$Z%mc~{eI(L1QmhDxG!F-)}cYd)NfNr9^2 z&3J!lIvms=k4Mu^@KH0TkVLauTvv7*&&FDC%>p&H^WGuspL`B>z5U7+I-cXUH`my< zK0Q2JbdRk$Ncm95$IN`m6lyZHmYbd5&)vroEGQ1a?hQG3^xsoz9elT{P+;4)2HwU4 z@k*F#C`o#U?}p1KQdycr8m`q}BTBB06S$(^Sgv9`yu7vxKK3rf>d#SZbCwLevgv}` zdM~i9GLVNK_aYO$zQfa7AE@KzP7K;7bdQ{4sjr?POpywJ^H)-^{y!CTU!{O8*SfgN z#SP-q_Xj}2gd0HGDshU59+$9GA*g2mDLriAMH+;F>A${$n?ByHTTYZ|q7wc4ieH|4&0K z9;S>brejcZWFiWL8`#qI8%AcAV(Eok2<;1lBlD->DtUK2vH1}Bwt5C0wDH2Yd3too z$7j^T^etX_uFXUHmhj=G7r1%*bFO&pB&K~@1l8|0LUZdesQso(Rc?fkHk}eoYKexq zk=lZbR$$mCwulei(FCvYXTZ0$3=>1FVBU=gOzIKiIzw&Do2?D=Chdd#m7if1AHq`R zw{Y#f>bP&8Cs;+bihg_-iDT7l#l^u+aJEeqHfQ{!Qojff4?8Z_ z*M0|4eov`oN63i`iD?HQc`6Q1rg`3(**hWZuOp$bNoLBn0BQfnOJ- zuD%EtI>y65ogOT59?JOTWn{K^NZ&6Yew3pn1p|H!r@1b5f^?uO%XLqL7tS$zH83D=Z+;n$gCdB}1ru0OX6viEF;_k*(G;q1-CDkuQ7 zv=ZQwOSD)it^=zrhN0^HI@mno5|kH@=7zQvXna`c%@nKwsa2P#N6mjghn11)+iFz9 zv6f11KMG!nd)Qv*c@P${T+|rg3C`yiLw|lUtjw52RqHjWuJi?}GOibn{yqj8Uwuh) zqZ5suE-;^#$1=xtk74NZ$Cz_tF>HU(CcF~q((r|fU|=y9Hx~s8yQ@I4d{{pmzdZn} zKRm{I{qKUyULGF&GKK?n3oyv;E0*QSvi3K^xm`4bj2iNqk5TEu%@IqWdHzd$Vo*ny zOZ$UNY$~7k^CF$(^j>scHHDWn55!ZJ9#r?rXRz^EM^Om~i>H=bzA$ht$32r=$z~df)ENZzO)B6$t0~fz1lZN!-{nJ}fA$2BrEoz6U zco@WQTBt*g9&VO)6z{s#4>#||RH<)~rM^wsxSI^-66rah{@NWEB;_$X=hZIjl0Nc< z4@^L^OcAa{S8@F{AGnFmA{JtLS@hX)Fx>136#sK6C!&<0bjIBe(D!2#Y9Ei|lI^uX z)B<6GQ#3fGZzH!W1~My8g$o}qN01jmepI0$oX&ogLroeMuv^A4d{EgOyth6D?<9`q z@;{~F!<>oOCa`O6%=`kO5R6WZ|L7!%p>VvjiR+kdf!PPo(Cis6=+qTDRK5NG8PuP6 zUE&4C>1kozg%xD4+d<&!|ETS~>(Dc=kVZTdSf9%8z$z$(+_u?3rMKRvgLfB;^Gl91 ziQoQkuJknAoMHoi=j~@nFV3-)&M*b4 zb5#Ym=mplaBTU>Q*IsqOW;7VYs~}kUu`>zt@YvcqY#93u{nQpwok^asPI(pTrCx(0 zUlzjGH!3vZL@(K;EI2s_|H7z3fuU1a&u#8Y!zX=Rk}<9X&wAv*pfpL0UONSMUG*33 zcy<={Kc7q_+7)r@Ra7A#EU3=47Wh~d3z_eKQk`>Y7~WHbZx0yL#5>J!aqUYy zupgjw#Cr^0Nz`%Gp zA;)bDpQ@jdxXSl*=CN=(af&yU8kkDPq#C(iw-&m7b=jyg>MTH093A#Y3dc9+vuD@u ziIk_8aJA`oA-+a|m1^I@dCO~HTU{$RzM;y!3;z>*qypE`wuz5_Gz}s#kjmScl(1*Zl|Z+SnNA z{#Qq3MySKfxt~F!?wDwbVF{}In8(PMwOCW8j;CjCgnMRzKvH_xm9EwJO)j6Mn>2!Z zVFvDXtKnw_t_)>aBxa-~i|(Dt2jou16oDCCOmoFWO%+fkWYP_u1QQ$iLipj@OC`eH zX~3KL)PU&1zpX%bq@AVP!u+5{shOR*6DGKr_fv_folshT4#gP@`QUr8u%toAWWDOc zV|rTbFinN~DT~F~u3HH;NTT*<<6)b>8+`rwlRJHR%WZun=$2oGY*Cs4AN1-RRha2d zRX+Js4XG2^Q}#Z$5p$Mq5u63tg4-@tEUn`NNrpXq zUVk(nyw@7qwBo7ss~~QrFolcqc7uw=QF!S+1HMjufddV5u;A@Sh&G9$IzQUcc7F#c zKKdITJ8P5591rO6`U0-~Vhr6Qyer&}$HLpjc;vu!h7Ce*!LoxUj;a8u>+<4+=)2H7 zB0zk$@d7vdu!c`kcfivT$#`!?m1s$zJ&pWP#N;oGfz_uzQ4945OttnSf9k72kmE7` z2@yiaxs^NTxkF-xH>fMz<-``mZm}QfU^RVc9)Fkotgt0`W+2x&pUrOAK4wP+SI^E1 z^52<0g5i7^1C`E>%q0&c}K|6Fn+0lmT zCkak9{lVl#`5#b_oP<>Zd*1Ji3^B|<%S~h6VyIjPlwJA>D?h9Oo32)LUhsj-oIk=9 z%l^YQ8Vzgef%Ko&!ySIVfIn~oEsrLyrY}$KrO3hQRY&N8U!BmB`h^V`egam1>tgRt z>hrrMo3MEN3#uf0h)dlVg>Qpuz%_O_#%q+Yj=fXRZO|IJ?ots^%dumVMiv1~{{!Ne z&fGde4sZHTrkX-VYG?Cy!JEDt+}e%lgkn4K*J@Mz6|)^?TLO%Vn9kB{KJzY@D^aS3tL2Mj9@#|KFXte@|v zfj$;g)Vqb6p*QT>p8+B3-O2Wxb&%3$HzkXMvlgY9(+Vek6%f8szY}@fWB&aTc_{G8U|`i=}-^CO2Mvr^@<<7`Jm5 zsjhND3H3mBv|}1>3Q}hgA473R%PBZMZ3Q%6eJA!L@sR`U1>H+UV)9|&05BCWlfe)ls3Y@bW@bc7e*cfu! z_2h?Ayp~vtQ8GE`HKvmq*eS88qE+IzJs-s_WxHK3IP3?3Jk1qvZ|CNtqp>k!2l;v^ zlgtnJfIn1?apN@|s4OWHmBbp;arVPu*@X@+TU$d1Pf4HyNgDoFd>ogZJ3_Ut9HztD zGpiDxUxWquE~Lt~6b5;o#>*zA;P`Yq8Y>rYzuYH0tjY$GUEJCHOk<>O|EO}h9cF(0 zfQNQWg8bk}YChvX>h}E~ypcSLZ>y(c#+MN0|G69zB$MEpz(^T-bP<%D-$bVd*i)q& zC+M)diyu}vYZ7;bYiiMu*0n6n!=pA`f?6=;GPV*_E%Z3<#Bkj!wUjT zRdn*}H)_>}*xqU6TWs?^vG_zS2pSDFRFUq9FL-{fQz#cb=t4W4LV|Zh4?CpU3}ONb%9HvaYB5|;VIcmcd$E4g0cFY zF^m5D0($QWUDvDx?tSVV?ikeqnR`{a+_ZOiqbx`0Dg6TF+mqqQho|@{=@rCP7_pN% zrg-B|8uF08aP0Rp>M4BJuh~k7zAFSX&vEDAq0Uk?d1}j3(v7NO>_VB(iB9x>JqOPE z{s7(YdC;ahmwE>YoLNmPoJtF6_WG5mpmYmg%$v*xofg5Al3?=ecN$#ww5KDxbp&3g zJbAzIIS$o##nZhvL25}Dxt?vu&FW{9(j7HW_@6%vowbXHRm=g_G6&{e31#W2ArNmb zxIzAV1y2&z!IsClu-T{qEjB$wS(PL>@lJ{!&v7Q#E1T)^2Ycxfw=LA)c_dsPoJdE# zeE=DntLU&q8F=bFnX5+4#DfM;K>ZHKw#5sbi)O5Z+h^xf#dXPa+_E{MedFUrN`1%R zpfWOluj#1y=Pe#Ns){$qJ7av}MymZ-$lqFeL)pP_=zmf{=g91*W4eX;j>Z}4=JpY{ z=$_^7r4!JH`vgZio+)e9HBK?!u{0(Y@M$2Vd;Z$ro<;|)~P7+=zogF zOQwrXHf_MrKMwFS?k#v3C1L#eeT2CWMCYC`OzIwkd#26d%BE9T;^jhky!j8=Z9E*y z$EtA&@o&uAH-@pVT72ZeCEWViaJW0O9Fxp_LB?@9xSQDEjos0r!%d$=@1_)>cavKa0@|%H{tKm$z2bDfpj87%4F(c#`s&7d`!Cne3wjZ$D^agAm-c5C1Zz212 zr15dMJGZe)!0uUExVboqrhXa;c5@5oyZ2{Q{vs2;rp zuQ|jao&ErvSEa!8Rd(X)cMI5NgML=#yNWABMd9r;gYl@Dz~bEF$~C7baXHITWY@f2 zs4-g!i{q-f$(bAIE^~}ZEi?p^7%!@$*iYvqMKVXN22KOh-W4>tvJf9En1_7K4=8!Ei6u=W#9JJuM%sbdUQXPFXB%wLG0H%JO2>p8wf?9J3WZN%>M+4RQfJ6oEuvQP3 zZruldpY@ngW&l3DdKed^ZKYa{(=oxz8fS*ih3bU4>`u#G^mIh@ZoQ8WCM;o3b$dxo zMHsfMy9tJaI#_9qDpZ%P0d-e5_PkFDRPX4a)ZSrO6u*_!E*QgumQ~}aBlXm+K_3R# z#p15eRIWRDE;l&X3=@x^M!SatvGjN&WQwGrEn^oZsSvU%V6aGQ(_Cunx(4p<8N?$M z`(RXZ6iK|VhYeDd?o0M1KMf;ZDsU&k z`B~9Xj&y|*DqT>-ef^oZMI|g{gW!v>*^T?}27qM(;WAnkFgnNw zB(6xK5i#WI>$VfE0h}%sJ>lkl!oGD_Eh_Jf;<_UQ?A9%9a^Gs6(4#*M_uf&sRv`F+ zq+dhVXgPA{&UvEQAUHY%8s@FTF;G=CfUdpr2tLm0MsKsRkli^0T$LU{sdfN}Wyhkv z!Fbr8bpm%6ZN+N6U*adhnRx1Z1`O=@k8ZbqgL@tXh*;BGu6k7!A8&gN5u=`P3H43v zSI8N7yK0vByuKt?%{@X21GOPh|2N2)YSWAT(bR8c23~AFDUx!SO1HkOqYCG;NQBx* z9v#($9ST3G%^W%Gd3>GGEWx!9oX7s?*unb&=479+qdUclnAqemg2ZFI|LlkOtI(6< zbt9<5(&tcd`Uie8ONIU^u2lAjF%JmKBXvt&K;UEtlzA1xWxWGnz``L_J06dN%0+3Q zTx5z0VY67pj|~{q>c^Gr5$o&P;T+nbTiZn3=db|2m=r_BlyP8sITh91Y@jygCwR4m zpw*@|D1IJHm2z%SnUO(kuUZf5?fEVaJ)ek~zw+@*^Ia4Z9WvhNI8|EIU-hL{pG}%Q zmIapOk)AtqVNvf>Qa#^Vq6E;vL36;Y`;?*3wbPH@rK>KEGQ;M{H@QD!M&r z!DnIU85?eN+nB{9rvnd+BYhKVP-e_<2!%=Tvz_t5Q-+8~{a#21izm9&{8oiNECl%% zQ*mozGz_O^?56TO-2AZ!H&?iV%YjeuV9qR1-&D{ESpfD0v~U@oh(%_F zxV`)}YFn*`*v!3f+tLu89EyYKJvz+qUl=5RM?#6gM2Nh4imPqPc>+&K=v zy9unr!jZT{(~lVH)e5^4ZxS7B2kYJ#!HM(|ux?2N-+Ccaf6IYoN+02>rm}4LE@593 zI)Jafe-JHv22e-^G;F5DQsMdFn$_JiD@T%HLJmMJzx;}gGxJes}t`T7`vzi)RVTe&mQJ8Eii$P;0qqa7OAp9pT{yO@({-qU#;v>WoZk^uC9C z`^>|$ORvC#iZF1a zo*{uLAH?kMwr6CgZ5_x?84bPel@K^P80s#>z=&%vakxehBz2~PWW+N%R+Ixa$(nS~ zK{*(={S{AY>%fMYONd6!2kIE3~_ z-`WbE_@#K}OgmAnGCfq_RDPf#$c>O9wUP$VIbEKZ$&7(_ap{;n;0Zq>pNgKs zt9g3Ea&VX)itY~umgJCIeD5=9jM55)&j+uP>9vN+K^~(cvQ~vtkw8RJ}p%w|B#V9pM7s zYBf1nw2;crc+UR(h{P{v_M&B$BOP(M61CwoUg@}ua$COOugNjcq^d^8CT_>>BrbkG za3WlL=!N|fMmVVTBD{+w8lzJ0f(y%8z*=t~$&!tMs z+XM0aMm;)wnLAw+yc4q*7h`#(51u%;1j-j|f(Z-!`9RStC^)v29>_9>he8k5VrLy@ zslLHU+4WTcHd&}!nM{Uiq|qV%m(bo~Bf3m(AcbvfFze?St~22&uC}}*-g>{4R8LqZ zjheKq&_`Nm+)@Gc6dCjggaMb{+-xiFb8_Gha^(_<+9Yw}CouEd(rf?ya z!oBK6ba+7|`!-08NF0*oc5_nkkKmB;IzElPA3K)1d`O}T-i^m8gM+#1(~&GX*#x{z zZxHdy;jmy$4tzQy?51a4gpC5{X_eCpl-%9R&HYbf&_8ST_WWl!7Jrf~#|p^(YCyFN zQnMqwPi-wU4pAvR$ z1lrhVLTarNxjM^|Xce}x$7c?RTGtlAvD5^((>{g7@F86E&>6p_?jcGYSIOIZFRS_= zPa`u!6`zQVo{|BBm z* zgIY|>z@cNJyj6VAu$~7a|l{Fs)#%LPXbxsmDnf+rSFFR4I^D*?4wc|Hmfzdzi zIFv76L>5_hW2`iC=_@B8bIAoVwkd)SJvtfYZkqzls++L>{U}<#erM(8bwhC8(^DkT zs}UzUm_vqwF1vrR2%-j^qiWul$+*0W$Rk_1`P$voTG5kBI#RAzUxo2{?#LFOOS9($b4moI_0{-x9>)}1QtnvK;*Evp9jFXK9WzsSxHM%?yN zHTmbYlG?4(6Q7)Ym9@MYOu9WegvDKh;tkd0$(S1W)~5zhGpN|_{wi$oy3Hr(mSM2= zEU2`6N`C)L$5)Lp^v1 z7~GmS3Enwp2%SJhR=(^u+?_p()hKSnw$BM9y><#z-m1Xey^gGHlONSp6ekJZ|q z_-vmA6n7oxvO!IJ)6o)AYq=E4iZ6@)d-xyk9=?7ksrige(JXdC7=n~kQ465cd(8}&=r zZQlx~ieI9$;N)vNc^`h?x(t(M_kjJ8&wQq^C+tNxE_JDjhresa)7r1Ot;S;f{qQZ_ zwKI~&#n`)CII#@o{<#k7Uruq!kVI0l{y!$Z%vooh4&fIrkrP9bswS*a5%u4G4S7N9 z$#+{-A|cL)jFkKMQ2k`p;FZO!dDMKWG{%>3%Op&{7zLvQX5jV#`&o8hBz{xO!r}Bi z?rl}U-9Ndwq~8#71a3|JS6e!$OA_k*)Is)ukbx0qbOAw&xn4yr?mtVJ%FLnA7_BF| z_0fus9~lizwq_V0e-4vMA8|5Ip1m9&!-mwG;ST8#=v#V@D%-r|TE?fDLvK1XxXp*I z+zp^?Hyi(4dn&y9Oaf5VL+>pfEN83%>>#i3a?n*iaQS+wJ8p&OW^*%nWI7zi9a$*O zn-K_CtvH$bCIpg0c2R|6=5WAp3Q2pRhA{_%$fWSIaIRbEhGaJr37v|9ZBHzW`0m34CY%wcFMG`H zc+KbQP7gp0sr798v`=J|qZKt*L9y|DQ$CYL!J7H%pb#sJN_V&5;++4u>6k2>`TQhK ze%-+hr0X!WjzIedU8=pw4EAa~W%&_yq!A0aPw90`?4C$!+Y0arlSQq6cc~e@fp2y( zaq;HWB*gd(I_Kw*uIM=^9$V@@b_!us&p-ojYt6+L9bGDlvWK9EkHYzSi*Hdj=919|$rk0c(3V>y-f7i` z6I2njFSQB|%FU=L`;}=)bcvGPbHO_<5AM4VuuB|{lix>*2V16M{Pur1t^bbbrRNSf z=hBLq5jj}?L4skej6VB~t4a->7i6P8l z#Co#hc0<*h)~{sIpgnv@v<{IiOyTl-&XC0^igcxhz$UNULnSwiq428!?vy_g@Obm;9oYJI1XS-;kYmw|n-Sh5EX?=z(G?;gPX z{8;RIZGwIdL*aq61na!oEz+74hzU}^`HY*q~(9 zD|+$>J8|2f{ICe1B9u;1%XET!I$gDtnyq=#Sz9NxBo|rk}3ja2o zbxjYnP6R{X`e>>w7Q;*V6>zDd6DFSg3VSx}rdF+s;prJm_}tfs_JPV=wfZ>GJf_DI zXa#14#DL_v!x(Pxjk=uw0&|v3!x0X5ai8Nl*qRzZZJvy$b}18~cxM`^jJS>46ejRt z(?^rcq&j%L`3)Z0H3C%>C&6B+FR(A;f@pG$A?EbP;p*fBS5*T^a&6igI9>RgOI2Ry zPQ7u|!s!DzO^(8xH>cRjfrt3EVk6AmZwyhotLPX6IMgMJH*Jo>QeoDWzF`EFQfHW9<5PiSmFL7xmYu_g16;7QAYSN@cVV$dAJ?5< z3Ks|7Wnq_8F{S7^>^)D&V^e3c+A{W(OX$P`!3($kI0T%j zpq8fsu;6GM3w8Iv={qlT$16{;bHI7jx9DYuCwIbxeF3mPB};UpOB!%=KC~~af#<$W zXqogw)c>)Q?Y8WpGM!_n2z?751&h1;ThSrwI7JqY*gzIx%z z1}mtZcG9JC?NS!tAowRX)binZC-Gv9I;-gYM-tu-#yg{%@n`g8Ox>b{J7S7a`auEK zWq-qW*?RERN(>M0heL{-CTcg%;cMMPxW;iexPqTxZ$f6(+o+Yoo^B7>b#*zUeU?M@ zq^B5G8prNT_M^MbucZ-lMo{HVo^UtB7xK@qKsT>V_`rWC>K#($W+OaB*;RtW)OiC} z{CR{+w2wmdA9GmM>_muM9|Mczw&Q~YJHB`H9SqI12L9WO1TL<}yLnRh>+vjVC`y1` zhE>24jkxoV?_6!7D_2N7j`2hFm~*%^Ybv-5x>gf$#+#=YKi3q7S$B|QU(e!&d4j9M zzy{K1rLYX4zfOyJ+dbl=<=QTconoa*`zyuA5dh1VJH8#PVmNK@j^Ae5> z+DrhBY{`pXjHzf>`= z{%)?fV5_K5m|?2N{e>w1yENGInaeoG-%zMCo=zw{LRGh2W-Uv9&_thJ_!St6ha6u9%5MOCj2negn?7>O!;;)EX#o+lUUb?}gw5+c`4Ih1JlJzkERi(@7e+pTH$lIt zW9$k%Y(aVIm7(yrqYpav#+HjGzDJ<< zy7M&pLNJvr8jkndH*s^8Om1CpV>Rsyh{a+R4C)!l^gIQAi*1~^WVA3t4%UO~?|oUV zUnBl~C@>v*ZVPk8(XiCEj||(tn9saa#a>+Sg-_SJxaEjaE+w%F&n9kR1q+;T&pK-) zLOxFVc?Z6K5aH6lHGt*}*$CAxWjNxIF8TCh0qpp6i_|(HY03-7u`5(X$94>Xj$au< zPGJeYoz9qh7!vkfgC8ha&hAE6(PeL1@Wi#nc(H%9z@)Z?Pq#csvC4EVE%Yw#UWp(n zE%&Iz@klyW>m4S&zX(+$Mv!vQ!u{sw*r$(I*p|Oz(Q&*2KK-~3H#T%|$?{*M9J2fjcMT%p+<%dv;=M{>a5*xu=_Ph$KnaiA zXh?}h5w#m}9@>?&sA=e>UKV)vcnV0r%L^ z91W4bjXHX0l)&uUHv!o-&WdPuc&dJpwda6^*`a#lRNpq zCtTR2&d0mFl?_7=87k?@$wvNyl(zWTq{9wlJ*q5?79+{j9AC5CkV_Vm5n5& zvJ84;FGJSD0pi*R^DwF`)dc9{L5os)NKn?9Hfwf)-^uEW{P$5^>4k!s82nXieg=wCfNb3h}d^XL42OM{Tou89H!a+g&>2D0MD$XY77O<{mdvNdu zSx%M@;VL5^P-icsy4j7$MxUT#BcDY3AMZ1;Ngy6 zTvl+)E&O~PlVVQ7-qr+G-qi)`)@0$L4RcYg^c;IJ;~c&ld5OH-wpUCp2tLFM?a2Dl zVBf}6$ZZ$A3}%0rai=U(l^=?``b}_F_+;w%qlFtfeZmrP7`P;7Kyv+fVcs(ud)l6{ zC#A21`+-09%^tu9j$Fuk-pYWga1Yp%NYIQ1@I^B|;GNHB(DSVipKxhCq;FaST~#6Y zxvQF5n|#NSRXZTEbTq8HmxGHWx4^|*My35?M8?xT;a{_MwoAwp-Z6a4b-k-e`|dMT zrde<+31hXOrW#fw@Skm>XaRG4pDuMnnsoWEZHc!yg#;RLIm7`G_^2m!ip)<@o-P_b^o%)DHg|OG)hTXK--E3qHthFe}N3LdD^$*sX?V zsCTmg`(x(|{ZDf`Smk#E)#9-hQ(ToN$-_n z_*Au8Y%yRsb!b+iQgi0uo%c^k+1vos(~&2a*IkF-U7nDv7L1*@hvBdFM{&$G1NOIL z4s;j~ggS>yLPzurT!#O^Xt?_7kR}toI*h#X+SmCK&B}_L7z=6*zaF6n5DEWSl$_#NPKfleR z?yuj_n6L+Mz;BtbE1S;;8HV%GBkUoez6e}R9+SGsmqit0!|`BmAi}EyT(f8!5Be(j z9C9PbnaRt!uHkL0n*0IpEfBGowlgqCXAHhR;LBc?OckD&A-HgPH&#vLps_2w>d{wU zEK#n-#H|1e4q9`)!_jycCqTexE95%Dy~6Ab4Zd*|XJ&on(!QypW1*|)&JF*;!q;!H zI5SSv{ht+|xTJwQE5vhOvq3Ou52bcH6=1YeC*=8QlcVd~XyO}7I{tSMJCL{)J!ka7 z{j9T48k&O#w!RYwWh{aU=QJ+Y_zkb048*x+hluy|&FtmPKumZ1hY&TFDoMoM8m-w=s-UUI_^Cc{OabU zOf%i}NXcZj`@|}o?c27$9 zP6AuwnUFOuvJ}Dm70$pfydy@TgUIr$FoksmTB zWtT=B#08KNQ-c3|C^r_30NW9uED*ew`y z3T8n1lhxE?!)memV} zKUa?Ng>mN=v=R}B{ zG|VvTUl3c=xCzsBAH(4#@8J&$T$r+zxOw_>VFzH1$^YF2wTEj+%=)F|;qNsdX*YtL zDHiw(CSfE=O&dbx1MpGaZqc-pc69u^57fbLfavYgiFC(&RVwtTsp$F)I&6pw$emk8 z6;5VDalQ){Hl>Ic4QrqhS9W8foG+g$utryDjYGH7*KuiLE^$)Qgys#G#T$m*qb7of zOwp!>Yr1SDrrP0fQ+5taWy|4~^Az~^Dvd~931gX4<)HFXITZYugm+Gl!oqzh5B+i#4uqByORs^DWqMH9v8AwX*^Mxz|B>kBngMWR)IE6MHx;Cg zzG6p){T8hm)kZCB!{`WZLG>oShvmZlI`q^_R+{~TW#4HfH^@2QS-kzcRL-;FR6)sRuZs3 zcnSJ%2~G=*9N3jN0gvRa=9;A@Z1Y(MrZY{*>7AVca-*u@kiR53lcr4dt_%J0kC|X2 zxM$SItz}^)=c#STBpPtiM0C||IGZ%Ih7`@w=kv$i;c}C*@VfU8)O;(6t-*H%HefS# z`+QZHE#Ki@_fNwGt7B}3v@DYo{5z2<)>PlvlH_$}q4qm(K2I$R_Dvo}Cslk0)wlW7 zeyLgr{>CYQWow9G z_Ew?y@e;PrrPOS;99&kf!?%8RV1C;f+BSa21-G=tn^Wtl&&qx}Hq!wTl7RF+J`5gS zvS`=+hKv?|&x!|xd0Ee9@O`rjhxNH_`e+TQpbg4-@Okm@06j|RffU~TRMGvBKfssE!k(4jHlNsafQ5W zE-^`9w)@P42a48^<8YTrj`n6oLy}p+vA-~66eI0@QmA!05B54wq=Rc_kcNr*@RRjZ zt7TSXi%%L{7Z^fc6yMYTDsret>p?OoRg;Xrwh4l=f;o8?0H)tJ!2Wr<+|x^N4Sr0_xz|5q@v%i~I6H6Zc9qDp3d1+J~#4n>}}u&rx7 zR!p|X{8j((t!pKgx6Xm$ykPJjw+8!CgAgXCh`ax+!yzNYSfBa`U%cN9Nr76R5r zY(j+65TZ|#WMxFc@BMH59*=MTLFc}Eciz|adOe?!_+nrkpMJUoFU4J_W-HpE%z<9PH{m>#XW@HHn~(ub~%3GcKzkTZg3487p^q{_XQ`|FF#RZl7;wg%*4v$p*VEHSB>4xP(Zkkh$n)d~d=I;XX zc7#94-8~k+ERjH^IqE=l{kYb_a%^0+3~wFt#POGRgUW<{jD2gu4vbghp+}D5`M7ha zerYY+v!@JV8e3tn%~iUMUI3}`msl)~u-m(oyX}9;9RHc4;p#p_3Us zKlhMQu?5_^T7aYLmkBe~3~oGb3?J)h43{3X(<2J^A?!vdZsgnGVN5(T7P2wr6FQ)C z)I|6+W30Xx;8gzWP&~gMnPqg>c2@Xc`a} zL=UXli<#%t`E2(wIBm2eRf$TVKI1>a;SnE1B^Q+_5ih4w?*~xHut{{-%WinMSC16QV5Kg}1YcTZ(ML-#FOU zYEDc~je&(7E^vNUA{I)@35@-NIK%uAnSNs$9kkmId=^b(BX~Gee>^Ly4n0Bl7H_62 z&Z?ugStGTS{6$LqJH_r7hC}wJIGTB2G0l;9!sn7^gqsgQqR5CHn`(i&D*+QOL}SJ8 zP$=tv2Ty+sS?94GQ1+=BiiQnh$0quLLpY@-=8?5sWMAn!!l# zAXNl+xKkI+&Sz>6wV9#d6{N}v?azzM6nD{?PE7COQ2EvWLJy4O~ z3imrrSwW@^-uf~OY2q0$P?>-V)pe*nb|VhudoXeGITBKB0XuINlX-(5@x=8TLAxT2 zE-qOI9XXj`J5Z4tDQ8iq3Cqdv(cO5>D;^TAWmA(A9^jl9jndo!*X*{(>%~_wMDMBS zc<>H%Jfp&P+g(PTO+}b%at;c{Md2A5-N_pIW&MP-J6L&3!RYe z@g!rih56dUa5QNadPI|C(L6MTWl97S$>!sD@$EM7Y@P>apI?LZTUK*J)c|p?@;IE^ zznBk7h!wp(FUif6|A}q=8t`Dy4$wR8K}SuDg_-u>u-1MOXA?drbm9_F+-1hD?)p+$uG$Qn54;g4iT3jKHRFXo|52PE-hv0$+z>beliBV1EWB4& zjWNrOK<3_fTy;?c?FSA*jn={Vqva2m|J+NB-{*qCSxa*AW0O%S*p!hb@VvlyC>imC5=b1FaD0gvK6G#T_4 zGTl_*+sx1S^o78w`; zGEZKJ?+c$t-G6;hI;s(p8wy45rB}jT??Q6%kqzmVl_iwMDAwDXD$e~TNuC81 z(CEv{f$iSM9d^{ioG^codX)@~{WrMYqiDWs?kVPeXECWxS0fj4B}JC*)dKHuAJ_V{ z7e_raV;Lic!oc}9@N9D$HJf}I&Q|z?L!t?m&&uPfYG!4lm7YM9#6B;GiukuJ2dqNcM= zAg%yvc*Kmq(XRYi_5f_X$$5Ds2V8@sM zQmQ`zRP^GUuDA%j>L^n#jdS4p21}51c@K++3jBwQ_1tXgLX0a&#g$76F)c{oUA(6J zp!*`s=Y440{+L_pIaMBRyNdy*t?<&*6dajGA?m)8)9$*ztm9@f+>ntYH)qA;rCBam z{@PCrB~4spw+qi3e&XX4q(NitVdVBfc-Ed000t6)R$adI=Z7t`~sV6NLra&}%QTrC+6{tC71$n)1+fPO)YS}%CG zK5+^$Rj1pq48FDQqHEr^Q27>rA-8J9qz}!4s*>%(v!|8qtM|u>0vWD&CYtpASVFAx z1%}V*FEH@RG)UIyfQ2vK($H_SNTq%=?oRB43vu6JP~B6EZPPOd6@;Z?nle2{~1{~9`zoiVO}#Dv?dtG$&gUKqxHoYDla@u#XsYPeBc6rVS# z5(D2X$3iHj37x5szPX0Z8`BL_eo3QWrWodA$Ya6iMd*RoaF+L6T$`;yI`5yvmG2*O zl?566=!Uu6eUvb()h|Zl1-)?Yay7}<2WaT8f~4jcK1)Xp-ezb}!!v&|;p{IyvDN1GY{9^36tUC#XG{4_3}7u z)<;+{{5J6MVr(4a!sW(ivy90*g}gv5_qpVV{`VMN@H$--o3C*x|9mmEd~J)vR(GRf z&O4A^S`U4T=hMl{9E9)6AJ#42%tdKSF#2{Pq|4+$LC7-5ZLFhyJFTF(a0?cNjbl-R zRxtXdv(o(f4RRwgiF_EN1mRhy;r*Z@oPY8nUY&oEM4s1XFXG;?KPrFV3-_Ti0qu~X zF9BBfpTHmEA8`8EVQ#NAfIB?BNz&z{sHxUg@%>L;7(DYKY|Z_SJC=>YeV_k9J(iLk zhwpQlgEL5J&viJtsSUf9PZf`-NF%?F9LIWK;3*k zH~DgmbtsmJoi!KYl-N@+WB6W{v%M6K1s36}S_gQf5fA%UBvIx3{!K$?L}~);t+WR*F@&~YgwDeW3Ft~fvZM&=l>QaV9RkJQ#;?VIt)rQd!7##}NZ^SpH!$^53c7i?z(5auIKMy-$JTlB zB|nAc&dQbGvdo_@B%j6IX5J8S&K7d+pAq}MT};h-*U~8!i!m$kCFVr7KthnKz*AdI z{4R#Wq2fDS%VP!)$=Qx&^PITllj~eIv4}+vxXI>>6yX{28r(;R6K%g$7$5h6x*m1L zlI^egwE4%${eTLQvF1U1u;ed@{l1D~R|);5o)kL#j2x8H-K@jzG~B=4CCnvax#zk$ zd`ZehG~X`9nLiL4&ZWU(Q(ZW8PS~x<^s?F;FSvC17#wdu9NU|Bk+nZ2!pZr&$)Jc_ zJoc|k$m3eWRONrT?^Fx3Xh{+{E2|+hLXx$OTQ1BeG^z5W^{{``H+FKpIlOs&h)P7M z!jfzcsMLQ;Wft||Pd7U>*H(t*U&CPAXJabs@C(i*ltG!qAHL{~AL5X5EZX+Az+C9|D*h-=rkqrbumDrK|~ zM_;KB#lBxj>FWDrMt>|^C=I4Ya;eZeUGQ0&)Ny^&$#njBdpcxe5NaI`BRf7#!s4Jx z82NDzG^q|3%QYopif9s>)DwobDtGZ$x;?6{Y^6$9)ro~~BAF4D0rjhtKx+;mlU&+Zib`H8WO2<7cS!!*6`z@)2)027t@F_rfkHnGaAN$R+Zhvg$f{LcbW|jHI7vXRnL?lb(>@ z%C+cEZ9GeF1|KHWU$ziOTXGm=N*~}DSA`L{x~o8J*A&2l}#4) zZ*gazf8K=D&6`QgV?%L-&U$WXnuIb>^O!~HEO^!*LhU;~(6AR$bo5gY-x1s%29E^C zN}ef~eB}Y!rx!rA`g``Gdq3Dox-7!n#bP_;uIEdEkBm+ZX;8y3vN8ixu9 zdpQ#J=#Iqs4?C$8=s0@$g5acffsOq>J?Z~tLV*CwkVa3*(i&{19D*X;oVrhF9A)3 zj?ksl*`lzX$6U(x5}&j!g~@8K$IQi}se;x%x?XPy%7F(PXHKrH zx}ZY`e!T$qLLZ5XB_p^`s0LRho^-^oPG}Fg#6k}pfdd^QsO{1?u2gw~5Ale{Ald0` ztHUa=$O?ef+ka4Vzd*L`wUB?USjDBi>(KAoc4%3c4CrZ1($7i2h@<{g#VwB;cewE_ z>O;xPFGBWoWe2x4mxS!KF6_^SB5{NI74T7)q0SA-0vn_hx9C)(`$K!;=W!W_+`a|k zhp)Lq<{=@EIg!N#%b{!ROOTaSg@&Os;jQ34(o&m;6&|>XvErEB&zi$rd$_x&)uUi{m^&NW~<=#f@7>$!ks0^=wMD_InH~y zjml*;fs?%ySI>Hc^S4|i%T#A@+2Plz>*p6VOgasp)K4L0_c9>*&R_gC@h5tftPq_a zmxvX+e4TC`)5gCS2V!!oF1kH=k2TYMSj0n1Sn^N~&IhZo%ppTrkC{D$zbb@c{7mM) zy$7k9TLtDt08Xhj1+PC7sM=vw$erHF?yXbeW-&%A4N}GX+m>>p!&=yVWGS^!3Zyb` z(h=Tvi%VRi+0s#;$&qgq$KTE3GJ?k}OFn|kSS1T%mL#$VG6{UDe*-@}xg2k6jRFm+ zA}9-pg5g^W#hJ+>?%zIx-HP}F=N;aVJdIp9u)>9D(KL+hZsfYH>-g5v*C=E07h_(m zYunJe( zGMRK}xeC33GnJQhJ8__P6~^xKfS`nPIBQ`kj+Nh7X?Vno-R+n`^ex=Dr_DDEl@U|R zMlF1EKZ9pC2|L>Q8`N2IH0;Pt!L-ghd|-VmMlLPDuH0De1_SZVnK%4I)J=@t?n#yZ z+#s%%_i?&{0o?hS0uA-?r02sxYGs{BbaN$G?B)|rWzojiS2_c2cTJ>mGJ=07FhVrC zdM*2RZV1+%;)bv)T)Xf&$=9RuVWY zip0alkHGJCym(Fd3wHHNF3~qvCTD`Iu~?>;+m}y-{Zro1K_evjg7#sYcKR@neiL#_ z^)~#cU?qHysxj;HM;v-S0bPbQLvG-4xOI6u6y1!6;OS+Uwt{e}*G_P(DUXDDmtfLz zhHAA!#_^mPB@OCSEx%h7GhrxNR?cOMx38i_SDNW&!yL3y^@U*|Fu@u#V9OCl>R7rE z4R13q&9buN?j29Nw)&HhDqlGKncnBV=J zD6RYh+b>QczHPghwa|%?tuBNQ%dXKe8(OI)@rLNvi?DLfL3FA0q?1BoEA|R^++U{d zSY&&a8$1)Z6wft5?Mw$OpE!mt*eT@nthLG9j7U6avyJ&Q{1kE|efVk0cT`zK$TGDd zu;-y7>pmJPzUte;Wi(z>jrEdrQvX+WZ%IE|W|d)zuQ9n1dk$42E^vvTr=TS06GW_x z!PFZ=gud24*3ebRD(j;vyt0LNk zq89C!IH`L*OW0b8qc4pmO)f%rb>1`5@nt7DJ@6QvdS@=lx6I^*U;pt#QC56t;vV4{ z=D;Va4#azJ-=kH`ER1n{4!HxeVZobUxF>$JQ;PgL{IKx>u2dl+3taD>aCDdqT+&$@Gw1{!TDPBD+kM6HaZyxi&mugdy@Lh28^P?v zdo1R+(9xGxgXoq@>KGykWhr;KsOA^38<7o~^bw=l#k$m4-%0b~Gu|h3>lIl`>FlSz&^BrY6xJQb?TVlGP7j)?qWVv zJ(snw0MafwnblU@1hp$q(WGZM%ldB|1}qE*dHr%+Q2CB;c36PlGY>;>`6EbL5{H$p z3z*M^G`2ta2snkqr6UUjhT#r@$L3l}$aYV7a%UXXS-1n{kMxGZ&B|yr_au}Q1yF-! zc_LZ&^`N#v5A_ZV1KYF9;8$Kb^sU@O)dB~=_b_L;TIE8rR6?=D$(Tvcp3US0E~;4R z1b(oc#jZ}!C4V+rqTyf#s(Iu%+-P=%*WO{353ZY|qrLh`pw;AEr7e_Gur9axG z+M>;ocMxE8g56>l<1hG3R^Ne z7#^5;LB+Ek(wALMzW6zCM}>0kBA!SWb@@|k|0?$EKM@~xqma*G9M=p@;c{!|;n8cu zU{YK!m)UQR+j7G2`!ErX;{8hSQc> zFqJEATqny7_Z^Xkdv%r6bz>gYTr-jVc;e2rR)+K4ul%`(yAc+BmcS)Nw@|mKjPLMS zFFrTHO?am-#HSXFd{4QF_*z}gXt;1uwdPKI1B~2XLcYL z<*ei?oiXFnRFuopc4os@ez;i2DS;6?pC8u@Sx`8f3pcfI%rH*Xul4FZPX^@wqh z;`kV!Mp<*y6O-8E-6PRl(FgZS3?uo0&XBRwks2mHCVOObS^ljDL~ffe z)cI80d|sAZtF?l2MqVJ;2XLMJIIOp*K`HH@qTruqkoG{x44DliyEVe_>ZBoL;cXLW z>C|+p+U^f;POTP2B-(=dQz5&dJP^N0ZG_}Md)YfV4>rZvlWI;X69*4A4^?>?Bc*fHuc?C$%JoOz%i+XP>oz4 zm^QP3R4X;oaZ;P2qRTf>e zgsK04Cf@r96PxG5mX!CRW7Gofzwl&ZCQo9ULj>1_*-&!4;+`n|qzY@K4)8Z^89Y{* z!sjfmL^xc-VW;)#}|Y_N=PduX44^c&=`I=a1z%wGy=X?4~J?5;;D?;WcQBk zR986zC0wJ?Qh7TclxM_DLN7AIe?}Nxy#n?>kRlefiA>_12@F#>Mb2I=#gNG-srQ&^ z@GrEJ8>`&sN>KuHmT!hTCH8P(RvR8!BMY{_zksIjj7zUb=Q{p&*l|mPYOj!_vZ|hZ z;?aS8v5yw=~vcl(t> z<53|mlK2v)1byN|?5^R^k0x}o_!LQ2b0u}3W7y;>W4gg+F!;OVqtC~qVx99ELRN7d zcqzGqpC`^=_k#3|X^?b#2>EZY zEhYw?=Sr&QVd7E?QNhT8IMpN!?;MNA?4ynX1alGT)D?QJgZjnCa-Lv$`2((8<^dT~ zJmIg>N%qh{$aCim;l$RDd<$BFv1>>2q_qREeO^9$#@FH`Dr80-`_RsEo@ku@RjxnG z#i?{=Jel6tk9#NPz^KccxrYBfZlgYlH2Doi<=C%$NOm-~9I9s<7M63XA6oEQV9MUk z_JjdGXK-VYD=!`VR=o3`4#polgZc_*AV7aL{5wcd(>)l~DtaKJLK$PF&VfvbH$-lq zfr$&exaH@$T-j$ixNFzJUypXNXV`nHoSjJLfXDQ0-e=>8eZ5(6GOV>%9^7LU;1{$Pq5MS4Wxb{qdQIi5u=8stol;zo5$NFCSpK zku+CJLE+F!@+nr2gctsRaq|mVVW|(+{tLtDhbOZ9j1fZCC5D|J>IUiMO6;nMzwn?V*-HkqYZ`(ww3dz`zrv#d#wAtyVn)f%MyHSwTC@yP(ah7HrBLl0C|uYj%gXCcrgAkNj$I36# zq`IErAt%yMS`DZED3J2*Czw3e8NN9OQ=7U-DBG}ERFH3m^O_%$6V*4-iFIPcg)%Ci zFa~;_-$D=nP*@tKOAgdTaLwg6G3QPbiGIIc#AlB~BYjoA^-4G|d?7F@e(Azor9rgV zax|6NpoNMl1kF2Txy;h_XcnVGl&=3J57v&rDRwWoM^h``_IU!#eZCixrTTtx=LR~g_$G{*J5=yk=mK#b46ZMSpt@TiItJcm6-D1fIoUz@ zWqk{%1OfD5?iT{k^0eTC|!_3)?%#rTrf!OMyrM8CnE1iY3P1-KitpU)8mp%2#?l!6l@ zHb6?ydyuy`!{u|r#51DSQ3I>%BsWhACSTSfcf7Xp#Iap`LeF@}-29t-8?<|JsMimsX)@@eruLQdv1cM@f_!Bybemt@*tFTG+3;P zGmTE+zMwWVjGc*x$|xPMyaV#)TCn$?bMSh?ZuVvJBdRl~0$@W1cAE_1R>H0&X-yCe zShz?Wxkc!Z{?r1G@-A)}k;CRRg^3@h!Pvrcx!f+M2wN*kP$6qOMm+a|b!u}Vc9j}a zwQ}LoVc$McPNa2sJw(iyjoAT*@yGDvRBve*wd+{I;)||h%ZT0VcKJhcbwCRX z_~;~J&&LY>YA$)D87}(iOTgsRXrRv?;Wx!`EaJ5<9&%5D@cZRBb?gEXWiSrCPy33WW*vn( zYh%#rOd!G2R&#}L2bQ8X3KsYrCgOW%q4U0?;2YP$9V0?TQAe7EF5o0QCsECfZL-Ly z9A&!RSPg1g-gBF?n^8eKSZs5CHI@EX2B8P~na&>@YIsS9j_qAUdf65BGp-O9eM{sr z-#DB&A>7dxRnTR@5p*xtWe*;1z*II84%Rq={k38u9Xb`>3;!oao~UO(-cs0jY(H0M zJI}{QW{~b9$#j;2B8bDE3QYb1h#`N8zJ}mrug)ZgPB)W6-&}B;^%WIIxAHa9Q<=!R z5JO(<=eusHunT@w4DM-TZA1{Leb|ETQo=jvyj*HHC7cd=Dm()`CSl>aFSvbY3ZH$% zjKyDnhA(RRiGI`}I5G7OT<00Eso5C6A3sV8e>QOy#Rl^HfwSO~O((x>KGK0X8$t2- z7*HGhr}DPqWtbOy9%3G6<7eUTb?)Rh-1$K}U$(;q_f0T>@Ht<}W`($fZnQ=&ye-xyR+qo^rp)FHlgt6*`Y6RQ~k!#)L0s(05QoH(gN&oga(v z>ZlsN!SW{`_+czLEq9oFSg(g^>iSOUPbfCrK8D5O^E}&r4VVAD9}hh~P98oPj>eBP z&_Jr5OD^5Woz>5Yhksa)H)p+rGG!t2>m837g>oRjVKvV0Z)7LlucasZ)>AdXc_bD8 zP9*zk1oZ!!%=)YL!PSP_FtjTl7qCx)-|7Z6y(tFS$%6mlle%cx<|r_z@u1fC%|$5z zx%k~(o|_MvgS{@NaklCiI_HNU>=inhWrkA(#&`*P}zM z22k~$;qWg&0`|YQ0^gJaaN$4U9YAL#G5@z5yyQl~&z`e*aM5W}Zs`V3eY?n$rvGry z;d^}TwddH@D1i%;mT>=bvfO#iZoH{uf(0iZvJtZ3=w4(=UR7ge1-CZJXS#)AlYMR;C>KBp8QR1@6N-eeY#+MeLJ_yi{ds+ zj_$dz40cq`=HAy*xkt@NJht#Dw3X&Vd2c&LKUso5{aT>H(hn9+d<4r=RoMGIui1;q zf;YJ1FSN~m#cmgk#zCDeuza01_FgyP_QW02s|>+ZSRH0$E{1}kd-1SmGUVp3V7XGy zAjwvl1V#)J_A^<`1ys1kR3V2v^Cc|k%0} zmUM3#OfVP0rt%hi@#7}jS)$EusE6XMtzKlt-m&EEX><14HV9+?9)OCjQ@DQJL~i~r z7uBa#;rT1G_&}$#Xg5yC1o(V$y5R7hYFZB!T?u|6_^6nW&8rdJFTag4-4R&OXwN!5 z`te;!Bz_wt%!n!;(g@i@EPP)Sqq_o0#jawMzq^{$ed#1$gv{`|{A%l z?NBsZ4kW%V!p37CF=nWSkV|rc-kG{Ue+R(Xo-T-QEvDAq-gs8s5i32;KJ81t&3g4fX=aObFc!HL&Bm35ogtTL@HE%0s z-MfjyhE|CyGR&Qp&YgsTukygJJs5tT-N+2wexvO_S30Eg4Er0llPk7KK+fh_uqMkE z<`-Y)iQeD&8nc5W#lw@!p1gwZM>c{L*M=OG5Y_{P|?9DrMM^jEB5rfWJ^Ckf_*P{VCAj_f~D#m zt|K4u!;zuobd*F3zq^DfR7m|y<=hS<5?0v}!a#~#+5!lRmJsB?dlz=My% z^olv$^!7n4Xs>~z|0%-Y5e<-BD7>58sX*V+7VMK#HhU+``9}`)r>3#-grp!ch+0g5JVnhv9hm`+AVP{)H}jDLgCe7P2OhSS;_>i4}s2U}SwN zjTn9s${b{o9eTx2o4(;@-FXnn^I-O`tHzej?TgIiBB)>To{56bOpD>(H^*GpUFc#k|5Pj77i!G<1@E)`2Ouyaq;Yjc+=q> ziFfgbgPX3>@hgGO{22sKR&|4~(0jj|+r<8|9BeIf7d<*)RGR4Ek~O6G<{$#hRi!( z;GE-Hl5=qm?*3@R?Sws(&AdPs{q+wW7ivd!ehLcJ_m>4n;YxgRfWyoE0W|V<56ia* z7P7MP6`=tOfY3P!<#GjnF}34p(?%OVmA0p>BzoM0K_c zS<+lg82$@0d&O*(_hGc&-cBM~y5Z84Enu+16B^%|ut}x6aOL>z;tP+r2)@iNJW?xk za{NT-8Wcs`yejbgqN{xReQ$ieY$#^09fm_TFA{BXk>VcxP26L^2^JpEK|aaYL)wMY zu<>;>_#M*}+>n+TJQ z_@UQW?hC}N5I0~b$hf|*?9aL(mJD!Zyg3B_^sB~bO-~-#qj{ngm zkwqtAjEUf>l9r>6-<#?1;0|&9mk>OYJfF(y72}5;IoweDEv{cP0^1hrv7WDwCHf6o@W_{1t&5M$*mvFMny;n{!AE1PCJr^F>XFB$fmeoC7`_qHvk=L!mF zRrwqYS^h}ec3zTh?a(6!$vDhdXhHI}xl@H5qaa;4W1qC0m2-ai!>Db&pz9b-eHLw@ zE1wP`4#B}>kI!eC+NVm_4?4#d1}}nB*(d1=!Fx04b33LPZ^2DZ?V)1IK{|8IA8}5R zED^1rN{Mv{o$~D=JG{p}8WzDr)n zWmS=kYYimf(@A(zXd|$ec9L+J4Scx$C6LGv7_O7u&|>~{C_D2V`fCJEg!BiXj@xmr zrWfJ~Unm*9jV+xt3Ojp(up-NWTy8YLUTGVyeJ_X(NV-id)VuN6%HI$)_Z7BCB}4h< z(;z$K1GNsef^<1W^mybY`ZA)B>wLQav%6M1mb@Fum#fC0MPM_IxTt_rZcPSz<%8tQ zkj3I5XP@&C+q0l?{CzkwFNoEC(jsTNhQd7aWe`1P7^rn$=f>-jaog}Jx-aLL=zaTC zYJGzQ&rDn&|`uH@(bX5i+( zsHTwhwDU5@tA!WH!7nRFqcB^k-Vw;nkGz5J!hPiW*t@V|$t*Y)ZVfND|G-SW2rd`< z1bb`+XXef8ctbaeJl?&8-M^!>rOv8fYw)-T5OU+SVRE)~lv*DumCO;PZoN&%1b<>B$EI`Lh|M@NZy`Jv z+*jBBv&QdFWZ9|5BcQ&17j?K+1Qx4e&^hx3j(oeQ(({@F*W%6Wa?5kP*^$5pn@lIu z$``XXgBjdBy_QQZ{|qyCuEy4l)A3>41Mj#&2Vy1@AI0G2%p=3H*3>k~)9 za#1p!8)ZwCPYnaB+}g^!6RJdAU$>xkYY3h_VSowYFQ~=P9+EfW5!M~FVP~IYp?ann z?vPxDbB^2MqTzOUrnVQHUAwrpb2l|r>VT~YE#%#d7jW!?9fp1g=L4fOKzuZT*&B}J z&PSR>H#RjuS&%oGYex(zgXD#`BPX#5%MEM~VhlSIu*M_%Xy z!+Qr2)yV|)ttXNGaXR4lAyROzMF?H`-P}h{4I{0~_(-F3u-9@E^_3q`dB*ED3g%)s zohggg>n32qr*w2mPyw6a4`98wJbo_pBF}9$L6>J9iyM3vPu;zO)_$}3u%-2t1N(*9 zmWeLfW<~O#_wsnXUPjU0LNBqR{XDsI_JHhB9BVAwsAUKBueDL>eVd5{1W-vHxKAz z4V2Az%~f8fat(bY^7>IfylmS6w;n5EbBZ0N^fuwk{_Ua*$3viQpbMA_yCmzrzk+k` z4|IRrgr-7XBVOHD#Ln!-%?n##-j{fGbZROtzbuWHmIYz^@yFt0T9Hs3gS z3;C}2K}rz##a3jza0!zbnvYXI zd*L`+p(}l~mfIYj%cHub;QG&xaD7QX)hJ8Dg_Z+E(#f}}i*P5MQxT0Z1I}T8*l;fW zuLVmC+NsPq5jIOY^U*aF z7Wtzj;!=gdzm^;pJj6qaHFdBX>mAL6zjf8nQ}fnYp)9~1{(Al1uG zfR2eCH;>uEX3cVk1LH5Sl}Z`7&^ttwr_WK?q`=P5&7`QIi_PDrhbMk2a-;X(*{(t@ z^fmayH3hDH?JNn%FgXbO;(l?789jW(plOg6+6JA~)p&5mYm(mXg?scSK)TOOxNr3u zR~1A-{_Fc7jE(SQcpg_#5b~$fkzEXQ;C44sq5M-ixglSIt6n8yTZ03(YComX(R-+A z(`+noPK6?N0Q#$jL9wwCD4R~fwO4PE=PI9>d{m#XJ1c|!J%al&p?JXme*Sj?|2u*I WoxuN2;D0CZzZ3Z13H(1hf&T;ImD~RS literal 0 HcmV?d00001 diff --git a/docs/source/tutorials/lensed_target_image.fits b/docs/source/tutorials/lensed_target_image.fits new file mode 100644 index 0000000000000000000000000000000000000000..73759811dfbcd50b75e7b436bd66de20b7454a31 GIT binary patch literal 95040 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

Hu-VKVP2RVGrGM(c<;iSxL+l!gJFf$FUdfo3 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

;o{H_tno_H2T2{PZMErGI{yiD2si~X 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

7Tu&`%8`6#nL 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

GHz?@PAkMHqRrt@|dw=btf=#H$U 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]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ℑcjlo^JMBTE*!KzQ~~YM{BE)^_M} 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 #eVBf$4Q+{;mKfC;1N# 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≶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 z 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∾oD@nRTW9YfCv0@dMzV;klh-2l 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�TQa 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[kCx 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

?QPKhgEUSB9ZJuipE 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

SRy->;?t=8PFGct`)^?)3k}%fGt= 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%0A5~! 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 SRo>To1>P(0UV%TT0{;gaii>*y literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index a998410e..5bd89221 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers=[ ] dependencies=[ "astropy>=5.3", - "caskade~=0.15.0", + "caskade~=1.0.0", "h5py>=3.8.0", "matplotlib>=3.7", "numpy>=1.24.0,<2.0.0", diff --git a/tests/conftest.py b/tests/conftest.py index 6690744f..7f82cd09 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,8 @@ def close_show(*args, **kwargs): @pytest.fixture() def sersic(request): + if not hasattr(request, "param"): + request.param = {} np.random.seed(request.param.get("seed", 12345)) shape = request.param.get("shape", (52, 50)) mask = request.param.get("mask", None) diff --git a/tests/test_batch_model.py b/tests/test_batch_model.py new file mode 100644 index 00000000..3dd87bcc --- /dev/null +++ b/tests/test_batch_model.py @@ -0,0 +1,38 @@ +import astrophot as ap +import numpy as np + + +def test_batch_model(sersic): + + M = ap.Model(model_type="batch model", model=sersic) + assert ( + M.target is sersic.target + ), "BatchModel should share the same target as its component model" + assert ( + M.window.extent == sersic.window.extent + ), "BatchModel should share the same window as its component model" + assert M.mask is sersic.mask, "BatchModel should share the same mask as its component model" + + sersic.center = [[5, 5], [30, 10], [20, 35]] + sersic.q = [0.7, 0.4, 0.3] + + gll0 = M.gaussian_log_likelihood() + pll0 = M.poisson_log_likelihood() + assert ap.backend.isfinite(gll0), "Gaussian log likelihood should be finite" + assert ap.backend.isfinite(pll0), "Poisson log likelihood should be finite" + grad = M.gradient() + assert ap.backend.all(ap.backend.isfinite(grad)), "Gradient should be finite" + jac = M.jacobian() + assert ap.backend.all(ap.backend.isfinite(jac.data)), "Jacobian should be finite" + + res = ap.fit.LM(M, max_iter=5).fit() + + assert len(res.loss_history) >= 2, "Optimizer must be able to find steps to improve the model" + gll1 = M.gaussian_log_likelihood() + pll1 = M.poisson_log_likelihood() + assert ap.backend.isfinite(gll1), "Gaussian log likelihood should be finite" + assert ap.backend.isfinite(pll1), "Poisson log likelihood should be finite" + assert gll1 > gll0 and pll1 > pll0, "Model should improve the likelihood after fitting" + assert np.all( + np.abs(sersic.q.npvalue - np.array([0.7, 0.4, 0.3])) > 0.1 + ), "Model parameters should change after fitting" diff --git a/tests/test_cmos_image.py b/tests/test_cmos_image.py index 16e6555d..c975abbe 100644 --- a/tests/test_cmos_image.py +++ b/tests/test_cmos_image.py @@ -57,7 +57,7 @@ def test_cmos_model_sample(cmos_target): integrate_mode="bright", ) model.initialize() - img = model.sample() + img = model() assert isinstance(img, ap.CMOSModelImage), "sampled image should be a CMOSModelImage" assert img.pixelscale == cmos_target.pixelscale, "sampled image should have the same pixelscale" diff --git a/tests/test_fit.py b/tests/test_fit.py index 1a53449e..1bf6c389 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -1,4 +1,3 @@ -import torch import numpy as np import astrophot as ap @@ -10,11 +9,11 @@ ###################################################################### -@pytest.mark.parametrize("center", [[20, 20], [25.1, 17.324567]]) +@pytest.mark.parametrize("center", [[20.01, 20.02], [25.1, 17.324567]]) @pytest.mark.parametrize("PA", [0, 60 * np.pi / 180]) -@pytest.mark.parametrize("q", [0.2, 0.8]) -@pytest.mark.parametrize("n", [1, 4]) -@pytest.mark.parametrize("Re", [10, 25.1]) +@pytest.mark.parametrize("q", [0.4, 0.8]) +@pytest.mark.parametrize("n", [1, 3]) +@pytest.mark.parametrize("Re", [15, 25.1]) def test_chunk_jacobian(center, PA, q, n, Re): target = make_basic_sersic() model = ap.Model( @@ -28,6 +27,7 @@ def test_chunk_jacobian(center, PA, q, n, Re): Ie=10.0, target=target, integrate_mode="none", + psf_convolve=False, ) Jtrue = model.jacobian() @@ -39,15 +39,6 @@ def test_chunk_jacobian(center, PA, q, n, Re): Jtrue.data, Jchunked.data ), "Param chunked Jacobian should match full Jacobian" - model.jacobian_maxparams = 10 - model.jacobian_maxpixels = 20**2 - - Jchunked = model.jacobian() - - assert ap.backend.allclose( - Jtrue.data, Jchunked.data - ), "Pixel chunked Jacobian should match full Jacobian" - @pytest.fixture def sersic_model(): @@ -72,7 +63,6 @@ def sersic_model(): [ (ap.fit.LM, {}), (ap.fit.LM, {"likelihood": "poisson"}), - (ap.fit.LMfast, {}), (ap.fit.IterParam, {"chunks": 3, "chunk_order": "sequential", "verbose": 2}), ( ap.fit.IterParam, @@ -91,7 +81,6 @@ def sersic_model(): "initial_state": [[20, 20, 0.7, np.pi, 2, 15, 10]], }, ), - (ap.fit.MiniFit, {}), (ap.fit.Slalom, {}), ], ) diff --git a/tests/test_group_models.py b/tests/test_group_models.py index bc0d2949..9bbcf77c 100644 --- a/tests/test_group_models.py +++ b/tests/test_group_models.py @@ -45,10 +45,6 @@ def test_jointmodel_creation(): ap.backend.isfinite(smod().flatten("data")) ).item(), "model_image should be real" - fm = smod.fit_mask() - for fmi in fm: - assert ap.backend.sum(fmi).item() == 0, "this fit_mask should not mask any pixels" - def test_psfgroupmodel_creation(): tar = make_basic_gaussian_psf() @@ -108,11 +104,7 @@ def test_joint_multi_band_multi_object(): # fmt: on model.initialize() - mask = model.fit_mask() - assert len(mask) == 4, "There should be 4 fit masks for the 4 targets" - for m in mask: - assert ap.backend.all(ap.backend.isfinite(m)), "this fit_mask should be finite" - sample = model.sample(window=ap.WindowList([target1.window, target2.window, target3.window])) + sample = model() assert isinstance(sample, ap.ImageList), "Sample should be an ImageList" for image in sample: assert ap.backend.all(ap.backend.isfinite(image.data)), "Sample image data should be finite" diff --git a/tests/test_image.py b/tests/test_image.py index 50e03415..9797c201 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -235,10 +235,10 @@ def test_target_image_psf(): zeropoint=1.0, ) assert new_image.has_psf, "target image should store variance" - assert new_image.psf.psf_pad == 4, "psf border should be half psf size" + assert new_image.psf.pad == 4, "psf border should be half psf size" reduced_image = new_image.reduce(3) - assert reduced_image.psf._data[0][0] == 9, "reduced image should sum sub pixels in psf" + assert reduced_image.psf.upsample == 3, "reduced image should now have upsampled PSF" new_image.psf = None assert not new_image.has_psf, "target image update to no psf" @@ -314,7 +314,7 @@ def test_psf_image_copying(): data=np.ones((15, 15)), ) - assert psf_image.psf_pad == 7, "psf image should have correct psf_pad" + assert psf_image.pad == 7, "psf image should have correct psf_pad" psf_image.normalize() assert np.allclose( ap.backend.to_numpy(psf_image._data), 1 / 15**2 diff --git a/tests/test_model.py b/tests/test_model.py index ac9dd4d1..c6cac710 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,6 +1,7 @@ +import os import astrophot as ap import numpy as np -from utils import make_basic_sersic, make_basic_gaussian_psf +from utils import make_basic_sersic import pytest # torch.autograd.set_detect_anomaly(True) @@ -32,11 +33,16 @@ def test_model_sampling_modes(): midpoint_bright = midpoint.copy() model.sampling_mode = "simpsons" simpsons = ap.backend.to_numpy(model().data) + model.sampling_mode = "upsample:5" + upsample5 = ap.backend.to_numpy(model().data) model.sampling_mode = "quad:5" quad5 = ap.backend.to_numpy(model().data) assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" + assert np.allclose( + midpoint, upsample5, rtol=1e-2 + ), "Upsample5 sampling should match midpoint sampling" assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" # Without subpixel integration @@ -46,6 +52,8 @@ def test_model_sampling_modes(): midpoint = ap.backend.to_numpy(model().data) model.sampling_mode = "simpsons" simpsons = ap.backend.to_numpy(model().data) + model.sampling_mode = "upsample:5" + upsample5 = ap.backend.to_numpy(model().data) model.sampling_mode = "quad:5" quad5 = ap.backend.to_numpy(model().data) assert np.allclose( @@ -54,6 +62,9 @@ def test_model_sampling_modes(): assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" + assert np.allclose( + midpoint, upsample5, rtol=1e-2 + ), "Upsample5 sampling should match midpoint sampling" assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" # curvature based subpixel integration @@ -63,6 +74,8 @@ def test_model_sampling_modes(): midpoint = ap.backend.to_numpy(model().data) model.sampling_mode = "simpsons" simpsons = ap.backend.to_numpy(model().data) + model.sampling_mode = "upsample:5" + upsample5 = ap.backend.to_numpy(model().data) model.sampling_mode = "quad:5" quad5 = ap.backend.to_numpy(model().data) assert np.allclose( @@ -71,15 +84,16 @@ def test_model_sampling_modes(): assert np.allclose(midpoint, auto, rtol=1e-2), "Midpoint sampling should match auto sampling" assert np.allclose(midpoint, simpsons, rtol=1e-2), "Simpsons sampling should match midpoint" assert np.allclose(midpoint, quad5, rtol=1e-2), "Quad5 sampling should match midpoint sampling" + assert np.allclose( + midpoint, upsample5, rtol=1e-2 + ), "Upsample5 sampling should match midpoint sampling" assert np.allclose(simpsons, quad5, rtol=1e-6), "Quad5 sampling should match Simpsons sampling" - model.integrate_mode = "should raise" with pytest.raises(ap.errors.SpecificationConflict): - model() + model.integrate_mode = "should raise" model.integrate_mode = "none" - model.sampling_mode = "should raise" with pytest.raises(ap.errors.SpecificationConflict): - model() + model.sampling_mode = "should raise" model.sampling_mode = "midpoint" model.integrate_mode = "none" @@ -128,6 +142,11 @@ def test_all_model_sample(model_type): ): pytest.skip("JAX version doesnt support these models yet, difficulty with gradients") + if any(t in model_type for t in ["warp", "fourier"]): + pytest.skip("Warp and Fourier models are complex and slow to fit, skipping for now") + if model_type.startswith("truncated"): + pytest.skip("Testing truncated models is redundant") + target = make_basic_sersic() target.zeropoint = 22.5 MODEL = ap.Model( @@ -149,8 +168,7 @@ def test_all_model_sample(model_type): ap.backend.isfinite(img.data) ), "Model should evaluate a real number for the full image" - res = ap.fit.LM(MODEL, max_iter=10, verbose=1).fit() - print(res.loss_history) + res = ap.fit.LM(MODEL, max_iter=5, verbose=1).fit() print(MODEL) # test printing @@ -235,35 +253,61 @@ def test_sersic_save_load(): assert model.target.crtan.value[0] == 0.0, "Model target crtan should be loaded correctly" assert model.target.crtan.value[1] == 0.0, "Model target crtan should be loaded correctly" - -@pytest.mark.parametrize("center", [[20, 20], [25.1, 17.324567]]) -@pytest.mark.parametrize("PA", [0, 60 * np.pi / 180]) -@pytest.mark.parametrize("q", [0.2, 0.8]) -@pytest.mark.parametrize("n", [1, 4]) -@pytest.mark.parametrize("Re", [10, 25.1]) -def test_chunk_sample(center, PA, q, n, Re): - target = make_basic_sersic() - model = ap.Model( - name="test_sersic", - model_type="sersic galaxy model", - center=center, - PA=PA, - q=q, - n=n, - Re=Re, - Ie=10.0, - target=target, - integrate_mode="none", + os.remove("test_AstroPhot_sersic.hdf5") + + +def test_batch_model(sersic): + M = ap.Model(model_type="batch model", model=sersic) + M.initialize() + # Now any parameters may be given an extra dimension for the batch + sersic.center = [[10, 10], [20, 70], [50, 50], [70, 30], [80, 80]] + sersic.q = [0.7, 0.6, 0.5, 0.4, 0.3] + sersic.PA = np.array([30, 60, 90, 120, 150]) * np.pi / 180 + + img = M() + assert isinstance(img, ap.image.ModelImage), "Output of batch model should be a ModelImage" + assert img.data.shape == sersic.target.data.shape, "batched model should produce regular output" + + M.set_values(ap.backend.ones_like(M.get_values()), attribute="uncertainty") + + assert M.total_flux() > 0, "Total flux of batch model should be positive" + assert ( + M.total_flux_uncertainty() > 0 + ), "Total flux uncertainty of batch model should be positive" + + +def test_batch_scene_model(sersic): + target_batch = ap.TargetImageBatch( + images=[ + ap.TargetImage( + data=np.zeros((52, 50)), + CD=ap.utils.initialize.R(np.pi / 3), + crtan=(10, 0), + name="image1", + ), + ap.TargetImage( + data=np.zeros((52, 50)), CD=ap.utils.initialize.R(np.pi / 6), name="image2" + ), + ap.TargetImage( + data=np.zeros((52, 50)), + CD=ap.utils.initialize.R(np.pi / 16), + crtan=(-15, 0), + name="image3", + ), + ] ) - - full_img = model.sample() - - chunk_img = target.model_image() - - for chunk in model.window.chunk(20**2): - sample = model.sample(window=chunk) - chunk_img += sample - - assert ap.backend.allclose( - full_img.data, chunk_img.data - ), "Chunked sample should match full sample within tolerance" + model = ap.Model(model_type="batch scene model", model=sersic, target=target_batch) + model.initialize() + sersic.Ie = [10, 20, 30] + img = model() + assert isinstance( + img, ap.image.ModelImageBatch + ), "Output of batch scene model should be a ModelImageBatch" + assert img.data.shape[0] == 3, "Batch scene model should produce output for each target image" + jac = model.jacobian() + assert isinstance( + jac, ap.image.JacobianImageBatch + ), "Jacobian of batch scene model should be a JacobianImageBatch" + assert ( + jac.data.shape[0] == 3 + ), "Batch scene model jacobian should produce output for each target image" diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index aaa7d40a..1eac5782 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -4,6 +4,7 @@ import runpy import subprocess import os +import shutil import caskade as ck import astrophot as ap @@ -28,6 +29,9 @@ def convert_notebook_to_py(nbpath): pypath = nbpath.replace(".ipynb", ".py") with open(pypath, "r") as f: content = f.readlines() + + content.insert(0, "import socket\n") + content.insert(1, "socket.setdefaulttimeout(120)\n") with open(pypath, "w") as f: for line in content: if line.startswith("get_ipython()"): @@ -50,6 +54,8 @@ def test_notebook(nb_path): pytest.skip("Requires torch backend") convert_notebook_to_py(nb_path) try: + for targ in glob.glob(os.path.join(os.path.dirname(nb_path), "*target_image*.fits")): + shutil.copy(targ, os.getcwd()) runpy.run_path(nb_path.replace(".ipynb", ".py"), run_name="__main__") finally: ck.backend.backend = "torch" diff --git a/tests/test_param.py b/tests/test_param.py index d3bd4156..78b52ed5 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -41,7 +41,7 @@ def test_module(): model.initialize() U = ap.backend.ones_like(model.get_values()) * 0.1 - model.fill_dynamic_value_uncertainties(U) + model.set_values(U, attribute="uncertainty") paramsu = model.get_values(attribute="uncertainty") assert ap.backend.all(ap.backend.isfinite(paramsu)), "All parameters should be finite" @@ -51,9 +51,3 @@ def test_module(): paramsun = model.build_params_array_units() assert all(isinstance(unit, str) for unit in paramsun), "All parameter units should be strings" - - index = model.dynamic_params_array_index(model2.q) - assert index == [9], "Parameter index should be correct" - - with pytest.raises(ValueError): - model.dynamic_params_array_index(5.0) # Not a Param instance diff --git a/tests/test_psfmodel.py b/tests/test_psfmodel.py index 9c6c7ca3..b3d21349 100644 --- a/tests/test_psfmodel.py +++ b/tests/test_psfmodel.py @@ -15,24 +15,25 @@ def test_all_psfmodel_sample(model_type): pytest.skip( "Skipping airy psf model, JAX does not support bessel_j1 with finite derivatives it seems" ) + if any(t in model_type for t in ["warp", "fourier"]): + pytest.skip("Skipping warp and fourier psf models, which are slow") - if "nuker" in model_type: - kwargs = {"Ib": {"value": None, "dynamic": True}} - elif "gaussian" in model_type: - kwargs = {"flux": {"value": None, "dynamic": True}} - elif "exponential" in model_type: - kwargs = {"Ie": {"value": None, "dynamic": True}} - else: - kwargs = {} target = make_basic_gaussian_psf(pixelscale=0.8) MODEL = ap.Model( name="test_model", model_type=model_type, target=target, normalize_psf=False, - **kwargs, ) + for p in MODEL.all_params: + if p.units in ["flux", "flux/pix^2"]: + p.to_dynamic(None) MODEL.initialize() + for p in MODEL.all_params: + if p.units in ["flux", "flux/pix^2"]: + p.to_dynamic(p.value * 1.5) + if p.units == "pix" and not p.name == "center": + p.to_dynamic(p.value + 0.5) print(MODEL) for P in MODEL.dynamic_params: assert P.value is not None, ( @@ -52,15 +53,20 @@ def test_all_psfmodel_sample(model_type): ap.backend.isfinite(MODEL.jacobian().data) ), "Model should evaluate a real number for the jacobian" - res = ap.fit.LM(MODEL, max_iter=10).fit() + res = ap.fit.LM(MODEL, max_iter=5).fit() - assert len(res.loss_history) > 2, "Optimizer must be able to find steps to improve the model" + assert len(res.loss_history) >= 2, "Optimizer must be able to find steps to improve the model" - if "pixelated" in model_type: # fixme pixelated having difficulties - return - assert ((res.loss_history[0] - 1) > (2 * (res.loss_history[-1] - 1))) or ( - res.loss_history[-1] < 1.0 - ), ( - f"Model {model_type} should fit to the target image, but did not. " - f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" - ) + if res.message == "success": + # Be less strict if fit succeeded quickly + assert res.loss_history[-1] < res.loss_history[0], ( + f"Model {model_type} should fit to the target image, but did not. " + f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" + ) + else: + assert ((res.loss_history[0] - 1) > (2 * (res.loss_history[-1] - 1))) or ( + res.loss_history[-1] < 1.0 + ), ( + f"Model {model_type} should fit to the target image, but did not. " + f"Initial loss: {res.loss_history[0]}, Final loss: {res.loss_history[-1]}" + ) diff --git a/tests/test_sip_image.py b/tests/test_sip_image.py index f79570be..48ddf96c 100644 --- a/tests/test_sip_image.py +++ b/tests/test_sip_image.py @@ -83,7 +83,10 @@ def test_sip_image_creation(sip_target): sip_model_crop = sip_model_image.crop([1, 2, 3, 4]) assert sip_model_crop._data.shape == (29, 15), "cropped model image should have correct shape" - sip_model_crop.fluxdensity_to_flux() + assert ap.backend.allclose( + sip_model_crop.pixel_area_map, + sip_model_crop.pixel_collecting_area(*sip_model_crop.pixel_center_meshgrid()), + ), "SIP image pixel area map should match collecting area at pixel centers" assert ap.backend.all( sip_model_crop.data >= 0 ), "cropped model image data should be non-negative after flux density to flux conversion" diff --git a/tests/test_utils.py b/tests/test_utils.py index 79c1c43a..d9db1420 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,7 +11,7 @@ def test_make_psf(): target = make_basic_gaussian(x=10, y=10) - target += make_basic_gaussian(x=40, y=40, rand=54321) + target += make_basic_gaussian(x=30, y=30, rand=54321) assert np.all( np.isfinite(ap.backend.to_numpy(target.data)) diff --git a/tests/utils.py b/tests/utils.py index 038bb747..29f3aa84 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -81,8 +81,7 @@ def make_basic_gaussian( pixelscale=0.8, x=24.5, y=25.4, - PA=45 * np.pi / 180, - sigma=3, + sigma=10, flux=1, rand=12345, ): @@ -126,7 +125,7 @@ def make_basic_gaussian_psf( psf = ap.utils.initialize.gaussian_psf(sigma * pixelscale, N, pixelscale) target = ap.PSFImage( data=psf + np.random.normal(scale=np.sqrt(psf) / 20), - pixelscale=pixelscale, + upsample=2, variance=psf / 400, ) target.normalize() From b9d3a67f8abca8de260a2bd402fb5a3ca5b32ff1 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 23 Mar 2026 22:22:00 -0400 Subject: [PATCH 189/191] corrections from copilot review --- astrophot/image/func/__init__.py | 3 --- astrophot/image/func/image.py | 4 ++-- astrophot/image/func/window.py | 16 ---------------- astrophot/image/image_object.py | 2 +- astrophot/image/jacobian_image.py | 3 +++ astrophot/models/group_psf_model.py | 2 +- astrophot/models/multi_gaussian_expansion.py | 4 ++-- 7 files changed, 9 insertions(+), 25 deletions(-) delete mode 100644 astrophot/image/func/window.py diff --git a/astrophot/image/func/__init__.py b/astrophot/image/func/__init__.py index f0723080..d4159c35 100644 --- a/astrophot/image/func/__init__.py +++ b/astrophot/image/func/__init__.py @@ -16,7 +16,6 @@ sip_backward_transform, sip_matrix, ) -from .window import window_or, window_and __all__ = ( "pixel_center_meshgrid", @@ -33,6 +32,4 @@ "sip_coefs", "sip_backward_transform", "sip_matrix", - "window_or", - "window_and", ) diff --git a/astrophot/image/func/image.py b/astrophot/image/func/image.py index d4ef3296..3563351c 100644 --- a/astrophot/image/func/image.py +++ b/astrophot/image/func/image.py @@ -65,6 +65,6 @@ def rotate(theta: ArrayLike, x: ArrayLike, y: ArrayLike) -> tuple: """ Applies a rotation matrix to the X,Y coordinates """ - s = theta.sin() - c = theta.cos() + s = backend.sin(theta) + c = backend.cos(theta) return c * x - s * y, s * x + c * y diff --git a/astrophot/image/func/window.py b/astrophot/image/func/window.py deleted file mode 100644 index 46be8061..00000000 --- a/astrophot/image/func/window.py +++ /dev/null @@ -1,16 +0,0 @@ -from ...backend_obj import backend - - -def window_or(other_origin, self_end, other_end): - - new_origin = backend.minimum(-0.5 * backend.ones_like(other_origin), other_origin) - new_end = backend.maximum(self_end, other_end) - - return new_origin, new_end - - -def window_and(other_origin, self_end, other_end): - new_origin = backend.maximum(-0.5 * backend.ones_like(other_origin), other_origin) - new_end = backend.minimum(self_end, other_end) - - return new_origin, new_end diff --git a/astrophot/image/image_object.py b/astrophot/image/image_object.py index c094bdd2..6fc57609 100644 --- a/astrophot/image/image_object.py +++ b/astrophot/image/image_object.py @@ -440,7 +440,7 @@ def to(self, dtype=None, device=None): return self def flatten(self, attribute: str = "data") -> ArrayLike: - return backend.flatten(getattr(self, attribute), end_dim=1) + return backend.flatten(getattr(self, attribute)) def fits_info(self) -> dict: return { diff --git a/astrophot/image/jacobian_image.py b/astrophot/image/jacobian_image.py index 0d64ab1a..7c6d0777 100644 --- a/astrophot/image/jacobian_image.py +++ b/astrophot/image/jacobian_image.py @@ -32,6 +32,9 @@ def __init__( def copy(self, **kwargs): return super().copy(parameters=self.parameters, **kwargs) + def flatten(self, attribute: str = "data"): + return getattr(self, attribute).reshape(-1, len(self.parameters)) + def match_parameters(self, other: Union["JacobianImage", "JacobianImageList", List]): self_i = [] other_i = [] diff --git a/astrophot/models/group_psf_model.py b/astrophot/models/group_psf_model.py index e5d15022..92b48bcf 100644 --- a/astrophot/models/group_psf_model.py +++ b/astrophot/models/group_psf_model.py @@ -27,7 +27,7 @@ def target(self): @target.setter def target(self, target): if not (target is None or isinstance(target, PSFImage)): - raise InvalidTarget("Group_Model target must be a PSF_Image instance.") + raise InvalidTarget("GroupModel target must be a PSFImage instance.") try: del self._target # Remove old target if it exists except AttributeError: diff --git a/astrophot/models/multi_gaussian_expansion.py b/astrophot/models/multi_gaussian_expansion.py index 105c77cb..abc3c949 100644 --- a/astrophot/models/multi_gaussian_expansion.py +++ b/astrophot/models/multi_gaussian_expansion.py @@ -77,7 +77,7 @@ def initialize(self): if not self.flux.initialized: self.flux.value = (np.sum(dat) / self.n_components) * np.ones(self.n_components) - if self.PA.initialized or self.q.initialized: + if self.PA.initialized and self.q.initialized: return x, y = target_area.coordinate_center_meshgrid() @@ -128,7 +128,7 @@ def brightness( R = self.radius_metric(x, y) return backend.sum( backend.vmap( - lambda A, r, sig, _q: (A / backend.sqrt(2 * np.pi * _q * sig**2)) + lambda A, r, sig, _q: (A / (2 * np.pi * _q * sig**2)) * backend.exp(-0.5 * (r / sig) ** 2) )(flux, R, sigma, q), dim=0, From 36d7be122d1d1434c56be9c4aabd3dc7cd6bac11 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Mon, 23 Mar 2026 22:28:15 -0400 Subject: [PATCH 190/191] pre-commit fixes --- astrophot/models/__init__.py | 1 - astrophot/models/func/sersic.py | 1 - astrophot/models/pixelated_model.py | 1 - tests/test_plots.py | 1 - 4 files changed, 4 deletions(-) diff --git a/astrophot/models/__init__.py b/astrophot/models/__init__.py index f404dcb2..cfbaf284 100644 --- a/astrophot/models/__init__.py +++ b/astrophot/models/__init__.py @@ -155,7 +155,6 @@ ) from . import func - __all__ = ( "Model", "ComponentModel", diff --git a/astrophot/models/func/sersic.py b/astrophot/models/func/sersic.py index 79165fd7..6ba3f4b7 100644 --- a/astrophot/models/func/sersic.py +++ b/astrophot/models/func/sersic.py @@ -1,6 +1,5 @@ from ...backend_obj import backend, ArrayLike - C1 = 4 / 405 C2 = 46 / 25515 C3 = 131 / 1148175 diff --git a/astrophot/models/pixelated_model.py b/astrophot/models/pixelated_model.py index 7b2599f6..0442e385 100644 --- a/astrophot/models/pixelated_model.py +++ b/astrophot/models/pixelated_model.py @@ -9,7 +9,6 @@ from ..utils.initialize import polar_decomposition from . import func - __all__ = ["Pixelated"] diff --git a/tests/test_plots.py b/tests/test_plots.py index 35d8f2e8..e9a32dd3 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -5,7 +5,6 @@ from utils import make_basic_sersic, make_basic_gaussian_psf import pytest - """ Can't test visuals, so this only tests that the code runs """ From 8e4f2bcb8d7a0581cd16071b49cdcf36faa46ad0 Mon Sep 17 00:00:00 2001 From: Connor Stone Date: Tue, 24 Mar 2026 09:19:11 -0400 Subject: [PATCH 191/191] documentation updates --- CITATION.cff | 2 +- README.md | 28 +++++++++++------ docs/source/install.rst | 2 ++ docs/source/tutorials/GettingStarted.ipynb | 31 ++++++++++++++++++- docs/source/tutorials/GettingStartedJAX.ipynb | 31 ++++++++++++++++++- 5 files changed, 81 insertions(+), 13 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 102a762c..9fe173da 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -67,5 +67,5 @@ keywords: - astronomy license: GPL-3.0 commit: 543dd68 -version: 0.16.0 +version: 0.17.0 date-released: '2023-08-23' diff --git a/README.md b/README.md index c4e23d23..6206b5cc 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,27 @@ [![Documentation Status](https://readthedocs.org/projects/astrophot/badge/?version=latest)](https://astrophot.readthedocs.io/en/latest/?badge=latest) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/Autostronomy/AstroPhot/main.svg)](https://results.pre-commit.ci/latest/github/Autostronomy/AstroPhot/main) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Static Badge](https://img.shields.io/badge/caskade%20ecosystem-8A2BE2?style=flat-square)](https://caskade.readthedocs.io) [![pypi](https://img.shields.io/pypi/v/astrophot.svg?logo=pypi&logoColor=white&label=PyPI)](https://pypi.org/project/astrophot/) [![downloads](https://img.shields.io/pypi/dm/astrophot?label=PyPI%20Downloads)](https://libraries.io/pypi/astrophot) [![codecov](https://img.shields.io/codecov/c/github/Autostronomy/AstroPhot?logo=codecov)](https://app.codecov.io/gh/Autostronomy/AstroPhot?search=&displayType=list) [![Static Badge](https://img.shields.io/badge/ADS-record-2A79E4)](https://ui.adsabs.harvard.edu/abs/2023MNRAS.525.6377S/abstract) [![DOI](https://zenodo.org/badge/473209170.svg)](https://zenodo.org/doi/10.5281/zenodo.10798979) -AstroPhot is a fast, flexible, and automated astronomical image modelling tool -for precise parallel multi-wavelength photometry. It is a python based package -that uses PyTorch to quickly and efficiently perform analysis tasks. Written by -[Connor Stone](https://connorjstone.com/) for tasks such as LSB imaging, -handling crowded fields, multi-band photometry, and analyzing massive data from -future telescopes. AstroPhot is flexible and fast for any astronomical image -modelling task. While it uses PyTorch (originally developed for Machine -Learning) it is NOT a machine learning based tool. +AstroPhot is a fast, flexible, and principled astronomical image modelling tool +for precise parallel multi-wavelength/epoch photometry. It is a python based +package that uses PyTorch or JAX to quickly and efficiently perform analysis +tasks. Written by [Connor Stone](https://connorjstone.com/) for tasks such as +LSB imaging, handling crowded fields, multi-band photometry, and analyzing +massive data from future telescopes. AstroPhot is flexible and fast for any +parametric astronomical image modelling task. While it uses PyTorch and/or JAX +(originally developed for Machine Learning) it is NOT a machine learning based +tool. In fact AstroPhot very rigidly sticks to Gaussian/Poisson likelihood +modelling (with extensions for priors if desired). + +AstroPhot is now a [caskade ecosystem project](https://caskade.readthedocs.io), +meaning its parameters have an incredible amount of flexibility. Check out the +documentation for more details! ## Installation @@ -43,8 +50,9 @@ See [the documentation](https://astrophot.readthedocs.io) for more details. You can find the documentation at the [ReadTheDocs site connected with the AstroPhot project](https://astrophot.readthedocs.io) -which covers many of the main use cases for AstroPhot. It is still in -development, but lots of useful information is there. Feel free to contact the +which covers many of the main use cases for AstroPhot. There is tons of useful +information in there, hopefully you can mix and match tutorials to get to just +about any parametric image modelling task quickly! Feel free to contact the author, [Connor Stone](https://connorjstone.com/), for any questions not answered by the documentation or tutorials. diff --git a/docs/source/install.rst b/docs/source/install.rst index 3ff98bb3..b75fdafa 100644 --- a/docs/source/install.rst +++ b/docs/source/install.rst @@ -11,6 +11,8 @@ Installation is very easy for most users, simply call:: If PyTorch gives you trouble, just follow the instructions on the `pytorch website `_ which will provide a command to copy into the terminal for very easy setup. +If you want to use AstroPhot with JAX, simply also install jax with ``pip install jax jaxlib`` and check out :doc:`tutorials/GettingStartedJAX`. + Requirements ------------ diff --git a/docs/source/tutorials/GettingStarted.ipynb b/docs/source/tutorials/GettingStarted.ipynb index 1f010326..61e7cfc4 100644 --- a/docs/source/tutorials/GettingStarted.ipynb +++ b/docs/source/tutorials/GettingStarted.ipynb @@ -6,7 +6,9 @@ "source": [ "# Getting Started with AstroPhot\n", "\n", - "In this notebook you will walk through the very basics of AstroPhot functionality. Here you will learn how to make models; how to set them up for fitting; and how to view the results. These core elements will come up every time you use AstroPhot, though in future notebooks you will learn how to take advantage of the advanced features in AstroPhot." + "In this notebook you will walk through the very basics of AstroPhot functionality. Here you will learn how to make models; how to set them up for fitting; and how to view the results. These core elements will come up every time you use AstroPhot, though in future notebooks you will learn how to take advantage of the advanced features in AstroPhot.\n", + "\n", + "**Note:** AstroPhot is now a [caskade ecosystem project](https://caskade.readthedocs.io), meaning its parameters have an incredible amount of flexibility. Check out the documentation for more details!" ] }, { @@ -331,6 +333,33 @@ "print(\"Parameter dynamic state, Re:\", model3.Re.dynamic, \"so it will be optimized by a fitter\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set static/dynamic parameters\n", + "\n", + "You can control which parameters will be optimized during fitting by changing them between `static` and `dynamic`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model3.Re.to_static() # Now this value will not change\n", + "model3.Re.to_dynamic() # Now this value will be optimized by a fitter again\n", + "\n", + "model3.to_static() # Now all parameters of this model will be static\n", + "model3.to_dynamic() # Now all parameters of this model will be dynamic again\n", + "\n", + "# For group models, you can set static/dynamic for all the sub-models at once by calling:\n", + "# group_model.to_static(children_only=False)\n", + "# group_model.to_dynamic(children_only=False)\n", + "# The default is for children_only to be true meaning only the immediate parameters would have been changed." + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/source/tutorials/GettingStartedJAX.ipynb b/docs/source/tutorials/GettingStartedJAX.ipynb index 743633c9..a69f737f 100644 --- a/docs/source/tutorials/GettingStartedJAX.ipynb +++ b/docs/source/tutorials/GettingStartedJAX.ipynb @@ -8,7 +8,9 @@ "\n", "In this notebook we will run through the same \"getting started\" tutorial, except this time using JAX!\n", "\n", - "You'll notice right away that basically everything is the same. The only difference is that now all the data and parameters are stored as ``jax.numpy`` arrays. So if that's how you prefer to interact with AstroPhot then forge on! AstroPhot should integrate with a JAX workflow very easily. If you want to treat AstroPhot in a functional way, then simply build the model you want then use ``f = lambda x: model(x).data`` and now ``f(x)`` returns the model image and you can do all the usual, vmap, autograd, etc stuff of JAX on this. Similarly, making ``l = lambda x: model.gaussian_log_likelihood(x)`` will return a scalar log likelihood function (Poisson also works). One note though, JAX has a reputation for being fast, this is true of JIT compiled JAX but not necessarily \"eager\" JAX where we simply define functions and evaluate them. This is the mode that AstroPhot mostly works in since it is so dynamic in the number of options it has and the freedom users have to change them. For this reason, you will find that AstroPhot is faster in PyTorch than JAX (uncompiled). For now we provide this API so JAX users can take advantage of AstroPhot in their workflow. So long as you work in a JAX-oriented way (JIT compile before expecting anything to be fast) then everything should work well and fast. There are only a handful of AstroPhot models that don't work yet in JAX (notably the isothermal edgeon galaxy model since JAX doesn't have the K1 Bessel function)." + "You'll notice right away that basically everything is the same. The only difference is that now all the data and parameters are stored as ``jax.numpy`` arrays. So if that's how you prefer to interact with AstroPhot then forge on! AstroPhot should integrate with a JAX workflow very easily. If you want to treat AstroPhot in a functional way, then simply build the model you want then use ``f = lambda x: model(x).data`` and now ``f(x)`` returns the model image and you can do all the usual, vmap, autograd, etc stuff of JAX on this. Similarly, making ``l = lambda x: model.gaussian_log_likelihood(x)`` will return a scalar log likelihood function (Poisson also works). One note though, JAX has a reputation for being fast, this is true of JIT compiled JAX but not necessarily \"eager\" JAX where we simply define functions and evaluate them. This is the mode that AstroPhot mostly works in since it is so dynamic in the number of options it has and the freedom users have to change them. For this reason, you will find that AstroPhot is faster in PyTorch than JAX (uncompiled). For now we provide this API so JAX users can take advantage of AstroPhot in their workflow. So long as you work in a JAX-oriented way (JIT compile before expecting anything to be fast) then everything should work well and fast. There are only a handful of AstroPhot models that don't work yet in JAX (notably the isothermal edgeon galaxy model since JAX doesn't have the K1 Bessel function).\n", + "\n", + "**Note:** AstroPhot is now a [caskade ecosystem project](https://caskade.readthedocs.io), meaning its parameters have an incredible amount of flexibility. Check out the documentation for more details!" ] }, { @@ -351,6 +353,33 @@ "print(\"Parameter dynamic state, Re:\", model3.Re.dynamic, \"so it will be optimized by a fitter\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set static/dynamic parameters\n", + "\n", + "You can control which parameters will be optimized during fitting by changing them between `static` and `dynamic`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model3.Re.to_static() # Now this value will not change\n", + "model3.Re.to_dynamic() # Now this value will be optimized by a fitter again\n", + "\n", + "model3.to_static() # Now all parameters of this model will be static\n", + "model3.to_dynamic() # Now all parameters of this model will be dynamic again\n", + "\n", + "# For group models, you can set static/dynamic for all the sub-models at once by calling:\n", + "# group_model.to_static(children_only=False)\n", + "# group_model.to_dynamic(children_only=False)\n", + "# The default is for children_only to be true meaning only the immediate parameters would have been changed." + ] + }, { "cell_type": "markdown", "metadata": {},

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%0A4%n2e}bfa+S~b{o4;i{I$QAApN{PL9yi(erfc#C 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 z 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 Z;BFCT2a*bp1^mtwhG5xi|I4;zZ_!ZUX62d+^N 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%0D(Te~J!1oNv*$ppI!lf$^ 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 ^a$hfME> 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

M;B;W?j^TFuEVuRD|%Nd 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 literal 0 HcmV?d00001 diff --git a/docs/source/tutorials/target_image.fits b/docs/source/tutorials/target_image.fits new file mode 100644 index 0000000000000000000000000000000000000000..455c13782c62499332624a763a7b1d5d516fafa3 GIT binary patch literal 1964160 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

C-`I$DJa;d386I9oeV%=P z<2ky7(S%rqXqtQcj6>nV2%%6oMkIYEqNu=F1swx+tFc@1JUsXK>1>_~-Sxg0j;4#B z=<8U`zvVsF_e#n|swDYDWqPOe(w?F9KAwZKj0QUNyAd8F%KK$C=JG8> zD0adkwUht!>jPxfpE@|F@#nAJUqbSy?u5~TV>M@;i;1>b4X#OQ-1mxabNZJSqK{k*^-bHDw$@rE!Qa3y%-KRF)lG#5wsriW`Z8JnlIgJXnub4W91bJ5 zYheD|N;0LT2*M*5<8LnplJueyre%VbS--Ux|6$E_C|c17x7G<|{=0g}d=_q$<%NF* z*Kf}zA;HZ|uAhcqcCn5W$!6e~N0YGcg$6uu<6yFSdx%U@z7#f`xGD?a4?}Hrpe&*9 zRyes%$5;NqG0g*%Ao44Q)qDDou|BCF`lg%&jqgns4Ot5V8UvwB*bnZ0{s5}VPSEH7)sv)aYijCz^GWY3HDpfuJ)EPyLJXP1WN8VNWYotY%-m%y zvhcPB9Gb8|7SL7-tMi<|4*3;Uo>_{6Z^yW{D_iic&FT1Z>KVM_dNR|b%mQ)I_x!%Z z1thfO9sb8h&G^Kwn6b!@hCnz$dtdWE9Ex66s!&nAm^^9lhlmEo~)Yn}tl=SWi?@5d>~b zkiFZSC-c4M566PmkhqE#2*TwM6WT^bn2(V6lCsFmAzPXGcYop^juiTM!|Q<80j~pI N2fPk=9r#~4@E3FXp(6kQ literal 0 HcmV?d00001 diff --git a/docs/source/tutorials/joint_group_target_image_W1.fits b/docs/source/tutorials/joint_group_target_image_W1.fits new file mode 100644 index 0000000000000000000000000000000000000000..92e77cdd0085eba942917378c6a634c7c9d41470 GIT binary patch literal 5760 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 K0{@>+;Qs&#JI4e7 literal 0 HcmV?d00001 diff --git a/docs/source/tutorials/joint_target_image_NUV.fits b/docs/source/tutorials/joint_target_image_NUV.fits new file mode 100644 index 0000000000000000000000000000000000000000..e0970141073346d862d51e80f28920f5bc833408 GIT binary patch literal 37440 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 literal 0 HcmV?d00001 diff --git a/docs/source/tutorials/joint_target_image_W1.fits b/docs/source/tutorials/joint_target_image_W1.fits new file mode 100644 index 0000000000000000000000000000000000000000..24e255ccb651d2971740d09c778bc74585e4ff6b GIT binary patch literal 14400 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 Xn)&flSE5XDu*ARHzdi8(V-Nf<5A4zy literal 0 HcmV?d00001 diff --git a/docs/source/tutorials/joint_target_image_r.fits b/docs/source/tutorials/joint_target_image_r.fits new file mode 100644 index 0000000000000000000000000000000000000000..d91b6816b54b275438a686c86dcfc15ecac88745 GIT binary patch literal 1005120 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ΠkO%(FA0YOm8Wj?JQKIN6 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