Coverage for python / lsst / images / serialization / _asdf_utils.py: 68%

124 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-13 08:43 +0000

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. 

11 

12from __future__ import annotations 

13 

14import operator 

15 

16__all__ = ( 

17 "ArrayReferenceModel", 

18 "ArrayReferenceQuantityModel", 

19 "InlineArray", 

20 "InlineArrayModel", 

21 "InlineArrayQuantity", 

22 "InlineArrayQuantityModel", 

23 "Quantity", 

24 "QuantityModel", 

25 "Time", 

26 "TimeModel", 

27 "Unit", 

28) 

29 

30from typing import Annotated, Any, Literal 

31 

32import astropy.time 

33import astropy.units 

34import numpy as np 

35import pydantic 

36import pydantic_core.core_schema as pcs 

37 

38from ._dtypes import NumberType 

39 

40 

41class _UnitSerialization: 

42 """Pydantic hooks for unit serialization. 

43 

44 This class provides implementations for the `Unit` type alias for 

45 `astropy.unit.Unit` that adds Pydantic serialization and validation. 

46 """ 

47 

48 @classmethod 

49 def __get_pydantic_core_schema__( 

50 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

51 ) -> pcs.CoreSchema: 

52 from_str_schema = pcs.chain_schema( 

53 [ 

54 pcs.str_schema(), 

55 pcs.no_info_plain_validator_function(cls.from_str), 

56 ] 

57 ) 

58 return pcs.json_or_python_schema( 

59 json_schema=from_str_schema, 

60 python_schema=pcs.union_schema([pcs.is_instance_schema(astropy.units.UnitBase), from_str_schema]), 

61 serialization=pcs.plain_serializer_function_ser_schema(cls.to_str), 

62 ) 

63 

64 @classmethod 

65 def from_str(cls, value: str) -> astropy.units.UnitBase: 

66 try: 

67 return astropy.units.Unit(value, format="vounit") 

68 except ValueError: 

69 pass 

70 # Some important units (e.g. "dn") are not supported by vounit, so 

71 # fall back to letting astropy to infer the format. 

72 return astropy.units.Unit(value) 

73 

74 @staticmethod 

75 def to_str(unit: astropy.units.UnitBase) -> str: 

76 try: 

77 return unit.to_string("vounit") 

78 except ValueError: 

79 pass 

80 # Some important units (e.g. "dn") are not supported by vounit, so 

81 # fall back to letting astropy use the default format. 

82 return unit.to_string() 

83 

84 

85type Unit = Annotated[ 

86 astropy.units.UnitBase, 

87 _UnitSerialization, 

88 pydantic.WithJsonSchema( 

89 { 

90 "type": "string", 

91 "$schema": "http://stsci.edu/schemas/yaml-schema/draft-01", 

92 "id": "http://stsci.edu/schemas/asdf/unit/unit-1.0.0", 

93 "tag": "!unit/unit-1.0.0", 

94 } 

95 ), 

96] 

97 

98 

99class ArrayReferenceModel(pydantic.BaseModel, ser_json_inf_nan="constants"): 

100 """Model for a subset of the ASDF 'ndarray' schema, in the case where the 

101 array data is stored elsewhere. 

102 """ 

103 

104 source: str | int = pydantic.Field(description="Location of the underlying binary data.") 

105 shape: list[int] = pydantic.Field( 

106 # In (e.g.) FITS this is stored outside of the JSON as well, and it 

107 # be hard to get it right if we need to make a reference to a column 

108 # before all rows have been written, so unlike ASDF we allow this to 

109 # be omitted. 

110 default_factory=list, 

111 description="Size of the array in each dimension.", 

112 exclude_if=operator.not_, 

113 ) 

114 datatype: NumberType = pydantic.Field(description="Data type of the array.") 

115 byteorder: Literal["big"] = pydantic.Field(default="big", description="Byte order for the binary data.") 

116 

117 def with_units(self, unit: astropy.units.UnitBase) -> ArrayReferenceQuantityModel: 

118 """Add units, transforming this model into a Quantity model.""" 

119 return ArrayReferenceQuantityModel.model_construct(value=self, unit=unit) 

120 

121 model_config = pydantic.ConfigDict( 

122 json_schema_extra={ 

123 "$schema": "http://stsci.edu/schemas/yaml-schema/draft-01", 

124 "id": "http://stsci.edu/schemas/asdf/core/ndarray-1.1.0", 

125 "tag": "!core/ndarray-1.1.0", 

126 } 

127 ) 

128 

129 

130class InlineArrayModel(pydantic.BaseModel, ser_json_inf_nan="constants"): 

131 """Model for a subset of the ASDF 'ndarray' schema, in the case where the 

132 array data is stored inline. 

133 """ 

134 

135 data: list[Any] 

136 datatype: NumberType 

137 

138 @property 

139 def shape(self) -> tuple[int, ...]: 

140 """The shape of the array (`tuple` [`int`, ...]).""" 

141 return self._extract_shape(self.data) 

142 

143 def with_units(self, unit: astropy.unit.UnitBase) -> InlineArrayQuantityModel: 

144 """Add units, transforming this model in to a Quantity model.""" 

145 return InlineArrayQuantityModel.model_construct(value=self, unit=unit) 

146 

147 @classmethod 

148 def _extract_shape(cls, data: list[Any], current: tuple[int, ...] = ()) -> tuple[int, ...]: 

149 if not data: 

150 return current + (0,) 

151 if not isinstance(data[0], list): 

152 return current + (len(data),) 

153 return cls._extract_shape(data[0], current + (len(data),)) 

154 

155 model_config = pydantic.ConfigDict( 

156 json_schema_extra={ 

157 "$schema": "http://stsci.edu/schemas/yaml-schema/draft-01", 

158 "id": "http://stsci.edu/schemas/asdf/core/ndarray-1.1.0", 

159 "tag": "!core/ndarray-1.1.0", 

160 } 

161 ) 

162 

163 

164class _InlineArraySerialization: 

165 """Pydantic hooks for array serialization. 

166 

167 This class provides implementations for the `Array` type alias for 

168 `numpy.ndarray` that adds Pydantic serialization and validation. 

169 """ 

170 

171 @classmethod 

172 def __get_pydantic_core_schema__( 

173 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

174 ) -> pcs.CoreSchema: 

175 from_model_schema = pcs.chain_schema( 

176 [ 

177 handler(InlineArrayModel), 

178 pcs.no_info_plain_validator_function(cls.from_model), 

179 ] 

180 ) 

181 return pcs.json_or_python_schema( 

182 json_schema=from_model_schema, 

183 python_schema=pcs.union_schema([pcs.is_instance_schema(np.ndarray), from_model_schema]), 

184 serialization=pcs.plain_serializer_function_ser_schema(cls.to_model), 

185 ) 

186 

187 @classmethod 

188 def from_model(cls, model: InlineArrayModel) -> np.ndarray: 

189 return np.array(model.data, dtype=model.datatype.to_numpy()) 

190 

191 @classmethod 

192 def to_model(cls, array: np.ndarray) -> InlineArrayModel: 

193 datatype = NumberType.from_numpy(array.dtype) 

194 return InlineArrayModel(data=array.tolist(), datatype=datatype) 

195 

196 

197type InlineArray = Annotated[np.ndarray, _InlineArraySerialization] 

198 

199 

200class QuantityModel(pydantic.BaseModel, ser_json_inf_nan="constants"): 

201 """Model for a subset of the ASDF 'quantity' schema for scalars.""" 

202 

203 value: pydantic.StrictFloat 

204 unit: Unit 

205 

206 model_config = pydantic.ConfigDict( 

207 json_schema_extra={ 

208 "$schema": "http://stsci.edu/schemas/yaml-schema/draft-01", 

209 "id": "http://stsci.edu/schemas/asdf/unit/quantity-1.2.0", 

210 "tag": "!unit/quantity-1.2.0", 

211 } 

212 ) 

213 

214 

215class InlineArrayQuantityModel(pydantic.BaseModel, ser_json_inf_nan="constants"): 

216 """Model for a subset of the ASDF 'quantity' schema for inline arrays.""" 

217 

218 value: InlineArrayModel 

219 unit: Unit 

220 

221 model_config = pydantic.ConfigDict( 

222 json_schema_extra={ 

223 "$schema": "http://stsci.edu/schemas/yaml-schema/draft-01", 

224 "id": "http://stsci.edu/schemas/asdf/unit/quantity-1.2.0", 

225 "tag": "!unit/quantity-1.2.0", 

226 } 

227 ) 

228 

229 

230class ArrayReferenceQuantityModel(pydantic.BaseModel, ser_json_inf_nan="constants"): 

231 """Model for a subset of the ASDF 'quantity' schema for external arrays.""" 

232 

233 value: ArrayReferenceModel 

234 unit: Unit 

235 

236 model_config = pydantic.ConfigDict( 

237 json_schema_extra={ 

238 "$schema": "http://stsci.edu/schemas/yaml-schema/draft-01", 

239 "id": "http://stsci.edu/schemas/asdf/unit/quantity-1.2.0", 

240 "tag": "!unit/quantity-1.2.0", 

241 } 

242 ) 

243 

244 

245class _QuantitySerialization: 

246 """Pydantic hooks for scalar quantity serialization.""" 

247 

248 @classmethod 

249 def __get_pydantic_core_schema__( 

250 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

251 ) -> pcs.CoreSchema: 

252 from_model_schema = pcs.chain_schema( 

253 [ 

254 handler(QuantityModel), 

255 pcs.no_info_plain_validator_function(cls.from_model), 

256 ] 

257 ) 

258 return pcs.json_or_python_schema( 

259 json_schema=from_model_schema, 

260 python_schema=pcs.union_schema( 

261 [pcs.is_instance_schema(astropy.units.Quantity), from_model_schema] 

262 ), 

263 serialization=pcs.plain_serializer_function_ser_schema(cls.to_model), 

264 ) 

265 

266 @classmethod 

267 def from_model(cls, model: QuantityModel) -> astropy.units.Quantity: 

268 return astropy.units.Quantity(model.value, unit=model.unit) 

269 

270 @classmethod 

271 def to_model(cls, quantity: astropy.units.Quantity) -> QuantityModel: 

272 assert quantity.isscalar 

273 return QuantityModel(value=quantity.to_value(), unit=quantity.unit) 

274 

275 

276type Quantity = Annotated[astropy.units.Quantity, _QuantitySerialization] 

277 

278 

279class _InlineArrayQuantitySerialization: 

280 """Pydantic hooks for inline array quantity serialization.""" 

281 

282 @classmethod 

283 def __get_pydantic_core_schema__( 

284 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

285 ) -> pcs.CoreSchema: 

286 from_model_schema = pcs.chain_schema( 

287 [ 

288 handler(InlineArrayQuantityModel), 

289 pcs.no_info_plain_validator_function(cls.from_model), 

290 ] 

291 ) 

292 return pcs.json_or_python_schema( 

293 json_schema=from_model_schema, 

294 python_schema=pcs.union_schema( 

295 [pcs.is_instance_schema(astropy.units.Quantity), from_model_schema] 

296 ), 

297 serialization=pcs.plain_serializer_function_ser_schema(cls.to_model), 

298 ) 

299 

300 @classmethod 

301 def from_model(cls, model: InlineArrayQuantityModel) -> astropy.units.Quantity: 

302 return astropy.units.Quantity(_InlineArraySerialization.from_model(model.value), unit=model.unit) 

303 

304 @classmethod 

305 def to_model(cls, quantity: astropy.units.Quantity) -> InlineArrayQuantityModel: 

306 assert quantity.isscalar 

307 return InlineArrayQuantityModel( 

308 value=_InlineArraySerialization.to_model(quantity.to_value()), 

309 unit=quantity.unit, 

310 ) 

311 

312 

313type InlineArrayQuantity = Annotated[astropy.units.Quantity, _InlineArrayQuantitySerialization] 

314 

315 

316class TimeModel(pydantic.BaseModel, ser_json_inf_nan="constants"): 

317 """Model for a subset of the ASDF 'time' schema.""" 

318 

319 value: str 

320 scale: Literal["utc", "tai"] 

321 format: Literal["iso"] = "iso" 

322 

323 model_config = pydantic.ConfigDict( 

324 json_schema_extra={ 

325 "$schema": "http://stsci.edu/schemas/yaml-schema/draft-01", 

326 "id": "http://stsci.edu/schemas/asdf/time/time-1.2.0", 

327 "tag": "!time/time-1.2.0", 

328 } 

329 ) 

330 

331 

332class _TimeSerialization: 

333 """Pydantic hooks for time serialization. 

334 

335 This class provides implementations for the `Time` type alias for 

336 `astropy.time.Time` that adds Pydantic serialization and validation. 

337 """ 

338 

339 @classmethod 

340 def __get_pydantic_core_schema__( 

341 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

342 ) -> pcs.CoreSchema: 

343 from_model_schema = pcs.chain_schema( 

344 [ 

345 TimeModel.__pydantic_core_schema__, 

346 pcs.no_info_plain_validator_function(cls.from_model), 

347 ] 

348 ) 

349 return pcs.json_or_python_schema( 

350 json_schema=from_model_schema, 

351 python_schema=pcs.union_schema([pcs.is_instance_schema(astropy.time.Time), from_model_schema]), 

352 serialization=pcs.plain_serializer_function_ser_schema(cls.to_model, info_arg=False), 

353 ) 

354 

355 @classmethod 

356 def from_model(cls, model: TimeModel) -> astropy.time.Time: 

357 return astropy.time.Time(model.value, scale=model.scale, format=model.format) 

358 

359 @classmethod 

360 def to_model(cls, time: astropy.time.Time) -> TimeModel: 

361 if time.scale != "utc" and time.scale != "tai": 

362 time = time.tai 

363 return TimeModel(value=time.to_value("iso"), scale=time.scale, format="iso") 

364 

365 

366type Time = Annotated[astropy.time.Time, _TimeSerialization]