Coverage for python / lsst / images / fields / _sum.py: 34%
78 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-27 01:31 -0700
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-27 01:31 -0700
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14__all__ = ("SumField", "SumFieldSerializationModel")
16from collections.abc import Iterable
17from typing import TYPE_CHECKING, Any, Literal, final
19import astropy.units
20import numpy as np
21import pydantic
23from .._geom import Bounds, Box
24from .._image import Image
25from ..serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive
26from ._base import BaseField
28if TYPE_CHECKING:
29 try:
30 from lsst.afw.math import BackgroundList as LegacyBackgroundList
31 except ImportError:
32 type LegacyBackgroundList = Any # type: ignore[no-redef]
34 from ._concrete import Field, FieldSerializationModel
37@final
38class SumField(BaseField):
39 """A field that sums other fields lazily.
41 Parameters
42 ----------
43 operands : `~collections.abc.Iterable` [ `BaseField` ]
44 The fields to sum together.
45 """
47 def __init__(self, operands: Iterable[Field]):
48 self._operands = tuple(operands)
49 if not self._operands:
50 raise ValueError("At least one operand must be provided.")
51 iterator = iter(self._operands)
52 first = next(iterator)
53 self._bounds = first.bounds
54 self._unit = first.unit
55 for operand in iterator:
56 self._bounds = self._bounds.intersection(operand.bounds)
57 if operand.unit is None:
58 if self._unit is not None:
59 raise astropy.units.UnitConversionError(
60 "Cannot add a field with no units to a field with units."
61 )
62 elif self._unit is None:
63 raise astropy.units.UnitConversionError(
64 "Cannot add a field with units to a field with no units."
65 )
66 else:
67 # Raise if these units are not sum-compatible.
68 self._unit.to(operand.unit)
70 @property
71 def bounds(self) -> Bounds:
72 return self._bounds
74 @property
75 def unit(self) -> astropy.units.UnitBase | None:
76 return self._unit
78 @property
79 def operands(self) -> tuple[Field, ...]:
80 """The fields that are summed together (`tuple` [`BaseField`, ...])."""
81 return self._operands
83 @property
84 def is_constant(self) -> bool:
85 return all(operand.is_constant for operand in self._operands)
87 def evaluate(
88 self, *, x: np.ndarray, y: np.ndarray, quantity: bool = False
89 ) -> np.ndarray | astropy.units.Quantity:
90 iterator = iter(self._operands)
91 first = next(iterator)
92 # We have to add quantities if this is a unit-aware field, as the
93 # terms in the sum might have different-but-compatible units.
94 result = first(x=x, y=y, quantity=(self.unit is not None))
95 for operand in iterator:
96 result += operand(x=x, y=y, quantity=(self.unit is not None))
97 if self.unit is not None and not quantity:
98 # Caller doesn't want a Quantity back.
99 assert isinstance(result, astropy.units.Quantity)
100 return result.to_value(self.unit)
101 if self.unit is None and quantity:
102 # Caller wants a Quantity back even though there's no units.
103 return astropy.units.Quantity(result)
104 return result
106 def render(self, bbox: Box | None = None, *, dtype: np.typing.DTypeLike | None = None) -> Image:
107 if bbox is None:
108 bbox = self.bounds.bbox
109 result = Image(0.0, bbox=bbox, dtype=dtype, unit=self.unit)
110 for operand in self._operands:
111 result.quantity += operand.render(bbox, dtype=dtype).quantity
112 return result
114 def multiply_constant(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> SumField:
115 return SumField([operand * factor for operand in self._operands])
117 def serialize(self, archive: OutputArchive[Any]) -> SumFieldSerializationModel:
118 """Serialize the field to an output archive."""
119 return SumFieldSerializationModel(operands=[operand.serialize(archive) for operand in self._operands])
121 @staticmethod
122 def _get_archive_tree_type(
123 pointer_type: type[Any],
124 ) -> type[SumFieldSerializationModel]:
125 """Return the serialization model type for this object for an archive
126 type that uses the given pointer type.
127 """
128 return SumFieldSerializationModel
130 @staticmethod
131 def from_legacy_background(
132 legacy_background: LegacyBackgroundList,
133 bounds: Bounds | None = None,
134 unit: astropy.units.UnitBase | None = None,
135 ) -> SumField:
136 """Convert from a legacy `lsst.afw.math.BackgroundList` instance.
138 Parameters
139 ----------
140 legacy
141 Legacy background object to convert.
142 bounds
143 The bounds of the returned field, if they should be different from
144 the bounding box of ``legacy_background``.
145 unit
146 The units of the returned field (`lsst.afw.math.BackgroundList`
147 objects do not know their units).
148 """
149 from ._concrete import field_from_legacy_background
151 return SumField(
152 [field_from_legacy_background(b, bounds=bounds, unit=unit) for b, *_ in legacy_background]
153 )
156class SumFieldSerializationModel(ArchiveTree):
157 """Serialization model for `SumField`."""
159 operands: list[FieldSerializationModel] = pydantic.Field(default_factory=list)
161 field_type: Literal["SUM"] = "SUM"
163 def deserialize(self, archive: InputArchive, **kwargs: Any) -> SumField:
164 """Deserialize the field from an input archive."""
165 if kwargs:
166 raise InvalidParameterError(f"Unrecognized parameters for SumField: {set(kwargs.keys())}.")
167 return SumField([operand.deserialize(archive) for operand in self.operands])