kuva-metadata 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kuva_metadata-0.1.0/PKG-INFO +26 -0
- kuva_metadata-0.1.0/kuva_metadata/__init__.py +23 -0
- kuva_metadata-0.1.0/kuva_metadata/custom_types.py +36 -0
- kuva_metadata-0.1.0/kuva_metadata/geometry_utils.py +173 -0
- kuva_metadata-0.1.0/kuva_metadata/helper_types.py +13 -0
- kuva_metadata-0.1.0/kuva_metadata/py.typed +0 -0
- kuva_metadata-0.1.0/kuva_metadata/sections_common.py +194 -0
- kuva_metadata-0.1.0/kuva_metadata/sections_l0.py +673 -0
- kuva_metadata-0.1.0/kuva_metadata/sections_l1.py +139 -0
- kuva_metadata-0.1.0/kuva_metadata/sections_l2.py +32 -0
- kuva_metadata-0.1.0/kuva_metadata/serializers.py +60 -0
- kuva_metadata-0.1.0/kuva_metadata/utils.py +3 -0
- kuva_metadata-0.1.0/kuva_metadata/validators.py +206 -0
- kuva_metadata-0.1.0/pyproject.toml +53 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: kuva-metadata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Definitions of Kuva Space product metadata
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Guillem Ballesteros
|
|
7
|
+
Author-email: guillem@kuvaspace.com
|
|
8
|
+
Requires-Python: >=3.10,<=3.13
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: kuva-geometry
|
|
16
|
+
Requires-Dist: networkx (>=3.4.2,<4.0.0)
|
|
17
|
+
Requires-Dist: numpy (>=1.26.4,<2.0.0)
|
|
18
|
+
Requires-Dist: numpy-quaternion (>=2022.4.4,<2023.0.0)
|
|
19
|
+
Requires-Dist: pint (>=0.22,<0.23)
|
|
20
|
+
Requires-Dist: pydantic (>=2.9.2,<3.0.0)
|
|
21
|
+
Requires-Dist: python-dateutil (>=2.9.0.post0,<3.0.0)
|
|
22
|
+
Requires-Dist: pytz (>=2023.4,<2024.0)
|
|
23
|
+
Requires-Dist: rasterio (>=1.4.1,<2.0.0)
|
|
24
|
+
Requires-Dist: rich (>=13.9.3,<14.0.0)
|
|
25
|
+
Requires-Dist: shapely (>=2.0.6,<3.0.0)
|
|
26
|
+
Requires-Dist: sympy (>=1.13.3,<2.0.0)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This library defines the metadata format used by the sidecar files that accompany
|
|
3
|
+
the images produced by Hyperfield satellites.
|
|
4
|
+
|
|
5
|
+
The core library used is pydantic which among other nice things allows us to
|
|
6
|
+
(de)serialize (from)to JSON .
|
|
7
|
+
|
|
8
|
+
If you are interested on reading from the Hyperfield metadata database into such
|
|
9
|
+
objects then you want to look at the `hyperfield-db` project.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .sections_common import MetadataBase
|
|
13
|
+
from .sections_l0 import MetadataLevel0
|
|
14
|
+
from .sections_l1 import MetadataLevel1AB, MetadataLevel1C
|
|
15
|
+
from .sections_l2 import MetadataLevel2A
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"MetadataBase",
|
|
19
|
+
"MetadataLevel0",
|
|
20
|
+
"MetadataLevel1AB",
|
|
21
|
+
"MetadataLevel1C",
|
|
22
|
+
"MetadataLevel2A",
|
|
23
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Custom classes to store in Pydantic models"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
from rasterio.crs import CRS
|
|
6
|
+
from shapely import Point, Polygon, get_coordinates
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CRSGeometry(BaseModel):
|
|
10
|
+
"""
|
|
11
|
+
Store a `shapely.geometry` together with the relevant CRS.
|
|
12
|
+
|
|
13
|
+
Attributes
|
|
14
|
+
----------
|
|
15
|
+
geom: Polygon
|
|
16
|
+
Shapely polygon with the geometry
|
|
17
|
+
crs_epsg: rasterio.crs.CRS
|
|
18
|
+
CRS over which the polygon is represented.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
geom: Polygon | Point
|
|
22
|
+
crs_epsg: CRS
|
|
23
|
+
model_config = ConfigDict(
|
|
24
|
+
validate_assignment=True,
|
|
25
|
+
arbitrary_types_allowed=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def numpy(self) -> np.ndarray:
|
|
30
|
+
"""Turn the geometry into a numpy array of polygon coordinates.
|
|
31
|
+
|
|
32
|
+
Notes
|
|
33
|
+
-----
|
|
34
|
+
This loses the CRS information so make sure to keep track of it elsewhere
|
|
35
|
+
"""
|
|
36
|
+
return get_coordinates(self.geom, include_z=True).squeeze()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Geometry utilities to find the footprint associated with an in orbit camera"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import quaternion
|
|
9
|
+
import shapely
|
|
10
|
+
from kuva_geometry import ellipsoid, geometry
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .sections_l0 import Camera, Frame
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_sensor_corner_rays(
|
|
17
|
+
camera: Camera,
|
|
18
|
+
position: np.ndarray,
|
|
19
|
+
orientation: quaternion.quaternion,
|
|
20
|
+
use_negative_sensor_plane: bool = False,
|
|
21
|
+
):
|
|
22
|
+
"""Get the rays emanating from a camera associated with the corners of the sensor
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
camera
|
|
27
|
+
Camera intrinsic parameters
|
|
28
|
+
position
|
|
29
|
+
The position of the satellite in ECEF coordinates expressed in meters (3 values)
|
|
30
|
+
orientation
|
|
31
|
+
Quaternion describing the orientation of the satellite wrt ECEF
|
|
32
|
+
use_negative_sensor_plane, optional
|
|
33
|
+
Whether to use the negative sensor plane to calculate the rays, by default False
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
The vectors that join the back nodal point to the sensor corners.
|
|
38
|
+
"""
|
|
39
|
+
ray_origin, rays = geometry.get_sensor_corner_rays(
|
|
40
|
+
np.array(position),
|
|
41
|
+
orientation,
|
|
42
|
+
camera.focal_distance.to("meters").magnitude,
|
|
43
|
+
camera.width.to("meters").magnitude,
|
|
44
|
+
camera.height.to("meters").magnitude,
|
|
45
|
+
use_negative_sensor_plane,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return ray_origin, rays
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_sensor_rays(
|
|
52
|
+
sensor_coords: np.ndarray,
|
|
53
|
+
camera: Camera,
|
|
54
|
+
position: np.ndarray,
|
|
55
|
+
orientation: quaternion.quaternion,
|
|
56
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
57
|
+
"""
|
|
58
|
+
Produce an array with the ray vectors for a collections of sensor coords.
|
|
59
|
+
|
|
60
|
+
Sensor coords are measured from the center of the sensor and are valid within
|
|
61
|
+
[-0.5, 0.5]^2.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
sensor_coords
|
|
66
|
+
A rank-2 tensor of dimension (n_sensor_coords, 2)
|
|
67
|
+
camera
|
|
68
|
+
Camera intrinsic parameters
|
|
69
|
+
position
|
|
70
|
+
The position of the satellite in ECEF coordinates expressed in meters (3 values)
|
|
71
|
+
orientation
|
|
72
|
+
Quaternion describing the orientation of the satellite wrt ECEF
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
The position from which the rays emanate, i.e. the camera position and rank 2 tensor
|
|
77
|
+
of dimensions (n_sensor_coords, 3) describing the direction vector of the rays.
|
|
78
|
+
"""
|
|
79
|
+
rays = geometry.get_sensor_rays(
|
|
80
|
+
sensor_coords,
|
|
81
|
+
position,
|
|
82
|
+
orientation,
|
|
83
|
+
camera.focal_distance.to("meters").magnitude,
|
|
84
|
+
camera.width.to("meters").magnitude,
|
|
85
|
+
camera.height.to("meters").magnitude,
|
|
86
|
+
use_negative_sensor_plane=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return position, rays
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def frame_ray_Earth_intersections(
|
|
93
|
+
sensor_coords: np.ndarray, frame: Frame, camera: Camera
|
|
94
|
+
) -> list[shapely.Point]:
|
|
95
|
+
"""
|
|
96
|
+
Return the intersecton of camera ray with the WGS84 ellipsoid.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
sensor_coords
|
|
101
|
+
The coordinates of the ray we are interested on. Within sensor coords range
|
|
102
|
+
between +- 0.5 on both axis.
|
|
103
|
+
frame
|
|
104
|
+
Information about how the frame was taken. Namely camera orientation and
|
|
105
|
+
position.
|
|
106
|
+
camera
|
|
107
|
+
The camera parameters
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
Intersection of camera rays on the Earth
|
|
112
|
+
"""
|
|
113
|
+
sat_pos, sat_ecef_orientation = frame.position.numpy, frame.sat_ecef_orientation
|
|
114
|
+
|
|
115
|
+
# Make sure the order is correct, i.e. begin from the right-hand side!
|
|
116
|
+
# The process is the same as for how we calculate homographies.
|
|
117
|
+
orientation = sat_ecef_orientation * camera.sensor_wrt_sat_axis_quaternion
|
|
118
|
+
|
|
119
|
+
_, rays = get_sensor_rays(sensor_coords, camera, sat_pos, orientation)
|
|
120
|
+
|
|
121
|
+
rays = rays / np.sqrt((rays**2).sum(axis=1))[:, None]
|
|
122
|
+
|
|
123
|
+
intersections = [
|
|
124
|
+
shapely.Point(*ellipsoid.ray_Earth_intersection(sat_pos, rays[idx]))
|
|
125
|
+
for idx in range(rays.shape[0])
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
return intersections
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def frame_footprint(
|
|
132
|
+
frame: Frame,
|
|
133
|
+
camera: Camera,
|
|
134
|
+
use_negative_sensor_plane: bool = False,
|
|
135
|
+
) -> shapely.Polygon:
|
|
136
|
+
"""Find the footprint on the ground associated with the corners rays of a camera
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
frame
|
|
141
|
+
Information about how the frame was taken. Namely camera orientation and
|
|
142
|
+
position.
|
|
143
|
+
camera
|
|
144
|
+
The camera parameters
|
|
145
|
+
use_negative_sensor_plane, optional
|
|
146
|
+
Whether to use the negative sensor plane to calculate the rays, by default False
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
The footprint on the ground as a polygon
|
|
151
|
+
"""
|
|
152
|
+
sat_pos, sat_ecef_orientation = frame.position.numpy, frame.sat_ecef_orientation
|
|
153
|
+
|
|
154
|
+
orientation = sat_ecef_orientation * camera.sensor_wrt_sat_axis_quaternion
|
|
155
|
+
|
|
156
|
+
_, sensor_corners_ray = get_sensor_corner_rays(
|
|
157
|
+
camera, sat_pos, orientation, use_negative_sensor_plane
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
sensor_corners_ray = (
|
|
161
|
+
sensor_corners_ray / np.sqrt((sensor_corners_ray**2).sum(axis=1))[:, None]
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
footprint = [
|
|
165
|
+
ellipsoid.ray_Earth_intersection(sat_pos, sensor_corners_ray[idx])
|
|
166
|
+
for idx in range(sensor_corners_ray.shape[0])
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
# Polygons are ordered X,Y despite the CRS being lat, long i.e. x,y. If you
|
|
170
|
+
# leave the points in the order they come in the 4326 CRS things will break.
|
|
171
|
+
footprint = shapely.Polygon(footprint)
|
|
172
|
+
|
|
173
|
+
return footprint
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Additional type aliases"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from pint import Quantity
|
|
5
|
+
from quaternion import quaternion
|
|
6
|
+
from shapely import Polygon
|
|
7
|
+
|
|
8
|
+
# All this types with lists would be better expressed with tuples but pydantic
|
|
9
|
+
# reads the json as lists so we have a type to match.
|
|
10
|
+
Quantity_ = Quantity | list[float | str]
|
|
11
|
+
quaternion_ = quaternion | list[float]
|
|
12
|
+
array_3x3_ = np.ndarray | list[list[float]]
|
|
13
|
+
Polygon_ = Polygon | str
|
|
File without changes
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import pytz
|
|
7
|
+
from pint import Quantity, UnitRegistry
|
|
8
|
+
from pydantic import (
|
|
9
|
+
UUID4,
|
|
10
|
+
BaseModel,
|
|
11
|
+
ConfigDict,
|
|
12
|
+
field_serializer,
|
|
13
|
+
field_validator,
|
|
14
|
+
)
|
|
15
|
+
from rasterio.rpc import RPC
|
|
16
|
+
|
|
17
|
+
from kuva_metadata.serializers import serialize_RPCs
|
|
18
|
+
from kuva_metadata.validators import check_is_utc_datetime, parse_rpcs, parse_timestamp
|
|
19
|
+
|
|
20
|
+
_T = typing.TypeVar("_T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Header(BaseModel):
|
|
24
|
+
"""Header for the metadata files
|
|
25
|
+
|
|
26
|
+
Attributes
|
|
27
|
+
----------
|
|
28
|
+
version
|
|
29
|
+
Version of the library used to create the file.
|
|
30
|
+
author
|
|
31
|
+
The author of the file.
|
|
32
|
+
creation_date
|
|
33
|
+
Creation date for the metadata file.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
version: str
|
|
37
|
+
author: str
|
|
38
|
+
creation_date: datetime = datetime.now(tz=pytz.utc)
|
|
39
|
+
|
|
40
|
+
_parse_timestamp = field_validator("creation_date", mode="before")(parse_timestamp)
|
|
41
|
+
_check_tz = field_validator("creation_date")(check_is_utc_datetime)
|
|
42
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Satellite(BaseModel):
|
|
46
|
+
"""Specifies the information relating to the satellite from which the file images
|
|
47
|
+
where acquired.
|
|
48
|
+
|
|
49
|
+
Attributes
|
|
50
|
+
----------
|
|
51
|
+
name
|
|
52
|
+
Short name of the satellite.
|
|
53
|
+
cospar_id
|
|
54
|
+
International designator assigned to the satellite after launch.
|
|
55
|
+
launch_date
|
|
56
|
+
When the satellite was launched
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
cospar_id: str
|
|
61
|
+
launch_date: datetime
|
|
62
|
+
|
|
63
|
+
_parse_timestamp = field_validator("launch_date", mode="before")(parse_timestamp)
|
|
64
|
+
_check_tz = field_validator("launch_date")(check_is_utc_datetime)
|
|
65
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Radiometry(BaseModel):
|
|
69
|
+
"""Information required for TOA calculations and physical units
|
|
70
|
+
|
|
71
|
+
Attributes
|
|
72
|
+
----------
|
|
73
|
+
lut_file
|
|
74
|
+
A lookup table stored together with the image file that associates raw image
|
|
75
|
+
values to a radiance for different wavelengths and integration times. Stored as
|
|
76
|
+
a numpy `npy` file.
|
|
77
|
+
sun_spectrum_file
|
|
78
|
+
Sun spectrum radiance of each band. Required for top of atmosphere calculation.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
lut_file: Path
|
|
82
|
+
sun_spectrum_file: Path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RPCoefficients(BaseModel):
|
|
86
|
+
"""Rational polynomial function coefficients for orthorectification.
|
|
87
|
+
|
|
88
|
+
A rational polynomial functions is simply a function which is the ratio of two
|
|
89
|
+
polynomials. In our case we have two functions that are R^3 -> R^2 and map world
|
|
90
|
+
coordinates to pixel space. The first function maps the x coordinates and the
|
|
91
|
+
second the y coordinates.
|
|
92
|
+
|
|
93
|
+
Attributes
|
|
94
|
+
----------
|
|
95
|
+
rpcs
|
|
96
|
+
Rational polynomial function coefficients for orthorectification
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
rpcs: RPC
|
|
100
|
+
|
|
101
|
+
_parse_rpcs = field_validator("rpcs", mode="before")(parse_rpcs)
|
|
102
|
+
|
|
103
|
+
@field_serializer("rpcs")
|
|
104
|
+
def _serialize_RPCs(self, rpcs: RPC):
|
|
105
|
+
return serialize_RPCs(rpcs)
|
|
106
|
+
|
|
107
|
+
model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class BaseModelWithUnits(BaseModel, typing.Generic[_T]):
|
|
111
|
+
"""Allows an pint unit registry to be plugged in one of the classes using units"""
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def model_validate_json_with_ureg(
|
|
115
|
+
cls, json_data: str, new_ureg: UnitRegistry, **val_kwargs
|
|
116
|
+
) -> _T:
|
|
117
|
+
"""Will create a model from JSON data. However, the data will be copied so that
|
|
118
|
+
each Quantity in all submodels is recursively converted to a new given
|
|
119
|
+
UnitRegistry. If data is read from a JSON file without this method, it will be
|
|
120
|
+
attached to the kuva-metadata default UnitRegistry.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
json_data
|
|
125
|
+
Model data to validate
|
|
126
|
+
new_ureg
|
|
127
|
+
Pint UnitRegistry to swap to
|
|
128
|
+
|
|
129
|
+
Returns
|
|
130
|
+
-------
|
|
131
|
+
The validated model instance
|
|
132
|
+
"""
|
|
133
|
+
model_instance = cls.model_validate_json(json_data, **val_kwargs)
|
|
134
|
+
swapped_instance = cast(
|
|
135
|
+
_T, swap_ureg_in_instance(model_instance, new_ureg, **val_kwargs)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return swapped_instance
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MetadataBase(BaseModelWithUnits):
|
|
142
|
+
"""Base class for all product levels' metadata
|
|
143
|
+
|
|
144
|
+
Attributes
|
|
145
|
+
----------
|
|
146
|
+
id
|
|
147
|
+
Metadata ID for identifying metadata from DB
|
|
148
|
+
header
|
|
149
|
+
Metadata file header
|
|
150
|
+
satellite
|
|
151
|
+
Satellite the metadata's product has been created for
|
|
152
|
+
image
|
|
153
|
+
Image that the metadata is associated to
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
id: UUID4
|
|
157
|
+
header: Header
|
|
158
|
+
satellite: Satellite
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def swap_ureg_in_instance(obj: BaseModel, new_ureg: UnitRegistry, **val_kwargs):
|
|
162
|
+
"""Swaps Pint UnitRegistry recursively within a pydantic model.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
obj
|
|
167
|
+
Instance of a model
|
|
168
|
+
new_ureg
|
|
169
|
+
Pint UnitRegistry to swap to
|
|
170
|
+
val_kwargs
|
|
171
|
+
Keyword arguments that are required in model validation, e.g. a pydantic context
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
The validated model instance which now has the new UnitRegistry in its or its
|
|
176
|
+
child objects' Quantities
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def _replace_ureg(value):
|
|
180
|
+
"""Helper recursion function to correctly go through the different attributes"""
|
|
181
|
+
if isinstance(value, Quantity):
|
|
182
|
+
return new_ureg.Quantity(value.magnitude, value.units)
|
|
183
|
+
elif isinstance(value, BaseModel):
|
|
184
|
+
return swap_ureg_in_instance(value, new_ureg, **val_kwargs)
|
|
185
|
+
elif isinstance(value, (list, tuple, set)):
|
|
186
|
+
return type(value)(_replace_ureg(v) for v in value)
|
|
187
|
+
elif isinstance(value, dict):
|
|
188
|
+
return {k: _replace_ureg(v) for k, v in value.items()}
|
|
189
|
+
else:
|
|
190
|
+
return value
|
|
191
|
+
|
|
192
|
+
field_values = obj.model_dump(**val_kwargs)
|
|
193
|
+
updated_field_values = _replace_ureg(field_values)
|
|
194
|
+
return obj.__class__.model_validate(updated_field_values, **val_kwargs)
|