From 2cb302d4893696bef2a2789e1a1d58573aee3b9a Mon Sep 17 00:00:00 2001 From: cophus Date: Tue, 2 Dec 2025 13:11:35 -0800 Subject: [PATCH 01/88] adding support for 3d dm4 files --> 4dstem --- src/quantem/core/io/file_readers.py | 222 +++++++++++++++++++++++++--- 1 file changed, 200 insertions(+), 22 deletions(-) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index cb36f1de..c07d9fbb 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -3,6 +3,7 @@ from pathlib import Path import h5py +import numpy as np from quantem.core.datastructures import Dataset as Dataset from quantem.core.datastructures import Dataset2d as Dataset2d @@ -14,57 +15,234 @@ def read_4dstem( file_path: str | PathLike, file_type: str | None = None, dataset_index: int | None = None, + scan_length: int | None = None, + scan_axis: int = 0, + transpose_scan_axes: bool = False, **kwargs, ) -> Dataset4dstem: """ - File reader for 4D-STEM data + File reader for 4D-STEM data. Parameters ---------- - file_path: str | PathLike - Path to data - file_type: str - The type of file reader needed. See rosettasciio for supported formats + file_path : str | PathLike + Path to data. + file_type : str, optional + The type of file reader needed. See RosettaSciIO for supported formats: https://hyperspy.org/rosettasciio/supported_formats/index.html - dataset_index: int, optional + dataset_index : int, optional Index of the dataset to load if file contains multiple datasets. If None, automatically selects the first 4D dataset found. - **kwargs: dict + If no 4D dataset is found but a 3D stack exists, a 3D dataset can be + interpreted as 4D if `scan_length` is provided. + scan_length : int, optional + For 3D datasets shaped (n_frames, ny, nx) (after possibly moving the + scan axis to the front), interpret the data as a raster scan with shape + (scan_y, scan_x, ny, nx), where scan_y = n_frames // scan_length and + scan_x = scan_length. Required if you want to treat a 3D stack as 4D. + scan_axis : int, default 0 + Which axis of a 3D dataset is the scan/time axis before reshaping. + Must be 0 or 1. The specified axis is moved to axis 0 before the + (scan_y, scan_x) reshape. + transpose_scan_axes : bool, default False + Only used when interpreting a 3D dataset as 4D via `scan_length`. + If True, transpose the scan axes after reshaping so that + (scan_y, scan_x) -> (scan_x, scan_y). This effectively swaps the + interpretation of scan rows and columns in the final 4D array. + + **kwargs : dict Additional keyword arguments to pass to the Dataset4dstem constructor. Returns - -------- + ------- Dataset4dstem """ + + def _reshape_3d_to_4d( + imported_data: dict, + *, + dataset_index_local: int | None, + scan_length_local: int, + scan_axis_local: int, + transpose_scan_axes_local: bool, + ) -> dict: + data = imported_data["data"] + if data.ndim != 3: + raise ValueError( + f"Expected 3D data to reshape, got ndim={data.ndim} " + f"with shape {data.shape}" + ) + + if scan_axis_local not in (0, 1): + raise ValueError(f"scan_axis must be 0 or 1, got {scan_axis_local}") + + # Move scan axis to front so it becomes the frame axis + if scan_axis_local != 0: + data = np.moveaxis(data, scan_axis_local, 0) + + n_frames, ny, nx = data.shape + + if scan_length_local <= 0: + raise ValueError(f"scan_length must be positive, got {scan_length_local}") + if n_frames % scan_length_local != 0: + raise ValueError( + f"scan_length={scan_length_local} is not compatible with n_frames={n_frames}; " + f"n_frames % scan_length = {n_frames % scan_length_local}" + ) + + scan_y = n_frames // scan_length_local + scan_x = scan_length_local + + data_4d = data.reshape(scan_y, scan_x, ny, nx) + + if transpose_scan_axes_local: + data_4d = np.transpose(data_4d, (1, 0, 2, 3)) + scan_y, scan_x = scan_x, scan_y + + old_axes = imported_data.get("axes", None) + if old_axes is None or len(old_axes) != 3: + raise ValueError( + "Expected 3 axes for 3D data when reshaping to 4D; " + f"got axes={old_axes}" + ) + + ax_scan_y = { + "scale": 1.0, + "offset": 0.0, + "units": "pixels", + "name": "scan_y", + } + ax_scan_x = { + "scale": 1.0, + "offset": 0.0, + "units": "pixels", + "name": "scan_x", + } + + ax_qy = dict(old_axes[1]) + ax_qx = dict(old_axes[2]) + + imported_data_4d = imported_data.copy() + imported_data_4d["data"] = data_4d + imported_data_4d["axes"] = [ax_scan_y, ax_scan_x, ax_qy, ax_qx] + + original_shape = imported_data["data"].shape + new_shape = data_4d.shape + if dataset_index_local is not None: + print( + f"Using 3D dataset {dataset_index_local} with shape {original_shape} " + f"interpreted as 4D with shape={new_shape} " + f"(scan_axis={scan_axis_local}, scan_length={scan_length_local}, " + f"transpose_scan_axes={transpose_scan_axes_local})." + ) + else: + print( + f"Using 3D dataset with shape {original_shape} " + f"interpreted as 4D with shape={new_shape} " + f"(scan_axis={scan_axis_local}, scan_length={scan_length_local}, " + f"transpose_scan_axes={transpose_scan_axes_local})." + ) + + return imported_data_4d + if file_type is None: file_type = Path(file_path).suffix.lower().lstrip(".") file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader data_list = file_reader(file_path) - # If specific index provided, use it + if not data_list: + raise ValueError(f"No datasets returned by rsciio.{file_type} for '{file_path}'") + + # Case 1: dataset_index specified explicitly if dataset_index is not None: imported_data = data_list[dataset_index] - if imported_data["data"].ndim != 4: + ndim = imported_data["data"].ndim + + if ndim == 4: + # Use 4D as-is + pass + elif ndim == 3: + if scan_length is None: + raise ValueError( + f"Dataset at index {dataset_index} is 3D (shape={imported_data['data'].shape}). " + "To interpret it as 4D-STEM, please provide scan_length." + ) + imported_data = _reshape_3d_to_4d( + imported_data, + dataset_index_local=dataset_index, + scan_length_local=scan_length, + scan_axis_local=scan_axis, + transpose_scan_axes_local=transpose_scan_axes, + ) + else: raise ValueError( - f"Dataset at index {dataset_index} has {imported_data['data'].ndim} dimensions, " - f"expected 4D. Shape: {imported_data['data'].shape}" + f"Dataset at index {dataset_index} has ndim={ndim}, " + f"expected 4D or 3D. Shape: {imported_data['data'].shape}" ) + else: - # Automatically find first 4D dataset + # Case 2: auto-select dataset four_d_datasets = [(i, d) for i, d in enumerate(data_list) if d["data"].ndim == 4] - if len(four_d_datasets) == 0: - print(f"No 4D datasets found in {file_path}. Available datasets:") - for i, d in enumerate(data_list): - print(f" Dataset {i}: shape {d['data'].shape}, ndim={d['data'].ndim}") - raise ValueError("No 4D dataset found in file") + if four_d_datasets: + dataset_index, imported_data = four_d_datasets[0] + if len(data_list) > 1: + print( + f"File contains {len(data_list)} dataset(s). Using 4D dataset " + f"{dataset_index} with shape {imported_data['data'].shape}" + ) + else: + three_d_datasets = [(i, d) for i, d in enumerate(data_list) if d["data"].ndim == 3] - dataset_index, imported_data = four_d_datasets[0] + if not three_d_datasets: + print(f"No 4D datasets found in {file_path}. Available datasets:") + for i, d in enumerate(data_list): + print(f" Dataset {i}: shape {d['data'].shape}, ndim={d['data'].ndim}") + raise ValueError("No 4D or 3D dataset found in file") - if len(data_list) > 1: - print( - f"File contains {len(data_list)} dataset(s). Using dataset {dataset_index} with shape {imported_data['data'].shape}" + if scan_length is None: + print(f"No 4D datasets found in {file_path}. Available datasets:") + for i, d in enumerate(data_list): + print(f" Dataset {i}: shape {d['data'].shape}, ndim={d['data'].ndim}") + raise ValueError( + "File contains only 3D datasets. To interpret one as 4D-STEM, " + "please specify scan_length so that n_frames % scan_length == 0." + ) + + # Choose first 3D dataset compatible with scan_length along scan_axis + candidates: list[tuple[int, dict]] = [] + for i, d in three_d_datasets: + shape = d["data"].shape + if scan_axis < 0 or scan_axis > 2: + raise ValueError(f"scan_axis must be in [0, 2] for 3D data, got {scan_axis}") + n_frames_axis = shape[scan_axis] + if n_frames_axis % scan_length == 0: + candidates.append((i, d)) + + if not candidates: + print(f"3D datasets in {file_path}:") + for i, d in three_d_datasets: + print(f" Dataset {i}: shape {d['data'].shape}") + raise ValueError( + f"No 3D dataset has length along scan_axis={scan_axis} " + f"divisible by scan_length={scan_length}." + ) + + dataset_index, imported_data = candidates[0] + if len(candidates) > 1: + print( + f"Multiple 3D datasets compatible with scan_length={scan_length} " + f"along scan_axis={scan_axis}. Using dataset {dataset_index} " + f"with shape {imported_data['data'].shape}" + ) + + imported_data = _reshape_3d_to_4d( + imported_data, + dataset_index_local=dataset_index, + scan_length_local=scan_length, + scan_axis_local=scan_axis, + transpose_scan_axes_local=transpose_scan_axes, ) imported_axes = imported_data["axes"] From d88ffb5be0cae76f01b9c85f6f2a8c2751519ec2 Mon Sep 17 00:00:00 2001 From: cophus Date: Wed, 3 Dec 2025 09:11:15 -0800 Subject: [PATCH 02/88] initial construction of polar4dstem class --- src/quantem/core/datastructures/__init__.py | 1 + .../core/datastructures/dataset4dstem.py | 5 + .../core/datastructures/polar4dstem.py | 330 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 src/quantem/core/datastructures/polar4dstem.py diff --git a/src/quantem/core/datastructures/__init__.py b/src/quantem/core/datastructures/__init__.py index dfb5b47a..ac8f3d64 100644 --- a/src/quantem/core/datastructures/__init__.py +++ b/src/quantem/core/datastructures/__init__.py @@ -2,6 +2,7 @@ from quantem.core.datastructures.vector import Vector as Vector from quantem.core.datastructures.dataset4dstem import Dataset4dstem as Dataset4dstem +from quantem.core.datastructures.polar4dstem import Polar4dstem as Polar4dstem from quantem.core.datastructures.dataset4d import Dataset4d as Dataset4d from quantem.core.datastructures.dataset3d import Dataset3d as Dataset3d from quantem.core.datastructures.dataset2d import Dataset2d as Dataset2d diff --git a/src/quantem/core/datastructures/dataset4dstem.py b/src/quantem/core/datastructures/dataset4dstem.py index 28328636..2d4c860e 100644 --- a/src/quantem/core/datastructures/dataset4dstem.py +++ b/src/quantem/core/datastructures/dataset4dstem.py @@ -7,6 +7,8 @@ from quantem.core.datastructures.dataset2d import Dataset2d from quantem.core.datastructures.dataset4d import Dataset4d +from quantem.core.datastructures.polar4dstem import dataset4dstem_polar_transform + from quantem.core.utils.validators import ensure_valid_array from quantem.core.visualization import show_2d from quantem.core.visualization.visualization_utils import ScalebarConfig @@ -751,3 +753,6 @@ def median_filter_masked_pixels(self, mask: np.ndarray, kernel_width: int = 3): self.array[:, :, index_x, index_y] = np.median( self.array[:, :, x_min:x_max, y_min:y_max], axis=(2, 3) ) + + + polar_transform = dataset4dstem_polar_transform \ No newline at end of file diff --git a/src/quantem/core/datastructures/polar4dstem.py b/src/quantem/core/datastructures/polar4dstem.py new file mode 100644 index 00000000..848469ec --- /dev/null +++ b/src/quantem/core/datastructures/polar4dstem.py @@ -0,0 +1,330 @@ +import numpy as np +from numpy.typing import NDArray +from typing import Any, TYPE_CHECKING + +from quantem.core.datastructures.dataset4d import Dataset4d +# from quantem.core.datastructures.dataset4dstem import Dataset4dstem + +if TYPE_CHECKING: + from .dataset4dstem import Dataset4dstem + +class Polar4dstem(Dataset4d): + """4D-STEM dataset in polar coordinates (scan_y, scan_x, phi, r).""" + + def __init__( + self, + array: NDArray | Any, + name: str, + origin: NDArray | tuple | list | float | int, + sampling: NDArray | tuple | list | float | int, + units: list[str] | tuple | list, + signal_units: str = "arb. units", + metadata: dict | None = None, + _token: object | None = None, + ): + if metadata is None: + metadata = {} + mdata_keys_polar = [ + "polar_radial_min", + "polar_radial_max", + "polar_radial_step", + "polar_num_annular_bins", + "polar_two_fold_rotation_symmetry", + "polar_origin_row", + "polar_origin_col", + "polar_ellipse_params", + ] + for k in mdata_keys_polar: + if k not in metadata: + metadata[k] = None + + super().__init__( + array=array, + name=name, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + metadata=metadata, + _token=_token, + ) + + @classmethod + def from_array( + cls, + array: NDArray | Any, + name: str | None = None, + origin: NDArray | tuple | list | float | int | None = None, + sampling: NDArray | tuple | list | float | int | None = None, + units: list[str] | tuple | list | None = None, + signal_units: str = "arb. units", + metadata: dict | None = None, + ) -> "Polar4dstem": + array = ensure_valid_array(array, ndim=4) + if origin is None: + origin = np.zeros(4) + if sampling is None: + sampling = np.ones(4) + if units is None: + units = ["pixels", "pixels", "deg", "pixels"] + if metadata is None: + metadata = {} + return cls( + array=array, + name=name if name is not None else "Polar 4D-STEM dataset", + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + metadata=metadata, + _token=cls._token, + ) + + @property + def n_phi(self) -> int: + return int(self.array.shape[2]) + + @property + def n_r(self) -> int: + return int(self.array.shape[3]) + + +def dataset4dstem_polar_transform( + self: "Dataset4dstem", + origin_row: float | NDArray, + origin_col: float | NDArray, + ellipse_params: tuple[float, float, float] | None = None, + num_annular_bins: int = 180, + radial_min: float = 0.0, + radial_max: float | None = None, + radial_step: float = 1.0, + two_fold_rotation_symmetry: bool = False, + name: str | None = None, + signal_units: str | None = None, +) -> Polar4dstem: + """Return a Polar4dstem with shape (scan_y, scan_x, phi, r).""" + if self.array.ndim != 4: + raise ValueError("polar_transform requires a 4D-STEM dataset (ndim=4).") + + scan_y, scan_x, ny, nx = self.array.shape + + mapping = _precompute_polar_mapping( + ny=ny, + nx=nx, + origin_row=float(origin_row), + origin_col=float(origin_col), + ellipse_params=ellipse_params, + num_annular_bins=num_annular_bins, + radial_min=radial_min, + radial_max=radial_max, + radial_step=radial_step, + two_fold_rotation_symmetry=two_fold_rotation_symmetry, + ) + + result_dtype = np.result_type(self.array.dtype, np.float32) + out = np.empty( + (scan_y, scan_x, mapping["n_phi"], mapping["n_r"]), + dtype=result_dtype, + ) + + for iy in range(scan_y): + for ix in range(scan_x): + out[iy, ix] = _apply_polar_mapping_single( + self.array[iy, ix], + mapping, + dtype=result_dtype, + ) + + phi_step_deg = mapping["phi_step"] * 180.0 / np.pi + phi_units = "deg" + radial_units = self.units[-1] + + sampling = np.array( + [ + self.sampling[0], + self.sampling[1], + phi_step_deg, + self.sampling[-1] * mapping["radial_step"], + ], + dtype=float, + ) + origin = np.array( + [ + self.origin[0], + self.origin[1], + 0.0, + self.sampling[-1] * mapping["radial_min"], + ], + dtype=float, + ) + units = [ + self.units[0], + self.units[1], + phi_units, + radial_units, + ] + + metadata = dict(self.metadata) + metadata.update( + { + "polar_radial_min": mapping["radial_min"], + "polar_radial_max": mapping["radial_max"], + "polar_radial_step": mapping["radial_step"], + "polar_num_annular_bins": mapping["n_phi"], + "polar_two_fold_rotation_symmetry": two_fold_rotation_symmetry, + "polar_origin_row": float(origin_row), + "polar_origin_col": float(origin_col), + "polar_ellipse_params": tuple(ellipse_params) + if ellipse_params is not None + else None, + } + ) + + return Polar4dstem( + array=out, + name=name if name is not None else f"{self.name}_polar", + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units if signal_units is not None else self.signal_units, + metadata=metadata, + _token=Polar4dstem._token, + ) + + +def _precompute_polar_mapping( + ny: int, + nx: int, + origin_row: float, + origin_col: float, + ellipse_params: tuple[float, float, float] | None, + num_annular_bins: int, + radial_min: float, + radial_max: float | None, + radial_step: float, + two_fold_rotation_symmetry: bool, +) -> dict[str, Any]: + origin_row = float(origin_row) + origin_col = float(origin_col) + annular_range = np.pi if two_fold_rotation_symmetry else 2.0 * np.pi + + rows = np.arange(ny, dtype=np.float64) + cols = np.arange(nx, dtype=np.float64) + cc, rr = np.meshgrid(cols, rows, indexing="xy") + x = cc - origin_col + y = rr - origin_row + + if ellipse_params is None: + rr_pix = np.sqrt(x * x + y * y) + tt = np.mod(np.arctan2(y, x), annular_range) + else: + if len(ellipse_params) != 3: + raise ValueError("ellipse_params must be a length-3 tuple (a, b, theta_deg).") + a, b, theta_deg = ellipse_params + theta = np.deg2rad(theta_deg) + cos_t = np.cos(theta) + sin_t = np.sin(theta) + xc = x * cos_t + y * sin_t + yc = (y * cos_t - x * sin_t) * (a / b) + rr_pix = (b / a) * np.hypot(xc, yc) + tt = np.mod(np.arctan2(yc, xc) + theta, annular_range) + + if radial_step <= 0: + raise ValueError("radial_step must be > 0.") + radial_min = float(radial_min) + + if radial_max is None: + radial_max_eff = float(rr_pix.max()) + else: + radial_max_eff = float(radial_max) + if radial_max_eff <= radial_min + radial_step: + radial_max_eff = radial_min + radial_step + + radial_bins = np.arange(radial_min, radial_max_eff, radial_step, dtype=np.float64) + n_r = radial_bins.size + if n_r < 1: + raise ValueError("No radial bins defined. Check radial_min, radial_max, and radial_step.") + + n_phi = int(num_annular_bins) + if n_phi < 1: + raise ValueError("num_annular_bins must be >= 1.") + phi_step = annular_range / n_phi + + r_bin = (rr_pix - radial_min) / radial_step + t_bin = tt / phi_step + + r0 = np.floor(r_bin).astype(np.int64) + t0 = np.floor(t_bin).astype(np.int64) + dr = (r_bin - r0).astype(np.float64) + dt = (t_bin - t0).astype(np.float64) + + valid = (r0 >= 0) & (r0 < n_r - 1) + t0 = np.clip(t0, 0, n_phi - 1) + + flat_valid = valid.ravel() + r0v = r0.ravel()[flat_valid] + t0v = t0.ravel()[flat_valid] + drv = dr.ravel()[flat_valid] + dtv = dt.ravel()[flat_valid] + + n_bins = n_phi * n_r + idx00 = r0v + n_r * t0v + idx01 = r0v + n_r * ((t0v + 1) % n_phi) + idx10 = (r0v + 1) + n_r * t0v + idx11 = (r0v + 1) + n_r * ((t0v + 1) % n_phi) + + w00 = (1.0 - drv) * (1.0 - dtv) + w01 = (1.0 - drv) * dtv + w10 = drv * (1.0 - dtv) + w11 = drv * dtv + + weights_sum = np.bincount(idx00, weights=w00, minlength=n_bins) + weights_sum += np.bincount(idx01, weights=w01, minlength=n_bins) + weights_sum += np.bincount(idx10, weights=w10, minlength=n_bins) + weights_sum += np.bincount(idx11, weights=w11, minlength=n_bins) + weights_sum = weights_sum.reshape(n_phi, n_r) + + weights_inv = np.zeros_like(weights_sum, dtype=np.float64) + mask_bins = weights_sum > 0 + weights_inv[mask_bins] = 1.0 / weights_sum[mask_bins] + + return { + "flat_valid": flat_valid, + "idx00": idx00, + "idx01": idx01, + "idx10": idx10, + "idx11": idx11, + "w00": w00, + "w01": w01, + "w10": w10, + "w11": w11, + "weights_inv": weights_inv, + "n_phi": n_phi, + "n_r": n_r, + "radial_bins": radial_bins, + "phi_step": phi_step, + "annular_range": annular_range, + "radial_min": radial_min, + "radial_max": radial_min + radial_step * n_r, + "radial_step": radial_step, + } + + +def _apply_polar_mapping_single( + image: NDArray, + mapping: dict[str, Any], + dtype: Any, +) -> NDArray: + data = np.asarray(image, dtype=np.float64) + flat = data.ravel()[mapping["flat_valid"]] + n_bins = mapping["n_phi"] * mapping["n_r"] + + acc = np.bincount(mapping["idx00"], weights=flat * mapping["w00"], minlength=n_bins) + acc += np.bincount(mapping["idx01"], weights=flat * mapping["w01"], minlength=n_bins) + acc += np.bincount(mapping["idx10"], weights=flat * mapping["w10"], minlength=n_bins) + acc += np.bincount(mapping["idx11"], weights=flat * mapping["w11"], minlength=n_bins) + + acc = acc.reshape(mapping["n_phi"], mapping["n_r"]) + acc *= mapping["weights_inv"] + return acc.astype(dtype, copy=False) + From 13b8d3d1232840adcfc3f93a059bd3df48a4a663 Mon Sep 17 00:00:00 2001 From: cophus Date: Thu, 4 Dec 2025 10:53:10 -0800 Subject: [PATCH 03/88] Changing sampling direction for polar4dstem --- .../core/datastructures/polar4dstem.py | 307 ++++++------------ src/quantem/diffraction/polar.py | 0 2 files changed, 107 insertions(+), 200 deletions(-) create mode 100644 src/quantem/diffraction/polar.py diff --git a/src/quantem/core/datastructures/polar4dstem.py b/src/quantem/core/datastructures/polar4dstem.py index 848469ec..6619af5c 100644 --- a/src/quantem/core/datastructures/polar4dstem.py +++ b/src/quantem/core/datastructures/polar4dstem.py @@ -1,13 +1,14 @@ import numpy as np from numpy.typing import NDArray from typing import Any, TYPE_CHECKING - -from quantem.core.datastructures.dataset4d import Dataset4d -# from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from scipy.ndimage import map_coordinates if TYPE_CHECKING: from .dataset4dstem import Dataset4dstem +from quantem.core.datastructures.dataset4d import Dataset4d + + class Polar4dstem(Dataset4d): """4D-STEM dataset in polar coordinates (scan_y, scan_x, phi, r).""" @@ -37,7 +38,6 @@ def __init__( for k in mdata_keys_polar: if k not in metadata: metadata[k] = None - super().__init__( array=array, name=name, @@ -60,11 +60,13 @@ def from_array( signal_units: str = "arb. units", metadata: dict | None = None, ) -> "Polar4dstem": - array = ensure_valid_array(array, ndim=4) + array = np.asarray(array) + if array.ndim != 4: + raise ValueError("Polar4dstem.from_array expects a 4D array.") if origin is None: - origin = np.zeros(4) + origin = np.zeros(4, dtype=float) if sampling is None: - sampling = np.ones(4) + sampling = np.ones(4, dtype=float) if units is None: units = ["pixels", "pixels", "deg", "pixels"] if metadata is None: @@ -89,10 +91,68 @@ def n_r(self) -> int: return int(self.array.shape[3]) +def _precompute_polar_coords( + ny: int, + nx: int, + origin_row: float, + origin_col: float, + ellipse_params: tuple[float, float, float] | None, + num_annular_bins: int, + radial_min: float, + radial_max: float | None, + radial_step: float, + two_fold_rotation_symmetry: bool, +) -> tuple[NDArray, NDArray, NDArray, float]: + origin_row = float(origin_row) + origin_col = float(origin_col) + if radial_step <= 0: + raise ValueError("radial_step must be > 0.") + if num_annular_bins < 1: + raise ValueError("num_annular_bins must be >= 1.") + if radial_max is None: + r_row_pos = origin_row + r_row_neg = (ny - 1) - origin_row + r_col_pos = origin_col + r_col_neg = (nx - 1) - origin_col + radial_max_eff = float(min(r_row_pos, r_row_neg, r_col_pos, r_col_neg)) + else: + radial_max_eff = float(radial_max) + if radial_max_eff <= radial_min: + radial_max_eff = radial_min + radial_step + radial_bins = np.arange(radial_min, radial_max_eff, radial_step, dtype=np.float64) + if radial_bins.size == 0: + radial_bins = np.array([radial_min], dtype=np.float64) + if two_fold_rotation_symmetry: + phi_range = np.pi + else: + phi_range = 2.0 * np.pi + phi_bins = np.linspace(0.0, phi_range, num_annular_bins, endpoint=False, dtype=np.float64) + phi_grid, r_grid = np.meshgrid(phi_bins, radial_bins, indexing="ij") + if ellipse_params is None: + x = r_grid * np.cos(phi_grid) + y = r_grid * np.sin(phi_grid) + else: + if len(ellipse_params) != 3: + raise ValueError("ellipse_params must be (a, b, theta_deg).") + a, b, theta_deg = ellipse_params + theta = np.deg2rad(theta_deg) + alpha = phi_grid - theta + u = (a / b) * r_grid * np.cos(alpha) + v_prime = r_grid * np.sin(alpha) + cos_t = np.cos(theta) + sin_t = np.sin(theta) + x = u * cos_t - v_prime * sin_t + y = u * sin_t + v_prime * cos_t + coords_y = y + origin_row + coords_x = x + origin_col + coords = np.stack((coords_y, coords_x), axis=0) + return coords, phi_bins, radial_bins, radial_max_eff + + def dataset4dstem_polar_transform( self: "Dataset4dstem", - origin_row: float | NDArray, - origin_col: float | NDArray, + origin_row: float | int | NDArray, + origin_col: float | int | NDArray, ellipse_params: tuple[float, float, float] | None = None, num_annular_bins: int = 180, radial_min: float = 0.0, @@ -102,17 +162,16 @@ def dataset4dstem_polar_transform( name: str | None = None, signal_units: str | None = None, ) -> Polar4dstem: - """Return a Polar4dstem with shape (scan_y, scan_x, phi, r).""" if self.array.ndim != 4: raise ValueError("polar_transform requires a 4D-STEM dataset (ndim=4).") - scan_y, scan_x, ny, nx = self.array.shape - - mapping = _precompute_polar_mapping( + origin_row_f = float(origin_row) + origin_col_f = float(origin_col) + coords, phi_bins, radial_bins, radial_max_eff = _precompute_polar_coords( ny=ny, nx=nx, - origin_row=float(origin_row), - origin_col=float(origin_col), + origin_row=origin_row_f, + origin_col=origin_col_f, ellipse_params=ellipse_params, num_annular_bins=num_annular_bins, radial_min=radial_min, @@ -120,66 +179,52 @@ def dataset4dstem_polar_transform( radial_step=radial_step, two_fold_rotation_symmetry=two_fold_rotation_symmetry, ) - + n_phi = phi_bins.size + n_r = radial_bins.size result_dtype = np.result_type(self.array.dtype, np.float32) - out = np.empty( - (scan_y, scan_x, mapping["n_phi"], mapping["n_r"]), - dtype=result_dtype, - ) - + out = np.empty((scan_y, scan_x, n_phi, n_r), dtype=result_dtype) for iy in range(scan_y): for ix in range(scan_x): - out[iy, ix] = _apply_polar_mapping_single( - self.array[iy, ix], - mapping, - dtype=result_dtype, + dp = self.array[iy, ix] + out[iy, ix] = map_coordinates( + dp, + coords, + order=1, + mode="constant", + cval=0.0, ) - - phi_step_deg = mapping["phi_step"] * 180.0 / np.pi - phi_units = "deg" - radial_units = self.units[-1] - - sampling = np.array( - [ - self.sampling[0], - self.sampling[1], - phi_step_deg, - self.sampling[-1] * mapping["radial_step"], - ], - dtype=float, - ) - origin = np.array( - [ - self.origin[0], - self.origin[1], - 0.0, - self.sampling[-1] * mapping["radial_min"], - ], - dtype=float, - ) + if two_fold_rotation_symmetry: + phi_range = np.pi + else: + phi_range = 2.0 * np.pi + phi_step_deg = (phi_range / float(n_phi)) * (180.0 / np.pi) + sampling = np.zeros(4, dtype=float) + origin = np.zeros(4, dtype=float) + sampling[0:2] = np.asarray(self.sampling)[0:2] + sampling[2] = phi_step_deg + sampling[3] = float(np.asarray(self.sampling)[-1]) * radial_step + origin[0:2] = np.asarray(self.origin)[0:2] + origin[2] = 0.0 + origin[3] = radial_min * float(np.asarray(self.sampling)[-1]) units = [ self.units[0], self.units[1], - phi_units, - radial_units, + "deg", + self.units[-1], ] - metadata = dict(self.metadata) metadata.update( { - "polar_radial_min": mapping["radial_min"], - "polar_radial_max": mapping["radial_max"], - "polar_radial_step": mapping["radial_step"], - "polar_num_annular_bins": mapping["n_phi"], - "polar_two_fold_rotation_symmetry": two_fold_rotation_symmetry, - "polar_origin_row": float(origin_row), - "polar_origin_col": float(origin_col), - "polar_ellipse_params": tuple(ellipse_params) - if ellipse_params is not None - else None, + "polar_radial_min": float(radial_min), + "polar_radial_max": float(radial_max_eff), + "polar_radial_step": float(radial_step), + "polar_num_annular_bins": int(n_phi), + "polar_two_fold_rotation_symmetry": bool(two_fold_rotation_symmetry), + "polar_origin_row": origin_row_f, + "polar_origin_col": origin_col_f, + "polar_ellipse_params": tuple(ellipse_params) if ellipse_params is not None else None, } ) - return Polar4dstem( array=out, name=name if name is not None else f"{self.name}_polar", @@ -190,141 +235,3 @@ def dataset4dstem_polar_transform( metadata=metadata, _token=Polar4dstem._token, ) - - -def _precompute_polar_mapping( - ny: int, - nx: int, - origin_row: float, - origin_col: float, - ellipse_params: tuple[float, float, float] | None, - num_annular_bins: int, - radial_min: float, - radial_max: float | None, - radial_step: float, - two_fold_rotation_symmetry: bool, -) -> dict[str, Any]: - origin_row = float(origin_row) - origin_col = float(origin_col) - annular_range = np.pi if two_fold_rotation_symmetry else 2.0 * np.pi - - rows = np.arange(ny, dtype=np.float64) - cols = np.arange(nx, dtype=np.float64) - cc, rr = np.meshgrid(cols, rows, indexing="xy") - x = cc - origin_col - y = rr - origin_row - - if ellipse_params is None: - rr_pix = np.sqrt(x * x + y * y) - tt = np.mod(np.arctan2(y, x), annular_range) - else: - if len(ellipse_params) != 3: - raise ValueError("ellipse_params must be a length-3 tuple (a, b, theta_deg).") - a, b, theta_deg = ellipse_params - theta = np.deg2rad(theta_deg) - cos_t = np.cos(theta) - sin_t = np.sin(theta) - xc = x * cos_t + y * sin_t - yc = (y * cos_t - x * sin_t) * (a / b) - rr_pix = (b / a) * np.hypot(xc, yc) - tt = np.mod(np.arctan2(yc, xc) + theta, annular_range) - - if radial_step <= 0: - raise ValueError("radial_step must be > 0.") - radial_min = float(radial_min) - - if radial_max is None: - radial_max_eff = float(rr_pix.max()) - else: - radial_max_eff = float(radial_max) - if radial_max_eff <= radial_min + radial_step: - radial_max_eff = radial_min + radial_step - - radial_bins = np.arange(radial_min, radial_max_eff, radial_step, dtype=np.float64) - n_r = radial_bins.size - if n_r < 1: - raise ValueError("No radial bins defined. Check radial_min, radial_max, and radial_step.") - - n_phi = int(num_annular_bins) - if n_phi < 1: - raise ValueError("num_annular_bins must be >= 1.") - phi_step = annular_range / n_phi - - r_bin = (rr_pix - radial_min) / radial_step - t_bin = tt / phi_step - - r0 = np.floor(r_bin).astype(np.int64) - t0 = np.floor(t_bin).astype(np.int64) - dr = (r_bin - r0).astype(np.float64) - dt = (t_bin - t0).astype(np.float64) - - valid = (r0 >= 0) & (r0 < n_r - 1) - t0 = np.clip(t0, 0, n_phi - 1) - - flat_valid = valid.ravel() - r0v = r0.ravel()[flat_valid] - t0v = t0.ravel()[flat_valid] - drv = dr.ravel()[flat_valid] - dtv = dt.ravel()[flat_valid] - - n_bins = n_phi * n_r - idx00 = r0v + n_r * t0v - idx01 = r0v + n_r * ((t0v + 1) % n_phi) - idx10 = (r0v + 1) + n_r * t0v - idx11 = (r0v + 1) + n_r * ((t0v + 1) % n_phi) - - w00 = (1.0 - drv) * (1.0 - dtv) - w01 = (1.0 - drv) * dtv - w10 = drv * (1.0 - dtv) - w11 = drv * dtv - - weights_sum = np.bincount(idx00, weights=w00, minlength=n_bins) - weights_sum += np.bincount(idx01, weights=w01, minlength=n_bins) - weights_sum += np.bincount(idx10, weights=w10, minlength=n_bins) - weights_sum += np.bincount(idx11, weights=w11, minlength=n_bins) - weights_sum = weights_sum.reshape(n_phi, n_r) - - weights_inv = np.zeros_like(weights_sum, dtype=np.float64) - mask_bins = weights_sum > 0 - weights_inv[mask_bins] = 1.0 / weights_sum[mask_bins] - - return { - "flat_valid": flat_valid, - "idx00": idx00, - "idx01": idx01, - "idx10": idx10, - "idx11": idx11, - "w00": w00, - "w01": w01, - "w10": w10, - "w11": w11, - "weights_inv": weights_inv, - "n_phi": n_phi, - "n_r": n_r, - "radial_bins": radial_bins, - "phi_step": phi_step, - "annular_range": annular_range, - "radial_min": radial_min, - "radial_max": radial_min + radial_step * n_r, - "radial_step": radial_step, - } - - -def _apply_polar_mapping_single( - image: NDArray, - mapping: dict[str, Any], - dtype: Any, -) -> NDArray: - data = np.asarray(image, dtype=np.float64) - flat = data.ravel()[mapping["flat_valid"]] - n_bins = mapping["n_phi"] * mapping["n_r"] - - acc = np.bincount(mapping["idx00"], weights=flat * mapping["w00"], minlength=n_bins) - acc += np.bincount(mapping["idx01"], weights=flat * mapping["w01"], minlength=n_bins) - acc += np.bincount(mapping["idx10"], weights=flat * mapping["w10"], minlength=n_bins) - acc += np.bincount(mapping["idx11"], weights=flat * mapping["w11"], minlength=n_bins) - - acc = acc.reshape(mapping["n_phi"], mapping["n_r"]) - acc *= mapping["weights_inv"] - return acc.astype(dtype, copy=False) - diff --git a/src/quantem/diffraction/polar.py b/src/quantem/diffraction/polar.py new file mode 100644 index 00000000..e69de29b From 9b1ecbf9fb43ac3417e855a6e977577d267f66b8 Mon Sep 17 00:00:00 2001 From: cophus Date: Thu, 4 Dec 2025 13:22:56 -0800 Subject: [PATCH 04/88] initial commit for RDF class --- src/quantem/__init__.py | 1 + src/quantem/core/datastructures/dataset.py | 34 +++ src/quantem/diffraction/__init__.py | 1 + src/quantem/diffraction/polar.py | 326 +++++++++++++++++++++ 4 files changed, 362 insertions(+) diff --git a/src/quantem/__init__.py b/src/quantem/__init__.py index db96bb4f..36f3c68b 100644 --- a/src/quantem/__init__.py +++ b/src/quantem/__init__.py @@ -3,4 +3,5 @@ from quantem.core import visualization as visualization from quantem import imaging as imaging +from quantem import diffraction as diffraction from quantem import diffractive_imaging as diffractive_imaging diff --git a/src/quantem/core/datastructures/dataset.py b/src/quantem/core/datastructures/dataset.py index d04c08c5..317b669b 100644 --- a/src/quantem/core/datastructures/dataset.py +++ b/src/quantem/core/datastructures/dataset.py @@ -147,6 +147,11 @@ def sampling(self) -> NDArray: def sampling(self, value: NDArray | tuple | list | float | int) -> None: self._sampling = validate_ndinfo(value, self.ndim, "sampling") + @property + def origin_units(self) -> NDArray: + # Origin expressed in physical units: origin * sampling + return np.asarray(self.origin) * np.asarray(self.sampling) + @property def units(self) -> list[str]: return self._units @@ -289,6 +294,35 @@ def _copy_custom_attributes(self, new_dataset: Self) -> None: # Skip attributes that can't be copied pass + def coords(self, axis: int) -> Any: + """ + Coordinate array for a given axis in pixel units. + + coords(d) = arange(shape[d]) - origin[d] + """ + axis = int(axis) + if axis < 0 or axis >= self.ndim: + raise ValueError(f"axis {axis} out of bounds for ndim={self.ndim}") + + xp = self._xp + n = int(self.shape[axis]) + origin_d = float(np.asarray(self.origin)[axis]) + + return xp.arange(n, dtype=float) - origin_d + + def coords_units(self, axis: int) -> Any: + """ + Coordinate array for a given axis in physical units. + + coords_units(d) = (arange(shape[d]) - origin[d]) * sampling[d] + """ + axis = int(axis) + if axis < 0 or axis >= self.ndim: + raise ValueError(f"axis {axis} out of bounds for ndim={self.ndim}") + + sampling_d = float(np.asarray(self.sampling)[axis]) + return self.coords(axis) * sampling_d + def mean(self, axes: int | tuple[int, ...] | None = None) -> Any: """ Computes and returns mean of the data array. diff --git a/src/quantem/diffraction/__init__.py b/src/quantem/diffraction/__init__.py index e69de29b..fdbb2b3d 100644 --- a/src/quantem/diffraction/__init__.py +++ b/src/quantem/diffraction/__init__.py @@ -0,0 +1 @@ +from quantem.diffraction.polar import RDF as RDF diff --git a/src/quantem/diffraction/polar.py b/src/quantem/diffraction/polar.py index e69de29b..7e87eff3 100644 --- a/src/quantem/diffraction/polar.py +++ b/src/quantem/diffraction/polar.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, List, Union + +import matplotlib.pyplot as plt +import numpy as np +from numpy.typing import NDArray + +from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.datastructures.dataset3d import Dataset3d +from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from quantem.core.datastructures.polar4dstem import Polar4dstem +from quantem.core.io.serialize import AutoSerialize +from quantem.core.utils.validators import ensure_valid_array + + +class RDF(AutoSerialize): + """ + Radial distribution / fluctuation electron microscopy analysis helper. + + This class wraps a 4D-STEM (or 2D diffraction) dataset and stores a + polar-transformed representation as a Polar4dstem instance in `self.polar`. + Analysis methods (radial statistics, PDF, FEM, clustering, etc.) are + provided as stubs for now and will be implemented in future revisions. + """ + + _token = object() + + def __init__( + self, + polar: Polar4dstem, + input_data: Any | None = None, + _token: object | None = None, + ): + if _token is not self._token: + raise RuntimeError( + "Use RadialDistributionFunction.from_data() to instantiate this class." + ) + + super().__init__() + self.polar = polar + self.input_data = input_data + + # Placeholders for analysis results (to be populated by future methods) + self.radial_mean: NDArray | None = None + self.radial_var: NDArray | None = None + self.radial_var_norm: NDArray | None = None + + self.pdf_r: NDArray | None = None + self.pdf_reduced: NDArray | None = None + self.pdf: NDArray | None = None + + self.Sk: NDArray | None = None + self.fk: NDArray | None = None + self.bg: NDArray | None = None + self.offset: float | None = None + self.Sk_mask: NDArray | None = None + + # ------------------------------------------------------------------ + # Constructors + # ------------------------------------------------------------------ + @classmethod + def from_data( + cls, + data: Union[NDArray, Dataset2d, Dataset3d, Dataset4dstem, Polar4dstem], + *, + origin_row: float | None = None, + origin_col: float | None = None, + ellipse_params: tuple[float, float, float] | None = None, + num_annular_bins: int = 180, + radial_min: float = 0.0, + radial_max: float | None = None, + radial_step: float = 1.0, + two_fold_rotation_symmetry: bool = False, + ) -> "RadialDistributionFunction": + """ + Create a RadialDistributionFunction object from various input types. + + Parameters + ---------- + data + Supported inputs: + - 2D numpy array (single diffraction pattern) + - 4D numpy array (scan_y, scan_x, ky, kx) + - Dataset2d + - Dataset4dstem + - Polar4dstem + origin_row, origin_col + Diffraction-space origin (in pixels). If None, defaults to the + central pixel of the diffraction pattern. + Other parameters + Passed through to Dataset4dstem.polar_transform when needed. + """ + # Polar input: use directly + if isinstance(data, Polar4dstem): + polar = data + return cls(polar=polar, input_data=data, _token=cls._token) + + # Dataset4dstem input: polar-transform it + if isinstance(data, Dataset4dstem): + scan_y, scan_x, ny, nx = data.array.shape + if origin_row is None: + origin_row = (ny - 1) / 2.0 + if origin_col is None: + origin_col = (nx - 1) / 2.0 + + polar = data.polar_transform( + origin_row=origin_row, + origin_col=origin_col, + ellipse_params=ellipse_params, + num_annular_bins=num_annular_bins, + radial_min=radial_min, + radial_max=radial_max, + radial_step=radial_step, + two_fold_rotation_symmetry=two_fold_rotation_symmetry, + ) + return cls(polar=polar, input_data=data, _token=cls._token) + + # Dataset2d input: wrap as a trivial 4D-STEM (1x1 scan) then polar-transform + if isinstance(data, Dataset2d): + arr2d = data.array + if arr2d.ndim != 2: + raise ValueError("Dataset2d for RDF must be 2D.") + arr4 = arr2d[None, None, ...] # (1, 1, ky, kx) + + ds4 = Dataset4dstem.from_array( + array=arr4, + name=f"{data.name}_as4dstem" if getattr(data, "name", None) else "rdf_4dstem_from_2d", + origin=np.concatenate( + [np.zeros(2, dtype=float), np.asarray(data.origin, dtype=float)] + ), + sampling=np.concatenate( + [np.ones(2, dtype=float), np.asarray(data.sampling, dtype=float)] + ), + units=["pixels", "pixels"] + list(data.units), + signal_units=data.signal_units, + ) + ny, nx = ds4.array.shape[-2:] + if origin_row is None: + origin_row = (ny - 1) / 2.0 + if origin_col is None: + origin_col = (nx - 1) / 2.0 + + polar = ds4.polar_transform( + origin_row=origin_row, + origin_col=origin_col, + ellipse_params=ellipse_params, + num_annular_bins=num_annular_bins, + radial_min=radial_min, + radial_max=radial_max, + radial_step=radial_step, + two_fold_rotation_symmetry=two_fold_rotation_symmetry, + ) + return cls(polar=polar, input_data=data, _token=cls._token) + + # Dataset3d input: not yet specified how to interpret + if isinstance(data, Dataset3d): + raise NotImplementedError( + "RadialDistributionFunction.from_data does not yet support Dataset3d inputs." + ) + + # Numpy array input + arr = ensure_valid_array(data) + if arr.ndim == 2: + ds2 = Dataset2d.from_array(arr, name="rdf_input_2d") + return cls.from_data( + ds2, + origin_row=origin_row, + origin_col=origin_col, + ellipse_params=ellipse_params, + num_annular_bins=num_annular_bins, + radial_min=radial_min, + radial_max=radial_max, + radial_step=radial_step, + two_fold_rotation_symmetry=two_fold_rotation_symmetry, + ) + elif arr.ndim == 4: + ds4 = Dataset4dstem.from_array(arr, name="rdf_input_4dstem") + return cls.from_data( + ds4, + origin_row=origin_row, + origin_col=origin_col, + ellipse_params=ellipse_params, + num_annular_bins=num_annular_bins, + radial_min=radial_min, + radial_max=radial_max, + radial_step=radial_step, + two_fold_rotation_symmetry=two_fold_rotation_symmetry, + ) + else: + raise ValueError( + "RadialDistributionFunction.from_data only supports 2D or 4D arrays." + ) + + # ------------------------------------------------------------------ + # Convenience accessors + # ------------------------------------------------------------------ + @property + def qq(self) -> Any: + """ + Scattering vector coordinate array along the radial dimension of `self.polar`, + in physical units (using Polar4dstem.sampling and origin). + """ + # Polar4dstem dims: (scan_y, scan_x, phi, r) + # radial axis is 3 + return self.polar.coords_units(3) + + @property + def radial_bins(self) -> Any: + """ + Radial bin centers in pixel units (convenience alias). + """ + return self.polar.coords(3) + + # ------------------------------------------------------------------ + # Analysis method stubs (py4DSTEM-style API) + # ------------------------------------------------------------------ + def calculate_radial_statistics( + self, + mask_realspace: NDArray | None = None, + plot_results_mean: bool = False, + plot_results_var: bool = False, + figsize: tuple[float, float] = (8, 4), + returnval: bool = False, + returnfig: bool = False, + progress_bar: bool = True, + ): + """ + Stub for radial statistics (FEM-style) calculation on the polar data. + + Intended to compute radial mean, variance, and normalized variance + from self.polar. Not implemented yet. + """ + raise NotImplementedError("calculate_radial_statistics is not implemented yet.") + + def plot_radial_mean( + self, + log_x: bool = False, + log_y: bool = False, + figsize: tuple[float, float] = (8, 4), + returnfig: bool = False, + ): + """ + Stub for plotting radial mean intensity vs scattering vector. + """ + raise NotImplementedError("plot_radial_mean is not implemented yet.") + + def plot_radial_var_norm( + self, + figsize: tuple[float, float] = (8, 4), + returnfig: bool = False, + ): + """ + Stub for plotting normalized radial variance vs scattering vector. + """ + raise NotImplementedError("plot_radial_var_norm is not implemented yet.") + + def calculate_pair_dist_function( + self, + k_min: float = 0.05, + k_max: float | None = None, + k_width: float = 0.25, + k_lowpass: float | None = None, + k_highpass: float | None = None, + r_min: float = 0.0, + r_max: float = 20.0, + r_step: float = 0.02, + damp_origin_fluctuations: bool = True, + enforce_positivity: bool = True, + density: float | None = None, + plot_background_fits: bool = False, + plot_sf_estimate: bool = False, + plot_reduced_pdf: bool = True, + plot_pdf: bool = False, + figsize: tuple[float, float] = (8, 4), + maxfev: int | None = None, + returnval: bool = False, + returnfig: bool = False, + ): + """ + Stub for pair distribution function (PDF) calculation from radial statistics. + + Intended to estimate S(k), background, and transform to real-space g(r)/G(r). + """ + raise NotImplementedError("calculate_pair_dist_function is not implemented yet.") + + def plot_background_fits( + self, + figsize: tuple[float, float] = (8, 4), + returnfig: bool = False, + ): + """ + Stub for plotting background fit vs radial mean intensity. + """ + raise NotImplementedError("plot_background_fits is not implemented yet.") + + def plot_sf_estimate( + self, + figsize: tuple[float, float] = (8, 4), + returnfig: bool = False, + ): + """ + Stub for plotting reduced structure factor S(k). + """ + raise NotImplementedError("plot_sf_estimate is not implemented yet.") + + def plot_reduced_pdf( + self, + figsize: tuple[float, float] = (8, 4), + returnfig: bool = False, + ): + """ + Stub for plotting reduced PDF g(r). + """ + raise NotImplementedError("plot_reduced_pdf is not implemented yet.") + + def plot_pdf( + self, + figsize: tuple[float, float] = (8, 4), + returnfig: bool = False, + ): + """ + Stub for plotting full PDF G(r). + """ + raise NotImplementedError("plot_pdf is not implemented yet.") From 1d0ec612daf018a45bbfee04ad640c8f0eb8c902 Mon Sep 17 00:00:00 2001 From: cophus Date: Thu, 1 Jan 2026 12:42:17 -0800 Subject: [PATCH 05/88] initial commit for StrainMap --- .../core/datastructures/dataset4dstem.py | 2 +- src/quantem/core/utils/imaging_utils.py | 88 +++++++++ src/quantem/diffraction/__init__.py | 1 + src/quantem/diffraction/strain.py | 179 ++++++++++++++++++ 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/quantem/diffraction/strain.py diff --git a/src/quantem/core/datastructures/dataset4dstem.py b/src/quantem/core/datastructures/dataset4dstem.py index 2d4c860e..eac15ac5 100644 --- a/src/quantem/core/datastructures/dataset4dstem.py +++ b/src/quantem/core/datastructures/dataset4dstem.py @@ -74,7 +74,7 @@ def __init__( _token : object | None, optional Token to prevent direct instantiation, by default None """ - mdata_keys_4dstem = ["r_to_q_rotation_cw_deg", "ellipticity"] + mdata_keys_4dstem = ["q_to_r_rotation_cw_deg", 'q_transpose', "ellipticity"] for k in mdata_keys_4dstem: if k not in metadata.keys(): metadata[k] = None diff --git a/src/quantem/core/utils/imaging_utils.py b/src/quantem/core/utils/imaging_utils.py index c72fb9c3..3d86000d 100644 --- a/src/quantem/core/utils/imaging_utils.py +++ b/src/quantem/core/utils/imaging_utils.py @@ -7,6 +7,7 @@ import torch from numpy.typing import NDArray from scipy.ndimage import gaussian_filter +from scipy.ndimage import map_coordinates from quantem.core.utils.utils import generate_batches @@ -546,3 +547,90 @@ def fourier_cropping( result[-h2:, -w2:] = corner_centered_array[-h2:, -w2:] return result + + +def rotate_image( + im, + rotation_deg: float, + origin: tuple[float, float] | None = None, + clockwise: bool = True, + interpolation: str = "bilinear", + mode: str = "constant", + cval: float = 0.0, +): + """Rotate an array about a pixel origin using bilinear/bicubic interpolation. + + Parameters + ---------- + im + Input array; last two dimensions are treated as (H, W). Any leading + dimensions are treated as batch and rotated independently. + rotation_deg + Rotation angle in degrees. Interpreted as clockwise if clockwise=True, + otherwise counterclockwise. + origin + Rotation origin (row, col) in pixel coordinates. If None, uses (H//2, W//2). + clockwise + If True, interpret rotation_deg as clockwise; if False, as counterclockwise. + interpolation + "bilinear" (order=1) or "bicubic" (order=3). + mode, cval + Boundary handling passed to scipy.ndimage.map_coordinates. + + Returns + ------- + out + Rotated array with the same shape as `im`. + """ + im = np.asarray(im) + if im.ndim < 2: + raise ValueError("im must have at least 2 dimensions") + + H, W = im.shape[-2], im.shape[-1] + if origin is None: + r0 = float(H // 2) + c0 = float(W // 2) + else: + r0 = float(origin[0]) + c0 = float(origin[1]) + + interp = str(interpolation).lower() + if interp in {"bilinear", "linear"}: + order = 1 + elif interp in {"bicubic", "cubic"}: + order = 3 + else: + raise ValueError("interpolation must be 'bilinear' or 'bicubic'") + + theta = float(np.deg2rad(rotation_deg)) + if clockwise: + theta = -theta + + ct = float(np.cos(theta)) + st = float(np.sin(theta)) + + r_out, c_out = np.meshgrid( + np.arange(H, dtype=np.float64), + np.arange(W, dtype=np.float64), + indexing="ij", + ) + + c_rel = c_out - c0 + r_rel = r_out - r0 + + c_in = ct * c_rel + st * r_rel + c0 + r_in = -st * c_rel + ct * r_rel + r0 + + coords = np.vstack((r_in.ravel(), c_in.ravel())) + + if im.ndim == 2: + out = map_coordinates(im, coords, order=order, mode=mode, cval=cval) + return out.reshape(H, W) + + prefix = im.shape[:-2] + n = int(np.prod(prefix)) if prefix else 1 + im_flat = im.reshape(n, H, W) + out_flat = np.empty((n, H * W), dtype=np.result_type(im_flat.dtype, np.float64)) + for i in range(n): + out_flat[i] = map_coordinates(im_flat[i], coords, order=order, mode=mode, cval=cval) + return out_flat.reshape(*prefix, H, W) diff --git a/src/quantem/diffraction/__init__.py b/src/quantem/diffraction/__init__.py index fdbb2b3d..6d0e4202 100644 --- a/src/quantem/diffraction/__init__.py +++ b/src/quantem/diffraction/__init__.py @@ -1 +1,2 @@ from quantem.diffraction.polar import RDF as RDF +from quantem.diffraction.strain import StrainMap as StrainMap diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py new file mode 100644 index 00000000..55c57d9d --- /dev/null +++ b/src/quantem/diffraction/strain.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from typing import Any + +import numpy as np +from numpy.typing import NDArray + +from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from quantem.core.io.serialize import AutoSerialize +from quantem.core.utils.imaging_utils import rotate_image +from quantem.core.utils.utils import electron_wavelength_angstrom +from quantem.core.utils.validators import ensure_valid_array +from quantem.core.visualization import ScalebarConfig, show_2d + + +class StrainMap(AutoSerialize): + """ + Nanobeam strain mapping + """ + + _token = object() + + def __init__( + self, + dataset: Dataset4dstem, + input_data: Any | None = None, + _token: object | None = None, + ): + if _token is not self._token: + raise RuntimeError("Use StrainMap.from_data() to instantiate this class.") + super().__init__() + self.dataset = dataset + self.input_data = input_data + self.strain = None + self.metadata: dict[str, Any] = {} + self.transform: Dataset2d | None = None + self.transform_rotated: Dataset2d | None = None + + @classmethod + def from_data(cls, data: NDArray | Dataset4dstem, *, name: str = "strain_map") -> StrainMap: + if isinstance(data, Dataset4dstem): + return cls(dataset=data, input_data=data, _token=cls._token) + + arr = ensure_valid_array(data) + if arr.ndim != 4: + raise ValueError( + "StrainMap.from_data expects a 4D array with shape (scan_r, scan_c, dp_r, dp_c)." + ) + + ds4 = Dataset4dstem.from_array(arr, name=name) + return cls(dataset=ds4, input_data=data, _token=cls._token) + + def preprocess( + self, + mode: str = "linear", + plot_transform: bool = True, + cropping_factor: float = 0.5, + **plot_kwargs: Any, + ) -> StrainMap: + if self.dataset.units[2] == "A": + qrow_sampling_ang = float(self.dataset.sampling[2]) + elif self.dataset.units[2] == "mrad": + wavelength = float(electron_wavelength_angstrom(float(self.dataset.metadata["energy"]))) + qrow_sampling_ang = float(self.dataset.sampling[2]) / 1000.0 / wavelength + else: + raise ValueError(f"unrecognized diffraction-space unit for axis 2: {self.dataset.units[2]}") + + if self.dataset.units[3] == "A": + qcol_sampling_ang = float(self.dataset.sampling[3]) + elif self.dataset.units[3] == "mrad": + wavelength = float(electron_wavelength_angstrom(float(self.dataset.metadata["energy"]))) + qcol_sampling_ang = float(self.dataset.sampling[3]) / 1000.0 / wavelength + else: + raise ValueError(f"unrecognized diffraction-space unit for axis 3: {self.dataset.units[3]}") + + self.metadata["sampling_real"] = np.array( + ( + 1.0 / (qrow_sampling_ang * float(self.dataset.shape[2])), + 1.0 / (qcol_sampling_ang * float(self.dataset.shape[3])), + ), + dtype=float, + ) + + if mode == "linear": + im = np.mean(np.abs(np.fft.fft2(self.dataset.array)), axis=(0, 1)) + elif mode == "log": + im = np.mean(np.abs(np.fft.fft2(np.log(self.dataset.array + 1.0))), axis=(0, 1)) + else: + raise ValueError("mode must be 'linear' or 'log'") + + self.transform = Dataset2d.from_array( + np.fft.fftshift(im), + origin=(im.shape[0] // 2, im.shape[1] // 2), + sampling=(1.0, 1.0), + units=("A", "A"), + signal_units="intensity", + ) + + self.transform_rotated = Dataset2d.from_array( + rotate_image( + self.transform.array, + float(self.dataset.metadata["q_to_r_rotation_cw_deg"]), + clockwise=True, + ), + origin=(im.shape[0] // 2, im.shape[1] // 2), + sampling=(1.0, 1.0), + units=("A", "A"), + signal_units="intensity", + ) + + if plot_transform: + self.plot_transform(cropping_factor=cropping_factor, **plot_kwargs) + + return self + + + + + + + + + + def plot_transform( + self, + cropping_factor: float = 0.25, + **plot_kwargs: Any + ): + if self.transform is None or self.transform_rotated is None: + raise ValueError("Run preprocess() first to compute transform images.") + + defaults = dict( + vmax=1.0, + title=("Original Transform", "Rotated Transform"), + scalebar=ScalebarConfig( + sampling=self.metadata["sampling_real"], + units=r"$\mathrm{\AA}$", + length=2, + ), + ) + defaults.update(plot_kwargs) + + fig, ax = show_2d([self.transform, self.transform_rotated], **defaults) + + axes = np.atleast_1d(ax) + for a in axes: + _apply_center_crop_limits(a, self.transform.shape, cropping_factor) + + return fig, ax + + + + + + + +def _apply_center_crop_limits(ax, shape: tuple[int, int], cropping_factor: float) -> None: + cf = float(cropping_factor) + if cf >= 1.0: + return + if not (0.0 < cf <= 1.0): + raise ValueError("cropping_factor must be in (0, 1].") + + H, W = int(shape[0]), int(shape[1]) + r0 = (H - 1) / 2.0 + c0 = (W - 1) / 2.0 + half_h = 0.5 * cf * H + half_w = 0.5 * cf * W + + ax.set_xlim(c0 - half_w, c0 + half_w) + + y0, y1 = ax.get_ylim() + if y0 > y1: + ax.set_ylim(r0 + half_h, r0 - half_h) + else: + ax.set_ylim(r0 - half_h, r0 + half_h) + + From 5ace6c73d0ac44268063d3bb36009773cddb6a9c Mon Sep 17 00:00:00 2001 From: cophus Date: Thu, 1 Jan 2026 19:32:20 -0800 Subject: [PATCH 06/88] all basic functions implemented --- .../core/datastructures/dataset4dstem.py | 2 +- src/quantem/core/utils/imaging_utils.py | 27 +- src/quantem/diffraction/strain.py | 794 +++++++++++++++++- 3 files changed, 754 insertions(+), 69 deletions(-) diff --git a/src/quantem/core/datastructures/dataset4dstem.py b/src/quantem/core/datastructures/dataset4dstem.py index eac15ac5..dd92b914 100644 --- a/src/quantem/core/datastructures/dataset4dstem.py +++ b/src/quantem/core/datastructures/dataset4dstem.py @@ -74,7 +74,7 @@ def __init__( _token : object | None, optional Token to prevent direct instantiation, by default None """ - mdata_keys_4dstem = ["q_to_r_rotation_cw_deg", 'q_transpose', "ellipticity"] + mdata_keys_4dstem = ["q_to_r_rotation_ccw_deg", 'q_transpose', "ellipticity"] for k in mdata_keys_4dstem: if k not in metadata.keys(): metadata[k] = None diff --git a/src/quantem/core/utils/imaging_utils.py b/src/quantem/core/utils/imaging_utils.py index 3d86000d..8a07b3c1 100644 --- a/src/quantem/core/utils/imaging_utils.py +++ b/src/quantem/core/utils/imaging_utils.py @@ -558,30 +558,7 @@ def rotate_image( mode: str = "constant", cval: float = 0.0, ): - """Rotate an array about a pixel origin using bilinear/bicubic interpolation. - - Parameters - ---------- - im - Input array; last two dimensions are treated as (H, W). Any leading - dimensions are treated as batch and rotated independently. - rotation_deg - Rotation angle in degrees. Interpreted as clockwise if clockwise=True, - otherwise counterclockwise. - origin - Rotation origin (row, col) in pixel coordinates. If None, uses (H//2, W//2). - clockwise - If True, interpret rotation_deg as clockwise; if False, as counterclockwise. - interpolation - "bilinear" (order=1) or "bicubic" (order=3). - mode, cval - Boundary handling passed to scipy.ndimage.map_coordinates. - - Returns - ------- - out - Rotated array with the same shape as `im`. - """ + """Rotate an array about a pixel origin using bilinear/bicubic interpolation.""" im = np.asarray(im) if im.ndim < 2: raise ValueError("im must have at least 2 dimensions") @@ -603,7 +580,7 @@ def rotate_image( raise ValueError("interpolation must be 'bilinear' or 'bicubic'") theta = float(np.deg2rad(rotation_deg)) - if clockwise: + if not clockwise: theta = -theta ct = float(np.cos(theta)) diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py index 55c57d9d..b5549ff7 100644 --- a/src/quantem/diffraction/strain.py +++ b/src/quantem/diffraction/strain.py @@ -2,23 +2,23 @@ from typing import Any +import matplotlib.pyplot as plt import numpy as np from numpy.typing import NDArray +from scipy.ndimage import distance_transform_edt from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.datastructures.dataset3d import Dataset3d +from quantem.core.datastructures.dataset4d import Dataset4d from quantem.core.datastructures.dataset4dstem import Dataset4dstem from quantem.core.io.serialize import AutoSerialize -from quantem.core.utils.imaging_utils import rotate_image +from quantem.core.utils.imaging_utils import dft_upsample, rotate_image from quantem.core.utils.utils import electron_wavelength_angstrom from quantem.core.utils.validators import ensure_valid_array from quantem.core.visualization import ScalebarConfig, show_2d class StrainMap(AutoSerialize): - """ - Nanobeam strain mapping - """ - _token = object() def __init__( @@ -36,9 +36,13 @@ def __init__( self.metadata: dict[str, Any] = {} self.transform: Dataset2d | None = None self.transform_rotated: Dataset2d | None = None + self.u: NDArray | None = None + self.v: NDArray | None = None + self.mask_diffraction = np.ones(self.dataset.array.shape[2:]) + self.mask_diffraction_inv = np.zeros(self.dataset.array.shape[2:]) @classmethod - def from_data(cls, data: NDArray | Dataset4dstem, *, name: str = "strain_map") -> StrainMap: + def from_data(cls, data: NDArray | Dataset4dstem, *, name: str = "strain_map") -> "StrainMap": if isinstance(data, Dataset4dstem): return cls(dataset=data, input_data=data, _token=cls._token) @@ -51,28 +55,81 @@ def from_data(cls, data: NDArray | Dataset4dstem, *, name: str = "strain_map") - ds4 = Dataset4dstem.from_array(arr, name=name) return cls(dataset=ds4, input_data=data, _token=cls._token) + + def diffraction_mask( + self, + threshold = None, + edge_blend = 32.0, + plot_mask = True, + figsize = (8,4), + ): + dp_mean = np.mean(self.dataset.array,axis=(0,1)) + mask_init = dp_mean < threshold + mask_init[:,0] = True + mask_init[0,:] = True + mask_init[:,-1] = True + mask_init[-1,:] = True + + self.mask_diffraction = np.sin( + np.clip( + distance_transform_edt(np.logical_not(mask_init)) / edge_blend, + 0.0, + 1.0, + )*np.pi/2, + )**2 + # int_edge = np.sum(dp_mean*self.mask_diffraction) / np.sum(self.mask_diffraction) + int_edge = np.min(dp_mean[self.mask_diffraction>0.99]) + self.mask_diffraction_inv = (1 - self.mask_diffraction) * int_edge + + if plot_mask: + fig,ax = plt.subplots(1,2,figsize=figsize) + ax[0].imshow( + np.log(np.maximum(dp_mean,np.min(dp_mean[dp_mean>0]))), + cmap = 'gray', + ) + ax[1].imshow( + np.log( + dp_mean*self.mask_diffraction + \ + self.mask_diffraction_inv, + ), + cmap = 'gray', + ) + + return self + + def preprocess( self, mode: str = "linear", + q_to_r_rotation_ccw_deg: float | None = None, + q_transpose: bool | None = None, plot_transform: bool = True, - cropping_factor: float = 0.5, + cropping_factor: float = 0.25, **plot_kwargs: Any, - ) -> StrainMap: - if self.dataset.units[2] == "A": + ) -> "StrainMap": + + self.metadata["mode"] = mode + + qrow_unit = str(self.dataset.units[2]) + qcol_unit = str(self.dataset.units[3]) + + if qrow_unit in {"A", "Å"}: qrow_sampling_ang = float(self.dataset.sampling[2]) - elif self.dataset.units[2] == "mrad": + elif qrow_unit == "mrad": wavelength = float(electron_wavelength_angstrom(float(self.dataset.metadata["energy"]))) qrow_sampling_ang = float(self.dataset.sampling[2]) / 1000.0 / wavelength else: - raise ValueError(f"unrecognized diffraction-space unit for axis 2: {self.dataset.units[2]}") + qrow_sampling_ang = 1.0 + qrow_unit = "pixels" - if self.dataset.units[3] == "A": + if qcol_unit in {"A", "Å"}: qcol_sampling_ang = float(self.dataset.sampling[3]) - elif self.dataset.units[3] == "mrad": + elif qcol_unit == "mrad": wavelength = float(electron_wavelength_angstrom(float(self.dataset.metadata["energy"]))) qcol_sampling_ang = float(self.dataset.sampling[3]) / 1000.0 / wavelength else: - raise ValueError(f"unrecognized diffraction-space unit for axis 3: {self.dataset.units[3]}") + qcol_sampling_ang = 1.0 + qcol_unit = "pixels" self.metadata["sampling_real"] = np.array( ( @@ -82,30 +139,81 @@ def preprocess( dtype=float, ) - if mode == "linear": - im = np.mean(np.abs(np.fft.fft2(self.dataset.array)), axis=(0, 1)) - elif mode == "log": - im = np.mean(np.abs(np.fft.fft2(np.log(self.dataset.array + 1.0))), axis=(0, 1)) + if qrow_unit == "pixels" and qcol_unit == "pixels": + self.metadata["real_units"] = "1/pixels" + else: + self.metadata["real_units"] = r"$\mathrm{\AA}$" + + if q_to_r_rotation_ccw_deg is None or q_transpose is None: + parent_rot = self.dataset.metadata.get("q_to_r_rotation_ccw_deg", None) + parent_tr = self.dataset.metadata.get("q_transpose", None) + if q_to_r_rotation_ccw_deg is None and parent_rot is not None: + q_to_r_rotation_ccw_deg = float(parent_rot) + if q_transpose is None and parent_tr is not None: + q_transpose = bool(parent_tr) + if (parent_rot is not None or parent_tr is not None) and ( + q_to_r_rotation_ccw_deg is not None or q_transpose is not None + ): + import warnings + + warnings.warn( + f"StrainMap.preprocess: using parent Dataset4dstem metadata " + f"(q_to_r_rotation_ccw_deg={float(q_to_r_rotation_ccw_deg or 0.0)}, " + f"q_transpose={bool(q_transpose or False)}).", + UserWarning, + ) + + if q_to_r_rotation_ccw_deg is None or q_transpose is None: + import warnings + + q_to_r_rotation_ccw_deg = ( + 0.0 if q_to_r_rotation_ccw_deg is None else float(q_to_r_rotation_ccw_deg) + ) + q_transpose = False if q_transpose is None else bool(q_transpose) + warnings.warn( + "StrainMap.preprocess: setting q_to_r_rotation_ccw_deg=0.0 and q_transpose=False.", + UserWarning, + ) + + self.metadata["q_to_r_rotation_ccw_deg"] = float(q_to_r_rotation_ccw_deg) + self.metadata["q_transpose"] = bool(q_transpose) + + if self.metadata["mode"] == "linear": + im = np.mean(np.abs(np.fft.fft2( + self.dataset.array * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + )), axis=(0, 1)) + elif self.metadata["mode"] == "log": + im = np.mean(np.abs(np.fft.fft2(np.log( + self.dataset.array * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + ))), axis=(0, 1)) else: raise ValueError("mode must be 'linear' or 'log'") + im = np.fft.fftshift(im) + self.transform = Dataset2d.from_array( - np.fft.fftshift(im), + im, origin=(im.shape[0] // 2, im.shape[1] // 2), sampling=(1.0, 1.0), - units=("A", "A"), + units=(qrow_unit, qcol_unit), signal_units="intensity", ) + im_plot = self.transform.array + if bool(self.metadata["q_transpose"]): + im_plot = im_plot.T + self.transform_rotated = Dataset2d.from_array( rotate_image( - self.transform.array, - float(self.dataset.metadata["q_to_r_rotation_cw_deg"]), - clockwise=True, + im_plot, + float(self.metadata["q_to_r_rotation_ccw_deg"]), + clockwise=False, ), origin=(im.shape[0] // 2, im.shape[1] // 2), sampling=(1.0, 1.0), - units=("A", "A"), + units=(self.metadata["real_units"], self.metadata["real_units"]), signal_units="intensity", ) @@ -114,48 +222,419 @@ def preprocess( return self - - - - - - - - def plot_transform( - self, - cropping_factor: float = 0.25, - **plot_kwargs: Any + self, + cropping_factor: float = 0.25, + scalebar_fraction: float = 0.25, + **plot_kwargs: Any, ): if self.transform is None or self.transform_rotated is None: raise ValueError("Run preprocess() first to compute transform images.") + sampling = float(np.mean(self.metadata["sampling_real"])) + units = str(self.metadata.get("real_units", r"$\mathrm{\AA}$")) + + W = int(self.transform.shape[1]) + view_w_px = float(W) * float(cropping_factor) + target_units = float(scalebar_fraction) * view_w_px * sampling + sb_len = _nice_length_units(target_units) + defaults = dict( vmax=1.0, title=("Original Transform", "Rotated Transform"), scalebar=ScalebarConfig( - sampling=self.metadata["sampling_real"], - units=r"$\mathrm{\AA}$", - length=2, + sampling=sampling, + units=units, + length=sb_len if sb_len > 0 else None, ), ) defaults.update(plot_kwargs) fig, ax = show_2d([self.transform, self.transform_rotated], **defaults) - axes = np.atleast_1d(ax) - for a in axes: + for a in _flatten_axes(ax): _apply_center_crop_limits(a, self.transform.shape, cropping_factor) return fig, ax + def choose_lattice_vector( + self, + *, + u: tuple[float, float] | NDArray, + v: tuple[float, float] | NDArray, + define_in_rotated: bool = False, + refine_subpixel: bool = True, + refine_subpixel_dft: bool = False, + refine_radius_px: float = 2.0, + refine_log: bool = False, + upsample: int = 16, + plot: bool = True, + cropping_factor: float = 0.25, + **plot_kwargs: Any, + ) -> "StrainMap": + if self.transform is None or self.transform_rotated is None: + raise ValueError("Run preprocess() first to compute transform images.") + + u_rc = np.asarray(u, dtype=float).reshape(2) + v_rc = np.asarray(v, dtype=float).reshape(2) + + rot_ccw = float(self.metadata.get("q_to_r_rotation_ccw_deg", 0.0)) + q_transpose = bool(self.metadata.get("q_transpose", False)) + + if define_in_rotated: + u_rc = _display_vec_to_raw(u_rc, rotation_ccw_deg=rot_ccw, transpose=q_transpose) + v_rc = _display_vec_to_raw(v_rc, rotation_ccw_deg=rot_ccw, transpose=q_transpose) + + if refine_subpixel_dft: + refine_subpixel = True + + if refine_subpixel: + u_rc, v_rc = _refine_lattice_vectors( + self.transform.array, + u_rc=u_rc, + v_rc=v_rc, + radius_px=float(refine_radius_px), + log_fit=bool(refine_log), + refine_dft=bool(refine_subpixel_dft), + upsample=int(upsample), + ) + + self.u = u_rc + self.v = v_rc + self.metadata["lattice_u_rc"] = self.u.copy() + self.metadata["lattice_v_rc"] = self.v.copy() + + if plot: + fig, ax = self.plot_transform(cropping_factor=cropping_factor, **plot_kwargs) + _overlay_lattice_vectors( + ax=ax, + shape=self.transform.shape, + u_rc=self.u, + v_rc=self.v, + rot_ccw_deg=rot_ccw, + q_transpose=q_transpose, + ) + return self + + return self + + + def fit_lattice_vectors( + self, + refine_subpixel: bool = True, + refine_subpixel_dft: bool = False, + refine_radius_px: float = 2.0, + upsample: int = 16, + refine_log: bool = False, + progressbar: bool = True, + ) -> "StrainMap": + from quantem.core.datastructures.dataset3d import Dataset3d + + if self.u is None or self.v is None: + raise ValueError("Run choose_lattice_vector() first to set initial lattice vectors (self.u, self.v).") + + if refine_subpixel_dft: + refine_subpixel = True + + scan_r = self.dataset.shape[0] + scan_c = self.dataset.shape[1] + self.u_fit = Dataset3d.from_shape( + (scan_r, scan_c, 2), + name="u_fits", + signal_units="pixels", + ) + self.v_fit = Dataset3d.from_shape( + (scan_r, scan_c, 2), + name="v_fits", + signal_units="pixels", + ) + + mode = str(self.metadata.get("mode", "linear")).lower() + + it = np.ndindex(scan_r, scan_c) + if progressbar: + try: + from tqdm.auto import tqdm # type: ignore + + it = tqdm(it, total=scan_r * scan_c, desc="fit_lattice_vectors", leave=True) + except Exception: + pass + + u0 = np.asarray(self.u, dtype=float).reshape(2) + v0 = np.asarray(self.v, dtype=float).reshape(2) + + for r, c in it: + dp = self.dataset.array[r, c]*self.mask_diffraction + \ + self.mask_diffraction_inv + + if mode == "linear": + im = np.fft.fftshift(np.abs(np.fft.fft2(dp))) + elif mode == "log": + int_range = np.max(dp) - np.min(dp) + im = np.fft.fftshift(np.abs(np.fft.fft2(np.log(dp + int_range*0.01)))) + else: + raise ValueError("metadata['mode'] must be 'linear' or 'log'") + + if refine_subpixel: + u_rc, v_rc = _refine_lattice_vectors( + im, + u_rc=u0, + v_rc=v0, + radius_px=float(refine_radius_px), + log_fit=bool(refine_log), + refine_dft=bool(refine_subpixel_dft), + upsample=int(upsample), + ) + else: + u_rc = u0 + v_rc = v0 + + self.u_fit.array[r, c, :] = u_rc + self.v_fit.array[r, c, :] = v_rc + + self.metadata["fit_refine_subpixel"] = bool(refine_subpixel) + self.metadata["fit_refine_subpixel_dft"] = bool(refine_subpixel_dft) + self.metadata["fit_refine_radius_px"] = float(refine_radius_px) + self.metadata["fit_refine_log"] = bool(refine_log) + self.metadata["fit_upsample"] = int(upsample) + + return self + + + def plot_lattice_vectors( + self, + subtract_mean: bool = True, + scalebar: bool = False, + **plot_kwargs: Any, + ): + if getattr(self, "u_fit", None) is None or getattr(self, "v_fit", None) is None: + raise ValueError("Run fit_lattice_vectors() first to compute u_fit and v_fit.") + + im0 = self.u_fit.array[:,:,0] + im1 = self.u_fit.array[:,:,1] + im2 = self.v_fit.array[:,:,0] + im3 = self.v_fit.array[:,:,1] + + if subtract_mean: + im0 = im0 - float(np.nanmean(im0)) + im1 = im1 - float(np.nanmean(im1)) + im2 = im2 - float(np.nanmean(im2)) + im3 = im3 - float(np.nanmean(im3)) + + vlim = float(np.nanmax(np.abs(np.stack([im0, im1, im2, im3], axis=0)))) + vmin = -vlim + vmax = vlim + + defaults: dict[str, Any] = dict( + title=("u_r", "u_c", "v_r", "v_c"), + vmin=vmin, + vmax=vmax, + ) + + if scalebar: + s0 = float(self.dataset.sampling[0]) if len(self.dataset.sampling) > 0 else 1.0 + s1 = float(self.dataset.sampling[1]) if len(self.dataset.sampling) > 1 else s0 + sampling_scan = float(np.mean([s0, s1])) + units_scan = str(self.dataset.units[0]) if len(self.dataset.units) > 0 else "pixels" + defaults["scalebar"] = ScalebarConfig(sampling=sampling_scan, units=units_scan) + + defaults.update(plot_kwargs) + + fig, ax = show_2d([im0, im1, im2, im3], **defaults) + return fig, ax + + + def fit_strain( + self, + mask_reference = None, + plot_strain = True, + ): + if self.u_fit is None or self.v_fit is None: + raise ValueError("Run fit_lattice_vectors() first to compute u_fit and v_fit.") + u_fit = self.u_fit.array + v_fit = self.v_fit.array + scan_r, scan_c = int(u_fit.shape[0]), int(u_fit.shape[1]) + if mask_reference is None: + self.u_ref = np.median(u_fit.reshape(-1, 2), axis=0) + self.v_ref = np.median(v_fit.reshape(-1, 2), axis=0) + else: + m = np.asarray(mask_reference, dtype=bool) + self.u_ref = np.array( + ( + np.median(u_fit[m, 0]), + np.median(u_fit[m, 1]), + ), + dtype=float, + ) + self.v_ref = np.array( + ( + np.median(v_fit[m, 0]), + np.median(v_fit[m, 1]), + ), + dtype=float, + ) + Uref = np.stack((self.u_ref, self.v_ref), axis=1).astype(float) + det = float(np.linalg.det(Uref)) + if not np.isfinite(det) or abs(det) < 1e-12: + Uref_inv = np.linalg.pinv(Uref) + else: + Uref_inv = np.linalg.inv(Uref) + # init + self.strain_trans = Dataset4d.from_shape( + (scan_r, scan_c, 2, 2), + name="transformation matrix", + signal_units="fractional", + ) -def _apply_center_crop_limits(ax, shape: tuple[int, int], cropping_factor: float) -> None: + # Loop over probe positions + for r in range(scan_r): + for c in range(scan_c): + U = np.stack((u_fit[r, c, :], v_fit[r, c, :]), axis=1) + self.strain_trans.array[r, c, :, :] = U @ Uref_inv + + # get strains in orthogonal directions + self.strain_raw_err = Dataset2d.from_array( + self.strain_trans.array[:, :, 0, 0] - 1, + name="strain err", + signal_units="fractional", + ) + self.strain_raw_ecc = Dataset2d.from_array( + self.strain_trans.array[:, :, 1, 1] - 1, + name="strain ecc", + signal_units="fractional", + ) + self.strain_raw_erc = Dataset2d.from_array( + self.strain_trans.array[:, :, 1, 0]*0.5 + self.strain_trans.array[:, :, 0, 1]*0.5, + name="strain erc", + signal_units="fractional", + ) + self.strain_rotation = Dataset2d.from_array( + self.strain_trans.array[:, :, 1, 0]*-0.5 + self.strain_trans.array[:, :, 0, 1]*0.5, + name="strain rotation", + signal_units="fractional", + ) + + return self + + + def plot_strain( + self, + ref_u_v=(1.0, 0.0), + ref_angle_degrees=None, + strain_range_percent=(-3.0, 3.0), + rotation_range_degrees=(-2.0, 2.0), + plot_rotation=True, + cmap_strain="PiYG_r", + cmap_rotation="PiYG_r", + layout="horizontal", + figsize=(6, 6), + ): + import matplotlib.pyplot as plt + + if ref_angle_degrees is None: + ref_vec = self.u_ref * float(ref_u_v[0]) + self.v_ref * float(ref_u_v[1]) + ref_angle = float(np.arctan2(ref_vec[1], ref_vec[0])) + else: + ref_angle = float(np.deg2rad(ref_angle_degrees)) + + angle = ref_angle + np.deg2rad(self.metadata["q_to_r_rotation_ccw_deg"]) + print(np.round(np.rad2deg(angle),2)) + + c = float(np.cos(angle)) + s = float(np.sin(angle)) + + err = self.strain_raw_err.array + ecc = self.strain_raw_ecc.array + erc = self.strain_raw_erc.array + + euu = err * (c * c) + 2.0 * erc * (c * s) + ecc * (s * s) + evv = err * (s * s) - 2.0 * erc * (c * s) + ecc * (c * c) + euv = (ecc - err) * (c * s) + erc * (c * c - s * s) + + self.strain_euu = self.strain_raw_err.copy() + self.strain_evv = self.strain_raw_ecc.copy() + self.strain_euv = self.strain_raw_erc.copy() + self.strain_euu.array[...] = euu + self.strain_evv.array[...] = evv + self.strain_euv.array[...] = euv + + if layout == "horizontal": + if plot_rotation: + fig, ax = plt.subplots(1, 4, figsize=figsize) + + ax[0].imshow( + self.strain_euu.array * 100, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cmap_strain, + ) + ax[1].imshow( + self.strain_evv.array * 100, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cmap_strain, + ) + ax[2].imshow( + self.strain_euv.array * 100, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cmap_strain, + ) + ax[3].imshow( + np.rad2deg(self.strain_rotation.array), + vmin=rotation_range_degrees[0], + vmax=rotation_range_degrees[1], + cmap=cmap_rotation, + ) + return fig, ax + + fig, ax = plt.subplots(1, 3, figsize=figsize) + ax[0].imshow( + self.strain_euu.array * 100, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cmap_strain, + ) + ax[1].imshow( + self.strain_evv.array * 100, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cmap_strain, + ) + ax[2].imshow( + self.strain_euv.array * 100, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cmap_strain, + ) + return fig, ax + + raise ValueError("layout must be 'horizontal'") + + +def _nice_length_units(target: float) -> float: + target = float(target) + if not np.isfinite(target) or target <= 0: + return 0.0 + exp = np.floor(np.log10(target)) + base = target / (10.0**exp) + if base < 1.5: + nice = 1.0 + elif base < 3.5: + nice = 2.0 + elif base < 7.5: + nice = 5.0 + else: + nice = 10.0 + return float(nice * (10.0**exp)) + + +def _apply_center_crop_limits(ax: Any, shape: tuple[int, int], cropping_factor: float) -> None: cf = float(cropping_factor) if cf >= 1.0: return @@ -163,8 +642,8 @@ def _apply_center_crop_limits(ax, shape: tuple[int, int], cropping_factor: float raise ValueError("cropping_factor must be in (0, 1].") H, W = int(shape[0]), int(shape[1]) - r0 = (H - 1) / 2.0 - c0 = (W - 1) / 2.0 + r0 = float(H // 2) + c0 = float(W // 2) half_h = 0.5 * cf * H half_w = 0.5 * cf * W @@ -177,3 +656,232 @@ def _apply_center_crop_limits(ax, shape: tuple[int, int], cropping_factor: float ax.set_ylim(r0 - half_h, r0 + half_h) +def _flatten_axes(ax: Any) -> list[Any]: + if isinstance(ax, np.ndarray): + return list(ax.ravel()) + if isinstance(ax, (list, tuple)): + out: list[Any] = [] + for a in ax: + out.extend(_flatten_axes(a)) + return out + return [ax] + + +def _raw_vec_to_display(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: bool) -> NDArray: + v = np.asarray(vec_rc, dtype=float).reshape(2) + dr, dc = float(v[0]), float(v[1]) + + if transpose: + dr, dc = dc, dr + + theta = float(np.deg2rad(rotation_ccw_deg)) + ct = float(np.cos(theta)) + st = float(np.sin(theta)) + + dr2 = ct * dr - st * dc + dc2 = st * dr + ct * dc + return np.array((dr2, dc2), dtype=float) + + +def _display_vec_to_raw(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: bool) -> NDArray: + v = np.asarray(vec_rc, dtype=float).reshape(2) + dr, dc = float(v[0]), float(v[1]) + + theta = float(np.deg2rad(rotation_ccw_deg)) + ct = float(np.cos(theta)) + st = float(np.sin(theta)) + + dr2 = ct * dr + st * dc + dc2 = -st * dr + ct * dc + + if transpose: + dr2, dc2 = dc2, dr2 + + return np.array((dr2, dc2), dtype=float) + + +def _plot_lattice_vectors(ax: Any, center_rc: tuple[float, float], u_rc: NDArray, v_rc: NDArray) -> None: + r0, c0 = float(center_rc[0]), float(center_rc[1]) + + def _draw(vec: NDArray, label: str, color: tuple[float, float, float]) -> None: + dr, dc = float(vec[0]), float(vec[1]) + ax.plot([c0, c0 + dc], [r0, r0 + dr], linewidth=2.75, color=color) + ax.plot([c0 + dc], [r0 + dr], marker="o", markersize=6.0, color=color) + ax.text(c0 + dc, r0 + dr, f" {label}", color=color, fontsize=18, va="center") + + _draw(np.asarray(u_rc, dtype=float).reshape(2), "u", (1.0, 0.0, 0.0)) + _draw(np.asarray(v_rc, dtype=float).reshape(2), "v", (0.0, 0.7, 1.0)) + + +def _overlay_lattice_vectors( + *, + ax: Any, + shape: tuple[int, int], + u_rc: NDArray, + v_rc: NDArray, + rot_ccw_deg: float, + q_transpose: bool, +) -> None: + axs = _flatten_axes(ax) + if not axs: + return + + H, W = int(shape[0]), int(shape[1]) + center_rc = (float(H // 2), float(W // 2)) + + _plot_lattice_vectors(axs[0], center_rc, u_rc, v_rc) + + if len(axs) >= 2: + u_disp = _raw_vec_to_display(u_rc, rotation_ccw_deg=float(rot_ccw_deg), transpose=bool(q_transpose)) + v_disp = _raw_vec_to_display(v_rc, rotation_ccw_deg=float(rot_ccw_deg), transpose=bool(q_transpose)) + _plot_lattice_vectors(axs[1], center_rc, u_disp, v_disp) + + +def _parabolic_vertex_delta(v_m1: float, v_0: float, v_p1: float) -> float: + denom = (v_m1 - 2.0 * v_0 + v_p1) + if denom == 0 or not np.isfinite(denom): + return 0.0 + delta = 0.5 * (v_m1 - v_p1) / denom + if not np.isfinite(delta): + return 0.0 + return float(np.clip(delta, -1.0, 1.0)) + + +def _refine_peak_subpixel( + im: NDArray, + *, + r_guess: float, + c_guess: float, + radius_px: float = 2.0, + log_fit: bool = False, +) -> tuple[float, float]: + im = np.asarray(im, dtype=float) + H, W = im.shape + + r0 = int(np.clip(int(np.round(r_guess)), 0, H - 1)) + c0 = int(np.clip(int(np.round(c_guess)), 0, W - 1)) + rad = int(max(0, int(np.ceil(float(radius_px))))) + + r1 = max(0, r0 - rad) + r2 = min(H, r0 + rad + 1) + c1 = max(0, c0 - rad) + c2 = min(W, c0 + rad + 1) + + win = im[r1:r2, c1:c2] + if win.size == 0: + return float(r_guess), float(c_guess) + + ir, ic = np.unravel_index(int(np.argmax(win)), win.shape) + r_peak = r1 + int(ir) + c_peak = c1 + int(ic) + + if 0 < r_peak < H - 1: + col = im[r_peak - 1 : r_peak + 2, c_peak] + if log_fit: + col = np.log(np.clip(col, 1e-12, None)) + dr = _parabolic_vertex_delta(float(col[0]), float(col[1]), float(col[2])) + else: + dr = 0.0 + + if 0 < c_peak < W - 1: + row = im[r_peak, c_peak - 1 : c_peak + 2] + if log_fit: + row = np.log(np.clip(row, 1e-12, None)) + dc = _parabolic_vertex_delta(float(row[0]), float(row[1]), float(row[2])) + else: + dc = 0.0 + + return float(r_peak) + dr, float(c_peak) + dc + + +def _refine_peak_subpixel_dft( + im: NDArray, + *, + r0: float, + c0: float, + upsample: int, + log_fit: bool = False, +) -> tuple[float, float]: + if int(upsample) <= 1: + return float(r0), float(c0) + + im = np.asarray(im, dtype=float) + F = np.fft.fft2(im) + + up = int(upsample) + du = int(np.ceil(1.5 * up)) + + patch = dft_upsample(F, up=up, shift=(float(r0), float(c0)), device="cpu") + patch = np.asarray(patch, dtype=float) + + i0, j0 = np.unravel_index(int(np.argmax(patch)), patch.shape) + i0 = int(i0) + j0 = int(j0) + + if 0 < i0 < patch.shape[0] - 1: + col = patch[i0 - 1 : i0 + 2, j0] + if log_fit: + col = np.log(np.clip(col, 1e-12, None)) + di = _parabolic_vertex_delta(float(col[0]), float(col[1]), float(col[2])) + else: + di = 0.0 + + if 0 < j0 < patch.shape[1] - 1: + row = patch[i0, j0 - 1 : j0 + 2] + if log_fit: + row = np.log(np.clip(row, 1e-12, None)) + dj = _parabolic_vertex_delta(float(row[0]), float(row[1]), float(row[2])) + else: + dj = 0.0 + + dr = (float(i0) - float(du) + float(di)) / float(up) + dc = (float(j0) - float(du) + float(dj)) / float(up) + + return float(r0) + dr, float(c0) + dc + + +def _refine_lattice_vectors( + im: NDArray, + *, + u_rc: NDArray, + v_rc: NDArray, + radius_px: float = 2.0, + log_fit: bool = False, + refine_dft: bool = True, + upsample: int = 16, +) -> tuple[NDArray, NDArray]: + im = np.asarray(im, dtype=float) + if im.ndim != 2: + raise ValueError("im must be 2D.") + + H, W = im.shape + r_center = float(H // 2) + c_center = float(W // 2) + + def _refine(vec: NDArray) -> NDArray: + vec = np.asarray(vec, dtype=float).reshape(2) + r_guess = r_center + float(vec[0]) + c_guess = c_center + float(vec[1]) + + r1, c1 = _refine_peak_subpixel( + im, + r_guess=float(r_guess), + c_guess=float(c_guess), + radius_px=float(radius_px), + log_fit=bool(log_fit), + ) + + if refine_dft and int(upsample) > 1: + r2, c2 = _refine_peak_subpixel_dft( + im, + r0=float(r1), + c0=float(c1), + upsample=int(upsample), + log_fit=bool(log_fit), + ) + else: + r2, c2 = float(r1), float(c1) + + return np.array((r2 - r_center, c2 - c_center), dtype=float) + + return _refine(u_rc), _refine(v_rc) From 9b943923a2140f92dc1b51c6b7ce351511c7ceb1 Mon Sep 17 00:00:00 2001 From: cophus Date: Sat, 3 Jan 2026 12:39:39 -0800 Subject: [PATCH 07/88] faster strain calc, adding h5plugin to read arina data --- pyproject.toml | 1 + src/quantem/diffraction/strain.py | 55 ++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9223ae7c..1c61b262 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,4 +69,5 @@ dev = [ "pre-commit>=4.2.0", "ruff>=0.11.5", "tomli>=2.2.1", + "hdf5plugin>=6.0.0", ] diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py index b5549ff7..bcfa981d 100644 --- a/src/quantem/diffraction/strain.py +++ b/src/quantem/diffraction/strain.py @@ -59,7 +59,7 @@ def from_data(cls, data: NDArray | Dataset4dstem, *, name: str = "strain_map") - def diffraction_mask( self, threshold = None, - edge_blend = 32.0, + edge_blend = 64.0, plot_mask = True, figsize = (8,4), ): @@ -103,6 +103,7 @@ def preprocess( mode: str = "linear", q_to_r_rotation_ccw_deg: float | None = None, q_transpose: bool | None = None, + skip = None, plot_transform: bool = True, cropping_factor: float = 0.25, **plot_kwargs: Any, @@ -178,18 +179,32 @@ def preprocess( self.metadata["q_to_r_rotation_ccw_deg"] = float(q_to_r_rotation_ccw_deg) self.metadata["q_transpose"] = bool(q_transpose) - if self.metadata["mode"] == "linear": - im = np.mean(np.abs(np.fft.fft2( - self.dataset.array * self.mask_diffraction[None,None,:,:] + \ - self.mask_diffraction_inv[None,None,:,:] - )), axis=(0, 1)) - elif self.metadata["mode"] == "log": - im = np.mean(np.abs(np.fft.fft2(np.log( - self.dataset.array * self.mask_diffraction[None,None,:,:] + \ - self.mask_diffraction_inv[None,None,:,:] - ))), axis=(0, 1)) + if skip is None: + if self.metadata["mode"] == "linear": + im = np.mean(np.abs(np.fft.fft2( + self.dataset.array * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + )), axis=(0, 1)) + elif self.metadata["mode"] == "log": + im = np.mean(np.abs(np.fft.fft2(np.log1p( + self.dataset.array * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + ))), axis=(0, 1)) + else: + raise ValueError("mode must be 'linear' or 'log'") else: - raise ValueError("mode must be 'linear' or 'log'") + if self.metadata["mode"] == "linear": + im = np.mean(np.abs(np.fft.fft2( + self.dataset.array[::skip,::skip] * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + )), axis=(0, 1)) + elif self.metadata["mode"] == "log": + im = np.mean(np.abs(np.fft.fft2(np.log1p( + self.dataset.array[::skip,::skip] * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + ))), axis=(0, 1)) + else: + raise ValueError("mode must be 'linear' or 'log'") im = np.fft.fftshift(im) @@ -239,8 +254,19 @@ def plot_transform( target_units = float(scalebar_fraction) * view_w_px * sampling sb_len = _nice_length_units(target_units) + # intensity scaling: compute from transform, apply same scaling to both panels + kr = (np.arange(self.transform.shape[0], dtype=float) - self.transform.shape[0] // 2)[:, None] + kc = (np.arange(self.transform.shape[1], dtype=float) - self.transform.shape[1] // 2)[None, :] + qmag = np.sqrt(kr * kr + kc * kc) + im0 = self.transform.array + tmp = im0 * qmag + i0 = np.unravel_index(int(np.nanargmax(tmp)), tmp.shape) + vmin = 0.0 + vmax = im0[i0] + defaults = dict( - vmax=1.0, + vmin=vmin, + vmax=vmax, title=("Original Transform", "Rotated Transform"), scalebar=ScalebarConfig( sampling=sampling, @@ -371,8 +397,7 @@ def fit_lattice_vectors( if mode == "linear": im = np.fft.fftshift(np.abs(np.fft.fft2(dp))) elif mode == "log": - int_range = np.max(dp) - np.min(dp) - im = np.fft.fftshift(np.abs(np.fft.fft2(np.log(dp + int_range*0.01)))) + im = np.fft.fftshift(np.abs(np.fft.fft2(np.log1p(dp)))) else: raise ValueError("metadata['mode'] must be 'linear' or 'log'") From dc4ee09d7941ca56b211d9e5f39099702c04df3d Mon Sep 17 00:00:00 2001 From: cophus Date: Sat, 3 Jan 2026 12:41:18 -0800 Subject: [PATCH 08/88] faster strain, adding h5plugin to read arina data --- pyproject.toml | 1 + src/quantem/diffraction/strain.py | 55 +- uv.lock | 3256 +++++++++++++++-------------- 3 files changed, 1670 insertions(+), 1642 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9223ae7c..1c61b262 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,4 +69,5 @@ dev = [ "pre-commit>=4.2.0", "ruff>=0.11.5", "tomli>=2.2.1", + "hdf5plugin>=6.0.0", ] diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py index b5549ff7..bcfa981d 100644 --- a/src/quantem/diffraction/strain.py +++ b/src/quantem/diffraction/strain.py @@ -59,7 +59,7 @@ def from_data(cls, data: NDArray | Dataset4dstem, *, name: str = "strain_map") - def diffraction_mask( self, threshold = None, - edge_blend = 32.0, + edge_blend = 64.0, plot_mask = True, figsize = (8,4), ): @@ -103,6 +103,7 @@ def preprocess( mode: str = "linear", q_to_r_rotation_ccw_deg: float | None = None, q_transpose: bool | None = None, + skip = None, plot_transform: bool = True, cropping_factor: float = 0.25, **plot_kwargs: Any, @@ -178,18 +179,32 @@ def preprocess( self.metadata["q_to_r_rotation_ccw_deg"] = float(q_to_r_rotation_ccw_deg) self.metadata["q_transpose"] = bool(q_transpose) - if self.metadata["mode"] == "linear": - im = np.mean(np.abs(np.fft.fft2( - self.dataset.array * self.mask_diffraction[None,None,:,:] + \ - self.mask_diffraction_inv[None,None,:,:] - )), axis=(0, 1)) - elif self.metadata["mode"] == "log": - im = np.mean(np.abs(np.fft.fft2(np.log( - self.dataset.array * self.mask_diffraction[None,None,:,:] + \ - self.mask_diffraction_inv[None,None,:,:] - ))), axis=(0, 1)) + if skip is None: + if self.metadata["mode"] == "linear": + im = np.mean(np.abs(np.fft.fft2( + self.dataset.array * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + )), axis=(0, 1)) + elif self.metadata["mode"] == "log": + im = np.mean(np.abs(np.fft.fft2(np.log1p( + self.dataset.array * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + ))), axis=(0, 1)) + else: + raise ValueError("mode must be 'linear' or 'log'") else: - raise ValueError("mode must be 'linear' or 'log'") + if self.metadata["mode"] == "linear": + im = np.mean(np.abs(np.fft.fft2( + self.dataset.array[::skip,::skip] * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + )), axis=(0, 1)) + elif self.metadata["mode"] == "log": + im = np.mean(np.abs(np.fft.fft2(np.log1p( + self.dataset.array[::skip,::skip] * self.mask_diffraction[None,None,:,:] + \ + self.mask_diffraction_inv[None,None,:,:] + ))), axis=(0, 1)) + else: + raise ValueError("mode must be 'linear' or 'log'") im = np.fft.fftshift(im) @@ -239,8 +254,19 @@ def plot_transform( target_units = float(scalebar_fraction) * view_w_px * sampling sb_len = _nice_length_units(target_units) + # intensity scaling: compute from transform, apply same scaling to both panels + kr = (np.arange(self.transform.shape[0], dtype=float) - self.transform.shape[0] // 2)[:, None] + kc = (np.arange(self.transform.shape[1], dtype=float) - self.transform.shape[1] // 2)[None, :] + qmag = np.sqrt(kr * kr + kc * kc) + im0 = self.transform.array + tmp = im0 * qmag + i0 = np.unravel_index(int(np.nanargmax(tmp)), tmp.shape) + vmin = 0.0 + vmax = im0[i0] + defaults = dict( - vmax=1.0, + vmin=vmin, + vmax=vmax, title=("Original Transform", "Rotated Transform"), scalebar=ScalebarConfig( sampling=sampling, @@ -371,8 +397,7 @@ def fit_lattice_vectors( if mode == "linear": im = np.fft.fftshift(np.abs(np.fft.fft2(dp))) elif mode == "log": - int_range = np.max(dp) - np.min(dp) - im = np.fft.fftshift(np.abs(np.fft.fft2(np.log(dp + int_range*0.01)))) + im = np.fft.fftshift(np.abs(np.fft.fft2(np.log1p(dp)))) else: raise ValueError("metadata['mode'] must be 'linear' or 'log'") diff --git a/uv.lock b/uv.lock index 2960e9b3..ae9f2360 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 1 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -11,9 +11,9 @@ resolution-markers = [ name = "absl-py" version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588, upload-time = "2025-07-03T09:31:44.05Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, + { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811 }, ] [[package]] @@ -25,9 +25,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554 }, ] [[package]] @@ -39,18 +39,18 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094 } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097 }, ] [[package]] name = "appnope" version = "0.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170 } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 }, ] [[package]] @@ -60,9 +60,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argon2-cffi-bindings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657 }, ] [[package]] @@ -72,28 +72,28 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, - { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, - { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, - { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, - { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, - { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, - { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, - { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, - { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, - { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393 }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328 }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269 }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558 }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364 }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637 }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934 }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158 }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597 }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231 }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121 }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177 }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090 }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246 }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126 }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343 }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777 }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180 }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715 }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149 }, ] [[package]] @@ -104,45 +104,45 @@ dependencies = [ { name = "python-dateutil" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797 }, ] [[package]] name = "asttokens" version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047 }, ] [[package]] name = "async-lru" version = "2.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380, upload-time = "2025-03-16T17:25:36.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380 } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069 }, ] [[package]] name = "attrs" version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, ] [[package]] name = "babel" version = "2.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, ] [[package]] @@ -153,9 +153,9 @@ dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822 } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392 }, ] [[package]] @@ -165,9 +165,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437 }, ] [package.optional-dependencies] @@ -179,9 +179,9 @@ css = [ name = "certifi" version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538 } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 }, ] [[package]] @@ -191,149 +191,149 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, ] [[package]] name = "cfgv" version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, ] [[package]] name = "charset-normalizer" version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, ] [[package]] @@ -343,18 +343,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, ] [[package]] name = "cloudpickle" version = "3.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330 } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228 }, ] [[package]] @@ -366,18 +366,18 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/ca/39152b506133881be74bd46cced27ec10d9bbc85db5bdfc22c8f8e4011f9/cmasher-1.9.2.tar.gz", hash = "sha256:6c90c2cec87146e3210d3e7f2321fceee38ac7d87c136cd0de25160a14f57ff5", size = 520860, upload-time = "2024-11-19T11:17:02.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/ca/39152b506133881be74bd46cced27ec10d9bbc85db5bdfc22c8f8e4011f9/cmasher-1.9.2.tar.gz", hash = "sha256:6c90c2cec87146e3210d3e7f2321fceee38ac7d87c136cd0de25160a14f57ff5", size = 520860 } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/d2/6fcb93a60777fef7662d84ba60846d2dee9b6b70b4a62472515110f79cee/cmasher-1.9.2-py3-none-any.whl", hash = "sha256:2fe45fde06051050dda5c023a527ba9066ca21f161c793f22839a6ebe6e4bbbb", size = 506496, upload-time = "2024-11-19T11:16:58.549Z" }, + { url = "https://files.pythonhosted.org/packages/91/d2/6fcb93a60777fef7662d84ba60846d2dee9b6b70b4a62472515110f79cee/cmasher-1.9.2-py3-none-any.whl", hash = "sha256:2fe45fde06051050dda5c023a527ba9066ca21f161c793f22839a6ebe6e4bbbb", size = 506496 }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] @@ -387,9 +387,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743 }, ] [[package]] @@ -399,18 +399,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/e4/aa41ae14c5c061205715006c8834496d86ec7500f1edda5981f0f0190cc6/colorspacious-1.1.2.tar.gz", hash = "sha256:5e9072e8cdca889dac445c35c9362a22ccf758e97b00b79ff0d5a7ba3e11b618", size = 688573, upload-time = "2018-04-08T04:27:30.83Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/e4/aa41ae14c5c061205715006c8834496d86ec7500f1edda5981f0f0190cc6/colorspacious-1.1.2.tar.gz", hash = "sha256:5e9072e8cdca889dac445c35c9362a22ccf758e97b00b79ff0d5a7ba3e11b618", size = 688573 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/a1/318b9aeca7b9856410ededa4f52d6f82174d1a41e64bdd70d951e532675a/colorspacious-1.1.2-py2.py3-none-any.whl", hash = "sha256:c78befa603cea5dccb332464e7dd29e96469eebf6cd5133029153d1e69e3fd6f", size = 37735, upload-time = "2018-04-08T04:27:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a1/318b9aeca7b9856410ededa4f52d6f82174d1a41e64bdd70d951e532675a/colorspacious-1.1.2-py2.py3-none-any.whl", hash = "sha256:c78befa603cea5dccb332464e7dd29e96469eebf6cd5133029153d1e69e3fd6f", size = 37735 }, ] [[package]] name = "comm" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319 } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294 }, ] [[package]] @@ -420,155 +420,155 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773 }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149 }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222 }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234 }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555 }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238 }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218 }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867 }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677 }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234 }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123 }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419 }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979 }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653 }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536 }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397 }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601 }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288 }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386 }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018 }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567 }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655 }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257 }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034 }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672 }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234 }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169 }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859 }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062 }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932 }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024 }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578 }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524 }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730 }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897 }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751 }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486 }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106 }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548 }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297 }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023 }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157 }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570 }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713 }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189 }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251 }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810 }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871 }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264 }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819 }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650 }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833 }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692 }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424 }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300 }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769 }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892 }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748 }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554 }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118 }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555 }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295 }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027 }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809 }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593 }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202 }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207 }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315 }, ] [[package]] name = "crc32c" version = "2.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/66/7e97aa77af7cf6afbff26e3651b564fe41932599bc2d3dce0b2f73d4829a/crc32c-2.8.tar.gz", hash = "sha256:578728964e59c47c356aeeedee6220e021e124b9d3e8631d95d9a5e5f06e261c", size = 48179, upload-time = "2025-10-17T06:20:13.61Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/0b/5e03b22d913698e9cc563f39b9f6bbd508606bf6b8e9122cd6bf196b87ea/crc32c-2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e560a97fbb96c9897cb1d9b5076ef12fc12e2e25622530a1afd0de4240f17e1f", size = 66329, upload-time = "2025-10-17T06:19:01.771Z" }, - { url = "https://files.pythonhosted.org/packages/6b/38/2fe0051ffe8c6a650c8b1ac0da31b8802d1dbe5fa40a84e4b6b6f5583db5/crc32c-2.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6762d276d90331a490ef7e71ffee53b9c0eb053bd75a272d786f3b08d3fe3671", size = 62988, upload-time = "2025-10-17T06:19:02.953Z" }, - { url = "https://files.pythonhosted.org/packages/3e/30/5837a71c014be83aba1469c58820d287fc836512a0cad6b8fdd43868accd/crc32c-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:60670569f5ede91e39f48fb0cb4060e05b8d8704dd9e17ede930bf441b2f73ef", size = 61522, upload-time = "2025-10-17T06:19:03.796Z" }, - { url = "https://files.pythonhosted.org/packages/ca/29/63972fc1452778e2092ae998c50cbfc2fc93e3fa9798a0278650cd6169c5/crc32c-2.8-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:711743da6ccc70b3c6718c328947b0b6f34a1fe6a6c27cc6c1d69cc226bf70e9", size = 80200, upload-time = "2025-10-17T06:19:04.617Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3a/60eb49d7bdada4122b3ffd45b0df54bdc1b8dd092cda4b069a287bdfcff4/crc32c-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eb4094a2054774f13b26f21bf56792bb44fa1fcee6c6ad099387a43ffbfb4fa", size = 81757, upload-time = "2025-10-17T06:19:05.496Z" }, - { url = "https://files.pythonhosted.org/packages/f5/63/6efc1b64429ef7d23bd58b75b7ac24d15df327e3ebbe9c247a0f7b1c2ed1/crc32c-2.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fff15bf2bd3e95780516baae935ed12be88deaa5ebe6143c53eb0d26a7bdc7b7", size = 80830, upload-time = "2025-10-17T06:19:06.621Z" }, - { url = "https://files.pythonhosted.org/packages/e1/eb/0ae9f436f8004f1c88f7429e659a7218a3879bd11a6b18ed1257aad7e98b/crc32c-2.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c0e11e3826668121fa53e0745635baf5e4f0ded437e8ff63ea56f38fc4f970a", size = 80095, upload-time = "2025-10-17T06:19:07.381Z" }, - { url = "https://files.pythonhosted.org/packages/9e/81/4afc9d468977a4cd94a2eb62908553345009a7c0d30e74463a15d4b48ec3/crc32c-2.8-cp311-cp311-win32.whl", hash = "sha256:38f915336715d1f1353ab07d7d786f8a789b119e273aea106ba55355dfc9101d", size = 64886, upload-time = "2025-10-17T06:19:08.497Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e8/94e839c9f7e767bf8479046a207afd440a08f5c59b52586e1af5e64fa4a0/crc32c-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:60e0a765b1caab8d31b2ea80840639253906a9351d4b861551c8c8625ea20f86", size = 66639, upload-time = "2025-10-17T06:19:09.338Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/fd18ef23c42926b79c7003e16cb0f79043b5b179c633521343d3b499e996/crc32c-2.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:572ffb1b78cce3d88e8d4143e154d31044a44be42cb3f6fbbf77f1e7a941c5ab", size = 66379, upload-time = "2025-10-17T06:19:10.115Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b8/c584958e53f7798dd358f5bdb1bbfc97483134f053ee399d3eeb26cca075/crc32c-2.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cf827b3758ee0c4aacd21ceca0e2da83681f10295c38a10bfeb105f7d98f7a68", size = 63042, upload-time = "2025-10-17T06:19:10.946Z" }, - { url = "https://files.pythonhosted.org/packages/62/e6/6f2af0ec64a668a46c861e5bc778ea3ee42171fedfc5440f791f470fd783/crc32c-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:106fbd79013e06fa92bc3b51031694fcc1249811ed4364ef1554ee3dd2c7f5a2", size = 61528, upload-time = "2025-10-17T06:19:11.768Z" }, - { url = "https://files.pythonhosted.org/packages/17/8b/4a04bd80a024f1a23978f19ae99407783e06549e361ab56e9c08bba3c1d3/crc32c-2.8-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6dde035f91ffbfe23163e68605ee5a4bb8ceebd71ed54bb1fb1d0526cdd125a2", size = 80028, upload-time = "2025-10-17T06:19:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/21/8f/01c7afdc76ac2007d0e6a98e7300b4470b170480f8188475b597d1f4b4c6/crc32c-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e41ebe7c2f0fdcd9f3a3fd206989a36b460b4d3f24816d53e5be6c7dba72c5e1", size = 81531, upload-time = "2025-10-17T06:19:13.406Z" }, - { url = "https://files.pythonhosted.org/packages/32/2b/8f78c5a8cc66486be5f51b6f038fc347c3ba748d3ea68be17a014283c331/crc32c-2.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecf66cf90266d9c15cea597d5cc86c01917cd1a238dc3c51420c7886fa750d7e", size = 80608, upload-time = "2025-10-17T06:19:14.223Z" }, - { url = "https://files.pythonhosted.org/packages/db/86/fad1a94cdeeeb6b6e2323c87f970186e74bfd6fbfbc247bf5c88ad0873d5/crc32c-2.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:59eee5f3a69ad0793d5fa9cdc9b9d743b0cd50edf7fccc0a3988a821fef0208c", size = 79886, upload-time = "2025-10-17T06:19:15.345Z" }, - { url = "https://files.pythonhosted.org/packages/d5/db/1a7cb6757a1e32376fa2dfce00c815ea4ee614a94f9bff8228e37420c183/crc32c-2.8-cp312-cp312-win32.whl", hash = "sha256:a73d03ce3604aa5d7a2698e9057a0eef69f529c46497b27ee1c38158e90ceb76", size = 64896, upload-time = "2025-10-17T06:19:16.457Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8e/2024de34399b2e401a37dcb54b224b56c747b0dc46de4966886827b4d370/crc32c-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:56b3b7d015247962cf58186e06d18c3d75a1a63d709d3233509e1c50a2d36aa2", size = 66645, upload-time = "2025-10-17T06:19:17.235Z" }, - { url = "https://files.pythonhosted.org/packages/e8/d8/3ae227890b3be40955a7144106ef4dd97d6123a82c2a5310cdab58ca49d8/crc32c-2.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36f1e03ee9e9c6938e67d3bcb60e36f260170aa5f37da1185e04ef37b56af395", size = 66380, upload-time = "2025-10-17T06:19:18.009Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8b/178d3f987cd0e049b484615512d3f91f3d2caeeb8ff336bb5896ae317438/crc32c-2.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2f3226b94b85a8dd9b3533601d7a63e9e3e8edf03a8a169830ee8303a199aeb", size = 63048, upload-time = "2025-10-17T06:19:18.853Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a1/48145ae2545ebc0169d3283ebe882da580ea4606bfb67cf4ca922ac3cfc3/crc32c-2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6e08628bc72d5b6bc8e0730e8f142194b610e780a98c58cb6698e665cb885a5b", size = 61530, upload-time = "2025-10-17T06:19:19.974Z" }, - { url = "https://files.pythonhosted.org/packages/06/4b/cf05ed9d934cc30e5ae22f97c8272face420a476090e736615d9a6b53de0/crc32c-2.8-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:086f64793c5ec856d1ab31a026d52ad2b895ac83d7a38fce557d74eb857f0a82", size = 80001, upload-time = "2025-10-17T06:19:20.784Z" }, - { url = "https://files.pythonhosted.org/packages/15/ab/4b04801739faf36345f6ba1920be5b1c70282fec52f8280afd3613fb13e2/crc32c-2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcf72ee7e0135b3d941c34bb2c26c3fc6bc207106b49fd89aaafaeae223ae209", size = 81543, upload-time = "2025-10-17T06:19:21.557Z" }, - { url = "https://files.pythonhosted.org/packages/a9/1b/6e38dde5bfd2ea69b7f2ab6ec229fcd972a53d39e2db4efe75c0ac0382ce/crc32c-2.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a717dd9c3fd777d9bc6603717eae172887d402c4ab589d124ebd0184a83f89e", size = 80644, upload-time = "2025-10-17T06:19:22.325Z" }, - { url = "https://files.pythonhosted.org/packages/ce/45/012176ffee90059ae8ec7131019c71724ea472aa63e72c0c8edbd1fad1d7/crc32c-2.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0450bb845b3c3c7b9bdc0b4e95620ec9a40824abdc8c86d6285c919a90743c1a", size = 79919, upload-time = "2025-10-17T06:19:23.101Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/f557629842f9dec2b3461cb3a0d854bb586ec45b814cea58b082c32f0dde/crc32c-2.8-cp313-cp313-win32.whl", hash = "sha256:765d220bfcbcffa6598ac11eb1e10af0ee4802b49fe126aa6bf79f8ddb9931d1", size = 64896, upload-time = "2025-10-17T06:19:23.88Z" }, - { url = "https://files.pythonhosted.org/packages/d0/db/fd0f698c15d1e21d47c64181a98290665a08fcbb3940cd559e9c15bda57e/crc32c-2.8-cp313-cp313-win_amd64.whl", hash = "sha256:171ff0260d112c62abcce29332986950a57bddee514e0a2418bfde493ea06bb3", size = 66646, upload-time = "2025-10-17T06:19:24.702Z" }, - { url = "https://files.pythonhosted.org/packages/db/b9/8e5d7054fe8e7eecab10fd0c8e7ffb01439417bdb6de1d66a81c38fc4a20/crc32c-2.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b977a32a3708d6f51703c8557008f190aaa434d7347431efb0e86fcbe78c2a50", size = 66203, upload-time = "2025-10-17T06:19:25.872Z" }, - { url = "https://files.pythonhosted.org/packages/55/5f/cc926c70057a63cc0c98a3c8a896eb15fc7e74d3034eadd53c94917c6cc3/crc32c-2.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7399b01db4adaf41da2fb36fe2408e75a8d82a179a9564ed7619412e427b26d6", size = 62956, upload-time = "2025-10-17T06:19:26.652Z" }, - { url = "https://files.pythonhosted.org/packages/a1/8a/0660c44a2dd2cb6ccbb529eb363b9280f5c766f1017bc8355ed8d695bd94/crc32c-2.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4379f73f9cdad31958a673d11a332ec725ca71572401ca865867229f5f15e853", size = 61442, upload-time = "2025-10-17T06:19:27.74Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5a/6108d2dfc0fe33522ce83ba07aed4b22014911b387afa228808a278e27cd/crc32c-2.8-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e68264555fab19bab08331550dab58573e351a63ed79c869d455edd3b0aa417", size = 79109, upload-time = "2025-10-17T06:19:28.535Z" }, - { url = "https://files.pythonhosted.org/packages/84/1e/c054f9e390090c197abf3d2936f4f9effaf0c6ee14569ae03d6ddf86958a/crc32c-2.8-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b48f2486727b8d0e7ccbae4a34cb0300498433d2a9d6b49cb13cb57c2e3f19cb", size = 80987, upload-time = "2025-10-17T06:19:29.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ad/1650e5c3341e4a485f800ea83116d72965030c5d48ccc168fcc685756e4d/crc32c-2.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ecf123348934a086df8c8fde7f9f2d716d523ca0707c5a1367b8bb00d8134823", size = 79994, upload-time = "2025-10-17T06:19:30.109Z" }, - { url = "https://files.pythonhosted.org/packages/d7/3b/f2ed924b177729cbb2ab30ca2902abff653c31d48c95e7b66717a9ca9fcc/crc32c-2.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e636ac60f76de538f7a2c0d0f3abf43104ee83a8f5e516f6345dc283ed1a4df7", size = 79046, upload-time = "2025-10-17T06:19:30.894Z" }, - { url = "https://files.pythonhosted.org/packages/4b/80/413b05ee6ace613208b31b3670c3135ee1cf451f0e72a9c839b4946acc04/crc32c-2.8-cp313-cp313t-win32.whl", hash = "sha256:8dd4a19505e0253892e1b2f1425cc3bd47f79ae5a04cb8800315d00aad7197f2", size = 64837, upload-time = "2025-10-17T06:19:32.03Z" }, - { url = "https://files.pythonhosted.org/packages/3b/1b/85eddb6ac5b38496c4e35c20298aae627970c88c3c624a22ab33e84f16c7/crc32c-2.8-cp313-cp313t-win_amd64.whl", hash = "sha256:4bb18e4bd98fb266596523ffc6be9c5b2387b2fa4e505ec56ca36336f49cb639", size = 66574, upload-time = "2025-10-17T06:19:33.143Z" }, - { url = "https://files.pythonhosted.org/packages/aa/df/50e9079b532ff53dbfc0e66eed781374bd455af02ed5df8b56ad538de4ff/crc32c-2.8-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3a3b2e4bcf7b3ee333050e7d3ff38e2ba46ea205f1d73d8949b248aaffe937ac", size = 66399, upload-time = "2025-10-17T06:19:34.279Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2e/67e3b0bc3d30e46ea5d16365cc81203286387671e22f2307eb41f19abb9c/crc32c-2.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:445e559e66dff16be54f8a4ef95aa6b01db799a639956d995c5498ba513fccc2", size = 63044, upload-time = "2025-10-17T06:19:35.062Z" }, - { url = "https://files.pythonhosted.org/packages/36/ea/1723b17437e4344ed8d067456382ecb1f5b535d83fdc5aaebab676c6d273/crc32c-2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bf3040919e17afa5782e01b1875d6a05f44b8f19c05f211d8b9f8a1deb8bbd9c", size = 61541, upload-time = "2025-10-17T06:19:36.204Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6a/cbec8a235c5b46a01f319939b538958662159aec0ed3a74944e3a6de21f1/crc32c-2.8-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5607ab8221e1ffd411f64aa40dbb6850cf06dd2908c9debd05d371e1acf62ff3", size = 80139, upload-time = "2025-10-17T06:19:37.351Z" }, - { url = "https://files.pythonhosted.org/packages/21/31/d096722fe74b692d6e8206c27da1ea5f6b2a12ff92c54a62a6ba2f376254/crc32c-2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f5db4f16816926986d3c94253314920689706ae13a9bf4888b47336c6735ce", size = 81736, upload-time = "2025-10-17T06:19:38.16Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a2/f75ef716ff7e3c22f385ba6ef30c5de80c19a21ebe699dc90824a1903275/crc32c-2.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70b0153c4d418b673309d3529334d117e1074c4a3b2d7f676e430d72c14de67b", size = 80795, upload-time = "2025-10-17T06:19:38.948Z" }, - { url = "https://files.pythonhosted.org/packages/d8/94/6d647a12d96ab087d9b8eacee3da073f981987827d57c7072f89ffc7b6cd/crc32c-2.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c8933531442042438753755a5c8a9034e4d88b01da9eb796f7e151b31a7256c", size = 80042, upload-time = "2025-10-17T06:19:39.725Z" }, - { url = "https://files.pythonhosted.org/packages/cd/dc/32b8896b40a0afee7a3c040536d0da5a73e68df2be9fadd21770fd158e16/crc32c-2.8-cp314-cp314-win32.whl", hash = "sha256:cdc83a3fe6c4e5df9457294cfd643de7d95bd4e9382c1dd6ed1e0f0f9169172c", size = 64914, upload-time = "2025-10-17T06:19:40.527Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b4/4308b27d307e8ecaf8dd1dcc63bbb0e47ae1826d93faa3e62d1ee00ee2d5/crc32c-2.8-cp314-cp314-win_amd64.whl", hash = "sha256:509e10035106df66770fe24b9eb8d9e32b6fb967df17744402fb67772d8b2bc7", size = 66723, upload-time = "2025-10-17T06:19:42.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/d5/a19d2489fa997a143bfbbf971a5c9a43f8b1ba9e775b1fb362d8fb15260c/crc32c-2.8-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:864359a39777a07b09b28eb31337c0cc603d5c1bf0fc328c3af736a8da624ec0", size = 66201, upload-time = "2025-10-17T06:19:43.273Z" }, - { url = "https://files.pythonhosted.org/packages/98/c2/5f82f22d2c1242cb6f6fe92aa9a42991ebea86de994b8f9974d9c1d128e2/crc32c-2.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:14511d7cfc5d9f5e1a6c6b64caa6225c2bdc1ed00d725e9a374a3e84073ce180", size = 62956, upload-time = "2025-10-17T06:19:44.099Z" }, - { url = "https://files.pythonhosted.org/packages/9b/61/3d43d33489cf974fb78bfb3500845770e139ae6d1d83473b660bd8f79a6c/crc32c-2.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:918b7999b52b5dcbcea34081e9a02d46917d571921a3f209956a9a429b2e06e5", size = 61443, upload-time = "2025-10-17T06:19:44.89Z" }, - { url = "https://files.pythonhosted.org/packages/52/6d/f306ce64a352a3002f76b0fc88a1373f4541f9d34fad3668688610bab14b/crc32c-2.8-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cc445da03fc012a5a03b71da1df1b40139729e6a5571fd4215ab40bfb39689c7", size = 79106, upload-time = "2025-10-17T06:19:45.688Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b7/1f74965dd7ea762954a69d172dfb3a706049c84ffa45d31401d010a4a126/crc32c-2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e3dde2ec59a8a830511d72a086ead95c0b0b7f0d418f93ea106244c5e77e350", size = 80983, upload-time = "2025-10-17T06:19:46.792Z" }, - { url = "https://files.pythonhosted.org/packages/1b/50/af93f0d91ccd61833ce77374ebfbd16f5805f5c17d18c6470976d9866d76/crc32c-2.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:61d51681a08b6a2a2e771b7f0cd1947fb87cb28f38ed55a01cb7c40b2ac4cdd8", size = 80009, upload-time = "2025-10-17T06:19:47.619Z" }, - { url = "https://files.pythonhosted.org/packages/ee/fa/94f394beb68a88258af694dab2f1284f55a406b615d7900bdd6235283bc4/crc32c-2.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:67c0716c3b1a02d5235be649487b637eed21f2d070f2b3f63f709dcd2fefb4c7", size = 79066, upload-time = "2025-10-17T06:19:48.409Z" }, - { url = "https://files.pythonhosted.org/packages/91/c6/a6050e0c64fd73c67a97da96cb59f08b05111e00b958fb87ecdce99f17ac/crc32c-2.8-cp314-cp314t-win32.whl", hash = "sha256:2e8fe863fbbd8bdb6b414a2090f1b0f52106e76e9a9c96a413495dbe5ebe492a", size = 64869, upload-time = "2025-10-17T06:19:49.197Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/c7735034e401cb1ea14f996a224518e3a3fa9987cb13680e707328a7d779/crc32c-2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:20a9cfb897693eb6da19e52e2a7be2026fd4d9fc8ae318f086c0d71d5dd2d8e0", size = 66633, upload-time = "2025-10-17T06:19:50.003Z" }, - { url = "https://files.pythonhosted.org/packages/a7/1d/dd926c68eb8aac8b142a1a10b8eb62d95212c1cf81775644373fe7cceac2/crc32c-2.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5833f4071da7ea182c514ba17d1eee8aec3c5be927d798222fbfbbd0f5eea02c", size = 62345, upload-time = "2025-10-17T06:20:09.39Z" }, - { url = "https://files.pythonhosted.org/packages/51/be/803404e5abea2ef2c15042edca04bbb7f625044cca879e47f186b43887c2/crc32c-2.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1dc4da036126ac07b39dd9d03e93e585ec615a2ad28ff12757aef7de175295a8", size = 61229, upload-time = "2025-10-17T06:20:10.236Z" }, - { url = "https://files.pythonhosted.org/packages/fc/3a/00cc578cd27ed0b22c9be25cef2c24539d92df9fa80ebd67a3fc5419724c/crc32c-2.8-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15905fa78344654e241371c47e6ed2411f9eeb2b8095311c68c88eccf541e8b4", size = 64108, upload-time = "2025-10-17T06:20:11.072Z" }, - { url = "https://files.pythonhosted.org/packages/6b/bc/0587ef99a1c7629f95dd0c9d4f3d894de383a0df85831eb16c48a6afdae4/crc32c-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c596f918688821f796434e89b431b1698396c38bf0b56de873621528fe3ecb1e", size = 64815, upload-time = "2025-10-17T06:20:11.919Z" }, - { url = "https://files.pythonhosted.org/packages/73/42/94f2b8b92eae9064fcfb8deef2b971514065bd606231f8857ff8ae02bebd/crc32c-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8d23c4fe01b3844cb6e091044bc1cebdef7d16472e058ce12d9fadf10d2614af", size = 66659, upload-time = "2025-10-17T06:20:12.766Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e3/66/7e97aa77af7cf6afbff26e3651b564fe41932599bc2d3dce0b2f73d4829a/crc32c-2.8.tar.gz", hash = "sha256:578728964e59c47c356aeeedee6220e021e124b9d3e8631d95d9a5e5f06e261c", size = 48179 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0b/5e03b22d913698e9cc563f39b9f6bbd508606bf6b8e9122cd6bf196b87ea/crc32c-2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e560a97fbb96c9897cb1d9b5076ef12fc12e2e25622530a1afd0de4240f17e1f", size = 66329 }, + { url = "https://files.pythonhosted.org/packages/6b/38/2fe0051ffe8c6a650c8b1ac0da31b8802d1dbe5fa40a84e4b6b6f5583db5/crc32c-2.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6762d276d90331a490ef7e71ffee53b9c0eb053bd75a272d786f3b08d3fe3671", size = 62988 }, + { url = "https://files.pythonhosted.org/packages/3e/30/5837a71c014be83aba1469c58820d287fc836512a0cad6b8fdd43868accd/crc32c-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:60670569f5ede91e39f48fb0cb4060e05b8d8704dd9e17ede930bf441b2f73ef", size = 61522 }, + { url = "https://files.pythonhosted.org/packages/ca/29/63972fc1452778e2092ae998c50cbfc2fc93e3fa9798a0278650cd6169c5/crc32c-2.8-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:711743da6ccc70b3c6718c328947b0b6f34a1fe6a6c27cc6c1d69cc226bf70e9", size = 80200 }, + { url = "https://files.pythonhosted.org/packages/cb/3a/60eb49d7bdada4122b3ffd45b0df54bdc1b8dd092cda4b069a287bdfcff4/crc32c-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eb4094a2054774f13b26f21bf56792bb44fa1fcee6c6ad099387a43ffbfb4fa", size = 81757 }, + { url = "https://files.pythonhosted.org/packages/f5/63/6efc1b64429ef7d23bd58b75b7ac24d15df327e3ebbe9c247a0f7b1c2ed1/crc32c-2.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fff15bf2bd3e95780516baae935ed12be88deaa5ebe6143c53eb0d26a7bdc7b7", size = 80830 }, + { url = "https://files.pythonhosted.org/packages/e1/eb/0ae9f436f8004f1c88f7429e659a7218a3879bd11a6b18ed1257aad7e98b/crc32c-2.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c0e11e3826668121fa53e0745635baf5e4f0ded437e8ff63ea56f38fc4f970a", size = 80095 }, + { url = "https://files.pythonhosted.org/packages/9e/81/4afc9d468977a4cd94a2eb62908553345009a7c0d30e74463a15d4b48ec3/crc32c-2.8-cp311-cp311-win32.whl", hash = "sha256:38f915336715d1f1353ab07d7d786f8a789b119e273aea106ba55355dfc9101d", size = 64886 }, + { url = "https://files.pythonhosted.org/packages/d6/e8/94e839c9f7e767bf8479046a207afd440a08f5c59b52586e1af5e64fa4a0/crc32c-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:60e0a765b1caab8d31b2ea80840639253906a9351d4b861551c8c8625ea20f86", size = 66639 }, + { url = "https://files.pythonhosted.org/packages/b6/36/fd18ef23c42926b79c7003e16cb0f79043b5b179c633521343d3b499e996/crc32c-2.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:572ffb1b78cce3d88e8d4143e154d31044a44be42cb3f6fbbf77f1e7a941c5ab", size = 66379 }, + { url = "https://files.pythonhosted.org/packages/7f/b8/c584958e53f7798dd358f5bdb1bbfc97483134f053ee399d3eeb26cca075/crc32c-2.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cf827b3758ee0c4aacd21ceca0e2da83681f10295c38a10bfeb105f7d98f7a68", size = 63042 }, + { url = "https://files.pythonhosted.org/packages/62/e6/6f2af0ec64a668a46c861e5bc778ea3ee42171fedfc5440f791f470fd783/crc32c-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:106fbd79013e06fa92bc3b51031694fcc1249811ed4364ef1554ee3dd2c7f5a2", size = 61528 }, + { url = "https://files.pythonhosted.org/packages/17/8b/4a04bd80a024f1a23978f19ae99407783e06549e361ab56e9c08bba3c1d3/crc32c-2.8-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6dde035f91ffbfe23163e68605ee5a4bb8ceebd71ed54bb1fb1d0526cdd125a2", size = 80028 }, + { url = "https://files.pythonhosted.org/packages/21/8f/01c7afdc76ac2007d0e6a98e7300b4470b170480f8188475b597d1f4b4c6/crc32c-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e41ebe7c2f0fdcd9f3a3fd206989a36b460b4d3f24816d53e5be6c7dba72c5e1", size = 81531 }, + { url = "https://files.pythonhosted.org/packages/32/2b/8f78c5a8cc66486be5f51b6f038fc347c3ba748d3ea68be17a014283c331/crc32c-2.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecf66cf90266d9c15cea597d5cc86c01917cd1a238dc3c51420c7886fa750d7e", size = 80608 }, + { url = "https://files.pythonhosted.org/packages/db/86/fad1a94cdeeeb6b6e2323c87f970186e74bfd6fbfbc247bf5c88ad0873d5/crc32c-2.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:59eee5f3a69ad0793d5fa9cdc9b9d743b0cd50edf7fccc0a3988a821fef0208c", size = 79886 }, + { url = "https://files.pythonhosted.org/packages/d5/db/1a7cb6757a1e32376fa2dfce00c815ea4ee614a94f9bff8228e37420c183/crc32c-2.8-cp312-cp312-win32.whl", hash = "sha256:a73d03ce3604aa5d7a2698e9057a0eef69f529c46497b27ee1c38158e90ceb76", size = 64896 }, + { url = "https://files.pythonhosted.org/packages/bf/8e/2024de34399b2e401a37dcb54b224b56c747b0dc46de4966886827b4d370/crc32c-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:56b3b7d015247962cf58186e06d18c3d75a1a63d709d3233509e1c50a2d36aa2", size = 66645 }, + { url = "https://files.pythonhosted.org/packages/e8/d8/3ae227890b3be40955a7144106ef4dd97d6123a82c2a5310cdab58ca49d8/crc32c-2.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36f1e03ee9e9c6938e67d3bcb60e36f260170aa5f37da1185e04ef37b56af395", size = 66380 }, + { url = "https://files.pythonhosted.org/packages/bd/8b/178d3f987cd0e049b484615512d3f91f3d2caeeb8ff336bb5896ae317438/crc32c-2.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2f3226b94b85a8dd9b3533601d7a63e9e3e8edf03a8a169830ee8303a199aeb", size = 63048 }, + { url = "https://files.pythonhosted.org/packages/f2/a1/48145ae2545ebc0169d3283ebe882da580ea4606bfb67cf4ca922ac3cfc3/crc32c-2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6e08628bc72d5b6bc8e0730e8f142194b610e780a98c58cb6698e665cb885a5b", size = 61530 }, + { url = "https://files.pythonhosted.org/packages/06/4b/cf05ed9d934cc30e5ae22f97c8272face420a476090e736615d9a6b53de0/crc32c-2.8-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:086f64793c5ec856d1ab31a026d52ad2b895ac83d7a38fce557d74eb857f0a82", size = 80001 }, + { url = "https://files.pythonhosted.org/packages/15/ab/4b04801739faf36345f6ba1920be5b1c70282fec52f8280afd3613fb13e2/crc32c-2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcf72ee7e0135b3d941c34bb2c26c3fc6bc207106b49fd89aaafaeae223ae209", size = 81543 }, + { url = "https://files.pythonhosted.org/packages/a9/1b/6e38dde5bfd2ea69b7f2ab6ec229fcd972a53d39e2db4efe75c0ac0382ce/crc32c-2.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a717dd9c3fd777d9bc6603717eae172887d402c4ab589d124ebd0184a83f89e", size = 80644 }, + { url = "https://files.pythonhosted.org/packages/ce/45/012176ffee90059ae8ec7131019c71724ea472aa63e72c0c8edbd1fad1d7/crc32c-2.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0450bb845b3c3c7b9bdc0b4e95620ec9a40824abdc8c86d6285c919a90743c1a", size = 79919 }, + { url = "https://files.pythonhosted.org/packages/f0/2b/f557629842f9dec2b3461cb3a0d854bb586ec45b814cea58b082c32f0dde/crc32c-2.8-cp313-cp313-win32.whl", hash = "sha256:765d220bfcbcffa6598ac11eb1e10af0ee4802b49fe126aa6bf79f8ddb9931d1", size = 64896 }, + { url = "https://files.pythonhosted.org/packages/d0/db/fd0f698c15d1e21d47c64181a98290665a08fcbb3940cd559e9c15bda57e/crc32c-2.8-cp313-cp313-win_amd64.whl", hash = "sha256:171ff0260d112c62abcce29332986950a57bddee514e0a2418bfde493ea06bb3", size = 66646 }, + { url = "https://files.pythonhosted.org/packages/db/b9/8e5d7054fe8e7eecab10fd0c8e7ffb01439417bdb6de1d66a81c38fc4a20/crc32c-2.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b977a32a3708d6f51703c8557008f190aaa434d7347431efb0e86fcbe78c2a50", size = 66203 }, + { url = "https://files.pythonhosted.org/packages/55/5f/cc926c70057a63cc0c98a3c8a896eb15fc7e74d3034eadd53c94917c6cc3/crc32c-2.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7399b01db4adaf41da2fb36fe2408e75a8d82a179a9564ed7619412e427b26d6", size = 62956 }, + { url = "https://files.pythonhosted.org/packages/a1/8a/0660c44a2dd2cb6ccbb529eb363b9280f5c766f1017bc8355ed8d695bd94/crc32c-2.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4379f73f9cdad31958a673d11a332ec725ca71572401ca865867229f5f15e853", size = 61442 }, + { url = "https://files.pythonhosted.org/packages/f5/5a/6108d2dfc0fe33522ce83ba07aed4b22014911b387afa228808a278e27cd/crc32c-2.8-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e68264555fab19bab08331550dab58573e351a63ed79c869d455edd3b0aa417", size = 79109 }, + { url = "https://files.pythonhosted.org/packages/84/1e/c054f9e390090c197abf3d2936f4f9effaf0c6ee14569ae03d6ddf86958a/crc32c-2.8-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b48f2486727b8d0e7ccbae4a34cb0300498433d2a9d6b49cb13cb57c2e3f19cb", size = 80987 }, + { url = "https://files.pythonhosted.org/packages/c8/ad/1650e5c3341e4a485f800ea83116d72965030c5d48ccc168fcc685756e4d/crc32c-2.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ecf123348934a086df8c8fde7f9f2d716d523ca0707c5a1367b8bb00d8134823", size = 79994 }, + { url = "https://files.pythonhosted.org/packages/d7/3b/f2ed924b177729cbb2ab30ca2902abff653c31d48c95e7b66717a9ca9fcc/crc32c-2.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e636ac60f76de538f7a2c0d0f3abf43104ee83a8f5e516f6345dc283ed1a4df7", size = 79046 }, + { url = "https://files.pythonhosted.org/packages/4b/80/413b05ee6ace613208b31b3670c3135ee1cf451f0e72a9c839b4946acc04/crc32c-2.8-cp313-cp313t-win32.whl", hash = "sha256:8dd4a19505e0253892e1b2f1425cc3bd47f79ae5a04cb8800315d00aad7197f2", size = 64837 }, + { url = "https://files.pythonhosted.org/packages/3b/1b/85eddb6ac5b38496c4e35c20298aae627970c88c3c624a22ab33e84f16c7/crc32c-2.8-cp313-cp313t-win_amd64.whl", hash = "sha256:4bb18e4bd98fb266596523ffc6be9c5b2387b2fa4e505ec56ca36336f49cb639", size = 66574 }, + { url = "https://files.pythonhosted.org/packages/aa/df/50e9079b532ff53dbfc0e66eed781374bd455af02ed5df8b56ad538de4ff/crc32c-2.8-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3a3b2e4bcf7b3ee333050e7d3ff38e2ba46ea205f1d73d8949b248aaffe937ac", size = 66399 }, + { url = "https://files.pythonhosted.org/packages/5a/2e/67e3b0bc3d30e46ea5d16365cc81203286387671e22f2307eb41f19abb9c/crc32c-2.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:445e559e66dff16be54f8a4ef95aa6b01db799a639956d995c5498ba513fccc2", size = 63044 }, + { url = "https://files.pythonhosted.org/packages/36/ea/1723b17437e4344ed8d067456382ecb1f5b535d83fdc5aaebab676c6d273/crc32c-2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bf3040919e17afa5782e01b1875d6a05f44b8f19c05f211d8b9f8a1deb8bbd9c", size = 61541 }, + { url = "https://files.pythonhosted.org/packages/4c/6a/cbec8a235c5b46a01f319939b538958662159aec0ed3a74944e3a6de21f1/crc32c-2.8-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5607ab8221e1ffd411f64aa40dbb6850cf06dd2908c9debd05d371e1acf62ff3", size = 80139 }, + { url = "https://files.pythonhosted.org/packages/21/31/d096722fe74b692d6e8206c27da1ea5f6b2a12ff92c54a62a6ba2f376254/crc32c-2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f5db4f16816926986d3c94253314920689706ae13a9bf4888b47336c6735ce", size = 81736 }, + { url = "https://files.pythonhosted.org/packages/f6/a2/f75ef716ff7e3c22f385ba6ef30c5de80c19a21ebe699dc90824a1903275/crc32c-2.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70b0153c4d418b673309d3529334d117e1074c4a3b2d7f676e430d72c14de67b", size = 80795 }, + { url = "https://files.pythonhosted.org/packages/d8/94/6d647a12d96ab087d9b8eacee3da073f981987827d57c7072f89ffc7b6cd/crc32c-2.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c8933531442042438753755a5c8a9034e4d88b01da9eb796f7e151b31a7256c", size = 80042 }, + { url = "https://files.pythonhosted.org/packages/cd/dc/32b8896b40a0afee7a3c040536d0da5a73e68df2be9fadd21770fd158e16/crc32c-2.8-cp314-cp314-win32.whl", hash = "sha256:cdc83a3fe6c4e5df9457294cfd643de7d95bd4e9382c1dd6ed1e0f0f9169172c", size = 64914 }, + { url = "https://files.pythonhosted.org/packages/f2/b4/4308b27d307e8ecaf8dd1dcc63bbb0e47ae1826d93faa3e62d1ee00ee2d5/crc32c-2.8-cp314-cp314-win_amd64.whl", hash = "sha256:509e10035106df66770fe24b9eb8d9e32b6fb967df17744402fb67772d8b2bc7", size = 66723 }, + { url = "https://files.pythonhosted.org/packages/90/d5/a19d2489fa997a143bfbbf971a5c9a43f8b1ba9e775b1fb362d8fb15260c/crc32c-2.8-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:864359a39777a07b09b28eb31337c0cc603d5c1bf0fc328c3af736a8da624ec0", size = 66201 }, + { url = "https://files.pythonhosted.org/packages/98/c2/5f82f22d2c1242cb6f6fe92aa9a42991ebea86de994b8f9974d9c1d128e2/crc32c-2.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:14511d7cfc5d9f5e1a6c6b64caa6225c2bdc1ed00d725e9a374a3e84073ce180", size = 62956 }, + { url = "https://files.pythonhosted.org/packages/9b/61/3d43d33489cf974fb78bfb3500845770e139ae6d1d83473b660bd8f79a6c/crc32c-2.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:918b7999b52b5dcbcea34081e9a02d46917d571921a3f209956a9a429b2e06e5", size = 61443 }, + { url = "https://files.pythonhosted.org/packages/52/6d/f306ce64a352a3002f76b0fc88a1373f4541f9d34fad3668688610bab14b/crc32c-2.8-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cc445da03fc012a5a03b71da1df1b40139729e6a5571fd4215ab40bfb39689c7", size = 79106 }, + { url = "https://files.pythonhosted.org/packages/a5/b7/1f74965dd7ea762954a69d172dfb3a706049c84ffa45d31401d010a4a126/crc32c-2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e3dde2ec59a8a830511d72a086ead95c0b0b7f0d418f93ea106244c5e77e350", size = 80983 }, + { url = "https://files.pythonhosted.org/packages/1b/50/af93f0d91ccd61833ce77374ebfbd16f5805f5c17d18c6470976d9866d76/crc32c-2.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:61d51681a08b6a2a2e771b7f0cd1947fb87cb28f38ed55a01cb7c40b2ac4cdd8", size = 80009 }, + { url = "https://files.pythonhosted.org/packages/ee/fa/94f394beb68a88258af694dab2f1284f55a406b615d7900bdd6235283bc4/crc32c-2.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:67c0716c3b1a02d5235be649487b637eed21f2d070f2b3f63f709dcd2fefb4c7", size = 79066 }, + { url = "https://files.pythonhosted.org/packages/91/c6/a6050e0c64fd73c67a97da96cb59f08b05111e00b958fb87ecdce99f17ac/crc32c-2.8-cp314-cp314t-win32.whl", hash = "sha256:2e8fe863fbbd8bdb6b414a2090f1b0f52106e76e9a9c96a413495dbe5ebe492a", size = 64869 }, + { url = "https://files.pythonhosted.org/packages/08/1f/c7735034e401cb1ea14f996a224518e3a3fa9987cb13680e707328a7d779/crc32c-2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:20a9cfb897693eb6da19e52e2a7be2026fd4d9fc8ae318f086c0d71d5dd2d8e0", size = 66633 }, + { url = "https://files.pythonhosted.org/packages/a7/1d/dd926c68eb8aac8b142a1a10b8eb62d95212c1cf81775644373fe7cceac2/crc32c-2.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5833f4071da7ea182c514ba17d1eee8aec3c5be927d798222fbfbbd0f5eea02c", size = 62345 }, + { url = "https://files.pythonhosted.org/packages/51/be/803404e5abea2ef2c15042edca04bbb7f625044cca879e47f186b43887c2/crc32c-2.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1dc4da036126ac07b39dd9d03e93e585ec615a2ad28ff12757aef7de175295a8", size = 61229 }, + { url = "https://files.pythonhosted.org/packages/fc/3a/00cc578cd27ed0b22c9be25cef2c24539d92df9fa80ebd67a3fc5419724c/crc32c-2.8-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15905fa78344654e241371c47e6ed2411f9eeb2b8095311c68c88eccf541e8b4", size = 64108 }, + { url = "https://files.pythonhosted.org/packages/6b/bc/0587ef99a1c7629f95dd0c9d4f3d894de383a0df85831eb16c48a6afdae4/crc32c-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c596f918688821f796434e89b431b1698396c38bf0b56de873621528fe3ecb1e", size = 64815 }, + { url = "https://files.pythonhosted.org/packages/73/42/94f2b8b92eae9064fcfb8deef2b971514065bd606231f8857ff8ae02bebd/crc32c-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8d23c4fe01b3844cb6e091044bc1cebdef7d16472e058ce12d9fadf10d2614af", size = 66659 }, ] [[package]] name = "cycler" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, ] [[package]] @@ -585,9 +585,9 @@ dependencies = [ { name = "pyyaml" }, { name = "toolz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/33/eacaa72731f7fc64868caaf2d35060d50049eff889bd217263e68f76472f/dask-2025.11.0.tar.gz", hash = "sha256:23d59e624b80ee05b7cc8df858682cca58262c4c3b197ccf61da0f6543c8f7c3", size = 10984781, upload-time = "2025-11-06T16:56:51.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/33/eacaa72731f7fc64868caaf2d35060d50049eff889bd217263e68f76472f/dask-2025.11.0.tar.gz", hash = "sha256:23d59e624b80ee05b7cc8df858682cca58262c4c3b197ccf61da0f6543c8f7c3", size = 10984781 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/54/a46920229d12c3a6e9f0081d1bdaeffad23c1826353ace95714faee926e5/dask-2025.11.0-py3-none-any.whl", hash = "sha256:08c35a8146c05c93b34f83cf651009129c42ee71762da7ca452fb7308641c2b8", size = 1477108, upload-time = "2025-11-06T16:56:44.892Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/a46920229d12c3a6e9f0081d1bdaeffad23c1826353ace95714faee926e5/dask-2025.11.0-py3-none-any.whl", hash = "sha256:08c35a8146c05c93b34f83cf651009129c42ee71762da7ca452fb7308641c2b8", size = 1477108 }, ] [package.optional-dependencies] @@ -599,61 +599,61 @@ array = [ name = "debugpy" version = "1.8.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129, upload-time = "2025-09-17T16:33:20.633Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/53/3af72b5c159278c4a0cf4cffa518675a0e73bdb7d1cac0239b815502d2ce/debugpy-1.8.17-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:d3fce3f0e3de262a3b67e69916d001f3e767661c6e1ee42553009d445d1cd840", size = 2207154, upload-time = "2025-09-17T16:33:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/8f/6d/204f407df45600e2245b4a39860ed4ba32552330a0b3f5f160ae4cc30072/debugpy-1.8.17-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:c6bdf134457ae0cac6fb68205776be635d31174eeac9541e1d0c062165c6461f", size = 3170322, upload-time = "2025-09-17T16:33:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/f2/13/1b8f87d39cf83c6b713de2620c31205299e6065622e7dd37aff4808dd410/debugpy-1.8.17-cp311-cp311-win32.whl", hash = "sha256:e79a195f9e059edfe5d8bf6f3749b2599452d3e9380484cd261f6b7cd2c7c4da", size = 5155078, upload-time = "2025-09-17T16:33:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c5/c012c60a2922cc91caa9675d0ddfbb14ba59e1e36228355f41cab6483469/debugpy-1.8.17-cp311-cp311-win_amd64.whl", hash = "sha256:b532282ad4eca958b1b2d7dbcb2b7218e02cb934165859b918e3b6ba7772d3f4", size = 5179011, upload-time = "2025-09-17T16:33:35.711Z" }, - { url = "https://files.pythonhosted.org/packages/08/2b/9d8e65beb2751876c82e1aceb32f328c43ec872711fa80257c7674f45650/debugpy-1.8.17-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:f14467edef672195c6f6b8e27ce5005313cb5d03c9239059bc7182b60c176e2d", size = 2549522, upload-time = "2025-09-17T16:33:38.466Z" }, - { url = "https://files.pythonhosted.org/packages/b4/78/eb0d77f02971c05fca0eb7465b18058ba84bd957062f5eec82f941ac792a/debugpy-1.8.17-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:24693179ef9dfa20dca8605905a42b392be56d410c333af82f1c5dff807a64cc", size = 4309417, upload-time = "2025-09-17T16:33:41.299Z" }, - { url = "https://files.pythonhosted.org/packages/37/42/c40f1d8cc1fed1e75ea54298a382395b8b937d923fcf41ab0797a554f555/debugpy-1.8.17-cp312-cp312-win32.whl", hash = "sha256:6a4e9dacf2cbb60d2514ff7b04b4534b0139facbf2abdffe0639ddb6088e59cf", size = 5277130, upload-time = "2025-09-17T16:33:43.554Z" }, - { url = "https://files.pythonhosted.org/packages/72/22/84263b205baad32b81b36eac076de0cdbe09fe2d0637f5b32243dc7c925b/debugpy-1.8.17-cp312-cp312-win_amd64.whl", hash = "sha256:e8f8f61c518952fb15f74a302e068b48d9c4691768ade433e4adeea961993464", size = 5319053, upload-time = "2025-09-17T16:33:53.033Z" }, - { url = "https://files.pythonhosted.org/packages/50/76/597e5cb97d026274ba297af8d89138dfd9e695767ba0e0895edb20963f40/debugpy-1.8.17-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:857c1dd5d70042502aef1c6d1c2801211f3ea7e56f75e9c335f434afb403e464", size = 2538386, upload-time = "2025-09-17T16:33:54.594Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/ce5c34fcdfec493701f9d1532dba95b21b2f6394147234dce21160bd923f/debugpy-1.8.17-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:3bea3b0b12f3946e098cce9b43c3c46e317b567f79570c3f43f0b96d00788088", size = 4292100, upload-time = "2025-09-17T16:33:56.353Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/7873cf2146577ef71d2a20bf553f12df865922a6f87b9e8ee1df04f01785/debugpy-1.8.17-cp313-cp313-win32.whl", hash = "sha256:e34ee844c2f17b18556b5bbe59e1e2ff4e86a00282d2a46edab73fd7f18f4a83", size = 5277002, upload-time = "2025-09-17T16:33:58.231Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/18c79a1cee5ff539a94ec4aa290c1c069a5580fd5cfd2fb2e282f8e905da/debugpy-1.8.17-cp313-cp313-win_amd64.whl", hash = "sha256:6c5cd6f009ad4fca8e33e5238210dc1e5f42db07d4b6ab21ac7ffa904a196420", size = 5319047, upload-time = "2025-09-17T16:34:00.586Z" }, - { url = "https://files.pythonhosted.org/packages/de/45/115d55b2a9da6de812696064ceb505c31e952c5d89c4ed1d9bb983deec34/debugpy-1.8.17-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:045290c010bcd2d82bc97aa2daf6837443cd52f6328592698809b4549babcee1", size = 2536899, upload-time = "2025-09-17T16:34:02.657Z" }, - { url = "https://files.pythonhosted.org/packages/5a/73/2aa00c7f1f06e997ef57dc9b23d61a92120bec1437a012afb6d176585197/debugpy-1.8.17-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:b69b6bd9dba6a03632534cdf67c760625760a215ae289f7489a452af1031fe1f", size = 4268254, upload-time = "2025-09-17T16:34:04.486Z" }, - { url = "https://files.pythonhosted.org/packages/86/b5/ed3e65c63c68a6634e3ba04bd10255c8e46ec16ebed7d1c79e4816d8a760/debugpy-1.8.17-cp314-cp314-win32.whl", hash = "sha256:5c59b74aa5630f3a5194467100c3b3d1c77898f9ab27e3f7dc5d40fc2f122670", size = 5277203, upload-time = "2025-09-17T16:34:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/b0/26/394276b71c7538445f29e792f589ab7379ae70fd26ff5577dfde71158e96/debugpy-1.8.17-cp314-cp314-win_amd64.whl", hash = "sha256:893cba7bb0f55161de4365584b025f7064e1f88913551bcd23be3260b231429c", size = 5318493, upload-time = "2025-09-17T16:34:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210, upload-time = "2025-09-17T16:34:25.835Z" }, + { url = "https://files.pythonhosted.org/packages/d8/53/3af72b5c159278c4a0cf4cffa518675a0e73bdb7d1cac0239b815502d2ce/debugpy-1.8.17-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:d3fce3f0e3de262a3b67e69916d001f3e767661c6e1ee42553009d445d1cd840", size = 2207154 }, + { url = "https://files.pythonhosted.org/packages/8f/6d/204f407df45600e2245b4a39860ed4ba32552330a0b3f5f160ae4cc30072/debugpy-1.8.17-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:c6bdf134457ae0cac6fb68205776be635d31174eeac9541e1d0c062165c6461f", size = 3170322 }, + { url = "https://files.pythonhosted.org/packages/f2/13/1b8f87d39cf83c6b713de2620c31205299e6065622e7dd37aff4808dd410/debugpy-1.8.17-cp311-cp311-win32.whl", hash = "sha256:e79a195f9e059edfe5d8bf6f3749b2599452d3e9380484cd261f6b7cd2c7c4da", size = 5155078 }, + { url = "https://files.pythonhosted.org/packages/c2/c5/c012c60a2922cc91caa9675d0ddfbb14ba59e1e36228355f41cab6483469/debugpy-1.8.17-cp311-cp311-win_amd64.whl", hash = "sha256:b532282ad4eca958b1b2d7dbcb2b7218e02cb934165859b918e3b6ba7772d3f4", size = 5179011 }, + { url = "https://files.pythonhosted.org/packages/08/2b/9d8e65beb2751876c82e1aceb32f328c43ec872711fa80257c7674f45650/debugpy-1.8.17-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:f14467edef672195c6f6b8e27ce5005313cb5d03c9239059bc7182b60c176e2d", size = 2549522 }, + { url = "https://files.pythonhosted.org/packages/b4/78/eb0d77f02971c05fca0eb7465b18058ba84bd957062f5eec82f941ac792a/debugpy-1.8.17-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:24693179ef9dfa20dca8605905a42b392be56d410c333af82f1c5dff807a64cc", size = 4309417 }, + { url = "https://files.pythonhosted.org/packages/37/42/c40f1d8cc1fed1e75ea54298a382395b8b937d923fcf41ab0797a554f555/debugpy-1.8.17-cp312-cp312-win32.whl", hash = "sha256:6a4e9dacf2cbb60d2514ff7b04b4534b0139facbf2abdffe0639ddb6088e59cf", size = 5277130 }, + { url = "https://files.pythonhosted.org/packages/72/22/84263b205baad32b81b36eac076de0cdbe09fe2d0637f5b32243dc7c925b/debugpy-1.8.17-cp312-cp312-win_amd64.whl", hash = "sha256:e8f8f61c518952fb15f74a302e068b48d9c4691768ade433e4adeea961993464", size = 5319053 }, + { url = "https://files.pythonhosted.org/packages/50/76/597e5cb97d026274ba297af8d89138dfd9e695767ba0e0895edb20963f40/debugpy-1.8.17-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:857c1dd5d70042502aef1c6d1c2801211f3ea7e56f75e9c335f434afb403e464", size = 2538386 }, + { url = "https://files.pythonhosted.org/packages/5f/60/ce5c34fcdfec493701f9d1532dba95b21b2f6394147234dce21160bd923f/debugpy-1.8.17-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:3bea3b0b12f3946e098cce9b43c3c46e317b567f79570c3f43f0b96d00788088", size = 4292100 }, + { url = "https://files.pythonhosted.org/packages/e8/95/7873cf2146577ef71d2a20bf553f12df865922a6f87b9e8ee1df04f01785/debugpy-1.8.17-cp313-cp313-win32.whl", hash = "sha256:e34ee844c2f17b18556b5bbe59e1e2ff4e86a00282d2a46edab73fd7f18f4a83", size = 5277002 }, + { url = "https://files.pythonhosted.org/packages/46/11/18c79a1cee5ff539a94ec4aa290c1c069a5580fd5cfd2fb2e282f8e905da/debugpy-1.8.17-cp313-cp313-win_amd64.whl", hash = "sha256:6c5cd6f009ad4fca8e33e5238210dc1e5f42db07d4b6ab21ac7ffa904a196420", size = 5319047 }, + { url = "https://files.pythonhosted.org/packages/de/45/115d55b2a9da6de812696064ceb505c31e952c5d89c4ed1d9bb983deec34/debugpy-1.8.17-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:045290c010bcd2d82bc97aa2daf6837443cd52f6328592698809b4549babcee1", size = 2536899 }, + { url = "https://files.pythonhosted.org/packages/5a/73/2aa00c7f1f06e997ef57dc9b23d61a92120bec1437a012afb6d176585197/debugpy-1.8.17-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:b69b6bd9dba6a03632534cdf67c760625760a215ae289f7489a452af1031fe1f", size = 4268254 }, + { url = "https://files.pythonhosted.org/packages/86/b5/ed3e65c63c68a6634e3ba04bd10255c8e46ec16ebed7d1c79e4816d8a760/debugpy-1.8.17-cp314-cp314-win32.whl", hash = "sha256:5c59b74aa5630f3a5194467100c3b3d1c77898f9ab27e3f7dc5d40fc2f122670", size = 5277203 }, + { url = "https://files.pythonhosted.org/packages/b0/26/394276b71c7538445f29e792f589ab7379ae70fd26ff5577dfde71158e96/debugpy-1.8.17-cp314-cp314-win_amd64.whl", hash = "sha256:893cba7bb0f55161de4365584b025f7064e1f88913551bcd23be3260b231429c", size = 5318493 }, + { url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210 }, ] [[package]] name = "decorator" version = "5.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, ] [[package]] name = "defusedxml" version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, ] [[package]] name = "dill" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976 } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668 }, ] [[package]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, ] [[package]] @@ -663,36 +663,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506, upload-time = "2024-05-23T14:14:31.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592 }, ] [[package]] name = "executing" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, ] [[package]] name = "fastjsonschema" version = "2.21.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024 }, ] [[package]] name = "filelock" version = "3.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922 } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054 }, ] [[package]] @@ -702,9 +702,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816, upload-time = "2024-03-09T03:21:07.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816 } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263, upload-time = "2024-03-09T03:21:05.635Z" }, + { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263 }, ] [[package]] @@ -714,126 +714,126 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799, upload-time = "2024-11-07T02:00:56.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625 }, ] [[package]] name = "fonttools" version = "4.60.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" }, - { url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" }, - { url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" }, - { url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, - { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb", size = 2825777, upload-time = "2025-09-29T21:12:01.22Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8a/de9cc0540f542963ba5e8f3a1f6ad48fa211badc3177783b9d5cadf79b5d/fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4", size = 2348080, upload-time = "2025-09-29T21:12:03.785Z" }, - { url = "https://files.pythonhosted.org/packages/2d/8b/371ab3cec97ee3fe1126b3406b7abd60c8fec8975fd79a3c75cdea0c3d83/fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c", size = 4903082, upload-time = "2025-09-29T21:12:06.382Z" }, - { url = "https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77", size = 4960125, upload-time = "2025-09-29T21:12:09.314Z" }, - { url = "https://files.pythonhosted.org/packages/8e/37/f3b840fcb2666f6cb97038793606bdd83488dca2d0b0fc542ccc20afa668/fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199", size = 4901454, upload-time = "2025-09-29T21:12:11.931Z" }, - { url = "https://files.pythonhosted.org/packages/fd/9e/eb76f77e82f8d4a46420aadff12cec6237751b0fb9ef1de373186dcffb5f/fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c", size = 5044495, upload-time = "2025-09-29T21:12:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b3/cede8f8235d42ff7ae891bae8d619d02c8ac9fd0cfc450c5927a6200c70d/fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272", size = 2217028, upload-time = "2025-09-29T21:12:17.96Z" }, - { url = "https://files.pythonhosted.org/packages/75/4d/b022c1577807ce8b31ffe055306ec13a866f2337ecee96e75b24b9b753ea/fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac", size = 2266200, upload-time = "2025-09-29T21:12:20.14Z" }, - { url = "https://files.pythonhosted.org/packages/9a/83/752ca11c1aa9a899b793a130f2e466b79ea0cf7279c8d79c178fc954a07b/fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3", size = 2822830, upload-time = "2025-09-29T21:12:24.406Z" }, - { url = "https://files.pythonhosted.org/packages/57/17/bbeab391100331950a96ce55cfbbff27d781c1b85ebafb4167eae50d9fe3/fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85", size = 2345524, upload-time = "2025-09-29T21:12:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2e/d4831caa96d85a84dd0da1d9f90d81cec081f551e0ea216df684092c6c97/fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537", size = 4843490, upload-time = "2025-09-29T21:12:29.123Z" }, - { url = "https://files.pythonhosted.org/packages/49/13/5e2ea7c7a101b6fc3941be65307ef8df92cbbfa6ec4804032baf1893b434/fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003", size = 4944184, upload-time = "2025-09-29T21:12:31.414Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2b/cf9603551c525b73fc47c52ee0b82a891579a93d9651ed694e4e2cd08bb8/fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08", size = 4890218, upload-time = "2025-09-29T21:12:33.936Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2f/933d2352422e25f2376aae74f79eaa882a50fb3bfef3c0d4f50501267101/fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99", size = 4999324, upload-time = "2025-09-29T21:12:36.637Z" }, - { url = "https://files.pythonhosted.org/packages/38/99/234594c0391221f66216bc2c886923513b3399a148defaccf81dc3be6560/fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6", size = 2220861, upload-time = "2025-09-29T21:12:39.108Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1d/edb5b23726dde50fc4068e1493e4fc7658eeefcaf75d4c5ffce067d07ae5/fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987", size = 2270934, upload-time = "2025-09-29T21:12:41.339Z" }, - { url = "https://files.pythonhosted.org/packages/fb/da/1392aaa2170adc7071fe7f9cfd181a5684a7afcde605aebddf1fb4d76df5/fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299", size = 2894340, upload-time = "2025-09-29T21:12:43.774Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a7/3b9f16e010d536ce567058b931a20b590d8f3177b2eda09edd92e392375d/fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01", size = 2375073, upload-time = "2025-09-29T21:12:46.437Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b5/e9bcf51980f98e59bb5bb7c382a63c6f6cac0eec5f67de6d8f2322382065/fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801", size = 4849758, upload-time = "2025-09-29T21:12:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/1d2cf7d1cba82264b2f8385db3f5960e3d8ce756b4dc65b700d2c496f7e9/fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc", size = 5085598, upload-time = "2025-09-29T21:12:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/5d/4d/279e28ba87fb20e0c69baf72b60bbf1c4d873af1476806a7b5f2b7fac1ff/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc", size = 4957603, upload-time = "2025-09-29T21:12:53.423Z" }, - { url = "https://files.pythonhosted.org/packages/78/d4/ff19976305e0c05aa3340c805475abb00224c954d3c65e82c0a69633d55d/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed", size = 4974184, upload-time = "2025-09-29T21:12:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/22/8553ff6166f5cd21cfaa115aaacaa0dc73b91c079a8cfd54a482cbc0f4f5/fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259", size = 2282241, upload-time = "2025-09-29T21:12:58.179Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/fa7b4d148e11d5a72761a22e595344133e83a9507a4c231df972e657579b/fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c", size = 2345760, upload-time = "2025-09-29T21:13:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872 }, + { url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990 }, + { url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189 }, + { url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683 }, + { url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372 }, + { url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562 }, + { url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288 }, + { url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184 }, + { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953 }, + { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706 }, + { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716 }, + { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175 }, + { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031 }, + { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966 }, + { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750 }, + { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026 }, + { url = "https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb", size = 2825777 }, + { url = "https://files.pythonhosted.org/packages/d6/8a/de9cc0540f542963ba5e8f3a1f6ad48fa211badc3177783b9d5cadf79b5d/fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4", size = 2348080 }, + { url = "https://files.pythonhosted.org/packages/2d/8b/371ab3cec97ee3fe1126b3406b7abd60c8fec8975fd79a3c75cdea0c3d83/fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c", size = 4903082 }, + { url = "https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77", size = 4960125 }, + { url = "https://files.pythonhosted.org/packages/8e/37/f3b840fcb2666f6cb97038793606bdd83488dca2d0b0fc542ccc20afa668/fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199", size = 4901454 }, + { url = "https://files.pythonhosted.org/packages/fd/9e/eb76f77e82f8d4a46420aadff12cec6237751b0fb9ef1de373186dcffb5f/fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c", size = 5044495 }, + { url = "https://files.pythonhosted.org/packages/f8/b3/cede8f8235d42ff7ae891bae8d619d02c8ac9fd0cfc450c5927a6200c70d/fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272", size = 2217028 }, + { url = "https://files.pythonhosted.org/packages/75/4d/b022c1577807ce8b31ffe055306ec13a866f2337ecee96e75b24b9b753ea/fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac", size = 2266200 }, + { url = "https://files.pythonhosted.org/packages/9a/83/752ca11c1aa9a899b793a130f2e466b79ea0cf7279c8d79c178fc954a07b/fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3", size = 2822830 }, + { url = "https://files.pythonhosted.org/packages/57/17/bbeab391100331950a96ce55cfbbff27d781c1b85ebafb4167eae50d9fe3/fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85", size = 2345524 }, + { url = "https://files.pythonhosted.org/packages/3d/2e/d4831caa96d85a84dd0da1d9f90d81cec081f551e0ea216df684092c6c97/fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537", size = 4843490 }, + { url = "https://files.pythonhosted.org/packages/49/13/5e2ea7c7a101b6fc3941be65307ef8df92cbbfa6ec4804032baf1893b434/fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003", size = 4944184 }, + { url = "https://files.pythonhosted.org/packages/0c/2b/cf9603551c525b73fc47c52ee0b82a891579a93d9651ed694e4e2cd08bb8/fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08", size = 4890218 }, + { url = "https://files.pythonhosted.org/packages/fd/2f/933d2352422e25f2376aae74f79eaa882a50fb3bfef3c0d4f50501267101/fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99", size = 4999324 }, + { url = "https://files.pythonhosted.org/packages/38/99/234594c0391221f66216bc2c886923513b3399a148defaccf81dc3be6560/fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6", size = 2220861 }, + { url = "https://files.pythonhosted.org/packages/3e/1d/edb5b23726dde50fc4068e1493e4fc7658eeefcaf75d4c5ffce067d07ae5/fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987", size = 2270934 }, + { url = "https://files.pythonhosted.org/packages/fb/da/1392aaa2170adc7071fe7f9cfd181a5684a7afcde605aebddf1fb4d76df5/fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299", size = 2894340 }, + { url = "https://files.pythonhosted.org/packages/bf/a7/3b9f16e010d536ce567058b931a20b590d8f3177b2eda09edd92e392375d/fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01", size = 2375073 }, + { url = "https://files.pythonhosted.org/packages/9b/b5/e9bcf51980f98e59bb5bb7c382a63c6f6cac0eec5f67de6d8f2322382065/fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801", size = 4849758 }, + { url = "https://files.pythonhosted.org/packages/e3/dc/1d2cf7d1cba82264b2f8385db3f5960e3d8ce756b4dc65b700d2c496f7e9/fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc", size = 5085598 }, + { url = "https://files.pythonhosted.org/packages/5d/4d/279e28ba87fb20e0c69baf72b60bbf1c4d873af1476806a7b5f2b7fac1ff/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc", size = 4957603 }, + { url = "https://files.pythonhosted.org/packages/78/d4/ff19976305e0c05aa3340c805475abb00224c954d3c65e82c0a69633d55d/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed", size = 4974184 }, + { url = "https://files.pythonhosted.org/packages/63/22/8553ff6166f5cd21cfaa115aaacaa0dc73b91c079a8cfd54a482cbc0f4f5/fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259", size = 2282241 }, + { url = "https://files.pythonhosted.org/packages/8a/cb/fa7b4d148e11d5a72761a22e595344133e83a9507a4c231df972e657579b/fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c", size = 2345760 }, + { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175 }, ] [[package]] name = "fqdn" version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121 }, ] [[package]] name = "fsspec" version = "2025.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/7f/2747c0d332b9acfa75dc84447a066fdf812b5a6b8d30472b74d309bfe8cb/fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59", size = 309285, upload-time = "2025-10-30T14:58:44.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/7f/2747c0d332b9acfa75dc84447a066fdf812b5a6b8d30472b74d309bfe8cb/fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59", size = 309285 } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966 }, ] [[package]] name = "greenlet" version = "3.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, - { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, - { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, - { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, - { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, - { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, - { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, - { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, - { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, - { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, - { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, - { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, - { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, - { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, - { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, - { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, - { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, - { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, - { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, - { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, - { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, - { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, - { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305 }, + { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646 }, + { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519 }, + { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707 }, + { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684 }, + { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647 }, + { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073 }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385 }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329 }, + { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100 }, + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079 }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997 }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185 }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926 }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839 }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586 }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281 }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142 }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846 }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814 }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899 }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814 }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073 }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191 }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516 }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169 }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497 }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662 }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210 }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759 }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288 }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685 }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586 }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346 }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218 }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659 }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355 }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512 }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508 }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760 }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425 }, ] [[package]] @@ -843,57 +843,57 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567 }, + { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017 }, + { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027 }, + { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913 }, + { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417 }, + { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683 }, + { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109 }, + { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676 }, + { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688 }, + { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315 }, + { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718 }, + { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627 }, + { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167 }, + { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267 }, + { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963 }, + { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484 }, + { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777 }, + { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014 }, + { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750 }, + { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003 }, + { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716 }, + { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522 }, + { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558 }, + { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990 }, + { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387 }, + { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668 }, + { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928 }, + { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983 }, + { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727 }, + { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799 }, + { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417 }, + { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219 }, + { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826 }, + { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550 }, + { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564 }, + { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236 }, + { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795 }, + { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214 }, + { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961 }, + { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462 }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] [[package]] @@ -903,40 +903,40 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/6a/0d79de0b025aa85dc8864de8e97659c94cf3d23148394a954dc5ca52f8c8/h5py-3.15.1.tar.gz", hash = "sha256:c86e3ed45c4473564de55aa83b6fc9e5ead86578773dfbd93047380042e26b69", size = 426236, upload-time = "2025-10-16T10:35:27.404Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/fd/8349b48b15b47768042cff06ad6e1c229f0a4bd89225bf6b6894fea27e6d/h5py-3.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5aaa330bcbf2830150c50897ea5dcbed30b5b6d56897289846ac5b9e529ec243", size = 3434135, upload-time = "2025-10-16T10:33:47.954Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b0/1c628e26a0b95858f54aba17e1599e7f6cd241727596cc2580b72cb0a9bf/h5py-3.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c970fb80001fffabb0109eaf95116c8e7c0d3ca2de854e0901e8a04c1f098509", size = 2870958, upload-time = "2025-10-16T10:33:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e3/c255cafc9b85e6ea04e2ad1bba1416baa1d7f57fc98a214be1144087690c/h5py-3.15.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80e5bb5b9508d5d9da09f81fd00abbb3f85da8143e56b1585d59bc8ceb1dba8b", size = 4504770, upload-time = "2025-10-16T10:33:54.357Z" }, - { url = "https://files.pythonhosted.org/packages/8b/23/4ab1108e87851ccc69694b03b817d92e142966a6c4abd99e17db77f2c066/h5py-3.15.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b849ba619a066196169763c33f9f0f02e381156d61c03e000bb0100f9950faf", size = 4700329, upload-time = "2025-10-16T10:33:57.616Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e4/932a3a8516e4e475b90969bf250b1924dbe3612a02b897e426613aed68f4/h5py-3.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7f6c841efd4e6e5b7e82222eaf90819927b6d256ab0f3aca29675601f654f3c", size = 4152456, upload-time = "2025-10-16T10:34:00.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0a/f74d589883b13737021b2049ac796328f188dbb60c2ed35b101f5b95a3fc/h5py-3.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca8a3a22458956ee7b40d8e39c9a9dc01f82933e4c030c964f8b875592f4d831", size = 4617295, upload-time = "2025-10-16T10:34:04.154Z" }, - { url = "https://files.pythonhosted.org/packages/23/95/499b4e56452ef8b6c95a271af0dde08dac4ddb70515a75f346d4f400579b/h5py-3.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:550e51131376889656feec4aff2170efc054a7fe79eb1da3bb92e1625d1ac878", size = 2882129, upload-time = "2025-10-16T10:34:06.886Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bb/cfcc70b8a42222ba3ad4478bcef1791181ea908e2adbd7d53c66395edad5/h5py-3.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:b39239947cb36a819147fc19e86b618dcb0953d1cd969f5ed71fc0de60392427", size = 2477121, upload-time = "2025-10-16T10:34:09.579Z" }, - { url = "https://files.pythonhosted.org/packages/62/b8/c0d9aa013ecfa8b7057946c080c0c07f6fa41e231d2e9bd306a2f8110bdc/h5py-3.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:316dd0f119734f324ca7ed10b5627a2de4ea42cc4dfbcedbee026aaa361c238c", size = 3399089, upload-time = "2025-10-16T10:34:12.135Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5e/3c6f6e0430813c7aefe784d00c6711166f46225f5d229546eb53032c3707/h5py-3.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b51469890e58e85d5242e43aab29f5e9c7e526b951caab354f3ded4ac88e7b76", size = 2847803, upload-time = "2025-10-16T10:34:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/00/69/ba36273b888a4a48d78f9268d2aee05787e4438557450a8442946ab8f3ec/h5py-3.15.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a33bfd5dfcea037196f7778534b1ff7e36a7f40a89e648c8f2967292eb6898e", size = 4914884, upload-time = "2025-10-16T10:34:18.452Z" }, - { url = "https://files.pythonhosted.org/packages/3a/30/d1c94066343a98bb2cea40120873193a4fed68c4ad7f8935c11caf74c681/h5py-3.15.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25c8843fec43b2cc368aa15afa1cdf83fc5e17b1c4e10cd3771ef6c39b72e5ce", size = 5109965, upload-time = "2025-10-16T10:34:21.853Z" }, - { url = "https://files.pythonhosted.org/packages/81/3d/d28172116eafc3bc9f5991b3cb3fd2c8a95f5984f50880adfdf991de9087/h5py-3.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a308fd8681a864c04423c0324527237a0484e2611e3441f8089fd00ed56a8171", size = 4561870, upload-time = "2025-10-16T10:34:26.69Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/393a7226024238b0f51965a7156004eaae1fcf84aa4bfecf7e582676271b/h5py-3.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f4a016df3f4a8a14d573b496e4d1964deb380e26031fc85fb40e417e9131888a", size = 5037161, upload-time = "2025-10-16T10:34:30.383Z" }, - { url = "https://files.pythonhosted.org/packages/cf/51/329e7436bf87ca6b0fe06dd0a3795c34bebe4ed8d6c44450a20565d57832/h5py-3.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:59b25cf02411bf12e14f803fef0b80886444c7fe21a5ad17c6a28d3f08098a1e", size = 2874165, upload-time = "2025-10-16T10:34:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/09/a8/2d02b10a66747c54446e932171dd89b8b4126c0111b440e6bc05a7c852ec/h5py-3.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:61d5a58a9851e01ee61c932bbbb1c98fe20aba0a5674776600fb9a361c0aa652", size = 2458214, upload-time = "2025-10-16T10:34:35.733Z" }, - { url = "https://files.pythonhosted.org/packages/88/b3/40207e0192415cbff7ea1d37b9f24b33f6d38a5a2f5d18a678de78f967ae/h5py-3.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8440fd8bee9500c235ecb7aa1917a0389a2adb80c209fa1cc485bd70e0d94a5", size = 3376511, upload-time = "2025-10-16T10:34:38.596Z" }, - { url = "https://files.pythonhosted.org/packages/31/96/ba99a003c763998035b0de4c299598125df5fc6c9ccf834f152ddd60e0fb/h5py-3.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ab2219dbc6fcdb6932f76b548e2b16f34a1f52b7666e998157a4dfc02e2c4123", size = 2826143, upload-time = "2025-10-16T10:34:41.342Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c2/fc6375d07ea3962df7afad7d863fe4bde18bb88530678c20d4c90c18de1d/h5py-3.15.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8cb02c3a96255149ed3ac811eeea25b655d959c6dd5ce702c9a95ff11859eb5", size = 4908316, upload-time = "2025-10-16T10:34:44.619Z" }, - { url = "https://files.pythonhosted.org/packages/d9/69/4402ea66272dacc10b298cca18ed73e1c0791ff2ae9ed218d3859f9698ac/h5py-3.15.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:121b2b7a4c1915d63737483b7bff14ef253020f617c2fb2811f67a4bed9ac5e8", size = 5103710, upload-time = "2025-10-16T10:34:48.639Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f6/11f1e2432d57d71322c02a97a5567829a75f223a8c821764a0e71a65cde8/h5py-3.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59b0d63b318bf3cc06687def2b45afd75926bbc006f7b8cd2b1a231299fc8599", size = 4556042, upload-time = "2025-10-16T10:34:51.841Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/3eda3ef16bfe7a7dbc3d8d6836bbaa7986feb5ff091395e140dc13927bcc/h5py-3.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e02fe77a03f652500d8bff288cbf3675f742fc0411f5a628fa37116507dc7cc0", size = 5030639, upload-time = "2025-10-16T10:34:55.257Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ea/fbb258a98863f99befb10ed727152b4ae659f322e1d9c0576f8a62754e81/h5py-3.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:dea78b092fd80a083563ed79a3171258d4a4d307492e7cf8b2313d464c82ba52", size = 2864363, upload-time = "2025-10-16T10:34:58.099Z" }, - { url = "https://files.pythonhosted.org/packages/5d/c9/35021cc9cd2b2915a7da3026e3d77a05bed1144a414ff840953b33937fb9/h5py-3.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:c256254a8a81e2bddc0d376e23e2a6d2dc8a1e8a2261835ed8c1281a0744cd97", size = 2449570, upload-time = "2025-10-16T10:35:00.473Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2c/926eba1514e4d2e47d0e9eb16c784e717d8b066398ccfca9b283917b1bfb/h5py-3.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f4fb0567eb8517c3ecd6b3c02c4f4e9da220c8932604960fd04e24ee1254763", size = 3380368, upload-time = "2025-10-16T10:35:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/65/4b/d715ed454d3baa5f6ae1d30b7eca4c7a1c1084f6a2edead9e801a1541d62/h5py-3.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:954e480433e82d3872503104f9b285d369048c3a788b2b1a00e53d1c47c98dd2", size = 2833793, upload-time = "2025-10-16T10:35:05.623Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d4/ef386c28e4579314610a8bffebbee3b69295b0237bc967340b7c653c6c10/h5py-3.15.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd125c131889ebbef0849f4a0e29cf363b48aba42f228d08b4079913b576bb3a", size = 4903199, upload-time = "2025-10-16T10:35:08.972Z" }, - { url = "https://files.pythonhosted.org/packages/33/5d/65c619e195e0b5e54ea5a95c1bb600c8ff8715e0d09676e4cce56d89f492/h5py-3.15.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28a20e1a4082a479b3d7db2169f3a5034af010b90842e75ebbf2e9e49eb4183e", size = 5097224, upload-time = "2025-10-16T10:35:12.808Z" }, - { url = "https://files.pythonhosted.org/packages/30/30/5273218400bf2da01609e1292f562c94b461fcb73c7a9e27fdadd43abc0a/h5py-3.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8df5267f545b4946df8ca0d93d23382191018e4cda2deda4c2cedf9a010e13", size = 4551207, upload-time = "2025-10-16T10:35:16.24Z" }, - { url = "https://files.pythonhosted.org/packages/d3/39/a7ef948ddf4d1c556b0b2b9559534777bccc318543b3f5a1efdf6b556c9c/h5py-3.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99d374a21f7321a4c6ab327c4ab23bd925ad69821aeb53a1e75dd809d19f67fa", size = 5025426, upload-time = "2025-10-16T10:35:19.831Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d8/7368679b8df6925b8415f9dcc9ab1dab01ddc384d2b2c24aac9191bd9ceb/h5py-3.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:9c73d1d7cdb97d5b17ae385153472ce118bed607e43be11e9a9deefaa54e0734", size = 2865704, upload-time = "2025-10-16T10:35:22.658Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b7/4a806f85d62c20157e62e58e03b27513dc9c55499768530acc4f4c5ce4be/h5py-3.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:a6d8c5a05a76aca9a494b4c53ce8a9c29023b7f64f625c6ce1841e92a362ccdf", size = 2465544, upload-time = "2025-10-16T10:35:25.695Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/4d/6a/0d79de0b025aa85dc8864de8e97659c94cf3d23148394a954dc5ca52f8c8/h5py-3.15.1.tar.gz", hash = "sha256:c86e3ed45c4473564de55aa83b6fc9e5ead86578773dfbd93047380042e26b69", size = 426236 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/fd/8349b48b15b47768042cff06ad6e1c229f0a4bd89225bf6b6894fea27e6d/h5py-3.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5aaa330bcbf2830150c50897ea5dcbed30b5b6d56897289846ac5b9e529ec243", size = 3434135 }, + { url = "https://files.pythonhosted.org/packages/c1/b0/1c628e26a0b95858f54aba17e1599e7f6cd241727596cc2580b72cb0a9bf/h5py-3.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c970fb80001fffabb0109eaf95116c8e7c0d3ca2de854e0901e8a04c1f098509", size = 2870958 }, + { url = "https://files.pythonhosted.org/packages/f9/e3/c255cafc9b85e6ea04e2ad1bba1416baa1d7f57fc98a214be1144087690c/h5py-3.15.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80e5bb5b9508d5d9da09f81fd00abbb3f85da8143e56b1585d59bc8ceb1dba8b", size = 4504770 }, + { url = "https://files.pythonhosted.org/packages/8b/23/4ab1108e87851ccc69694b03b817d92e142966a6c4abd99e17db77f2c066/h5py-3.15.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b849ba619a066196169763c33f9f0f02e381156d61c03e000bb0100f9950faf", size = 4700329 }, + { url = "https://files.pythonhosted.org/packages/a4/e4/932a3a8516e4e475b90969bf250b1924dbe3612a02b897e426613aed68f4/h5py-3.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7f6c841efd4e6e5b7e82222eaf90819927b6d256ab0f3aca29675601f654f3c", size = 4152456 }, + { url = "https://files.pythonhosted.org/packages/2a/0a/f74d589883b13737021b2049ac796328f188dbb60c2ed35b101f5b95a3fc/h5py-3.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca8a3a22458956ee7b40d8e39c9a9dc01f82933e4c030c964f8b875592f4d831", size = 4617295 }, + { url = "https://files.pythonhosted.org/packages/23/95/499b4e56452ef8b6c95a271af0dde08dac4ddb70515a75f346d4f400579b/h5py-3.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:550e51131376889656feec4aff2170efc054a7fe79eb1da3bb92e1625d1ac878", size = 2882129 }, + { url = "https://files.pythonhosted.org/packages/ce/bb/cfcc70b8a42222ba3ad4478bcef1791181ea908e2adbd7d53c66395edad5/h5py-3.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:b39239947cb36a819147fc19e86b618dcb0953d1cd969f5ed71fc0de60392427", size = 2477121 }, + { url = "https://files.pythonhosted.org/packages/62/b8/c0d9aa013ecfa8b7057946c080c0c07f6fa41e231d2e9bd306a2f8110bdc/h5py-3.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:316dd0f119734f324ca7ed10b5627a2de4ea42cc4dfbcedbee026aaa361c238c", size = 3399089 }, + { url = "https://files.pythonhosted.org/packages/a4/5e/3c6f6e0430813c7aefe784d00c6711166f46225f5d229546eb53032c3707/h5py-3.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b51469890e58e85d5242e43aab29f5e9c7e526b951caab354f3ded4ac88e7b76", size = 2847803 }, + { url = "https://files.pythonhosted.org/packages/00/69/ba36273b888a4a48d78f9268d2aee05787e4438557450a8442946ab8f3ec/h5py-3.15.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a33bfd5dfcea037196f7778534b1ff7e36a7f40a89e648c8f2967292eb6898e", size = 4914884 }, + { url = "https://files.pythonhosted.org/packages/3a/30/d1c94066343a98bb2cea40120873193a4fed68c4ad7f8935c11caf74c681/h5py-3.15.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25c8843fec43b2cc368aa15afa1cdf83fc5e17b1c4e10cd3771ef6c39b72e5ce", size = 5109965 }, + { url = "https://files.pythonhosted.org/packages/81/3d/d28172116eafc3bc9f5991b3cb3fd2c8a95f5984f50880adfdf991de9087/h5py-3.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a308fd8681a864c04423c0324527237a0484e2611e3441f8089fd00ed56a8171", size = 4561870 }, + { url = "https://files.pythonhosted.org/packages/a5/83/393a7226024238b0f51965a7156004eaae1fcf84aa4bfecf7e582676271b/h5py-3.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f4a016df3f4a8a14d573b496e4d1964deb380e26031fc85fb40e417e9131888a", size = 5037161 }, + { url = "https://files.pythonhosted.org/packages/cf/51/329e7436bf87ca6b0fe06dd0a3795c34bebe4ed8d6c44450a20565d57832/h5py-3.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:59b25cf02411bf12e14f803fef0b80886444c7fe21a5ad17c6a28d3f08098a1e", size = 2874165 }, + { url = "https://files.pythonhosted.org/packages/09/a8/2d02b10a66747c54446e932171dd89b8b4126c0111b440e6bc05a7c852ec/h5py-3.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:61d5a58a9851e01ee61c932bbbb1c98fe20aba0a5674776600fb9a361c0aa652", size = 2458214 }, + { url = "https://files.pythonhosted.org/packages/88/b3/40207e0192415cbff7ea1d37b9f24b33f6d38a5a2f5d18a678de78f967ae/h5py-3.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8440fd8bee9500c235ecb7aa1917a0389a2adb80c209fa1cc485bd70e0d94a5", size = 3376511 }, + { url = "https://files.pythonhosted.org/packages/31/96/ba99a003c763998035b0de4c299598125df5fc6c9ccf834f152ddd60e0fb/h5py-3.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ab2219dbc6fcdb6932f76b548e2b16f34a1f52b7666e998157a4dfc02e2c4123", size = 2826143 }, + { url = "https://files.pythonhosted.org/packages/6a/c2/fc6375d07ea3962df7afad7d863fe4bde18bb88530678c20d4c90c18de1d/h5py-3.15.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8cb02c3a96255149ed3ac811eeea25b655d959c6dd5ce702c9a95ff11859eb5", size = 4908316 }, + { url = "https://files.pythonhosted.org/packages/d9/69/4402ea66272dacc10b298cca18ed73e1c0791ff2ae9ed218d3859f9698ac/h5py-3.15.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:121b2b7a4c1915d63737483b7bff14ef253020f617c2fb2811f67a4bed9ac5e8", size = 5103710 }, + { url = "https://files.pythonhosted.org/packages/e0/f6/11f1e2432d57d71322c02a97a5567829a75f223a8c821764a0e71a65cde8/h5py-3.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59b0d63b318bf3cc06687def2b45afd75926bbc006f7b8cd2b1a231299fc8599", size = 4556042 }, + { url = "https://files.pythonhosted.org/packages/18/88/3eda3ef16bfe7a7dbc3d8d6836bbaa7986feb5ff091395e140dc13927bcc/h5py-3.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e02fe77a03f652500d8bff288cbf3675f742fc0411f5a628fa37116507dc7cc0", size = 5030639 }, + { url = "https://files.pythonhosted.org/packages/e5/ea/fbb258a98863f99befb10ed727152b4ae659f322e1d9c0576f8a62754e81/h5py-3.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:dea78b092fd80a083563ed79a3171258d4a4d307492e7cf8b2313d464c82ba52", size = 2864363 }, + { url = "https://files.pythonhosted.org/packages/5d/c9/35021cc9cd2b2915a7da3026e3d77a05bed1144a414ff840953b33937fb9/h5py-3.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:c256254a8a81e2bddc0d376e23e2a6d2dc8a1e8a2261835ed8c1281a0744cd97", size = 2449570 }, + { url = "https://files.pythonhosted.org/packages/a0/2c/926eba1514e4d2e47d0e9eb16c784e717d8b066398ccfca9b283917b1bfb/h5py-3.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f4fb0567eb8517c3ecd6b3c02c4f4e9da220c8932604960fd04e24ee1254763", size = 3380368 }, + { url = "https://files.pythonhosted.org/packages/65/4b/d715ed454d3baa5f6ae1d30b7eca4c7a1c1084f6a2edead9e801a1541d62/h5py-3.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:954e480433e82d3872503104f9b285d369048c3a788b2b1a00e53d1c47c98dd2", size = 2833793 }, + { url = "https://files.pythonhosted.org/packages/ef/d4/ef386c28e4579314610a8bffebbee3b69295b0237bc967340b7c653c6c10/h5py-3.15.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd125c131889ebbef0849f4a0e29cf363b48aba42f228d08b4079913b576bb3a", size = 4903199 }, + { url = "https://files.pythonhosted.org/packages/33/5d/65c619e195e0b5e54ea5a95c1bb600c8ff8715e0d09676e4cce56d89f492/h5py-3.15.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28a20e1a4082a479b3d7db2169f3a5034af010b90842e75ebbf2e9e49eb4183e", size = 5097224 }, + { url = "https://files.pythonhosted.org/packages/30/30/5273218400bf2da01609e1292f562c94b461fcb73c7a9e27fdadd43abc0a/h5py-3.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8df5267f545b4946df8ca0d93d23382191018e4cda2deda4c2cedf9a010e13", size = 4551207 }, + { url = "https://files.pythonhosted.org/packages/d3/39/a7ef948ddf4d1c556b0b2b9559534777bccc318543b3f5a1efdf6b556c9c/h5py-3.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99d374a21f7321a4c6ab327c4ab23bd925ad69821aeb53a1e75dd809d19f67fa", size = 5025426 }, + { url = "https://files.pythonhosted.org/packages/b6/d8/7368679b8df6925b8415f9dcc9ab1dab01ddc384d2b2c24aac9191bd9ceb/h5py-3.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:9c73d1d7cdb97d5b17ae385153472ce118bed607e43be11e9a9deefaa54e0734", size = 2865704 }, + { url = "https://files.pythonhosted.org/packages/d3/b7/4a806f85d62c20157e62e58e03b27513dc9c55499768530acc4f4c5ce4be/h5py-3.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:a6d8c5a05a76aca9a494b4c53ce8a9c29023b7f64f625c6ce1841e92a362ccdf", size = 2465544 }, ] [[package]] @@ -946,13 +946,13 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h5py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/4f/9130151e3aa475b3e4e9a611bf608107fe5c72d277d74c4cf36f164b7c81/hdf5plugin-6.0.0.tar.gz", hash = "sha256:847ed9e96b451367a110f0ba64a3b260d38d64bbf3f25751858d3b56e094cfe0", size = 66372085, upload-time = "2025-10-08T18:16:28.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/4f/9130151e3aa475b3e4e9a611bf608107fe5c72d277d74c4cf36f164b7c81/hdf5plugin-6.0.0.tar.gz", hash = "sha256:847ed9e96b451367a110f0ba64a3b260d38d64bbf3f25751858d3b56e094cfe0", size = 66372085 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/13/15017f6210bfea843316d62f0f121e364e17bb129444ed803a256a213036/hdf5plugin-6.0.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:a59fbd5d4290a8a5334d82ccb4c6b9bfc7aaf586de7fedb88762e8601bc05fd4", size = 13339413, upload-time = "2025-10-08T18:16:10.656Z" }, - { url = "https://files.pythonhosted.org/packages/40/bf/d1f3765fb879820d7331e30e860b684f5b78d3ec17324e8f54130cbe560b/hdf5plugin-6.0.0-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d301f4b9295872bacf277c70628d4c5e965ee47db762d8fde2d4849f201b9897", size = 42858563, upload-time = "2025-10-08T18:16:14.106Z" }, - { url = "https://files.pythonhosted.org/packages/0a/67/37d0b84fbbf26bf0d6a99a8f98bcd82bb6d437dc8cabee259fb3d7506ec7/hdf5plugin-6.0.0-py3-none-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:78b082ea355fe46bf5b396024de1fb662a1aaf9a5e11861ad61a5a2a6316d59d", size = 45126124, upload-time = "2025-10-08T18:16:17.992Z" }, - { url = "https://files.pythonhosted.org/packages/ed/2f/1046d464ad1db29a4f6c70ba4e19b39baa8a6542c719eaa4e765108f07f1/hdf5plugin-6.0.0-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79e0524d18ddc41c0cf2e1bb2e529d4e154c286f6a1bd85f3d44019d2a17574a", size = 44857273, upload-time = "2025-10-08T18:16:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/75478bdfee85533777de4204373f563aa7a1074355300743c3aedc33cac5/hdf5plugin-6.0.0-py3-none-win_amd64.whl", hash = "sha256:99866f90be1ceac5519e6e038669564be326c233618d59ba1f38c9dd8c32099e", size = 3379316, upload-time = "2025-10-08T18:16:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/9c/13/15017f6210bfea843316d62f0f121e364e17bb129444ed803a256a213036/hdf5plugin-6.0.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:a59fbd5d4290a8a5334d82ccb4c6b9bfc7aaf586de7fedb88762e8601bc05fd4", size = 13339413 }, + { url = "https://files.pythonhosted.org/packages/40/bf/d1f3765fb879820d7331e30e860b684f5b78d3ec17324e8f54130cbe560b/hdf5plugin-6.0.0-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d301f4b9295872bacf277c70628d4c5e965ee47db762d8fde2d4849f201b9897", size = 42858563 }, + { url = "https://files.pythonhosted.org/packages/0a/67/37d0b84fbbf26bf0d6a99a8f98bcd82bb6d437dc8cabee259fb3d7506ec7/hdf5plugin-6.0.0-py3-none-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:78b082ea355fe46bf5b396024de1fb662a1aaf9a5e11861ad61a5a2a6316d59d", size = 45126124 }, + { url = "https://files.pythonhosted.org/packages/ed/2f/1046d464ad1db29a4f6c70ba4e19b39baa8a6542c719eaa4e765108f07f1/hdf5plugin-6.0.0-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79e0524d18ddc41c0cf2e1bb2e529d4e154c286f6a1bd85f3d44019d2a17574a", size = 44857273 }, + { url = "https://files.pythonhosted.org/packages/61/b3/75478bdfee85533777de4204373f563aa7a1074355300743c3aedc33cac5/hdf5plugin-6.0.0-py3-none-win_amd64.whl", hash = "sha256:99866f90be1ceac5519e6e038669564be326c233618d59ba1f38c9dd8c32099e", size = 3379316 }, ] [[package]] @@ -963,9 +963,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] [[package]] @@ -978,27 +978,27 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [[package]] name = "identify" version = "2.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183 }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, ] [[package]] @@ -1009,9 +1009,9 @@ dependencies = [ { name = "numpy" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646 }, ] [[package]] @@ -1021,18 +1021,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[package]] @@ -1054,9 +1054,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579, upload-time = "2025-10-27T09:46:39.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, + { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968 }, ] [[package]] @@ -1076,9 +1076,9 @@ dependencies = [ { name = "traitlets" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/e6/48c74d54039241a456add616464ea28c6ebf782e4110d419411b83dae06f/ipython-9.7.0.tar.gz", hash = "sha256:5f6de88c905a566c6a9d6c400a8fed54a638e1f7543d17aae2551133216b1e4e", size = 4422115, upload-time = "2025-11-05T12:18:54.646Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/e6/48c74d54039241a456add616464ea28c6ebf782e4110d419411b83dae06f/ipython-9.7.0.tar.gz", hash = "sha256:5f6de88c905a566c6a9d6c400a8fed54a638e1f7543d17aae2551133216b1e4e", size = 4422115 } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl", hash = "sha256:bce8ac85eb9521adc94e1845b4c03d88365fd6ac2f4908ec4ed1eb1b0a065f9f", size = 618911, upload-time = "2025-11-05T12:18:52.484Z" }, + { url = "https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl", hash = "sha256:bce8ac85eb9521adc94e1845b4c03d88365fd6ac2f4908ec4ed1eb1b0a065f9f", size = 618911 }, ] [[package]] @@ -1088,9 +1088,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, ] [[package]] @@ -1100,9 +1100,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "arrow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321 }, ] [[package]] @@ -1112,9 +1112,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, ] [[package]] @@ -1124,27 +1124,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] [[package]] name = "json5" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/ae/929aee9619e9eba9015207a9d2c1c54db18311da7eb4dcf6d41ad6f0eb67/json5-0.12.1.tar.gz", hash = "sha256:b2743e77b3242f8d03c143dd975a6ec7c52e2f2afe76ed934e53503dd4ad4990", size = 52191, upload-time = "2025-08-12T19:47:42.583Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/ae/929aee9619e9eba9015207a9d2c1c54db18311da7eb4dcf6d41ad6f0eb67/json5-0.12.1.tar.gz", hash = "sha256:b2743e77b3242f8d03c143dd975a6ec7c52e2f2afe76ed934e53503dd4ad4990", size = 52191 } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/e2/05328bd2621be49a6fed9e3030b1e51a2d04537d3f816d211b9cc53c5262/json5-0.12.1-py3-none-any.whl", hash = "sha256:d9c9b3bc34a5f54d43c35e11ef7cb87d8bdd098c6ace87117a7b7e83e705c1d5", size = 36119, upload-time = "2025-08-12T19:47:41.131Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/05328bd2621be49a6fed9e3030b1e51a2d04537d3f816d211b9cc53c5262/json5-0.12.1-py3-none-any.whl", hash = "sha256:d9c9b3bc34a5f54d43c35e11ef7cb87d8bdd098c6ace87117a7b7e83e705c1d5", size = 36119 }, ] [[package]] name = "jsonpointer" version = "3.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114 } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595 }, ] [[package]] @@ -1157,9 +1157,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342 } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040 }, ] [package.optional-dependencies] @@ -1182,9 +1182,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, ] [[package]] @@ -1198,9 +1198,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019 } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105 }, ] [[package]] @@ -1211,9 +1211,9 @@ dependencies = [ { name = "platformdirs" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032 }, ] [[package]] @@ -1230,9 +1230,9 @@ dependencies = [ { name = "rfc3986-validator" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430 }, ] [[package]] @@ -1242,9 +1242,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, + { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687 }, ] [[package]] @@ -1272,9 +1272,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949 } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, + { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221 }, ] [[package]] @@ -1285,9 +1285,9 @@ dependencies = [ { name = "pywinpty", marker = "os_name == 'nt'" }, { name = "terminado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/562469734f476159e99a55426d697cbf8e7eb5efe89fb0e0b4f83a3d3459/jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269", size = 31430, upload-time = "2024-03-12T14:37:03.049Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/562469734f476159e99a55426d697cbf8e7eb5efe89fb0e0b4f83a3d3459/jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269", size = 31430 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/2d/2b32cdbe8d2a602f697a649798554e4f072115438e92249624e532e8aca6/jupyter_server_terminals-0.5.3-py3-none-any.whl", hash = "sha256:41ee0d7dc0ebf2809c668e0fc726dfaf258fcd3e769568996ca731b6194ae9aa", size = 13656, upload-time = "2024-03-12T14:37:00.708Z" }, + { url = "https://files.pythonhosted.org/packages/07/2d/2b32cdbe8d2a602f697a649798554e4f072115438e92249624e532e8aca6/jupyter_server_terminals-0.5.3-py3-none-any.whl", hash = "sha256:41ee0d7dc0ebf2809c668e0fc726dfaf258fcd3e769568996ca731b6194ae9aa", size = 13656 }, ] [[package]] @@ -1309,18 +1309,18 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/e5/4fa382a796a6d8e2cd867816b64f1ff27f906e43a7a83ad9eb389e448cd8/jupyterlab-4.5.0.tar.gz", hash = "sha256:aec33d6d8f1225b495ee2cf20f0514f45e6df8e360bdd7ac9bace0b7ac5177ea", size = 23989880, upload-time = "2025-11-18T13:19:00.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/e5/4fa382a796a6d8e2cd867816b64f1ff27f906e43a7a83ad9eb389e448cd8/jupyterlab-4.5.0.tar.gz", hash = "sha256:aec33d6d8f1225b495ee2cf20f0514f45e6df8e360bdd7ac9bace0b7ac5177ea", size = 23989880 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/1e/5a4d5498eba382fee667ed797cf64ae5d1b13b04356df62f067f48bb0f61/jupyterlab-4.5.0-py3-none-any.whl", hash = "sha256:88e157c75c1afff64c7dc4b801ec471450b922a4eae4305211ddd40da8201c8a", size = 12380641, upload-time = "2025-11-18T13:18:56.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1e/5a4d5498eba382fee667ed797cf64ae5d1b13b04356df62f067f48bb0f61/jupyterlab-4.5.0-py3-none-any.whl", hash = "sha256:88e157c75c1afff64c7dc4b801ec471450b922a4eae4305211ddd40da8201c8a", size = 12380641 }, ] [[package]] name = "jupyterlab-pygments" version = "0.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884 }, ] [[package]] @@ -1336,108 +1336,108 @@ dependencies = [ { name = "packaging" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830 }, ] [[package]] name = "kiwisolver" version = "1.4.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, - { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, - { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, - { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, - { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, - { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, - { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, - { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, - { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, - { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, - { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167 }, + { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579 }, + { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309 }, + { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596 }, + { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548 }, + { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618 }, + { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437 }, + { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742 }, + { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810 }, + { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579 }, + { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071 }, + { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840 }, + { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159 }, + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686 }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460 }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952 }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756 }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404 }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410 }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631 }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963 }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295 }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987 }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817 }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895 }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992 }, + { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681 }, + { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464 }, + { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961 }, + { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607 }, + { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546 }, + { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482 }, + { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720 }, + { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907 }, + { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334 }, + { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313 }, + { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970 }, + { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894 }, + { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995 }, + { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510 }, + { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903 }, + { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402 }, + { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135 }, + { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409 }, + { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763 }, + { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643 }, + { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818 }, + { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963 }, + { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639 }, + { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741 }, + { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646 }, + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806 }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605 }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925 }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414 }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272 }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578 }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607 }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150 }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979 }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456 }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621 }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417 }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582 }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514 }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905 }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399 }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197 }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125 }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612 }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990 }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601 }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041 }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897 }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835 }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988 }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260 }, + { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104 }, + { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592 }, + { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281 }, + { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009 }, + { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929 }, ] [[package]] name = "lark" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732 } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151 }, ] [[package]] @@ -1447,9 +1447,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431 } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, + { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097 }, ] [[package]] @@ -1461,18 +1461,18 @@ dependencies = [ { name = "setuptools" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/39/6fc58ca81492db047149b4b8fd385aa1bfb8c28cd7cacb0c7eb0c44d842f/lightning_utilities-0.15.2.tar.gz", hash = "sha256:cdf12f530214a63dacefd713f180d1ecf5d165338101617b4742e8f22c032e24", size = 31090, upload-time = "2025-08-06T13:57:39.242Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/39/6fc58ca81492db047149b4b8fd385aa1bfb8c28cd7cacb0c7eb0c44d842f/lightning_utilities-0.15.2.tar.gz", hash = "sha256:cdf12f530214a63dacefd713f180d1ecf5d165338101617b4742e8f22c032e24", size = 31090 } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/73/3d757cb3fc16f0f9794dd289bcd0c4a031d9cf54d8137d6b984b2d02edf3/lightning_utilities-0.15.2-py3-none-any.whl", hash = "sha256:ad3ab1703775044bbf880dbf7ddaaac899396c96315f3aa1779cec9d618a9841", size = 29431, upload-time = "2025-08-06T13:57:38.046Z" }, + { url = "https://files.pythonhosted.org/packages/de/73/3d757cb3fc16f0f9794dd289bcd0c4a031d9cf54d8137d6b984b2d02edf3/lightning_utilities-0.15.2-py3-none-any.whl", hash = "sha256:ad3ab1703775044bbf880dbf7ddaaac899396c96315f3aa1779cec9d618a9841", size = 29431 }, ] [[package]] name = "locket" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/83/97b29fe05cb6ae28d2dbd30b81e2e402a3eed5f460c26e9eaa5895ceacf5/locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632", size = 4350, upload-time = "2022-04-20T22:04:44.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/83/97b29fe05cb6ae28d2dbd30b81e2e402a3eed5f460c26e9eaa5895ceacf5/locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632", size = 4350 } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398 }, ] [[package]] @@ -1482,92 +1482,92 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 }, ] [[package]] name = "markdown" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931 } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678 }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, ] [[package]] @@ -1585,53 +1585,53 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507, upload-time = "2025-10-09T00:26:19.073Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565, upload-time = "2025-10-09T00:26:21.023Z" }, - { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668, upload-time = "2025-10-09T00:26:22.967Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e7/664d2b97016f46683a02d854d730cfcf54ff92c1dafa424beebef50f831d/matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1", size = 9521051, upload-time = "2025-10-09T00:26:25.041Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a3/37aef1404efa615f49b5758a5e0261c16dd88f389bc1861e722620e4a754/matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc", size = 9576878, upload-time = "2025-10-09T00:26:27.478Z" }, - { url = "https://files.pythonhosted.org/packages/33/cd/b145f9797126f3f809d177ca378de57c45413c5099c5990de2658760594a/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e", size = 8115142, upload-time = "2025-10-09T00:26:29.774Z" }, - { url = "https://files.pythonhosted.org/packages/2e/39/63bca9d2b78455ed497fcf51a9c71df200a11048f48249038f06447fa947/matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9", size = 7992439, upload-time = "2025-10-09T00:26:40.32Z" }, - { url = "https://files.pythonhosted.org/packages/be/b3/09eb0f7796932826ec20c25b517d568627754f6c6462fca19e12c02f2e12/matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748", size = 8272389, upload-time = "2025-10-09T00:26:42.474Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/1ae80ddafb8652fd8046cb5c8460ecc8d4afccb89e2c6d6bec61e04e1eaf/matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f", size = 8128247, upload-time = "2025-10-09T00:26:44.77Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/95ae2e242d4a5c98bd6e90e36e128d71cf1c7e39b0874feaed3ef782e789/matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0", size = 8696996, upload-time = "2025-10-09T00:26:46.792Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3d/5b559efc800bd05cb2033aa85f7e13af51958136a48327f7c261801ff90a/matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695", size = 9530153, upload-time = "2025-10-09T00:26:49.07Z" }, - { url = "https://files.pythonhosted.org/packages/88/57/eab4a719fd110312d3c220595d63a3c85ec2a39723f0f4e7fa7e6e3f74ba/matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65", size = 9593093, upload-time = "2025-10-09T00:26:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/31/3c/80816f027b3a4a28cd2a0a6ef7f89a2db22310e945cd886ec25bfb399221/matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee", size = 8122771, upload-time = "2025-10-09T00:26:53.296Z" }, - { url = "https://files.pythonhosted.org/packages/de/77/ef1fc78bfe99999b2675435cc52120887191c566b25017d78beaabef7f2d/matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8", size = 7992812, upload-time = "2025-10-09T00:26:54.882Z" }, - { url = "https://files.pythonhosted.org/packages/02/9c/207547916a02c78f6bdd83448d9b21afbc42f6379ed887ecf610984f3b4e/matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f", size = 8273212, upload-time = "2025-10-09T00:26:56.752Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c", size = 8128713, upload-time = "2025-10-09T00:26:59.001Z" }, - { url = "https://files.pythonhosted.org/packages/22/ff/6425bf5c20d79aa5b959d1ce9e65f599632345391381c9a104133fe0b171/matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1", size = 8698527, upload-time = "2025-10-09T00:27:00.69Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632", size = 9529690, upload-time = "2025-10-09T00:27:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/b8/95/b80fc2c1f269f21ff3d193ca697358e24408c33ce2b106a7438a45407b63/matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84", size = 9593732, upload-time = "2025-10-09T00:27:04.653Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b6/23064a96308b9aeceeffa65e96bcde459a2ea4934d311dee20afde7407a0/matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815", size = 8122727, upload-time = "2025-10-09T00:27:06.814Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a6/2faaf48133b82cf3607759027f82b5c702aa99cdfcefb7f93d6ccf26a424/matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7", size = 7992958, upload-time = "2025-10-09T00:27:08.567Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f0/b018fed0b599bd48d84c08794cb242227fe3341952da102ee9d9682db574/matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355", size = 8316849, upload-time = "2025-10-09T00:27:10.254Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b7/bb4f23856197659f275e11a2a164e36e65e9b48ea3e93c4ec25b4f163198/matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b", size = 8178225, upload-time = "2025-10-09T00:27:12.241Z" }, - { url = "https://files.pythonhosted.org/packages/62/56/0600609893ff277e6f3ab3c0cef4eafa6e61006c058e84286c467223d4d5/matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67", size = 8711708, upload-time = "2025-10-09T00:27:13.879Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1a/6bfecb0cafe94d6658f2f1af22c43b76cf7a1c2f0dc34ef84cbb6809617e/matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67", size = 9541409, upload-time = "2025-10-09T00:27:15.684Z" }, - { url = "https://files.pythonhosted.org/packages/08/50/95122a407d7f2e446fd865e2388a232a23f2b81934960ea802f3171518e4/matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84", size = 9594054, upload-time = "2025-10-09T00:27:17.547Z" }, - { url = "https://files.pythonhosted.org/packages/13/76/75b194a43b81583478a81e78a07da8d9ca6ddf50dd0a2ccabf258059481d/matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2", size = 8200100, upload-time = "2025-10-09T00:27:20.039Z" }, - { url = "https://files.pythonhosted.org/packages/f5/9e/6aefebdc9f8235c12bdeeda44cc0383d89c1e41da2c400caf3ee2073a3ce/matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf", size = 8042131, upload-time = "2025-10-09T00:27:21.608Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4b/e5bc2c321b6a7e3a75638d937d19ea267c34bd5a90e12bee76c4d7c7a0d9/matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100", size = 8273787, upload-time = "2025-10-09T00:27:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/6efae459c56c2fbc404da154e13e3a6039129f3c942b0152624f1c621f05/matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f", size = 8131348, upload-time = "2025-10-09T00:27:24.926Z" }, - { url = "https://files.pythonhosted.org/packages/a6/5a/a4284d2958dee4116359cc05d7e19c057e64ece1b4ac986ab0f2f4d52d5a/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715", size = 9533949, upload-time = "2025-10-09T00:27:26.704Z" }, - { url = "https://files.pythonhosted.org/packages/de/ff/f3781b5057fa3786623ad8976fc9f7b0d02b2f28534751fd5a44240de4cf/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1", size = 9804247, upload-time = "2025-10-09T00:27:28.514Z" }, - { url = "https://files.pythonhosted.org/packages/47/5a/993a59facb8444efb0e197bf55f545ee449902dcee86a4dfc580c3b61314/matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722", size = 9595497, upload-time = "2025-10-09T00:27:30.418Z" }, - { url = "https://files.pythonhosted.org/packages/0d/a5/77c95aaa9bb32c345cbb49626ad8eb15550cba2e6d4c88081a6c2ac7b08d/matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866", size = 8252732, upload-time = "2025-10-09T00:27:32.332Z" }, - { url = "https://files.pythonhosted.org/packages/74/04/45d269b4268d222390d7817dae77b159651909669a34ee9fdee336db5883/matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb", size = 8124240, upload-time = "2025-10-09T00:27:33.94Z" }, - { url = "https://files.pythonhosted.org/packages/4b/c7/ca01c607bb827158b439208c153d6f14ddb9fb640768f06f7ca3488ae67b/matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1", size = 8316938, upload-time = "2025-10-09T00:27:35.534Z" }, - { url = "https://files.pythonhosted.org/packages/84/d2/5539e66e9f56d2fdec94bb8436f5e449683b4e199bcc897c44fbe3c99e28/matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4", size = 8178245, upload-time = "2025-10-09T00:27:37.334Z" }, - { url = "https://files.pythonhosted.org/packages/77/b5/e6ca22901fd3e4fe433a82e583436dd872f6c966fca7e63cf806b40356f8/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318", size = 9541411, upload-time = "2025-10-09T00:27:39.387Z" }, - { url = "https://files.pythonhosted.org/packages/9e/99/a4524db57cad8fee54b7237239a8f8360bfcfa3170d37c9e71c090c0f409/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca", size = 9803664, upload-time = "2025-10-09T00:27:41.492Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066, upload-time = "2025-10-09T00:27:43.694Z" }, - { url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832, upload-time = "2025-10-09T00:27:45.543Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585, upload-time = "2025-10-09T00:27:47.185Z" }, - { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283, upload-time = "2025-10-09T00:27:54.739Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733, upload-time = "2025-10-09T00:27:56.406Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919, upload-time = "2025-10-09T00:27:58.41Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507 }, + { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565 }, + { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668 }, + { url = "https://files.pythonhosted.org/packages/e6/e7/664d2b97016f46683a02d854d730cfcf54ff92c1dafa424beebef50f831d/matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1", size = 9521051 }, + { url = "https://files.pythonhosted.org/packages/a8/a3/37aef1404efa615f49b5758a5e0261c16dd88f389bc1861e722620e4a754/matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc", size = 9576878 }, + { url = "https://files.pythonhosted.org/packages/33/cd/b145f9797126f3f809d177ca378de57c45413c5099c5990de2658760594a/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e", size = 8115142 }, + { url = "https://files.pythonhosted.org/packages/2e/39/63bca9d2b78455ed497fcf51a9c71df200a11048f48249038f06447fa947/matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9", size = 7992439 }, + { url = "https://files.pythonhosted.org/packages/be/b3/09eb0f7796932826ec20c25b517d568627754f6c6462fca19e12c02f2e12/matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748", size = 8272389 }, + { url = "https://files.pythonhosted.org/packages/11/0b/1ae80ddafb8652fd8046cb5c8460ecc8d4afccb89e2c6d6bec61e04e1eaf/matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f", size = 8128247 }, + { url = "https://files.pythonhosted.org/packages/7d/18/95ae2e242d4a5c98bd6e90e36e128d71cf1c7e39b0874feaed3ef782e789/matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0", size = 8696996 }, + { url = "https://files.pythonhosted.org/packages/7e/3d/5b559efc800bd05cb2033aa85f7e13af51958136a48327f7c261801ff90a/matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695", size = 9530153 }, + { url = "https://files.pythonhosted.org/packages/88/57/eab4a719fd110312d3c220595d63a3c85ec2a39723f0f4e7fa7e6e3f74ba/matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65", size = 9593093 }, + { url = "https://files.pythonhosted.org/packages/31/3c/80816f027b3a4a28cd2a0a6ef7f89a2db22310e945cd886ec25bfb399221/matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee", size = 8122771 }, + { url = "https://files.pythonhosted.org/packages/de/77/ef1fc78bfe99999b2675435cc52120887191c566b25017d78beaabef7f2d/matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8", size = 7992812 }, + { url = "https://files.pythonhosted.org/packages/02/9c/207547916a02c78f6bdd83448d9b21afbc42f6379ed887ecf610984f3b4e/matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f", size = 8273212 }, + { url = "https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c", size = 8128713 }, + { url = "https://files.pythonhosted.org/packages/22/ff/6425bf5c20d79aa5b959d1ce9e65f599632345391381c9a104133fe0b171/matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1", size = 8698527 }, + { url = "https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632", size = 9529690 }, + { url = "https://files.pythonhosted.org/packages/b8/95/b80fc2c1f269f21ff3d193ca697358e24408c33ce2b106a7438a45407b63/matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84", size = 9593732 }, + { url = "https://files.pythonhosted.org/packages/e1/b6/23064a96308b9aeceeffa65e96bcde459a2ea4934d311dee20afde7407a0/matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815", size = 8122727 }, + { url = "https://files.pythonhosted.org/packages/b3/a6/2faaf48133b82cf3607759027f82b5c702aa99cdfcefb7f93d6ccf26a424/matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7", size = 7992958 }, + { url = "https://files.pythonhosted.org/packages/4a/f0/b018fed0b599bd48d84c08794cb242227fe3341952da102ee9d9682db574/matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355", size = 8316849 }, + { url = "https://files.pythonhosted.org/packages/b0/b7/bb4f23856197659f275e11a2a164e36e65e9b48ea3e93c4ec25b4f163198/matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b", size = 8178225 }, + { url = "https://files.pythonhosted.org/packages/62/56/0600609893ff277e6f3ab3c0cef4eafa6e61006c058e84286c467223d4d5/matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67", size = 8711708 }, + { url = "https://files.pythonhosted.org/packages/d8/1a/6bfecb0cafe94d6658f2f1af22c43b76cf7a1c2f0dc34ef84cbb6809617e/matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67", size = 9541409 }, + { url = "https://files.pythonhosted.org/packages/08/50/95122a407d7f2e446fd865e2388a232a23f2b81934960ea802f3171518e4/matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84", size = 9594054 }, + { url = "https://files.pythonhosted.org/packages/13/76/75b194a43b81583478a81e78a07da8d9ca6ddf50dd0a2ccabf258059481d/matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2", size = 8200100 }, + { url = "https://files.pythonhosted.org/packages/f5/9e/6aefebdc9f8235c12bdeeda44cc0383d89c1e41da2c400caf3ee2073a3ce/matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf", size = 8042131 }, + { url = "https://files.pythonhosted.org/packages/0d/4b/e5bc2c321b6a7e3a75638d937d19ea267c34bd5a90e12bee76c4d7c7a0d9/matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100", size = 8273787 }, + { url = "https://files.pythonhosted.org/packages/86/ad/6efae459c56c2fbc404da154e13e3a6039129f3c942b0152624f1c621f05/matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f", size = 8131348 }, + { url = "https://files.pythonhosted.org/packages/a6/5a/a4284d2958dee4116359cc05d7e19c057e64ece1b4ac986ab0f2f4d52d5a/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715", size = 9533949 }, + { url = "https://files.pythonhosted.org/packages/de/ff/f3781b5057fa3786623ad8976fc9f7b0d02b2f28534751fd5a44240de4cf/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1", size = 9804247 }, + { url = "https://files.pythonhosted.org/packages/47/5a/993a59facb8444efb0e197bf55f545ee449902dcee86a4dfc580c3b61314/matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722", size = 9595497 }, + { url = "https://files.pythonhosted.org/packages/0d/a5/77c95aaa9bb32c345cbb49626ad8eb15550cba2e6d4c88081a6c2ac7b08d/matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866", size = 8252732 }, + { url = "https://files.pythonhosted.org/packages/74/04/45d269b4268d222390d7817dae77b159651909669a34ee9fdee336db5883/matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb", size = 8124240 }, + { url = "https://files.pythonhosted.org/packages/4b/c7/ca01c607bb827158b439208c153d6f14ddb9fb640768f06f7ca3488ae67b/matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1", size = 8316938 }, + { url = "https://files.pythonhosted.org/packages/84/d2/5539e66e9f56d2fdec94bb8436f5e449683b4e199bcc897c44fbe3c99e28/matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4", size = 8178245 }, + { url = "https://files.pythonhosted.org/packages/77/b5/e6ca22901fd3e4fe433a82e583436dd872f6c966fca7e63cf806b40356f8/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318", size = 9541411 }, + { url = "https://files.pythonhosted.org/packages/9e/99/a4524db57cad8fee54b7237239a8f8360bfcfa3170d37c9e71c090c0f409/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca", size = 9803664 }, + { url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066 }, + { url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832 }, + { url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585 }, + { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283 }, + { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733 }, + { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919 }, ] [[package]] @@ -1641,27 +1641,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110 } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516 }, ] [[package]] name = "mistune" version = "3.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481 }, ] [[package]] name = "mpmath" version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, ] [[package]] @@ -1674,9 +1674,9 @@ dependencies = [ { name = "nbformat" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424, upload-time = "2024-12-19T10:32:27.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424 } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434, upload-time = "2024-12-19T10:32:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434 }, ] [[package]] @@ -1699,9 +1699,9 @@ dependencies = [ { name = "pygments" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715, upload-time = "2025-01-28T09:29:14.724Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525, upload-time = "2025-01-28T09:29:12.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525 }, ] [[package]] @@ -1714,36 +1714,36 @@ dependencies = [ { name = "jupyter-core" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454 }, ] [[package]] name = "nest-asyncio" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, ] [[package]] name = "networkx" version = "3.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065 } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406 }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] [[package]] @@ -1753,9 +1753,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307 }, ] [[package]] @@ -1766,23 +1766,23 @@ dependencies = [ { name = "numpy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/48/6188e359b90a9d8a1850f2bc888c023e66f4a8b2b496820babbea414f008/numcodecs-0.16.3.tar.gz", hash = "sha256:53d705865faaf0a7927c973af3777532001c8fbb653de119c1e844608614d799", size = 6275704, upload-time = "2025-09-18T18:54:57.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/cc/917a85972537498f2bbd7914047efc98babc8667587ceb9dcb228378978a/numcodecs-0.16.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:95c9f2a49bef10cf91ad614a761cba9bfe96656b60c12540e1080de5d909b4ca", size = 1642356, upload-time = "2025-09-18T18:54:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/3b/6a/64c25a089e8537441fe67c09ecb7f3f7fb5d98cd04faf01f605d43aca41c/numcodecs-0.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2afe73d5ebaf9ca0cd5c83aad945da80d29a33d860a80d43a7248491d8813ff", size = 1169186, upload-time = "2025-09-18T18:54:37.838Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a0/0de627baeb43e2045a3d4b3de99bf8b69af329a33df1ed4cda468d70c1fb/numcodecs-0.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913f08194d82dcb37594e6705e6d4ae6ccd4b6571500b832fb3e4a155de1dfe8", size = 8341668, upload-time = "2025-09-18T18:54:39.444Z" }, - { url = "https://files.pythonhosted.org/packages/b6/0f/49d1f74a216149240c4b9403218111f11670bd11af0919fda357bb056bf2/numcodecs-0.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a7f1cae9eb18b85709af46570bf9c60056e7155c4c8f610e8080c68124d0e5", size = 8866611, upload-time = "2025-09-18T18:54:41.168Z" }, - { url = "https://files.pythonhosted.org/packages/aa/51/03aece765108fe247717105b5131856546e5428f22a56a14ffdebd017424/numcodecs-0.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:f7bb7f2c46eb7ec8a1c5f8d8fe1a72c222256dd6d6df5af9eaac7a6b905f3575", size = 806787, upload-time = "2025-09-18T18:54:42.78Z" }, - { url = "https://files.pythonhosted.org/packages/0d/78/e4b34803a3aa1d0769919695de4b133266c18c80c474d32ebc462fa1a9bd/numcodecs-0.16.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c77454d92941a335d148b0b822f5d4783103f392774d5d76283bbf7f21b49529", size = 1681108, upload-time = "2025-09-18T18:54:43.856Z" }, - { url = "https://files.pythonhosted.org/packages/25/cf/ca36f463b03a4097767d2a1c1b72f31810e8c6384e9449dd9b925203783c/numcodecs-0.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:270e7a33ee96bdf5c957acf25a2487002a233811a125a155c400c2f036b69c73", size = 1165589, upload-time = "2025-09-18T18:54:44.954Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ae/670260c3c4b5ed34a0674561355f3d4ce7fcbdf09a667e5bc841526d271c/numcodecs-0.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12f43fa4a347d1dba775c4506a1c9b15b90144c258433b81f79f1c1b1a990db5", size = 8316365, upload-time = "2025-09-18T18:54:46.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/fa/94e022419c751a60ff0f53642ebae5ef81ed3cc3640f958588e3ad3dc18d/numcodecs-0.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44869ef564a50aa545215c6a0d42ba5bbc34e9715523fb2336ada3d1fb2b331d", size = 8846228, upload-time = "2025-09-18T18:54:47.858Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/f23733589f3e059bf8589508acd23ffeec230bdf179f138a54f5ab16e0a6/numcodecs-0.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:9aae6996172ba10c5f5111b2998709071b5aeba6b58b1ee0b26b61ed6aa7f2f4", size = 806260, upload-time = "2025-09-18T18:54:49.41Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d5/d3536d06ac1e5fb848a3186958204082b68b106364c9a3669652dd786731/numcodecs-0.16.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:947406b01c20f2ce7ce2e631e7f21b782e8a9d4b57b374a41c9e7b1341a8f3a2", size = 1677129, upload-time = "2025-09-18T18:54:50.5Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fd/b0513a3428dc2b38ec85eea771703ae69c49f09b9650d6c44c9105c80073/numcodecs-0.16.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7cf50e351398a34b45817974c411527629e88937b7683695e276afd65da6ed6f", size = 1159058, upload-time = "2025-09-18T18:54:51.675Z" }, - { url = "https://files.pythonhosted.org/packages/98/05/b7c127283cfb154a97abb284363825401b69302d71a28608af66f73257cc/numcodecs-0.16.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7938502fcc060ed9543814f38ca67048b33d7bd2667756e36e6b1060455b17e", size = 8260987, upload-time = "2025-09-18T18:54:52.883Z" }, - { url = "https://files.pythonhosted.org/packages/ff/46/320d960aff884bc63abaaf846ffa3de4803e83e8070b6f84c5688464839c/numcodecs-0.16.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:010d628c95be1214536fb22c0df4ced58da954b404b1fcb25ddebf64e4a3f7f3", size = 8805295, upload-time = "2025-09-18T18:54:54.698Z" }, - { url = "https://files.pythonhosted.org/packages/31/ae/acc2e0f1f49ba32afa2174578f170673139248ef86f77e334f2619133867/numcodecs-0.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:e83115e3c32de798c7b7164503e06aae9f9746c1cef564d029616eb44bd6cd90", size = 803204, upload-time = "2025-09-18T18:54:56.192Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f6/48/6188e359b90a9d8a1850f2bc888c023e66f4a8b2b496820babbea414f008/numcodecs-0.16.3.tar.gz", hash = "sha256:53d705865faaf0a7927c973af3777532001c8fbb653de119c1e844608614d799", size = 6275704 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/cc/917a85972537498f2bbd7914047efc98babc8667587ceb9dcb228378978a/numcodecs-0.16.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:95c9f2a49bef10cf91ad614a761cba9bfe96656b60c12540e1080de5d909b4ca", size = 1642356 }, + { url = "https://files.pythonhosted.org/packages/3b/6a/64c25a089e8537441fe67c09ecb7f3f7fb5d98cd04faf01f605d43aca41c/numcodecs-0.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2afe73d5ebaf9ca0cd5c83aad945da80d29a33d860a80d43a7248491d8813ff", size = 1169186 }, + { url = "https://files.pythonhosted.org/packages/d8/a0/0de627baeb43e2045a3d4b3de99bf8b69af329a33df1ed4cda468d70c1fb/numcodecs-0.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913f08194d82dcb37594e6705e6d4ae6ccd4b6571500b832fb3e4a155de1dfe8", size = 8341668 }, + { url = "https://files.pythonhosted.org/packages/b6/0f/49d1f74a216149240c4b9403218111f11670bd11af0919fda357bb056bf2/numcodecs-0.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a7f1cae9eb18b85709af46570bf9c60056e7155c4c8f610e8080c68124d0e5", size = 8866611 }, + { url = "https://files.pythonhosted.org/packages/aa/51/03aece765108fe247717105b5131856546e5428f22a56a14ffdebd017424/numcodecs-0.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:f7bb7f2c46eb7ec8a1c5f8d8fe1a72c222256dd6d6df5af9eaac7a6b905f3575", size = 806787 }, + { url = "https://files.pythonhosted.org/packages/0d/78/e4b34803a3aa1d0769919695de4b133266c18c80c474d32ebc462fa1a9bd/numcodecs-0.16.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c77454d92941a335d148b0b822f5d4783103f392774d5d76283bbf7f21b49529", size = 1681108 }, + { url = "https://files.pythonhosted.org/packages/25/cf/ca36f463b03a4097767d2a1c1b72f31810e8c6384e9449dd9b925203783c/numcodecs-0.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:270e7a33ee96bdf5c957acf25a2487002a233811a125a155c400c2f036b69c73", size = 1165589 }, + { url = "https://files.pythonhosted.org/packages/ed/ae/670260c3c4b5ed34a0674561355f3d4ce7fcbdf09a667e5bc841526d271c/numcodecs-0.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12f43fa4a347d1dba775c4506a1c9b15b90144c258433b81f79f1c1b1a990db5", size = 8316365 }, + { url = "https://files.pythonhosted.org/packages/bb/fa/94e022419c751a60ff0f53642ebae5ef81ed3cc3640f958588e3ad3dc18d/numcodecs-0.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44869ef564a50aa545215c6a0d42ba5bbc34e9715523fb2336ada3d1fb2b331d", size = 8846228 }, + { url = "https://files.pythonhosted.org/packages/71/60/f23733589f3e059bf8589508acd23ffeec230bdf179f138a54f5ab16e0a6/numcodecs-0.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:9aae6996172ba10c5f5111b2998709071b5aeba6b58b1ee0b26b61ed6aa7f2f4", size = 806260 }, + { url = "https://files.pythonhosted.org/packages/3c/d5/d3536d06ac1e5fb848a3186958204082b68b106364c9a3669652dd786731/numcodecs-0.16.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:947406b01c20f2ce7ce2e631e7f21b782e8a9d4b57b374a41c9e7b1341a8f3a2", size = 1677129 }, + { url = "https://files.pythonhosted.org/packages/e1/fd/b0513a3428dc2b38ec85eea771703ae69c49f09b9650d6c44c9105c80073/numcodecs-0.16.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7cf50e351398a34b45817974c411527629e88937b7683695e276afd65da6ed6f", size = 1159058 }, + { url = "https://files.pythonhosted.org/packages/98/05/b7c127283cfb154a97abb284363825401b69302d71a28608af66f73257cc/numcodecs-0.16.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7938502fcc060ed9543814f38ca67048b33d7bd2667756e36e6b1060455b17e", size = 8260987 }, + { url = "https://files.pythonhosted.org/packages/ff/46/320d960aff884bc63abaaf846ffa3de4803e83e8070b6f84c5688464839c/numcodecs-0.16.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:010d628c95be1214536fb22c0df4ced58da954b404b1fcb25ddebf64e4a3f7f3", size = 8805295 }, + { url = "https://files.pythonhosted.org/packages/31/ae/acc2e0f1f49ba32afa2174578f170673139248ef86f77e334f2619133867/numcodecs-0.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:e83115e3c32de798c7b7164503e06aae9f9746c1cef564d029616eb44bd6cd90", size = 803204 }, ] [package.optional-dependencies] @@ -1794,81 +1794,81 @@ crc32c = [ name = "numpy" version = "2.3.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641, upload-time = "2025-11-16T22:49:19.336Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ea/25e26fa5837106cde46ae7d0b667e20f69cbbc0efd64cba8221411ab26ae/numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218", size = 12528324, upload-time = "2025-11-16T22:49:22.582Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1a/e85f0eea4cf03d6a0228f5c0256b53f2df4bc794706e7df019fc622e47f1/numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d", size = 5356872, upload-time = "2025-11-16T22:49:25.408Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148, upload-time = "2025-11-16T22:49:27.549Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2b/05bbeb06e2dff5eab512dfc678b1cc5ee94d8ac5956a0885c64b6b26252b/numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7", size = 14557282, upload-time = "2025-11-16T22:49:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/65/fb/2b23769462b34398d9326081fad5655198fcf18966fcb1f1e49db44fbf31/numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4", size = 16897903, upload-time = "2025-11-16T22:49:34.191Z" }, - { url = "https://files.pythonhosted.org/packages/ac/14/085f4cf05fc3f1e8aa95e85404e984ffca9b2275a5dc2b1aae18a67538b8/numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e", size = 16341672, upload-time = "2025-11-16T22:49:37.2Z" }, - { url = "https://files.pythonhosted.org/packages/6f/3b/1f73994904142b2aa290449b3bb99772477b5fd94d787093e4f24f5af763/numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748", size = 18838896, upload-time = "2025-11-16T22:49:39.727Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b9/cf6649b2124f288309ffc353070792caf42ad69047dcc60da85ee85fea58/numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c", size = 6563608, upload-time = "2025-11-16T22:49:42.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/44/9fe81ae1dcc29c531843852e2874080dc441338574ccc4306b39e2ff6e59/numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c", size = 13078442, upload-time = "2025-11-16T22:49:43.99Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a7/f99a41553d2da82a20a2f22e93c94f928e4490bb447c9ff3c4ff230581d3/numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa", size = 10458555, upload-time = "2025-11-16T22:49:47.092Z" }, - { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" }, - { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" }, - { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" }, - { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" }, - { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" }, - { url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251, upload-time = "2025-11-16T22:50:19.013Z" }, - { url = "https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188", size = 12254652, upload-time = "2025-11-16T22:50:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/78/da/8c7738060ca9c31b30e9301ee0cf6c5ffdbf889d9593285a1cead337f9a5/numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0", size = 5083172, upload-time = "2025-11-16T22:50:24.562Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990, upload-time = "2025-11-16T22:50:26.47Z" }, - { url = "https://files.pythonhosted.org/packages/95/03/dc0723a013c7d7c19de5ef29e932c3081df1c14ba582b8b86b5de9db7f0f/numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d", size = 14248902, upload-time = "2025-11-16T22:50:28.861Z" }, - { url = "https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017", size = 16597430, upload-time = "2025-11-16T22:50:31.56Z" }, - { url = "https://files.pythonhosted.org/packages/2a/51/c1e29be863588db58175175f057286900b4b3327a1351e706d5e0f8dd679/numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf", size = 16024551, upload-time = "2025-11-16T22:50:34.242Z" }, - { url = "https://files.pythonhosted.org/packages/83/68/8236589d4dbb87253d28259d04d9b814ec0ecce7cb1c7fed29729f4c3a78/numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce", size = 18533275, upload-time = "2025-11-16T22:50:37.651Z" }, - { url = "https://files.pythonhosted.org/packages/40/56/2932d75b6f13465239e3b7b7e511be27f1b8161ca2510854f0b6e521c395/numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e", size = 6277637, upload-time = "2025-11-16T22:50:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/0c/88/e2eaa6cffb115b85ed7c7c87775cb8bcf0816816bc98ca8dbfa2ee33fe6e/numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b", size = 12779090, upload-time = "2025-11-16T22:50:42.503Z" }, - { url = "https://files.pythonhosted.org/packages/8f/88/3f41e13a44ebd4034ee17baa384acac29ba6a4fcc2aca95f6f08ca0447d1/numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae", size = 10194710, upload-time = "2025-11-16T22:50:44.971Z" }, - { url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292, upload-time = "2025-11-16T22:50:47.715Z" }, - { url = "https://files.pythonhosted.org/packages/71/80/ba9dc6f2a4398e7f42b708a7fdc841bb638d353be255655498edbf9a15a8/numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f", size = 12378897, upload-time = "2025-11-16T22:50:51.327Z" }, - { url = "https://files.pythonhosted.org/packages/2e/6d/db2151b9f64264bcceccd51741aa39b50150de9b602d98ecfe7e0c4bff39/numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a", size = 5207391, upload-time = "2025-11-16T22:50:54.542Z" }, - { url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275, upload-time = "2025-11-16T22:50:56.794Z" }, - { url = "https://files.pythonhosted.org/packages/74/5b/1919abf32d8722646a38cd527bc3771eb229a32724ee6ba340ead9b92249/numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e", size = 14306855, upload-time = "2025-11-16T22:50:59.208Z" }, - { url = "https://files.pythonhosted.org/packages/a5/87/6831980559434973bebc30cd9c1f21e541a0f2b0c280d43d3afd909b66d0/numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9", size = 16657359, upload-time = "2025-11-16T22:51:01.991Z" }, - { url = "https://files.pythonhosted.org/packages/dd/91/c797f544491ee99fd00495f12ebb7802c440c1915811d72ac5b4479a3356/numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946", size = 16093374, upload-time = "2025-11-16T22:51:05.291Z" }, - { url = "https://files.pythonhosted.org/packages/74/a6/54da03253afcbe7a72785ec4da9c69fb7a17710141ff9ac5fcb2e32dbe64/numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1", size = 18594587, upload-time = "2025-11-16T22:51:08.585Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/aff53abbdd41b0ecca94285f325aff42357c6b5abc482a3fcb4994290b18/numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3", size = 6405940, upload-time = "2025-11-16T22:51:11.541Z" }, - { url = "https://files.pythonhosted.org/packages/d5/81/50613fec9d4de5480de18d4f8ef59ad7e344d497edbef3cfd80f24f98461/numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234", size = 12920341, upload-time = "2025-11-16T22:51:14.312Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ab/08fd63b9a74303947f34f0bd7c5903b9c5532c2d287bead5bdf4c556c486/numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7", size = 10262507, upload-time = "2025-11-16T22:51:16.846Z" }, - { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" }, - { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" }, - { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" }, - { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" }, - { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" }, - { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" }, - { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" }, - { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" }, - { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" }, - { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689, upload-time = "2025-11-16T22:52:23.247Z" }, - { url = "https://files.pythonhosted.org/packages/00/4f/edb00032a8fb92ec0a679d3830368355da91a69cab6f3e9c21b64d0bb986/numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c", size = 12457053, upload-time = "2025-11-16T22:52:26.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/a4/e8a53b5abd500a63836a29ebe145fc1ab1f2eefe1cfe59276020373ae0aa/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18", size = 5285635, upload-time = "2025-11-16T22:52:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770, upload-time = "2025-11-16T22:52:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e4/68d2f474df2cb671b2b6c2986a02e520671295647dad82484cde80ca427b/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb", size = 14391768, upload-time = "2025-11-16T22:52:33.593Z" }, - { url = "https://files.pythonhosted.org/packages/b8/50/94ccd8a2b141cb50651fddd4f6a48874acb3c91c8f0842b08a6afc4b0b21/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7", size = 16729263, upload-time = "2025-11-16T22:52:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641 }, + { url = "https://files.pythonhosted.org/packages/2a/ea/25e26fa5837106cde46ae7d0b667e20f69cbbc0efd64cba8221411ab26ae/numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218", size = 12528324 }, + { url = "https://files.pythonhosted.org/packages/4d/1a/e85f0eea4cf03d6a0228f5c0256b53f2df4bc794706e7df019fc622e47f1/numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d", size = 5356872 }, + { url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148 }, + { url = "https://files.pythonhosted.org/packages/f2/2b/05bbeb06e2dff5eab512dfc678b1cc5ee94d8ac5956a0885c64b6b26252b/numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7", size = 14557282 }, + { url = "https://files.pythonhosted.org/packages/65/fb/2b23769462b34398d9326081fad5655198fcf18966fcb1f1e49db44fbf31/numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4", size = 16897903 }, + { url = "https://files.pythonhosted.org/packages/ac/14/085f4cf05fc3f1e8aa95e85404e984ffca9b2275a5dc2b1aae18a67538b8/numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e", size = 16341672 }, + { url = "https://files.pythonhosted.org/packages/6f/3b/1f73994904142b2aa290449b3bb99772477b5fd94d787093e4f24f5af763/numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748", size = 18838896 }, + { url = "https://files.pythonhosted.org/packages/cd/b9/cf6649b2124f288309ffc353070792caf42ad69047dcc60da85ee85fea58/numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c", size = 6563608 }, + { url = "https://files.pythonhosted.org/packages/aa/44/9fe81ae1dcc29c531843852e2874080dc441338574ccc4306b39e2ff6e59/numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c", size = 13078442 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/f99a41553d2da82a20a2f22e93c94f928e4490bb447c9ff3c4ff230581d3/numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa", size = 10458555 }, + { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873 }, + { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838 }, + { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378 }, + { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559 }, + { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702 }, + { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086 }, + { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985 }, + { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976 }, + { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274 }, + { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922 }, + { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667 }, + { url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251 }, + { url = "https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188", size = 12254652 }, + { url = "https://files.pythonhosted.org/packages/78/da/8c7738060ca9c31b30e9301ee0cf6c5ffdbf889d9593285a1cead337f9a5/numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0", size = 5083172 }, + { url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990 }, + { url = "https://files.pythonhosted.org/packages/95/03/dc0723a013c7d7c19de5ef29e932c3081df1c14ba582b8b86b5de9db7f0f/numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d", size = 14248902 }, + { url = "https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017", size = 16597430 }, + { url = "https://files.pythonhosted.org/packages/2a/51/c1e29be863588db58175175f057286900b4b3327a1351e706d5e0f8dd679/numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf", size = 16024551 }, + { url = "https://files.pythonhosted.org/packages/83/68/8236589d4dbb87253d28259d04d9b814ec0ecce7cb1c7fed29729f4c3a78/numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce", size = 18533275 }, + { url = "https://files.pythonhosted.org/packages/40/56/2932d75b6f13465239e3b7b7e511be27f1b8161ca2510854f0b6e521c395/numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e", size = 6277637 }, + { url = "https://files.pythonhosted.org/packages/0c/88/e2eaa6cffb115b85ed7c7c87775cb8bcf0816816bc98ca8dbfa2ee33fe6e/numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b", size = 12779090 }, + { url = "https://files.pythonhosted.org/packages/8f/88/3f41e13a44ebd4034ee17baa384acac29ba6a4fcc2aca95f6f08ca0447d1/numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae", size = 10194710 }, + { url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292 }, + { url = "https://files.pythonhosted.org/packages/71/80/ba9dc6f2a4398e7f42b708a7fdc841bb638d353be255655498edbf9a15a8/numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f", size = 12378897 }, + { url = "https://files.pythonhosted.org/packages/2e/6d/db2151b9f64264bcceccd51741aa39b50150de9b602d98ecfe7e0c4bff39/numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a", size = 5207391 }, + { url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275 }, + { url = "https://files.pythonhosted.org/packages/74/5b/1919abf32d8722646a38cd527bc3771eb229a32724ee6ba340ead9b92249/numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e", size = 14306855 }, + { url = "https://files.pythonhosted.org/packages/a5/87/6831980559434973bebc30cd9c1f21e541a0f2b0c280d43d3afd909b66d0/numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9", size = 16657359 }, + { url = "https://files.pythonhosted.org/packages/dd/91/c797f544491ee99fd00495f12ebb7802c440c1915811d72ac5b4479a3356/numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946", size = 16093374 }, + { url = "https://files.pythonhosted.org/packages/74/a6/54da03253afcbe7a72785ec4da9c69fb7a17710141ff9ac5fcb2e32dbe64/numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1", size = 18594587 }, + { url = "https://files.pythonhosted.org/packages/80/e9/aff53abbdd41b0ecca94285f325aff42357c6b5abc482a3fcb4994290b18/numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3", size = 6405940 }, + { url = "https://files.pythonhosted.org/packages/d5/81/50613fec9d4de5480de18d4f8ef59ad7e344d497edbef3cfd80f24f98461/numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234", size = 12920341 }, + { url = "https://files.pythonhosted.org/packages/bb/ab/08fd63b9a74303947f34f0bd7c5903b9c5532c2d287bead5bdf4c556c486/numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7", size = 10262507 }, + { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706 }, + { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507 }, + { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049 }, + { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603 }, + { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696 }, + { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350 }, + { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190 }, + { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749 }, + { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432 }, + { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388 }, + { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651 }, + { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503 }, + { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612 }, + { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042 }, + { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502 }, + { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962 }, + { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054 }, + { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613 }, + { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147 }, + { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806 }, + { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760 }, + { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459 }, + { url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689 }, + { url = "https://files.pythonhosted.org/packages/00/4f/edb00032a8fb92ec0a679d3830368355da91a69cab6f3e9c21b64d0bb986/numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c", size = 12457053 }, + { url = "https://files.pythonhosted.org/packages/16/a4/e8a53b5abd500a63836a29ebe145fc1ab1f2eefe1cfe59276020373ae0aa/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18", size = 5285635 }, + { url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770 }, + { url = "https://files.pythonhosted.org/packages/7d/e4/68d2f474df2cb671b2b6c2986a02e520671295647dad82484cde80ca427b/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb", size = 14391768 }, + { url = "https://files.pythonhosted.org/packages/b8/50/94ccd8a2b141cb50651fddd4f6a48874acb3c91c8f0842b08a6afc4b0b21/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7", size = 16729263 }, + { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213 }, ] [[package]] @@ -1876,7 +1876,7 @@ name = "nvidia-cublas-cu12" version = "12.8.4.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921 }, ] [[package]] @@ -1884,7 +1884,7 @@ name = "nvidia-cuda-cupti-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621 }, ] [[package]] @@ -1892,7 +1892,7 @@ name = "nvidia-cuda-nvrtc-cu12" version = "12.8.93" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029 }, ] [[package]] @@ -1900,7 +1900,7 @@ name = "nvidia-cuda-runtime-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765 }, ] [[package]] @@ -1911,7 +1911,7 @@ dependencies = [ { name = "nvidia-cublas-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467 }, ] [[package]] @@ -1922,7 +1922,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695 }, ] [[package]] @@ -1930,7 +1930,7 @@ name = "nvidia-cufile-cu12" version = "1.13.1.3" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834 }, ] [[package]] @@ -1938,7 +1938,7 @@ name = "nvidia-curand-cu12" version = "10.3.9.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976 }, ] [[package]] @@ -1951,7 +1951,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905 }, ] [[package]] @@ -1962,7 +1962,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466 }, ] [[package]] @@ -1970,7 +1970,7 @@ name = "nvidia-cusparselt-cu12" version = "0.7.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691 }, ] [[package]] @@ -1978,7 +1978,7 @@ name = "nvidia-nccl-cu12" version = "2.27.5" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229 }, ] [[package]] @@ -1986,7 +1986,7 @@ name = "nvidia-nvjitlink-cu12" version = "12.8.93" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836 }, ] [[package]] @@ -1994,7 +1994,7 @@ name = "nvidia-nvshmem-cu12" version = "3.3.20" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5", size = 124657145, upload-time = "2025-08-04T20:25:19.995Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5", size = 124657145 }, ] [[package]] @@ -2002,7 +2002,7 @@ name = "nvidia-nvtx-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954 }, ] [[package]] @@ -2018,45 +2018,45 @@ dependencies = [ { name = "sqlalchemy" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/81/08f90f194eed78178064a9383432eca95611e2c5331e7b01e2418ce4b15a/optuna-4.6.0.tar.gz", hash = "sha256:89e38c2447c7f793a726617b8043f01e31f0bad54855040db17eb3b49404a369", size = 477444, upload-time = "2025-11-10T05:14:30.151Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/81/08f90f194eed78178064a9383432eca95611e2c5331e7b01e2418ce4b15a/optuna-4.6.0.tar.gz", hash = "sha256:89e38c2447c7f793a726617b8043f01e31f0bad54855040db17eb3b49404a369", size = 477444 } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/de/3d8455b08cb6312f8cc46aacdf16c71d4d881a1db4a4140fc5ef31108422/optuna-4.6.0-py3-none-any.whl", hash = "sha256:4c3a9facdef2b2dd7e3e2a8ae3697effa70fae4056fcf3425cfc6f5a40feb069", size = 404708, upload-time = "2025-11-10T05:14:28.6Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/3d8455b08cb6312f8cc46aacdf16c71d4d881a1db4a4140fc5ef31108422/optuna-4.6.0-py3-none-any.whl", hash = "sha256:4c3a9facdef2b2dd7e3e2a8ae3697effa70fae4056fcf3425cfc6f5a40feb069", size = 404708 }, ] [[package]] name = "overrides" version = "7.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832 }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, ] [[package]] name = "pandocfilters" version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663 }, ] [[package]] name = "parso" version = "0.8.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205 } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668 }, ] [[package]] @@ -2067,9 +2067,9 @@ dependencies = [ { name = "locket" }, { name = "toolz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/3a/3f06f34820a31257ddcabdfafc2672c5816be79c7e353b02c1f318daa7d4/partd-1.4.2.tar.gz", hash = "sha256:d022c33afbdc8405c226621b015e8067888173d85f7f5ecebb3cafed9a20f02c", size = 21029, upload-time = "2024-05-06T19:51:41.945Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/3a/3f06f34820a31257ddcabdfafc2672c5816be79c7e353b02c1f318daa7d4/partd-1.4.2.tar.gz", hash = "sha256:d022c33afbdc8405c226621b015e8067888173d85f7f5ecebb3cafed9a20f02c", size = 21029 } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905, upload-time = "2024-05-06T19:51:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905 }, ] [[package]] @@ -2079,96 +2079,96 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ptyprocess" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, ] [[package]] name = "pillow" version = "12.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, - { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, - { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, - { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, - { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, - { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, - { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, - { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, - { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798 }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589 }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472 }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887 }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964 }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756 }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075 }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955 }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440 }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256 }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025 }, + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377 }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343 }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981 }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399 }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740 }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201 }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334 }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162 }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769 }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107 }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012 }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493 }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461 }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912 }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132 }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099 }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808 }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804 }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553 }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729 }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789 }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917 }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391 }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477 }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918 }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406 }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218 }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564 }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260 }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248 }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043 }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915 }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998 }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201 }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165 }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834 }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531 }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554 }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812 }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689 }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186 }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308 }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222 }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657 }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482 }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416 }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584 }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621 }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916 }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836 }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092 }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158 }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882 }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001 }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146 }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344 }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864 }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911 }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045 }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282 }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630 }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068 }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994 }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639 }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839 }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505 }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654 }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850 }, ] [[package]] @@ -2181,27 +2181,27 @@ dependencies = [ { name = "platformdirs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/74/bc3f671997158aef171194c3c4041e549946f4784b8690baa0626a0a164b/pint-0.25.2.tar.gz", hash = "sha256:85a45d1da8fe9c9f7477fed8aef59ad2b939af3d6611507e1a9cbdacdcd3450a", size = 254467, upload-time = "2025-11-06T22:08:09.184Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/74/bc3f671997158aef171194c3c4041e549946f4784b8690baa0626a0a164b/pint-0.25.2.tar.gz", hash = "sha256:85a45d1da8fe9c9f7477fed8aef59ad2b939af3d6611507e1a9cbdacdcd3450a", size = 254467 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/88/550d41e81e6d43335603a960cd9c75c1d88f9cf01bc9d4ee8e86290aba7d/pint-0.25.2-py3-none-any.whl", hash = "sha256:ca35ab1d8eeeb6f7d9942b3cb5f34ca42b61cdd5fb3eae79531553dcca04dda7", size = 306762, upload-time = "2025-11-06T22:08:07.745Z" }, + { url = "https://files.pythonhosted.org/packages/ab/88/550d41e81e6d43335603a960cd9c75c1d88f9cf01bc9d4ee8e86290aba7d/pint-0.25.2-py3-none-any.whl", hash = "sha256:ca35ab1d8eeeb6f7d9942b3cb5f34ca42b61cdd5fb3eae79531553dcca04dda7", size = 306762 }, ] [[package]] name = "platformdirs" version = "4.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632 } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651 }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] [[package]] @@ -2215,18 +2215,18 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501 } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049 }, ] [[package]] name = "prometheus-client" version = "0.23.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145 }, ] [[package]] @@ -2236,95 +2236,95 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, ] [[package]] name = "protobuf" version = "6.33.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/03/a1440979a3f74f16cab3b75b0da1a1a7f922d56a8ddea96092391998edc0/protobuf-6.33.1.tar.gz", hash = "sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b", size = 443432, upload-time = "2025-11-13T16:44:18.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/03/a1440979a3f74f16cab3b75b0da1a1a7f922d56a8ddea96092391998edc0/protobuf-6.33.1.tar.gz", hash = "sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b", size = 443432 } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/f1/446a9bbd2c60772ca36556bac8bfde40eceb28d9cc7838755bc41e001d8f/protobuf-6.33.1-cp310-abi3-win32.whl", hash = "sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b", size = 425593, upload-time = "2025-11-13T16:44:06.275Z" }, - { url = "https://files.pythonhosted.org/packages/a6/79/8780a378c650e3df849b73de8b13cf5412f521ca2ff9b78a45c247029440/protobuf-6.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed", size = 436883, upload-time = "2025-11-13T16:44:09.222Z" }, - { url = "https://files.pythonhosted.org/packages/cd/93/26213ff72b103ae55bb0d73e7fb91ea570ef407c3ab4fd2f1f27cac16044/protobuf-6.33.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490", size = 427522, upload-time = "2025-11-13T16:44:10.475Z" }, - { url = "https://files.pythonhosted.org/packages/c2/32/df4a35247923393aa6b887c3b3244a8c941c32a25681775f96e2b418f90e/protobuf-6.33.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178", size = 324445, upload-time = "2025-11-13T16:44:11.869Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d0/d796e419e2ec93d2f3fa44888861c3f88f722cde02b7c3488fcc6a166820/protobuf-6.33.1-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53", size = 339161, upload-time = "2025-11-13T16:44:12.778Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2a/3c5f05a4af06649547027d288747f68525755de692a26a7720dced3652c0/protobuf-6.33.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1", size = 323171, upload-time = "2025-11-13T16:44:14.035Z" }, - { url = "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa", size = 170477, upload-time = "2025-11-13T16:44:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/06/f1/446a9bbd2c60772ca36556bac8bfde40eceb28d9cc7838755bc41e001d8f/protobuf-6.33.1-cp310-abi3-win32.whl", hash = "sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b", size = 425593 }, + { url = "https://files.pythonhosted.org/packages/a6/79/8780a378c650e3df849b73de8b13cf5412f521ca2ff9b78a45c247029440/protobuf-6.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed", size = 436883 }, + { url = "https://files.pythonhosted.org/packages/cd/93/26213ff72b103ae55bb0d73e7fb91ea570ef407c3ab4fd2f1f27cac16044/protobuf-6.33.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490", size = 427522 }, + { url = "https://files.pythonhosted.org/packages/c2/32/df4a35247923393aa6b887c3b3244a8c941c32a25681775f96e2b418f90e/protobuf-6.33.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178", size = 324445 }, + { url = "https://files.pythonhosted.org/packages/8e/d0/d796e419e2ec93d2f3fa44888861c3f88f722cde02b7c3488fcc6a166820/protobuf-6.33.1-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53", size = 339161 }, + { url = "https://files.pythonhosted.org/packages/1d/2a/3c5f05a4af06649547027d288747f68525755de692a26a7720dced3652c0/protobuf-6.33.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1", size = 323171 }, + { url = "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa", size = 170477 }, ] [[package]] name = "psutil" version = "7.1.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, - { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, - { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, - { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, - { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, - { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, - { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, - { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751 }, + { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368 }, + { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134 }, + { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904 }, + { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642 }, + { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518 }, + { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843 }, + { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369 }, + { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210 }, + { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182 }, + { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466 }, + { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756 }, + { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359 }, + { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171 }, + { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261 }, + { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635 }, + { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633 }, + { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608 }, ] [[package]] name = "ptyprocess" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, ] [[package]] name = "pure-eval" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, ] [[package]] name = "pycparser" version = "2.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140 }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] [[package]] name = "pyparsing" version = "3.2.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274 } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890 }, ] [[package]] @@ -2338,27 +2338,27 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668 }, ] [[package]] name = "python-box" version = "7.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/f7/635eed8c500adf26208e86e985bbffb6ff039cd8950e3a4749ceca904218/python_box-7.3.2.tar.gz", hash = "sha256:028b9917129e67f311932d93347b8a4f1b500d7a5a2870ee3c035f4e7b19403b", size = 45771, upload-time = "2025-01-16T19:10:05.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/f7/635eed8c500adf26208e86e985bbffb6ff039cd8950e3a4749ceca904218/python_box-7.3.2.tar.gz", hash = "sha256:028b9917129e67f311932d93347b8a4f1b500d7a5a2870ee3c035f4e7b19403b", size = 45771 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/3f/133619c00d8a9d4f86efd8626c0e4ec356c8b8dabac66da18dac5cfaf70c/python_box-7.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:32163b1cb151883de0da62b0cd3572610dc72ccf0762f2447baf1d2562e25bea", size = 1834122, upload-time = "2025-01-16T19:10:27.886Z" }, - { url = "https://files.pythonhosted.org/packages/b7/52/51b6081562daa864847692536260200b337ccb4176d1e5f626ae48a7d493/python_box-7.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:064cb59b41e25aaf7dbd39efe53151a5f6797cc1cb3c68610f0f21a9d406d67e", size = 4305556, upload-time = "2025-01-16T19:15:29.286Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e2/6cdc8649381ae14def88c3e2e93d5b8b17a622a95896e0d1c92861270b7d/python_box-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:488f0fba9a6416c3334b602366dcd92825adb0811e07e03753dfcf0ed79cd6f7", size = 1232328, upload-time = "2025-01-16T19:11:37.676Z" }, - { url = "https://files.pythonhosted.org/packages/45/68/0c2f289d8055d3e1b156ff258847f0e8f1010063e284cf5a612f09435575/python_box-7.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39009a2da5c20133718b24891a206592adbe09169856aedc450ad1600fc2e511", size = 1819681, upload-time = "2025-01-16T19:10:25.187Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5d/76b4d6d0e41edb676a229f032848a1ecea166890fa8d501513ea1a030f4d/python_box-7.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2a72e2f6fb97c7e472ff3272da207ecc615aa222e52e98352391428527c469", size = 4270424, upload-time = "2025-01-16T19:15:32.376Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6b/32484b2a3cd2fb5e5f56bfb53a4537d93a4d2014ccf7fc0c0017fa6f65e9/python_box-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9eead914b9fb7d98a1473f5027dcfe27d26b3a10ffa33b9ba22cf948a23cd280", size = 1211252, upload-time = "2025-01-16T19:11:00.248Z" }, - { url = "https://files.pythonhosted.org/packages/2f/39/8bec609e93dbc5e0d3ea26cfb5af3ca78915f7a55ef5414713462fedeb59/python_box-7.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1dfc3b9b073f3d7cad1fa90de98eaaa684a494d0574bbc0666f74fa8307fd6b6", size = 1804675, upload-time = "2025-01-16T19:10:23.281Z" }, - { url = "https://files.pythonhosted.org/packages/88/ae/baf3a8057d8129896a7e02619df43ea0d918fc5b2bb66eb6e2470595fbac/python_box-7.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca4685a7f764b5a71b6e08535ce2a96b7964bb63d8cb4df10f6bb7147b6c54b", size = 4265645, upload-time = "2025-01-16T19:15:34.087Z" }, - { url = "https://files.pythonhosted.org/packages/43/90/72367e03033c11a5e82676ee389b572bf136647ff4e3081557392b37e1ad/python_box-7.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e143295f74d47a9ab24562ead2375c9be10629599b57f2e86717d3fff60f82a9", size = 1206740, upload-time = "2025-01-16T19:11:30.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/13/8a990c6e2b6cc12700dce16f3cb383324e6d9a30f604eca22a2fdf84c923/python_box-7.3.2-py3-none-any.whl", hash = "sha256:fd7d74d5a848623f93b5221fd9fb00b8c00ff0e130fa87f396277aa188659c92", size = 29479, upload-time = "2025-01-16T19:10:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3f/133619c00d8a9d4f86efd8626c0e4ec356c8b8dabac66da18dac5cfaf70c/python_box-7.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:32163b1cb151883de0da62b0cd3572610dc72ccf0762f2447baf1d2562e25bea", size = 1834122 }, + { url = "https://files.pythonhosted.org/packages/b7/52/51b6081562daa864847692536260200b337ccb4176d1e5f626ae48a7d493/python_box-7.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:064cb59b41e25aaf7dbd39efe53151a5f6797cc1cb3c68610f0f21a9d406d67e", size = 4305556 }, + { url = "https://files.pythonhosted.org/packages/d4/e2/6cdc8649381ae14def88c3e2e93d5b8b17a622a95896e0d1c92861270b7d/python_box-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:488f0fba9a6416c3334b602366dcd92825adb0811e07e03753dfcf0ed79cd6f7", size = 1232328 }, + { url = "https://files.pythonhosted.org/packages/45/68/0c2f289d8055d3e1b156ff258847f0e8f1010063e284cf5a612f09435575/python_box-7.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39009a2da5c20133718b24891a206592adbe09169856aedc450ad1600fc2e511", size = 1819681 }, + { url = "https://files.pythonhosted.org/packages/ce/5d/76b4d6d0e41edb676a229f032848a1ecea166890fa8d501513ea1a030f4d/python_box-7.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2a72e2f6fb97c7e472ff3272da207ecc615aa222e52e98352391428527c469", size = 4270424 }, + { url = "https://files.pythonhosted.org/packages/e4/6b/32484b2a3cd2fb5e5f56bfb53a4537d93a4d2014ccf7fc0c0017fa6f65e9/python_box-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9eead914b9fb7d98a1473f5027dcfe27d26b3a10ffa33b9ba22cf948a23cd280", size = 1211252 }, + { url = "https://files.pythonhosted.org/packages/2f/39/8bec609e93dbc5e0d3ea26cfb5af3ca78915f7a55ef5414713462fedeb59/python_box-7.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1dfc3b9b073f3d7cad1fa90de98eaaa684a494d0574bbc0666f74fa8307fd6b6", size = 1804675 }, + { url = "https://files.pythonhosted.org/packages/88/ae/baf3a8057d8129896a7e02619df43ea0d918fc5b2bb66eb6e2470595fbac/python_box-7.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca4685a7f764b5a71b6e08535ce2a96b7964bb63d8cb4df10f6bb7147b6c54b", size = 4265645 }, + { url = "https://files.pythonhosted.org/packages/43/90/72367e03033c11a5e82676ee389b572bf136647ff4e3081557392b37e1ad/python_box-7.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e143295f74d47a9ab24562ead2375c9be10629599b57f2e86717d3fff60f82a9", size = 1206740 }, + { url = "https://files.pythonhosted.org/packages/37/13/8a990c6e2b6cc12700dce16f3cb383324e6d9a30f604eca22a2fdf84c923/python_box-7.3.2-py3-none-any.whl", hash = "sha256:fd7d74d5a848623f93b5221fd9fb00b8c00ff0e130fa87f396277aa188659c92", size = 29479 }, ] [[package]] @@ -2368,87 +2368,87 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] [[package]] name = "python-json-logger" version = "4.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683 } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548 }, ] [[package]] name = "pywinpty" version = "3.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/bb/a7cc2967c5c4eceb6cc49cfe39447d4bfc56e6c865e7c2249b6eb978935f/pywinpty-3.0.2.tar.gz", hash = "sha256:1505cc4cb248af42cb6285a65c9c2086ee9e7e574078ee60933d5d7fa86fb004", size = 30669, upload-time = "2025-10-03T21:16:29.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/bb/a7cc2967c5c4eceb6cc49cfe39447d4bfc56e6c865e7c2249b6eb978935f/pywinpty-3.0.2.tar.gz", hash = "sha256:1505cc4cb248af42cb6285a65c9c2086ee9e7e574078ee60933d5d7fa86fb004", size = 30669 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a1/409c1651c9f874d598c10f51ff586c416625601df4bca315d08baec4c3e3/pywinpty-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:327790d70e4c841ebd9d0f295a780177149aeb405bca44c7115a3de5c2054b23", size = 2050304, upload-time = "2025-10-03T21:19:29.466Z" }, - { url = "https://files.pythonhosted.org/packages/02/4e/1098484e042c9485f56f16eb2b69b43b874bd526044ee401512234cf9e04/pywinpty-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:99fdd9b455f0ad6419aba6731a7a0d2f88ced83c3c94a80ff9533d95fa8d8a9e", size = 2050391, upload-time = "2025-10-03T21:19:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/fc/19/b757fe28008236a4a713e813283721b8a40aa60cd7d3f83549f2e25a3155/pywinpty-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:18f78b81e4cfee6aabe7ea8688441d30247b73e52cd9657138015c5f4ee13a51", size = 2050057, upload-time = "2025-10-03T21:19:26.732Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/cbae12ecf6f4fa4129c36871fd09c6bef4f98d5f625ecefb5e2449765508/pywinpty-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:663383ecfab7fc382cc97ea5c4f7f0bb32c2f889259855df6ea34e5df42d305b", size = 2049874, upload-time = "2025-10-03T21:18:53.923Z" }, - { url = "https://files.pythonhosted.org/packages/ca/15/f12c6055e2d7a617d4d5820e8ac4ceaff849da4cb124640ef5116a230771/pywinpty-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:28297cecc37bee9f24d8889e47231972d6e9e84f7b668909de54f36ca785029a", size = 2050386, upload-time = "2025-10-03T21:18:50.477Z" }, - { url = "https://files.pythonhosted.org/packages/de/24/c6907c5bb06043df98ad6a0a0ff5db2e0affcecbc3b15c42404393a3f72a/pywinpty-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:34b55ae9a1b671fe3eae071d86618110538e8eaad18fcb1531c0830b91a82767", size = 2049834, upload-time = "2025-10-03T21:19:25.688Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/409c1651c9f874d598c10f51ff586c416625601df4bca315d08baec4c3e3/pywinpty-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:327790d70e4c841ebd9d0f295a780177149aeb405bca44c7115a3de5c2054b23", size = 2050304 }, + { url = "https://files.pythonhosted.org/packages/02/4e/1098484e042c9485f56f16eb2b69b43b874bd526044ee401512234cf9e04/pywinpty-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:99fdd9b455f0ad6419aba6731a7a0d2f88ced83c3c94a80ff9533d95fa8d8a9e", size = 2050391 }, + { url = "https://files.pythonhosted.org/packages/fc/19/b757fe28008236a4a713e813283721b8a40aa60cd7d3f83549f2e25a3155/pywinpty-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:18f78b81e4cfee6aabe7ea8688441d30247b73e52cd9657138015c5f4ee13a51", size = 2050057 }, + { url = "https://files.pythonhosted.org/packages/cb/44/cbae12ecf6f4fa4129c36871fd09c6bef4f98d5f625ecefb5e2449765508/pywinpty-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:663383ecfab7fc382cc97ea5c4f7f0bb32c2f889259855df6ea34e5df42d305b", size = 2049874 }, + { url = "https://files.pythonhosted.org/packages/ca/15/f12c6055e2d7a617d4d5820e8ac4ceaff849da4cb124640ef5116a230771/pywinpty-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:28297cecc37bee9f24d8889e47231972d6e9e84f7b668909de54f36ca785029a", size = 2050386 }, + { url = "https://files.pythonhosted.org/packages/de/24/c6907c5bb06043df98ad6a0a0ff5db2e0affcecbc3b15c42404393a3f72a/pywinpty-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:34b55ae9a1b671fe3eae071d86618110538e8eaad18fcb1531c0830b91a82767", size = 2049834 }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, ] [[package]] @@ -2458,55 +2458,55 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, - { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, - { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, - { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, - { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, - { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, - { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328 }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803 }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836 }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038 }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531 }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786 }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220 }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155 }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428 }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497 }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279 }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645 }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574 }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995 }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070 }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121 }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550 }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184 }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480 }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993 }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436 }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301 }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197 }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275 }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469 }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961 }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282 }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468 }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394 }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964 }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029 }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541 }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197 }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175 }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427 }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929 }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193 }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388 }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316 }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472 }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401 }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170 }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265 }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208 }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747 }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371 }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862 }, ] [[package]] @@ -2535,6 +2535,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "hdf5plugin" }, { name = "jupyterlab" }, { name = "packaging" }, { name = "pre-commit" }, @@ -2569,6 +2570,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "hdf5plugin", specifier = ">=6.0.0" }, { name = "jupyterlab", specifier = ">=4.4.0" }, { name = "packaging", specifier = ">=24.2" }, { name = "pre-commit", specifier = ">=4.2.0" }, @@ -2587,9 +2589,9 @@ dependencies = [ { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, ] [[package]] @@ -2602,9 +2604,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, ] [[package]] @@ -2614,18 +2616,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490 }, ] [[package]] name = "rfc3986-validator" version = "0.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242 }, ] [[package]] @@ -2635,9 +2637,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lark" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046 }, ] [[package]] @@ -2652,163 +2654,163 @@ dependencies = [ { name = "python-dateutil" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/4c/f1560842f8b9aba82973b670eb9a4e57cdfca0d998fd106140c6c74ae1e0/rosettasciio-0.10.0.tar.gz", hash = "sha256:0082ad325029fb815d1da8015f9069df90e979114fcba84bda94a63c2e63039b", size = 1186926, upload-time = "2025-07-27T18:12:15.59Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/db/1d65725ed9fbd1ed9ce89d0bf38aed0a98d5683db13e898f25a2d8b239ca/rosettasciio-0.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:718ec836f2cf884c584be86cd0da188419692d7c32915665738d46ad6b6e3a37", size = 795531, upload-time = "2025-07-27T18:11:37.418Z" }, - { url = "https://files.pythonhosted.org/packages/10/ef/5ffde08333c095978c231dd488e7f05d97ee5187cabe5670df83f96d8403/rosettasciio-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f75142a4bfa4b85384efdf1d69af21ec8d9a83e17dfd3e577166c3a87a796420", size = 789939, upload-time = "2025-07-27T18:11:38.875Z" }, - { url = "https://files.pythonhosted.org/packages/84/ba/bfea3c8aa25b313d01e955821130bf1b4e5cc1eb48ee33fd0ef7db0cbf37/rosettasciio-0.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21a5d7acf1501e979f8439995acc8edb2d24d42b9dc5ecfbe498c624cd84d4ee", size = 1255637, upload-time = "2025-07-27T18:11:40.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/54/dbc75dcd3ff0fd6f0d631d636d6d2410c6c4feac0fd8507d9d133fc4715d/rosettasciio-0.10.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b2ef07b84dfb8a9cb3dfa0cf4e071c6bee2a70cd173293c85522b791e7ceb07", size = 1258334, upload-time = "2025-07-27T18:11:41.872Z" }, - { url = "https://files.pythonhosted.org/packages/ef/77/e8b57c6fde7a01af588448ee7be43b7f29fdf265d00c646eb7b8196653a8/rosettasciio-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1fb511579431dd1c7b7aed6626f469ac2b77ab45f6728c834c3d054913ac71f", size = 787920, upload-time = "2025-07-27T18:11:43.096Z" }, - { url = "https://files.pythonhosted.org/packages/e6/4f/94b8166640de28b7f2c8a98664288bfc674b22a5ba49d9953bb2af1dd932/rosettasciio-0.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3441354fc7cb1d92c5e0bf8eccdcd26a5abc429299625a820087f95daed4cfa2", size = 796549, upload-time = "2025-07-27T18:11:44.407Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/af8159fd1b9dfeb82a47c84587a81d1d98974f05f85e764952862a5206de/rosettasciio-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96e08cd6a373dc794edc268da4c644b3fd93927e2324c5a3721c3dd71d3b592c", size = 789810, upload-time = "2025-07-27T18:11:46.039Z" }, - { url = "https://files.pythonhosted.org/packages/99/9d/5cf650f4c39c649e3c09ff885b6a471aa3a0c57e52e1a538f88156083ca8/rosettasciio-0.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e056f143c5c7e41ca55ee5dc59b5efaa2f14ace6f6f8039d5102f156385f2c9", size = 1257517, upload-time = "2025-07-27T18:11:47.409Z" }, - { url = "https://files.pythonhosted.org/packages/2d/49/d0484376a7724aa8bbc3628d6e4331f0593dcbee3efd3ba193c81cc380f6/rosettasciio-0.10.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:465811041da11db7259f8fde1c72fb6838aaa14678c000feed8719c4f8d2dfe0", size = 1263282, upload-time = "2025-07-27T18:11:49.011Z" }, - { url = "https://files.pythonhosted.org/packages/94/99/a70aeef2b316de92a315c8b6f5d3153fc1b36bbaedc96ad3caf70b45606c/rosettasciio-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:fffc27d62c14e63818d8928d55458fe660f0153c406b4294e3cd4cbd63e1bea7", size = 788436, upload-time = "2025-07-27T18:11:51.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/5f/ef4db517d63a657a9c1d626d65e9c843850fd8da8a364cfb674abe1e6fc4/rosettasciio-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8627f64c027361face099eaad76ea02eb7cfce4f7c66174826f36e236204e970", size = 795544, upload-time = "2025-07-27T18:11:52.664Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/d1590ce68aed0cf1c8e76cd52c1077a5a92f4e14c97d2a7456871296899c/rosettasciio-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c5ca0b541c5744b9b9559f5e002b5f2362d4cb09a002b2650a9d358025477261", size = 788949, upload-time = "2025-07-27T18:11:53.906Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/680259cc71741d9fa0c418230653177cf9bea4f554014a26354ab8079380/rosettasciio-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccf69c1d3e141bca2feca2275158a796f27299888a8ac7943eceec9779f1b811", size = 1249455, upload-time = "2025-07-27T18:11:55.127Z" }, - { url = "https://files.pythonhosted.org/packages/24/4f/8b668c9312ab4d2c87a342e4d947a2c0c10cadda44391bff1154df5a623e/rosettasciio-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a8929f285c2cfdebb5ae180dfe80e90ada916e37063e83d8ea7840be7851ddb", size = 1259454, upload-time = "2025-07-27T18:11:56.502Z" }, - { url = "https://files.pythonhosted.org/packages/ba/5f/07ba8f617b121431b0d8f45f946bd9602256fb1f1d43c275f21fecfe9f27/rosettasciio-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c802e0292537cb0f4878eca631d227183562a7e13c2449fe1e6f23b4eae808ff", size = 788185, upload-time = "2025-07-27T18:11:57.776Z" }, - { url = "https://files.pythonhosted.org/packages/40/2f/4515d05d896b13410c379eb21a6090c61fd654956fae0f54fce108a7379a/rosettasciio-0.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a8ab4c344987364c479232001d2e072f00a855fe3d385cbcbac68641a0c2f802", size = 800276, upload-time = "2025-07-27T18:11:59.083Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/2bd8a60a7758d5a894ae59192846bb72a68bd773b24daed9ea8c737c41bf/rosettasciio-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e8bbf513d81e05242b6ee5d35d58ba06232431990adaff6473bd942518cf2b82", size = 795978, upload-time = "2025-07-27T18:12:00.34Z" }, - { url = "https://files.pythonhosted.org/packages/35/54/dae525b83a98b27fbabe2152045d990b4baa858522024196c5e47d06bbc1/rosettasciio-0.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b34b629ef130aec286062753b44d4e4d73caf1a4d9aba3530ec3801bd49755c", size = 1265997, upload-time = "2025-07-27T18:12:01.634Z" }, - { url = "https://files.pythonhosted.org/packages/06/ae/a5969d089adc71df61451af8665df7e30a34957a0ebdc2614c449d2c37cf/rosettasciio-0.10.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365750bd177c7cff56f88b9f8cf9b59d2c99b52e7d38db142282996e25e552c2", size = 1255782, upload-time = "2025-07-27T18:12:03.883Z" }, - { url = "https://files.pythonhosted.org/packages/d2/38/7e9d8eb167419d9e8b34433b698532b88c4b06ed38cd6723f7e702260f9f/rosettasciio-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a16dbadb662290be0d4ea0a2496109cff4484c155fd12eb6b57345e64d9015d9", size = 798624, upload-time = "2025-07-27T18:12:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/92/a6/a4cff62b7346daca9dfdd1d08c1aed5e0c27283845465b6b5c797fb41713/rosettasciio-0.10.0-py3-none-any.whl", hash = "sha256:a4e353365972865e915b7a689d2f9ba38a6f94aec8cd19c2e35513523b718b1f", size = 550949, upload-time = "2025-07-27T18:12:13.987Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cc/4c/f1560842f8b9aba82973b670eb9a4e57cdfca0d998fd106140c6c74ae1e0/rosettasciio-0.10.0.tar.gz", hash = "sha256:0082ad325029fb815d1da8015f9069df90e979114fcba84bda94a63c2e63039b", size = 1186926 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/db/1d65725ed9fbd1ed9ce89d0bf38aed0a98d5683db13e898f25a2d8b239ca/rosettasciio-0.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:718ec836f2cf884c584be86cd0da188419692d7c32915665738d46ad6b6e3a37", size = 795531 }, + { url = "https://files.pythonhosted.org/packages/10/ef/5ffde08333c095978c231dd488e7f05d97ee5187cabe5670df83f96d8403/rosettasciio-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f75142a4bfa4b85384efdf1d69af21ec8d9a83e17dfd3e577166c3a87a796420", size = 789939 }, + { url = "https://files.pythonhosted.org/packages/84/ba/bfea3c8aa25b313d01e955821130bf1b4e5cc1eb48ee33fd0ef7db0cbf37/rosettasciio-0.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21a5d7acf1501e979f8439995acc8edb2d24d42b9dc5ecfbe498c624cd84d4ee", size = 1255637 }, + { url = "https://files.pythonhosted.org/packages/6e/54/dbc75dcd3ff0fd6f0d631d636d6d2410c6c4feac0fd8507d9d133fc4715d/rosettasciio-0.10.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b2ef07b84dfb8a9cb3dfa0cf4e071c6bee2a70cd173293c85522b791e7ceb07", size = 1258334 }, + { url = "https://files.pythonhosted.org/packages/ef/77/e8b57c6fde7a01af588448ee7be43b7f29fdf265d00c646eb7b8196653a8/rosettasciio-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1fb511579431dd1c7b7aed6626f469ac2b77ab45f6728c834c3d054913ac71f", size = 787920 }, + { url = "https://files.pythonhosted.org/packages/e6/4f/94b8166640de28b7f2c8a98664288bfc674b22a5ba49d9953bb2af1dd932/rosettasciio-0.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3441354fc7cb1d92c5e0bf8eccdcd26a5abc429299625a820087f95daed4cfa2", size = 796549 }, + { url = "https://files.pythonhosted.org/packages/f1/48/af8159fd1b9dfeb82a47c84587a81d1d98974f05f85e764952862a5206de/rosettasciio-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96e08cd6a373dc794edc268da4c644b3fd93927e2324c5a3721c3dd71d3b592c", size = 789810 }, + { url = "https://files.pythonhosted.org/packages/99/9d/5cf650f4c39c649e3c09ff885b6a471aa3a0c57e52e1a538f88156083ca8/rosettasciio-0.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e056f143c5c7e41ca55ee5dc59b5efaa2f14ace6f6f8039d5102f156385f2c9", size = 1257517 }, + { url = "https://files.pythonhosted.org/packages/2d/49/d0484376a7724aa8bbc3628d6e4331f0593dcbee3efd3ba193c81cc380f6/rosettasciio-0.10.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:465811041da11db7259f8fde1c72fb6838aaa14678c000feed8719c4f8d2dfe0", size = 1263282 }, + { url = "https://files.pythonhosted.org/packages/94/99/a70aeef2b316de92a315c8b6f5d3153fc1b36bbaedc96ad3caf70b45606c/rosettasciio-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:fffc27d62c14e63818d8928d55458fe660f0153c406b4294e3cd4cbd63e1bea7", size = 788436 }, + { url = "https://files.pythonhosted.org/packages/bc/5f/ef4db517d63a657a9c1d626d65e9c843850fd8da8a364cfb674abe1e6fc4/rosettasciio-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8627f64c027361face099eaad76ea02eb7cfce4f7c66174826f36e236204e970", size = 795544 }, + { url = "https://files.pythonhosted.org/packages/90/45/d1590ce68aed0cf1c8e76cd52c1077a5a92f4e14c97d2a7456871296899c/rosettasciio-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c5ca0b541c5744b9b9559f5e002b5f2362d4cb09a002b2650a9d358025477261", size = 788949 }, + { url = "https://files.pythonhosted.org/packages/b0/6f/680259cc71741d9fa0c418230653177cf9bea4f554014a26354ab8079380/rosettasciio-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccf69c1d3e141bca2feca2275158a796f27299888a8ac7943eceec9779f1b811", size = 1249455 }, + { url = "https://files.pythonhosted.org/packages/24/4f/8b668c9312ab4d2c87a342e4d947a2c0c10cadda44391bff1154df5a623e/rosettasciio-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a8929f285c2cfdebb5ae180dfe80e90ada916e37063e83d8ea7840be7851ddb", size = 1259454 }, + { url = "https://files.pythonhosted.org/packages/ba/5f/07ba8f617b121431b0d8f45f946bd9602256fb1f1d43c275f21fecfe9f27/rosettasciio-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c802e0292537cb0f4878eca631d227183562a7e13c2449fe1e6f23b4eae808ff", size = 788185 }, + { url = "https://files.pythonhosted.org/packages/40/2f/4515d05d896b13410c379eb21a6090c61fd654956fae0f54fce108a7379a/rosettasciio-0.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a8ab4c344987364c479232001d2e072f00a855fe3d385cbcbac68641a0c2f802", size = 800276 }, + { url = "https://files.pythonhosted.org/packages/57/d7/2bd8a60a7758d5a894ae59192846bb72a68bd773b24daed9ea8c737c41bf/rosettasciio-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e8bbf513d81e05242b6ee5d35d58ba06232431990adaff6473bd942518cf2b82", size = 795978 }, + { url = "https://files.pythonhosted.org/packages/35/54/dae525b83a98b27fbabe2152045d990b4baa858522024196c5e47d06bbc1/rosettasciio-0.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b34b629ef130aec286062753b44d4e4d73caf1a4d9aba3530ec3801bd49755c", size = 1265997 }, + { url = "https://files.pythonhosted.org/packages/06/ae/a5969d089adc71df61451af8665df7e30a34957a0ebdc2614c449d2c37cf/rosettasciio-0.10.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365750bd177c7cff56f88b9f8cf9b59d2c99b52e7d38db142282996e25e552c2", size = 1255782 }, + { url = "https://files.pythonhosted.org/packages/d2/38/7e9d8eb167419d9e8b34433b698532b88c4b06ed38cd6723f7e702260f9f/rosettasciio-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a16dbadb662290be0d4ea0a2496109cff4484c155fd12eb6b57345e64d9015d9", size = 798624 }, + { url = "https://files.pythonhosted.org/packages/92/a6/a4cff62b7346daca9dfdd1d08c1aed5e0c27283845465b6b5c797fb41713/rosettasciio-0.10.0-py3-none-any.whl", hash = "sha256:a4e353365972865e915b7a689d2f9ba38a6f94aec8cd19c2e35513523b718b1f", size = 550949 }, ] [[package]] name = "rpds-py" version = "0.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/ab/7fb95163a53ab122c74a7c42d2d2f012819af2cf3deb43fb0d5acf45cc1a/rpds_py-0.29.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9b9c764a11fd637e0322a488560533112837f5334ffeb48b1be20f6d98a7b437", size = 372344, upload-time = "2025-11-16T14:47:57.279Z" }, - { url = "https://files.pythonhosted.org/packages/b3/45/f3c30084c03b0d0f918cb4c5ae2c20b0a148b51ba2b3f6456765b629bedd/rpds_py-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fd2164d73812026ce970d44c3ebd51e019d2a26a4425a5dcbdfa93a34abc383", size = 363041, upload-time = "2025-11-16T14:47:58.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e9/4d044a1662608c47a87cbb37b999d4d5af54c6d6ebdda93a4d8bbf8b2a10/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a097b7f7f7274164566ae90a221fd725363c0e9d243e2e9ed43d195ccc5495c", size = 391775, upload-time = "2025-11-16T14:48:00.197Z" }, - { url = "https://files.pythonhosted.org/packages/50/c9/7616d3ace4e6731aeb6e3cd85123e03aec58e439044e214b9c5c60fd8eb1/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cdc0490374e31cedefefaa1520d5fe38e82fde8748cbc926e7284574c714d6b", size = 405624, upload-time = "2025-11-16T14:48:01.496Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e2/6d7d6941ca0843609fd2d72c966a438d6f22617baf22d46c3d2156c31350/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89ca2e673ddd5bde9b386da9a0aac0cab0e76f40c8f0aaf0d6311b6bbf2aa311", size = 527894, upload-time = "2025-11-16T14:48:03.167Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f7/aee14dc2db61bb2ae1e3068f134ca9da5f28c586120889a70ff504bb026f/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5d9da3ff5af1ca1249b1adb8ef0573b94c76e6ae880ba1852f033bf429d4588", size = 412720, upload-time = "2025-11-16T14:48:04.413Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e2/2293f236e887c0360c2723d90c00d48dee296406994d6271faf1712e94ec/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8238d1d310283e87376c12f658b61e1ee23a14c0e54c7c0ce953efdbdc72deed", size = 392945, upload-time = "2025-11-16T14:48:06.252Z" }, - { url = "https://files.pythonhosted.org/packages/14/cd/ceea6147acd3bd1fd028d1975228f08ff19d62098078d5ec3eed49703797/rpds_py-0.29.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2d6fb2ad1c36f91c4646989811e84b1ea5e0c3cf9690b826b6e32b7965853a63", size = 406385, upload-time = "2025-11-16T14:48:07.575Z" }, - { url = "https://files.pythonhosted.org/packages/52/36/fe4dead19e45eb77a0524acfdbf51e6cda597b26fc5b6dddbff55fbbb1a5/rpds_py-0.29.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:534dc9df211387547267ccdb42253aa30527482acb38dd9b21c5c115d66a96d2", size = 423943, upload-time = "2025-11-16T14:48:10.175Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7b/4551510803b582fa4abbc8645441a2d15aa0c962c3b21ebb380b7e74f6a1/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d456e64724a075441e4ed648d7f154dc62e9aabff29bcdf723d0c00e9e1d352f", size = 574204, upload-time = "2025-11-16T14:48:11.499Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/071ccdd7b171e727a6ae079f02c26f75790b41555f12ca8f1151336d2124/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a738f2da2f565989401bd6fd0b15990a4d1523c6d7fe83f300b7e7d17212feca", size = 600587, upload-time = "2025-11-16T14:48:12.822Z" }, - { url = "https://files.pythonhosted.org/packages/03/09/96983d48c8cf5a1e03c7d9cc1f4b48266adfb858ae48c7c2ce978dbba349/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a110e14508fd26fd2e472bb541f37c209409876ba601cf57e739e87d8a53cf95", size = 562287, upload-time = "2025-11-16T14:48:14.108Z" }, - { url = "https://files.pythonhosted.org/packages/40/f0/8c01aaedc0fa92156f0391f39ea93b5952bc0ec56b897763858f95da8168/rpds_py-0.29.0-cp311-cp311-win32.whl", hash = "sha256:923248a56dd8d158389a28934f6f69ebf89f218ef96a6b216a9be6861804d3f4", size = 221394, upload-time = "2025-11-16T14:48:15.374Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/a8b21c54c7d234efdc83dc034a4d7cd9668e3613b6316876a29b49dece71/rpds_py-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:539eb77eb043afcc45314d1be09ea6d6cafb3addc73e0547c171c6d636957f60", size = 235713, upload-time = "2025-11-16T14:48:16.636Z" }, - { url = "https://files.pythonhosted.org/packages/a7/1f/df3c56219523947b1be402fa12e6323fe6d61d883cf35d6cb5d5bb6db9d9/rpds_py-0.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:bdb67151ea81fcf02d8f494703fb728d4d34d24556cbff5f417d74f6f5792e7c", size = 229157, upload-time = "2025-11-16T14:48:17.891Z" }, - { url = "https://files.pythonhosted.org/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000, upload-time = "2025-11-16T14:48:19.141Z" }, - { url = "https://files.pythonhosted.org/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575, upload-time = "2025-11-16T14:48:20.443Z" }, - { url = "https://files.pythonhosted.org/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159, upload-time = "2025-11-16T14:48:22.12Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602, upload-time = "2025-11-16T14:48:23.563Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808, upload-time = "2025-11-16T14:48:24.949Z" }, - { url = "https://files.pythonhosted.org/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015, upload-time = "2025-11-16T14:48:26.782Z" }, - { url = "https://files.pythonhosted.org/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325, upload-time = "2025-11-16T14:48:28.055Z" }, - { url = "https://files.pythonhosted.org/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160, upload-time = "2025-11-16T14:48:29.338Z" }, - { url = "https://files.pythonhosted.org/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309, upload-time = "2025-11-16T14:48:30.691Z" }, - { url = "https://files.pythonhosted.org/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644, upload-time = "2025-11-16T14:48:31.964Z" }, - { url = "https://files.pythonhosted.org/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605, upload-time = "2025-11-16T14:48:33.252Z" }, - { url = "https://files.pythonhosted.org/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593, upload-time = "2025-11-16T14:48:34.643Z" }, - { url = "https://files.pythonhosted.org/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853, upload-time = "2025-11-16T14:48:36.419Z" }, - { url = "https://files.pythonhosted.org/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895, upload-time = "2025-11-16T14:48:37.956Z" }, - { url = "https://files.pythonhosted.org/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321, upload-time = "2025-11-16T14:48:39.71Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710, upload-time = "2025-11-16T14:48:41.063Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582, upload-time = "2025-11-16T14:48:42.423Z" }, - { url = "https://files.pythonhosted.org/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172, upload-time = "2025-11-16T14:48:43.75Z" }, - { url = "https://files.pythonhosted.org/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586, upload-time = "2025-11-16T14:48:45.498Z" }, - { url = "https://files.pythonhosted.org/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339, upload-time = "2025-11-16T14:48:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201, upload-time = "2025-11-16T14:48:48.615Z" }, - { url = "https://files.pythonhosted.org/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095, upload-time = "2025-11-16T14:48:50.027Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077, upload-time = "2025-11-16T14:48:51.515Z" }, - { url = "https://files.pythonhosted.org/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548, upload-time = "2025-11-16T14:48:53.237Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661, upload-time = "2025-11-16T14:48:54.769Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937, upload-time = "2025-11-16T14:48:56.247Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496, upload-time = "2025-11-16T14:48:57.691Z" }, - { url = "https://files.pythonhosted.org/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126, upload-time = "2025-11-16T14:48:59.058Z" }, - { url = "https://files.pythonhosted.org/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771, upload-time = "2025-11-16T14:49:00.872Z" }, - { url = "https://files.pythonhosted.org/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994, upload-time = "2025-11-16T14:49:02.673Z" }, - { url = "https://files.pythonhosted.org/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886, upload-time = "2025-11-16T14:49:04.133Z" }, - { url = "https://files.pythonhosted.org/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262, upload-time = "2025-11-16T14:49:05.543Z" }, - { url = "https://files.pythonhosted.org/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826, upload-time = "2025-11-16T14:49:07.301Z" }, - { url = "https://files.pythonhosted.org/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234, upload-time = "2025-11-16T14:49:08.782Z" }, - { url = "https://files.pythonhosted.org/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008, upload-time = "2025-11-16T14:49:10.253Z" }, - { url = "https://files.pythonhosted.org/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569, upload-time = "2025-11-16T14:49:12.478Z" }, - { url = "https://files.pythonhosted.org/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188, upload-time = "2025-11-16T14:49:13.88Z" }, - { url = "https://files.pythonhosted.org/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587, upload-time = "2025-11-16T14:49:15.339Z" }, - { url = "https://files.pythonhosted.org/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641, upload-time = "2025-11-16T14:49:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683, upload-time = "2025-11-16T14:49:18.342Z" }, - { url = "https://files.pythonhosted.org/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730, upload-time = "2025-11-16T14:49:19.767Z" }, - { url = "https://files.pythonhosted.org/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361, upload-time = "2025-11-16T14:49:21.574Z" }, - { url = "https://files.pythonhosted.org/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227, upload-time = "2025-11-16T14:49:23.03Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" }, - { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, - { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, - { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, - { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, - { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, - { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, - { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, - { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, - { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, - { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, - { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, - { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, - { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ac/b97e80bf107159e5b9ba9c91df1ab95f69e5e41b435f27bdd737f0d583ac/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:acd82a9e39082dc5f4492d15a6b6c8599aa21db5c35aaf7d6889aea16502c07d", size = 373963, upload-time = "2025-11-16T14:50:16.205Z" }, - { url = "https://files.pythonhosted.org/packages/40/5a/55e72962d5d29bd912f40c594e68880d3c7a52774b0f75542775f9250712/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:715b67eac317bf1c7657508170a3e011a1ea6ccb1c9d5f296e20ba14196be6b3", size = 364644, upload-time = "2025-11-16T14:50:18.22Z" }, - { url = "https://files.pythonhosted.org/packages/99/2a/6b6524d0191b7fc1351c3c0840baac42250515afb48ae40c7ed15499a6a2/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3b1b87a237cb2dba4db18bcfaaa44ba4cd5936b91121b62292ff21df577fc43", size = 393847, upload-time = "2025-11-16T14:50:20.012Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b8/c5692a7df577b3c0c7faed7ac01ee3c608b81750fc5d89f84529229b6873/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c3c3e8101bb06e337c88eb0c0ede3187131f19d97d43ea0e1c5407ea74c0cbf", size = 407281, upload-time = "2025-11-16T14:50:21.64Z" }, - { url = "https://files.pythonhosted.org/packages/f0/57/0546c6f84031b7ea08b76646a8e33e45607cc6bd879ff1917dc077bb881e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8e54d6e61f3ecd3abe032065ce83ea63417a24f437e4a3d73d2f85ce7b7cfe", size = 529213, upload-time = "2025-11-16T14:50:23.219Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c1/01dd5f444233605555bc11fe5fed6a5c18f379f02013870c176c8e630a23/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fbd4e9aebf110473a420dea85a238b254cf8a15acb04b22a5a6b5ce8925b760", size = 413808, upload-time = "2025-11-16T14:50:25.262Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0a/60f98b06156ea2a7af849fb148e00fbcfdb540909a5174a5ed10c93745c7/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fdf53d36e6c72819993e35d1ebeeb8e8fc688d0c6c2b391b55e335b3afba5a", size = 394600, upload-time = "2025-11-16T14:50:26.956Z" }, - { url = "https://files.pythonhosted.org/packages/37/f1/dc9312fc9bec040ece08396429f2bd9e0977924ba7a11c5ad7056428465e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:ea7173df5d86f625f8dde6d5929629ad811ed8decda3b60ae603903839ac9ac0", size = 408634, upload-time = "2025-11-16T14:50:28.989Z" }, - { url = "https://files.pythonhosted.org/packages/ed/41/65024c9fd40c89bb7d604cf73beda4cbdbcebe92d8765345dd65855b6449/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:76054d540061eda273274f3d13a21a4abdde90e13eaefdc205db37c05230efce", size = 426064, upload-time = "2025-11-16T14:50:30.674Z" }, - { url = "https://files.pythonhosted.org/packages/a2/e0/cf95478881fc88ca2fdbf56381d7df36567cccc39a05394beac72182cd62/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:9f84c549746a5be3bc7415830747a3a0312573afc9f95785eb35228bb17742ec", size = 575871, upload-time = "2025-11-16T14:50:33.428Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c0/df88097e64339a0218b57bd5f9ca49898e4c394db756c67fccc64add850a/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0ea962671af5cb9a260489e311fa22b2e97103e3f9f0caaea6f81390af96a9ed", size = 601702, upload-time = "2025-11-16T14:50:36.051Z" }, - { url = "https://files.pythonhosted.org/packages/87/f4/09ffb3ebd0cbb9e2c7c9b84d252557ecf434cd71584ee1e32f66013824df/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7728653900035fb7b8d06e1e5900545d8088efc9d5d4545782da7df03ec803f", size = 564054, upload-time = "2025-11-16T14:50:37.733Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/ab/7fb95163a53ab122c74a7c42d2d2f012819af2cf3deb43fb0d5acf45cc1a/rpds_py-0.29.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9b9c764a11fd637e0322a488560533112837f5334ffeb48b1be20f6d98a7b437", size = 372344 }, + { url = "https://files.pythonhosted.org/packages/b3/45/f3c30084c03b0d0f918cb4c5ae2c20b0a148b51ba2b3f6456765b629bedd/rpds_py-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fd2164d73812026ce970d44c3ebd51e019d2a26a4425a5dcbdfa93a34abc383", size = 363041 }, + { url = "https://files.pythonhosted.org/packages/e3/e9/4d044a1662608c47a87cbb37b999d4d5af54c6d6ebdda93a4d8bbf8b2a10/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a097b7f7f7274164566ae90a221fd725363c0e9d243e2e9ed43d195ccc5495c", size = 391775 }, + { url = "https://files.pythonhosted.org/packages/50/c9/7616d3ace4e6731aeb6e3cd85123e03aec58e439044e214b9c5c60fd8eb1/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cdc0490374e31cedefefaa1520d5fe38e82fde8748cbc926e7284574c714d6b", size = 405624 }, + { url = "https://files.pythonhosted.org/packages/c2/e2/6d7d6941ca0843609fd2d72c966a438d6f22617baf22d46c3d2156c31350/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89ca2e673ddd5bde9b386da9a0aac0cab0e76f40c8f0aaf0d6311b6bbf2aa311", size = 527894 }, + { url = "https://files.pythonhosted.org/packages/8d/f7/aee14dc2db61bb2ae1e3068f134ca9da5f28c586120889a70ff504bb026f/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5d9da3ff5af1ca1249b1adb8ef0573b94c76e6ae880ba1852f033bf429d4588", size = 412720 }, + { url = "https://files.pythonhosted.org/packages/2f/e2/2293f236e887c0360c2723d90c00d48dee296406994d6271faf1712e94ec/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8238d1d310283e87376c12f658b61e1ee23a14c0e54c7c0ce953efdbdc72deed", size = 392945 }, + { url = "https://files.pythonhosted.org/packages/14/cd/ceea6147acd3bd1fd028d1975228f08ff19d62098078d5ec3eed49703797/rpds_py-0.29.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2d6fb2ad1c36f91c4646989811e84b1ea5e0c3cf9690b826b6e32b7965853a63", size = 406385 }, + { url = "https://files.pythonhosted.org/packages/52/36/fe4dead19e45eb77a0524acfdbf51e6cda597b26fc5b6dddbff55fbbb1a5/rpds_py-0.29.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:534dc9df211387547267ccdb42253aa30527482acb38dd9b21c5c115d66a96d2", size = 423943 }, + { url = "https://files.pythonhosted.org/packages/a1/7b/4551510803b582fa4abbc8645441a2d15aa0c962c3b21ebb380b7e74f6a1/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d456e64724a075441e4ed648d7f154dc62e9aabff29bcdf723d0c00e9e1d352f", size = 574204 }, + { url = "https://files.pythonhosted.org/packages/64/ba/071ccdd7b171e727a6ae079f02c26f75790b41555f12ca8f1151336d2124/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a738f2da2f565989401bd6fd0b15990a4d1523c6d7fe83f300b7e7d17212feca", size = 600587 }, + { url = "https://files.pythonhosted.org/packages/03/09/96983d48c8cf5a1e03c7d9cc1f4b48266adfb858ae48c7c2ce978dbba349/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a110e14508fd26fd2e472bb541f37c209409876ba601cf57e739e87d8a53cf95", size = 562287 }, + { url = "https://files.pythonhosted.org/packages/40/f0/8c01aaedc0fa92156f0391f39ea93b5952bc0ec56b897763858f95da8168/rpds_py-0.29.0-cp311-cp311-win32.whl", hash = "sha256:923248a56dd8d158389a28934f6f69ebf89f218ef96a6b216a9be6861804d3f4", size = 221394 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/a8b21c54c7d234efdc83dc034a4d7cd9668e3613b6316876a29b49dece71/rpds_py-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:539eb77eb043afcc45314d1be09ea6d6cafb3addc73e0547c171c6d636957f60", size = 235713 }, + { url = "https://files.pythonhosted.org/packages/a7/1f/df3c56219523947b1be402fa12e6323fe6d61d883cf35d6cb5d5bb6db9d9/rpds_py-0.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:bdb67151ea81fcf02d8f494703fb728d4d34d24556cbff5f417d74f6f5792e7c", size = 229157 }, + { url = "https://files.pythonhosted.org/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000 }, + { url = "https://files.pythonhosted.org/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575 }, + { url = "https://files.pythonhosted.org/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159 }, + { url = "https://files.pythonhosted.org/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602 }, + { url = "https://files.pythonhosted.org/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808 }, + { url = "https://files.pythonhosted.org/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015 }, + { url = "https://files.pythonhosted.org/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325 }, + { url = "https://files.pythonhosted.org/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160 }, + { url = "https://files.pythonhosted.org/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309 }, + { url = "https://files.pythonhosted.org/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644 }, + { url = "https://files.pythonhosted.org/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605 }, + { url = "https://files.pythonhosted.org/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593 }, + { url = "https://files.pythonhosted.org/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853 }, + { url = "https://files.pythonhosted.org/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895 }, + { url = "https://files.pythonhosted.org/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321 }, + { url = "https://files.pythonhosted.org/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710 }, + { url = "https://files.pythonhosted.org/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582 }, + { url = "https://files.pythonhosted.org/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172 }, + { url = "https://files.pythonhosted.org/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586 }, + { url = "https://files.pythonhosted.org/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339 }, + { url = "https://files.pythonhosted.org/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201 }, + { url = "https://files.pythonhosted.org/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095 }, + { url = "https://files.pythonhosted.org/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077 }, + { url = "https://files.pythonhosted.org/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548 }, + { url = "https://files.pythonhosted.org/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661 }, + { url = "https://files.pythonhosted.org/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937 }, + { url = "https://files.pythonhosted.org/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496 }, + { url = "https://files.pythonhosted.org/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126 }, + { url = "https://files.pythonhosted.org/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771 }, + { url = "https://files.pythonhosted.org/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994 }, + { url = "https://files.pythonhosted.org/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886 }, + { url = "https://files.pythonhosted.org/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262 }, + { url = "https://files.pythonhosted.org/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826 }, + { url = "https://files.pythonhosted.org/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234 }, + { url = "https://files.pythonhosted.org/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008 }, + { url = "https://files.pythonhosted.org/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569 }, + { url = "https://files.pythonhosted.org/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188 }, + { url = "https://files.pythonhosted.org/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587 }, + { url = "https://files.pythonhosted.org/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641 }, + { url = "https://files.pythonhosted.org/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683 }, + { url = "https://files.pythonhosted.org/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730 }, + { url = "https://files.pythonhosted.org/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361 }, + { url = "https://files.pythonhosted.org/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227 }, + { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248 }, + { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731 }, + { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343 }, + { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406 }, + { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162 }, + { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719 }, + { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498 }, + { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743 }, + { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317 }, + { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979 }, + { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288 }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157 }, + { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741 }, + { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508 }, + { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125 }, + { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992 }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425 }, + { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282 }, + { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968 }, + { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714 }, + { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136 }, + { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250 }, + { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940 }, + { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392 }, + { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796 }, + { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843 }, + { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956 }, + { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288 }, + { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382 }, + { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919 }, + { url = "https://files.pythonhosted.org/packages/f2/ac/b97e80bf107159e5b9ba9c91df1ab95f69e5e41b435f27bdd737f0d583ac/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:acd82a9e39082dc5f4492d15a6b6c8599aa21db5c35aaf7d6889aea16502c07d", size = 373963 }, + { url = "https://files.pythonhosted.org/packages/40/5a/55e72962d5d29bd912f40c594e68880d3c7a52774b0f75542775f9250712/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:715b67eac317bf1c7657508170a3e011a1ea6ccb1c9d5f296e20ba14196be6b3", size = 364644 }, + { url = "https://files.pythonhosted.org/packages/99/2a/6b6524d0191b7fc1351c3c0840baac42250515afb48ae40c7ed15499a6a2/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3b1b87a237cb2dba4db18bcfaaa44ba4cd5936b91121b62292ff21df577fc43", size = 393847 }, + { url = "https://files.pythonhosted.org/packages/1c/b8/c5692a7df577b3c0c7faed7ac01ee3c608b81750fc5d89f84529229b6873/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c3c3e8101bb06e337c88eb0c0ede3187131f19d97d43ea0e1c5407ea74c0cbf", size = 407281 }, + { url = "https://files.pythonhosted.org/packages/f0/57/0546c6f84031b7ea08b76646a8e33e45607cc6bd879ff1917dc077bb881e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8e54d6e61f3ecd3abe032065ce83ea63417a24f437e4a3d73d2f85ce7b7cfe", size = 529213 }, + { url = "https://files.pythonhosted.org/packages/fa/c1/01dd5f444233605555bc11fe5fed6a5c18f379f02013870c176c8e630a23/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fbd4e9aebf110473a420dea85a238b254cf8a15acb04b22a5a6b5ce8925b760", size = 413808 }, + { url = "https://files.pythonhosted.org/packages/aa/0a/60f98b06156ea2a7af849fb148e00fbcfdb540909a5174a5ed10c93745c7/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fdf53d36e6c72819993e35d1ebeeb8e8fc688d0c6c2b391b55e335b3afba5a", size = 394600 }, + { url = "https://files.pythonhosted.org/packages/37/f1/dc9312fc9bec040ece08396429f2bd9e0977924ba7a11c5ad7056428465e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:ea7173df5d86f625f8dde6d5929629ad811ed8decda3b60ae603903839ac9ac0", size = 408634 }, + { url = "https://files.pythonhosted.org/packages/ed/41/65024c9fd40c89bb7d604cf73beda4cbdbcebe92d8765345dd65855b6449/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:76054d540061eda273274f3d13a21a4abdde90e13eaefdc205db37c05230efce", size = 426064 }, + { url = "https://files.pythonhosted.org/packages/a2/e0/cf95478881fc88ca2fdbf56381d7df36567cccc39a05394beac72182cd62/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:9f84c549746a5be3bc7415830747a3a0312573afc9f95785eb35228bb17742ec", size = 575871 }, + { url = "https://files.pythonhosted.org/packages/ea/c0/df88097e64339a0218b57bd5f9ca49898e4c394db756c67fccc64add850a/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0ea962671af5cb9a260489e311fa22b2e97103e3f9f0caaea6f81390af96a9ed", size = 601702 }, + { url = "https://files.pythonhosted.org/packages/87/f4/09ffb3ebd0cbb9e2c7c9b84d252557ecf434cd71584ee1e32f66013824df/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7728653900035fb7b8d06e1e5900545d8088efc9d5d4545782da7df03ec803f", size = 564054 }, ] [[package]] name = "ruff" version = "0.14.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, - { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, - { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, - { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, - { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, - { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, - { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, - { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, - { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, - { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, - { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630 }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925 }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040 }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755 }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641 }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854 }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088 }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717 }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812 }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656 }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922 }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501 }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319 }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209 }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709 }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808 }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546 }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331 }, ] [[package]] @@ -2825,24 +2827,24 @@ dependencies = [ { name = "scipy" }, { name = "tifffile" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057, upload-time = "2025-02-18T18:04:30.395Z" }, - { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335, upload-time = "2025-02-18T18:04:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783, upload-time = "2025-02-18T18:04:36.594Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376, upload-time = "2025-02-18T18:04:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698, upload-time = "2025-02-18T18:04:42.868Z" }, - { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000, upload-time = "2025-02-18T18:04:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893, upload-time = "2025-02-18T18:04:51.049Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389, upload-time = "2025-02-18T18:04:54.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435, upload-time = "2025-02-18T18:04:57.586Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474, upload-time = "2025-02-18T18:05:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841, upload-time = "2025-02-18T18:05:03.963Z" }, - { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862, upload-time = "2025-02-18T18:05:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785, upload-time = "2025-02-18T18:05:10.69Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119, upload-time = "2025-02-18T18:05:13.871Z" }, - { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116, upload-time = "2025-02-18T18:05:17.844Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801, upload-time = "2025-02-18T18:05:20.783Z" }, + { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057 }, + { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335 }, + { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783 }, + { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376 }, + { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698 }, + { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000 }, + { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893 }, + { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389 }, + { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435 }, + { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474 }, + { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841 }, + { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862 }, + { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785 }, + { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119 }, + { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116 }, + { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801 }, ] [[package]] @@ -2852,113 +2854,113 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload-time = "2025-10-28T17:38:54.068Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881, upload-time = "2025-10-28T17:31:47.104Z" }, - { url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012, upload-time = "2025-10-28T17:31:53.411Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935, upload-time = "2025-10-28T17:31:57.361Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466, upload-time = "2025-10-28T17:32:01.875Z" }, - { url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618, upload-time = "2025-10-28T17:32:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798, upload-time = "2025-10-28T17:32:12.665Z" }, - { url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154, upload-time = "2025-10-28T17:32:17.961Z" }, - { url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540, upload-time = "2025-10-28T17:32:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107, upload-time = "2025-10-28T17:32:29.921Z" }, - { url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272, upload-time = "2025-10-28T17:32:34.577Z" }, - { url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043, upload-time = "2025-10-28T17:32:40.285Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986, upload-time = "2025-10-28T17:32:45.325Z" }, - { url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814, upload-time = "2025-10-28T17:32:49.277Z" }, - { url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795, upload-time = "2025-10-28T17:32:53.337Z" }, - { url = "https://files.pythonhosted.org/packages/fa/46/d1146ff536d034d02f83c8afc3c4bab2eddb634624d6529a8512f3afc9da/scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203", size = 33349476, upload-time = "2025-10-28T17:32:58.353Z" }, - { url = "https://files.pythonhosted.org/packages/79/2e/415119c9ab3e62249e18c2b082c07aff907a273741b3f8160414b0e9193c/scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1", size = 35676692, upload-time = "2025-10-28T17:33:03.88Z" }, - { url = "https://files.pythonhosted.org/packages/27/82/df26e44da78bf8d2aeaf7566082260cfa15955a5a6e96e6a29935b64132f/scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe", size = 36019345, upload-time = "2025-10-28T17:33:09.773Z" }, - { url = "https://files.pythonhosted.org/packages/82/31/006cbb4b648ba379a95c87262c2855cd0d09453e500937f78b30f02fa1cd/scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70", size = 38678975, upload-time = "2025-10-28T17:33:15.809Z" }, - { url = "https://files.pythonhosted.org/packages/c2/7f/acbd28c97e990b421af7d6d6cd416358c9c293fc958b8529e0bd5d2a2a19/scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc", size = 38555926, upload-time = "2025-10-28T17:33:21.388Z" }, - { url = "https://files.pythonhosted.org/packages/ce/69/c5c7807fd007dad4f48e0a5f2153038dc96e8725d3345b9ee31b2b7bed46/scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2", size = 25463014, upload-time = "2025-10-28T17:33:25.975Z" }, - { url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856, upload-time = "2025-10-28T17:33:31.375Z" }, - { url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306, upload-time = "2025-10-28T17:33:36.516Z" }, - { url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371, upload-time = "2025-10-28T17:33:42.094Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877, upload-time = "2025-10-28T17:33:48.483Z" }, - { url = "https://files.pythonhosted.org/packages/7d/6b/3f911e1ebc364cb81320223a3422aab7d26c9c7973109a9cd0f27c64c6c0/scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959", size = 33342103, upload-time = "2025-10-28T17:33:56.495Z" }, - { url = "https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88", size = 35697297, upload-time = "2025-10-28T17:34:04.722Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6496dadbc80d8d896ff72511ecfe2316b50313bfc3ebf07a3f580f08bd8c/scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234", size = 36021756, upload-time = "2025-10-28T17:34:13.482Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bd/a8c7799e0136b987bda3e1b23d155bcb31aec68a4a472554df5f0937eef7/scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d", size = 38696566, upload-time = "2025-10-28T17:34:22.384Z" }, - { url = "https://files.pythonhosted.org/packages/cd/01/1204382461fcbfeb05b6161b594f4007e78b6eba9b375382f79153172b4d/scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304", size = 38529877, upload-time = "2025-10-28T17:35:51.076Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/9d9fbcaa1260a94f4bb5b64ba9213ceb5d03cd88841fe9fd1ffd47a45b73/scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2", size = 25455366, upload-time = "2025-10-28T17:35:59.014Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931, upload-time = "2025-10-28T17:34:31.451Z" }, - { url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081, upload-time = "2025-10-28T17:34:39.087Z" }, - { url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244, upload-time = "2025-10-28T17:34:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753, upload-time = "2025-10-28T17:34:51.793Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4b/f756cf8161d5365dcdef9e5f460ab226c068211030a175d2fc7f3f41ca64/scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c", size = 33496912, upload-time = "2025-10-28T17:34:59.8Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/222b1e49a58668f23839ca1542a6322bb095ab8d6590d4f71723869a6c2c/scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e", size = 35802371, upload-time = "2025-10-28T17:35:08.173Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8d/5964ef68bb31829bde27611f8c9deeac13764589fe74a75390242b64ca44/scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135", size = 36190477, upload-time = "2025-10-28T17:35:16.7Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f2/b31d75cb9b5fa4dd39a0a931ee9b33e7f6f36f23be5ef560bf72e0f92f32/scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6", size = 38796678, upload-time = "2025-10-28T17:35:26.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/1e/b3723d8ff64ab548c38d87055483714fefe6ee20e0189b62352b5e015bb1/scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc", size = 38640178, upload-time = "2025-10-28T17:35:35.304Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f3/d854ff38789aca9b0cc23008d607ced9de4f7ab14fa1ca4329f86b3758ca/scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a", size = 25803246, upload-time = "2025-10-28T17:35:42.155Z" }, - { url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469, upload-time = "2025-10-28T17:36:08.741Z" }, - { url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043, upload-time = "2025-10-28T17:36:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952, upload-time = "2025-10-28T17:36:22.966Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512, upload-time = "2025-10-28T17:36:29.731Z" }, - { url = "https://files.pythonhosted.org/packages/1f/60/c45a12b98ad591536bfe5330cb3cfe1850d7570259303563b1721564d458/scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22", size = 33413639, upload-time = "2025-10-28T17:36:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/71/bc/35957d88645476307e4839712642896689df442f3e53b0fa016ecf8a3357/scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc", size = 35704729, upload-time = "2025-10-28T17:36:46.547Z" }, - { url = "https://files.pythonhosted.org/packages/3b/15/89105e659041b1ca11c386e9995aefacd513a78493656e57789f9d9eab61/scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0", size = 36086251, upload-time = "2025-10-28T17:36:55.161Z" }, - { url = "https://files.pythonhosted.org/packages/1a/87/c0ea673ac9c6cc50b3da2196d860273bc7389aa69b64efa8493bdd25b093/scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800", size = 38716681, upload-time = "2025-10-28T17:37:04.1Z" }, - { url = "https://files.pythonhosted.org/packages/91/06/837893227b043fb9b0d13e4bd7586982d8136cb249ffb3492930dab905b8/scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d", size = 39358423, upload-time = "2025-10-28T17:38:20.005Z" }, - { url = "https://files.pythonhosted.org/packages/95/03/28bce0355e4d34a7c034727505a02d19548549e190bedd13a721e35380b7/scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f", size = 26135027, upload-time = "2025-10-28T17:38:24.966Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379, upload-time = "2025-10-28T17:37:14.061Z" }, - { url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052, upload-time = "2025-10-28T17:37:21.709Z" }, - { url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183, upload-time = "2025-10-28T17:37:29.559Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174, upload-time = "2025-10-28T17:37:36.306Z" }, - { url = "https://files.pythonhosted.org/packages/16/9d/d9e148b0ec680c0f042581a2be79a28a7ab66c0c4946697f9e7553ead337/scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8", size = 33497852, upload-time = "2025-10-28T17:37:42.228Z" }, - { url = "https://files.pythonhosted.org/packages/2f/22/4e5f7561e4f98b7bea63cf3fd7934bff1e3182e9f1626b089a679914d5c8/scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353", size = 35798595, upload-time = "2025-10-28T17:37:48.102Z" }, - { url = "https://files.pythonhosted.org/packages/83/42/6644d714c179429fc7196857866f219fef25238319b650bb32dde7bf7a48/scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146", size = 36186269, upload-time = "2025-10-28T17:37:53.72Z" }, - { url = "https://files.pythonhosted.org/packages/ac/70/64b4d7ca92f9cf2e6fc6aaa2eecf80bb9b6b985043a9583f32f8177ea122/scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d", size = 38802779, upload-time = "2025-10-28T17:37:59.393Z" }, - { url = "https://files.pythonhosted.org/packages/61/82/8d0e39f62764cce5ffd5284131e109f07cf8955aef9ab8ed4e3aa5e30539/scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7", size = 39471128, upload-time = "2025-10-28T17:38:05.259Z" }, - { url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127, upload-time = "2025-10-28T17:38:11.34Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881 }, + { url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012 }, + { url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935 }, + { url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466 }, + { url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618 }, + { url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798 }, + { url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154 }, + { url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540 }, + { url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107 }, + { url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272 }, + { url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043 }, + { url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986 }, + { url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814 }, + { url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795 }, + { url = "https://files.pythonhosted.org/packages/fa/46/d1146ff536d034d02f83c8afc3c4bab2eddb634624d6529a8512f3afc9da/scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203", size = 33349476 }, + { url = "https://files.pythonhosted.org/packages/79/2e/415119c9ab3e62249e18c2b082c07aff907a273741b3f8160414b0e9193c/scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1", size = 35676692 }, + { url = "https://files.pythonhosted.org/packages/27/82/df26e44da78bf8d2aeaf7566082260cfa15955a5a6e96e6a29935b64132f/scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe", size = 36019345 }, + { url = "https://files.pythonhosted.org/packages/82/31/006cbb4b648ba379a95c87262c2855cd0d09453e500937f78b30f02fa1cd/scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70", size = 38678975 }, + { url = "https://files.pythonhosted.org/packages/c2/7f/acbd28c97e990b421af7d6d6cd416358c9c293fc958b8529e0bd5d2a2a19/scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc", size = 38555926 }, + { url = "https://files.pythonhosted.org/packages/ce/69/c5c7807fd007dad4f48e0a5f2153038dc96e8725d3345b9ee31b2b7bed46/scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2", size = 25463014 }, + { url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856 }, + { url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306 }, + { url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371 }, + { url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877 }, + { url = "https://files.pythonhosted.org/packages/7d/6b/3f911e1ebc364cb81320223a3422aab7d26c9c7973109a9cd0f27c64c6c0/scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959", size = 33342103 }, + { url = "https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88", size = 35697297 }, + { url = "https://files.pythonhosted.org/packages/04/e1/6496dadbc80d8d896ff72511ecfe2316b50313bfc3ebf07a3f580f08bd8c/scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234", size = 36021756 }, + { url = "https://files.pythonhosted.org/packages/fe/bd/a8c7799e0136b987bda3e1b23d155bcb31aec68a4a472554df5f0937eef7/scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d", size = 38696566 }, + { url = "https://files.pythonhosted.org/packages/cd/01/1204382461fcbfeb05b6161b594f4007e78b6eba9b375382f79153172b4d/scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304", size = 38529877 }, + { url = "https://files.pythonhosted.org/packages/7f/14/9d9fbcaa1260a94f4bb5b64ba9213ceb5d03cd88841fe9fd1ffd47a45b73/scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2", size = 25455366 }, + { url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931 }, + { url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081 }, + { url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244 }, + { url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753 }, + { url = "https://files.pythonhosted.org/packages/4c/4b/f756cf8161d5365dcdef9e5f460ab226c068211030a175d2fc7f3f41ca64/scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c", size = 33496912 }, + { url = "https://files.pythonhosted.org/packages/09/b5/222b1e49a58668f23839ca1542a6322bb095ab8d6590d4f71723869a6c2c/scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e", size = 35802371 }, + { url = "https://files.pythonhosted.org/packages/c1/8d/5964ef68bb31829bde27611f8c9deeac13764589fe74a75390242b64ca44/scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135", size = 36190477 }, + { url = "https://files.pythonhosted.org/packages/ab/f2/b31d75cb9b5fa4dd39a0a931ee9b33e7f6f36f23be5ef560bf72e0f92f32/scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6", size = 38796678 }, + { url = "https://files.pythonhosted.org/packages/b4/1e/b3723d8ff64ab548c38d87055483714fefe6ee20e0189b62352b5e015bb1/scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc", size = 38640178 }, + { url = "https://files.pythonhosted.org/packages/8e/f3/d854ff38789aca9b0cc23008d607ced9de4f7ab14fa1ca4329f86b3758ca/scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a", size = 25803246 }, + { url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469 }, + { url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043 }, + { url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952 }, + { url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512 }, + { url = "https://files.pythonhosted.org/packages/1f/60/c45a12b98ad591536bfe5330cb3cfe1850d7570259303563b1721564d458/scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22", size = 33413639 }, + { url = "https://files.pythonhosted.org/packages/71/bc/35957d88645476307e4839712642896689df442f3e53b0fa016ecf8a3357/scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc", size = 35704729 }, + { url = "https://files.pythonhosted.org/packages/3b/15/89105e659041b1ca11c386e9995aefacd513a78493656e57789f9d9eab61/scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0", size = 36086251 }, + { url = "https://files.pythonhosted.org/packages/1a/87/c0ea673ac9c6cc50b3da2196d860273bc7389aa69b64efa8493bdd25b093/scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800", size = 38716681 }, + { url = "https://files.pythonhosted.org/packages/91/06/837893227b043fb9b0d13e4bd7586982d8136cb249ffb3492930dab905b8/scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d", size = 39358423 }, + { url = "https://files.pythonhosted.org/packages/95/03/28bce0355e4d34a7c034727505a02d19548549e190bedd13a721e35380b7/scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f", size = 26135027 }, + { url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379 }, + { url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052 }, + { url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183 }, + { url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174 }, + { url = "https://files.pythonhosted.org/packages/16/9d/d9e148b0ec680c0f042581a2be79a28a7ab66c0c4946697f9e7553ead337/scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8", size = 33497852 }, + { url = "https://files.pythonhosted.org/packages/2f/22/4e5f7561e4f98b7bea63cf3fd7934bff1e3182e9f1626b089a679914d5c8/scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353", size = 35798595 }, + { url = "https://files.pythonhosted.org/packages/83/42/6644d714c179429fc7196857866f219fef25238319b650bb32dde7bf7a48/scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146", size = 36186269 }, + { url = "https://files.pythonhosted.org/packages/ac/70/64b4d7ca92f9cf2e6fc6aaa2eecf80bb9b6b985043a9583f32f8177ea122/scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d", size = 38802779 }, + { url = "https://files.pythonhosted.org/packages/61/82/8d0e39f62764cce5ffd5284131e109f07cf8955aef9ab8ed4e3aa5e30539/scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7", size = 39471128 }, + { url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127 }, ] [[package]] name = "send2trash" version = "1.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/3a/aec9b02217bb79b87bbc1a21bc6abc51e3d5dcf65c30487ac96c0908c722/Send2Trash-1.8.3.tar.gz", hash = "sha256:b18e7a3966d99871aefeb00cfbcfdced55ce4871194810fc71f4aa484b953abf", size = 17394, upload-time = "2024-04-07T00:01:09.267Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3a/aec9b02217bb79b87bbc1a21bc6abc51e3d5dcf65c30487ac96c0908c722/Send2Trash-1.8.3.tar.gz", hash = "sha256:b18e7a3966d99871aefeb00cfbcfdced55ce4871194810fc71f4aa484b953abf", size = 17394 } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/b0/4562db6223154aa4e22f939003cb92514c79f3d4dccca3444253fd17f902/Send2Trash-1.8.3-py3-none-any.whl", hash = "sha256:0c31227e0bd08961c7665474a3d1ef7193929fedda4233843689baa056be46c9", size = 18072, upload-time = "2024-04-07T00:01:07.438Z" }, + { url = "https://files.pythonhosted.org/packages/40/b0/4562db6223154aa4e22f939003cb92514c79f3d4dccca3444253fd17f902/Send2Trash-1.8.3-py3-none-any.whl", hash = "sha256:0c31227e0bd08961c7665474a3d1ef7193929fedda4233843689baa056be46c9", size = 18072 }, ] [[package]] name = "setuptools" version = "80.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] [[package]] name = "soupsieve" version = "2.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472 } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679 }, ] [[package]] @@ -2969,33 +2971,33 @@ dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/81/15d7c161c9ddf0900b076b55345872ed04ff1ed6a0666e5e94ab44b0163c/sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd", size = 2140517, upload-time = "2025-10-10T15:36:15.64Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d5/4abd13b245c7d91bdf131d4916fd9e96a584dac74215f8b5bc945206a974/sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa", size = 2130738, upload-time = "2025-10-10T15:36:16.91Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3c/8418969879c26522019c1025171cefbb2a8586b6789ea13254ac602986c0/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e", size = 3304145, upload-time = "2025-10-10T15:34:19.569Z" }, - { url = "https://files.pythonhosted.org/packages/94/2d/fdb9246d9d32518bda5d90f4b65030b9bf403a935cfe4c36a474846517cb/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e", size = 3304511, upload-time = "2025-10-10T15:47:05.088Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fb/40f2ad1da97d5c83f6c1269664678293d3fe28e90ad17a1093b735420549/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399", size = 3235161, upload-time = "2025-10-10T15:34:21.193Z" }, - { url = "https://files.pythonhosted.org/packages/95/cb/7cf4078b46752dca917d18cf31910d4eff6076e5b513c2d66100c4293d83/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b", size = 3261426, upload-time = "2025-10-10T15:47:07.196Z" }, - { url = "https://files.pythonhosted.org/packages/f8/3b/55c09b285cb2d55bdfa711e778bdffdd0dc3ffa052b0af41f1c5d6e582fa/sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3", size = 2105392, upload-time = "2025-10-10T15:38:20.051Z" }, - { url = "https://files.pythonhosted.org/packages/c7/23/907193c2f4d680aedbfbdf7bf24c13925e3c7c292e813326c1b84a0b878e/sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5", size = 2130293, upload-time = "2025-10-10T15:38:21.601Z" }, - { url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" }, - { url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" }, - { url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" }, - { url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" }, - { url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" }, - { url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" }, - { url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" }, - { url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" }, - { url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" }, - { url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" }, - { url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" }, - { url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" }, - { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/81/15d7c161c9ddf0900b076b55345872ed04ff1ed6a0666e5e94ab44b0163c/sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd", size = 2140517 }, + { url = "https://files.pythonhosted.org/packages/d4/d5/4abd13b245c7d91bdf131d4916fd9e96a584dac74215f8b5bc945206a974/sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa", size = 2130738 }, + { url = "https://files.pythonhosted.org/packages/cb/3c/8418969879c26522019c1025171cefbb2a8586b6789ea13254ac602986c0/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e", size = 3304145 }, + { url = "https://files.pythonhosted.org/packages/94/2d/fdb9246d9d32518bda5d90f4b65030b9bf403a935cfe4c36a474846517cb/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e", size = 3304511 }, + { url = "https://files.pythonhosted.org/packages/7d/fb/40f2ad1da97d5c83f6c1269664678293d3fe28e90ad17a1093b735420549/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399", size = 3235161 }, + { url = "https://files.pythonhosted.org/packages/95/cb/7cf4078b46752dca917d18cf31910d4eff6076e5b513c2d66100c4293d83/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b", size = 3261426 }, + { url = "https://files.pythonhosted.org/packages/f8/3b/55c09b285cb2d55bdfa711e778bdffdd0dc3ffa052b0af41f1c5d6e582fa/sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3", size = 2105392 }, + { url = "https://files.pythonhosted.org/packages/c7/23/907193c2f4d680aedbfbdf7bf24c13925e3c7c292e813326c1b84a0b878e/sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5", size = 2130293 }, + { url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675 }, + { url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726 }, + { url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603 }, + { url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842 }, + { url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558 }, + { url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570 }, + { url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447 }, + { url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912 }, + { url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479 }, + { url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212 }, + { url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353 }, + { url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222 }, + { url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614 }, + { url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248 }, + { url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275 }, + { url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901 }, + { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718 }, ] [[package]] @@ -3007,9 +3009,9 @@ dependencies = [ { name = "executing" }, { name = "pure-eval" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, ] [[package]] @@ -3019,9 +3021,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mpmath" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 }, ] [[package]] @@ -3041,7 +3043,7 @@ dependencies = [ { name = "werkzeug" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680 }, ] [[package]] @@ -3049,9 +3051,9 @@ name = "tensorboard-data-server" version = "0.7.2" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" }, - { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" }, - { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356 }, + { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598 }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363 }, ] [[package]] @@ -3063,9 +3065,9 @@ dependencies = [ { name = "pywinpty", marker = "os_name == 'nt'" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154 }, ] [[package]] @@ -3075,9 +3077,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/b5/0d8f3d395f07d25ec4cafcdfc8cab234b2cc6bf2465e9d7660633983fe8f/tifffile-2025.10.16.tar.gz", hash = "sha256:425179ec7837ac0e07bc95d2ea5bea9b179ce854967c12ba07fc3f093e58efc1", size = 371848, upload-time = "2025-10-16T22:56:09.043Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/b5/0d8f3d395f07d25ec4cafcdfc8cab234b2cc6bf2465e9d7660633983fe8f/tifffile-2025.10.16.tar.gz", hash = "sha256:425179ec7837ac0e07bc95d2ea5bea9b179ce854967c12ba07fc3f093e58efc1", size = 371848 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/5e/56c751afab61336cf0e7aa671b134255a30f15f59cd9e04f59c598a37ff5/tifffile-2025.10.16-py3-none-any.whl", hash = "sha256:41463d979c1c262b0a5cdef2a7f95f0388a072ad82d899458b154a48609d759c", size = 231162, upload-time = "2025-10-16T22:56:07.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5e/56c751afab61336cf0e7aa671b134255a30f15f59cd9e04f59c598a37ff5/tifffile-2025.10.16-py3-none-any.whl", hash = "sha256:41463d979c1c262b0a5cdef2a7f95f0388a072ad82d899458b154a48609d759c", size = 231162 }, ] [[package]] @@ -3087,67 +3089,67 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 }, ] [[package]] name = "tomli" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236 }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084 }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832 }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052 }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555 }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128 }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445 }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165 }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891 }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796 }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121 }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070 }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859 }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296 }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124 }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698 }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819 }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766 }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771 }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586 }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792 }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909 }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946 }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705 }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244 }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637 }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925 }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045 }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835 }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109 }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930 }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964 }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065 }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088 }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193 }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488 }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669 }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709 }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563 }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756 }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408 }, ] [[package]] name = "toolz" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093 }, ] [[package]] @@ -3180,30 +3182,30 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/15/db/c064112ac0089af3d2f7a2b5bfbabf4aa407a78b74f87889e524b91c5402/torch-2.9.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:62b3fd888277946918cba4478cf849303da5359f0fb4e3bfb86b0533ba2eaf8d", size = 104220430, upload-time = "2025-11-12T15:20:31.705Z" }, - { url = "https://files.pythonhosted.org/packages/56/be/76eaa36c9cd032d3b01b001e2c5a05943df75f26211f68fae79e62f87734/torch-2.9.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d033ff0ac3f5400df862a51bdde9bad83561f3739ea0046e68f5401ebfa67c1b", size = 899821446, upload-time = "2025-11-12T15:20:15.544Z" }, - { url = "https://files.pythonhosted.org/packages/47/cc/7a2949e38dfe3244c4df21f0e1c27bce8aedd6c604a587dd44fc21017cb4/torch-2.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d06b30a9207b7c3516a9e0102114024755a07045f0c1d2f2a56b1819ac06bcb", size = 110973074, upload-time = "2025-11-12T15:21:39.958Z" }, - { url = "https://files.pythonhosted.org/packages/1e/ce/7d251155a783fb2c1bb6837b2b7023c622a2070a0a72726ca1df47e7ea34/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:52347912d868653e1528b47cafaf79b285b98be3f4f35d5955389b1b95224475", size = 74463887, upload-time = "2025-11-12T15:20:36.611Z" }, - { url = "https://files.pythonhosted.org/packages/0f/27/07c645c7673e73e53ded71705045d6cb5bae94c4b021b03aa8d03eee90ab/torch-2.9.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:da5f6f4d7f4940a173e5572791af238cb0b9e21b1aab592bd8b26da4c99f1cd6", size = 104126592, upload-time = "2025-11-12T15:20:41.62Z" }, - { url = "https://files.pythonhosted.org/packages/19/17/e377a460603132b00760511299fceba4102bd95db1a0ee788da21298ccff/torch-2.9.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:27331cd902fb4322252657f3902adf1c4f6acad9dcad81d8df3ae14c7c4f07c4", size = 899742281, upload-time = "2025-11-12T15:22:17.602Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1a/64f5769025db846a82567fa5b7d21dba4558a7234ee631712ee4771c436c/torch-2.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:81a285002d7b8cfd3fdf1b98aa8df138d41f1a8334fd9ea37511517cedf43083", size = 110940568, upload-time = "2025-11-12T15:21:18.689Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ab/07739fd776618e5882661d04c43f5b5586323e2f6a2d7d84aac20d8f20bd/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:c0d25d1d8e531b8343bea0ed811d5d528958f1dcbd37e7245bc686273177ad7e", size = 74479191, upload-time = "2025-11-12T15:21:25.816Z" }, - { url = "https://files.pythonhosted.org/packages/20/60/8fc5e828d050bddfab469b3fe78e5ab9a7e53dda9c3bdc6a43d17ce99e63/torch-2.9.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c29455d2b910b98738131990394da3e50eea8291dfeb4b12de71ecf1fdeb21cb", size = 104135743, upload-time = "2025-11-12T15:21:34.936Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b7/6d3f80e6918213babddb2a37b46dbb14c15b14c5f473e347869a51f40e1f/torch-2.9.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:524de44cd13931208ba2c4bde9ec7741fd4ae6bfd06409a604fc32f6520c2bc9", size = 899749493, upload-time = "2025-11-12T15:24:36.356Z" }, - { url = "https://files.pythonhosted.org/packages/a6/47/c7843d69d6de8938c1cbb1eba426b1d48ddf375f101473d3e31a5fc52b74/torch-2.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:545844cc16b3f91e08ce3b40e9c2d77012dd33a48d505aed34b7740ed627a1b2", size = 110944162, upload-time = "2025-11-12T15:21:53.151Z" }, - { url = "https://files.pythonhosted.org/packages/28/0e/2a37247957e72c12151b33a01e4df651d9d155dd74d8cfcbfad15a79b44a/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5be4bf7496f1e3ffb1dd44b672adb1ac3f081f204c5ca81eba6442f5f634df8e", size = 74830751, upload-time = "2025-11-12T15:21:43.792Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f7/7a18745edcd7b9ca2381aa03353647bca8aace91683c4975f19ac233809d/torch-2.9.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:30a3e170a84894f3652434b56d59a64a2c11366b0ed5776fab33c2439396bf9a", size = 104142929, upload-time = "2025-11-12T15:21:48.319Z" }, - { url = "https://files.pythonhosted.org/packages/f4/dd/f1c0d879f2863ef209e18823a988dc7a1bf40470750e3ebe927efdb9407f/torch-2.9.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8301a7b431e51764629208d0edaa4f9e4c33e6df0f2f90b90e261d623df6a4e2", size = 899748978, upload-time = "2025-11-12T15:23:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/1f/9f/6986b83a53b4d043e36f3f898b798ab51f7f20fdf1a9b01a2720f445043d/torch-2.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2e1c42c0ae92bf803a4b2409fdfed85e30f9027a66887f5e7dcdbc014c7531db", size = 111176995, upload-time = "2025-11-12T15:22:01.618Z" }, - { url = "https://files.pythonhosted.org/packages/40/60/71c698b466dd01e65d0e9514b5405faae200c52a76901baf6906856f17e4/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:2c14b3da5df416cf9cb5efab83aa3056f5b8cd8620b8fde81b4987ecab730587", size = 74480347, upload-time = "2025-11-12T15:21:57.648Z" }, - { url = "https://files.pythonhosted.org/packages/48/50/c4b5112546d0d13cc9eaa1c732b823d676a9f49ae8b6f97772f795874a03/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1edee27a7c9897f4e0b7c14cfc2f3008c571921134522d5b9b5ec4ebbc69041a", size = 74433245, upload-time = "2025-11-12T15:22:39.027Z" }, - { url = "https://files.pythonhosted.org/packages/81/c9/2628f408f0518b3bae49c95f5af3728b6ab498c8624ab1e03a43dd53d650/torch-2.9.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:19d144d6b3e29921f1fc70503e9f2fc572cde6a5115c0c0de2f7ca8b1483e8b6", size = 104134804, upload-time = "2025-11-12T15:22:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/28/fc/5bc91d6d831ae41bf6e9e6da6468f25330522e92347c9156eb3f1cb95956/torch-2.9.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c432d04376f6d9767a9852ea0def7b47a7bbc8e7af3b16ac9cf9ce02b12851c9", size = 899747132, upload-time = "2025-11-12T15:23:36.068Z" }, - { url = "https://files.pythonhosted.org/packages/63/5d/e8d4e009e52b6b2cf1684bde2a6be157b96fb873732542fb2a9a99e85a83/torch-2.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:d187566a2cdc726fc80138c3cdb260970fab1c27e99f85452721f7759bbd554d", size = 110934845, upload-time = "2025-11-12T15:22:48.367Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b2/2d15a52516b2ea3f414643b8de68fa4cb220d3877ac8b1028c83dc8ca1c4/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb10896a1f7fedaddbccc2017ce6ca9ecaaf990f0973bdfcf405439750118d2c", size = 74823558, upload-time = "2025-11-12T15:22:43.392Z" }, - { url = "https://files.pythonhosted.org/packages/86/5c/5b2e5d84f5b9850cd1e71af07524d8cbb74cba19379800f1f9f7c997fc70/torch-2.9.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0a2bd769944991c74acf0c4ef23603b9c777fdf7637f115605a4b2d8023110c7", size = 104145788, upload-time = "2025-11-12T15:23:52.109Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8c/3da60787bcf70add986c4ad485993026ac0ca74f2fc21410bc4eb1bb7695/torch-2.9.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:07c8a9660bc9414c39cac530ac83b1fb1b679d7155824144a40a54f4a47bfa73", size = 899735500, upload-time = "2025-11-12T15:24:08.788Z" }, - { url = "https://files.pythonhosted.org/packages/db/2b/f7818f6ec88758dfd21da46b6cd46af9d1b3433e53ddbb19ad1e0da17f9b/torch-2.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c88d3299ddeb2b35dcc31753305612db485ab6f1823e37fb29451c8b2732b87e", size = 111163659, upload-time = "2025-11-12T15:23:20.009Z" }, + { url = "https://files.pythonhosted.org/packages/15/db/c064112ac0089af3d2f7a2b5bfbabf4aa407a78b74f87889e524b91c5402/torch-2.9.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:62b3fd888277946918cba4478cf849303da5359f0fb4e3bfb86b0533ba2eaf8d", size = 104220430 }, + { url = "https://files.pythonhosted.org/packages/56/be/76eaa36c9cd032d3b01b001e2c5a05943df75f26211f68fae79e62f87734/torch-2.9.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d033ff0ac3f5400df862a51bdde9bad83561f3739ea0046e68f5401ebfa67c1b", size = 899821446 }, + { url = "https://files.pythonhosted.org/packages/47/cc/7a2949e38dfe3244c4df21f0e1c27bce8aedd6c604a587dd44fc21017cb4/torch-2.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d06b30a9207b7c3516a9e0102114024755a07045f0c1d2f2a56b1819ac06bcb", size = 110973074 }, + { url = "https://files.pythonhosted.org/packages/1e/ce/7d251155a783fb2c1bb6837b2b7023c622a2070a0a72726ca1df47e7ea34/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:52347912d868653e1528b47cafaf79b285b98be3f4f35d5955389b1b95224475", size = 74463887 }, + { url = "https://files.pythonhosted.org/packages/0f/27/07c645c7673e73e53ded71705045d6cb5bae94c4b021b03aa8d03eee90ab/torch-2.9.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:da5f6f4d7f4940a173e5572791af238cb0b9e21b1aab592bd8b26da4c99f1cd6", size = 104126592 }, + { url = "https://files.pythonhosted.org/packages/19/17/e377a460603132b00760511299fceba4102bd95db1a0ee788da21298ccff/torch-2.9.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:27331cd902fb4322252657f3902adf1c4f6acad9dcad81d8df3ae14c7c4f07c4", size = 899742281 }, + { url = "https://files.pythonhosted.org/packages/b1/1a/64f5769025db846a82567fa5b7d21dba4558a7234ee631712ee4771c436c/torch-2.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:81a285002d7b8cfd3fdf1b98aa8df138d41f1a8334fd9ea37511517cedf43083", size = 110940568 }, + { url = "https://files.pythonhosted.org/packages/6e/ab/07739fd776618e5882661d04c43f5b5586323e2f6a2d7d84aac20d8f20bd/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:c0d25d1d8e531b8343bea0ed811d5d528958f1dcbd37e7245bc686273177ad7e", size = 74479191 }, + { url = "https://files.pythonhosted.org/packages/20/60/8fc5e828d050bddfab469b3fe78e5ab9a7e53dda9c3bdc6a43d17ce99e63/torch-2.9.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c29455d2b910b98738131990394da3e50eea8291dfeb4b12de71ecf1fdeb21cb", size = 104135743 }, + { url = "https://files.pythonhosted.org/packages/f2/b7/6d3f80e6918213babddb2a37b46dbb14c15b14c5f473e347869a51f40e1f/torch-2.9.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:524de44cd13931208ba2c4bde9ec7741fd4ae6bfd06409a604fc32f6520c2bc9", size = 899749493 }, + { url = "https://files.pythonhosted.org/packages/a6/47/c7843d69d6de8938c1cbb1eba426b1d48ddf375f101473d3e31a5fc52b74/torch-2.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:545844cc16b3f91e08ce3b40e9c2d77012dd33a48d505aed34b7740ed627a1b2", size = 110944162 }, + { url = "https://files.pythonhosted.org/packages/28/0e/2a37247957e72c12151b33a01e4df651d9d155dd74d8cfcbfad15a79b44a/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5be4bf7496f1e3ffb1dd44b672adb1ac3f081f204c5ca81eba6442f5f634df8e", size = 74830751 }, + { url = "https://files.pythonhosted.org/packages/4b/f7/7a18745edcd7b9ca2381aa03353647bca8aace91683c4975f19ac233809d/torch-2.9.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:30a3e170a84894f3652434b56d59a64a2c11366b0ed5776fab33c2439396bf9a", size = 104142929 }, + { url = "https://files.pythonhosted.org/packages/f4/dd/f1c0d879f2863ef209e18823a988dc7a1bf40470750e3ebe927efdb9407f/torch-2.9.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8301a7b431e51764629208d0edaa4f9e4c33e6df0f2f90b90e261d623df6a4e2", size = 899748978 }, + { url = "https://files.pythonhosted.org/packages/1f/9f/6986b83a53b4d043e36f3f898b798ab51f7f20fdf1a9b01a2720f445043d/torch-2.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2e1c42c0ae92bf803a4b2409fdfed85e30f9027a66887f5e7dcdbc014c7531db", size = 111176995 }, + { url = "https://files.pythonhosted.org/packages/40/60/71c698b466dd01e65d0e9514b5405faae200c52a76901baf6906856f17e4/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:2c14b3da5df416cf9cb5efab83aa3056f5b8cd8620b8fde81b4987ecab730587", size = 74480347 }, + { url = "https://files.pythonhosted.org/packages/48/50/c4b5112546d0d13cc9eaa1c732b823d676a9f49ae8b6f97772f795874a03/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1edee27a7c9897f4e0b7c14cfc2f3008c571921134522d5b9b5ec4ebbc69041a", size = 74433245 }, + { url = "https://files.pythonhosted.org/packages/81/c9/2628f408f0518b3bae49c95f5af3728b6ab498c8624ab1e03a43dd53d650/torch-2.9.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:19d144d6b3e29921f1fc70503e9f2fc572cde6a5115c0c0de2f7ca8b1483e8b6", size = 104134804 }, + { url = "https://files.pythonhosted.org/packages/28/fc/5bc91d6d831ae41bf6e9e6da6468f25330522e92347c9156eb3f1cb95956/torch-2.9.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c432d04376f6d9767a9852ea0def7b47a7bbc8e7af3b16ac9cf9ce02b12851c9", size = 899747132 }, + { url = "https://files.pythonhosted.org/packages/63/5d/e8d4e009e52b6b2cf1684bde2a6be157b96fb873732542fb2a9a99e85a83/torch-2.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:d187566a2cdc726fc80138c3cdb260970fab1c27e99f85452721f7759bbd554d", size = 110934845 }, + { url = "https://files.pythonhosted.org/packages/bd/b2/2d15a52516b2ea3f414643b8de68fa4cb220d3877ac8b1028c83dc8ca1c4/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb10896a1f7fedaddbccc2017ce6ca9ecaaf990f0973bdfcf405439750118d2c", size = 74823558 }, + { url = "https://files.pythonhosted.org/packages/86/5c/5b2e5d84f5b9850cd1e71af07524d8cbb74cba19379800f1f9f7c997fc70/torch-2.9.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0a2bd769944991c74acf0c4ef23603b9c777fdf7637f115605a4b2d8023110c7", size = 104145788 }, + { url = "https://files.pythonhosted.org/packages/a9/8c/3da60787bcf70add986c4ad485993026ac0ca74f2fc21410bc4eb1bb7695/torch-2.9.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:07c8a9660bc9414c39cac530ac83b1fb1b679d7155824144a40a54f4a47bfa73", size = 899735500 }, + { url = "https://files.pythonhosted.org/packages/db/2b/f7818f6ec88758dfd21da46b6cd46af9d1b3433e53ddbb19ad1e0da17f9b/torch-2.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c88d3299ddeb2b35dcc31753305612db485ab6f1823e37fb29451c8b2732b87e", size = 111163659 }, ] [[package]] @@ -3216,9 +3218,9 @@ dependencies = [ { name = "packaging" }, { name = "torch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679, upload-time = "2025-09-03T14:00:54.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679 } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161, upload-time = "2025-09-03T14:00:51.921Z" }, + { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161 }, ] [[package]] @@ -3231,49 +3233,49 @@ dependencies = [ { name = "torch" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/69/30f5f03752aa1a7c23931d2519b31e557f3f10af5089d787cddf3b903ecf/torchvision-0.24.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:056c525dc875f18fe8e9c27079ada166a7b2755cea5a2199b0bc7f1f8364e600", size = 1891436, upload-time = "2025-11-12T15:25:04.3Z" }, - { url = "https://files.pythonhosted.org/packages/0c/69/49aae86edb75fe16460b59a191fcc0f568c2378f780bb063850db0fe007a/torchvision-0.24.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1e39619de698e2821d71976c92c8a9e50cdfd1e993507dfb340f2688bfdd8283", size = 2387757, upload-time = "2025-11-12T15:25:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/1dfc3db98797b326f1d0c3f3bb61c83b167a813fc7eab6fcd2edb8c7eb9d/torchvision-0.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a0f106663e60332aa4fcb1ca2159ef8c3f2ed266b0e6df88de261048a840e0df", size = 8047682, upload-time = "2025-11-12T15:25:21.125Z" }, - { url = "https://files.pythonhosted.org/packages/fa/bb/cfc6a6f6ccc84a534ed1fdf029ae5716dd6ff04e57ed9dc2dab38bf652d5/torchvision-0.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:a9308cdd37d8a42e14a3e7fd9d271830c7fecb150dd929b642f3c1460514599a", size = 4037588, upload-time = "2025-11-12T15:25:14.402Z" }, - { url = "https://files.pythonhosted.org/packages/f0/af/18e2c6b9538a045f60718a0c5a058908ccb24f88fde8e6f0fc12d5ff7bd3/torchvision-0.24.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e48bf6a8ec95872eb45763f06499f87bd2fb246b9b96cb00aae260fda2f96193", size = 1891433, upload-time = "2025-11-12T15:25:03.232Z" }, - { url = "https://files.pythonhosted.org/packages/9d/43/600e5cfb0643d10d633124f5982d7abc2170dfd7ce985584ff16edab3e76/torchvision-0.24.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7fb7590c737ebe3e1c077ad60c0e5e2e56bb26e7bccc3b9d04dbfc34fd09f050", size = 2386737, upload-time = "2025-11-12T15:25:08.288Z" }, - { url = "https://files.pythonhosted.org/packages/93/b1/db2941526ecddd84884132e2742a55c9311296a6a38627f9e2627f5ac889/torchvision-0.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:66a98471fc18cad9064123106d810a75f57f0838eee20edc56233fd8484b0cc7", size = 8049868, upload-time = "2025-11-12T15:25:13.058Z" }, - { url = "https://files.pythonhosted.org/packages/69/98/16e583f59f86cd59949f59d52bfa8fc286f86341a229a9d15cbe7a694f0c/torchvision-0.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:4aa6cb806eb8541e92c9b313e96192c6b826e9eb0042720e2fa250d021079952", size = 4302006, upload-time = "2025-11-12T15:25:16.184Z" }, - { url = "https://files.pythonhosted.org/packages/e4/97/ab40550f482577f2788304c27220e8ba02c63313bd74cf2f8920526aac20/torchvision-0.24.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8a6696db7fb71eadb2c6a48602106e136c785642e598eb1533e0b27744f2cce6", size = 1891435, upload-time = "2025-11-12T15:25:28.642Z" }, - { url = "https://files.pythonhosted.org/packages/30/65/ac0a3f9be6abdbe4e1d82c915d7e20de97e7fd0e9a277970508b015309f3/torchvision-0.24.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:db2125c46f9cb25dc740be831ce3ce99303cfe60439249a41b04fd9f373be671", size = 2338718, upload-time = "2025-11-12T15:25:26.19Z" }, - { url = "https://files.pythonhosted.org/packages/10/b5/5bba24ff9d325181508501ed7f0c3de8ed3dd2edca0784d48b144b6c5252/torchvision-0.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f035f0cacd1f44a8ff6cb7ca3627d84c54d685055961d73a1a9fb9827a5414c8", size = 8049661, upload-time = "2025-11-12T15:25:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ec/54a96ae9ab6a0dd66d4bba27771f892e36478a9c3489fa56e51c70abcc4d/torchvision-0.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:16274823b93048e0a29d83415166a2e9e0bf4e1b432668357b657612a4802864", size = 4319808, upload-time = "2025-11-12T15:25:17.318Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f3/a90a389a7e547f3eb8821b13f96ea7c0563cdefbbbb60a10e08dda9720ff/torchvision-0.24.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e3f96208b4bef54cd60e415545f5200346a65024e04f29a26cd0006dbf9e8e66", size = 2005342, upload-time = "2025-11-12T15:25:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/a9/fe/ff27d2ed1b524078164bea1062f23d2618a5fc3208e247d6153c18c91a76/torchvision-0.24.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f231f6a4f2aa6522713326d0d2563538fa72d613741ae364f9913027fa52ea35", size = 2341708, upload-time = "2025-11-12T15:25:25.08Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b9/d6c903495cbdfd2533b3ef6f7b5643ff589ea062f8feb5c206ee79b9d9e5/torchvision-0.24.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:1540a9e7f8cf55fe17554482f5a125a7e426347b71de07327d5de6bfd8d17caa", size = 8177239, upload-time = "2025-11-12T15:25:18.554Z" }, - { url = "https://files.pythonhosted.org/packages/4f/2b/ba02e4261369c3798310483028495cf507e6cb3f394f42e4796981ecf3a7/torchvision-0.24.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d83e16d70ea85d2f196d678bfb702c36be7a655b003abed84e465988b6128938", size = 4251604, upload-time = "2025-11-12T15:25:34.069Z" }, - { url = "https://files.pythonhosted.org/packages/42/84/577b2cef8f32094add5f52887867da4c2a3e6b4261538447e9b48eb25812/torchvision-0.24.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cccf4b4fec7fdfcd3431b9ea75d1588c0a8596d0333245dafebee0462abe3388", size = 2005319, upload-time = "2025-11-12T15:25:23.827Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/ecb786bffe0159a3b49941a61caaae089853132f3cd1e8f555e3621f7e6f/torchvision-0.24.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1b495edd3a8f9911292424117544f0b4ab780452e998649425d1f4b2bed6695f", size = 2338844, upload-time = "2025-11-12T15:25:32.625Z" }, - { url = "https://files.pythonhosted.org/packages/51/99/a84623786a6969504c87f2dc3892200f586ee13503f519d282faab0bb4f0/torchvision-0.24.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ab211e1807dc3e53acf8f6638df9a7444c80c0ad050466e8d652b3e83776987b", size = 8175144, upload-time = "2025-11-12T15:25:31.355Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ba/8fae3525b233e109317ce6a9c1de922ab2881737b029a7e88021f81e068f/torchvision-0.24.1-cp314-cp314-win_amd64.whl", hash = "sha256:18f9cb60e64b37b551cd605a3d62c15730c086362b40682d23e24b616a697d41", size = 4234459, upload-time = "2025-11-12T15:25:19.859Z" }, - { url = "https://files.pythonhosted.org/packages/50/33/481602c1c72d0485d4b3a6b48c9534b71c2957c9d83bf860eb837bf5a620/torchvision-0.24.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec9d7379c519428395e4ffda4dbb99ec56be64b0a75b95989e00f9ec7ae0b2d7", size = 2005336, upload-time = "2025-11-12T15:25:27.225Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7f/372de60bf3dd8f5593bd0d03f4aecf0d1fd58f5bc6943618d9d913f5e6d5/torchvision-0.24.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:af9201184c2712d808bd4eb656899011afdfce1e83721c7cb08000034df353fe", size = 2341704, upload-time = "2025-11-12T15:25:29.857Z" }, - { url = "https://files.pythonhosted.org/packages/36/9b/0f3b9ff3d0225ee2324ec663de0e7fb3eb855615ca958ac1875f22f1f8e5/torchvision-0.24.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9ef95d819fd6df81bc7cc97b8f21a15d2c0d3ac5dbfaab5cbc2d2ce57114b19e", size = 8177422, upload-time = "2025-11-12T15:25:37.357Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ab/e2bcc7c2f13d882a58f8b30ff86f794210b075736587ea50f8c545834f8a/torchvision-0.24.1-cp314-cp314t-win_amd64.whl", hash = "sha256:480b271d6edff83ac2e8d69bbb4cf2073f93366516a50d48f140ccfceedb002e", size = 4335190, upload-time = "2025-11-12T15:25:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/e7/69/30f5f03752aa1a7c23931d2519b31e557f3f10af5089d787cddf3b903ecf/torchvision-0.24.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:056c525dc875f18fe8e9c27079ada166a7b2755cea5a2199b0bc7f1f8364e600", size = 1891436 }, + { url = "https://files.pythonhosted.org/packages/0c/69/49aae86edb75fe16460b59a191fcc0f568c2378f780bb063850db0fe007a/torchvision-0.24.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1e39619de698e2821d71976c92c8a9e50cdfd1e993507dfb340f2688bfdd8283", size = 2387757 }, + { url = "https://files.pythonhosted.org/packages/11/c9/1dfc3db98797b326f1d0c3f3bb61c83b167a813fc7eab6fcd2edb8c7eb9d/torchvision-0.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a0f106663e60332aa4fcb1ca2159ef8c3f2ed266b0e6df88de261048a840e0df", size = 8047682 }, + { url = "https://files.pythonhosted.org/packages/fa/bb/cfc6a6f6ccc84a534ed1fdf029ae5716dd6ff04e57ed9dc2dab38bf652d5/torchvision-0.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:a9308cdd37d8a42e14a3e7fd9d271830c7fecb150dd929b642f3c1460514599a", size = 4037588 }, + { url = "https://files.pythonhosted.org/packages/f0/af/18e2c6b9538a045f60718a0c5a058908ccb24f88fde8e6f0fc12d5ff7bd3/torchvision-0.24.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e48bf6a8ec95872eb45763f06499f87bd2fb246b9b96cb00aae260fda2f96193", size = 1891433 }, + { url = "https://files.pythonhosted.org/packages/9d/43/600e5cfb0643d10d633124f5982d7abc2170dfd7ce985584ff16edab3e76/torchvision-0.24.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7fb7590c737ebe3e1c077ad60c0e5e2e56bb26e7bccc3b9d04dbfc34fd09f050", size = 2386737 }, + { url = "https://files.pythonhosted.org/packages/93/b1/db2941526ecddd84884132e2742a55c9311296a6a38627f9e2627f5ac889/torchvision-0.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:66a98471fc18cad9064123106d810a75f57f0838eee20edc56233fd8484b0cc7", size = 8049868 }, + { url = "https://files.pythonhosted.org/packages/69/98/16e583f59f86cd59949f59d52bfa8fc286f86341a229a9d15cbe7a694f0c/torchvision-0.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:4aa6cb806eb8541e92c9b313e96192c6b826e9eb0042720e2fa250d021079952", size = 4302006 }, + { url = "https://files.pythonhosted.org/packages/e4/97/ab40550f482577f2788304c27220e8ba02c63313bd74cf2f8920526aac20/torchvision-0.24.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8a6696db7fb71eadb2c6a48602106e136c785642e598eb1533e0b27744f2cce6", size = 1891435 }, + { url = "https://files.pythonhosted.org/packages/30/65/ac0a3f9be6abdbe4e1d82c915d7e20de97e7fd0e9a277970508b015309f3/torchvision-0.24.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:db2125c46f9cb25dc740be831ce3ce99303cfe60439249a41b04fd9f373be671", size = 2338718 }, + { url = "https://files.pythonhosted.org/packages/10/b5/5bba24ff9d325181508501ed7f0c3de8ed3dd2edca0784d48b144b6c5252/torchvision-0.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f035f0cacd1f44a8ff6cb7ca3627d84c54d685055961d73a1a9fb9827a5414c8", size = 8049661 }, + { url = "https://files.pythonhosted.org/packages/5c/ec/54a96ae9ab6a0dd66d4bba27771f892e36478a9c3489fa56e51c70abcc4d/torchvision-0.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:16274823b93048e0a29d83415166a2e9e0bf4e1b432668357b657612a4802864", size = 4319808 }, + { url = "https://files.pythonhosted.org/packages/d5/f3/a90a389a7e547f3eb8821b13f96ea7c0563cdefbbbb60a10e08dda9720ff/torchvision-0.24.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e3f96208b4bef54cd60e415545f5200346a65024e04f29a26cd0006dbf9e8e66", size = 2005342 }, + { url = "https://files.pythonhosted.org/packages/a9/fe/ff27d2ed1b524078164bea1062f23d2618a5fc3208e247d6153c18c91a76/torchvision-0.24.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f231f6a4f2aa6522713326d0d2563538fa72d613741ae364f9913027fa52ea35", size = 2341708 }, + { url = "https://files.pythonhosted.org/packages/b1/b9/d6c903495cbdfd2533b3ef6f7b5643ff589ea062f8feb5c206ee79b9d9e5/torchvision-0.24.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:1540a9e7f8cf55fe17554482f5a125a7e426347b71de07327d5de6bfd8d17caa", size = 8177239 }, + { url = "https://files.pythonhosted.org/packages/4f/2b/ba02e4261369c3798310483028495cf507e6cb3f394f42e4796981ecf3a7/torchvision-0.24.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d83e16d70ea85d2f196d678bfb702c36be7a655b003abed84e465988b6128938", size = 4251604 }, + { url = "https://files.pythonhosted.org/packages/42/84/577b2cef8f32094add5f52887867da4c2a3e6b4261538447e9b48eb25812/torchvision-0.24.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cccf4b4fec7fdfcd3431b9ea75d1588c0a8596d0333245dafebee0462abe3388", size = 2005319 }, + { url = "https://files.pythonhosted.org/packages/5f/34/ecb786bffe0159a3b49941a61caaae089853132f3cd1e8f555e3621f7e6f/torchvision-0.24.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1b495edd3a8f9911292424117544f0b4ab780452e998649425d1f4b2bed6695f", size = 2338844 }, + { url = "https://files.pythonhosted.org/packages/51/99/a84623786a6969504c87f2dc3892200f586ee13503f519d282faab0bb4f0/torchvision-0.24.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ab211e1807dc3e53acf8f6638df9a7444c80c0ad050466e8d652b3e83776987b", size = 8175144 }, + { url = "https://files.pythonhosted.org/packages/6d/ba/8fae3525b233e109317ce6a9c1de922ab2881737b029a7e88021f81e068f/torchvision-0.24.1-cp314-cp314-win_amd64.whl", hash = "sha256:18f9cb60e64b37b551cd605a3d62c15730c086362b40682d23e24b616a697d41", size = 4234459 }, + { url = "https://files.pythonhosted.org/packages/50/33/481602c1c72d0485d4b3a6b48c9534b71c2957c9d83bf860eb837bf5a620/torchvision-0.24.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec9d7379c519428395e4ffda4dbb99ec56be64b0a75b95989e00f9ec7ae0b2d7", size = 2005336 }, + { url = "https://files.pythonhosted.org/packages/d0/7f/372de60bf3dd8f5593bd0d03f4aecf0d1fd58f5bc6943618d9d913f5e6d5/torchvision-0.24.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:af9201184c2712d808bd4eb656899011afdfce1e83721c7cb08000034df353fe", size = 2341704 }, + { url = "https://files.pythonhosted.org/packages/36/9b/0f3b9ff3d0225ee2324ec663de0e7fb3eb855615ca958ac1875f22f1f8e5/torchvision-0.24.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9ef95d819fd6df81bc7cc97b8f21a15d2c0d3ac5dbfaab5cbc2d2ce57114b19e", size = 8177422 }, + { url = "https://files.pythonhosted.org/packages/d6/ab/e2bcc7c2f13d882a58f8b30ff86f794210b075736587ea50f8c545834f8a/torchvision-0.24.1-cp314-cp314t-win_amd64.whl", hash = "sha256:480b271d6edff83ac2e8d69bbb4cf2073f93366516a50d48f140ccfceedb002e", size = 4335190 }, ] [[package]] name = "tornado" version = "6.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, - { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, - { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, - { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, - { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563 }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729 }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295 }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644 }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878 }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549 }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973 }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954 }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023 }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427 }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456 }, ] [[package]] @@ -3283,18 +3285,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, ] [[package]] name = "traitlets" version = "5.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, ] [[package]] @@ -3302,48 +3304,48 @@ name = "triton" version = "3.5.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/72/ec90c3519eaf168f22cb1757ad412f3a2add4782ad3a92861c9ad135d886/triton-3.5.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61413522a48add32302353fdbaaf92daaaab06f6b5e3229940d21b5207f47579", size = 170425802, upload-time = "2025-11-11T17:40:53.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/50/9a8358d3ef58162c0a415d173cfb45b67de60176e1024f71fbc4d24c0b6d/triton-3.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d2c6b915a03888ab931a9fd3e55ba36785e1fe70cbea0b40c6ef93b20fc85232", size = 170470207, upload-time = "2025-11-11T17:41:00.253Z" }, - { url = "https://files.pythonhosted.org/packages/27/46/8c3bbb5b0a19313f50edcaa363b599e5a1a5ac9683ead82b9b80fe497c8d/triton-3.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3f4346b6ebbd4fad18773f5ba839114f4826037c9f2f34e0148894cd5dd3dba", size = 170470410, upload-time = "2025-11-11T17:41:06.319Z" }, - { url = "https://files.pythonhosted.org/packages/37/92/e97fcc6b2c27cdb87ce5ee063d77f8f26f19f06916aa680464c8104ef0f6/triton-3.5.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b4d2c70127fca6a23e247f9348b8adde979d2e7a20391bfbabaac6aebc7e6a8", size = 170579924, upload-time = "2025-11-11T17:41:12.455Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e6/c595c35e5c50c4bc56a7bac96493dad321e9e29b953b526bbbe20f9911d0/triton-3.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0637b1efb1db599a8e9dc960d53ab6e4637db7d4ab6630a0974705d77b14b60", size = 170480488, upload-time = "2025-11-11T17:41:18.222Z" }, - { url = "https://files.pythonhosted.org/packages/16/b5/b0d3d8b901b6a04ca38df5e24c27e53afb15b93624d7fd7d658c7cd9352a/triton-3.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bac7f7d959ad0f48c0e97d6643a1cc0fd5786fe61cb1f83b537c6b2d54776478", size = 170582192, upload-time = "2025-11-11T17:41:23.963Z" }, + { url = "https://files.pythonhosted.org/packages/b0/72/ec90c3519eaf168f22cb1757ad412f3a2add4782ad3a92861c9ad135d886/triton-3.5.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61413522a48add32302353fdbaaf92daaaab06f6b5e3229940d21b5207f47579", size = 170425802 }, + { url = "https://files.pythonhosted.org/packages/f2/50/9a8358d3ef58162c0a415d173cfb45b67de60176e1024f71fbc4d24c0b6d/triton-3.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d2c6b915a03888ab931a9fd3e55ba36785e1fe70cbea0b40c6ef93b20fc85232", size = 170470207 }, + { url = "https://files.pythonhosted.org/packages/27/46/8c3bbb5b0a19313f50edcaa363b599e5a1a5ac9683ead82b9b80fe497c8d/triton-3.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3f4346b6ebbd4fad18773f5ba839114f4826037c9f2f34e0148894cd5dd3dba", size = 170470410 }, + { url = "https://files.pythonhosted.org/packages/37/92/e97fcc6b2c27cdb87ce5ee063d77f8f26f19f06916aa680464c8104ef0f6/triton-3.5.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b4d2c70127fca6a23e247f9348b8adde979d2e7a20391bfbabaac6aebc7e6a8", size = 170579924 }, + { url = "https://files.pythonhosted.org/packages/a4/e6/c595c35e5c50c4bc56a7bac96493dad321e9e29b953b526bbbe20f9911d0/triton-3.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0637b1efb1db599a8e9dc960d53ab6e4637db7d4ab6630a0974705d77b14b60", size = 170480488 }, + { url = "https://files.pythonhosted.org/packages/16/b5/b0d3d8b901b6a04ca38df5e24c27e53afb15b93624d7fd7d658c7cd9352a/triton-3.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bac7f7d959ad0f48c0e97d6643a1cc0fd5786fe61cb1f83b537c6b2d54776478", size = 170582192 }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, ] [[package]] name = "uri-template" version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140 }, ] [[package]] name = "urllib3" version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, ] [[package]] @@ -3355,45 +3357,45 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799 } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095 }, ] [[package]] name = "wcwidth" version = "0.2.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293 } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286 }, ] [[package]] name = "webcolors" version = "25.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905 }, ] [[package]] name = "webencodings" version = "0.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, ] [[package]] name = "websocket-client" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, ] [[package]] @@ -3403,9 +3405,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, ] [[package]] @@ -3419,16 +3421,16 @@ dependencies = [ { name = "packaging" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/67/14be68a7bad15eecda09b1e81fca2420f7533645fe187bf4d6104c1aad52/zarr-3.1.3.tar.gz", hash = "sha256:01342f3e26a02ed5670db608a5576fbdb8d76acb5c280bd2d0082454b1ba6f79", size = 349125, upload-time = "2025-09-18T19:32:41.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/67/14be68a7bad15eecda09b1e81fca2420f7533645fe187bf4d6104c1aad52/zarr-3.1.3.tar.gz", hash = "sha256:01342f3e26a02ed5670db608a5576fbdb8d76acb5c280bd2d0082454b1ba6f79", size = 349125 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/71/9de7229515a53d1cc5705ca9c411530f711a2242f962214d9dbfe2741aa4/zarr-3.1.3-py3-none-any.whl", hash = "sha256:45f67f87f65f14fa453f99dd8110a5936b7ac69f3a21981d33e90407c80c302a", size = 276427, upload-time = "2025-09-18T19:32:40.042Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/9de7229515a53d1cc5705ca9c411530f711a2242f962214d9dbfe2741aa4/zarr-3.1.3-py3-none-any.whl", hash = "sha256:45f67f87f65f14fa453f99dd8110a5936b7ac69f3a21981d33e90407c80c302a", size = 276427 }, ] [[package]] name = "zipp" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, ] From 0852c49218e183244af0d5bd5779c805b8fbad25 Mon Sep 17 00:00:00 2001 From: cophus Date: Sun, 18 Jan 2026 16:52:56 -0800 Subject: [PATCH 09/88] temp removing function for merge --- src/quantem/core/utils/imaging_utils.py | 63 ------------------------- 1 file changed, 63 deletions(-) diff --git a/src/quantem/core/utils/imaging_utils.py b/src/quantem/core/utils/imaging_utils.py index 8a07b3c1..5dced5bc 100644 --- a/src/quantem/core/utils/imaging_utils.py +++ b/src/quantem/core/utils/imaging_utils.py @@ -548,66 +548,3 @@ def fourier_cropping( return result - -def rotate_image( - im, - rotation_deg: float, - origin: tuple[float, float] | None = None, - clockwise: bool = True, - interpolation: str = "bilinear", - mode: str = "constant", - cval: float = 0.0, -): - """Rotate an array about a pixel origin using bilinear/bicubic interpolation.""" - im = np.asarray(im) - if im.ndim < 2: - raise ValueError("im must have at least 2 dimensions") - - H, W = im.shape[-2], im.shape[-1] - if origin is None: - r0 = float(H // 2) - c0 = float(W // 2) - else: - r0 = float(origin[0]) - c0 = float(origin[1]) - - interp = str(interpolation).lower() - if interp in {"bilinear", "linear"}: - order = 1 - elif interp in {"bicubic", "cubic"}: - order = 3 - else: - raise ValueError("interpolation must be 'bilinear' or 'bicubic'") - - theta = float(np.deg2rad(rotation_deg)) - if not clockwise: - theta = -theta - - ct = float(np.cos(theta)) - st = float(np.sin(theta)) - - r_out, c_out = np.meshgrid( - np.arange(H, dtype=np.float64), - np.arange(W, dtype=np.float64), - indexing="ij", - ) - - c_rel = c_out - c0 - r_rel = r_out - r0 - - c_in = ct * c_rel + st * r_rel + c0 - r_in = -st * c_rel + ct * r_rel + r0 - - coords = np.vstack((r_in.ravel(), c_in.ravel())) - - if im.ndim == 2: - out = map_coordinates(im, coords, order=order, mode=mode, cval=cval) - return out.reshape(H, W) - - prefix = im.shape[:-2] - n = int(np.prod(prefix)) if prefix else 1 - im_flat = im.reshape(n, H, W) - out_flat = np.empty((n, H * W), dtype=np.result_type(im_flat.dtype, np.float64)) - for i in range(n): - out_flat[i] = map_coordinates(im_flat[i], coords, order=order, mode=mode, cval=cval) - return out_flat.reshape(*prefix, H, W) From 53ac2d5c473e8361393f15be30d0ac0d94a12203 Mon Sep 17 00:00:00 2001 From: cophus Date: Sun, 18 Jan 2026 16:53:47 -0800 Subject: [PATCH 10/88] adding rotate_image back --- src/quantem/core/utils/imaging_utils.py | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/quantem/core/utils/imaging_utils.py b/src/quantem/core/utils/imaging_utils.py index 9be6c9c4..ba5fc3e9 100644 --- a/src/quantem/core/utils/imaging_utils.py +++ b/src/quantem/core/utils/imaging_utils.py @@ -1049,3 +1049,68 @@ def unwrap_phase_2d_torch( raise ValueError( f'`method` must be one of {{"reliability-sorting", "poisson"}}, got {method!r}' ) + + + +def rotate_image( + im, + rotation_deg: float, + origin: tuple[float, float] | None = None, + clockwise: bool = True, + interpolation: str = "bilinear", + mode: str = "constant", + cval: float = 0.0, +): + """Rotate an array about a pixel origin using bilinear/bicubic interpolation.""" + im = np.asarray(im) + if im.ndim < 2: + raise ValueError("im must have at least 2 dimensions") + + H, W = im.shape[-2], im.shape[-1] + if origin is None: + r0 = float(H // 2) + c0 = float(W // 2) + else: + r0 = float(origin[0]) + c0 = float(origin[1]) + + interp = str(interpolation).lower() + if interp in {"bilinear", "linear"}: + order = 1 + elif interp in {"bicubic", "cubic"}: + order = 3 + else: + raise ValueError("interpolation must be 'bilinear' or 'bicubic'") + + theta = float(np.deg2rad(rotation_deg)) + if not clockwise: + theta = -theta + + ct = float(np.cos(theta)) + st = float(np.sin(theta)) + + r_out, c_out = np.meshgrid( + np.arange(H, dtype=np.float64), + np.arange(W, dtype=np.float64), + indexing="ij", + ) + + c_rel = c_out - c0 + r_rel = r_out - r0 + + c_in = ct * c_rel + st * r_rel + c0 + r_in = -st * c_rel + ct * r_rel + r0 + + coords = np.vstack((r_in.ravel(), c_in.ravel())) + + if im.ndim == 2: + out = map_coordinates(im, coords, order=order, mode=mode, cval=cval) + return out.reshape(H, W) + + prefix = im.shape[:-2] + n = int(np.prod(prefix)) if prefix else 1 + im_flat = im.reshape(n, H, W) + out_flat = np.empty((n, H * W), dtype=np.result_type(im_flat.dtype, np.float64)) + for i in range(n): + out_flat[i] = map_coordinates(im_flat[i], coords, order=order, mode=mode, cval=cval) + return out_flat.reshape(*prefix, H, W) From 22804b2289b9195900a4ef89e87c064b4489c9e4 Mon Sep 17 00:00:00 2001 From: cophus Date: Sun, 18 Jan 2026 20:23:34 -0800 Subject: [PATCH 11/88] strain mapping updates --- src/quantem/diffraction/__init__.py | 2 +- src/quantem/diffraction/strain.py | 953 ++++++++++++++++++---------- 2 files changed, 608 insertions(+), 347 deletions(-) diff --git a/src/quantem/diffraction/__init__.py b/src/quantem/diffraction/__init__.py index 6d0e4202..77bc5f82 100644 --- a/src/quantem/diffraction/__init__.py +++ b/src/quantem/diffraction/__init__.py @@ -1,2 +1,2 @@ from quantem.diffraction.polar import RDF as RDF -from quantem.diffraction.strain import StrainMap as StrainMap +from quantem.diffraction.strain import StrainMapAutocorrelation as StrainMapAutocorrelation diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py index bcfa981d..fa92c847 100644 --- a/src/quantem/diffraction/strain.py +++ b/src/quantem/diffraction/strain.py @@ -18,7 +18,7 @@ from quantem.core.visualization import ScalebarConfig, show_2d -class StrainMap(AutoSerialize): +class StrainMapAutocorrelation(AutoSerialize): _token = object() def __init__( @@ -28,7 +28,9 @@ def __init__( _token: object | None = None, ): if _token is not self._token: - raise RuntimeError("Use StrainMap.from_data() to instantiate this class.") + raise RuntimeError( + "Use StrainMapAutocorrelation.from_dataset() or StrainMapAutocorrelation.from_array() to instantiate this class." + ) super().__init__() self.dataset = dataset self.input_data = input_data @@ -36,106 +38,129 @@ def __init__( self.metadata: dict[str, Any] = {} self.transform: Dataset2d | None = None self.transform_rotated: Dataset2d | None = None + self.u: NDArray | None = None self.v: NDArray | None = None + + self.u_fit: Dataset3d | None = None + self.v_fit: Dataset3d | None = None + self.u_peak_fit: Dataset3d | None = None + self.v_peak_fit: Dataset3d | None = None + self.mask_diffraction = np.ones(self.dataset.array.shape[2:]) self.mask_diffraction_inv = np.zeros(self.dataset.array.shape[2:]) @classmethod - def from_data(cls, data: NDArray | Dataset4dstem, *, name: str = "strain_map") -> "StrainMap": - if isinstance(data, Dataset4dstem): - return cls(dataset=data, input_data=data, _token=cls._token) + def from_dataset(cls, dataset: Dataset4dstem, *, name: str | None = None) -> "StrainMapAutocorrelation": + if not isinstance(dataset, Dataset4dstem): + raise TypeError("StrainMapAutocorrelation.from_dataset expects a Dataset4dstem instance.") + if name is not None: + dataset.name = name + return cls(dataset=dataset, input_data=dataset, _token=cls._token) - arr = ensure_valid_array(data) + @classmethod + def from_array(cls, array: NDArray, *, name: str = "strain_map_autocorrelation") -> "StrainMapAutocorrelation": + arr = ensure_valid_array(array) if arr.ndim != 4: raise ValueError( - "StrainMap.from_data expects a 4D array with shape (scan_r, scan_c, dp_r, dp_c)." + "StrainMapAutocorrelation.from_array expects a 4D array with shape (scan_r, scan_c, dp_r, dp_c)." ) - ds4 = Dataset4dstem.from_array(arr, name=name) - return cls(dataset=ds4, input_data=data, _token=cls._token) - + return cls(dataset=ds4, input_data=array, _token=cls._token) def diffraction_mask( self, - threshold = None, - edge_blend = 64.0, - plot_mask = True, - figsize = (8,4), + threshold=None, + edge_blend=64.0, + plot_mask=True, + figsize=(8, 4), ): - dp_mean = np.mean(self.dataset.array,axis=(0,1)) + dp_mean = np.mean(self.dataset.array, axis=(0, 1)) mask_init = dp_mean < threshold - mask_init[:,0] = True - mask_init[0,:] = True - mask_init[:,-1] = True - mask_init[-1,:] = True + mask_init[:, 0] = True + mask_init[0, :] = True + mask_init[:, -1] = True + mask_init[-1, :] = True self.mask_diffraction = np.sin( np.clip( distance_transform_edt(np.logical_not(mask_init)) / edge_blend, 0.0, 1.0, - )*np.pi/2, - )**2 - # int_edge = np.sum(dp_mean*self.mask_diffraction) / np.sum(self.mask_diffraction) - int_edge = np.min(dp_mean[self.mask_diffraction>0.99]) + ) + * np.pi + / 2, + ) ** 2 + int_edge = np.min(dp_mean[self.mask_diffraction > 0.99]) self.mask_diffraction_inv = (1 - self.mask_diffraction) * int_edge if plot_mask: - fig,ax = plt.subplots(1,2,figsize=figsize) + fig, ax = plt.subplots(1, 2, figsize=figsize) ax[0].imshow( - np.log(np.maximum(dp_mean,np.min(dp_mean[dp_mean>0]))), - cmap = 'gray', + np.log(np.maximum(dp_mean, np.min(dp_mean[dp_mean > 0]))), + cmap="gray", ) ax[1].imshow( np.log( - dp_mean*self.mask_diffraction + \ - self.mask_diffraction_inv, + dp_mean * self.mask_diffraction + self.mask_diffraction_inv, ), - cmap = 'gray', + cmap="gray", ) - - return self + return self def preprocess( self, mode: str = "linear", q_to_r_rotation_ccw_deg: float | None = None, q_transpose: bool | None = None, - skip = None, + skip=None, plot_transform: bool = True, cropping_factor: float = 0.25, + gamma: float = 0.5, **plot_kwargs: Any, - ) -> "StrainMap": + ) -> "StrainMapAutocorrelation": + mode_in = mode.strip().lower() + if mode_in in {"linear", "patterson", "paterson", "acf", "autocorrelation"}: + mode_norm = "linear" + elif mode_in in {"log", "cepstrum", "cepstral"}: + mode_norm = "log" + elif mode_in in {"gamma", "power", "sqrt"}: + mode_norm = "gamma" + else: + raise ValueError( + "mode must be 'linear', 'log', or 'gamma' (aliases: 'patterson'->'linear', 'cepstrum'/'cepstral'->'log')." + ) - self.metadata["mode"] = mode + self.metadata["mode"] = mode_norm + if mode_norm == "gamma": + self.metadata["gamma"] = gamma - qrow_unit = str(self.dataset.units[2]) - qcol_unit = str(self.dataset.units[3]) + qrow_unit = self.dataset.units[2] + qcol_unit = self.dataset.units[3] if qrow_unit in {"A", "Å"}: - qrow_sampling_ang = float(self.dataset.sampling[2]) + qrow_sampling_ang = self.dataset.sampling[2] elif qrow_unit == "mrad": - wavelength = float(electron_wavelength_angstrom(float(self.dataset.metadata["energy"]))) - qrow_sampling_ang = float(self.dataset.sampling[2]) / 1000.0 / wavelength + wavelength = electron_wavelength_angstrom(self.dataset.metadata["energy"]) + qrow_sampling_ang = self.dataset.sampling[2] / 1000.0 / wavelength else: qrow_sampling_ang = 1.0 qrow_unit = "pixels" if qcol_unit in {"A", "Å"}: - qcol_sampling_ang = float(self.dataset.sampling[3]) + qcol_sampling_ang = self.dataset.sampling[3] elif qcol_unit == "mrad": - wavelength = float(electron_wavelength_angstrom(float(self.dataset.metadata["energy"]))) - qcol_sampling_ang = float(self.dataset.sampling[3]) / 1000.0 / wavelength + wavelength = electron_wavelength_angstrom(self.dataset.metadata["energy"]) + qcol_sampling_ang = self.dataset.sampling[3] / 1000.0 / wavelength else: qcol_sampling_ang = 1.0 qcol_unit = "pixels" self.metadata["sampling_real"] = np.array( ( - 1.0 / (qrow_sampling_ang * float(self.dataset.shape[2])), - 1.0 / (qcol_sampling_ang * float(self.dataset.shape[3])), + 1.0 / (qrow_sampling_ang * self.dataset.shape[2]), + 1.0 / (qcol_sampling_ang * self.dataset.shape[3]), ), dtype=float, ) @@ -145,67 +170,53 @@ def preprocess( else: self.metadata["real_units"] = r"$\mathrm{\AA}$" - if q_to_r_rotation_ccw_deg is None or q_transpose is None: - parent_rot = self.dataset.metadata.get("q_to_r_rotation_ccw_deg", None) - parent_tr = self.dataset.metadata.get("q_transpose", None) - if q_to_r_rotation_ccw_deg is None and parent_rot is not None: - q_to_r_rotation_ccw_deg = float(parent_rot) - if q_transpose is None and parent_tr is not None: - q_transpose = bool(parent_tr) - if (parent_rot is not None or parent_tr is not None) and ( - q_to_r_rotation_ccw_deg is not None or q_transpose is not None - ): - import warnings - - warnings.warn( - f"StrainMap.preprocess: using parent Dataset4dstem metadata " - f"(q_to_r_rotation_ccw_deg={float(q_to_r_rotation_ccw_deg or 0.0)}, " - f"q_transpose={bool(q_transpose or False)}).", - UserWarning, - ) + parent_rot = self.dataset.metadata.get("q_to_r_rotation_ccw_deg", None) + parent_tr = self.dataset.metadata.get("q_transpose", None) - if q_to_r_rotation_ccw_deg is None or q_transpose is None: + used_parent = False + if q_to_r_rotation_ccw_deg is None and parent_rot is not None: + q_to_r_rotation_ccw_deg = parent_rot + used_parent = True + if q_transpose is None and parent_tr is not None: + q_transpose = parent_tr + used_parent = True + + if used_parent: import warnings - q_to_r_rotation_ccw_deg = ( - 0.0 if q_to_r_rotation_ccw_deg is None else float(q_to_r_rotation_ccw_deg) + warnings.warn( + "StrainMapAutocorrelation.preprocess: using parent Dataset4dstem metadata " + f"(q_to_r_rotation_ccw_deg={q_to_r_rotation_ccw_deg or 0.0}, " + f"q_transpose={q_transpose or False}).", + UserWarning, ) - q_transpose = False if q_transpose is None else bool(q_transpose) + + if q_to_r_rotation_ccw_deg is None or q_transpose is None: + import warnings + + q_to_r_rotation_ccw_deg = 0.0 if q_to_r_rotation_ccw_deg is None else q_to_r_rotation_ccw_deg + q_transpose = False if q_transpose is None else q_transpose warnings.warn( - "StrainMap.preprocess: setting q_to_r_rotation_ccw_deg=0.0 and q_transpose=False.", + "StrainMapPatterson.preprocess: setting q_to_r_rotation_ccw_deg=0.0 and q_transpose=False.", UserWarning, ) - self.metadata["q_to_r_rotation_ccw_deg"] = float(q_to_r_rotation_ccw_deg) - self.metadata["q_transpose"] = bool(q_transpose) - - if skip is None: - if self.metadata["mode"] == "linear": - im = np.mean(np.abs(np.fft.fft2( - self.dataset.array * self.mask_diffraction[None,None,:,:] + \ - self.mask_diffraction_inv[None,None,:,:] - )), axis=(0, 1)) - elif self.metadata["mode"] == "log": - im = np.mean(np.abs(np.fft.fft2(np.log1p( - self.dataset.array * self.mask_diffraction[None,None,:,:] + \ - self.mask_diffraction_inv[None,None,:,:] - ))), axis=(0, 1)) - else: - raise ValueError("mode must be 'linear' or 'log'") + self.metadata["q_to_r_rotation_ccw_deg"] = q_to_r_rotation_ccw_deg + self.metadata["q_transpose"] = q_transpose + + arr = self.dataset.array if skip is None else self.dataset.array[::skip, ::skip] + dp = arr * self.mask_diffraction[None, None, :, :] + self.mask_diffraction_inv[None, None, :, :] + + if mode_norm == "linear": + dp_proc = dp + elif mode_norm == "log": + dp_proc = np.log1p(dp) + elif mode_norm == "gamma": + dp_proc = np.power(np.clip(dp, 0.0, None), self.metadata["gamma"]) else: - if self.metadata["mode"] == "linear": - im = np.mean(np.abs(np.fft.fft2( - self.dataset.array[::skip,::skip] * self.mask_diffraction[None,None,:,:] + \ - self.mask_diffraction_inv[None,None,:,:] - )), axis=(0, 1)) - elif self.metadata["mode"] == "log": - im = np.mean(np.abs(np.fft.fft2(np.log1p( - self.dataset.array[::skip,::skip] * self.mask_diffraction[None,None,:,:] + \ - self.mask_diffraction_inv[None,None,:,:] - ))), axis=(0, 1)) - else: - raise ValueError("mode must be 'linear' or 'log'") + raise RuntimeError("Unreachable: normalized mode mapping failed.") + im = np.mean(np.abs(np.fft.fft2(dp_proc)), axis=(0, 1)) im = np.fft.fftshift(im) self.transform = Dataset2d.from_array( @@ -217,13 +228,13 @@ def preprocess( ) im_plot = self.transform.array - if bool(self.metadata["q_transpose"]): + if self.metadata["q_transpose"]: im_plot = im_plot.T self.transform_rotated = Dataset2d.from_array( rotate_image( im_plot, - float(self.metadata["q_to_r_rotation_ccw_deg"]), + self.metadata["q_to_r_rotation_ccw_deg"], clockwise=False, ), origin=(im.shape[0] // 2, im.shape[1] // 2), @@ -246,21 +257,20 @@ def plot_transform( if self.transform is None or self.transform_rotated is None: raise ValueError("Run preprocess() first to compute transform images.") - sampling = float(np.mean(self.metadata["sampling_real"])) - units = str(self.metadata.get("real_units", r"$\mathrm{\AA}$")) + sampling = np.mean(self.metadata["sampling_real"]) + units = self.metadata.get("real_units", r"$\mathrm{\AA}$") - W = int(self.transform.shape[1]) - view_w_px = float(W) * float(cropping_factor) - target_units = float(scalebar_fraction) * view_w_px * sampling + W = self.transform.shape[1] + view_w_px = W * cropping_factor + target_units = scalebar_fraction * view_w_px * sampling sb_len = _nice_length_units(target_units) - # intensity scaling: compute from transform, apply same scaling to both panels kr = (np.arange(self.transform.shape[0], dtype=float) - self.transform.shape[0] // 2)[:, None] kc = (np.arange(self.transform.shape[1], dtype=float) - self.transform.shape[1] // 2)[None, :] qmag = np.sqrt(kr * kr + kc * kc) im0 = self.transform.array tmp = im0 * qmag - i0 = np.unravel_index(int(np.nanargmax(tmp)), tmp.shape) + i0 = np.unravel_index(np.nanargmax(tmp), tmp.shape) vmin = 0.0 vmax = im0[i0] @@ -283,53 +293,57 @@ def plot_transform( return fig, ax - def choose_lattice_vector( self, *, u: tuple[float, float] | NDArray, v: tuple[float, float] | NDArray, define_in_rotated: bool = False, - refine_subpixel: bool = True, - refine_subpixel_dft: bool = False, + refine_gaussian: bool = True, + refine_dft: bool = False, refine_radius_px: float = 2.0, - refine_log: bool = False, upsample: int = 16, + gaussian_maxfev: int = 100, plot: bool = True, cropping_factor: float = 0.25, **plot_kwargs: Any, - ) -> "StrainMap": + ) -> "StrainMapAutocorrelation": if self.transform is None or self.transform_rotated is None: raise ValueError("Run preprocess() first to compute transform images.") u_rc = np.asarray(u, dtype=float).reshape(2) v_rc = np.asarray(v, dtype=float).reshape(2) - rot_ccw = float(self.metadata.get("q_to_r_rotation_ccw_deg", 0.0)) - q_transpose = bool(self.metadata.get("q_transpose", False)) + rot_ccw = self.metadata["q_to_r_rotation_ccw_deg"] + q_transpose = self.metadata["q_transpose"] if define_in_rotated: u_rc = _display_vec_to_raw(u_rc, rotation_ccw_deg=rot_ccw, transpose=q_transpose) v_rc = _display_vec_to_raw(v_rc, rotation_ccw_deg=rot_ccw, transpose=q_transpose) - if refine_subpixel_dft: - refine_subpixel = True - - if refine_subpixel: - u_rc, v_rc = _refine_lattice_vectors( - self.transform.array, - u_rc=u_rc, - v_rc=v_rc, - radius_px=float(refine_radius_px), - log_fit=bool(refine_log), - refine_dft=bool(refine_subpixel_dft), - upsample=int(upsample), - ) + u_fit_abs, v_fit_abs = _refine_lattice_vectors( + self.transform.array, + u_rc=u_rc, + v_rc=v_rc, + radius_px=refine_radius_px, + refine_gaussian=refine_gaussian, + refine_dft=refine_dft, + upsample=upsample, + maxfev=gaussian_maxfev, + ) + + H, W = self.transform.array.shape + center = np.array((H // 2, W // 2), dtype=float) - self.u = u_rc - self.v = v_rc - self.metadata["lattice_u_rc"] = self.u.copy() - self.metadata["lattice_v_rc"] = self.v.copy() + self.u = u_fit_abs[:2] - center + self.v = v_fit_abs[:2] - center + + self.metadata["choose_define_in_rotated"] = define_in_rotated + self.metadata["choose_refine_gaussian"] = refine_gaussian + self.metadata["choose_refine_dft"] = refine_dft + self.metadata["choose_refine_radius_px"] = refine_radius_px + self.metadata["choose_upsample"] = upsample + self.metadata["choose_gaussian_maxfev"] = gaussian_maxfev if plot: fig, ax = self.plot_transform(cropping_factor=cropping_factor, **plot_kwargs) @@ -345,38 +359,46 @@ def choose_lattice_vector( return self - def fit_lattice_vectors( self, - refine_subpixel: bool = True, - refine_subpixel_dft: bool = False, + refine_gaussian: bool = True, + refine_dft: bool = False, refine_radius_px: float = 2.0, upsample: int = 16, - refine_log: bool = False, + gaussian_maxfev: int = 100, progressbar: bool = True, - ) -> "StrainMap": - from quantem.core.datastructures.dataset3d import Dataset3d - + ) -> "StrainMapAutocorrelation": if self.u is None or self.v is None: raise ValueError("Run choose_lattice_vector() first to set initial lattice vectors (self.u, self.v).") - if refine_subpixel_dft: - refine_subpixel = True - scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] + + self.u_peak_fit = Dataset3d.from_shape( + (scan_r, scan_c, 5), + name="u_peak_fit", + signal_units="mixed", + ) + self.v_peak_fit = Dataset3d.from_shape( + (scan_r, scan_c, 5), + name="v_peak_fit", + signal_units="mixed", + ) + self.u_fit = Dataset3d.from_shape( (scan_r, scan_c, 2), - name="u_fits", + name="u_fit", signal_units="pixels", ) self.v_fit = Dataset3d.from_shape( (scan_r, scan_c, 2), - name="v_fits", + name="v_fit", signal_units="pixels", ) - mode = str(self.metadata.get("mode", "linear")).lower() + mode = self.metadata.get("mode", "linear").lower() + if mode == "gamma": + g = self.metadata["gamma"] it = np.ndindex(scan_r, scan_c) if progressbar: @@ -390,97 +412,150 @@ def fit_lattice_vectors( u0 = np.asarray(self.u, dtype=float).reshape(2) v0 = np.asarray(self.v, dtype=float).reshape(2) + dp_shape = self.dataset.array.shape[2:] + r_center = dp_shape[0] // 2 + c_center = dp_shape[1] // 2 + for r, c in it: - dp = self.dataset.array[r, c]*self.mask_diffraction + \ - self.mask_diffraction_inv + dp = self.dataset.array[r, c] * self.mask_diffraction + self.mask_diffraction_inv if mode == "linear": im = np.fft.fftshift(np.abs(np.fft.fft2(dp))) elif mode == "log": im = np.fft.fftshift(np.abs(np.fft.fft2(np.log1p(dp)))) + elif mode == "gamma": + im = np.fft.fftshift(np.abs(np.fft.fft2(np.power(np.clip(dp, 0.0, None), g)))) else: - raise ValueError("metadata['mode'] must be 'linear' or 'log'") - - if refine_subpixel: - u_rc, v_rc = _refine_lattice_vectors( - im, - u_rc=u0, - v_rc=v0, - radius_px=float(refine_radius_px), - log_fit=bool(refine_log), - refine_dft=bool(refine_subpixel_dft), - upsample=int(upsample), - ) - else: - u_rc = u0 - v_rc = v0 + raise ValueError("metadata['mode'] must be 'linear', 'log', or 'gamma'") + + u_fit_abs, v_fit_abs = _refine_lattice_vectors( + im, + u_rc=u0, + v_rc=v0, + radius_px=refine_radius_px, + refine_gaussian=refine_gaussian, + refine_dft=refine_dft, + upsample=upsample, + maxfev=gaussian_maxfev, + ) - self.u_fit.array[r, c, :] = u_rc - self.v_fit.array[r, c, :] = v_rc + self.u_peak_fit.array[r, c, :] = u_fit_abs + self.v_peak_fit.array[r, c, :] = v_fit_abs - self.metadata["fit_refine_subpixel"] = bool(refine_subpixel) - self.metadata["fit_refine_subpixel_dft"] = bool(refine_subpixel_dft) - self.metadata["fit_refine_radius_px"] = float(refine_radius_px) - self.metadata["fit_refine_log"] = bool(refine_log) - self.metadata["fit_upsample"] = int(upsample) + self.u_fit.array[r, c, 0] = u_fit_abs[0] - r_center + self.u_fit.array[r, c, 1] = u_fit_abs[1] - c_center + self.v_fit.array[r, c, 0] = v_fit_abs[0] - r_center + self.v_fit.array[r, c, 1] = v_fit_abs[1] - c_center - return self + self.metadata["fit_refine_gaussian"] = refine_gaussian + self.metadata["fit_refine_dft"] = refine_dft + self.metadata["fit_refine_radius_px"] = refine_radius_px + self.metadata["fit_upsample"] = upsample + self.metadata["fit_gaussian_maxfev"] = gaussian_maxfev + return self def plot_lattice_vectors( self, subtract_mean: bool = True, - scalebar: bool = False, - **plot_kwargs: Any, + max_shift: float = 1.0, + cmap: str = "PiYG_r", + axsize: tuple[float, float] | None = None, + figsize: tuple[float, float] | None = None, + **imshow_kwargs: Any, ): - if getattr(self, "u_fit", None) is None or getattr(self, "v_fit", None) is None: + if self.u_fit is None or self.v_fit is None: raise ValueError("Run fit_lattice_vectors() first to compute u_fit and v_fit.") + if self.u is None or self.v is None: + raise ValueError("Run choose_lattice_vector() first to set self.u and self.v.") - im0 = self.u_fit.array[:,:,0] - im1 = self.u_fit.array[:,:,1] - im2 = self.v_fit.array[:,:,0] - im3 = self.v_fit.array[:,:,1] + im0 = self.u_fit.array[:, :, 0] + im1 = self.u_fit.array[:, :, 1] + im2 = self.v_fit.array[:, :, 0] + im3 = self.v_fit.array[:, :, 1] + + du0 = im0 - self.u[0] + du1 = im1 - self.u[1] + dv0 = im2 - self.v[0] + dv1 = im3 - self.v[1] + + max_shift2 = max_shift * max_shift + mu = (du0 * du0 + du1 * du1) <= max_shift2 + mv = (dv0 * dv0 + dv1 * dv1) <= max_shift2 if subtract_mean: - im0 = im0 - float(np.nanmean(im0)) - im1 = im1 - float(np.nanmean(im1)) - im2 = im2 - float(np.nanmean(im2)) - im3 = im3 - float(np.nanmean(im3)) + if np.any(mu): + im0 = im0 - np.mean(im0[mu]) + im1 = im1 - np.mean(im1[mu]) + else: + im0 = im0 - np.mean(im0) + im1 = im1 - np.mean(im1) + + if np.any(mv): + im2 = im2 - np.mean(im2[mv]) + im3 = im3 - np.mean(im3[mv]) + else: + im2 = im2 - np.mean(im2) + im3 = im3 - np.mean(im3) + + vals = [] + if np.any(mu): + vals.append(np.abs(im0[mu])) + vals.append(np.abs(im1[mu])) + if np.any(mv): + vals.append(np.abs(im2[mv])) + vals.append(np.abs(im3[mv])) + + if vals: + vlim = np.max(np.concatenate(vals)) + else: + vlim = np.max(np.abs(np.stack([im0, im1, im2, im3], axis=0))) - vlim = float(np.nanmax(np.abs(np.stack([im0, im1, im2, im3], axis=0)))) vmin = -vlim vmax = vlim - defaults: dict[str, Any] = dict( - title=("u_r", "u_c", "v_r", "v_c"), - vmin=vmin, - vmax=vmax, - ) + cm = plt.get_cmap(cmap).copy() + cm.set_bad(color="black") - if scalebar: - s0 = float(self.dataset.sampling[0]) if len(self.dataset.sampling) > 0 else 1.0 - s1 = float(self.dataset.sampling[1]) if len(self.dataset.sampling) > 1 else s0 - sampling_scan = float(np.mean([s0, s1])) - units_scan = str(self.dataset.units[0]) if len(self.dataset.units) > 0 else "pixels" - defaults["scalebar"] = ScalebarConfig(sampling=sampling_scan, units=units_scan) + m0 = np.ma.array(im0, mask=~mu) + m1 = np.ma.array(im1, mask=~mu) + m2 = np.ma.array(im2, mask=~mv) + m3 = np.ma.array(im3, mask=~mv) - defaults.update(plot_kwargs) + if axsize is None and figsize is None: + axsize = (4.0, 4.0) + if figsize is None: + figsize = (axsize[0] * 4.0, axsize[1]) - fig, ax = show_2d([im0, im1, im2, im3], **defaults) - return fig, ax + fig, ax = plt.subplots(1, 4, figsize=figsize) + + ax[0].imshow(m0, cmap=cm, vmin=vmin, vmax=vmax, **imshow_kwargs) + ax[1].imshow(m1, cmap=cm, vmin=vmin, vmax=vmax, **imshow_kwargs) + ax[2].imshow(m2, cmap=cm, vmin=vmin, vmax=vmax, **imshow_kwargs) + ax[3].imshow(m3, cmap=cm, vmin=vmin, vmax=vmax, **imshow_kwargs) + + ax[0].set_title("u_r") + ax[1].set_title("u_c") + ax[2].set_title("v_r") + ax[3].set_title("v_c") + for a in ax: + a.set_xticks([]) + a.set_yticks([]) + + return fig, ax def fit_strain( self, - mask_reference = None, - plot_strain = True, + mask_reference=None, + plot_strain=True, ): if self.u_fit is None or self.v_fit is None: raise ValueError("Run fit_lattice_vectors() first to compute u_fit and v_fit.") u_fit = self.u_fit.array v_fit = self.v_fit.array - scan_r, scan_c = int(u_fit.shape[0]), int(u_fit.shape[1]) + scan_r, scan_c = u_fit.shape[0], u_fit.shape[1] if mask_reference is None: self.u_ref = np.median(u_fit.reshape(-1, 2), axis=0) @@ -503,26 +578,23 @@ def fit_strain( ) Uref = np.stack((self.u_ref, self.v_ref), axis=1).astype(float) - det = float(np.linalg.det(Uref)) + det = np.linalg.det(Uref) if not np.isfinite(det) or abs(det) < 1e-12: Uref_inv = np.linalg.pinv(Uref) else: Uref_inv = np.linalg.inv(Uref) - # init self.strain_trans = Dataset4d.from_shape( (scan_r, scan_c, 2, 2), name="transformation matrix", signal_units="fractional", ) - # Loop over probe positions for r in range(scan_r): for c in range(scan_c): U = np.stack((u_fit[r, c, :], v_fit[r, c, :]), axis=1) self.strain_trans.array[r, c, :, :] = U @ Uref_inv - # get strains in orthogonal directions self.strain_raw_err = Dataset2d.from_array( self.strain_trans.array[:, :, 0, 0] - 1, name="strain err", @@ -534,12 +606,12 @@ def fit_strain( signal_units="fractional", ) self.strain_raw_erc = Dataset2d.from_array( - self.strain_trans.array[:, :, 1, 0]*0.5 + self.strain_trans.array[:, :, 0, 1]*0.5, + self.strain_trans.array[:, :, 1, 0] * 0.5 + self.strain_trans.array[:, :, 0, 1] * 0.5, name="strain erc", signal_units="fractional", ) self.strain_rotation = Dataset2d.from_array( - self.strain_trans.array[:, :, 1, 0]*-0.5 + self.strain_trans.array[:, :, 0, 1]*0.5, + self.strain_trans.array[:, :, 1, 0] * -0.5 + self.strain_trans.array[:, :, 0, 1] * 0.5, name="strain rotation", signal_units="fractional", ) @@ -555,23 +627,26 @@ def plot_strain( rotation_range_degrees=(-2.0, 2.0), plot_rotation=True, cmap_strain="PiYG_r", - cmap_rotation="PiYG_r", + cmap_rotation=None, layout="horizontal", figsize=(6, 6), + max_shift: tuple[float, float] | None = None, + amp_range: tuple[float, float] | None = None, ): import matplotlib.pyplot as plt + if cmap_rotation is None: + cmap_rotation = cmap_strain + if ref_angle_degrees is None: - ref_vec = self.u_ref * float(ref_u_v[0]) + self.v_ref * float(ref_u_v[1]) - ref_angle = float(np.arctan2(ref_vec[1], ref_vec[0])) + ref_vec = self.u_ref * ref_u_v[0] + self.v_ref * ref_u_v[1] + ref_angle = np.arctan2(ref_vec[1], ref_vec[0]) else: - ref_angle = float(np.deg2rad(ref_angle_degrees)) + ref_angle = np.deg2rad(ref_angle_degrees) angle = ref_angle + np.deg2rad(self.metadata["q_to_r_rotation_ccw_deg"]) - print(np.round(np.rad2deg(angle),2)) - - c = float(np.cos(angle)) - s = float(np.sin(angle)) + c = np.cos(angle) + s = np.sin(angle) err = self.strain_raw_err.array ecc = self.strain_raw_ecc.array @@ -588,62 +663,148 @@ def plot_strain( self.strain_evv.array[...] = evv self.strain_euv.array[...] = euv - if layout == "horizontal": - if plot_rotation: - fig, ax = plt.subplots(1, 4, figsize=figsize) - - ax[0].imshow( - self.strain_euu.array * 100, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cmap_strain, - ) - ax[1].imshow( - self.strain_evv.array * 100, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cmap_strain, - ) - ax[2].imshow( - self.strain_euv.array * 100, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cmap_strain, - ) - ax[3].imshow( - np.rad2deg(self.strain_rotation.array), - vmin=rotation_range_degrees[0], - vmax=rotation_range_degrees[1], - cmap=cmap_rotation, - ) - return fig, ax - - fig, ax = plt.subplots(1, 3, figsize=figsize) - ax[0].imshow( - self.strain_euu.array * 100, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cmap_strain, - ) - ax[1].imshow( - self.strain_evv.array * 100, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cmap_strain, - ) - ax[2].imshow( - self.strain_euv.array * 100, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cmap_strain, + alpha = None + if max_shift is not None: + if self.u_fit is None or self.v_fit is None or self.u is None or self.v is None: + raise ValueError("max_shift masking requires u_fit, v_fit, u, v to be available.") + + ur = self.u_fit.array[:, :, 0] + uc = self.u_fit.array[:, :, 1] + vr = self.v_fit.array[:, :, 0] + vc = self.v_fit.array[:, :, 1] + + du0 = ur - self.u[0] + du1 = uc - self.u[1] + dv0 = vr - self.v[0] + dv1 = vc - self.v[1] + + su = du0 * du0 + du1 * du1 + sv = dv0 * dv0 + dv1 * dv1 + sdist2 = 0.5 * (su + sv) + + smin, smax = max_shift + mask = np.clip((sdist2 - smin) / (smax - smin), 0.0, 1.0) + alpha = 1.0 - mask + + if amp_range is not None: + if self.u_peak_fit is None or self.v_peak_fit is None: + raise ValueError("amp_range masking requires u_peak_fit and v_peak_fit to be available.") + a = 0.5 * (self.u_peak_fit.array[:, :, 2] + self.v_peak_fit.array[:, :, 2]) + amin, amax = amp_range + a_mask = np.clip((a - amin) / (amax - amin), 0.0, 1.0) + alpha = a_mask if alpha is None else alpha * a_mask + + if alpha is not None: + alpha = np.asarray(alpha, dtype=float) + good = alpha > 0 + alpha_im = np.where(good, alpha, 1.0) + else: + good = None + alpha_im = None + + if layout != "horizontal": + raise ValueError("layout must be 'horizontal'") + + ncols = 4 if plot_rotation else 3 + fig, ax = plt.subplots(1, ncols, figsize=figsize) + + cm_strain = plt.get_cmap(cmap_strain).copy() + cm_strain.set_bad(color="black") + cm_rot = plt.get_cmap(cmap_rotation).copy() + cm_rot.set_bad(color="black") + + euu_pct = self.strain_euu.array * 100 + evv_pct = self.strain_evv.array * 100 + euv_pct = self.strain_euv.array * 100 + rot_deg = np.rad2deg(self.strain_rotation.array) + + if good is not None and np.any(good): + euu_m = np.ma.array(euu_pct, mask=~good) + evv_m = np.ma.array(evv_pct, mask=~good) + euv_m = np.ma.array(euv_pct, mask=~good) + rot_m = np.ma.array(rot_deg, mask=~good) + else: + euu_m = euu_pct + evv_m = evv_pct + euv_m = euv_pct + rot_m = rot_deg + + title_fs = 16 + im0 = ax[0].imshow( + euu_m, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cm_strain, + alpha=alpha_im, + ) + ax[1].imshow( + evv_m, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cm_strain, + alpha=alpha_im, + ) + ax[2].imshow( + euv_m, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cm_strain, + alpha=alpha_im, + ) + + ax[0].set_title(r"$\epsilon_{uu}$", fontsize=title_fs) + ax[1].set_title(r"$\epsilon_{vv}$", fontsize=title_fs) + ax[2].set_title(r"$\epsilon_{uv}$", fontsize=title_fs) + + if plot_rotation: + im3 = ax[3].imshow( + rot_m, + vmin=rotation_range_degrees[0], + vmax=rotation_range_degrees[1], + cmap=cm_rot, + alpha=alpha_im, ) - return fig, ax + ax[3].set_title("Rotation", fontsize=title_fs) - raise ValueError("layout must be 'horizontal'") + for a in ax: + a.set_xticks([]) + a.set_yticks([]) + a.set_facecolor("black") + + fig.subplots_adjust(left=0.02, right=0.98, top=0.90, bottom=0.16, wspace=0.03) + + b0 = ax[0].get_position() + b2 = ax[2].get_position() + left = b0.x0 + right = b2.x1 + width = right - left + + b3 = ax[3].get_position() if plot_rotation else None + + cb_height = 0.04 + cb_pad = 0.03 + y = b0.y0 - cb_pad - cb_height + + cax1 = fig.add_axes([left, y, width, cb_height]) + cbar1 = fig.colorbar(im0, cax=cax1, orientation="horizontal") + cbar1.set_label("Strain (%)", fontsize=title_fs) + cbar1.ax.tick_params(labelsize=12) + + if plot_rotation: + left_r = b3.x0 + width_r = b3.x1 - b3.x0 + cax2 = fig.add_axes([left_r, y, width_r, cb_height]) + cbar2 = fig.colorbar(im3, cax=cax2, orientation="horizontal") + cbar2.set_label("Rotation (deg)", fontsize=title_fs) + cbar2.ax.tick_params(labelsize=12) + + for a in ax: + a.set_aspect("equal") + + return fig, ax def _nice_length_units(target: float) -> float: - target = float(target) if not np.isfinite(target) or target <= 0: return 0.0 exp = np.floor(np.log10(target)) @@ -656,21 +817,20 @@ def _nice_length_units(target: float) -> float: nice = 5.0 else: nice = 10.0 - return float(nice * (10.0**exp)) + return nice * (10.0**exp) def _apply_center_crop_limits(ax: Any, shape: tuple[int, int], cropping_factor: float) -> None: - cf = float(cropping_factor) - if cf >= 1.0: + if cropping_factor >= 1.0: return - if not (0.0 < cf <= 1.0): + if not (0.0 < cropping_factor <= 1.0): raise ValueError("cropping_factor must be in (0, 1].") - H, W = int(shape[0]), int(shape[1]) - r0 = float(H // 2) - c0 = float(W // 2) - half_h = 0.5 * cf * H - half_w = 0.5 * cf * W + H, W = shape + r0 = H // 2 + c0 = W // 2 + half_h = 0.5 * cropping_factor * H + half_w = 0.5 * cropping_factor * W ax.set_xlim(c0 - half_w, c0 + half_w) @@ -694,14 +854,14 @@ def _flatten_axes(ax: Any) -> list[Any]: def _raw_vec_to_display(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: bool) -> NDArray: v = np.asarray(vec_rc, dtype=float).reshape(2) - dr, dc = float(v[0]), float(v[1]) + dr, dc = v[0], v[1] if transpose: dr, dc = dc, dr - theta = float(np.deg2rad(rotation_ccw_deg)) - ct = float(np.cos(theta)) - st = float(np.sin(theta)) + theta = np.deg2rad(rotation_ccw_deg) + ct = np.cos(theta) + st = np.sin(theta) dr2 = ct * dr - st * dc dc2 = st * dr + ct * dc @@ -710,11 +870,11 @@ def _raw_vec_to_display(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: def _display_vec_to_raw(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: bool) -> NDArray: v = np.asarray(vec_rc, dtype=float).reshape(2) - dr, dc = float(v[0]), float(v[1]) + dr, dc = v[0], v[1] - theta = float(np.deg2rad(rotation_ccw_deg)) - ct = float(np.cos(theta)) - st = float(np.sin(theta)) + theta = np.deg2rad(rotation_ccw_deg) + ct = np.cos(theta) + st = np.sin(theta) dr2 = ct * dr + st * dc dc2 = -st * dr + ct * dc @@ -725,17 +885,17 @@ def _display_vec_to_raw(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: return np.array((dr2, dc2), dtype=float) -def _plot_lattice_vectors(ax: Any, center_rc: tuple[float, float], u_rc: NDArray, v_rc: NDArray) -> None: - r0, c0 = float(center_rc[0]), float(center_rc[1]) +# def _plot_lattice_vectors(ax: Any, center_rc: tuple[float, float], u_rc: NDArray, v_rc: NDArray) -> None: +# r0, c0 = center_rc - def _draw(vec: NDArray, label: str, color: tuple[float, float, float]) -> None: - dr, dc = float(vec[0]), float(vec[1]) - ax.plot([c0, c0 + dc], [r0, r0 + dr], linewidth=2.75, color=color) - ax.plot([c0 + dc], [r0 + dr], marker="o", markersize=6.0, color=color) - ax.text(c0 + dc, r0 + dr, f" {label}", color=color, fontsize=18, va="center") +# def _draw(vec: NDArray, label: str, color: tuple[float, float, float]) -> None: +# dr, dc = vec[0], vec[1] +# ax.plot([c0, c0 + dc], [r0, r0 + dr], linewidth=2.75, color=color) +# ax.plot([c0 + dc], [r0 + dr], marker="o", markersize=6.0, color=color) +# ax.text(c0 + dc, r0 + dr, f" {label}", color=color, fontsize=18, va="center") - _draw(np.asarray(u_rc, dtype=float).reshape(2), "u", (1.0, 0.0, 0.0)) - _draw(np.asarray(v_rc, dtype=float).reshape(2), "v", (0.0, 0.7, 1.0)) +# _draw(np.asarray(u_rc, dtype=float).reshape(2), "u", (1.0, 0.0, 0.0)) +# _draw(np.asarray(v_rc, dtype=float).reshape(2), "v", (0.0, 0.7, 1.0)) def _overlay_lattice_vectors( @@ -751,25 +911,25 @@ def _overlay_lattice_vectors( if not axs: return - H, W = int(shape[0]), int(shape[1]) - center_rc = (float(H // 2), float(W // 2)) + H, W = shape + center_rc = (H // 2, W // 2) _plot_lattice_vectors(axs[0], center_rc, u_rc, v_rc) if len(axs) >= 2: - u_disp = _raw_vec_to_display(u_rc, rotation_ccw_deg=float(rot_ccw_deg), transpose=bool(q_transpose)) - v_disp = _raw_vec_to_display(v_rc, rotation_ccw_deg=float(rot_ccw_deg), transpose=bool(q_transpose)) + u_disp = _raw_vec_to_display(u_rc, rotation_ccw_deg=rot_ccw_deg, transpose=q_transpose) + v_disp = _raw_vec_to_display(v_rc, rotation_ccw_deg=rot_ccw_deg, transpose=q_transpose) _plot_lattice_vectors(axs[1], center_rc, u_disp, v_disp) def _parabolic_vertex_delta(v_m1: float, v_0: float, v_p1: float) -> float: - denom = (v_m1 - 2.0 * v_0 + v_p1) + denom = v_m1 - 2.0 * v_0 + v_p1 if denom == 0 or not np.isfinite(denom): return 0.0 delta = 0.5 * (v_m1 - v_p1) / denom if not np.isfinite(delta): return 0.0 - return float(np.clip(delta, -1.0, 1.0)) + return np.clip(delta, -1.0, 1.0) def _refine_peak_subpixel( @@ -778,14 +938,13 @@ def _refine_peak_subpixel( r_guess: float, c_guess: float, radius_px: float = 2.0, - log_fit: bool = False, ) -> tuple[float, float]: im = np.asarray(im, dtype=float) H, W = im.shape r0 = int(np.clip(int(np.round(r_guess)), 0, H - 1)) c0 = int(np.clip(int(np.round(c_guess)), 0, W - 1)) - rad = int(max(0, int(np.ceil(float(radius_px))))) + rad = int(max(0, int(np.ceil(radius_px)))) r1 = max(0, r0 - rad) r2 = min(H, r0 + rad + 1) @@ -794,29 +953,25 @@ def _refine_peak_subpixel( win = im[r1:r2, c1:c2] if win.size == 0: - return float(r_guess), float(c_guess) + return r_guess, c_guess - ir, ic = np.unravel_index(int(np.argmax(win)), win.shape) - r_peak = r1 + int(ir) - c_peak = c1 + int(ic) + ir, ic = np.unravel_index(np.argmax(win), win.shape) + r_peak = r1 + ir + c_peak = c1 + ic if 0 < r_peak < H - 1: col = im[r_peak - 1 : r_peak + 2, c_peak] - if log_fit: - col = np.log(np.clip(col, 1e-12, None)) - dr = _parabolic_vertex_delta(float(col[0]), float(col[1]), float(col[2])) + dr = _parabolic_vertex_delta(col[0], col[1], col[2]) else: dr = 0.0 if 0 < c_peak < W - 1: row = im[r_peak, c_peak - 1 : c_peak + 2] - if log_fit: - row = np.log(np.clip(row, 1e-12, None)) - dc = _parabolic_vertex_delta(float(row[0]), float(row[1]), float(row[2])) + dc = _parabolic_vertex_delta(row[0], row[1], row[2]) else: dc = 0.0 - return float(r_peak) + dr, float(c_peak) + dc + return r_peak + dr, c_peak + dc def _refine_peak_subpixel_dft( @@ -825,44 +980,37 @@ def _refine_peak_subpixel_dft( r0: float, c0: float, upsample: int, - log_fit: bool = False, ) -> tuple[float, float]: - if int(upsample) <= 1: - return float(r0), float(c0) + if upsample <= 1: + return r0, c0 im = np.asarray(im, dtype=float) F = np.fft.fft2(im) - up = int(upsample) + up = upsample du = int(np.ceil(1.5 * up)) - patch = dft_upsample(F, up=up, shift=(float(r0), float(c0)), device="cpu") + patch = dft_upsample(F, up=up, shift=(r0, c0), device="cpu") patch = np.asarray(patch, dtype=float) - i0, j0 = np.unravel_index(int(np.argmax(patch)), patch.shape) - i0 = int(i0) - j0 = int(j0) + i0, j0 = np.unravel_index(np.argmax(patch), patch.shape) if 0 < i0 < patch.shape[0] - 1: col = patch[i0 - 1 : i0 + 2, j0] - if log_fit: - col = np.log(np.clip(col, 1e-12, None)) - di = _parabolic_vertex_delta(float(col[0]), float(col[1]), float(col[2])) + di = _parabolic_vertex_delta(col[0], col[1], col[2]) else: di = 0.0 if 0 < j0 < patch.shape[1] - 1: row = patch[i0, j0 - 1 : j0 + 2] - if log_fit: - row = np.log(np.clip(row, 1e-12, None)) - dj = _parabolic_vertex_delta(float(row[0]), float(row[1]), float(row[2])) + dj = _parabolic_vertex_delta(row[0], row[1], row[2]) else: dj = 0.0 - dr = (float(i0) - float(du) + float(di)) / float(up) - dc = (float(j0) - float(du) + float(dj)) / float(up) + dr = (i0 - du + di) / up + dc = (j0 - du + dj) / up - return float(r0) + dr, float(c0) + dc + return r0 + dr, c0 + dc def _refine_lattice_vectors( @@ -871,42 +1019,155 @@ def _refine_lattice_vectors( u_rc: NDArray, v_rc: NDArray, radius_px: float = 2.0, - log_fit: bool = False, - refine_dft: bool = True, + refine_gaussian: bool = True, + refine_dft: bool = False, upsample: int = 16, + maxfev: int = 100, ) -> tuple[NDArray, NDArray]: + from scipy.optimize import curve_fit + im = np.asarray(im, dtype=float) if im.ndim != 2: raise ValueError("im must be 2D.") H, W = im.shape - r_center = float(H // 2) - c_center = float(W // 2) + r_center = H // 2 + c_center = W // 2 + + def _parabolic_peak_rc_amp(*, r_guess: float, c_guess: float) -> tuple[float, float, float]: + r0 = int(np.clip(int(np.round(r_guess)), 0, H - 1)) + c0 = int(np.clip(int(np.round(c_guess)), 0, W - 1)) + win = im[ + max(0, r0 - 1) : min(H, r0 + 2), + max(0, c0 - 1) : min(W, c0 + 2), + ] + if win.size == 0: + return r_guess, c_guess, 0.0 + + ir, ic = np.unravel_index(np.argmax(win), win.shape) + r_peak = max(0, r0 - 1) + ir + c_peak = max(0, c0 - 1) + ic + + r_ref = r_peak + c_ref = c_peak + + if 0 < r_peak < H - 1: + col = im[r_peak - 1 : r_peak + 2, c_peak] + dr = _parabolic_vertex_delta(col[0], col[1], col[2]) + else: + dr = 0.0 - def _refine(vec: NDArray) -> NDArray: + if 0 < c_peak < W - 1: + row = im[r_peak, c_peak - 1 : c_peak + 2] + dc = _parabolic_vertex_delta(row[0], row[1], row[2]) + else: + dc = 0.0 + + r_sub = r_ref + dr + c_sub = c_ref + dc + r_int = int(np.clip(int(np.round(r_sub)), 0, H - 1)) + c_int = int(np.clip(int(np.round(c_sub)), 0, W - 1)) + amp = im[r_int, c_int] + + return r_sub, c_sub, amp + + def _fit_gaussian_isotropic( + *, + r0: float, + c0: float, + radius_px: float, + maxfev: int, + ) -> tuple[float, float, float, float, float]: + rad = int(max(1, int(np.ceil(radius_px)))) + r0i = int(np.clip(int(np.round(r0)), 0, H - 1)) + c0i = int(np.clip(int(np.round(c0)), 0, W - 1)) + + r1 = max(0, r0i - rad) + r2 = min(H, r0i + rad + 1) + c1 = max(0, c0i - rad) + c2 = min(W, c0i + rad + 1) + + win = im[r1:r2, c1:c2] + if win.size == 0: + return r0, c0, 0.0, 0.0, 0.0 + + ir, ic = np.unravel_index(np.argmax(win), win.shape) + r_peak = r1 + ir + c_peak = c1 + ic + + bg0 = np.median(win) + amp0 = win[ir, ic] - bg0 + sig0 = max(0.75, radius_px / 2.0) + + rr = np.arange(r1, r2, dtype=float)[:, None] + cc = np.arange(c1, c2, dtype=float)[None, :] + RR = np.broadcast_to(rr, win.shape) + CC = np.broadcast_to(cc, win.shape) + + def _g2( + coords: tuple[NDArray, NDArray], + row: float, + col: float, + amp: float, + sigma: float, + background: float, + ) -> NDArray: + r, c = coords + sig = np.maximum(sigma, 1e-12) + return background + amp * np.exp(-((r - row) ** 2 + (c - col) ** 2) / (2.0 * sig * sig)) + + p0 = (r_peak, c_peak, max(0.0, amp0), sig0, bg0) + + rlo = r1 - 0.5 + rhi = (r2 - 1) + 0.5 + clo = c1 - 0.5 + chi = (c2 - 1) + 0.5 + + bounds_lo = (rlo, clo, 0.0, 0.25, -np.inf) + bounds_hi = (rhi, chi, np.inf, radius_px * 4.0, np.inf) + + try: + popt, _ = curve_fit( + _g2, + (RR.ravel(), CC.ravel()), + win.ravel(), + p0=p0, + bounds=(bounds_lo, bounds_hi), + maxfev=maxfev, + ) + row, col, amp, sig, bg = popt + if not (np.isfinite(row) and np.isfinite(col) and np.isfinite(amp) and np.isfinite(sig) and np.isfinite(bg)): + return r0, c0, p0[2], 0.0, 0.0 + return row, col, amp, sig, bg + except Exception: + return r0, c0, p0[2], 0.0, 0.0 + + def _refine_one(vec: NDArray) -> NDArray: vec = np.asarray(vec, dtype=float).reshape(2) - r_guess = r_center + float(vec[0]) - c_guess = c_center + float(vec[1]) + r_guess = r_center + vec[0] + c_guess = c_center + vec[1] - r1, c1 = _refine_peak_subpixel( - im, - r_guess=float(r_guess), - c_guess=float(c_guess), - radius_px=float(radius_px), - log_fit=bool(log_fit), - ) + r_par, c_par, amp_par = _parabolic_peak_rc_amp(r_guess=r_guess, c_guess=c_guess) - if refine_dft and int(upsample) > 1: - r2, c2 = _refine_peak_subpixel_dft( - im, - r0=float(r1), - c0=float(c1), - upsample=int(upsample), - log_fit=bool(log_fit), + if refine_gaussian: + r_fit, c_fit, amp, sig, bg = _fit_gaussian_isotropic( + r0=r_par, + c0=c_par, + radius_px=radius_px, + maxfev=maxfev, ) else: - r2, c2 = float(r1), float(c1) + r_fit, c_fit, amp, sig, bg = r_par, c_par, amp_par, 0.0, 0.0 + + if refine_dft and upsample > 1: + r_dft, c_dft = _refine_peak_subpixel_dft( + im, + r0=r_fit, + c0=c_fit, + upsample=upsample, + ) + r_fit, c_fit = r_dft, c_dft - return np.array((r2 - r_center, c2 - c_center), dtype=float) + return np.array((r_fit, c_fit, amp, sig, bg), dtype=float) - return _refine(u_rc), _refine(v_rc) + return _refine_one(u_rc), _refine_one(v_rc) From a154aa6229e1dae67fc809bef4d7d562a4cea4f9 Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 19 Jan 2026 11:45:06 -0800 Subject: [PATCH 12/88] renaming parent .py file --- src/quantem/diffraction/__init__.py | 2 +- .../{strain.py => strain_autocorrelation.py} | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) rename src/quantem/diffraction/{strain.py => strain_autocorrelation.py} (98%) diff --git a/src/quantem/diffraction/__init__.py b/src/quantem/diffraction/__init__.py index 77bc5f82..450b9b2f 100644 --- a/src/quantem/diffraction/__init__.py +++ b/src/quantem/diffraction/__init__.py @@ -1,2 +1,2 @@ from quantem.diffraction.polar import RDF as RDF -from quantem.diffraction.strain import StrainMapAutocorrelation as StrainMapAutocorrelation +from quantem.diffraction.strain_autocorrelation import StrainMapAutocorrelation as StrainMapAutocorrelation diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain_autocorrelation.py similarity index 98% rename from src/quantem/diffraction/strain.py rename to src/quantem/diffraction/strain_autocorrelation.py index fa92c847..a084b32d 100644 --- a/src/quantem/diffraction/strain.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -626,7 +626,7 @@ def plot_strain( strain_range_percent=(-3.0, 3.0), rotation_range_degrees=(-2.0, 2.0), plot_rotation=True, - cmap_strain="PiYG_r", + cmap_strain="RdBu_r", cmap_rotation=None, layout="horizontal", figsize=(6, 6), @@ -885,17 +885,17 @@ def _display_vec_to_raw(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: return np.array((dr2, dc2), dtype=float) -# def _plot_lattice_vectors(ax: Any, center_rc: tuple[float, float], u_rc: NDArray, v_rc: NDArray) -> None: -# r0, c0 = center_rc +def _plot_lattice_vectors(ax: Any, center_rc: tuple[float, float], u_rc: NDArray, v_rc: NDArray) -> None: + r0, c0 = center_rc -# def _draw(vec: NDArray, label: str, color: tuple[float, float, float]) -> None: -# dr, dc = vec[0], vec[1] -# ax.plot([c0, c0 + dc], [r0, r0 + dr], linewidth=2.75, color=color) -# ax.plot([c0 + dc], [r0 + dr], marker="o", markersize=6.0, color=color) -# ax.text(c0 + dc, r0 + dr, f" {label}", color=color, fontsize=18, va="center") + def _draw(vec: NDArray, label: str, color: tuple[float, float, float]) -> None: + dr, dc = vec[0], vec[1] + ax.plot([c0, c0 + dc], [r0, r0 + dr], linewidth=2.75, color=color) + ax.plot([c0 + dc], [r0 + dr], marker="o", markersize=6.0, color=color) + ax.text(c0 + dc, r0 + dr, f" {label}", color=color, fontsize=18, va="center") -# _draw(np.asarray(u_rc, dtype=float).reshape(2), "u", (1.0, 0.0, 0.0)) -# _draw(np.asarray(v_rc, dtype=float).reshape(2), "v", (0.0, 0.7, 1.0)) + _draw(np.asarray(u_rc, dtype=float).reshape(2), "u", (1.0, 0.0, 0.0)) + _draw(np.asarray(v_rc, dtype=float).reshape(2), "v", (0.0, 0.7, 1.0)) def _overlay_lattice_vectors( From d3d3aa5952888ad4441c40b35128426fec30ab95 Mon Sep 17 00:00:00 2001 From: cophus Date: Wed, 28 Jan 2026 20:18:07 -0800 Subject: [PATCH 13/88] initial class --- src/quantem/diffraction/__init__.py | 1 + src/quantem/diffraction/maped.py | 87 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/quantem/diffraction/maped.py diff --git a/src/quantem/diffraction/__init__.py b/src/quantem/diffraction/__init__.py index 450b9b2f..2a79312b 100644 --- a/src/quantem/diffraction/__init__.py +++ b/src/quantem/diffraction/__init__.py @@ -1,2 +1,3 @@ from quantem.diffraction.polar import RDF as RDF from quantem.diffraction.strain_autocorrelation import StrainMapAutocorrelation as StrainMapAutocorrelation +from quantem.diffraction.maped import MAPED as MAPED diff --git a/src/quantem/diffraction/maped.py b/src/quantem/diffraction/maped.py new file mode 100644 index 00000000..f301454e --- /dev/null +++ b/src/quantem/diffraction/maped.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Any, Sequence + +import numpy as np + +from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from quantem.core.io.serialize import AutoSerialize +from quantem.core.visualization import show_2d + + +class MAPED(AutoSerialize): + _token = object() + + def __init__(self, datasets: list[Dataset4dstem], _token: object | None = None): + if _token is not self._token: + raise RuntimeError("Use MAPED.from_datasets() to instantiate this class.") + AutoSerialize.__init__(self) + self.datasets = datasets + self.metadata: dict[str, Any] = {} + + @classmethod + def from_datasets(cls, datasets: Sequence[Dataset4dstem]) -> "MAPED": + if not isinstance(datasets, Sequence) or isinstance(datasets, (str, bytes)): + raise TypeError("MAPED.from_datasets expects a sequence of Dataset4dstem instances.") + ds_list: list[Dataset4dstem] = [] + for d in datasets: + if not isinstance(d, Dataset4dstem): + raise TypeError("MAPED.from_datasets expects a sequence of Dataset4dstem instances.") + ds_list.append(d) + if len(ds_list) == 0: + raise ValueError("MAPED.from_datasets expects a non-empty sequence of Dataset4dstem instances.") + return cls(datasets=ds_list, _token=cls._token) + + def preprocess( + self, + *, + plot_summary: bool = True, + scale: float | Sequence[float] | None = None, + **plot_kwargs: Any, + ) -> "MAPED": + n = len(self.datasets) + if scale is None: + self.scales = np.ones(n, dtype=float) + elif isinstance(scale, (int, float, np.floating)): + self.scales = np.full(n, float(scale), dtype=float) + else: + self.scales = np.asarray(list(scale), dtype=float) + if self.scales.shape != (n,): + raise ValueError("scale must be a scalar or a sequence with the same length as datasets.") + if np.any(self.scales == 0): + raise ValueError("scale entries must be nonzero.") + + self.dp_mean = [] + self.im_bf = [] + + for d in self.datasets: + if hasattr(d, "get_dp_mean"): + try: + d.get_dp_mean() + except TypeError: + try: + d.get_dp_mean(returnval=False) + except Exception: + pass + + dp = getattr(d, "dp_mean", None) + if dp is None: + dp_arr = np.mean(np.asarray(d.array), axis=(0, 1)) + else: + dp_arr = np.asarray(dp.array if hasattr(dp, "array") else dp) + + im_bf_arr = np.mean(np.asarray(d.array), axis=(2, 3)) + + self.dp_mean.append(np.asarray(dp_arr)) + self.im_bf.append(np.asarray(im_bf_arr)) + + if plot_summary: + show_2d( + [ + [self.im_bf[i] / self.scales[i] for i in range(n)], + [self.dp_mean[i] for i in range(n)], + ], + **plot_kwargs, + ) + + return self From ed4478945cc5a73ed5d502a7c90bdb47bc1e89f5 Mon Sep 17 00:00:00 2001 From: cophus Date: Sat, 31 Jan 2026 16:19:06 -0800 Subject: [PATCH 14/88] fixing DFT upsampling / image correlation, adding tests --- src/quantem/core/utils/imaging_utils.py | 365 +++++++----------------- tests/core/utils/test_imaging_utils.py | 75 +++++ 2 files changed, 178 insertions(+), 262 deletions(-) create mode 100644 tests/core/utils/test_imaging_utils.py diff --git a/src/quantem/core/utils/imaging_utils.py b/src/quantem/core/utils/imaging_utils.py index ba5fc3e9..805ca5b0 100644 --- a/src/quantem/core/utils/imaging_utils.py +++ b/src/quantem/core/utils/imaging_utils.py @@ -12,11 +12,17 @@ from quantem.core.utils.utils import generate_batches +def _parabolic_peak(v) -> float: + denom = 4.0 * v[1] - 2.0 * v[2] - 2.0 * v[0] + if denom == 0: + return 0.0 + return float((v[2] - v[0]) / denom) + + def dft_upsample( F: NDArray, up: int, shift: Tuple[float, float], - device: str = "cpu", ): """ Matrix multiplication DFT, from: @@ -25,27 +31,53 @@ def dft_upsample( image registration algorithms," Opt. Lett. 33, 156-158 (2008). http://www.sciencedirect.com/science/article/pii/S0045790612000778 """ - if device == "gpu": - import cupy as cp # type: ignore + M, N = F.shape + pixel_radius = 1.5 + num_row = int(math.ceil(pixel_radius * up)) + num_col = num_row - xp = cp - else: - xp = np + col_freq = np.fft.ifftshift(np.arange(N)) - math.floor(N / 2) + row_freq = np.fft.ifftshift(np.arange(M)) - math.floor(M / 2) - M, N = F.shape - du = np.ceil(1.5 * up).astype(int) - row = np.arange(-du, du + 1) - col = np.arange(-du, du + 1) - r_shift = shift[0] - M // 2 - c_shift = shift[1] - N // 2 - - kern_row = np.exp( - -2j * np.pi / (M * up) * np.outer(row, xp.fft.ifftshift(xp.arange(M)) - M // 2 + r_shift) - ) - kern_col = np.exp( - -2j * np.pi / (N * up) * np.outer(xp.fft.ifftshift(xp.arange(N)) - N // 2 + c_shift, col) - ) - return xp.real(kern_row @ F @ kern_col) + row_coords = np.arange(num_row, dtype=float) - float(shift[0]) + col_coords = np.arange(num_col, dtype=float) - float(shift[1]) + + factor_row = -2j * math.pi / (M * float(up)) + factor_col = -2j * math.pi / (N * float(up)) + + row_kern = np.exp(factor_row * (row_coords[:, None] * row_freq[None, :])).astype(F.dtype) + col_kern = np.exp(factor_col * (col_freq[:, None] * col_coords[None, :])).astype(F.dtype) + + return (row_kern @ F @ col_kern).real + + +def _upsampled_correlation_numpy( + imageCorr: NDArray, + upsampleFactor: int, + xyShift: NDArray, +) -> NDArray: + xyShift = np.round(xyShift * float(upsampleFactor)) / float(upsampleFactor) + globalShift = math.floor(math.ceil(upsampleFactor * 1.5) / 2.0) + upsampleCenter = float(globalShift) - (float(upsampleFactor) * xyShift) + + im_up = dft_upsample(np.conj(imageCorr), upsampleFactor, (float(upsampleCenter[0]), float(upsampleCenter[1]))) + imageCorrUpsample = np.conj(im_up) + + flat_idx = int(np.argmax(imageCorrUpsample.real)) + r = flat_idx // imageCorrUpsample.shape[1] + c = flat_idx % imageCorrUpsample.shape[1] + + dx = 0.0 + dy = 0.0 + patch = imageCorrUpsample.real[r - 1 : r + 2, c - 1 : c + 2] + if patch.shape == (3, 3): + dx = _parabolic_peak(patch[:, 1]) + dy = _parabolic_peak(patch[1, :]) + + xySubShift = np.array([float(r), float(c)], dtype=float) - float(globalShift) + xyShift = xyShift + (xySubShift + np.array([dx, dy], dtype=float)) / float(upsampleFactor) + + return xyShift def cross_correlation_shift( @@ -56,7 +88,6 @@ def cross_correlation_shift( return_shifted_image: bool = False, fft_input: bool = False, fft_output: bool = False, - device: str = "cpu", ): """ Estimate subpixel shift between two 2D images using Fourier cross-correlation. @@ -68,98 +99,78 @@ def cross_correlation_shift( im : ndarray Image to align or its FFT if fft_input=True upsample_factor : int - Subpixel upsampling factor (must be > 1 for subpixel accuracy) - fft_input : bool - If True, assumes im_ref and im are already in Fourier space + Subpixel upsampling factor (torch-equivalent behavior): + - <= 2 : half-pixel refinement (parabolic, then rounded to nearest 0.5 px) + - > 2 : additional DFT upsample refinement + max_shift : float or None + Optional radial cutoff in pixel-shift units (keeps only shifts with |shift| <= max_shift) return_shifted_image : bool If True, return the shifted version of `im` aligned to `im_ref` - device : str - 'cpu' or 'gpu' (requires CuPy) + fft_input : bool + If True, assumes im_ref and im are already in Fourier space + fft_output : bool + If True and return_shifted_image=True, return the shifted image in Fourier space Returns ------- shifts : tuple of float (row_shift, col_shift) to align `im` to `im_ref` image_shifted : ndarray (optional) - Shifted image in real space, only returned if return_shifted_image=True + Shifted image in real space (or Fourier space if fft_output=True) """ - if device == "gpu": - import cupy as cp # type: ignore - - xp = cp - else: - xp = np + F_ref = np.asarray(im_ref) if fft_input else np.fft.fft2(np.asarray(im_ref)) + F_im = np.asarray(im) if fft_input else np.fft.fft2(np.asarray(im)) - # Fourier transforms - F_ref = im_ref if fft_input else xp.fft.fft2(im_ref) - F_im = im if fft_input else xp.fft.fft2(im) + cc = F_ref * np.conj(F_im) + cc_real = np.fft.ifft2(cc).real - # Correlation - cc = F_ref * xp.conj(F_im) - cc_real = xp.real(xp.fft.ifft2(cc)) + M, N = cc_real.shape if max_shift is not None: - x = np.fft.fftfreq(cc.shape[0], 1 / cc.shape[0]) - y = np.fft.fftfreq(cc.shape[1], 1 / cc.shape[1]) - mask = x[:, None] ** 2 + y[None, :] ** 2 >= max_shift**2 - cc_real[mask] = 0.0 + x = np.fft.fftfreq(M) * M + y = np.fft.fftfreq(N) * N + mask = x[:, None] ** 2 + y[None, :] ** 2 > float(max_shift) ** 2 + cc_real = cc_real.copy() + cc_real[mask] = -np.inf - # Coarse peak - peak = xp.unravel_index(xp.argmax(cc_real), cc_real.shape) - x0, y0 = peak + flat_idx = int(np.argmax(cc_real)) + x0 = flat_idx // N + y0 = flat_idx % N - # Parabolic refinement - x_inds = xp.mod(x0 + xp.arange(-1, 2), cc.shape[0]).astype(int) - y_inds = xp.mod(y0 + xp.arange(-1, 2), cc.shape[1]).astype(int) + x_inds = [((x0 + dx) % M) for dx in (-1, 0, 1)] + y_inds = [((y0 + dy) % N) for dy in (-1, 0, 1)] vx = cc_real[x_inds, y0] vy = cc_real[x0, y_inds] - def parabolic_peak(v): - return (v[2] - v[0]) / (4 * v[1] - 2 * v[2] - 2 * v[0]) + dx = _parabolic_peak(vx) + dy = _parabolic_peak(vy) - dx = parabolic_peak(vx) - dy = parabolic_peak(vy) + x0 = np.round((float(x0) + float(dx)) * 2.0) / 2.0 + y0 = np.round((float(y0) + float(dy)) * 2.0) / 2.0 - x0 = (x0 + dx) % cc.shape[0] - y0 = (y0 + dy) % cc.shape[1] - - if upsample_factor <= 1: - shifts = (x0, y0) - else: - # Local DFT upsampling + xy_shift = np.array([x0, y0], dtype=float) - local = dft_upsample(cc, upsample_factor, (x0, y0), device=device) - peak = np.unravel_index(xp.argmax(local), local.shape) - - try: - lx, ly = peak - icc = local[lx - 1 : lx + 2, ly - 1 : ly + 2] - if icc.shape == (3, 3): - dxf = parabolic_peak(icc[:, 1]) - dyf = parabolic_peak(icc[1, :]) - else: - raise ValueError("Subarray too close to edge") - except (IndexError, ValueError): - dxf = dyf = 0.0 - - shifts = np.array([x0, y0]) + (np.array(peak) - upsample_factor) / upsample_factor - shifts += np.array([dxf, dyf]) / upsample_factor + if upsample_factor > 2: + xy_shift = _upsampled_correlation_numpy(cc, int(upsample_factor), xy_shift) - shifts = (shifts + 0.5 * np.array(cc.shape)) % cc.shape - 0.5 * np.array(cc.shape) + shifts = np.empty(2, dtype=float) + shifts[0] = ((xy_shift[0] + M / 2) % M) - M / 2 + shifts[1] = ((xy_shift[1] + N / 2) % N) - N / 2 + shifts = (float(shifts[0]), float(shifts[1])) if not return_shifted_image: return shifts - # Fourier shift image (F_im assumed to be FFT) - kx = xp.fft.fftfreq(F_im.shape[0])[:, None] - ky = xp.fft.fftfreq(F_im.shape[1])[None, :] - phase_ramp = xp.exp(-2j * np.pi * (kx * shifts[0] + ky * shifts[1])) + kx = np.fft.fftfreq(F_im.shape[0])[:, None] + ky = np.fft.fftfreq(F_im.shape[1])[None, :] + phase_ramp = np.exp(-2j * np.pi * (kx * shifts[0] + ky * shifts[1])) F_im_shifted = F_im * phase_ramp + if fft_output: image_shifted = F_im_shifted else: - image_shifted = xp.real(xp.fft.ifft2(F_im_shifted)) + image_shifted = np.fft.ifft2(F_im_shifted).real return shifts, image_shifted @@ -176,7 +187,6 @@ def cross_correlation_shift_torch( xy_shift = align_images_fourier_torch(G1, G2, upsample_factor) - # convert to centered signed shifts as original code M, N = im_ref.shape dx = ((xy_shift[0] + M / 2) % M) - M / 2 dy = ((xy_shift[1] + N / 2) % N) - N / 2 @@ -198,12 +208,10 @@ def align_images_fourier_torch( cc = G1 * G2.conj() cc_real = torch.fft.ifft2(cc).real - # local max (integer) flat_idx = torch.argmax(cc_real) x0 = (flat_idx // cc_real.shape[1]).to(torch.long).item() y0 = (flat_idx % cc_real.shape[1]).to(torch.long).item() - # half pixel shifts: pick ±1 indices with wrap (mod) M, N = cc_real.shape x_inds = [((x0 + dx) % M) for dx in (-1, 0, 1)] y_inds = [((y0 + dy) % N) for dy in (-1, 0, 1)] @@ -211,14 +219,11 @@ def align_images_fourier_torch( vx = cc_real[x_inds, y0] vy = cc_real[x0, y_inds] - # parabolic half-pixel refine - # dx = (vx[2] - vx[0]) / (4*vx[1] - 2*vx[2] - 2*vx[0]) denom_x = 4.0 * vx[1] - 2.0 * vx[2] - 2.0 * vx[0] denom_y = 4.0 * vy[1] - 2.0 * vy[2] - 2.0 * vy[0] dx = (vx[2] - vx[0]) / denom_x if denom_x != 0 else torch.tensor(0.0, device=device) dy = (vy[2] - vy[0]) / denom_y if denom_y != 0 else torch.tensor(0.0, device=device) - # round to nearest half-pixel x0 = torch.round((x0 + dx) * 2.0) / 2.0 y0 = torch.round((y0 + dy) * 2.0) / 2.0 @@ -243,7 +248,6 @@ def upsampled_correlation_torch( xyShift: 2-element tensor (x,y) in image coords; must be half-pixel precision as described. Returns refined xyShift (tensor length 2). """ - assert upsampleFactor > 2 xyShift = torch.round(xyShift * float(upsampleFactor)) / float(upsampleFactor) @@ -254,28 +258,25 @@ def upsampled_correlation_torch( im_up = dftUpsample_torch(conj_input, upsampleFactor, upsampleCenter) imageCorrUpsample = im_up.conj() - # find maximum - # flatten argmax -> unravel to 2D flat_idx = torch.argmax(imageCorrUpsample.real) - # unravel_index xySubShift0 = (flat_idx // imageCorrUpsample.shape[1]).to(torch.long) xySubShift1 = (flat_idx % imageCorrUpsample.shape[1]).to(torch.long) xySubShift = torch.tensor([xySubShift0.item(), xySubShift1.item()]) - # parabolic subpixel refinement dx = 0.0 dy = 0.0 try: - # extract 3x3 patch around found peak r = xySubShift[0].item() c = xySubShift[1].item() patch = imageCorrUpsample.real[r - 1 : r + 2, c - 1 : c + 2] - # if patch is incomplete (near edge) this will raise / have wrong shape -> except if patch.shape == (3, 3): icc = patch - # dx corresponds to row direction (vertical axis) as in original code: - dx = (icc[2, 1] - icc[0, 1]) / (4.0 * icc[1, 1] - 2.0 * icc[2, 1] - 2.0 * icc[0, 1]) - dy = (icc[1, 2] - icc[1, 0]) / (4.0 * icc[1, 1] - 2.0 * icc[1, 2] - 2.0 * icc[1, 0]) + dx = (icc[2, 1] - icc[0, 1]) / ( + 4.0 * icc[1, 1] - 2.0 * icc[2, 1] - 2.0 * icc[0, 1] + ) + dy = (icc[1, 2] - icc[1, 0]) / ( + 4.0 * icc[1, 1] - 2.0 * icc[1, 2] - 2.0 * icc[1, 0] + ) dx = dx.item() dy = dy.item() else: @@ -283,7 +284,6 @@ def upsampled_correlation_torch( except Exception: dx, dy = 0.0, 0.0 - # convert xySubShift to zero-centered by subtracting globalShift xySubShift = xySubShift.to(dtype=torch.get_default_dtype()) xySubShift = xySubShift - globalShift.to(xySubShift.dtype) @@ -312,13 +312,9 @@ def dftUpsample_torch( numRow = int(math.ceil(pixelRadius * upsampleFactor)) numCol = numRow - # prepare the vectors exactly like the numpy version - # col: frequency indices (centered) for N col_freq = torch.fft.ifftshift(torch.arange(N, device=device)) - math.floor(N / 2) - # row: frequency indices (centered) for M row_freq = torch.fft.ifftshift(torch.arange(M, device=device)) - math.floor(M / 2) - # small upsample grid coordinates (integer positions in the UPSAMPLED GRID) col_coords = torch.arange(numCol, device=device, dtype=torch.get_default_dtype()) - float( xyShift[1] ) @@ -326,25 +322,18 @@ def dftUpsample_torch( xyShift[0] ) - # build kernels: note factor signs and denominators match original numpy code - # colKern: shape (N, numCol) factor_col = -2j * math.pi / (N * float(upsampleFactor)) - # outer(col_freq, col_coords) -> shape (N, numCol) colKern = torch.exp(factor_col * (col_freq.unsqueeze(1) * col_coords.unsqueeze(0))).to( imageCorr.dtype ) - # rowKern: shape (numRow, M) factor_row = -2j * math.pi / (M * float(upsampleFactor)) - # outer(row_coords, row_freq) -> shape (numRow, M) rowKern = torch.exp(factor_row * (row_coords.unsqueeze(1) * row_freq.unsqueeze(0))).to( imageCorr.dtype ) - # perform the small-matrix DFT: (numRow, M) @ (M, N) @ (N, numCol) -> (numRow, numCol) imageUpsample = rowKern @ imageCorr @ colKern - # original code took xp.real(...) before returning return imageUpsample.real @@ -362,32 +351,6 @@ def bilinear_kde( ) -> NDArray | tuple[NDArray, NDArray]: """ Compute a bilinear kernel density estimate (KDE) with smooth threshold masking. - - Parameters - ---------- - xa : NDArray - Vertical (row) coordinates of input points. - ya : NDArray - Horizontal (col) coordinates of input points. - values : NDArray - Weights for each (xa, ya) point. - output_shape : tuple of int - Output image shape (rows, cols). - kde_sigma : float - Standard deviation of Gaussian KDE smoothing. - pad_value : float, default = 1.0 - Value to return when KDE support is too low. - threshold : float, default = 1e-3 - Minimum counts_KDE value for trusting the output signal. - lowpass_filter : bool, optional - If True, apply sinc-based inverse filtering to deconvolve the kernel. - max_batch_size : int or None, optional - Max number of points to process in one batch. - - Returns - ------- - NDArray - The estimated KDE image with threshold-masked output. """ rows, cols = output_shape xF = np.floor(xa.ravel()).astype(int) @@ -417,14 +380,12 @@ def bilinear_kde( inds_1D, weights=weights * w[start:end], minlength=rows * cols ) - # Reshape to 2D and apply Gaussian KDE pix_count = pix_count.reshape(output_shape) pix_output = pix_output.reshape(output_shape) pix_count = gaussian_filter(pix_count, kde_sigma) pix_output = gaussian_filter(pix_output, kde_sigma) - # Final image weight = np.minimum(pix_count / threshold, 1.0) image = pad_value * (1.0 - weight) + weight * (pix_output / np.maximum(pix_count, 1e-8)) @@ -456,23 +417,7 @@ def bilinear_array_interpolation( ) -> NDArray: """ Bilinear sampling of values from an array and pixel positions. - - Parameters - ---------- - image: np.ndarray - Image array to sample from - xa: np.ndarray - Vertical interpolation sampling positions of image array in pixels - ya: np.ndarray - Horizontal interpolation sampling positions of image array in pixels - - Returns - ------- - values: np.ndarray - Bilinear interpolation values of array at (xa,ya) positions - """ - xF = np.floor(xa.ravel()).astype("int") yF = np.floor(ya.ravel()).astype("int") dx = xa.ravel() - xF @@ -498,10 +443,7 @@ def bilinear_array_interpolation( values[start:end] += raveled_image[inds_1D] * weights - values = np.reshape( - values, - xa.shape, - ) + values = np.reshape(values, xa.shape) return values @@ -513,20 +455,7 @@ def fourier_cropping( """ Crops a corner-centered FFT array to retain only the lowest frequencies, equivalent to a center crop on the fftshifted version. - - Parameters: - ----------- - corner_centered_array : ndarray - 2D array (typically result of np.fft.fft2) with corner-centered DC - crop_shape : tuple of int - (height, width) of the desired cropped array (could be odd or even depending on arr.shape) - - Returns: - -------- - cropped : ndarray - Cropped array containing only the lowest frequencies, still corner-centered. """ - H, W = corner_centered_array.shape crop_h, crop_w = crop_shape @@ -537,13 +466,9 @@ def fourier_cropping( result = np.zeros(crop_shape, dtype=corner_centered_array.dtype) - # Top-left result[:h1, :w1] = corner_centered_array[:h1, :w1] - # Top-right result[:h1, -w2:] = corner_centered_array[:h1, -w2:] - # Bottom-left result[-h2:, :w1] = corner_centered_array[-h2:, :w1] - # Bottom-right result[-h2:, -w2:] = corner_centered_array[-h2:, -w2:] return result @@ -557,22 +482,6 @@ def compute_fsc_from_halfsets( """ Compute radially averaged Fourier Shell Correlation (FSC) from two half-set reconstructions. - - Parameters - ---------- - halfset_recons : list[torch.Tensor] - Two statistically-independent reconstructions, using half the dataset. - sampling: tuple[float,float] - Reconstruction sampling in Angstroms. - epsilon: float, optional - Small number to avoid dividing by zero - - Returns - ------- - q_bins: NDarray - Spatial frequency bins - fsc : NDarray - Fourier shell correlation as function of spatial frequency """ r1, r2 = halfset_recons @@ -602,12 +511,10 @@ def compute_fsc_from_halfsets( w0 = 1.0 - d_ind w1 = d_ind - # Flatten arrays cross = cross.reshape(-1) p1 = p1.reshape(-1) p2 = p2.reshape(-1) - # Accumulate cross_b = torch.bincount(inds_f, weights=cross * w0, minlength=num_bins) + torch.bincount( inds_f + 1, weights=cross * w1, minlength=num_bins ) @@ -637,45 +544,14 @@ def compute_spectral_snr_from_halfsets( ): """ Compute spectral SNR from two half-set reconstructions using symmetric/antisymmetric decomposition. - - The method decomposes the Fourier transforms into: - - Symmetric: (F₁ + F₂)/2 → signal + correlated noise - - Antisymmetric: (F₁ - F₂)/2 → uncorrelated noise only - - SSNR(q) = sqrt(signal_power / noise_power) - - where: - - signal_power = (|symmetric|² - |antisymmetric|²)₊ - - noise_power = |antisymmetric|² - - Parameters - ---------- - halfset_recons : list[torch.Tensor] - Two statistically-independent reconstructions, using half the dataset. - sampling: tuple[float,float] - Reconstruction sampling in Angstroms. - total_dose: float - Total _normalized_ electron dose, e.g. in DirectPtychography this is ~self.num_bf - epsilon: float, optional - Small number to avoid dividing by zero - - Returns - ------- - q_bins: NDarray - Spatial frequency bins - ssnr : NDarray - Radially averaged spectral SNR as function of spatial frequency """ - # Compute Fourier transforms halfset_1, halfset_2 = halfset_recons F1 = torch.fft.fft2(halfset_1) F2 = torch.fft.fft2(halfset_2) - # Symmetric and antisymmetric decomposition symmetric = (F1 + F2) / 2 antisymmetric = (F1 - F2) / 2 - # Power spectra noise_power = antisymmetric.abs() total_power = symmetric.abs() signal_power = (total_power - noise_power).clamp_min(0) @@ -699,11 +575,9 @@ def compute_spectral_snr_from_halfsets( w0 = 1.0 - d_ind w1 = d_ind - # Flatten arrays signal = signal_power.reshape(-1) noise = noise_power.reshape(-1) - # Accumulate signal_b = torch.bincount(inds_f, weights=signal * w0, minlength=num_bins) + torch.bincount( inds_f + 1, weights=signal * w1, minlength=num_bins ) @@ -726,20 +600,6 @@ def radially_average_fourier_array( ): """ Radially average a corner-centered Fourier array. - - Parameters - ---------- - corner_centered_array : list[torch.Tensor] - Fourier array to average radially. - sampling: tuple[float,float] - Reconstruction sampling in Angstroms. - - Returns - ------- - q_bins: NDarray - Spatial frequency bins - array_1d : NDarray - Radially averaged Fourier array as function of spatial frequency """ device = corner_centered_array.device nx, ny = corner_centered_array.shape @@ -760,10 +620,8 @@ def radially_average_fourier_array( w0 = 1.0 - d_ind w1 = d_ind - # Flatten arrays array = corner_centered_array.reshape(-1) - # Accumulate array_b = torch.bincount(inds_f, weights=array * w0, minlength=num_bins) + torch.bincount( inds_f + 1, weights=array * w1, minlength=num_bins ) @@ -842,9 +700,7 @@ def add_edges(i1, i2): inc = _find_wrap(phi_f[i1], phi_f[i2]) rel = rel_f[i1] + rel_f[i2] - edges.append( # ty:ignore[possibly-missing-attribute] - torch.stack([i1, i2, rel, inc], dim=1) - ) + edges.append(torch.stack([i1, i2, rel, inc], dim=1)) if wrap_around: add_edges(idx.flatten(), torch.roll(idx, -1, 1).flatten()) @@ -856,7 +712,6 @@ def add_edges(i1, i2): edges = torch.cat(edges, dim=0) edges = edges[edges[:, 2].argsort()] - # return integer tensors only (CPU) return ( edges[:, 0].long(), edges[:, 1].long(), @@ -885,7 +740,6 @@ def union(self, x, y, inc_xy): if rx == ry: return - # phase(y) + oy + inc = phase(x) + ox delta = ox - oy - inc_xy if self.rank[rx] < self.rank[ry]: @@ -963,18 +817,6 @@ def _unwrap_phase_2d_torch_poisson( ): """ Least-squares / Poisson phase unwrapping with optional mask. - - Parameters - ---------- - phi_wrapped : (H, W) tensor - Wrapped phase in (-pi, pi], any device - mask : (H, W) bool tensor, optional - True = valid pixel - - Returns - ------- - phi_unwrapped : (H, W) tensor - Unwrapped phase (same device as input) """ device = phi_wrapped.device dtype = phi_wrapped.dtype @@ -1014,10 +856,10 @@ def _unwrap_phase_2d_torch_poisson( denom = kx**2 + ky**2 + regularization_lambda else: denom = kx**2 + ky**2 - denom[0, 0] = 1.0 # avoid divide by zero + denom[0, 0] = 1.0 phi_hat = -div_hat / denom - phi_hat[0, 0] = 0.0 # fix piston + phi_hat[0, 0] = 0.0 phi = torch.fft.ifftn(phi_hat).real @@ -1051,7 +893,6 @@ def unwrap_phase_2d_torch( ) - def rotate_image( im, rotation_deg: float, diff --git a/tests/core/utils/test_imaging_utils.py b/tests/core/utils/test_imaging_utils.py new file mode 100644 index 00000000..691756dd --- /dev/null +++ b/tests/core/utils/test_imaging_utils.py @@ -0,0 +1,75 @@ +""" +Tests for imaging utilities in quantem.core.utils.imaging_utils +""" + +import numpy as np +import pytest + +torch = pytest.importorskip("torch") + +from quantem.core.utils.imaging_utils import cross_correlation_shift, cross_correlation_shift_torch + + +@pytest.fixture +def spot_image(): + from scipy.ndimage import gaussian_filter + + im = np.zeros((64, 64), dtype=np.float64) + im[32, 32] = 1.0 + im = gaussian_filter(im, 2.0) + im /= np.max(im) + return im + + +def _fourier_shift_numpy(im: np.ndarray, shift_rc: tuple[float, float]) -> np.ndarray: + dr, dc = shift_rc + kr = np.fft.fftfreq(im.shape[0])[:, None] + kc = np.fft.fftfreq(im.shape[1])[None, :] + F = np.fft.fft2(im) + phase = np.exp(-2j * np.pi * (kr * dr + kc * dc)) + return np.fft.ifft2(F * phase).real + + +def _wrap_shift_rc(shift_rc: tuple[float, float], shape: tuple[int, int]) -> tuple[float, float]: + dr, dc = shift_rc + M, N = shape + dr = ((dr + M / 2) % M) - M / 2 + dc = ((dc + N / 2) % N) - N / 2 + return float(dr), float(dc) + + +@pytest.mark.parametrize( + "shift_true, upsample_factor, atol", + [ + ((5.0, -3.0), 1000, 1e-3), + ((-7.123, 1.789), 1000, 1e-3), + ], +) +def test_cross_correlation_shift_numpy_matches_expected(spot_image, shift_true, upsample_factor, atol): + im_ref = spot_image + im = _fourier_shift_numpy(im_ref, shift_true) + expected = _wrap_shift_rc((-shift_true[0], -shift_true[1]), im_ref.shape) + + meas = cross_correlation_shift(im_ref, im, upsample_factor=upsample_factor) + assert meas[0] == pytest.approx(expected[0], abs=atol) + assert meas[1] == pytest.approx(expected[1], abs=atol) + + +@pytest.mark.parametrize( + "shift_true, upsample_factor, atol", + [ + ((5.0, -3.0), 1000, 1e-3), + ((-7.123, 1.789), 1000, 1e-3), + ], +) +def test_cross_correlation_shift_torch_matches_expected(spot_image, shift_true, upsample_factor, atol): + im_ref = spot_image + im = _fourier_shift_numpy(im_ref, shift_true) + expected = _wrap_shift_rc((-shift_true[0], -shift_true[1]), im_ref.shape) + + t_ref = torch.from_numpy(im_ref) + t_im = torch.from_numpy(im) + meas = cross_correlation_shift_torch(t_ref, t_im, upsample_factor=upsample_factor).cpu().numpy() + + assert float(meas[0]) == pytest.approx(expected[0], abs=atol) + assert float(meas[1]) == pytest.approx(expected[1], abs=atol) From f249c6c2b99b0f65db6bcc7dc7c3b6be3f34d78f Mon Sep 17 00:00:00 2001 From: cophus Date: Sat, 31 Jan 2026 16:19:17 -0800 Subject: [PATCH 15/88] initial maped class commit --- src/quantem/diffraction/maped.py | 169 ++++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 5 deletions(-) diff --git a/src/quantem/diffraction/maped.py b/src/quantem/diffraction/maped.py index f301454e..ca14c92f 100644 --- a/src/quantem/diffraction/maped.py +++ b/src/quantem/diffraction/maped.py @@ -3,6 +3,7 @@ from typing import Any, Sequence import numpy as np +from scipy.signal.windows import tukey from quantem.core.datastructures.dataset4dstem import Dataset4dstem from quantem.core.io.serialize import AutoSerialize @@ -34,7 +35,6 @@ def from_datasets(cls, datasets: Sequence[Dataset4dstem]) -> "MAPED": def preprocess( self, - *, plot_summary: bool = True, scale: float | Sequence[float] | None = None, **plot_kwargs: Any, @@ -76,12 +76,171 @@ def preprocess( self.im_bf.append(np.asarray(im_bf_arr)) if plot_summary: + tiles = [[(self.im_bf[i] / self.scales[i]), self.dp_mean[i]] for i in range(n)] + titles = [[f"{i} - Mean Bright Field", f"{i} - Mean Diffraction Pattern"] for i in range(n)] show_2d( - [ - [self.im_bf[i] / self.scales[i] for i in range(n)], - [self.dp_mean[i] for i in range(n)], - ], + tiles, + titles=titles, **plot_kwargs, ) return self + + + def diffraction_find_origin( + self, + origins=None, + sigma=None, + plot_origins: bool = True, + plot_indices=None, + ): + """ + Choose or automatically find the origin in diffraction space. + + Parameters + ---------- + origins + Optional manual origins. Can be: + - a single (row, col) tuple, applied to all datasets + - a list of (row, col) tuples of length n (one per dataset) + - a list of (row, col) tuples shorter than n, used for plot/inspection only (will error if not broadcastable) + sigma + Optional low-pass smoothing sigma (pixels) applied to each mean DP prior to peak finding. + plot_origins + If True, plot mean diffraction patterns with overlaid origin markers. + plot_indices + Optional indices to plot. If None, plots all datasets. + + Stores + ------ + self.diffraction_origins : np.ndarray + Array of shape (n, 2) with integer (row, col) origins. + """ + import numpy as _np + + try: + from scipy.ndimage import gaussian_filter as _gaussian_filter + except Exception: # pragma: no cover + _gaussian_filter = None + + n = len(self.datasets) + if not hasattr(self, "dp_mean"): + raise RuntimeError("Run preprocess() first so self.dp_mean exists.") + + if plot_indices is None: + plot_indices_list = list(range(n)) + else: + plot_indices_list = list(plot_indices) + for i in plot_indices_list: + if i < 0 or i >= n: + raise IndexError("plot_indices contains an out-of-range index.") + + if origins is None: + origins_arr = _np.zeros((n, 2), dtype=int) + for i in range(n): + dp = _np.asarray(self.dp_mean[i]) + if sigma is not None and float(sigma) > 0: + if _gaussian_filter is None: + raise ImportError("scipy is required for sigma smoothing (gaussian_filter).") + dp_use = _gaussian_filter(dp.astype(float, copy=False), float(sigma)) + else: + dp_use = dp + ind = int(_np.argmax(dp_use)) + r, c = _np.unravel_index(ind, dp_use.shape) + origins_arr[i, 0] = int(r) + origins_arr[i, 1] = int(c) + else: + if isinstance(origins, tuple) and len(origins) == 2: + origins_arr = _np.tile(_np.asarray(origins, dtype=int)[None, :], (n, 1)) + else: + origins_list = list(origins) + if len(origins_list) != n: + raise ValueError("origins must be a single (row,col) tuple or a list of length n.") + origins_arr = _np.asarray(origins_list, dtype=int) + if origins_arr.shape != (n, 2): + raise ValueError("origins must have shape (n, 2) after conversion.") + + self.diffraction_origins = origins_arr + + if plot_origins: + dp_tiles = [[_np.asarray(self.dp_mean[i]) for i in plot_indices_list]] + titles = [[f"{i} - Mean Diffraction Pattern" for i in plot_indices_list]] + fig, axs = show_2d(dp_tiles, titles=titles, returnfig=True, **{}) + if not isinstance(axs, (list, _np.ndarray)): + axs = [axs] + axs_flat = _np.ravel(axs) + for j, i in enumerate(plot_indices_list): + ax = axs_flat[j] + r, c = self.diffraction_origins[i] + ax.plot([c], [r], marker="+", color="red", markersize=16, markeredgewidth=2) + return fig, axs + + return self + + + def diffraction_align( + self, + edge_blend = 8.0, + padding = None, + weight_scale = 1/8, + plot_aligned = True, + linewidth = 2, + **kwargs, + ): + """ + Refine the diffraction space origins, set padding, align images + + """ + + # window function + from scipy.signal.windows import tukey + w = tukey(self.dp_mean[0].shape[0], alpha=2.0*edge_blend/self.dp_mean[0].shape[0])[:,None] * \ + tukey(self.dp_mean[0].shape[1], alpha=2.0*edge_blend/self.dp_mean[0].shape[1])[None,:] + + # coordinates + r = np.fft.fftfreq(self.dp_mean[0].shape[0],1/self.dp_mean[0].shape[0])[:,None] + c = np.fft.fftfreq(self.dp_mean[0].shape[1],1/self.dp_mean[0].shape[1])[None,:] + + # init + shifts = np.zeros((len(self.dp_mean),2)) + + # correlation alignment + G_ref = np.fft.fft2(w * self.dp_mean[0]) + xy0 = self.diffraction_origins[0] + for ind in range(1,2): + G = np.conj(np.fft.fft2(w * self.dp_mean[ind])) + xy = self.diffraction_origins[ind] + + dr2 = (r - xy0[0] + xy[0])**2 \ + + (c - xy0[1] + xy[1])**2 + im_weight = np.clip(1 - np.sqrt(dr2)/np.mean(self.dp_mean[0].shape)/weight_scale, 0.0, 1.0) + im_weight = np.sin(im_weight*np.pi/2)**2 + + im_corr = np.real(np.fft.ifft2(G_ref * G)) * im_weight + + + + if plot_aligned: + show_2d( + np.fft.fftshift(im_corr), + norm = { + 'upper_quantile':1.0, + }, + **kwargs, + ) + + + def real_space_align( + self + ): + pass + + + + def merge_datasets( + self + ): + pass + + + \ No newline at end of file From 494dc248cb3ecb28ce9d112da6fdb24bf49db234 Mon Sep 17 00:00:00 2001 From: cophus Date: Sat, 31 Jan 2026 17:20:00 -0800 Subject: [PATCH 16/88] Updating with weighted correlation --- src/quantem/core/utils/imaging_utils.py | 130 ++++++++++++++++++++++++ src/quantem/diffraction/maped.py | 21 +++- tests/core/utils/test_imaging_utils.py | 56 +++++++++- 3 files changed, 204 insertions(+), 3 deletions(-) diff --git a/src/quantem/core/utils/imaging_utils.py b/src/quantem/core/utils/imaging_utils.py index 805ca5b0..e9c22b09 100644 --- a/src/quantem/core/utils/imaging_utils.py +++ b/src/quantem/core/utils/imaging_utils.py @@ -337,6 +337,136 @@ def dftUpsample_torch( return imageUpsample.real +def weighted_cross_correlation_shift( + im_ref=None, + im=None, + *, + cc=None, + weight_real=None, + upsample_factor: int = 1, + max_shift=None, + fft_input: bool = False, + return_shifted: bool = False, + shifted_output: str = "real", +): + """ + Weighted peak selection + DFT subpixel refinement for Fourier cross-correlation. + + You can provide either: + - im_ref and im (real-space images, or Fourier-domain if fft_input=True), OR + - cc (the Fourier-domain cross-spectrum), where cc = F_ref * conj(F_im) + + The weight is applied ONLY in real-space correlation to choose the peak location, + but the subpixel refinement uses the true (unweighted) cross-spectrum `cc`. + + Parameters + ---------- + im_ref, im : ndarray or None + Input images (real space) or their FFTs (if fft_input=True). + cc : ndarray or None + Fourier-domain cross-spectrum cc = F_ref * conj(F_im). If provided, im_ref/im are ignored. + weight_real : ndarray or None + Real-space weight image (same shape as correlation). Used only for peak selection. + If None, peak selection is unweighted. + upsample_factor : int + <= 2: half-pixel refinement (parabolic then rounded to nearest 0.5 px) + > 2 : additional DFT upsample refinement via _upsampled_correlation_numpy + max_shift : float or None + Optional radial cutoff (in pixels) applied to the (weighted) real correlation during peak pick. + fft_input : bool + If True, im_ref and im are already Fourier-domain arrays. + return_shifted : bool + If True, also return shifted version of `im` (or its FFT) aligned to `im_ref`. + Requires im to be provided (or fft_input=True with im as FFT). If only cc is provided, + shifted output is unavailable. + shifted_output : {"real","fft"} + Output type for the shifted image. + + Returns + ------- + shift_rc : tuple[float, float] + (d_row, d_col) shift to apply to `im` to align it to `im_ref`. + shifted : ndarray (optional) + Shifted image (real) or FFT (corner-centered) depending on shifted_output. + """ + import numpy as np + + from quantem.core.utils.imaging_utils import _parabolic_peak, _upsampled_correlation_numpy + + if cc is None: + if im_ref is None or im is None: + raise ValueError("Provide either `cc` or both `im_ref` and `im`.") + F_ref = np.asarray(im_ref) if fft_input else np.fft.fft2(np.asarray(im_ref)) + F_im = np.asarray(im) if fft_input else np.fft.fft2(np.asarray(im)) + cc = F_ref * np.conj(F_im) + else: + cc = np.asarray(cc) + F_im = None + + cc_real = np.fft.ifft2(cc).real + M, N = cc_real.shape + + if weight_real is not None: + w = np.asarray(weight_real) + if w.shape != cc_real.shape: + raise ValueError(f"weight_real.shape={w.shape} must match correlation shape {cc_real.shape}.") + cc_pick = cc_real * w + else: + cc_pick = cc_real + + if max_shift is not None: + x = np.fft.fftfreq(M) * M + y = np.fft.fftfreq(N) * N + mask = x[:, None] ** 2 + y[None, :] ** 2 > float(max_shift) ** 2 + cc_pick = cc_pick.copy() + cc_pick[mask] = -np.inf + + flat_idx = int(np.argmax(cc_pick)) + x0 = flat_idx // N + y0 = flat_idx % N + + x_inds = [((x0 + dx) % M) for dx in (-1, 0, 1)] + y_inds = [((y0 + dy) % N) for dy in (-1, 0, 1)] + vx = cc_pick[x_inds, y0] + vy = cc_pick[x0, y_inds] + + dx = _parabolic_peak(vx) + dy = _parabolic_peak(vy) + + x0 = np.round((float(x0) + float(dx)) * 2.0) / 2.0 + y0 = np.round((float(y0) + float(dy)) * 2.0) / 2.0 + xy_shift = np.array([x0, y0], dtype=float) + + if upsample_factor > 2: + xy_shift = _upsampled_correlation_numpy(cc, int(upsample_factor), xy_shift) + + dr = ((xy_shift[0] + M / 2) % M) - M / 2 + dc = ((xy_shift[1] + N / 2) % N) - N / 2 + shift_rc = (float(dr), float(dc)) + + if not return_shifted: + return shift_rc + + if im is None: + raise ValueError("return_shifted=True requires `im` (or its FFT via fft_input=True).") + + if F_im is None: + F_im = np.asarray(im) if fft_input else np.fft.fft2(np.asarray(im)) + + kr = np.fft.fftfreq(M)[:, None] + kc = np.fft.fftfreq(N)[None, :] + phase_ramp = np.exp(-2j * np.pi * (kr * shift_rc[0] + kc * shift_rc[1])) + F_im_shifted = F_im * phase_ramp + + out_mode = str(shifted_output).lower() + if out_mode in {"fft", "fourier"}: + return shift_rc, F_im_shifted + if out_mode in {"real", "image"}: + return shift_rc, np.fft.ifft2(F_im_shifted).real + + raise ValueError("shifted_output must be 'real' or 'fft'.") + + def bilinear_kde( xa: NDArray, ya: NDArray, diff --git a/src/quantem/diffraction/maped.py b/src/quantem/diffraction/maped.py index ca14c92f..a6b21d5b 100644 --- a/src/quantem/diffraction/maped.py +++ b/src/quantem/diffraction/maped.py @@ -8,6 +8,10 @@ from quantem.core.datastructures.dataset4dstem import Dataset4dstem from quantem.core.io.serialize import AutoSerialize from quantem.core.visualization import show_2d +# from quantem.core.utils.imaging_utils import cross_correlation_shift +# from quantem.core.utils.imaging_utils import dft_upsample +# from quantem.core.utils.imaging_utils import correlation_peak_shift_from_real +from quantem.core.utils.imaging_utils import cross_correlation_shift class MAPED(AutoSerialize): @@ -216,8 +220,23 @@ def diffraction_align( im_weight = np.clip(1 - np.sqrt(dr2)/np.mean(self.dp_mean[0].shape)/weight_scale, 0.0, 1.0) im_weight = np.sin(im_weight*np.pi/2)**2 - im_corr = np.real(np.fft.ifft2(G_ref * G)) * im_weight + # im_corr = np.real(np.fft.ifft2(G_ref * G)) * im_weight + # cc_weighted = + cc = G_ref * G + cc_weighted = np.fft.fft2(np.fft.ifft2(cc)) * im_weight + + + shift_rc = cross_correlation_shift( + cc_weighted, + np.ones_like(cc_weighted), # identity: cc = cc_weighted * conj(1) = cc_weighted + fft_input=True, # treat inputs as Fourier-domain + upsample_factor=100, # or whatever you want + ) + + shift = correlation_peak_shift_from_real(im_corr, upsample_factor=100) + + print(shift) if plot_aligned: diff --git a/tests/core/utils/test_imaging_utils.py b/tests/core/utils/test_imaging_utils.py index 691756dd..bc3989d6 100644 --- a/tests/core/utils/test_imaging_utils.py +++ b/tests/core/utils/test_imaging_utils.py @@ -3,16 +3,16 @@ """ import numpy as np +from scipy.ndimage import gaussian_filter import pytest torch = pytest.importorskip("torch") -from quantem.core.utils.imaging_utils import cross_correlation_shift, cross_correlation_shift_torch +from quantem.core.utils.imaging_utils import cross_correlation_shift, cross_correlation_shift_torch, weighted_cross_correlation_shift @pytest.fixture def spot_image(): - from scipy.ndimage import gaussian_filter im = np.zeros((64, 64), dtype=np.float64) im[32, 32] = 1.0 @@ -73,3 +73,55 @@ def test_cross_correlation_shift_torch_matches_expected(spot_image, shift_true, assert float(meas[0]) == pytest.approx(expected[0], abs=atol) assert float(meas[1]) == pytest.approx(expected[1], abs=atol) + +import numpy as np +import pytest + +from quantem.core.utils.imaging_utils import weighted_cross_correlation_shift + + +@pytest.fixture +def peak_grid_images(): + im_ref = np.zeros((80, 80), dtype=float) + im = np.zeros_like(im_ref) + + r_ref = np.array([17, 27, 37, 47], dtype=int) + r_im = np.array([27, 37, 47, 57], dtype=int) # shifted +10 rows + c = np.array([17, 27, 37, 47], dtype=int) + + for rr in r_ref: + for cc in c: + im_ref[rr, cc] = 1.0 + + for rr in r_im: + for cc in c: + im[rr, cc] = 1.0 + + im_ref[37,27] = 3.0 + im[27,27] = 3.0 + + im_ref = gaussian_filter(im_ref,1.0) + im = gaussian_filter(im,1.0) + + # Smooth wrapped radial weight centered at 0 shift + M, N = im_ref.shape + fr = np.fft.fftfreq(M) * M + fc = np.fft.fftfreq(N) * N + dr2 = fr[:, None] ** 2 + fc[None, :] ** 2 + + sigma = 3.0 + weight = np.exp(dr2 / (-2.0*sigma**2)) + + return im_ref, im, weight + + +def test_weighted_cross_correlation_shift_unweighted_prefers_full_overlap(peak_grid_images): + im_ref, im, weight = peak_grid_images + shift = weighted_cross_correlation_shift(im_ref, im, upsample_factor=1000) + assert np.allclose(shift, (-10.0, 0.0), atol=1e-3) + + +def test_weighted_cross_correlation_shift_weighted_prefers_near_zero(peak_grid_images): + im_ref, im, weight = peak_grid_images + shift = weighted_cross_correlation_shift(im_ref, im, weight_real=weight, upsample_factor=1000) + assert np.allclose(shift, (0.0, 0.0), atol=1e-3) From 38a3fc21c6f34d697102947008b3c82a84e923e6 Mon Sep 17 00:00:00 2001 From: cophus Date: Sun, 1 Feb 2026 16:53:38 -0800 Subject: [PATCH 17/88] maped output --- src/quantem/core/utils/imaging_utils.py | 54 +-- src/quantem/diffraction/maped.py | 541 ++++++++++++++++++++++-- 2 files changed, 521 insertions(+), 74 deletions(-) diff --git a/src/quantem/core/utils/imaging_utils.py b/src/quantem/core/utils/imaging_utils.py index e9c22b09..f6e5b660 100644 --- a/src/quantem/core/utils/imaging_utils.py +++ b/src/quantem/core/utils/imaging_utils.py @@ -346,53 +346,27 @@ def weighted_cross_correlation_shift( upsample_factor: int = 1, max_shift=None, fft_input: bool = False, - return_shifted: bool = False, - shifted_output: str = "real", + fft_output: bool = False, + return_shifted_image: bool = False, ): """ Weighted peak selection + DFT subpixel refinement for Fourier cross-correlation. - You can provide either: + Provide either: - im_ref and im (real-space images, or Fourier-domain if fft_input=True), OR - cc (the Fourier-domain cross-spectrum), where cc = F_ref * conj(F_im) The weight is applied ONLY in real-space correlation to choose the peak location, but the subpixel refinement uses the true (unweighted) cross-spectrum `cc`. - Parameters - ---------- - im_ref, im : ndarray or None - Input images (real space) or their FFTs (if fft_input=True). - cc : ndarray or None - Fourier-domain cross-spectrum cc = F_ref * conj(F_im). If provided, im_ref/im are ignored. - weight_real : ndarray or None - Real-space weight image (same shape as correlation). Used only for peak selection. - If None, peak selection is unweighted. - upsample_factor : int - <= 2: half-pixel refinement (parabolic then rounded to nearest 0.5 px) - > 2 : additional DFT upsample refinement via _upsampled_correlation_numpy - max_shift : float or None - Optional radial cutoff (in pixels) applied to the (weighted) real correlation during peak pick. - fft_input : bool - If True, im_ref and im are already Fourier-domain arrays. - return_shifted : bool - If True, also return shifted version of `im` (or its FFT) aligned to `im_ref`. - Requires im to be provided (or fft_input=True with im as FFT). If only cc is provided, - shifted output is unavailable. - shifted_output : {"real","fft"} - Output type for the shifted image. - Returns ------- shift_rc : tuple[float, float] (d_row, d_col) shift to apply to `im` to align it to `im_ref`. shifted : ndarray (optional) - Shifted image (real) or FFT (corner-centered) depending on shifted_output. + If return_shifted=True: shifted image. If fft_output=True returns FFT (corner-centered), + else returns real-space image. """ - import numpy as np - - from quantem.core.utils.imaging_utils import _parabolic_peak, _upsampled_correlation_numpy - if cc is None: if im_ref is None or im is None: raise ValueError("Provide either `cc` or both `im_ref` and `im`.") @@ -415,9 +389,9 @@ def weighted_cross_correlation_shift( cc_pick = cc_real if max_shift is not None: - x = np.fft.fftfreq(M) * M - y = np.fft.fftfreq(N) * N - mask = x[:, None] ** 2 + y[None, :] ** 2 > float(max_shift) ** 2 + fr = np.fft.fftfreq(M) * M + fc = np.fft.fftfreq(N) * N + mask = fr[:, None] ** 2 + fc[None, :] ** 2 > float(max_shift) ** 2 cc_pick = cc_pick.copy() cc_pick[mask] = -np.inf @@ -444,11 +418,11 @@ def weighted_cross_correlation_shift( dc = ((xy_shift[1] + N / 2) % N) - N / 2 shift_rc = (float(dr), float(dc)) - if not return_shifted: + if not return_shifted_image: return shift_rc if im is None: - raise ValueError("return_shifted=True requires `im` (or its FFT via fft_input=True).") + raise ValueError("return_shifted_image=True requires `im` (or its FFT via fft_input=True).") if F_im is None: F_im = np.asarray(im) if fft_input else np.fft.fft2(np.asarray(im)) @@ -458,13 +432,9 @@ def weighted_cross_correlation_shift( phase_ramp = np.exp(-2j * np.pi * (kr * shift_rc[0] + kc * shift_rc[1])) F_im_shifted = F_im * phase_ramp - out_mode = str(shifted_output).lower() - if out_mode in {"fft", "fourier"}: + if fft_output: return shift_rc, F_im_shifted - if out_mode in {"real", "image"}: - return shift_rc, np.fft.ifft2(F_im_shifted).real - - raise ValueError("shifted_output must be 'real' or 'fft'.") + return shift_rc, np.fft.ifft2(F_im_shifted).real def bilinear_kde( diff --git a/src/quantem/diffraction/maped.py b/src/quantem/diffraction/maped.py index a6b21d5b..afb1fc0c 100644 --- a/src/quantem/diffraction/maped.py +++ b/src/quantem/diffraction/maped.py @@ -8,10 +8,7 @@ from quantem.core.datastructures.dataset4dstem import Dataset4dstem from quantem.core.io.serialize import AutoSerialize from quantem.core.visualization import show_2d -# from quantem.core.utils.imaging_utils import cross_correlation_shift -# from quantem.core.utils.imaging_utils import dft_upsample -# from quantem.core.utils.imaging_utils import correlation_peak_shift_from_real -from quantem.core.utils.imaging_utils import cross_correlation_shift +from quantem.core.utils.imaging_utils import weighted_cross_correlation_shift class MAPED(AutoSerialize): @@ -184,8 +181,10 @@ def diffraction_find_origin( def diffraction_align( self, - edge_blend = 8.0, + edge_blend = 16.0, padding = None, + pad_val = 'min', + upsample_factor = 100, weight_scale = 1/8, plot_aligned = True, linewidth = 2, @@ -206,13 +205,13 @@ def diffraction_align( c = np.fft.fftfreq(self.dp_mean[0].shape[1],1/self.dp_mean[0].shape[1])[None,:] # init - shifts = np.zeros((len(self.dp_mean),2)) + self.diffraction_shifts = np.zeros((len(self.dp_mean),2)) # correlation alignment G_ref = np.fft.fft2(w * self.dp_mean[0]) xy0 = self.diffraction_origins[0] - for ind in range(1,2): - G = np.conj(np.fft.fft2(w * self.dp_mean[ind])) + for ind in range(1,len(self.dp_mean)): + G = np.fft.fft2(w * self.dp_mean[ind]) xy = self.diffraction_origins[ind] dr2 = (r - xy0[0] + xy[0])**2 \ @@ -220,46 +219,524 @@ def diffraction_align( im_weight = np.clip(1 - np.sqrt(dr2)/np.mean(self.dp_mean[0].shape)/weight_scale, 0.0, 1.0) im_weight = np.sin(im_weight*np.pi/2)**2 - # im_corr = np.real(np.fft.ifft2(G_ref * G)) * im_weight - # cc_weighted = - - cc = G_ref * G - cc_weighted = np.fft.fft2(np.fft.ifft2(cc)) * im_weight - - - shift_rc = cross_correlation_shift( - cc_weighted, - np.ones_like(cc_weighted), # identity: cc = cc_weighted * conj(1) = cc_weighted - fft_input=True, # treat inputs as Fourier-domain - upsample_factor=100, # or whatever you want + shift, G_shift = weighted_cross_correlation_shift( + im_ref=G_ref, + im=G, + weight_real=im_weight*0+1.0, + upsample_factor = upsample_factor, + fft_input = True, + fft_output = True, + return_shifted_image = True, ) + self.diffraction_shifts[ind,:] = shift - shift = correlation_peak_shift_from_real(im_corr, upsample_factor=100) + # update reference + G_ref = G_ref*(ind/(ind+1)) + G_shift/(ind+1) - print(shift) + # Center shifts + self.diffraction_shifts -= np.mean(self.diffraction_shifts,axis=0)[None,:] + # Generate output image if plot_aligned: + im_aligned = shift_images( + images = self.dp_mean, + shifts_rc = self.diffraction_shifts, + edge_blend = edge_blend, + padding = padding, + pad_val = pad_val, + ) show_2d( - np.fft.fftshift(im_corr), - norm = { - 'upper_quantile':1.0, - }, + im_aligned, **kwargs, ) def real_space_align( - self + self, + num_images=None, + num_iter: int = 3, + edge_blend: float = 1.0, + padding=None, + pad_val: str | float = "median", + upsample_factor: int = 100, + max_shift=None, + shift_method: str = "bilinear", + edge_filter: bool = True, + edge_sigma: float = 2.0, + hanning_filter: bool = False, + plot_aligned: bool = True, + **kwargs, ): - pass + import numpy as np + from scipy.ndimage import gaussian_filter, shift as ndi_shift + from scipy.signal import convolve2d + from scipy.signal.windows import tukey + + from quantem.core.utils.imaging_utils import weighted_cross_correlation_shift + from quantem.core.visualization import show_2d + + if not hasattr(self, "im_bf"): + raise RuntimeError("Run preprocess() first so self.im_bf exists.") + if len(self.im_bf) == 0: + raise RuntimeError("No images found in self.im_bf.") + + H, W = self.im_bf[0].shape + for im in self.im_bf: + if im.shape != (H, W): + raise ValueError("all self.im_bf images must have the same shape") + + n_total = len(self.im_bf) + if num_images is None: + n = n_total + else: + n = int(num_images) + if n <= 0: + raise ValueError("num_images must be positive") + n = min(n, n_total) + + if int(num_iter) < 1: + raise ValueError("num_iter must be >= 1") + + if max_shift is not None: + pad_cc = int(np.ceil(float(max_shift))) + 4 + else: + pad_cc = int(np.ceil(float(edge_blend))) + 4 + + Hp = H + 2 * pad_cc + Wp = W + 2 * pad_cc + r0 = pad_cc + c0 = pad_cc + + w_h = np.ones((H, W), dtype=float) + if hanning_filter: + w_h = np.hanning(H)[:, None] * np.hanning(W)[None, :] + w_h_pad = np.zeros((Hp, Wp), dtype=float) + w_h_pad[r0 : r0 + H, c0 : c0 + W] = w_h + w_h_sum = float(np.sum(w_h_pad)) + if w_h_sum <= 0: + raise RuntimeError("hanning window sum is zero") + + wx = None + if edge_filter: + wx = np.array( + [ + [-1.0, -2.0, -1.0], + [ 0.0, 0.0, 0.0], + [ 1.0, 2.0, 1.0], + ], + dtype=float, + ) + + base_pad = np.zeros((n, Hp, Wp), dtype=float) + for i in range(n): + im0 = np.asarray(self.im_bf[i], dtype=float) + + if edge_filter: + gx = convolve2d(im0, wx, mode="same", boundary="symm") + gy = convolve2d(im0, wx.T, mode="same", boundary="symm") + gx = gaussian_filter(gx, float(edge_sigma), mode="nearest") + gy = gaussian_filter(gy, float(edge_sigma), mode="nearest") + im_use = np.sqrt(gx * gx + gy * gy) + else: + im_use = im0 + + base_pad[i, r0 : r0 + H, c0 : c0 + W] = im_use + + shifts = np.zeros((n, 2), dtype=float) + + for _ in range(int(num_iter)): + G_list = np.empty((n, Hp, Wp), dtype=np.complex128) + + for i in range(n): + im_a = ndi_shift( + base_pad[i], + shift=(float(shifts[i, 0]), float(shifts[i, 1])), + order=1, + mode="constant", + cval=0.0, + prefilter=False, + ) + im_mean = float(np.sum(im_a * w_h_pad) / w_h_sum) + im_win = (im_a - im_mean) * w_h_pad + G_list[i] = np.fft.fft2(im_win) + + G_ref = np.mean(G_list, axis=0) + + for i in range(1, n): + drc = weighted_cross_correlation_shift( + im_ref=G_ref, + im=G_list[i], + weight_real=None, + upsample_factor=int(upsample_factor), + max_shift=max_shift, + fft_input=True, + fft_output=False, + return_shifted_image=False, + ) + shifts[i, 0] += float(drc[0]) + shifts[i, 1] += float(drc[1]) + + shifts -= shifts[0][None, :] + + shifts -= np.mean(shifts, axis=0)[None, :] + + self.real_space_shifts = np.zeros((n_total, 2), dtype=float) + self.real_space_shifts[:n, :] = shifts + + if plot_aligned: + im_aligned = shift_images( + images=self.im_bf[:n], + shifts_rc=self.real_space_shifts[:n, :], + edge_blend=float(edge_blend), + padding=padding, + pad_val=pad_val, + shift_method=str(shift_method), + ) + show_2d(im_aligned, **kwargs) + + return self - def merge_datasets( - self + self, + real_space_padding=0, + real_space_edge_blend=1.0, + diffraction_padding=0, + diffraction_edge_blend=0.0, + diffraction_pad_val="min", + shift_method: str = "bilinear", + plot_result: bool = True, + **kwargs, ): - pass + import numpy as np + from scipy.ndimage import shift as ndi_shift + from scipy.signal.windows import tukey + from tqdm import tqdm + + if not hasattr(self, "real_space_shifts"): + raise RuntimeError("Run real_space_align() first so self.real_space_shifts exists.") + if not hasattr(self, "diffraction_shifts"): + raise RuntimeError("Run diffraction_align() first so self.diffraction_shifts exists.") + + n = len(self.datasets) + if n == 0: + raise RuntimeError("No datasets found in self.datasets.") + + arrays = [np.asarray(d.array, dtype=float) for d in self.datasets] + shape0 = arrays[0].shape + if len(shape0) != 4: + raise ValueError("Expected Dataset4dstem arrays with shape (R, C, H, W).") + Rs, Cs, H, W = shape0 + for a in arrays: + if a.shape != (Rs, Cs, H, W): + raise ValueError("All datasets must have the same shape (R, C, H, W).") + + rs_shifts = np.asarray(self.real_space_shifts, dtype=float) + dp_shifts = np.asarray(self.diffraction_shifts, dtype=float) + if rs_shifts.shape != (n, 2): + raise ValueError("self.real_space_shifts must have shape (n, 2).") + if dp_shifts.shape != (n, 2): + raise ValueError("self.diffraction_shifts must have shape (n, 2).") + + real_space_padding = int(real_space_padding) + if real_space_padding < 0: + raise ValueError("real_space_padding must be >= 0.") + + method = str(shift_method).strip().lower() + if method not in {"bilinear", "fourier"}: + raise ValueError("shift_method must be 'bilinear' or 'fourier'.") + + # Real-space taper window (used to weight contributions) + alpha_r = min(1.0, 2.0 * float(real_space_edge_blend) / float(Rs)) if real_space_edge_blend > 0 else 0.0 + alpha_c = min(1.0, 2.0 * float(real_space_edge_blend) / float(Cs)) if real_space_edge_blend > 0 else 0.0 + w_rs = tukey(Rs, alpha=alpha_r)[:, None] * tukey(Cs, alpha=alpha_c)[None, :] + w_rs = w_rs.astype(float, copy=False) + + # Diffraction padding (must be large enough to prevent wrap for Fourier shifts) + diffraction_padding = int(diffraction_padding) + if diffraction_padding < 0: + raise ValueError("diffraction_padding must be >= 0.") + max_abs_dp = float(np.max(np.abs(dp_shifts))) if dp_shifts.size else 0.0 + pad_dp_min = int(np.ceil(max_abs_dp)) + 2 + pad_dp = max(diffraction_padding, pad_dp_min) + + Hp = H + 2 * pad_dp + Wp = W + 2 * pad_dp + rp0 = pad_dp + cp0 = pad_dp + + # Diffraction taper window + alpha_hr = min(1.0, 2.0 * float(diffraction_edge_blend) / float(H)) if diffraction_edge_blend > 0 else 0.0 + alpha_hc = min(1.0, 2.0 * float(diffraction_edge_blend) / float(W)) if diffraction_edge_blend > 0 else 0.0 + w_dp = tukey(H, alpha=alpha_hr)[:, None] * tukey(W, alpha=alpha_hc)[None, :] + w_dp = w_dp.astype(float, copy=False) + + # Padded diffraction window (unshifted) + w_dp_pad = np.zeros((Hp, Wp)) + w_dp_pad[rp0 : rp0 + H, cp0 : cp0 + W] = w_dp + + # Pad value in diffraction space (computed from dp_means for speed) + if isinstance(diffraction_pad_val, str): + s = diffraction_pad_val.strip().lower() + dp_means = [np.mean(a, axis=(0, 1)) for a in arrays] + v = np.stack(dp_means, axis=0).reshape(-1) + if s == "min": + pad_val_dp = float(np.min(v)) + elif s == "max": + pad_val_dp = float(np.max(v)) + elif s == "mean": + pad_val_dp = float(np.mean(v)) + elif s == "median": + pad_val_dp = float(np.median(v)) + else: + raise ValueError("diffraction_pad_val must be a float or one of {'min','max','mean','median'}.") + else: + pad_val_dp = float(diffraction_pad_val) + + # Precompute diffraction window shifts per dataset + if method == "fourier": + kr = np.fft.fftfreq(Hp)[:, None] + kc = np.fft.fftfreq(Wp)[None, :] + ramps = [ + np.exp(-2j * np.pi * (kr * float(dp_shifts[i, 0]) + kc * float(dp_shifts[i, 1]))) + for i in range(n) + ] + wdp_shifted = np.empty((n, Hp, Wp)) + Fw0 = np.fft.fft2(w_dp_pad) + for i in range(n): + wtmp = np.fft.ifft2(Fw0 * ramps[i]).real + wtmp = np.clip(wtmp, 0.0, 1.0) + wdp_shifted[i] = wtmp + else: + wdp_shifted = np.empty((n, Hp, Wp)) + for i in range(n): + wtmp = ndi_shift( + w_dp_pad, + shift=(float(dp_shifts[i, 0]), float(dp_shifts[i, 1])), + order=1, + mode="constant", + cval=0.0, + prefilter=False, + ) + wtmp = np.clip(wtmp, 0.0, 1.0) + wdp_shifted[i] = wtmp + + # Edge blend weight for pad value (diffraction space) + edge_w_dp = 1.0 - np.clip(np.max(wdp_shifted, axis=0), 0.0, 1.0) + + Rout = Rs + 2 * real_space_padding + Cout = Cs + 2 * real_space_padding + merged = np.zeros((Rout, Cout, Hp, Wp)) + + dp_local = np.empty((H, W)) + dp_pad = np.zeros((Hp, Wp)) + dp_shifted_tmp = np.empty((Hp, Wp)) + num_tmp = np.zeros((Hp, Wp)) + den_tmp = np.zeros((Hp, Wp)) + out_tmp = np.empty((Hp, Wp)) + + for ro in tqdm(range(Rout), desc="Merging (rows)"): + r_base = float(ro - real_space_padding) + for co in range(Cout): + c_base = float(co - real_space_padding) + + num_tmp.fill(0.0) + den_tmp.fill(0.0) + max_wi = 0.0 + + for i in range(n): + r_in = r_base - float(rs_shifts[i, 0]) + c_in = c_base - float(rs_shifts[i, 1]) + + r0 = int(np.floor(r_in)) + c0 = int(np.floor(c_in)) + if r0 < 0 or r0 >= Rs - 1 or c0 < 0 or c0 >= Cs - 1: + continue + + dr = float(r_in - r0) + dc = float(c_in - c0) + + w00 = (1.0 - dr) * (1.0 - dc) + w10 = dr * (1.0 - dc) + w01 = (1.0 - dr) * dc + w11 = dr * dc + + wi = ( + w00 * w_rs[r0, c0] + + w10 * w_rs[r0 + 1, c0] + + w01 * w_rs[r0, c0 + 1] + + w11 * w_rs[r0 + 1, c0 + 1] + ) + if wi <= 0.0: + continue + if wi > max_wi: + max_wi = wi + + a = arrays[i] + dp_local[:] = ( + w00 * a[r0, c0] + + w10 * a[r0 + 1, c0] + + w01 * a[r0, c0 + 1] + + w11 * a[r0 + 1, c0 + 1] + ) + + dp_pad.fill(0.0) + dp_pad[rp0 : rp0 + H, cp0 : cp0 + W] = dp_local * w_dp + + if method == "fourier": + dp_shifted_tmp[:] = np.fft.ifft2(np.fft.fft2(dp_pad) * ramps[i]).real + else: + dp_shifted_tmp[:] = ndi_shift( + dp_pad, + shift=(float(dp_shifts[i, 0]), float(dp_shifts[i, 1])), + order=1, + mode="constant", + cval=0.0, + prefilter=False, + ) + + num_tmp += wi * dp_shifted_tmp + den_tmp += wi * wdp_shifted[i] + + if max_wi <= 0.0: + merged[ro, co] = 0.0 + continue + + num = num_tmp + edge_w_dp * pad_val_dp + den = den_tmp + edge_w_dp + + np.divide(num, den, out=out_tmp, where=(den > 0.0)) + out_tmp[den <= 0.0] = pad_val_dp + merged[ro, co] = out_tmp + + dataset_merged = Dataset4dstem.from_array(merged) + + self.im_bf_merged = np.mean(merged, axis=(2, 3)) + self.dp_mean_merged = np.mean(merged, axis=(0, 1)) + + dataset_merged.im_bf_merged = self.im_bf_merged + dataset_merged.dp_mean_merged = self.dp_mean_merged + dataset_merged.metadata["im_bf_merged"] = self.im_bf_merged + dataset_merged.metadata["dp_mean_merged"] = self.dp_mean_merged + dataset_merged.metadata["real_space_shifts_rc"] = rs_shifts.copy() + dataset_merged.metadata["diffraction_shifts_rc"] = dp_shifts.copy() + + if plot_result: + show_2d( + [[self.im_bf_merged, self.dp_mean_merged]], + titles=[["Merged Bright Field", "Merged Mean Diffraction Pattern"]], + **kwargs, + ) + + return dataset_merged + + +def shift_images( + images, + shifts_rc, + edge_blend: float = 8.0, + padding=None, + pad_val=0.0, + shift_method: str = "bilinear", +): + import numpy as np + from scipy.ndimage import shift as ndi_shift + from scipy.signal.windows import tukey + + images = [np.asarray(im, dtype=float) for im in images] + if len(images) == 0: + raise ValueError("images must be non-empty") + + H, W = images[0].shape + for im in images: + if im.shape != (H, W): + raise ValueError("all images must have the same shape") + + shifts_rc = np.asarray(shifts_rc, dtype=float) + if shifts_rc.shape != (len(images), 2): + raise ValueError("shifts_rc must have shape (len(images), 2)") + + if isinstance(pad_val, str): + s = pad_val.strip().lower() + v = np.stack(images, axis=0).reshape(-1) + if s == "min": + pad_val = float(np.min(v)) + elif s == "max": + pad_val = float(np.max(v)) + elif s == "mean": + pad_val = float(np.mean(v)) + elif s == "median": + pad_val = float(np.median(v)) + else: + raise ValueError("pad_val must be a float or one of {'min','max','mean','median'}") + else: + pad_val = float(pad_val) + + if padding is None: + max_shift = float(np.max(np.abs(shifts_rc))) if shifts_rc.size else 0.0 + padding = int(np.ceil(max_shift + float(edge_blend))) + 2 + padding = int(padding) + + alpha_r = min(1.0, 2.0 * float(edge_blend) / float(H)) if edge_blend > 0 else 0.0 + alpha_c = min(1.0, 2.0 * float(edge_blend) / float(W)) if edge_blend > 0 else 0.0 + w = tukey(H, alpha=alpha_r)[:, None] * tukey(W, alpha=alpha_c)[None, :] + w = w.astype(float, copy=False) + + Hp = H + 2 * padding + Wp = W + 2 * padding + + stack_w = np.zeros((len(images), Hp, Wp), dtype=float) + stack = np.zeros_like(stack_w) + + r0 = padding + c0 = padding + stack_w[:, r0 : r0 + H, c0 : c0 + W] = w[None, :, :] + for ind, im in enumerate(images): + stack[ind, r0 : r0 + H, c0 : c0 + W] = im * w + + method = str(shift_method).strip().lower() + if method not in {"bilinear", "fourier"}: + raise ValueError("shift_method must be 'bilinear' or 'fourier'") + + if method == "fourier": + kr = np.fft.fftfreq(Hp)[:, None] + kc = np.fft.fftfreq(Wp)[None, :] + for ind in range(len(images)): + dr, dc = float(shifts_rc[ind, 0]), float(shifts_rc[ind, 1]) + ramp = np.exp(-2j * np.pi * (kr * dr + kc * dc)) + + F = np.fft.fft2(stack[ind]) + stack[ind] = np.fft.ifft2(F * ramp).real + + Fw = np.fft.fft2(stack_w[ind]) + stack_w[ind] = np.fft.ifft2(Fw * ramp).real + stack_w[ind] = np.clip(stack_w[ind], 0.0, 1.0) + else: + for ind in range(len(images)): + stack[ind] = ndi_shift( + stack[ind], + shift=(float(shifts_rc[ind, 0]), float(shifts_rc[ind, 1])), + order=1, + mode="constant", + cval=0.0, + prefilter=False, + ) + stack_w[ind] = ndi_shift( + stack_w[ind], + shift=(float(shifts_rc[ind, 0]), float(shifts_rc[ind, 1])), + order=1, + mode="constant", + cval=0.0, + prefilter=False, + ) + stack_w[ind] = np.clip(stack_w[ind], 0.0, 1.0) + + # edge_w = 1.0 - np.clip(np.max(stack_w, axis=0), 0.0, 1.0) + edge_w = len(images) - np.sum(stack_w, axis=0) + num = np.sum(stack, axis=0) + edge_w * pad_val + den = np.sum(stack_w, axis=0) + edge_w + out = num / den - \ No newline at end of file + return out From 1e14d70ea12b9e4fb61b71d3ecdd90e66ebe9b6f Mon Sep 17 00:00:00 2001 From: cophus Date: Sun, 1 Feb 2026 17:13:41 -0800 Subject: [PATCH 18/88] datatype control for merged data --- src/quantem/diffraction/maped.py | 190 +++++++++++++++++++------------ 1 file changed, 118 insertions(+), 72 deletions(-) diff --git a/src/quantem/diffraction/maped.py b/src/quantem/diffraction/maped.py index afb1fc0c..9df542f3 100644 --- a/src/quantem/diffraction/maped.py +++ b/src/quantem/diffraction/maped.py @@ -406,9 +406,13 @@ def merge_datasets( diffraction_edge_blend=0.0, diffraction_pad_val="min", shift_method: str = "bilinear", + dtype=None, + scale_output: bool = False, plot_result: bool = True, **kwargs, ): + import warnings + import numpy as np from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey @@ -419,18 +423,15 @@ def merge_datasets( if not hasattr(self, "diffraction_shifts"): raise RuntimeError("Run diffraction_align() first so self.diffraction_shifts exists.") - n = len(self.datasets) + arrays = [np.asarray(d.array) for d in self.datasets] + n = len(arrays) if n == 0: raise RuntimeError("No datasets found in self.datasets.") - arrays = [np.asarray(d.array, dtype=float) for d in self.datasets] - shape0 = arrays[0].shape - if len(shape0) != 4: - raise ValueError("Expected Dataset4dstem arrays with shape (R, C, H, W).") - Rs, Cs, H, W = shape0 + Rs, Cs, H, W = arrays[0].shape for a in arrays: if a.shape != (Rs, Cs, H, W): - raise ValueError("All datasets must have the same shape (R, C, H, W).") + raise ValueError("All dataset arrays must have the same shape (Rs, Cs, H, W).") rs_shifts = np.asarray(self.real_space_shifts, dtype=float) dp_shifts = np.asarray(self.diffraction_shifts, dtype=float) @@ -439,48 +440,48 @@ def merge_datasets( if dp_shifts.shape != (n, 2): raise ValueError("self.diffraction_shifts must have shape (n, 2).") + if dtype is None: + dtype_out = np.asarray(arrays[0]).dtype + warnings.warn(f"dtype=None; using parent dtype {dtype_out}.", RuntimeWarning) + else: + dtype_out = np.dtype(dtype) + real_space_padding = int(real_space_padding) - if real_space_padding < 0: - raise ValueError("real_space_padding must be >= 0.") + diffraction_padding = int(diffraction_padding) + + Rout = Rs + 2 * real_space_padding + Cout = Cs + 2 * real_space_padding + + Hp = H + 2 * diffraction_padding + Wp = W + 2 * diffraction_padding + rp0 = diffraction_padding + cp0 = diffraction_padding method = str(shift_method).strip().lower() if method not in {"bilinear", "fourier"}: raise ValueError("shift_method must be 'bilinear' or 'fourier'.") - # Real-space taper window (used to weight contributions) - alpha_r = min(1.0, 2.0 * float(real_space_edge_blend) / float(Rs)) if real_space_edge_blend > 0 else 0.0 - alpha_c = min(1.0, 2.0 * float(real_space_edge_blend) / float(Cs)) if real_space_edge_blend > 0 else 0.0 - w_rs = tukey(Rs, alpha=alpha_r)[:, None] * tukey(Cs, alpha=alpha_c)[None, :] + if real_space_edge_blend and float(real_space_edge_blend) > 0: + alpha_r = min(1.0, 2.0 * float(real_space_edge_blend) / float(Rs)) + alpha_c = min(1.0, 2.0 * float(real_space_edge_blend) / float(Cs)) + w_rs = tukey(Rs, alpha=alpha_r)[:, None] * tukey(Cs, alpha=alpha_c)[None, :] + else: + w_rs = np.ones((Rs, Cs), dtype=float) w_rs = w_rs.astype(float, copy=False) - # Diffraction padding (must be large enough to prevent wrap for Fourier shifts) - diffraction_padding = int(diffraction_padding) - if diffraction_padding < 0: - raise ValueError("diffraction_padding must be >= 0.") - max_abs_dp = float(np.max(np.abs(dp_shifts))) if dp_shifts.size else 0.0 - pad_dp_min = int(np.ceil(max_abs_dp)) + 2 - pad_dp = max(diffraction_padding, pad_dp_min) - - Hp = H + 2 * pad_dp - Wp = W + 2 * pad_dp - rp0 = pad_dp - cp0 = pad_dp - - # Diffraction taper window - alpha_hr = min(1.0, 2.0 * float(diffraction_edge_blend) / float(H)) if diffraction_edge_blend > 0 else 0.0 - alpha_hc = min(1.0, 2.0 * float(diffraction_edge_blend) / float(W)) if diffraction_edge_blend > 0 else 0.0 - w_dp = tukey(H, alpha=alpha_hr)[:, None] * tukey(W, alpha=alpha_hc)[None, :] + if diffraction_edge_blend and float(diffraction_edge_blend) > 0: + alpha_dr = min(1.0, 2.0 * float(diffraction_edge_blend) / float(H)) + alpha_dc = min(1.0, 2.0 * float(diffraction_edge_blend) / float(W)) + w_dp = tukey(H, alpha=alpha_dr)[:, None] * tukey(W, alpha=alpha_dc)[None, :] + else: + w_dp = np.ones((H, W), dtype=float) w_dp = w_dp.astype(float, copy=False) - # Padded diffraction window (unshifted) - w_dp_pad = np.zeros((Hp, Wp)) - w_dp_pad[rp0 : rp0 + H, cp0 : cp0 + W] = w_dp + dp_means = [np.mean(a, axis=(0, 1), dtype=np.float64) for a in arrays] + v = np.stack(dp_means, axis=0).reshape(-1) - # Pad value in diffraction space (computed from dp_means for speed) if isinstance(diffraction_pad_val, str): s = diffraction_pad_val.strip().lower() - dp_means = [np.mean(a, axis=(0, 1)) for a in arrays] - v = np.stack(dp_means, axis=0).reshape(-1) if s == "min": pad_val_dp = float(np.min(v)) elif s == "max": @@ -494,47 +495,44 @@ def merge_datasets( else: pad_val_dp = float(diffraction_pad_val) - # Precompute diffraction window shifts per dataset + wdp_pad = np.zeros((Hp, Wp), dtype=float) + wdp_pad[rp0 : rp0 + H, cp0 : cp0 + W] = w_dp + + wdp_shifted = np.zeros((n, Hp, Wp), dtype=float) if method == "fourier": kr = np.fft.fftfreq(Hp)[:, None] kc = np.fft.fftfreq(Wp)[None, :] - ramps = [ - np.exp(-2j * np.pi * (kr * float(dp_shifts[i, 0]) + kc * float(dp_shifts[i, 1]))) - for i in range(n) - ] - wdp_shifted = np.empty((n, Hp, Wp)) - Fw0 = np.fft.fft2(w_dp_pad) + ramps = [] + Fw = np.fft.fft2(wdp_pad) for i in range(n): - wtmp = np.fft.ifft2(Fw0 * ramps[i]).real - wtmp = np.clip(wtmp, 0.0, 1.0) - wdp_shifted[i] = wtmp + dr, dc = float(dp_shifts[i, 0]), float(dp_shifts[i, 1]) + ramp = np.exp(-2j * np.pi * (kr * dr + kc * dc)) + ramps.append(ramp) + w_i = np.fft.ifft2(Fw * ramp).real + wdp_shifted[i] = np.clip(w_i, 0.0, 1.0) else: - wdp_shifted = np.empty((n, Hp, Wp)) for i in range(n): - wtmp = ndi_shift( - w_dp_pad, + w_i = ndi_shift( + wdp_pad, shift=(float(dp_shifts[i, 0]), float(dp_shifts[i, 1])), order=1, mode="constant", cval=0.0, prefilter=False, ) - wtmp = np.clip(wtmp, 0.0, 1.0) - wdp_shifted[i] = wtmp + wdp_shifted[i] = np.clip(w_i, 0.0, 1.0) + ramps = None - # Edge blend weight for pad value (diffraction space) - edge_w_dp = 1.0 - np.clip(np.max(wdp_shifted, axis=0), 0.0, 1.0) + edge_w_dp = 1.0 - np.max(wdp_shifted, axis=0) + edge_w_dp = np.clip(edge_w_dp, 0.0, 1.0) - Rout = Rs + 2 * real_space_padding - Cout = Cs + 2 * real_space_padding - merged = np.zeros((Rout, Cout, Hp, Wp)) + merged = np.zeros((Rout, Cout, Hp, Wp), dtype=np.float64) - dp_local = np.empty((H, W)) - dp_pad = np.zeros((Hp, Wp)) - dp_shifted_tmp = np.empty((Hp, Wp)) - num_tmp = np.zeros((Hp, Wp)) - den_tmp = np.zeros((Hp, Wp)) - out_tmp = np.empty((Hp, Wp)) + dp_local = np.zeros((H, W), dtype=np.float64) + dp_pad = np.zeros((Hp, Wp), dtype=np.float64) + dp_shifted_tmp = np.zeros((Hp, Wp), dtype=np.float64) + num_tmp = np.zeros((Hp, Wp), dtype=np.float64) + den_tmp = np.zeros((Hp, Wp), dtype=np.float64) for ro in tqdm(range(Rout), desc="Merging (rows)"): r_base = float(ro - real_space_padding) @@ -606,21 +604,69 @@ def merge_datasets( num = num_tmp + edge_w_dp * pad_val_dp den = den_tmp + edge_w_dp - np.divide(num, den, out=out_tmp, where=(den > 0.0)) - out_tmp[den <= 0.0] = pad_val_dp - merged[ro, co] = out_tmp + out = np.empty_like(num) + np.divide(num, den, out=out, where=den != 0.0) + out[den == 0.0] = 0.0 + merged[ro, co] = out + + self.im_bf_merged = np.mean(merged, axis=(2, 3), dtype=np.float64) + self.dp_mean_merged = np.mean(merged, axis=(0, 1), dtype=np.float64) + + if np.issubdtype(dtype_out, np.integer): + info = np.iinfo(dtype_out) + dmin = float(info.min) + dmax = float(info.max) + + merged_f = merged # float64 + + if scale_output: + peak = float(np.max(merged_f)) + if peak <= 0.0: + scale = 1.0 + merged_scaled = merged_f + else: + scale = dmax / peak + merged_scaled = merged_f * scale + + if np.issubdtype(dtype_out, np.unsignedinteger): + if float(np.min(merged_scaled)) < 0.0: + warnings.warn( + f"scale_output=True with unsigned dtype {dtype_out}: " + "negative values present; they will be clipped to 0.", + RuntimeWarning, + ) + lo, hi = 0.0, dmax + else: + lo, hi = dmin, dmax + + if float(np.min(merged_scaled)) < lo or float(np.max(merged_scaled)) > hi: + warnings.warn( + f"Output overflow for dtype {dtype_out} after scaling: " + f"data range [{float(np.min(merged_scaled))}, {float(np.max(merged_scaled))}] exceeds " + f"[{lo}, {hi}]. Values will be clipped.", + RuntimeWarning, + ) + + merged_out = np.rint(np.clip(merged_scaled, lo, hi)).astype(dtype_out) + + else: + below = float(np.min(merged_f)) + above = float(np.max(merged_f)) + if below < dmin or above > dmax: + warnings.warn( + f"Output overflow for dtype {dtype_out}: data range [{below}, {above}] exceeds " + f"[{dmin}, {dmax}]. Values will be clipped.", + RuntimeWarning, + ) + merged_out = np.rint(np.clip(merged_f, dmin, dmax)).astype(dtype_out) + else: + merged_out = merged.astype(dtype_out, copy=False) - dataset_merged = Dataset4dstem.from_array(merged) - self.im_bf_merged = np.mean(merged, axis=(2, 3)) - self.dp_mean_merged = np.mean(merged, axis=(0, 1)) + dataset_merged = Dataset4dstem.from_array(array=merged_out) dataset_merged.im_bf_merged = self.im_bf_merged dataset_merged.dp_mean_merged = self.dp_mean_merged - dataset_merged.metadata["im_bf_merged"] = self.im_bf_merged - dataset_merged.metadata["dp_mean_merged"] = self.dp_mean_merged - dataset_merged.metadata["real_space_shifts_rc"] = rs_shifts.copy() - dataset_merged.metadata["diffraction_shifts_rc"] = dp_shifts.copy() if plot_result: show_2d( From 3196aca903ca46bc427706394cd9afc8a66d3a5e Mon Sep 17 00:00:00 2001 From: cophus Date: Sun, 1 Feb 2026 17:40:36 -0800 Subject: [PATCH 19/88] adding docstrings --- src/quantem/diffraction/maped.py | 452 +++++++++++++++++++------------ 1 file changed, 278 insertions(+), 174 deletions(-) diff --git a/src/quantem/diffraction/maped.py b/src/quantem/diffraction/maped.py index 9df542f3..3b5154c0 100644 --- a/src/quantem/diffraction/maped.py +++ b/src/quantem/diffraction/maped.py @@ -1,28 +1,55 @@ from __future__ import annotations +import warnings from typing import Any, Sequence import numpy as np +from scipy.ndimage import gaussian_filter, shift as ndi_shift +from scipy.signal import convolve2d from scipy.signal.windows import tukey +from tqdm import tqdm from quantem.core.datastructures.dataset4dstem import Dataset4dstem from quantem.core.io.serialize import AutoSerialize -from quantem.core.visualization import show_2d from quantem.core.utils.imaging_utils import weighted_cross_correlation_shift +from quantem.core.visualization import show_2d class MAPED(AutoSerialize): + """ + Merge-Averaged Precession Electron Diffraction (MAPED) helper. + + This class manages a set of 4D-STEM datasets and provides utilities to: + - compute mean BF and mean DP summaries, + - choose/find diffraction origins, + - align diffraction space and real space, + - merge datasets into a single composite Dataset4dstem. + """ + _token = object() def __init__(self, datasets: list[Dataset4dstem], _token: object | None = None): if _token is not self._token: raise RuntimeError("Use MAPED.from_datasets() to instantiate this class.") - AutoSerialize.__init__(self) + super().__init__() self.datasets = datasets self.metadata: dict[str, Any] = {} @classmethod - def from_datasets(cls, datasets: Sequence[Dataset4dstem]) -> "MAPED": + def from_datasets(cls, datasets: Sequence[Dataset4dstem]) -> MAPED: + """ + Construct a MAPED instance from a non-empty sequence of Dataset4dstem. + + Parameters + ---------- + datasets + Sequence of Dataset4dstem instances. + + Returns + ------- + MAPED + New MAPED instance. + """ if not isinstance(datasets, Sequence) or isinstance(datasets, (str, bytes)): raise TypeError("MAPED.from_datasets expects a sequence of Dataset4dstem instances.") ds_list: list[Dataset4dstem] = [] @@ -30,7 +57,7 @@ def from_datasets(cls, datasets: Sequence[Dataset4dstem]) -> "MAPED": if not isinstance(d, Dataset4dstem): raise TypeError("MAPED.from_datasets expects a sequence of Dataset4dstem instances.") ds_list.append(d) - if len(ds_list) == 0: + if not ds_list: raise ValueError("MAPED.from_datasets expects a non-empty sequence of Dataset4dstem instances.") return cls(datasets=ds_list, _token=cls._token) @@ -39,7 +66,19 @@ def preprocess( plot_summary: bool = True, scale: float | Sequence[float] | None = None, **plot_kwargs: Any, - ) -> "MAPED": + ) -> MAPED: + """ + Compute dataset summary images. + + Stores + ------ + self.scales : np.ndarray + Per-dataset scaling factors (n,). + self.dp_mean : list[np.ndarray] + Mean diffraction patterns (H, W), one per dataset. + self.im_bf : list[np.ndarray] + Mean bright-field images (R, C), one per dataset. + """ n = len(self.datasets) if scale is None: self.scales = np.ones(n, dtype=float) @@ -52,8 +91,8 @@ def preprocess( if np.any(self.scales == 0): raise ValueError("scale entries must be nonzero.") - self.dp_mean = [] - self.im_bf = [] + self.dp_mean: list[np.ndarray] = [] + self.im_bf: list[np.ndarray] = [] for d in self.datasets: if hasattr(d, "get_dp_mean"): @@ -67,11 +106,13 @@ def preprocess( dp = getattr(d, "dp_mean", None) if dp is None: - dp_arr = np.mean(np.asarray(d.array), axis=(0, 1)) + arr = np.asarray(d.array) + dp_arr = np.mean(arr, axis=(0, 1)) else: dp_arr = np.asarray(dp.array if hasattr(dp, "array") else dp) - im_bf_arr = np.mean(np.asarray(d.array), axis=(2, 3)) + arr = np.asarray(d.array) + im_bf_arr = np.mean(arr, axis=(2, 3)) self.dp_mean.append(np.asarray(dp_arr)) self.im_bf.append(np.asarray(im_bf_arr)) @@ -79,22 +120,18 @@ def preprocess( if plot_summary: tiles = [[(self.im_bf[i] / self.scales[i]), self.dp_mean[i]] for i in range(n)] titles = [[f"{i} - Mean Bright Field", f"{i} - Mean Diffraction Pattern"] for i in range(n)] - show_2d( - tiles, - titles=titles, - **plot_kwargs, - ) + show_2d(tiles, title=titles, **plot_kwargs) return self - - def diffraction_find_origin( + def diffraction_origin( self, origins=None, sigma=None, plot_origins: bool = True, plot_indices=None, - ): + **plot_kwargs: Any, + ) -> MAPED: """ Choose or automatically find the origin in diffraction space. @@ -104,26 +141,20 @@ def diffraction_find_origin( Optional manual origins. Can be: - a single (row, col) tuple, applied to all datasets - a list of (row, col) tuples of length n (one per dataset) - - a list of (row, col) tuples shorter than n, used for plot/inspection only (will error if not broadcastable) sigma Optional low-pass smoothing sigma (pixels) applied to each mean DP prior to peak finding. plot_origins If True, plot mean diffraction patterns with overlaid origin markers. plot_indices Optional indices to plot. If None, plots all datasets. + **plot_kwargs + Passed to show_2d. Stores ------ self.diffraction_origins : np.ndarray Array of shape (n, 2) with integer (row, col) origins. """ - import numpy as _np - - try: - from scipy.ndimage import gaussian_filter as _gaussian_filter - except Exception: # pragma: no cover - _gaussian_filter = None - n = len(self.datasets) if not hasattr(self, "dp_mean"): raise RuntimeError("Run preprocess() first so self.dp_mean exists.") @@ -137,119 +168,133 @@ def diffraction_find_origin( raise IndexError("plot_indices contains an out-of-range index.") if origins is None: - origins_arr = _np.zeros((n, 2), dtype=int) + origins_arr = np.zeros((n, 2), dtype=int) for i in range(n): - dp = _np.asarray(self.dp_mean[i]) + dp = np.asarray(self.dp_mean[i]) if sigma is not None and float(sigma) > 0: - if _gaussian_filter is None: - raise ImportError("scipy is required for sigma smoothing (gaussian_filter).") - dp_use = _gaussian_filter(dp.astype(float, copy=False), float(sigma)) + dp_use = gaussian_filter(dp.astype(float, copy=False), float(sigma), mode="nearest") else: dp_use = dp - ind = int(_np.argmax(dp_use)) - r, c = _np.unravel_index(ind, dp_use.shape) + r, c = np.unravel_index(int(np.argmax(dp_use)), dp_use.shape) origins_arr[i, 0] = int(r) origins_arr[i, 1] = int(c) else: if isinstance(origins, tuple) and len(origins) == 2: - origins_arr = _np.tile(_np.asarray(origins, dtype=int)[None, :], (n, 1)) + origins_arr = np.tile(np.asarray(origins, dtype=int)[None, :], (n, 1)) else: origins_list = list(origins) if len(origins_list) != n: raise ValueError("origins must be a single (row,col) tuple or a list of length n.") - origins_arr = _np.asarray(origins_list, dtype=int) + origins_arr = np.asarray(origins_list, dtype=int) if origins_arr.shape != (n, 2): raise ValueError("origins must have shape (n, 2) after conversion.") self.diffraction_origins = origins_arr if plot_origins: - dp_tiles = [[_np.asarray(self.dp_mean[i]) for i in plot_indices_list]] - titles = [[f"{i} - Mean Diffraction Pattern" for i in plot_indices_list]] - fig, axs = show_2d(dp_tiles, titles=titles, returnfig=True, **{}) - if not isinstance(axs, (list, _np.ndarray)): - axs = [axs] - axs_flat = _np.ravel(axs) + arrays = [np.asarray(self.dp_mean[i]) for i in plot_indices_list] + titles = [f"{i} - Mean Diffraction Pattern" for i in plot_indices_list] + fig, ax = show_2d(arrays, title=titles, returnfig=True, **plot_kwargs) + axs = np.ravel(np.asarray(ax, dtype=object)) for j, i in enumerate(plot_indices_list): - ax = axs_flat[j] r, c = self.diffraction_origins[i] - ax.plot([c], [r], marker="+", color="red", markersize=16, markeredgewidth=2) - return fig, axs + axs[j].plot([c], [r], marker="+", color="red", markersize=16, markeredgewidth=2) return self - def diffraction_align( self, - edge_blend = 16.0, - padding = None, - pad_val = 'min', - upsample_factor = 100, - weight_scale = 1/8, - plot_aligned = True, - linewidth = 2, - **kwargs, - ): + edge_blend: float = 16.0, + padding=None, + pad_val: str | float = "min", + upsample_factor: int = 100, + weight_scale: float = 1 / 8, + plot_aligned: bool = True, + **plot_kwargs: Any, + ) -> MAPED: """ - Refine the diffraction space origins, set padding, align images + Align mean diffraction patterns using weighted cross-correlation in Fourier space. + Parameters + ---------- + edge_blend + Tukey window edge taper (pixels). + padding + Passed to shift_images for plotting. + pad_val + Passed to shift_images for plotting. + upsample_factor + Subpixel upsampling factor for correlation peak estimation. + weight_scale + Radial weight falloff scale (fraction of mean DP size). + plot_aligned + If True, plot aligned mean diffraction patterns. + **plot_kwargs + Passed to show_2d when plotting. + + Stores + ------ + self.diffraction_shifts : np.ndarray + Array of shape (n, 2) with (row, col) shifts to align diffraction patterns. """ + if not hasattr(self, "dp_mean"): + raise RuntimeError("Run preprocess() first so self.dp_mean exists.") + if not hasattr(self, "diffraction_origins"): + raise RuntimeError("Run diffraction_origin() first so self.diffraction_origins exists.") + + H, W = np.asarray(self.dp_mean[0]).shape + + w = tukey(H, alpha=2.0 * float(edge_blend) / float(H))[:, None] * tukey( + W, alpha=2.0 * float(edge_blend) / float(W) + )[None, :] - # window function - from scipy.signal.windows import tukey - w = tukey(self.dp_mean[0].shape[0], alpha=2.0*edge_blend/self.dp_mean[0].shape[0])[:,None] * \ - tukey(self.dp_mean[0].shape[1], alpha=2.0*edge_blend/self.dp_mean[0].shape[1])[None,:] + r = np.fft.fftfreq(H, 1.0 / float(H))[:, None] + c = np.fft.fftfreq(W, 1.0 / float(W))[None, :] - # coordinates - r = np.fft.fftfreq(self.dp_mean[0].shape[0],1/self.dp_mean[0].shape[0])[:,None] - c = np.fft.fftfreq(self.dp_mean[0].shape[1],1/self.dp_mean[0].shape[1])[None,:] + n = len(self.dp_mean) + self.diffraction_shifts = np.zeros((n, 2), dtype=float) - # init - self.diffraction_shifts = np.zeros((len(self.dp_mean),2)) + G_ref = np.fft.fft2(w * np.asarray(self.dp_mean[0])) + xy0 = np.asarray(self.diffraction_origins[0], dtype=float) - # correlation alignment - G_ref = np.fft.fft2(w * self.dp_mean[0]) - xy0 = self.diffraction_origins[0] - for ind in range(1,len(self.dp_mean)): - G = np.fft.fft2(w * self.dp_mean[ind]) - xy = self.diffraction_origins[ind] + for ind in range(1, n): + G = np.fft.fft2(w * np.asarray(self.dp_mean[ind])) + xy = np.asarray(self.diffraction_origins[ind], dtype=float) - dr2 = (r - xy0[0] + xy[0])**2 \ - + (c - xy0[1] + xy[1])**2 - im_weight = np.clip(1 - np.sqrt(dr2)/np.mean(self.dp_mean[0].shape)/weight_scale, 0.0, 1.0) - im_weight = np.sin(im_weight*np.pi/2)**2 + dr2 = (r - xy0[0] + xy[0]) ** 2 + (c - xy0[1] + xy[1]) ** 2 + im_weight = np.clip( + 1.0 - np.sqrt(dr2) / float(np.mean((H, W))) / float(weight_scale), + 0.0, + 1.0, + ) + im_weight = np.sin(im_weight * np.pi / 2.0) ** 2 - shift, G_shift = weighted_cross_correlation_shift( + shift_rc, G_shift = weighted_cross_correlation_shift( im_ref=G_ref, im=G, - weight_real=im_weight*0+1.0, - upsample_factor = upsample_factor, - fft_input = True, - fft_output = True, - return_shifted_image = True, + weight_real=im_weight * 0.0 + 1.0, + upsample_factor=int(upsample_factor), + fft_input=True, + fft_output=True, + return_shifted_image=True, ) - self.diffraction_shifts[ind,:] = shift - - # update reference - G_ref = G_ref*(ind/(ind+1)) + G_shift/(ind+1) + self.diffraction_shifts[ind, :] = np.asarray(shift_rc, dtype=float) - # Center shifts - self.diffraction_shifts -= np.mean(self.diffraction_shifts,axis=0)[None,:] + G_ref = G_ref * (ind / (ind + 1)) + G_shift / (ind + 1) - # Generate output image + self.diffraction_shifts -= np.mean(self.diffraction_shifts, axis=0)[None, :] if plot_aligned: im_aligned = shift_images( - images = self.dp_mean, - shifts_rc = self.diffraction_shifts, - edge_blend = edge_blend, - padding = padding, - pad_val = pad_val, - ) - show_2d( - im_aligned, - **kwargs, + images=self.dp_mean, + shifts_rc=self.diffraction_shifts, + edge_blend=float(edge_blend), + padding=padding, + pad_val=pad_val, ) + show_2d(im_aligned, **plot_kwargs) + + return self def real_space_align( @@ -266,16 +311,45 @@ def real_space_align( edge_sigma: float = 2.0, hanning_filter: bool = False, plot_aligned: bool = True, - **kwargs, - ): - import numpy as np - from scipy.ndimage import gaussian_filter, shift as ndi_shift - from scipy.signal import convolve2d - from scipy.signal.windows import tukey + **plot_kwargs: Any, + ) -> MAPED: + """ + Align real-space mean BF images using iterative average-reference correlation. - from quantem.core.utils.imaging_utils import weighted_cross_correlation_shift - from quantem.core.visualization import show_2d + Parameters + ---------- + num_images + If provided, align only the first num_images images. + num_iter + Number of refinement iterations. + edge_blend + Used to set default correlation padding when max_shift is None. + padding + Passed to shift_images for plotting. + pad_val + Passed to shift_images for plotting. + upsample_factor + Subpixel upsampling factor for correlation peak estimation. + max_shift + Optional maximum shift constraint passed to weighted_cross_correlation_shift. + shift_method + Passed to shift_images for plotting ('bilinear' or 'fourier'). + edge_filter + If True, correlate on gradient magnitude instead of raw intensity. + edge_sigma + Gaussian sigma applied to gradients when edge_filter is True. + hanning_filter + If True, apply a Hanning window prior to FFT. + plot_aligned + If True, plot aligned mean BF images. + **plot_kwargs + Passed to show_2d when plotting. + Stores + ------ + self.real_space_shifts : np.ndarray + Array of shape (n_total, 2) with (row, col) shifts for aligned datasets. + """ if not hasattr(self, "im_bf"): raise RuntimeError("Run preprocess() first so self.im_bf exists.") if len(self.im_bf) == 0: @@ -317,16 +391,13 @@ def real_space_align( if w_h_sum <= 0: raise RuntimeError("hanning window sum is zero") - wx = None if edge_filter: wx = np.array( - [ - [-1.0, -2.0, -1.0], - [ 0.0, 0.0, 0.0], - [ 1.0, 2.0, 1.0], - ], + [[-1.0, -2.0, -1.0], [0.0, 0.0, 0.0], [1.0, 2.0, 1.0]], dtype=float, ) + else: + wx = None base_pad = np.zeros((n, Hp, Wp), dtype=float) for i in range(n): @@ -351,7 +422,7 @@ def real_space_align( for i in range(n): im_a = ndi_shift( base_pad[i], - shift=(float(shifts[i, 0]), float(shifts[i, 1])), + shift=(shifts[i, 0], shifts[i, 1]), order=1, mode="constant", cval=0.0, @@ -391,13 +462,12 @@ def real_space_align( edge_blend=float(edge_blend), padding=padding, pad_val=pad_val, - shift_method=str(shift_method), + shift_method=shift_method, ) - show_2d(im_aligned, **kwargs) + show_2d(im_aligned, **plot_kwargs) return self - def merge_datasets( self, real_space_padding=0, @@ -409,15 +479,46 @@ def merge_datasets( dtype=None, scale_output: bool = False, plot_result: bool = True, - **kwargs, - ): - import warnings + **plot_kwargs: Any, + ) -> Dataset4dstem: + """ + Merge aligned datasets into a single Dataset4dstem. - import numpy as np - from scipy.ndimage import shift as ndi_shift - from scipy.signal.windows import tukey - from tqdm import tqdm + Requires + -------- + self.real_space_shifts + From real_space_align(). + self.diffraction_shifts + From diffraction_align(). + Parameters + ---------- + real_space_padding + Output scan padding in pixels (adds border to scan grid). + real_space_edge_blend + Tukey taper width for scan-space interpolation weights. + diffraction_padding + Output diffraction padding in pixels (adds border around DPs). + diffraction_edge_blend + Tukey taper width for diffraction-space weights. + diffraction_pad_val + Pad value for diffraction padding ('min','max','mean','median' or float). + shift_method + Diffraction shift method: 'bilinear' or 'fourier'. + dtype + Output dtype. If None, uses parent dtype. + scale_output + If True and dtype is integer, scale to full dynamic range using global max. + plot_result + If True, plot merged BF and merged mean DP. + **plot_kwargs + Passed to show_2d. + + Returns + ------- + Dataset4dstem + Merged dataset. + """ if not hasattr(self, "real_space_shifts"): raise RuntimeError("Run real_space_align() first so self.real_space_shifts exists.") if not hasattr(self, "diffraction_shifts"): @@ -502,10 +603,10 @@ def merge_datasets( if method == "fourier": kr = np.fft.fftfreq(Hp)[:, None] kc = np.fft.fftfreq(Wp)[None, :] - ramps = [] Fw = np.fft.fft2(wdp_pad) + ramps: list[np.ndarray] = [] for i in range(n): - dr, dc = float(dp_shifts[i, 0]), float(dp_shifts[i, 1]) + dr, dc = dp_shifts[i, 0], dp_shifts[i, 1] ramp = np.exp(-2j * np.pi * (kr * dr + kc * dc)) ramps.append(ramp) w_i = np.fft.ifft2(Fw * ramp).real @@ -514,17 +615,17 @@ def merge_datasets( for i in range(n): w_i = ndi_shift( wdp_pad, - shift=(float(dp_shifts[i, 0]), float(dp_shifts[i, 1])), + shift=(dp_shifts[i, 0], dp_shifts[i, 1]), order=1, mode="constant", cval=0.0, prefilter=False, ) wdp_shifted[i] = np.clip(w_i, 0.0, 1.0) - ramps = None + ramps = [] - edge_w_dp = 1.0 - np.max(wdp_shifted, axis=0) - edge_w_dp = np.clip(edge_w_dp, 0.0, 1.0) + coverage = np.clip(np.sum(wdp_shifted, axis=0), 0.0, 1.0) + edge_w_dp = 1.0 - coverage merged = np.zeros((Rout, Cout, Hp, Wp), dtype=np.float64) @@ -535,25 +636,25 @@ def merge_datasets( den_tmp = np.zeros((Hp, Wp), dtype=np.float64) for ro in tqdm(range(Rout), desc="Merging (rows)"): - r_base = float(ro - real_space_padding) + r_base = ro - real_space_padding for co in range(Cout): - c_base = float(co - real_space_padding) + c_base = co - real_space_padding num_tmp.fill(0.0) den_tmp.fill(0.0) max_wi = 0.0 for i in range(n): - r_in = r_base - float(rs_shifts[i, 0]) - c_in = c_base - float(rs_shifts[i, 1]) + r_in = r_base - rs_shifts[i, 0] + c_in = c_base - rs_shifts[i, 1] r0 = int(np.floor(r_in)) c0 = int(np.floor(c_in)) if r0 < 0 or r0 >= Rs - 1 or c0 < 0 or c0 >= Cs - 1: continue - dr = float(r_in - r0) - dc = float(c_in - c0) + dr = r_in - r0 + dc = c_in - c0 w00 = (1.0 - dr) * (1.0 - dc) w10 = dr * (1.0 - dc) @@ -583,11 +684,12 @@ def merge_datasets( dp_pad[rp0 : rp0 + H, cp0 : cp0 + W] = dp_local * w_dp if method == "fourier": - dp_shifted_tmp[:] = np.fft.ifft2(np.fft.fft2(dp_pad) * ramps[i]).real + ramp = ramps[i] + dp_shifted_tmp[:] = np.fft.ifft2(np.fft.fft2(dp_pad) * ramp).real else: dp_shifted_tmp[:] = ndi_shift( dp_pad, - shift=(float(dp_shifts[i, 0]), float(dp_shifts[i, 1])), + shift=(dp_shifts[i, 0], dp_shifts[i, 1]), order=1, mode="constant", cval=0.0, @@ -617,38 +719,21 @@ def merge_datasets( dmin = float(info.min) dmax = float(info.max) - merged_f = merged # float64 + merged_f = merged if scale_output: peak = float(np.max(merged_f)) if peak <= 0.0: - scale = 1.0 merged_scaled = merged_f else: - scale = dmax / peak - merged_scaled = merged_f * scale + merged_scaled = merged_f * (dmax / peak) if np.issubdtype(dtype_out, np.unsignedinteger): - if float(np.min(merged_scaled)) < 0.0: - warnings.warn( - f"scale_output=True with unsigned dtype {dtype_out}: " - "negative values present; they will be clipped to 0.", - RuntimeWarning, - ) lo, hi = 0.0, dmax else: lo, hi = dmin, dmax - if float(np.min(merged_scaled)) < lo or float(np.max(merged_scaled)) > hi: - warnings.warn( - f"Output overflow for dtype {dtype_out} after scaling: " - f"data range [{float(np.min(merged_scaled))}, {float(np.max(merged_scaled))}] exceeds " - f"[{lo}, {hi}]. Values will be clipped.", - RuntimeWarning, - ) - merged_out = np.rint(np.clip(merged_scaled, lo, hi)).astype(dtype_out) - else: below = float(np.min(merged_f)) above = float(np.max(merged_f)) @@ -662,17 +747,15 @@ def merge_datasets( else: merged_out = merged.astype(dtype_out, copy=False) - dataset_merged = Dataset4dstem.from_array(array=merged_out) - dataset_merged.im_bf_merged = self.im_bf_merged dataset_merged.dp_mean_merged = self.dp_mean_merged if plot_result: show_2d( [[self.im_bf_merged, self.dp_mean_merged]], - titles=[["Merged Bright Field", "Merged Mean Diffraction Pattern"]], - **kwargs, + title=[["Merged Bright Field", "Merged Mean Diffraction Pattern"]], + **plot_kwargs, ) return dataset_merged @@ -683,13 +766,32 @@ def shift_images( shifts_rc, edge_blend: float = 8.0, padding=None, - pad_val=0.0, + pad_val: str | float = 0.0, shift_method: str = "bilinear", ): - import numpy as np - from scipy.ndimage import shift as ndi_shift - from scipy.signal.windows import tukey - + """ + Shift and blend a stack of 2D images into a common padded canvas. + + Parameters + ---------- + images + Sequence of (H, W) arrays. + shifts_rc + Array-like of shape (n, 2) with (row, col) shifts for each image. + edge_blend + Tukey taper width in pixels for image blending. + padding + Output padding. If None, set from max shift and edge_blend. + pad_val + Fill value outside support ('min','max','mean','median' or float). + shift_method + 'bilinear' or 'fourier'. + + Returns + ------- + np.ndarray + Blended image of shape (H + 2*padding, W + 2*padding). + """ images = [np.asarray(im, dtype=float) for im in images] if len(images) == 0: raise ValueError("images must be non-empty") @@ -707,17 +809,17 @@ def shift_images( s = pad_val.strip().lower() v = np.stack(images, axis=0).reshape(-1) if s == "min": - pad_val = float(np.min(v)) + pad_val_f = float(np.min(v)) elif s == "max": - pad_val = float(np.max(v)) + pad_val_f = float(np.max(v)) elif s == "mean": - pad_val = float(np.mean(v)) + pad_val_f = float(np.mean(v)) elif s == "median": - pad_val = float(np.median(v)) + pad_val_f = float(np.median(v)) else: raise ValueError("pad_val must be a float or one of {'min','max','mean','median'}") else: - pad_val = float(pad_val) + pad_val_f = float(pad_val) if padding is None: max_shift = float(np.max(np.abs(shifts_rc))) if shifts_rc.size else 0.0 @@ -749,7 +851,7 @@ def shift_images( kr = np.fft.fftfreq(Hp)[:, None] kc = np.fft.fftfreq(Wp)[None, :] for ind in range(len(images)): - dr, dc = float(shifts_rc[ind, 0]), float(shifts_rc[ind, 1]) + dr, dc = shifts_rc[ind, 0], shifts_rc[ind, 1] ramp = np.exp(-2j * np.pi * (kr * dr + kc * dc)) F = np.fft.fft2(stack[ind]) @@ -762,7 +864,7 @@ def shift_images( for ind in range(len(images)): stack[ind] = ndi_shift( stack[ind], - shift=(float(shifts_rc[ind, 0]), float(shifts_rc[ind, 1])), + shift=(shifts_rc[ind, 0], shifts_rc[ind, 1]), order=1, mode="constant", cval=0.0, @@ -770,7 +872,7 @@ def shift_images( ) stack_w[ind] = ndi_shift( stack_w[ind], - shift=(float(shifts_rc[ind, 0]), float(shifts_rc[ind, 1])), + shift=(shifts_rc[ind, 0], shifts_rc[ind, 1]), order=1, mode="constant", cval=0.0, @@ -778,11 +880,13 @@ def shift_images( ) stack_w[ind] = np.clip(stack_w[ind], 0.0, 1.0) - # edge_w = 1.0 - np.clip(np.max(stack_w, axis=0), 0.0, 1.0) - edge_w = len(images) - np.sum(stack_w, axis=0) + edge_w = np.clip(1.0 - np.sum(stack_w, axis=0), 0.0, 1.0) - num = np.sum(stack, axis=0) + edge_w * pad_val + num = np.sum(stack, axis=0) + edge_w * pad_val_f den = np.sum(stack_w, axis=0) + edge_w - out = num / den + + out = np.empty_like(num) + np.divide(num, den, out=out, where=den != 0.0) + out[den == 0.0] = 0.0 return out From bf4b2b87f00ac847df94903252bd3187b31e7691 Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 16 Feb 2026 11:42:05 -0800 Subject: [PATCH 20/88] initial commit for model based refinment --- src/quantem/core/models/__init__.py | 29 +++ src/quantem/core/models/background.py | 78 ++++++ src/quantem/core/models/base.py | 241 +++++++++++++++++++ src/quantem/core/models/diffraction.py | 294 +++++++++++++++++++++++ src/quantem/diffraction/__init__.py | 1 + src/quantem/diffraction/model_fitting.py | 272 +++++++++++++++++++++ 6 files changed, 915 insertions(+) create mode 100644 src/quantem/core/models/__init__.py create mode 100644 src/quantem/core/models/background.py create mode 100644 src/quantem/core/models/base.py create mode 100644 src/quantem/core/models/diffraction.py create mode 100644 src/quantem/diffraction/model_fitting.py diff --git a/src/quantem/core/models/__init__.py b/src/quantem/core/models/__init__.py new file mode 100644 index 00000000..82d81d8e --- /dev/null +++ b/src/quantem/core/models/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from quantem.core.models.base import Component as Component +from quantem.core.models.base import Model as Model +from quantem.core.models.base import ModelContext as ModelContext +from quantem.core.models.base import OriginND as OriginND +from quantem.core.models.base import Overlay as Overlay +from quantem.core.models.base import Parameter as Parameter +from quantem.core.models.base import PreparedModel as PreparedModel + +from quantem.core.models.background import DCBackground as DCBackground +from quantem.core.models.background import GaussianBackground as GaussianBackground + +from quantem.core.models.diffraction import DiskTemplate as DiskTemplate +from quantem.core.models.diffraction import SyntheticDiskLattice as SyntheticDiskLattice + +__all__ = [ + "Parameter", + "Component", + "Overlay", + "OriginND", + "ModelContext", + "PreparedModel", + "Model", + "DCBackground", + "GaussianBackground", + "DiskTemplate", + "SyntheticDiskLattice", +] diff --git a/src/quantem/core/models/background.py b/src/quantem/core/models/background.py new file mode 100644 index 00000000..0f41f6a5 --- /dev/null +++ b/src/quantem/core/models/background.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import torch + +from quantem.core.models.base import Component, ModelContext, Overlay, Parameter + +__all__ = ["DCBackground", "GaussianBackground"] + + +class DCBackground(Component): + def __init__( + self, + *, + intensity: float | tuple[float, float] | tuple[float, float, float | None], + name: str = "DCBackground", + ): + super().__init__(name=name) + self.p_intensity = Parameter(intensity, tags={"role": "background_dc", "name": name}) + + def prepare(self, ctx: ModelContext) -> Any: + pI = self.p_intensity + + @dataclass + class PreparedDCBackground: + name: str + + def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: + out += pI.value(x, device=ctx.device, dtype=ctx.dtype) + + def overlays(self) -> list[Overlay]: + return [] + + return PreparedDCBackground(name=self.name) + + +class GaussianBackground(Component): + def __init__( + self, + *, + sigma: float | tuple[float, float] | tuple[float, float, float | None], + intensity: float | tuple[float, float] | tuple[float, float, float | None], + origin_key: str = "origin", + name: str = "GaussianBackground", + ): + super().__init__(name=name) + self.origin_key = str(origin_key) + self.p_sigma = Parameter(sigma, tags={"role": "background_gaussian_sigma", "name": name}) + self.p_intensity = Parameter(intensity, tags={"role": "background_gaussian_intensity", "name": name}) + + def prepare(self, ctx: ModelContext) -> Any: + ok = self.origin_key + pS = self.p_sigma + pI = self.p_intensity + + rr = torch.arange(ctx.H, device=ctx.device, dtype=ctx.dtype)[:, None] + cc = torch.arange(ctx.W, device=ctx.device, dtype=ctx.dtype)[None, :] + + @dataclass + class PreparedGaussianBackground: + origin_key: str + + def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: + if self.origin_key not in ctx.origins: + return + r0, c0 = ctx.origins[self.origin_key] + sig = torch.clamp(pS.value(x, device=ctx.device, dtype=ctx.dtype), min=1e-6) + inten = pI.value(x, device=ctx.device, dtype=ctx.dtype) + dr = rr - r0 + dc = cc - c0 + out += inten * torch.exp(-0.5 * (dr * dr + dc * dc) / (sig * sig)) + + def overlays(self) -> list[Overlay]: + return [] + + return PreparedGaussianBackground(origin_key=ok) diff --git a/src/quantem/core/models/base.py b/src/quantem/core/models/base.py new file mode 100644 index 00000000..16c15840 --- /dev/null +++ b/src/quantem/core/models/base.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Mapping, Sequence + +import numpy as np +import torch + +__all__ = [ + "Overlay", + "Parameter", + "ModelContext", + "Component", + "OriginND", + "Model", + "PreparedModel", +] + + +@dataclass(frozen=True) +class Overlay: + kind: str + data: dict[str, Any] + + +class Parameter: + def __init__( + self, + value: float | Sequence[float], + *, + lower_bound: float | None = None, + upper_bound: float | None = None, + tags: Mapping[str, Any] | None = None, + ): + v0, lb, ub = self._parse(value, lower_bound, upper_bound) + self.initial_value = float(v0) + self.lower_bound = float(lb) + self.upper_bound = float(ub) + self.tags: dict[str, Any] = dict(tags) if tags is not None else {} + self._index: int | None = None + + @staticmethod + def _parse( + value: float | Sequence[float], + lower_bound: float | None, + upper_bound: float | None, + ) -> tuple[float, float, float]: + if isinstance(value, (list, tuple, np.ndarray)): + seq = list(value) + if len(seq) == 2: + v0 = float(seq[0]) + dev = float(seq[1]) + return v0, v0 - dev, v0 + dev + if len(seq) == 3: + v0 = float(seq[0]) + lb = -np.inf if seq[1] is None else float(seq[1]) + ub = np.inf if seq[2] is None else float(seq[2]) + return v0, lb, ub + raise ValueError("Parameter sequences must have length 2 or 3.") + v0 = float(value) + lb = -np.inf if lower_bound is None else float(lower_bound) + ub = np.inf if upper_bound is None else float(upper_bound) + return v0, lb, ub + + def bind(self, index: int) -> None: + self._index = int(index) + + def value(self, x: torch.Tensor, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + if self._index is None: + return torch.as_tensor(self.initial_value, device=device, dtype=dtype).reshape(()) + return x[int(self._index)].to(device=device, dtype=dtype).reshape(()) + + +class ModelContext: + def __init__( + self, + *, + H: int, + W: int, + device: torch.device, + dtype: torch.dtype, + mask: torch.Tensor | None = None, + fields: Mapping[str, Any] | None = None, + ): + self.H = int(H) + self.W = int(W) + self.device = device + self.dtype = dtype + self.mask = mask + self.fields: dict[str, Any] = dict(fields) if fields is not None else {} + self.origins: dict[str, tuple[torch.Tensor, ...]] = {} + + def __getattr__(self, name: str) -> Any: + if name in self.fields: + return self.fields[name] + raise AttributeError(name) + + +class Component: + def __init__(self, *, name: str): + self.name = str(name) + + def parameters(self) -> list[Parameter]: + out: list[Parameter] = [] + + def add(obj: Any) -> None: + if obj is None: + return + if isinstance(obj, Parameter): + out.append(obj) + return + if isinstance(obj, (list, tuple)): + for z in obj: + add(z) + + for v in self.__dict__.values(): + add(v) + return out + + def prepare(self, ctx: ModelContext) -> Any: + raise NotImplementedError + + +class OriginND(Component): + def __init__(self, *, key: str, coords: Sequence[Parameter]): + super().__init__(name=f"Origin[{key}]") + self.key = str(key) + self.coords = list(coords) + + @classmethod + def from_row_col( + cls, + *, + key: str, + origin_row: float | Sequence[float], + origin_col: float | Sequence[float], + ) -> "OriginND": + p_row = Parameter(origin_row, tags={"role": "origin_row", "origin_key": str(key)}) + p_col = Parameter(origin_col, tags={"role": "origin_col", "origin_key": str(key)}) + return cls(key=key, coords=[p_row, p_col]) + + def prepare(self, ctx: ModelContext) -> Any: + key = self.key + p0 = self.coords[0] + p1 = self.coords[1] + + r0 = torch.as_tensor(p0.initial_value, device=ctx.device, dtype=ctx.dtype).reshape(()) + c0 = torch.as_tensor(p1.initial_value, device=ctx.device, dtype=ctx.dtype).reshape(()) + ctx.origins[key] = (r0, c0) + + class PreparedOriginND: + origin_key = key + + def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: + r = p0.value(x, device=ctx.device, dtype=ctx.dtype) + c = p1.value(x, device=ctx.device, dtype=ctx.dtype) + ctx.origins[key] = (r, c) + + def overlays(self) -> list[Overlay]: + r = float(p0.initial_value) + c = float(p1.initial_value) + return [ + Overlay( + kind="points_rc", + data={"r": np.array([r]), "c": np.array([c]), "marker": "x", "s": 80.0, "color": "tab:blue"}, + ) + ] + + return PreparedOriginND() + + +class Model: + def __init__(self) -> None: + self._components: list[Component] = [] + + def add(self, items: Iterable[Component]) -> "Model": + for obj in items: + if not isinstance(obj, Component): + raise TypeError("Model.add expects Component objects.") + self._components.append(obj) + return self + + def compile(self, ctx: ModelContext) -> "PreparedModel": + seen: set[int] = set() + params: list[Parameter] = [] + for c in self._components: + for p in c.parameters(): + if id(p) in seen: + continue + seen.add(id(p)) + params.append(p) + + for i, p in enumerate(params): + p.bind(i) + + x0 = torch.as_tensor([p.initial_value for p in params], device=ctx.device, dtype=ctx.dtype) + + prepared_components: list[Any] = [] + for c in self._components: + prepared_components.append(c.prepare(ctx)) + + return PreparedModel( + components=prepared_components, + ctx=ctx, + params=params, + x0=x0, + ) + + +class PreparedModel: + def __init__( + self, + *, + components: list[Any], + ctx: ModelContext, + params: list[Parameter], + x0: torch.Tensor, + ): + self.components = components + self.ctx = ctx + self.params = params + self.x0 = x0 + + def render(self, x: torch.Tensor) -> torch.Tensor: + out = torch.zeros((self.ctx.H, self.ctx.W), device=self.ctx.device, dtype=self.ctx.dtype) + for c in self.components: + if hasattr(c, "render"): + c.render(out, x, self.ctx) + return out + + def render_initial(self) -> torch.Tensor: + return self.render(self.x0) + + def overlays(self) -> list[Overlay]: + out: list[Overlay] = [] + for c in self.components: + if hasattr(c, "overlays"): + ov = c.overlays() + if ov: + out.extend(list(ov)) + return out diff --git a/src/quantem/core/models/diffraction.py b/src/quantem/core/models/diffraction.py new file mode 100644 index 00000000..ebf820bd --- /dev/null +++ b/src/quantem/core/models/diffraction.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable + +import numpy as np +import torch + +from quantem.core.models.base import Component, ModelContext, Overlay, Parameter + +__all__ = ["DiskTemplate", "DiskAtOrigin", "SyntheticDiskLattice"] + + +def _as_tensor(x: Any, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + if torch.is_tensor(x): + return x.to(device=device, dtype=dtype) + return torch.as_tensor(x, device=device, dtype=dtype) + + +def _keep_center(r: float, c: float, H: int, W: int, boundary_px: float) -> bool: + b = float(boundary_px) + return (r >= b) and (r <= (H - 1) - b) and (c >= b) and (c <= (W - 1) - b) + + +def _place_patch_add(out: torch.Tensor, patch: torch.Tensor, center_r: float, center_c: float) -> None: + H, W = int(out.shape[-2]), int(out.shape[-1]) + ph, pw = int(patch.shape[-2]), int(patch.shape[-1]) + pr = ph // 2 + pc = pw // 2 + + r0 = int(np.rint(center_r)) - pr + c0 = int(np.rint(center_c)) - pc + r1 = r0 + ph + c1 = c0 + pw + + rr0 = max(0, r0) + cc0 = max(0, c0) + rr1 = min(H, r1) + cc1 = min(W, c1) + if rr1 <= rr0 or cc1 <= cc0: + return + + pr0 = rr0 - r0 + pc0 = cc0 - c0 + pr1 = pr0 + (rr1 - rr0) + pc1 = pc0 + (cc1 - cc0) + + out[rr0:rr1, cc0:cc1] += patch[pr0:pr1, pc0:pc1] + + +class DiskTemplate(Component): + def __init__( + self, + *, + name: str, + array: np.ndarray, + refine_all_pixels: bool = False, + place_at_origin: bool = False, + origin_key: str = "origin", + origin_intensity: float | tuple[float, float] | tuple[float, float, float | None] = 1.0, + ): + super().__init__(name=name) + arr = np.asarray(array, dtype=np.float32) + if arr.ndim != 2: + raise ValueError("DiskTemplate.array must be 2D.") + self.shape = (int(arr.shape[0]), int(arr.shape[1])) + self.refine_all_pixels = bool(refine_all_pixels) + self.place_at_origin = bool(place_at_origin) + self.origin_key = str(origin_key) + + self._array0 = arr + self.p_origin_intensity = Parameter(origin_intensity, tags={"role": "disk_origin_intensity", "name": name}) + + self.p_pixels: list[Parameter] = [] + if self.refine_all_pixels: + flat = arr.reshape(-1) + for i, v in enumerate(flat): + self.p_pixels.append(Parameter(float(v), tags={"role": "disk_pixel", "name": name, "pixel_index": int(i)})) + + @classmethod + def from_array( + cls, + *, + name: str, + array: np.ndarray, + refine_all_pixels: bool = False, + place_at_origin: bool = False, + origin_key: str = "origin", + origin_intensity: float | tuple[float, float] | tuple[float, float, float | None] = 1.0, + ) -> "DiskTemplate": + return cls( + name=name, + array=array, + refine_all_pixels=refine_all_pixels, + place_at_origin=place_at_origin, + origin_key=origin_key, + origin_intensity=origin_intensity, + ) + + def prepare(self, ctx: ModelContext) -> Any: + disk = self + ok = disk.origin_key + + if disk.refine_all_pixels: + idxs = [p._index for p in disk.p_pixels] + if any(i is None for i in idxs): + raise RuntimeError("DiskTemplate pixel parameters not bound.") + idx_t = torch.as_tensor([int(i) for i in idxs], device=ctx.device, dtype=torch.int64) + + def patch(x: torch.Tensor) -> torch.Tensor: + return x.index_select(0, idx_t).to(device=ctx.device, dtype=ctx.dtype).reshape(disk.shape[0], disk.shape[1]) + + else: + patch0 = _as_tensor(disk._array0, device=ctx.device, dtype=ctx.dtype) + + def patch(x: torch.Tensor) -> torch.Tensor: + return patch0 + + @dataclass + class PreparedDiskTemplate: + origin_key: str + place_at_origin: bool + + def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: + if not self.place_at_origin: + return + if self.origin_key not in ctx.origins: + return + r0, c0 = ctx.origins[self.origin_key] + r = float(r0.detach().cpu().item()) + c = float(c0.detach().cpu().item()) + inten = disk.p_origin_intensity.value(x, device=ctx.device, dtype=ctx.dtype) + _place_patch_add(out, patch(x) * inten, r, c) + + def overlays(self) -> list[Overlay]: + return [] + + return PreparedDiskTemplate(origin_key=ok, place_at_origin=disk.place_at_origin) + + +class SyntheticDiskLattice(Component): + def __init__( + self, + *, + name: str, + disk: DiskTemplate, + u_row: float | tuple[float, float] | tuple[float, float, float | None], + u_col: float | tuple[float, float] | tuple[float, float, float | None], + v_row: float | tuple[float, float] | tuple[float, float, float | None], + v_col: float | tuple[float, float] | tuple[float, float, float | None], + u_max: int = 0, + v_max: int = 0, + intensity_0: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + intensity_row: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + intensity_col: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + exclude_indices: Iterable[tuple[int, int]] | None = [], + boundary_px: float = 0.0, + origin_key: str = "origin", + ): + super().__init__(name=name) + self.disk = disk + self.origin_key = str(origin_key) + self.u_max = int(u_max) + self.v_max = int(v_max) + self.boundary_px = float(boundary_px) + + if exclude_indices is None: + self.exclude_indices = {(0, 0)} + else: + self.exclude_indices = {(int(a), int(b)) for (a, b) in exclude_indices} + + self.p_u_row = Parameter(u_row, tags={"role": "lattice_u_row", "name": name}) + self.p_u_col = Parameter(u_col, tags={"role": "lattice_u_col", "name": name}) + self.p_v_row = Parameter(v_row, tags={"role": "lattice_v_row", "name": name}) + self.p_v_col = Parameter(v_col, tags={"role": "lattice_v_col", "name": name}) + + self.p_intensity_0 = Parameter(intensity_0, tags={"role": "disk_intensity_0", "name": name}) + self.p_intensity_row = Parameter(intensity_row, tags={"role": "disk_intensity_row", "name": name}) + self.p_intensity_col = Parameter(intensity_col, tags={"role": "disk_intensity_col", "name": name}) + + def _uv_list(self) -> list[tuple[int, int]]: + uv: list[tuple[int, int]] = [] + for u in range(-self.u_max, self.u_max + 1): + for v in range(-self.v_max, self.v_max + 1): + if (u, v) in self.exclude_indices: + continue + uv.append((u, v)) + return uv + + def prepare(self, ctx: ModelContext) -> Any: + lat = self + ok = lat.origin_key + uv = lat._uv_list() + + if lat.disk.refine_all_pixels: + didxs = [p._index for p in lat.disk.p_pixels] + if any(i is None for i in didxs): + raise RuntimeError("DiskTemplate pixel parameters not bound.") + didx_t = torch.as_tensor([int(i) for i in didxs], device=ctx.device, dtype=torch.int64) + + def patch(x: torch.Tensor) -> torch.Tensor: + return x.index_select(0, didx_t).to(device=ctx.device, dtype=ctx.dtype).reshape(lat.disk.shape[0], lat.disk.shape[1]) + + else: + patch0 = _as_tensor(lat.disk._array0, device=ctx.device, dtype=ctx.dtype) + + def patch(x: torch.Tensor) -> torch.Tensor: + return patch0 + + r0_list: list[float] = [] + c0_list: list[float] = [] + if ok in ctx.origins: + or0, oc0 = ctx.origins[ok] + orow0 = float(or0.detach().cpu().item()) + ocol0 = float(oc0.detach().cpu().item()) + urow0 = float(lat.p_u_row.initial_value) + ucol0 = float(lat.p_u_col.initial_value) + vrow0 = float(lat.p_v_row.initial_value) + vcol0 = float(lat.p_v_col.initial_value) + for u, v in uv: + rr = orow0 + u * urow0 + v * vrow0 + cc = ocol0 + u * ucol0 + v * vcol0 + if _keep_center(rr, cc, ctx.H, ctx.W, lat.boundary_px): + r0_list.append(rr) + c0_list.append(cc) + + r0_np = np.asarray(r0_list, dtype=np.float32) + c0_np = np.asarray(c0_list, dtype=np.float32) + + @dataclass + class PreparedSyntheticDiskLattice: + origin_key: str + boundary_px: float + uv: list[tuple[int, int]] + r0: np.ndarray + c0: np.ndarray + + def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: + if self.origin_key not in ctx.origins: + return + if not self.uv: + return + + r_origin, c_origin = ctx.origins[self.origin_key] + r_origin = r_origin.reshape(()) + c_origin = c_origin.reshape(()) + + urow = lat.p_u_row.value(x, device=ctx.device, dtype=ctx.dtype) + ucol = lat.p_u_col.value(x, device=ctx.device, dtype=ctx.dtype) + vrow = lat.p_v_row.value(x, device=ctx.device, dtype=ctx.dtype) + vcol = lat.p_v_col.value(x, device=ctx.device, dtype=ctx.dtype) + + I0 = lat.p_intensity_0.value(x, device=ctx.device, dtype=ctx.dtype) + Ir = lat.p_intensity_row.value(x, device=ctx.device, dtype=ctx.dtype) + Ic = lat.p_intensity_col.value(x, device=ctx.device, dtype=ctx.dtype) + + uv_t = torch.as_tensor(self.uv, device=ctx.device, dtype=torch.int32) + uu = uv_t[:, 0].to(device=ctx.device, dtype=ctx.dtype) + vv = uv_t[:, 1].to(device=ctx.device, dtype=ctx.dtype) + + rr = r_origin + uu * urow + vv * vrow + cc = c_origin + uu * ucol + vv * vcol + + p = patch(x) + + H = int(ctx.H) + W = int(ctx.W) + for i in range(int(rr.numel())): + r = float(rr[i].detach().cpu().item()) + c = float(cc[i].detach().cpu().item()) + if not _keep_center(r, c, H, W, self.boundary_px): + continue + dr = rr[i] - r_origin + dc = cc[i] - c_origin + inten = torch.clamp(I0 + Ir * dr + Ic * dc, min=0.0) + _place_patch_add(out, p * inten, r, c) + + def overlays(self) -> list[Overlay]: + if self.r0.size == 0: + return [] + return [ + Overlay( + kind="points_rc", + data={"r": self.r0, "c": self.c0, "marker": "x", "s": 60.0, "color": "orange"}, + ) + ] + + return PreparedSyntheticDiskLattice( + origin_key=ok, + boundary_px=lat.boundary_px, + uv=uv, + r0=r0_np, + c0=c0_np, + ) diff --git a/src/quantem/diffraction/__init__.py b/src/quantem/diffraction/__init__.py index 2a79312b..a64a357e 100644 --- a/src/quantem/diffraction/__init__.py +++ b/src/quantem/diffraction/__init__.py @@ -1,3 +1,4 @@ from quantem.diffraction.polar import RDF as RDF from quantem.diffraction.strain_autocorrelation import StrainMapAutocorrelation as StrainMapAutocorrelation from quantem.diffraction.maped import MAPED as MAPED +from quantem.diffraction.model_fitting import ModelDiffraction as ModelDiffraction diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py new file mode 100644 index 00000000..84d9e5c8 --- /dev/null +++ b/src/quantem/diffraction/model_fitting.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from typing import Any + +import numpy as np +import torch +from scipy.ndimage import shift as ndi_shift +from scipy.signal.windows import tukey + +from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.datastructures.dataset3d import Dataset3d +from quantem.core.datastructures.dataset4d import Dataset4d +from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from quantem.core.io.serialize import AutoSerialize +from quantem.core.models.base import Model, ModelContext, OriginND, Overlay, PreparedModel +from quantem.core.utils.imaging_utils import cross_correlation_shift +from quantem.core.visualization import show_2d + +__all__ = ["ModelDiffraction"] + + +def _to_numpy(x: Any) -> np.ndarray: + if isinstance(x, np.ndarray): + return x + if torch.is_tensor(x): + return x.detach().cpu().numpy() + return np.asarray(x) + + +class ModelDiffraction(AutoSerialize): + _token = object() + + def __init__(self, dataset: Any, _token: object | None = None): + if _token is not self._token: + raise RuntimeError("Use ModelDiffraction.from_dataset() or .from_file().") + super().__init__() + self.dataset = dataset + self.metadata: dict[str, Any] = {} + self.index_shape: tuple[int, ...] | None = None + self.image_ref: np.ndarray | None = None + self.preprocess_shifts: np.ndarray | None = None + self.model: Model | None = None + self.prepared: PreparedModel | None = None + self.fit_mask: np.ndarray | None = None + + @classmethod + def from_dataset(self, dataset: Dataset2d | Dataset3d | Dataset4d | Dataset4dstem | Any) -> "ModelDiffraction": + if isinstance(dataset, (Dataset2d, Dataset3d, Dataset4d, Dataset4dstem)): + return ModelDiffraction(dataset=dataset, _token=ModelDiffraction._token) + raise TypeError("from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance.") + + def preprocess( + self, + *, + align: bool = False, + edge_blend: float = 8.0, + upsample_factor: int = 32, + max_shift: float | None = None, + shift_order: int = 1, + ) -> "ModelDiffraction": + arr = np.asarray(self.dataset.array) + if arr.ndim < 2: + raise ValueError("dataset.array must have at least 2 dimensions.") + H, W = int(arr.shape[-2]), int(arr.shape[-1]) + self.index_shape = tuple(arr.shape[:-2]) + + stack = arr.reshape((-1, H, W)).astype(np.float32, copy=False) + n = int(stack.shape[0]) + + if (not align) or n <= 1: + self.image_ref = np.mean(stack, axis=0) + self.preprocess_shifts = None + self.metadata["preprocess"] = { + "align": bool(align), + "edge_blend": float(edge_blend), + "upsample_factor": int(upsample_factor), + "max_shift": None if max_shift is None else float(max_shift), + "shift_order": int(shift_order), + } + return self + + alpha_r = 0.0 if edge_blend <= 0 else min(1.0, 2.0 * float(edge_blend) / float(H)) + alpha_c = 0.0 if edge_blend <= 0 else min(1.0, 2.0 * float(edge_blend) / float(W)) + w = tukey(H, alpha=alpha_r)[:, None] * tukey(W, alpha=alpha_c)[None, :] + w = w.astype(np.float32, copy=False) + + shifts = np.zeros((n, 2), dtype=np.float32) + F_ref = np.fft.fft2(w * stack[0]) + + for i in range(1, n): + F_i = np.fft.fft2(w * stack[i]) + drc, F_shift = cross_correlation_shift( + F_ref, + F_i, + upsample_factor=int(upsample_factor), + max_shift=max_shift, + fft_input=True, + fft_output=True, + return_shifted_image=True, + ) + shifts[i, 0] = float(drc[0]) + shifts[i, 1] = float(drc[1]) + F_ref = F_ref * (i / (i + 1)) + F_shift / (i + 1) + + shifts -= np.mean(shifts, axis=0, keepdims=True) + + aligned = np.empty_like(stack, dtype=np.float32) + for i in range(n): + aligned[i] = ndi_shift( + stack[i], + shift=(float(shifts[i, 0]), float(shifts[i, 1])), + order=int(shift_order), + mode="nearest", + prefilter=False, + ) + + self.image_ref = np.mean(aligned, axis=0) + self.preprocess_shifts = shifts.reshape(self.index_shape + (2,)) + + self.metadata["preprocess"] = { + "align": bool(align), + "edge_blend": float(edge_blend), + "upsample_factor": int(upsample_factor), + "max_shift": None if max_shift is None else float(max_shift), + "shift_order": int(shift_order), + } + return self + + def _ensure_image_ref(self) -> None: + if self.image_ref is not None: + return + arr = np.asarray(self.dataset.array) + if arr.ndim < 2: + raise ValueError("dataset.array must have at least 2 dimensions.") + H, W = int(arr.shape[-2]), int(arr.shape[-1]) + self.index_shape = tuple(arr.shape[:-2]) + stack = arr.reshape((-1, H, W)).astype(np.float32, copy=False) + self.image_ref = np.mean(stack, axis=0) + self.preprocess_shifts = None + + def define_model( + self, + *, + origin_row: float | tuple[float, float] | tuple[float, float, float | None], + origin_col: float | tuple[float, float] | tuple[float, float, float | None], + components: list[Any], + device: torch.device | str | None = None, + dtype: torch.dtype | None = None, + mask: np.ndarray | torch.Tensor | None = None, + origin_key: str = "origin", + ) -> "ModelDiffraction": + self._ensure_image_ref() + if self.image_ref is None: + raise RuntimeError("image_ref not available.") + H, W = int(self.image_ref.shape[-2]), int(self.image_ref.shape[-1]) + + dev = torch.device(device) if device is not None else torch.device("cpu") + dt = dtype if dtype is not None else torch.float32 + + mask_t = None + if mask is not None: + if torch.is_tensor(mask): + mask_t = mask.to(device=dev, dtype=torch.float32) + else: + m = np.asarray(mask) + if m.shape != (H, W): + raise ValueError("mask must have shape (H, W).") + mask_t = torch.as_tensor(m.astype(np.float32, copy=False), device=dev) + + ctx = ModelContext(H=H, W=W, device=dev, dtype=dt, mask=mask_t) + + m = Model() + origin = OriginND.from_row_col(key=str(origin_key), origin_row=origin_row, origin_col=origin_col) + comps = [origin] + list(components) + m.add(comps) + + self.model = m + self.prepared = m.compile(ctx) + self.metadata["define_model"] = {"origin_key": str(origin_key), "device": str(dev), "dtype": str(dt)} + return self + + def _apply_overlays(self, ax: Any, overlays: list[Overlay]) -> None: + for ov in overlays: + d = dict(ov.data) + if ov.kind in {"points", "points_rc"} and ("r" in d) and ("c" in d): + r = np.asarray(d["r"]).ravel() + c = np.asarray(d["c"]).ravel() + s = float(d.get("s", 60.0)) + marker = d.get("marker", "x") + color = d.get("color", "orange") + ax.scatter(c, r, s=s, marker=marker, c=color) + continue + + def plot_mean_model( + self, + *, + power: float = 0.25, + returnfig: bool = False, + show_overlays: bool = True, + axsize: tuple[int, int] = (6, 6), + ) -> tuple[Any, Any] | None: + self._ensure_image_ref() + if self.image_ref is None: + raise RuntimeError("image_ref not available.") + if self.prepared is None: + raise RuntimeError("No model defined. Call .define_model(...) first.") + + ref = np.asarray(self.image_ref, dtype=np.float32) + init = _to_numpy(self.prepared.render_initial()).astype(np.float32, copy=False) + + refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) + initp = init if power == 1.0 else np.maximum(init, 0.0) ** float(power) + + vmin = float(np.min([refp.min(), initp.min()])) + vmax = float(np.max([refp.max(), initp.max()])) + + fig, ax = show_2d( + [refp, initp], + title=["image_ref", "model"], + cmap="gray", + cbar=False, + returnfig=True, + axsize=axsize, + norm="linear_minmax", + vmin=vmin, + vmax=vmax, + ) + + H, W = int(ref.shape[-2]), int(ref.shape[-1]) + + boundaries: list[float] = [] + for comp in getattr(self.prepared, "components", []): + b = getattr(comp, "boundary_px", None) + if b is not None: + boundaries.append(float(b)) + + inset = 0 + pad = 0 + if boundaries: + pos = [b for b in boundaries if b > 0.0] + neg = [b for b in boundaries if b < 0.0] + if pos: + inset = int(np.ceil(max(pos))) + if neg: + pad = int(np.ceil(-min(neg))) + + if isinstance(ax, np.ndarray): + axes = list(ax.ravel()) + elif isinstance(ax, (list, tuple)): + axes = list(ax) + else: + axes = [ax] + + x0 = (-pad + inset) + x1 = (W - 1) + pad - inset + y0 = (-pad + inset) + y1 = (H - 1) + pad - inset + + for a in axes[:2]: + a.set_xlim(x0, x1) + a.set_ylim(y1, y0) + + if show_overlays: + overlays = self.prepared.overlays() + if len(axes) >= 1: + self._apply_overlays(axes[0], overlays) + if len(axes) >= 2: + self._apply_overlays(axes[1], overlays) + + if returnfig: + return fig, ax + return None From 9bb0a56331ba95d0572f65061446e7fac1c90fe4 Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 16 Feb 2026 16:56:41 -0800 Subject: [PATCH 21/88] fitting model working! --- src/quantem/core/models/__init__.py | 18 +- src/quantem/core/models/background.py | 65 +-- src/quantem/core/models/base.py | 218 ++++------ src/quantem/core/models/diffraction.py | 489 +++++++++++++++-------- src/quantem/diffraction/model_fitting.py | 478 +++++++++++++++++----- 5 files changed, 830 insertions(+), 438 deletions(-) diff --git a/src/quantem/core/models/__init__.py b/src/quantem/core/models/__init__.py index 82d81d8e..7330baff 100644 --- a/src/quantem/core/models/__init__.py +++ b/src/quantem/core/models/__init__.py @@ -3,27 +3,27 @@ from quantem.core.models.base import Component as Component from quantem.core.models.base import Model as Model from quantem.core.models.base import ModelContext as ModelContext -from quantem.core.models.base import OriginND as OriginND from quantem.core.models.base import Overlay as Overlay from quantem.core.models.base import Parameter as Parameter from quantem.core.models.base import PreparedModel as PreparedModel -from quantem.core.models.background import DCBackground as DCBackground -from quantem.core.models.background import GaussianBackground as GaussianBackground - from quantem.core.models.diffraction import DiskTemplate as DiskTemplate +from quantem.core.models.diffraction import Origin2D as Origin2D from quantem.core.models.diffraction import SyntheticDiskLattice as SyntheticDiskLattice +from quantem.core.models.background import DCBackground as DCBackground +from quantem.core.models.background import GaussianBackground as GaussianBackground + __all__ = [ "Parameter", - "Component", "Overlay", - "OriginND", "ModelContext", - "PreparedModel", + "Component", "Model", - "DCBackground", - "GaussianBackground", + "PreparedModel", + "Origin2D", "DiskTemplate", "SyntheticDiskLattice", + "DCBackground", + "GaussianBackground", ] diff --git a/src/quantem/core/models/background.py b/src/quantem/core/models/background.py index 0f41f6a5..d0a9adff 100644 --- a/src/quantem/core/models/background.py +++ b/src/quantem/core/models/background.py @@ -1,78 +1,79 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Iterable import torch from quantem.core.models.base import Component, ModelContext, Overlay, Parameter - -__all__ = ["DCBackground", "GaussianBackground"] +from quantem.core.models.diffraction import _origin_indices class DCBackground(Component): def __init__( self, *, - intensity: float | tuple[float, float] | tuple[float, float, float | None], - name: str = "DCBackground", + intensity: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + name: str = "dc_background", ): super().__init__(name=name) - self.p_intensity = Parameter(intensity, tags={"role": "background_dc", "name": name}) + self.p_intensity = Parameter(intensity, lower_bound=0.0, tags={"role": "dc_bg"}) + + def parameters(self) -> list[Parameter]: + return [self.p_intensity] def prepare(self, ctx: ModelContext) -> Any: - pI = self.p_intensity + idx = self.p_intensity.index @dataclass - class PreparedDCBackground: - name: str - + class Prepared: def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - out += pI.value(x, device=ctx.device, dtype=ctx.dtype) + out.add_(x[idx]) - def overlays(self) -> list[Overlay]: + def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: return [] - return PreparedDCBackground(name=self.name) + return Prepared() class GaussianBackground(Component): def __init__( self, *, - sigma: float | tuple[float, float] | tuple[float, float, float | None], - intensity: float | tuple[float, float] | tuple[float, float, float | None], + sigma: float | tuple[float, float] | tuple[float, float, float | None] = (40.0, 5.0, None), + intensity: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, origin_key: str = "origin", - name: str = "GaussianBackground", + name: str = "gaussian_background", ): super().__init__(name=name) self.origin_key = str(origin_key) - self.p_sigma = Parameter(sigma, tags={"role": "background_gaussian_sigma", "name": name}) - self.p_intensity = Parameter(intensity, tags={"role": "background_gaussian_intensity", "name": name}) + self.p_sigma = Parameter(sigma, lower_bound=1e-6, upper_bound=None, tags={"role": "gauss_sigma"}) + self.p_intensity = Parameter(intensity, lower_bound=0.0, tags={"role": "gauss_int"}) + + def parameters(self) -> list[Parameter]: + return [self.p_sigma, self.p_intensity] def prepare(self, ctx: ModelContext) -> Any: - ok = self.origin_key - pS = self.p_sigma - pI = self.p_intensity + i_sig = self.p_sigma.index + i_int = self.p_intensity.index + r_idx, c_idx = _origin_indices(ctx, self.origin_key) rr = torch.arange(ctx.H, device=ctx.device, dtype=ctx.dtype)[:, None] cc = torch.arange(ctx.W, device=ctx.device, dtype=ctx.dtype)[None, :] @dataclass - class PreparedGaussianBackground: - origin_key: str - + class Prepared: def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - if self.origin_key not in ctx.origins: - return - r0, c0 = ctx.origins[self.origin_key] - sig = torch.clamp(pS.value(x, device=ctx.device, dtype=ctx.dtype), min=1e-6) - inten = pI.value(x, device=ctx.device, dtype=ctx.dtype) + sig = torch.clamp(x[i_sig], min=1e-6) + inten = x[i_int] + r0 = x[r_idx] + c0 = x[c_idx] dr = rr - r0 dc = cc - c0 - out += inten * torch.exp(-0.5 * (dr * dr + dc * dc) / (sig * sig)) + r2 = dr * dr + dc * dc + out.add_(inten * torch.exp(-0.5 * r2 / (sig * sig))) - def overlays(self) -> list[Overlay]: + def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: return [] - return PreparedGaussianBackground(origin_key=ok) + return Prepared() diff --git a/src/quantem/core/models/base.py b/src/quantem/core/models/base.py index 16c15840..7d453c59 100644 --- a/src/quantem/core/models/base.py +++ b/src/quantem/core/models/base.py @@ -6,16 +6,6 @@ import numpy as np import torch -__all__ = [ - "Overlay", - "Parameter", - "ModelContext", - "Component", - "OriginND", - "Model", - "PreparedModel", -] - @dataclass(frozen=True) class Overlay: @@ -32,43 +22,47 @@ def __init__( upper_bound: float | None = None, tags: Mapping[str, Any] | None = None, ): - v0, lb, ub = self._parse(value, lower_bound, upper_bound) - self.initial_value = float(v0) - self.lower_bound = float(lb) - self.upper_bound = float(ub) - self.tags: dict[str, Any] = dict(tags) if tags is not None else {} + self.tags: dict[str, Any] = {} if tags is None else dict(tags) + self.initial_value, self.lower_bound, self.upper_bound = self._parse_value( + value, lower_bound, upper_bound + ) self._index: int | None = None @staticmethod - def _parse( + def _parse_value( value: float | Sequence[float], lower_bound: float | None, upper_bound: float | None, - ) -> tuple[float, float, float]: + ) -> tuple[float, float | None, float | None]: if isinstance(value, (list, tuple, np.ndarray)): - seq = list(value) - if len(seq) == 2: - v0 = float(seq[0]) - dev = float(seq[1]) - return v0, v0 - dev, v0 + dev - if len(seq) == 3: - v0 = float(seq[0]) - lb = -np.inf if seq[1] is None else float(seq[1]) - ub = np.inf if seq[2] is None else float(seq[2]) + v = list(value) + if len(v) == 2: + v0 = float(v[0]) + dev = float(v[1]) + lb = v0 - dev if lower_bound is None else float(lower_bound) + ub = v0 + dev if upper_bound is None else float(upper_bound) + return v0, lb, ub + if len(v) == 3: + v0 = float(v[0]) + lb = None if v[1] is None else float(v[1]) + ub = None if v[2] is None else float(v[2]) + if lower_bound is not None: + lb = float(lower_bound) + if upper_bound is not None: + ub = float(upper_bound) return v0, lb, ub raise ValueError("Parameter sequences must have length 2 or 3.") v0 = float(value) - lb = -np.inf if lower_bound is None else float(lower_bound) - ub = np.inf if upper_bound is None else float(upper_bound) - return v0, lb, ub + return v0, lower_bound, upper_bound def bind(self, index: int) -> None: self._index = int(index) - def value(self, x: torch.Tensor, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + @property + def index(self) -> int: if self._index is None: - return torch.as_tensor(self.initial_value, device=device, dtype=dtype).reshape(()) - return x[int(self._index)].to(device=device, dtype=dtype).reshape(()) + raise RuntimeError("Parameter is not bound. Call Model.compile(...) first.") + return self._index class ModelContext: @@ -87,8 +81,7 @@ def __init__( self.device = device self.dtype = dtype self.mask = mask - self.fields: dict[str, Any] = dict(fields) if fields is not None else {} - self.origins: dict[str, tuple[torch.Tensor, ...]] = {} + self.fields: dict[str, Any] = {} if fields is None else dict(fields) def __getattr__(self, name: str) -> Any: if name in self.fields: @@ -101,76 +94,53 @@ def __init__(self, *, name: str): self.name = str(name) def parameters(self) -> list[Parameter]: - out: list[Parameter] = [] - - def add(obj: Any) -> None: - if obj is None: - return - if isinstance(obj, Parameter): - out.append(obj) - return - if isinstance(obj, (list, tuple)): - for z in obj: - add(z) - - for v in self.__dict__.values(): - add(v) - return out + return [] def prepare(self, ctx: ModelContext) -> Any: raise NotImplementedError -class OriginND(Component): - def __init__(self, *, key: str, coords: Sequence[Parameter]): - super().__init__(name=f"Origin[{key}]") - self.key = str(key) - self.coords = list(coords) - - @classmethod - def from_row_col( - cls, +class PreparedModel: + def __init__( + self, *, - key: str, - origin_row: float | Sequence[float], - origin_col: float | Sequence[float], - ) -> "OriginND": - p_row = Parameter(origin_row, tags={"role": "origin_row", "origin_key": str(key)}) - p_col = Parameter(origin_col, tags={"role": "origin_col", "origin_key": str(key)}) - return cls(key=key, coords=[p_row, p_col]) - - def prepare(self, ctx: ModelContext) -> Any: - key = self.key - p0 = self.coords[0] - p1 = self.coords[1] - - r0 = torch.as_tensor(p0.initial_value, device=ctx.device, dtype=ctx.dtype).reshape(()) - c0 = torch.as_tensor(p1.initial_value, device=ctx.device, dtype=ctx.dtype).reshape(()) - ctx.origins[key] = (r0, c0) - - class PreparedOriginND: - origin_key = key + components: list[Any], + ctx: ModelContext, + params: list[Parameter], + x0: torch.Tensor, + lb: torch.Tensor, + ub: torch.Tensor, + ): + self.components = components + self.ctx = ctx + self.params = params + self.x0 = x0 + self.lb = lb + self.ub = ub - def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - r = p0.value(x, device=ctx.device, dtype=ctx.dtype) - c = p1.value(x, device=ctx.device, dtype=ctx.dtype) - ctx.origins[key] = (r, c) + def render(self, x: torch.Tensor) -> torch.Tensor: + out = torch.zeros((self.ctx.H, self.ctx.W), device=self.ctx.device, dtype=self.ctx.dtype) + for c in self.components: + c.render(out, x, self.ctx) + return out - def overlays(self) -> list[Overlay]: - r = float(p0.initial_value) - c = float(p1.initial_value) - return [ - Overlay( - kind="points_rc", - data={"r": np.array([r]), "c": np.array([c]), "marker": "x", "s": 80.0, "color": "tab:blue"}, - ) - ] + def render_initial(self) -> torch.Tensor: + return self.render(self.x0) - return PreparedOriginND() + def overlays(self, x: torch.Tensor | None = None) -> list[Overlay]: + out: list[Overlay] = [] + for c in self.components: + fn = getattr(c, "overlays", None) + if fn is None: + continue + ov = fn(self.x0 if x is None else x, self.ctx) + if ov: + out.extend(list(ov)) + return out class Model: - def __init__(self) -> None: + def __init__(self): self._components: list[Component] = [] def add(self, items: Iterable[Component]) -> "Model": @@ -180,62 +150,30 @@ def add(self, items: Iterable[Component]) -> "Model": self._components.append(obj) return self - def compile(self, ctx: ModelContext) -> "PreparedModel": - seen: set[int] = set() + def parameter_list(self) -> list[Parameter]: params: list[Parameter] = [] for c in self._components: - for p in c.parameters(): - if id(p) in seen: - continue - seen.add(id(p)) - params.append(p) + params.extend(c.parameters()) + return params + def compile(self, ctx: ModelContext) -> PreparedModel: + params = self.parameter_list() for i, p in enumerate(params): p.bind(i) - x0 = torch.as_tensor([p.initial_value for p in params], device=ctx.device, dtype=ctx.dtype) + x0 = torch.zeros((len(params),), device=ctx.device, dtype=ctx.dtype) + lb = torch.full((len(params),), -torch.inf, device=ctx.device, dtype=ctx.dtype) + ub = torch.full((len(params),), torch.inf, device=ctx.device, dtype=ctx.dtype) + + for p in params: + x0[p.index] = torch.as_tensor(p.initial_value, device=ctx.device, dtype=ctx.dtype) + if p.lower_bound is not None: + lb[p.index] = torch.as_tensor(p.lower_bound, device=ctx.device, dtype=ctx.dtype) + if p.upper_bound is not None: + ub[p.index] = torch.as_tensor(p.upper_bound, device=ctx.device, dtype=ctx.dtype) prepared_components: list[Any] = [] for c in self._components: prepared_components.append(c.prepare(ctx)) - return PreparedModel( - components=prepared_components, - ctx=ctx, - params=params, - x0=x0, - ) - - -class PreparedModel: - def __init__( - self, - *, - components: list[Any], - ctx: ModelContext, - params: list[Parameter], - x0: torch.Tensor, - ): - self.components = components - self.ctx = ctx - self.params = params - self.x0 = x0 - - def render(self, x: torch.Tensor) -> torch.Tensor: - out = torch.zeros((self.ctx.H, self.ctx.W), device=self.ctx.device, dtype=self.ctx.dtype) - for c in self.components: - if hasattr(c, "render"): - c.render(out, x, self.ctx) - return out - - def render_initial(self) -> torch.Tensor: - return self.render(self.x0) - - def overlays(self) -> list[Overlay]: - out: list[Overlay] = [] - for c in self.components: - if hasattr(c, "overlays"): - ov = c.overlays() - if ov: - out.extend(list(ov)) - return out + return PreparedModel(components=prepared_components, ctx=ctx, params=params, x0=x0, lb=lb, ub=ub) diff --git a/src/quantem/core/models/diffraction.py b/src/quantem/core/models/diffraction.py index ebf820bd..8ecaf729 100644 --- a/src/quantem/core/models/diffraction.py +++ b/src/quantem/core/models/diffraction.py @@ -1,51 +1,115 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Iterable +from typing import Any, Iterable, Sequence import numpy as np import torch from quantem.core.models.base import Component, ModelContext, Overlay, Parameter -__all__ = ["DiskTemplate", "DiskAtOrigin", "SyntheticDiskLattice"] - -def _as_tensor(x: Any, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor: +def _as_t(x: Any, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor: if torch.is_tensor(x): return x.to(device=device, dtype=dtype) return torch.as_tensor(x, device=device, dtype=dtype) -def _keep_center(r: float, c: float, H: int, W: int, boundary_px: float) -> bool: - b = float(boundary_px) - return (r >= b) and (r <= (H - 1) - b) and (c >= b) and (c <= (W - 1) - b) +def _splat_patch( + out: torch.Tensor, + *, + r0: torch.Tensor, + c0: torch.Tensor, + patch_vals: torch.Tensor, + dr: torch.Tensor, + dc: torch.Tensor, + scale: torch.Tensor, +) -> None: + H = out.shape[0] + W = out.shape[1] + + r = r0 + dr + c = c0 + dc + + r_base = torch.floor(r) + c_base = torch.floor(c) + + fr = r - r_base + fc = c - c_base + r0i = r_base.to(torch.long) + c0i = c_base.to(torch.long) -def _place_patch_add(out: torch.Tensor, patch: torch.Tensor, center_r: float, center_c: float) -> None: - H, W = int(out.shape[-2]), int(out.shape[-1]) - ph, pw = int(patch.shape[-2]), int(patch.shape[-1]) - pr = ph // 2 - pc = pw // 2 + w00 = (1.0 - fr) * (1.0 - fc) + w01 = (1.0 - fr) * fc + w10 = fr * (1.0 - fc) + w11 = fr * fc - r0 = int(np.rint(center_r)) - pr - c0 = int(np.rint(center_c)) - pc - r1 = r0 + ph - c1 = c0 + pw + v = patch_vals * scale - rr0 = max(0, r0) - cc0 = max(0, c0) - rr1 = min(H, r1) - cc1 = min(W, c1) - if rr1 <= rr0 or cc1 <= cc0: - return + def put(rr: torch.Tensor, cc: torch.Tensor, ww: torch.Tensor) -> None: + m = (rr >= 0) & (rr < H) & (cc >= 0) & (cc < W) + if torch.any(m): + out.index_put_((rr[m], cc[m]), (v[m] * ww[m]), accumulate=True) - pr0 = rr0 - r0 - pc0 = cc0 - c0 - pr1 = pr0 + (rr1 - rr0) - pc1 = pc0 + (cc1 - cc0) + put(r0i, c0i, w00) + put(r0i, c0i + 1, w01) + put(r0i + 1, c0i, w10) + put(r0i + 1, c0i + 1, w11) - out[rr0:rr1, cc0:cc1] += patch[pr0:pr1, pc0:pc1] + +def _origin_indices(ctx: ModelContext, origin_key: str) -> tuple[int, int]: + origins: dict[str, Any] = ctx.fields.get("origins", {}) + o = origins.get(origin_key) + if o is None or "row_param" not in o or "col_param" not in o: + raise RuntimeError(f"Origin '{origin_key}' not defined. Origin2D must be included first.") + return int(o["row_param"].index), int(o["col_param"].index) + + +class Origin2D(Component): + def __init__( + self, + *, + name: str = "origin", + origin_key: str = "origin", + row: float | tuple[float, float] | tuple[float, float, float | None] = (0.0, 0.0, None), + col: float | tuple[float, float] | tuple[float, float, float | None] = (0.0, 0.0, None), + ): + super().__init__(name=name) + self.origin_key = str(origin_key) + self.p_row = Parameter(row, tags={"role": "origin_row", "origin_key": self.origin_key}) + self.p_col = Parameter(col, tags={"role": "origin_col", "origin_key": self.origin_key}) + + def parameters(self) -> list[Parameter]: + return [self.p_row, self.p_col] + + def prepare(self, ctx: ModelContext) -> Any: + origins = ctx.fields.setdefault("origins", {}) + origins[self.origin_key] = {"row_param": self.p_row, "col_param": self.p_col} + + i_r = self.p_row.index + i_c = self.p_col.index + + @dataclass + class Prepared: + def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: + return + + def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: + return [ + Overlay( + kind="points_rc", + data={ + "r": x[i_r].detach(), + "c": x[i_c].detach(), + "marker": "+", + "s": 100.0, + "color": "dodgerblue", + }, + ) + ] + + return Prepared() class DiskTemplate(Component): @@ -56,26 +120,49 @@ def __init__( array: np.ndarray, refine_all_pixels: bool = False, place_at_origin: bool = False, + normalize: str = "none", origin_key: str = "origin", - origin_intensity: float | tuple[float, float] | tuple[float, float, float | None] = 1.0, + intensity: float | tuple[float, float] | tuple[float, float, float | None] = 1.0, ): super().__init__(name=name) - arr = np.asarray(array, dtype=np.float32) - if arr.ndim != 2: + + a = np.asarray(array, dtype=np.float32) + if a.ndim != 2: raise ValueError("DiskTemplate.array must be 2D.") - self.shape = (int(arr.shape[0]), int(arr.shape[1])) + + if normalize == "max": + mx = float(np.max(a)) + if mx > 0: + a = a / mx + elif normalize == "mean": + m = float(np.mean(a)) + if m != 0: + a = a / m + elif normalize == "none": + pass + else: + raise ValueError("normalize must be one of: 'none', 'max', 'mean'.") + + self.array = a self.refine_all_pixels = bool(refine_all_pixels) self.place_at_origin = bool(place_at_origin) self.origin_key = str(origin_key) - self._array0 = arr - self.p_origin_intensity = Parameter(origin_intensity, tags={"role": "disk_origin_intensity", "name": name}) - self.p_pixels: list[Parameter] = [] if self.refine_all_pixels: - flat = arr.reshape(-1) + flat = self.array.ravel() for i, v in enumerate(flat): - self.p_pixels.append(Parameter(float(v), tags={"role": "disk_pixel", "name": name, "pixel_index": int(i)})) + self.p_pixels.append( + Parameter( + float(v), + lower_bound=0.0, + tags={"role": "disk_pixel", "disk": self.name, "i": int(i)}, + ) + ) + + self.p_intensity: Parameter | None = None + if self.place_at_origin: + self.p_intensity = Parameter(intensity, lower_bound=0.0, tags={"role": "disk_intensity", "disk": self.name}) @classmethod def from_array( @@ -85,57 +172,79 @@ def from_array( array: np.ndarray, refine_all_pixels: bool = False, place_at_origin: bool = False, + normalize: str = "none", origin_key: str = "origin", - origin_intensity: float | tuple[float, float] | tuple[float, float, float | None] = 1.0, + intensity: float | tuple[float, float] | tuple[float, float, float | None] = 1.0, ) -> "DiskTemplate": return cls( name=name, array=array, refine_all_pixels=refine_all_pixels, place_at_origin=place_at_origin, + normalize=normalize, origin_key=origin_key, - origin_intensity=origin_intensity, + intensity=intensity, ) + def parameters(self) -> list[Parameter]: + out = list(self.p_pixels) + if self.p_intensity is not None: + out.append(self.p_intensity) + return out + def prepare(self, ctx: ModelContext) -> Any: - disk = self - ok = disk.origin_key + device = ctx.device + dtype = ctx.dtype - if disk.refine_all_pixels: - idxs = [p._index for p in disk.p_pixels] - if any(i is None for i in idxs): - raise RuntimeError("DiskTemplate pixel parameters not bound.") - idx_t = torch.as_tensor([int(i) for i in idxs], device=ctx.device, dtype=torch.int64) + Ht, Wt = self.array.shape + rr, cc = np.mgrid[0:Ht, 0:Wt] + rr = rr.astype(np.float32) - (Ht - 1) * 0.5 + cc = cc.astype(np.float32) - (Wt - 1) * 0.5 - def patch(x: torch.Tensor) -> torch.Tensor: - return x.index_select(0, idx_t).to(device=ctx.device, dtype=ctx.dtype).reshape(disk.shape[0], disk.shape[1]) + dr = _as_t(rr.ravel(), device=device, dtype=dtype) + dc = _as_t(cc.ravel(), device=device, dtype=dtype) + if self.refine_all_pixels: + pix_idx = torch.as_tensor([p.index for p in self.p_pixels], device=device, dtype=torch.long) + base_vals = None else: - patch0 = _as_tensor(disk._array0, device=ctx.device, dtype=ctx.dtype) - - def patch(x: torch.Tensor) -> torch.Tensor: - return patch0 + pix_idx = None + base_vals = _as_t(self.array.ravel(), device=device, dtype=dtype) + + ctx.fields.setdefault("disk_templates", {})[self.name] = { + "dr": dr, + "dc": dc, + "pix_idx": pix_idx, + "base": base_vals, + } + + i_int = None if self.p_intensity is None else self.p_intensity.index + if i_int is None: + r_idx = c_idx = None + else: + r_idx, c_idx = _origin_indices(ctx, self.origin_key) @dataclass - class PreparedDiskTemplate: - origin_key: str - place_at_origin: bool + class Prepared: + def patch(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + if pix_idx is None: + vals = base_vals + else: + vals = x[pix_idx] + return vals, dr, dc def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - if not self.place_at_origin: + if i_int is None: return - if self.origin_key not in ctx.origins: - return - r0, c0 = ctx.origins[self.origin_key] - r = float(r0.detach().cpu().item()) - c = float(c0.detach().cpu().item()) - inten = disk.p_origin_intensity.value(x, device=ctx.device, dtype=ctx.dtype) - _place_patch_add(out, patch(x) * inten, r, c) + vals, drl, dcl = self.patch(x) + r0 = x[r_idx] + c0 = x[c_idx] + _splat_patch(out, r0=r0, c0=c0, patch_vals=vals, dr=drl, dc=dcl, scale=x[i_int]) - def overlays(self) -> list[Overlay]: + def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: return [] - return PreparedDiskTemplate(origin_key=ok, place_at_origin=disk.place_at_origin) + return Prepared() class SyntheticDiskLattice(Component): @@ -153,142 +262,188 @@ def __init__( intensity_0: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, intensity_row: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, intensity_col: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, - exclude_indices: Iterable[tuple[int, int]] | None = [], + per_disk_intensity: bool = False, + per_disk_slopes: bool = True, + center_intensity_0: float | tuple[float, float] | tuple[float, float, float | None] | None = None, + exclude_indices: Iterable[tuple[int, int]] | None = None, boundary_px: float = 0.0, origin_key: str = "origin", ): super().__init__(name=name) self.disk = disk - self.origin_key = str(origin_key) self.u_max = int(u_max) self.v_max = int(v_max) self.boundary_px = float(boundary_px) + self.origin_key = str(origin_key) + self.per_disk_intensity = bool(per_disk_intensity) + self.per_disk_slopes = bool(per_disk_slopes) if exclude_indices is None: - self.exclude_indices = {(0, 0)} + self.exclude_indices = {(0, 0)} if bool(getattr(disk, "place_at_origin", False)) else set() else: - self.exclude_indices = {(int(a), int(b)) for (a, b) in exclude_indices} - - self.p_u_row = Parameter(u_row, tags={"role": "lattice_u_row", "name": name}) - self.p_u_col = Parameter(u_col, tags={"role": "lattice_u_col", "name": name}) - self.p_v_row = Parameter(v_row, tags={"role": "lattice_v_row", "name": name}) - self.p_v_col = Parameter(v_col, tags={"role": "lattice_v_col", "name": name}) - - self.p_intensity_0 = Parameter(intensity_0, tags={"role": "disk_intensity_0", "name": name}) - self.p_intensity_row = Parameter(intensity_row, tags={"role": "disk_intensity_row", "name": name}) - self.p_intensity_col = Parameter(intensity_col, tags={"role": "disk_intensity_col", "name": name}) + self.exclude_indices = set(exclude_indices) - def _uv_list(self) -> list[tuple[int, int]]: uv: list[tuple[int, int]] = [] for u in range(-self.u_max, self.u_max + 1): for v in range(-self.v_max, self.v_max + 1): if (u, v) in self.exclude_indices: continue uv.append((u, v)) - return uv + self.uv_indices = uv + + self.p_u_row = Parameter(u_row, tags={"role": "lat_u_row"}) + self.p_u_col = Parameter(u_col, tags={"role": "lat_u_col"}) + self.p_v_row = Parameter(v_row, tags={"role": "lat_v_row"}) + self.p_v_col = Parameter(v_col, tags={"role": "lat_v_col"}) + self.p_i0 = None + self.p_ir = None + self.p_ic = None + self.p_i0_list: list[Parameter] = [] + self.p_ir_list: list[Parameter] = [] + self.p_ic_list: list[Parameter] = [] + if self.per_disk_intensity: + for u, v in self.uv_indices: + i0_val = center_intensity_0 if (center_intensity_0 is not None and (u, v) == (0, 0)) else intensity_0 + self.p_i0_list.append(Parameter(i0_val, lower_bound=0.0, tags={"role": "lat_int0", "u": u, "v": v})) + if self.per_disk_slopes: + self.p_ir_list.append(Parameter(intensity_row, tags={"role": "lat_int_row", "u": u, "v": v})) + self.p_ic_list.append(Parameter(intensity_col, tags={"role": "lat_int_col", "u": u, "v": v})) + else: + self.p_i0 = Parameter(intensity_0, lower_bound=0.0, tags={"role": "lat_int0"}) + self.p_ir = Parameter(intensity_row, tags={"role": "lat_int_row"}) + self.p_ic = Parameter(intensity_col, tags={"role": "lat_int_col"}) + + def parameters(self) -> list[Parameter]: + out = [self.p_u_row, self.p_u_col, self.p_v_row, self.p_v_col] + if self.per_disk_intensity: + out.extend(self.p_i0_list) + if self.per_disk_slopes: + out.extend(self.p_ir_list) + out.extend(self.p_ic_list) + else: + out.extend([self.p_i0, self.p_ir, self.p_ic]) + return out def prepare(self, ctx: ModelContext) -> Any: - lat = self - ok = lat.origin_key - uv = lat._uv_list() + dt = ctx.fields.get("disk_templates", {}) + if self.disk.name not in dt: + raise RuntimeError("DiskTemplate must be included before SyntheticDiskLattice in components.") + d = dt[self.disk.name] + dr = d["dr"] + dc = d["dc"] + pix_idx = d["pix_idx"] + base_vals = d["base"] + + r_idx, c_idx = _origin_indices(ctx, self.origin_key) + + i_ur = self.p_u_row.index + i_uc = self.p_u_col.index + i_vr = self.p_v_row.index + i_vc = self.p_v_col.index + if self.per_disk_intensity: + i_i0 = i_ir = i_ic = None + else: + i_i0 = self.p_i0.index + i_ir = self.p_ir.index + i_ic = self.p_ic.index + + uv = self.uv_indices + uv_t = torch.as_tensor(uv, device=ctx.device, dtype=torch.long) if uv else None + + boundary = torch.as_tensor(self.boundary_px, device=ctx.device, dtype=ctx.dtype) + + if self.per_disk_intensity: + i0_idx = torch.as_tensor([p.index for p in self.p_i0_list], device=ctx.device, dtype=torch.long) + if self.per_disk_slopes: + ir_idx = torch.as_tensor([p.index for p in self.p_ir_list], device=ctx.device, dtype=torch.long) + ic_idx = torch.as_tensor([p.index for p in self.p_ic_list], device=ctx.device, dtype=torch.long) + else: + ir_idx = ic_idx = None + else: + i0_idx = ir_idx = ic_idx = None + per_disk_intensity = self.per_disk_intensity + per_disk_slopes = self.per_disk_slopes - if lat.disk.refine_all_pixels: - didxs = [p._index for p in lat.disk.p_pixels] - if any(i is None for i in didxs): - raise RuntimeError("DiskTemplate pixel parameters not bound.") - didx_t = torch.as_tensor([int(i) for i in didxs], device=ctx.device, dtype=torch.int64) + @dataclass + class Prepared: + boundary_px: float = float(self.boundary_px) - def patch(x: torch.Tensor) -> torch.Tensor: - return x.index_select(0, didx_t).to(device=ctx.device, dtype=ctx.dtype).reshape(lat.disk.shape[0], lat.disk.shape[1]) + def _patch(self, x: torch.Tensor) -> torch.Tensor: + if pix_idx is None: + return base_vals + return x[pix_idx] - else: - patch0 = _as_tensor(lat.disk._array0, device=ctx.device, dtype=ctx.dtype) - - def patch(x: torch.Tensor) -> torch.Tensor: - return patch0 - - r0_list: list[float] = [] - c0_list: list[float] = [] - if ok in ctx.origins: - or0, oc0 = ctx.origins[ok] - orow0 = float(or0.detach().cpu().item()) - ocol0 = float(oc0.detach().cpu().item()) - urow0 = float(lat.p_u_row.initial_value) - ucol0 = float(lat.p_u_col.initial_value) - vrow0 = float(lat.p_v_row.initial_value) - vcol0 = float(lat.p_v_col.initial_value) - for u, v in uv: - rr = orow0 + u * urow0 + v * vrow0 - cc = ocol0 + u * ucol0 + v * vcol0 - if _keep_center(rr, cc, ctx.H, ctx.W, lat.boundary_px): - r0_list.append(rr) - c0_list.append(cc) - - r0_np = np.asarray(r0_list, dtype=np.float32) - c0_np = np.asarray(c0_list, dtype=np.float32) + def _centers(self, x: torch.Tensor, ctx: ModelContext) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + if uv_t is None: + z = torch.empty((0,), device=ctx.device, dtype=ctx.dtype) + return z, z, z - @dataclass - class PreparedSyntheticDiskLattice: - origin_key: str - boundary_px: float - uv: list[tuple[int, int]] - r0: np.ndarray - c0: np.ndarray + r0 = x[r_idx] + c0 = x[c_idx] + + ur = x[i_ur] + uc = x[i_uc] + vr = x[i_vr] + vc = x[i_vc] + + u = uv_t[:, 0].to(ctx.dtype) + v = uv_t[:, 1].to(ctx.dtype) + + centers_r = r0 + u * ur + v * vr + centers_c = c0 + u * uc + v * vc + + keep = (centers_r >= boundary) & (centers_r <= (ctx.H - 1) - boundary) + keep &= (centers_c >= boundary) & (centers_c <= (ctx.W - 1) - boundary) + return centers_r[keep], centers_c[keep], keep def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - if self.origin_key not in ctx.origins: + if uv_t is None: return - if not self.uv: + + centers_r, centers_c, keep = self._centers(x, ctx) + if centers_r.numel() == 0: return - r_origin, c_origin = ctx.origins[self.origin_key] - r_origin = r_origin.reshape(()) - c_origin = c_origin.reshape(()) - - urow = lat.p_u_row.value(x, device=ctx.device, dtype=ctx.dtype) - ucol = lat.p_u_col.value(x, device=ctx.device, dtype=ctx.dtype) - vrow = lat.p_v_row.value(x, device=ctx.device, dtype=ctx.dtype) - vcol = lat.p_v_col.value(x, device=ctx.device, dtype=ctx.dtype) - - I0 = lat.p_intensity_0.value(x, device=ctx.device, dtype=ctx.dtype) - Ir = lat.p_intensity_row.value(x, device=ctx.device, dtype=ctx.dtype) - Ic = lat.p_intensity_col.value(x, device=ctx.device, dtype=ctx.dtype) - - uv_t = torch.as_tensor(self.uv, device=ctx.device, dtype=torch.int32) - uu = uv_t[:, 0].to(device=ctx.device, dtype=ctx.dtype) - vv = uv_t[:, 1].to(device=ctx.device, dtype=ctx.dtype) - - rr = r_origin + uu * urow + vv * vrow - cc = c_origin + uu * ucol + vv * vcol - - p = patch(x) - - H = int(ctx.H) - W = int(ctx.W) - for i in range(int(rr.numel())): - r = float(rr[i].detach().cpu().item()) - c = float(cc[i].detach().cpu().item()) - if not _keep_center(r, c, H, W, self.boundary_px): - continue - dr = rr[i] - r_origin - dc = cc[i] - c_origin - inten = torch.clamp(I0 + Ir * dr + Ic * dc, min=0.0) - _place_patch_add(out, p * inten, r, c) - - def overlays(self) -> list[Overlay]: - if self.r0.size == 0: + vals = self._patch(x) + + if per_disk_intensity: + keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) + i0_keep = x[i0_idx[keep_idx]] + if per_disk_slopes: + ir_keep = x[ir_idx[keep_idx]] + ic_keep = x[ic_idx[keep_idx]] + for j, (rr0, cc0) in enumerate(zip(centers_r, centers_c)): + inten_local = torch.clamp(i0_keep[j] + ir_keep[j] * dr + ic_keep[j] * dc, min=0.0) + _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten_local) + else: + for j, (rr0, cc0) in enumerate(zip(centers_r, centers_c)): + inten_local = torch.clamp(i0_keep[j], min=0.0) + _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten_local) + else: + i0 = x[i_i0] + ir = x[i_ir] + ic = x[i_ic] + for rr0, cc0 in zip(centers_r, centers_c): + inten = torch.clamp(i0 + ir * rr0 + ic * cc0, min=0.0) + _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten) + + def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: + if uv_t is None: + return [] + centers_r, centers_c, _ = self._centers(x, ctx) + if centers_r.numel() == 0: return [] return [ Overlay( kind="points_rc", - data={"r": self.r0, "c": self.c0, "marker": "x", "s": 60.0, "color": "orange"}, + data={ + "r": centers_r.detach(), + "c": centers_c.detach(), + "marker": "x", + "s": 60.0, + "color": "orange", + }, ) ] - return PreparedSyntheticDiskLattice( - origin_key=ok, - boundary_px=lat.boundary_px, - uv=uv, - r0=r0_np, - c0=c0_np, - ) + return Prepared() diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 84d9e5c8..f382ab27 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any +import warnings +from typing import Any, Sequence import numpy as np import torch @@ -12,12 +13,11 @@ from quantem.core.datastructures.dataset4d import Dataset4d from quantem.core.datastructures.dataset4dstem import Dataset4dstem from quantem.core.io.serialize import AutoSerialize -from quantem.core.models.base import Model, ModelContext, OriginND, Overlay, PreparedModel +from quantem.core.models.base import Model, ModelContext, Overlay, PreparedModel +from quantem.core.models.diffraction import Origin2D from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.core.visualization import show_2d -__all__ = ["ModelDiffraction"] - def _to_numpy(x: Any) -> np.ndarray: if isinstance(x, np.ndarray): @@ -36,17 +36,40 @@ def __init__(self, dataset: Any, _token: object | None = None): super().__init__() self.dataset = dataset self.metadata: dict[str, Any] = {} - self.index_shape: tuple[int, ...] | None = None self.image_ref: np.ndarray | None = None self.preprocess_shifts: np.ndarray | None = None + self.index_shape: tuple[int, ...] | None = None self.model: Model | None = None self.prepared: PreparedModel | None = None - self.fit_mask: np.ndarray | None = None + self.x_mean: torch.Tensor | None = None + self.x_defined: torch.Tensor | None = None + self.x_initial: torch.Tensor | None = None + self.mean_refined: bool = False + self.x_patterns: torch.Tensor | None = None + self.pattern_fit_losses: np.ndarray | None = None + self.pattern_fit_linear_indices: np.ndarray | None = None + self.pattern_fit_indices: list[tuple[int, ...]] | None = None + + @staticmethod + def _weak_softplus(x: torch.Tensor, *, scale: float) -> torch.Tensor: + s = torch.as_tensor(float(scale), device=x.device, dtype=x.dtype) + return torch.nn.functional.softplus(x / s) * s + + @classmethod + def _apply_intensity_transform( + cls, x: torch.Tensor, *, mode: str, weak_softplus_scale: float + ) -> torch.Tensor: + m = str(mode).lower() + if m == "none": + return x + if m == "weak_softplus": + return cls._weak_softplus(x, scale=weak_softplus_scale) + raise ValueError("intensity_transform must be one of: 'none', 'weak_softplus'.") @classmethod - def from_dataset(self, dataset: Dataset2d | Dataset3d | Dataset4d | Dataset4dstem | Any) -> "ModelDiffraction": + def from_dataset(cls, dataset: Dataset2d | Dataset3d | Dataset4d | Dataset4dstem | Any) -> "ModelDiffraction": if isinstance(dataset, (Dataset2d, Dataset3d, Dataset4d, Dataset4dstem)): - return ModelDiffraction(dataset=dataset, _token=ModelDiffraction._token) + return cls(dataset=dataset, _token=cls._token) raise TypeError("from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance.") def preprocess( @@ -61,22 +84,15 @@ def preprocess( arr = np.asarray(self.dataset.array) if arr.ndim < 2: raise ValueError("dataset.array must have at least 2 dimensions.") - H, W = int(arr.shape[-2]), int(arr.shape[-1]) + H, W = arr.shape[-2], arr.shape[-1] self.index_shape = tuple(arr.shape[:-2]) stack = arr.reshape((-1, H, W)).astype(np.float32, copy=False) - n = int(stack.shape[0]) + n = stack.shape[0] - if (not align) or n <= 1: + if not align or n <= 1: self.image_ref = np.mean(stack, axis=0) self.preprocess_shifts = None - self.metadata["preprocess"] = { - "align": bool(align), - "edge_blend": float(edge_blend), - "upsample_factor": int(upsample_factor), - "max_shift": None if max_shift is None else float(max_shift), - "shift_order": int(shift_order), - } return self alpha_r = 0.0 if edge_blend <= 0 else min(1.0, 2.0 * float(edge_blend) / float(H)) @@ -116,28 +132,8 @@ def preprocess( self.image_ref = np.mean(aligned, axis=0) self.preprocess_shifts = shifts.reshape(self.index_shape + (2,)) - - self.metadata["preprocess"] = { - "align": bool(align), - "edge_blend": float(edge_blend), - "upsample_factor": int(upsample_factor), - "max_shift": None if max_shift is None else float(max_shift), - "shift_order": int(shift_order), - } return self - def _ensure_image_ref(self) -> None: - if self.image_ref is not None: - return - arr = np.asarray(self.dataset.array) - if arr.ndim < 2: - raise ValueError("dataset.array must have at least 2 dimensions.") - H, W = int(arr.shape[-2]), int(arr.shape[-1]) - self.index_shape = tuple(arr.shape[:-2]) - stack = arr.reshape((-1, H, W)).astype(np.float32, copy=False) - self.image_ref = np.mean(stack, axis=0) - self.preprocess_shifts = None - def define_model( self, *, @@ -149,10 +145,12 @@ def define_model( mask: np.ndarray | torch.Tensor | None = None, origin_key: str = "origin", ) -> "ModelDiffraction": - self._ensure_image_ref() + if self.image_ref is None: + self.preprocess() + if self.image_ref is None: raise RuntimeError("image_ref not available.") - H, W = int(self.image_ref.shape[-2]), int(self.image_ref.shape[-1]) + H, W = int(self.image_ref.shape[0]), int(self.image_ref.shape[1]) dev = torch.device(device) if device is not None else torch.device("cpu") dt = dtype if dtype is not None else torch.float32 @@ -160,36 +158,346 @@ def define_model( mask_t = None if mask is not None: if torch.is_tensor(mask): - mask_t = mask.to(device=dev, dtype=torch.float32) + mask_t = mask.to(device=dev, dtype=dt) else: m = np.asarray(mask) if m.shape != (H, W): raise ValueError("mask must have shape (H, W).") - mask_t = torch.as_tensor(m.astype(np.float32, copy=False), device=dev) - - ctx = ModelContext(H=H, W=W, device=dev, dtype=dt, mask=mask_t) + mask_t = torch.as_tensor(m.astype(np.float32, copy=False), device=dev, dtype=dt) + ctx = ModelContext(H=H, W=W, device=dev, dtype=dt, mask=mask_t, fields={}) m = Model() - origin = OriginND.from_row_col(key=str(origin_key), origin_row=origin_row, origin_col=origin_col) - comps = [origin] + list(components) - m.add(comps) + m.add([Origin2D(origin_key=str(origin_key), row=origin_row, col=origin_col)]) + m.add(list(components)) self.model = m self.prepared = m.compile(ctx) - self.metadata["define_model"] = {"origin_key": str(origin_key), "device": str(dev), "dtype": str(dt)} + self.x_defined = self.prepared.x0.detach().clone() + self.x_initial = self.x_defined.detach().clone() + self.x_mean = self.x_initial.detach().clone() + self.mean_refined = False + self.x_patterns = None + self.pattern_fit_losses = None + self.pattern_fit_linear_indices = None + self.pattern_fit_indices = None + return self + + def _fit_target_image( + self, + *, + target: torch.Tensor, + x_start: torch.Tensor, + n_steps: int, + lr: float, + method: str, + power: float | None, + fit_disk_pixels: bool | None, + fit_only_disk_pixels: bool, + intensity_transform: str, + weak_softplus_scale: float, + progress: bool = False, + progress_desc: str | None = None, + ) -> tuple[torch.Tensor, float]: + if self.prepared is None: + raise RuntimeError("Call .define_model(...) first.") + + ctx = self.prepared.ctx + lb = self.prepared.lb + ub = self.prepared.ub + + x = x_start.detach().clone().to(device=ctx.device, dtype=ctx.dtype) + x.requires_grad_(True) + + if fit_disk_pixels is None: + fit_disk_pixels = any(p.tags.get("role") == "disk_pixel" for p in self.prepared.params) + + freeze = torch.zeros_like(x, dtype=torch.bool) + disk_mask = torch.zeros_like(x, dtype=torch.bool) + for p in self.prepared.params: + if p.tags.get("role") == "disk_pixel": + disk_mask[p.index] = True + + if fit_only_disk_pixels: + if not fit_disk_pixels: + raise ValueError("fit_only_disk_pixels=True requires fit_disk_pixels=True.") + freeze[:] = True + freeze[disk_mask] = False + elif not fit_disk_pixels: + freeze[disk_mask] = True + x_frozen = x.detach().clone() + + target_t = target.to(device=ctx.device, dtype=ctx.dtype) + target_t = self._apply_intensity_transform( + target_t, mode=intensity_transform, weak_softplus_scale=weak_softplus_scale + ) + if power is not None: + target_t = torch.clamp(target_t, min=0.0) ** float(power) + + def clamp_inplace() -> None: + with torch.no_grad(): + x.data = torch.max(torch.min(x.data, ub), lb) + if torch.any(freeze): + x.data[freeze] = x_frozen[freeze] + + def loss_fn() -> torch.Tensor: + clamp_inplace() + pred = self.prepared.render(x) + pred = self._apply_intensity_transform( + pred, mode=intensity_transform, weak_softplus_scale=weak_softplus_scale + ) + if power is not None: + pred = torch.clamp(pred, min=0.0) ** float(power) + if ctx.mask is not None: + m = ctx.mask + diff = (pred - target_t) * m + denom = torch.clamp(torch.sum(m), min=1.0) + return torch.sum(diff * diff) / denom + diff = pred - target_t + return torch.mean(diff * diff) + + if method == "adam": + opt = torch.optim.Adam([x], lr=lr) + step_iter: Any = range(int(n_steps)) + if progress: + try: + from tqdm.auto import trange + + step_iter = trange(int(n_steps), desc=progress_desc or "Refining", leave=False) + except Exception: + warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) + for _ in step_iter: + opt.zero_grad(set_to_none=True) + loss = loss_fn() + loss.backward() + opt.step() + clamp_inplace() + elif method == "lbfgs": + opt = torch.optim.LBFGS([x], lr=lr, max_iter=int(n_steps), line_search_fn="strong_wolfe") + + def closure() -> torch.Tensor: + opt.zero_grad(set_to_none=True) + loss = loss_fn() + loss.backward() + return loss + + opt.step(closure) + clamp_inplace() + else: + raise ValueError("method must be one of: 'lbfgs', 'adam'.") + + with torch.no_grad(): + final_loss = float(loss_fn().detach().cpu()) + return x.detach().clone(), final_loss + + def _resolve_pattern_indices(self, indices: Any, n: int, index_shape: tuple[int, ...]) -> np.ndarray: + if indices is None: + out = np.arange(n, dtype=np.int64) + elif isinstance(indices, (int, np.integer)): + out = np.asarray([int(indices)], dtype=np.int64) + elif isinstance(indices, slice): + out = np.arange(n, dtype=np.int64)[indices] + elif isinstance(indices, tuple) and all(isinstance(i, (int, np.integer)) for i in indices): + if len(index_shape) == 0: + if len(indices) != 0: + raise ValueError("indices tuple must be empty for single-pattern datasets.") + out = np.asarray([0], dtype=np.int64) + else: + out = np.asarray([np.ravel_multi_index(tuple(int(i) for i in indices), index_shape)], dtype=np.int64) + elif isinstance(indices, tuple): + if len(index_shape) == 0: + raise ValueError("slice tuple indices are not valid for single-pattern datasets.") + grid = np.arange(n, dtype=np.int64).reshape(index_shape) + out = np.asarray(grid[indices], dtype=np.int64).ravel() + elif isinstance(indices, np.ndarray) and indices.dtype == np.bool_: + if indices.shape != index_shape: + raise ValueError(f"Boolean mask must have shape {index_shape}.") + out = np.flatnonzero(indices.ravel()).astype(np.int64, copy=False) + elif isinstance(indices, Sequence) and not isinstance(indices, (str, bytes)): + seq = list(indices) + if len(seq) == 0: + out = np.asarray([], dtype=np.int64) + elif isinstance(seq[0], (tuple, list, np.ndarray)): + if len(index_shape) == 0: + raise ValueError("multi-index selection is not valid for single-pattern datasets.") + out = np.asarray( + [np.ravel_multi_index(tuple(int(j) for j in i), index_shape) for i in seq], + dtype=np.int64, + ) + else: + out = np.asarray([int(i) for i in seq], dtype=np.int64) + else: + raise TypeError("Unsupported indices type for fit_all_patterns.") + + if out.ndim != 1: + out = out.ravel() + if np.any(out < 0) or np.any(out >= n): + raise IndexError(f"indices must be in [0, {n - 1}].") + return out + + def refine_mean_model( + self, + *, + n_steps: int = 50, + lr: float = 1e-3, + method: str = "adam", + power: float | None = 1.0, + fit_disk_pixels: bool | None = None, + fit_only_disk_pixels: bool = False, + warmup_disk_steps: int = 0, + overwrite_initial: bool = True, + intensity_transform: str = "none", + weak_softplus_scale: float = 1e-3, + progress: bool = False, + ) -> "ModelDiffraction": + method = str(method).lower() + + if self.image_ref is None: + self.preprocess() + if self.image_ref is None or self.prepared is None or self.x_mean is None or self.x_initial is None: + raise RuntimeError("Call .define_model(...) first.") + + ctx = self.prepared.ctx + target = torch.as_tensor(self.image_ref, device=ctx.device, dtype=ctx.dtype) + x_start = self.x_initial if overwrite_initial else self.x_mean + if int(warmup_disk_steps) > 0: + x_start, _ = self._fit_target_image( + target=target, + x_start=x_start, + n_steps=int(warmup_disk_steps), + lr=float(lr), + method=method, + power=power, + fit_disk_pixels=True, + fit_only_disk_pixels=True, + intensity_transform=intensity_transform, + weak_softplus_scale=float(weak_softplus_scale), + progress=bool(progress), + progress_desc="Refine mean model (disk warmup)", + ) + x_fit, _ = self._fit_target_image( + target=target, + x_start=x_start, + n_steps=int(n_steps), + lr=float(lr), + method=method, + power=power, + fit_disk_pixels=fit_disk_pixels, + fit_only_disk_pixels=bool(fit_only_disk_pixels), + intensity_transform=intensity_transform, + weak_softplus_scale=float(weak_softplus_scale), + progress=bool(progress), + progress_desc="Refine mean model", + ) + + self.x_mean = x_fit + self.mean_refined = True + if overwrite_initial: + self.x_initial = x_fit.detach().clone() + return self + + def fit_all_patterns( + self, + *, + indices: Any = None, + use_refined_init: bool = True, + strict_refined_init: bool = False, + n_steps: int = 50, + lr: float = 1e-3, + method: str = "adam", + power: float | None = 1.0, + fit_disk_pixels: bool | None = None, + fit_only_disk_pixels: bool = False, + intensity_transform: str = "none", + weak_softplus_scale: float = 1e-3, + progress: bool = False, + ) -> "ModelDiffraction": + method = str(method).lower() + + if self.prepared is None or self.x_defined is None or self.x_initial is None: + raise RuntimeError("Call .define_model(...) first.") + + arr = np.asarray(self.dataset.array) + if arr.ndim < 2: + raise ValueError("dataset.array must have at least 2 dimensions.") + H, W = arr.shape[-2], arr.shape[-1] + index_shape = tuple(arr.shape[:-2]) + stack = arr.reshape((-1, H, W)).astype(np.float32, copy=False) + n = int(stack.shape[0]) + + linear = self._resolve_pattern_indices(indices=indices, n=n, index_shape=index_shape) + if linear.size == 0: + raise ValueError("No patterns selected for fitting.") + + if use_refined_init: + if not self.mean_refined: + msg = "fit_all_patterns(use_refined_init=True) was requested before refine_mean_model()." + if strict_refined_init: + raise RuntimeError(msg) + warnings.warn(f"{msg} Falling back to defined initial parameters.", stacklevel=2) + x_seed = self.x_defined + else: + x_seed = self.x_initial + else: + x_seed = self.x_defined + + n_sel = int(linear.size) + x_fit_all = torch.empty( + (n_sel, self.x_defined.numel()), + device=self.prepared.ctx.device, + dtype=self.prepared.ctx.dtype, + ) + losses = np.empty((n_sel,), dtype=np.float32) + + pat_iter: Any = enumerate(linear) + if progress: + try: + from tqdm.auto import tqdm + + pat_iter = enumerate(tqdm(linear, desc="Fit patterns", leave=False)) + except Exception: + warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) + + for j, i_lin in pat_iter: + target = torch.as_tensor(stack[int(i_lin)], device=self.prepared.ctx.device, dtype=self.prepared.ctx.dtype) + x_fit, loss = self._fit_target_image( + target=target, + x_start=x_seed, + n_steps=int(n_steps), + lr=float(lr), + method=method, + power=power, + fit_disk_pixels=fit_disk_pixels, + fit_only_disk_pixels=bool(fit_only_disk_pixels), + intensity_transform=intensity_transform, + weak_softplus_scale=float(weak_softplus_scale), + progress=False, + ) + x_fit_all[j] = x_fit + losses[j] = float(loss) + + self.x_patterns = x_fit_all + self.pattern_fit_losses = losses + self.pattern_fit_linear_indices = linear + if len(index_shape) == 0: + self.pattern_fit_indices = [tuple() for _ in linear] + else: + self.pattern_fit_indices = [tuple(int(k) for k in np.unravel_index(int(i), index_shape)) for i in linear] return self def _apply_overlays(self, ax: Any, overlays: list[Overlay]) -> None: for ov in overlays: - d = dict(ov.data) - if ov.kind in {"points", "points_rc"} and ("r" in d) and ("c" in d): - r = np.asarray(d["r"]).ravel() - c = np.asarray(d["c"]).ravel() - s = float(d.get("s", 60.0)) - marker = d.get("marker", "x") - color = d.get("color", "orange") - ax.scatter(c, r, s=s, marker=marker, c=color) + if ov.kind != "points_rc": continue + d = dict(ov.data) + r = _to_numpy(d["r"]).ravel() + c = _to_numpy(d["c"]).ravel() + ax.scatter( + c, + r, + s=float(d.get("s", 60.0)), + marker=d.get("marker", "x"), + color=d.get("color", "orange"), + ) def plot_mean_model( self, @@ -199,51 +507,46 @@ def plot_mean_model( show_overlays: bool = True, axsize: tuple[int, int] = (6, 6), ) -> tuple[Any, Any] | None: - self._ensure_image_ref() if self.image_ref is None: - raise RuntimeError("image_ref not available.") - if self.prepared is None: - raise RuntimeError("No model defined. Call .define_model(...) first.") + self.preprocess() + if self.image_ref is None or self.prepared is None: + raise RuntimeError("Call .define_model(...) first.") + if self.x_mean is None: + self.x_mean = self.prepared.x0.detach().clone() ref = np.asarray(self.image_ref, dtype=np.float32) - init = _to_numpy(self.prepared.render_initial()).astype(np.float32, copy=False) + mod = _to_numpy(self.prepared.render(self.x_mean)).astype(np.float32, copy=False) refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) - initp = init if power == 1.0 else np.maximum(init, 0.0) ** float(power) + modp = mod if power == 1.0 else np.maximum(mod, 0.0) ** float(power) - vmin = float(np.min([refp.min(), initp.min()])) - vmax = float(np.max([refp.max(), initp.max()])) + vmin = float(min(refp.min(), modp.min())) + vmax = float(max(refp.max(), modp.max())) fig, ax = show_2d( - [refp, initp], + [refp, modp], title=["image_ref", "model"], cmap="gray", cbar=False, returnfig=True, axsize=axsize, - norm="linear_minmax", vmin=vmin, vmax=vmax, ) - H, W = int(ref.shape[-2]), int(ref.shape[-1]) - - boundaries: list[float] = [] - for comp in getattr(self.prepared, "components", []): - b = getattr(comp, "boundary_px", None) + H, W = ref.shape[-2], ref.shape[-1] + pad = 0 + boundaries = [] + for c in getattr(self.prepared, "components", []): + b = getattr(c, "boundary_px", None) if b is not None: boundaries.append(float(b)) - - inset = 0 - pad = 0 if boundaries: - pos = [b for b in boundaries if b > 0.0] - neg = [b for b in boundaries if b < 0.0] - if pos: - inset = int(np.ceil(max(pos))) - if neg: - pad = int(np.ceil(-min(neg))) + min_b = float(np.min(boundaries)) + if min_b < 0.0: + pad = int(np.ceil(-min_b)) + axes: list[Any] if isinstance(ax, np.ndarray): axes = list(ax.ravel()) elif isinstance(ax, (list, tuple)): @@ -251,21 +554,16 @@ def plot_mean_model( else: axes = [ax] - x0 = (-pad + inset) - x1 = (W - 1) + pad - inset - y0 = (-pad + inset) - y1 = (H - 1) + pad - inset - for a in axes[:2]: - a.set_xlim(x0, x1) - a.set_ylim(y1, y0) + a.set_xlim(-pad, (W - 1) + pad) + a.set_ylim((H - 1) + pad, -pad) if show_overlays: - overlays = self.prepared.overlays() + ovs = self.prepared.overlays(self.x_mean) if len(axes) >= 1: - self._apply_overlays(axes[0], overlays) + self._apply_overlays(axes[0], ovs) if len(axes) >= 2: - self._apply_overlays(axes[1], overlays) + self._apply_overlays(axes[1], ovs) if returnfig: return fig, ax From 8cc81321491093ce7a19b11275f320923d7a73f7 Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 16 Feb 2026 20:18:27 -0800 Subject: [PATCH 22/88] adding docstrings --- src/quantem/core/models/background.py | 28 ++++ src/quantem/core/models/base.py | 94 +++++++++++ src/quantem/core/models/diffraction.py | 119 ++++++++++++++ src/quantem/diffraction/model_fitting.py | 191 +++++++++++++++++++++++ 4 files changed, 432 insertions(+) diff --git a/src/quantem/core/models/background.py b/src/quantem/core/models/background.py index d0a9adff..f0231a43 100644 --- a/src/quantem/core/models/background.py +++ b/src/quantem/core/models/background.py @@ -1,5 +1,7 @@ from __future__ import annotations +"""Background components for diffraction model fitting.""" + from dataclasses import dataclass from typing import Any, Iterable @@ -10,6 +12,17 @@ class DCBackground(Component): + """ + Uniform additive background with nonnegative intensity. + + Parameters + ---------- + intensity + Constant background intensity parameter specification. + name + Component name. + """ + def __init__( self, *, @@ -37,6 +50,21 @@ def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: class GaussianBackground(Component): + """ + Radial Gaussian background centered at a named model origin. + + Parameters + ---------- + sigma + Gaussian width parameter specification (constrained to `>= 1e-6`). + intensity + Nonnegative Gaussian amplitude parameter specification. + origin_key + Name of the origin to use from `ModelContext.fields["origins"]`. + name + Component name. + """ + def __init__( self, *, diff --git a/src/quantem/core/models/base.py b/src/quantem/core/models/base.py index 7d453c59..53a4c0aa 100644 --- a/src/quantem/core/models/base.py +++ b/src/quantem/core/models/base.py @@ -1,5 +1,7 @@ from __future__ import annotations +"""Composable model primitives for diffraction-style forward models.""" + from dataclasses import dataclass from typing import Any, Iterable, Mapping, Sequence @@ -9,11 +11,34 @@ @dataclass(frozen=True) class Overlay: + """Lightweight plotting overlay emitted by prepared components.""" + kind: str data: dict[str, Any] class Parameter: + """ + Scalar model parameter with optional bounds and metadata tags. + + Parameters + ---------- + value + Initial parameter specification. Supported forms: + - scalar: `v0` + - 2-sequence: `(v0, dev)` interpreted as bounds `(v0-dev, v0+dev)` + - 3-sequence: `(v0, lb, ub)` where either bound can be None + lower_bound, upper_bound + Optional explicit overrides for lower/upper bound. + tags + Optional metadata dictionary used by higher-level fitting code for + grouping/freezing/selecting parameters. + + Notes + ----- + Parameter indices are assigned during `Model.compile(...)`. + """ + def __init__( self, value: float | Sequence[float], @@ -66,6 +91,22 @@ def index(self) -> int: class ModelContext: + """ + Execution context shared by components during preparation and rendering. + + Parameters + ---------- + H, W + Output image height and width. + device, dtype + Torch device and dtype for parameter tensors/rendering. + mask + Optional per-pixel mask used by fitting loss functions. + fields + Mutable shared dictionary used by components to publish/consume + prepared state (for example origins or template metadata). + """ + def __init__( self, *, @@ -90,6 +131,14 @@ def __getattr__(self, name: str) -> Any: class Component: + """ + Abstract base class for additive model components. + + Subclasses implement: + - `parameters()` to expose fit parameters + - `prepare(ctx)` to build prepared render objects + """ + def __init__(self, *, name: str): self.name = str(name) @@ -101,6 +150,17 @@ def prepare(self, ctx: ModelContext) -> Any: class PreparedModel: + """ + Compiled model bundle with bound parameter vectors and render helpers. + + Attributes + ---------- + x0, lb, ub + Initial parameter vector and bound vectors, all on `ctx.device`. + components + Prepared component objects called in sequence during `render`. + """ + def __init__( self, *, @@ -119,15 +179,18 @@ def __init__( self.ub = ub def render(self, x: torch.Tensor) -> torch.Tensor: + """Render a model image for parameter vector ``x``.""" out = torch.zeros((self.ctx.H, self.ctx.W), device=self.ctx.device, dtype=self.ctx.dtype) for c in self.components: c.render(out, x, self.ctx) return out def render_initial(self) -> torch.Tensor: + """Render the model using the compile-time initial parameter vector.""" return self.render(self.x0) def overlays(self, x: torch.Tensor | None = None) -> list[Overlay]: + """Collect component overlays for plotting/debugging.""" out: list[Overlay] = [] for c in self.components: fn = getattr(c, "overlays", None) @@ -140,10 +203,27 @@ def overlays(self, x: torch.Tensor | None = None) -> list[Overlay]: class Model: + """ + Container for additive components that compiles to a `PreparedModel`. + + Notes + ----- + Component order defines render order and can also define dependency order + when components share prepared state through `ModelContext.fields`. + """ + def __init__(self): self._components: list[Component] = [] def add(self, items: Iterable[Component]) -> "Model": + """ + Append components to the model in render order. + + Parameters + ---------- + items + Iterable of `Component` instances. + """ for obj in items: if not isinstance(obj, Component): raise TypeError("Model.add expects Component objects.") @@ -151,12 +231,26 @@ def add(self, items: Iterable[Component]) -> "Model": return self def parameter_list(self) -> list[Parameter]: + """Return the flattened parameter list across all components.""" params: list[Parameter] = [] for c in self._components: params.extend(c.parameters()) return params def compile(self, ctx: ModelContext) -> PreparedModel: + """ + Bind parameter indices and prepare components for fast rendering. + + Parameters + ---------- + ctx + Model execution context. + + Returns + ------- + PreparedModel + Compiled model with `x0`, bounds, and prepared component state. + """ params = self.parameter_list() for i, p in enumerate(params): p.bind(i) diff --git a/src/quantem/core/models/diffraction.py b/src/quantem/core/models/diffraction.py index 8ecaf729..c324ecca 100644 --- a/src/quantem/core/models/diffraction.py +++ b/src/quantem/core/models/diffraction.py @@ -1,5 +1,7 @@ from __future__ import annotations +"""Diffraction-specific model components and low-level rendering helpers.""" + from dataclasses import dataclass from typing import Any, Iterable, Sequence @@ -10,6 +12,16 @@ def _as_t(x: Any, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + """ + Convert values to tensors on the requested device/dtype. + + Parameters + ---------- + x + Input scalar/array/tensor. + device, dtype + Target torch device and dtype. + """ if torch.is_tensor(x): return x.to(device=device, dtype=dtype) return torch.as_tensor(x, device=device, dtype=dtype) @@ -25,6 +37,22 @@ def _splat_patch( dc: torch.Tensor, scale: torch.Tensor, ) -> None: + """ + Render a shifted patch into ``out`` with bilinear interpolation. + + Parameters + ---------- + out + Destination image `(H, W)` updated in-place. + r0, c0 + Patch center coordinates in output-image row/column space. + patch_vals + Flattened patch intensities. + dr, dc + Flattened local row/column offsets for each patch sample. + scale + Scalar or vector multiplier applied to `patch_vals`. + """ H = out.shape[0] W = out.shape[1] @@ -59,6 +87,16 @@ def put(rr: torch.Tensor, cc: torch.Tensor, ww: torch.Tensor) -> None: def _origin_indices(ctx: ModelContext, origin_key: str) -> tuple[int, int]: + """ + Resolve origin parameter indices from ``ModelContext``. + + Parameters + ---------- + ctx + Model context with an `origins` registry in `ctx.fields`. + origin_key + Origin name to resolve. + """ origins: dict[str, Any] = ctx.fields.get("origins", {}) o = origins.get(origin_key) if o is None or "row_param" not in o or "col_param" not in o: @@ -67,6 +105,19 @@ def _origin_indices(ctx: ModelContext, origin_key: str) -> tuple[int, int]: class Origin2D(Component): + """ + Named 2D origin component that exposes row/column fit parameters. + + Parameters + ---------- + name + Component name. + origin_key + Key used to publish this origin into `ctx.fields["origins"]`. + row, col + Parameter specifications for origin row/column. + """ + def __init__( self, *, @@ -113,6 +164,33 @@ def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: class DiskTemplate(Component): + """ + Template patch component used for central disks and lattice motifs. + + Parameters + ---------- + name + Template name used for registry lookup by dependent components. + array + 2D template image. + refine_all_pixels + If True, every template pixel is exposed as a fit parameter. + place_at_origin + If True, render this template once at the named origin with a + separate nonnegative intensity parameter. + normalize + Optional array normalization mode: `"none"`, `"max"`, `"mean"`. + origin_key + Origin key used when `place_at_origin=True`. + intensity + Intensity parameter specification used when `place_at_origin=True`. + + Notes + ----- + The template is also published into `ctx.fields["disk_templates"]` so + lattice components can reuse its prepared sampling offsets. + """ + def __init__( self, *, @@ -248,6 +326,47 @@ def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: class SyntheticDiskLattice(Component): + """ + Lattice of shifted ``DiskTemplate`` patches around a shared origin. + + Parameters + ---------- + name + Component name. + disk + `DiskTemplate` used as the motif for each lattice spot. + u_row, u_col, v_row, v_col + Basis vector parameter specifications in row/column coordinates. + u_max, v_max + Inclusive lattice index extents. + intensity_0, intensity_row, intensity_col + Intensity parameter specifications. Interpretation depends on + `per_disk_intensity` and `per_disk_slopes`. + per_disk_intensity + If True, allocate independent base intensities per lattice disk. + per_disk_slopes + If True and `per_disk_intensity=True`, also allocate per-disk local + slope parameters for template-local coordinates. + center_intensity_0 + Optional override for the `(u, v) == (0, 0)` disk base intensity. + exclude_indices + Optional set/list of lattice indices to skip. + boundary_px + Keep only lattice centers within `[boundary_px, size-1-boundary_px]`. + origin_key + Origin key used for center placement. + + Notes + ----- + Intensity models: + - shared mode (`per_disk_intensity=False`): + `max(i0 + ir*row_center + ic*col_center, 0)` + - per-disk scalar mode (`per_disk_intensity=True`, `per_disk_slopes=False`): + `max(i0_i, 0)` + - per-disk local affine mode (`per_disk_intensity=True`, `per_disk_slopes=True`): + `max(i0_i + ir_i*dr + ic_i*dc, 0)` where `dr/dc` are template-local offsets. + """ + def __init__( self, *, diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index f382ab27..69dda4ca 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -1,5 +1,7 @@ from __future__ import annotations +"""High-level diffraction model fitting workflow utilities.""" + import warnings from typing import Any, Sequence @@ -20,6 +22,7 @@ def _to_numpy(x: Any) -> np.ndarray: + """Convert arrays/tensors to NumPy arrays.""" if isinstance(x, np.ndarray): return x if torch.is_tensor(x): @@ -28,6 +31,31 @@ def _to_numpy(x: Any) -> np.ndarray: class ModelDiffraction(AutoSerialize): + """ + End-to-end helper for defining and fitting additive diffraction forward models. + + This class wraps a diffraction dataset, builds an average reference image + (`image_ref`), compiles a composable component model, and provides optimization + routines for: + - fitting to the mean reference image, + - fitting selected individual diffraction patterns. + + Features + -------- + - Build a mean reference image with optional stack alignment. + - Define a composable model from origin/background/template/lattice components. + - Refine a mean model with Adam or L-BFGS. + - Fit all or selected patterns with optional progress bars. + - Plot reference/model comparisons and component overlays. + + Typical workflow + ---------------- + >>> md = ModelDiffraction.from_dataset(ds).preprocess().define_model(...) + >>> md.refine_mean_model(...) + >>> md.fit_all_patterns(...) + >>> md.plot_mean_model(...) + """ + _token = object() def __init__(self, dataset: Any, _token: object | None = None): @@ -68,6 +96,19 @@ def _apply_intensity_transform( @classmethod def from_dataset(cls, dataset: Dataset2d | Dataset3d | Dataset4d | Dataset4dstem | Any) -> "ModelDiffraction": + """ + Construct a ModelDiffraction object from a QuantEM dataset container. + + Parameters + ---------- + dataset + Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance. + + Returns + ------- + ModelDiffraction + New model-fitting helper bound to the provided dataset. + """ if isinstance(dataset, (Dataset2d, Dataset3d, Dataset4d, Dataset4dstem)): return cls(dataset=dataset, _token=cls._token) raise TypeError("from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance.") @@ -81,6 +122,34 @@ def preprocess( max_shift: float | None = None, shift_order: int = 1, ) -> "ModelDiffraction": + """ + Precompute the mean reference image used for model fitting. + + Parameters + ---------- + align + If True, align the flattened pattern stack before averaging. + edge_blend + Tukey edge taper width (pixels) used for robust FFT alignment. + upsample_factor + Sub-pixel alignment upsampling factor for cross-correlation shift. + max_shift + Optional maximum shift magnitude during alignment. + shift_order + Interpolation order used when applying shifts to patterns. + + Returns + ------- + ModelDiffraction + Returns self. + + Notes + ----- + - `dataset.array` is interpreted as `(..., H, W)`, where leading dimensions + are flattened into a pattern stack. + - The computed stack-average is stored in `self.image_ref`. + - If `align=False`, preprocessing is a direct mean over stack elements. + """ arr = np.asarray(self.dataset.array) if arr.ndim < 2: raise ValueError("dataset.array must have at least 2 dimensions.") @@ -145,6 +214,43 @@ def define_model( mask: np.ndarray | torch.Tensor | None = None, origin_key: str = "origin", ) -> "ModelDiffraction": + """ + Define and compile a diffraction model against `image_ref`. + + Parameters + ---------- + origin_row, origin_col + Initial origin parameter specification. Supported forms are: + - scalar: fixed initial value with no explicit bounds + - `(value, deviation)`: symmetric bounds `(value - deviation, value + deviation)` + - `(value, lower_bound, upper_bound)`: explicit bounds + components + Sequence of model components (e.g. `DiskTemplate`, backgrounds, lattice). + Components are rendered additively in the provided order. + device + Torch device used for compiled parameters and rendering. + dtype + Torch dtype used for compiled parameters and rendering. + mask + Optional `(H, W)` mask for weighted loss during optimization. + origin_key + Name used to register/retrieve the origin component in context fields. + + Returns + ------- + ModelDiffraction + Returns self with compiled model state. + + Notes + ----- + - If `image_ref` is missing, `preprocess()` is run automatically. + - `Origin2D` is inserted automatically before user components. + - Component dependency ordering still matters for shared context fields: + for example, `DiskTemplate` should appear before `SyntheticDiskLattice` + when the lattice references that template. + - This method resets fit state (`x_defined`, `x_initial`, `x_mean`, + `x_patterns`, and pattern-fit metadata). + """ if self.image_ref is None: self.preprocess() @@ -349,6 +455,43 @@ def refine_mean_model( weak_softplus_scale: float = 1e-3, progress: bool = False, ) -> "ModelDiffraction": + """ + Refine model parameters against the mean reference image. + + Parameters + ---------- + n_steps + Number of optimization steps/iterations for the main phase. + lr + Optimizer learning rate. + method + Optimizer name: `"adam"` or `"lbfgs"`. + power + Optional power-law transform applied to both target and prediction + before computing MSE loss. + fit_disk_pixels + Controls whether `disk_pixel` parameters are trainable. If None, + inferred from model parameters. + fit_only_disk_pixels + If True, freeze all non-disk parameters. + warmup_disk_steps + Optional number of disk-only warmup steps run before the main phase. + overwrite_initial + If True, store refined parameters as new initial parameters for + subsequent per-pattern fitting. + intensity_transform + Optional intensity transform (`"none"` or `"weak_softplus"`) applied + before the power transform and loss evaluation. + weak_softplus_scale + Scale parameter used when `intensity_transform="weak_softplus"`. + progress + If True, show a tqdm progress bar (when tqdm is available). + + Returns + ------- + ModelDiffraction + Returns self with updated `x_mean` (and optionally `x_initial`). + """ method = str(method).lower() if self.image_ref is None: @@ -411,6 +554,35 @@ def fit_all_patterns( weak_softplus_scale: float = 1e-3, progress: bool = False, ) -> "ModelDiffraction": + """ + Fit selected diffraction patterns using the compiled model. + + Parameters + ---------- + indices + Pattern selector. Supported forms include None (all patterns), + integer, slice, tuple indexing, list of linear indices, list of + multi-indices, or boolean mask shaped like scan dimensions. + use_refined_init + If True, initialize per-pattern fitting from `x_initial`. + strict_refined_init + If True, raise when refined init is requested before mean refinement. + If False, emit warning and fall back to defined initialization. + n_steps, lr, method, power, fit_disk_pixels, fit_only_disk_pixels + Optimization settings analogous to `refine_mean_model`. + intensity_transform, weak_softplus_scale + Prediction/target intensity transform options. + progress + If True, show a tqdm progress bar over selected patterns. + + Returns + ------- + ModelDiffraction + Returns self with: + - `x_patterns`: fitted parameter vectors `(n_selected, n_params)` + - `pattern_fit_losses`: per-pattern final losses + - index bookkeeping for selected patterns. + """ method = str(method).lower() if self.prepared is None or self.x_defined is None or self.x_initial is None: @@ -507,6 +679,25 @@ def plot_mean_model( show_overlays: bool = True, axsize: tuple[int, int] = (6, 6), ) -> tuple[Any, Any] | None: + """ + Plot `image_ref` and the current mean-model prediction side-by-side. + + Parameters + ---------- + power + Display transform exponent applied to both reference and model images. + returnfig + If True, return `(fig, ax)` from `show_2d`. + show_overlays + If True, draw component overlays (e.g., origin and lattice markers). + axsize + Base axis size passed to the plotting helper. + + Returns + ------- + tuple[Any, Any] | None + `(fig, ax)` if `returnfig=True`, else None. + """ if self.image_ref is None: self.preprocess() if self.image_ref is None or self.prepared is None: From 7dd327a7b8483a06aae478d1cfd1491633bb3c3c Mon Sep 17 00:00:00 2001 From: cophus Date: Thu, 19 Feb 2026 19:20:44 -0800 Subject: [PATCH 23/88] fixing CoM behaviour --- src/quantem/core/models/diffraction.py | 1 + src/quantem/diffraction/model_fitting.py | 84 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/quantem/core/models/diffraction.py b/src/quantem/core/models/diffraction.py index c324ecca..7b6d00ae 100644 --- a/src/quantem/core/models/diffraction.py +++ b/src/quantem/core/models/diffraction.py @@ -294,6 +294,7 @@ def prepare(self, ctx: ModelContext) -> Any: "dc": dc, "pix_idx": pix_idx, "base": base_vals, + "shape": (int(Ht), int(Wt)), } i_int = None if self.p_intensity is None else self.p_intensity.index diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 69dda4ca..3362d60c 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -7,6 +7,7 @@ import numpy as np import torch +import torch.nn.functional as F from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey @@ -299,6 +300,8 @@ def _fit_target_image( power: float | None, fit_disk_pixels: bool | None, fit_only_disk_pixels: bool, + enforce_disk_max_one: bool, + enforce_disk_center_of_mass: bool, intensity_transform: str, weak_softplus_scale: float, progress: bool = False, @@ -323,6 +326,40 @@ def _fit_target_image( if p.tags.get("role") == "disk_pixel": disk_mask[p.index] = True + # Cache template groups for optional per-step projection constraints. + disk_templates = self.prepared.ctx.fields.get("disk_templates", {}) + disk_param_groups: list[dict[str, Any]] = [] + if enforce_disk_max_one or enforce_disk_center_of_mass: + grouped: dict[str, list[tuple[int, int]]] = {} + for p in self.prepared.params: + if p.tags.get("role") != "disk_pixel": + continue + name = str(p.tags.get("disk")) + i_flat = int(p.tags.get("i")) + grouped.setdefault(name, []).append((i_flat, int(p.index))) + + for name, pairs in grouped.items(): + if name not in disk_templates: + continue + dmeta = disk_templates[name] + shape = dmeta.get("shape", None) + if shape is None: + continue + Ht, Wt = int(shape[0]), int(shape[1]) + order = sorted(pairs, key=lambda t: t[0]) + flat_i = torch.as_tensor([t[0] for t in order], device=ctx.device, dtype=torch.long) + p_idx = torch.as_tensor([t[1] for t in order], device=ctx.device, dtype=torch.long) + dr = dmeta["dr"][flat_i] + dc = dmeta["dc"][flat_i] + disk_param_groups.append( + { + "param_idx": p_idx, + "shape": (Ht, Wt), + "dr": dr, + "dc": dc, + } + ) + if fit_only_disk_pixels: if not fit_disk_pixels: raise ValueError("fit_only_disk_pixels=True requires fit_disk_pixels=True.") @@ -344,6 +381,43 @@ def clamp_inplace() -> None: x.data = torch.max(torch.min(x.data, ub), lb) if torch.any(freeze): x.data[freeze] = x_frozen[freeze] + if (enforce_disk_max_one or enforce_disk_center_of_mass) and disk_param_groups: + eps = torch.as_tensor(1e-12, device=ctx.device, dtype=ctx.dtype) + for g in disk_param_groups: + p_idx = g["param_idx"] + if torch.all(freeze[p_idx]): + continue + vals = x.data[p_idx] + vals = torch.clamp(vals, min=0.0) + + if enforce_disk_center_of_mass: + mass = torch.sum(vals) + if mass > eps: + r_com = torch.sum(vals * g["dr"]) / mass + c_com = torch.sum(vals * g["dc"]) / mass + Ht, Wt = g["shape"] + img = vals.reshape(Ht, Wt)[None, None, :, :] + yy = torch.linspace(-1.0, 1.0, Ht, device=ctx.device, dtype=ctx.dtype) + xx = torch.linspace(-1.0, 1.0, Wt, device=ctx.device, dtype=ctx.dtype) + gy, gx = torch.meshgrid(yy, xx, indexing="ij") + sx = gx + (2.0 * c_com / max(Wt - 1, 1)) + sy = gy + (2.0 * r_com / max(Ht - 1, 1)) + grid = torch.stack((sx, sy), dim=-1)[None, :, :, :] + img_shift = F.grid_sample( + img, + grid, + mode="bilinear", + padding_mode="zeros", + align_corners=True, + ) + vals = torch.clamp(img_shift[0, 0].reshape(-1), min=0.0) + + if enforce_disk_max_one: + vmax = torch.max(vals) + if vmax > eps: + vals = vals / vmax + + x.data[p_idx] = vals def loss_fn() -> torch.Tensor: clamp_inplace() @@ -449,6 +523,8 @@ def refine_mean_model( power: float | None = 1.0, fit_disk_pixels: bool | None = None, fit_only_disk_pixels: bool = False, + enforce_disk_max_one: bool = True, + enforce_disk_center_of_mass: bool = True, warmup_disk_steps: int = 0, overwrite_initial: bool = True, intensity_transform: str = "none", @@ -512,6 +588,8 @@ def refine_mean_model( power=power, fit_disk_pixels=True, fit_only_disk_pixels=True, + enforce_disk_max_one=bool(enforce_disk_max_one), + enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=bool(progress), @@ -526,6 +604,8 @@ def refine_mean_model( power=power, fit_disk_pixels=fit_disk_pixels, fit_only_disk_pixels=bool(fit_only_disk_pixels), + enforce_disk_max_one=bool(enforce_disk_max_one), + enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=bool(progress), @@ -550,6 +630,8 @@ def fit_all_patterns( power: float | None = 1.0, fit_disk_pixels: bool | None = None, fit_only_disk_pixels: bool = False, + enforce_disk_max_one: bool = True, + enforce_disk_center_of_mass: bool = True, intensity_transform: str = "none", weak_softplus_scale: float = 1e-3, progress: bool = False, @@ -640,6 +722,8 @@ def fit_all_patterns( power=power, fit_disk_pixels=fit_disk_pixels, fit_only_disk_pixels=bool(fit_only_disk_pixels), + enforce_disk_max_one=bool(enforce_disk_max_one), + enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=False, From 9bfaa7f02c3ac7f49f85c94494dc764cd909799d Mon Sep 17 00:00:00 2001 From: cophus Date: Fri, 20 Feb 2026 11:45:48 -0800 Subject: [PATCH 24/88] updates --- src/quantem/core/fitting/__init__.py | 29 +++ .../core/{models => fitting}/background.py | 4 +- src/quantem/core/{models => fitting}/base.py | 0 .../core/{models => fitting}/diffraction.py | 152 +++++++++++---- src/quantem/core/models/__init__.py | 29 --- src/quantem/diffraction/model_fitting.py | 182 +++++++++--------- 6 files changed, 242 insertions(+), 154 deletions(-) create mode 100644 src/quantem/core/fitting/__init__.py rename src/quantem/core/{models => fitting}/background.py (95%) rename src/quantem/core/{models => fitting}/base.py (100%) rename src/quantem/core/{models => fitting}/diffraction.py (71%) delete mode 100644 src/quantem/core/models/__init__.py diff --git a/src/quantem/core/fitting/__init__.py b/src/quantem/core/fitting/__init__.py new file mode 100644 index 00000000..5aa67459 --- /dev/null +++ b/src/quantem/core/fitting/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from quantem.core.fitting.base import Component as Component +from quantem.core.fitting.base import Model as Model +from quantem.core.fitting.base import ModelContext as ModelContext +from quantem.core.fitting.base import Overlay as Overlay +from quantem.core.fitting.base import Parameter as Parameter +from quantem.core.fitting.base import PreparedModel as PreparedModel + +from quantem.core.fitting.diffraction import DiskTemplate as DiskTemplate +from quantem.core.fitting.diffraction import Origin2D as Origin2D +from quantem.core.fitting.diffraction import SyntheticDiskLattice as SyntheticDiskLattice + +from quantem.core.fitting.background import DCBackground as DCBackground +from quantem.core.fitting.background import GaussianBackground as GaussianBackground + +__all__ = [ + "Parameter", + "Overlay", + "ModelContext", + "Component", + "Model", + "PreparedModel", + "Origin2D", + "DiskTemplate", + "SyntheticDiskLattice", + "DCBackground", + "GaussianBackground", +] diff --git a/src/quantem/core/models/background.py b/src/quantem/core/fitting/background.py similarity index 95% rename from src/quantem/core/models/background.py rename to src/quantem/core/fitting/background.py index f0231a43..2db88616 100644 --- a/src/quantem/core/models/background.py +++ b/src/quantem/core/fitting/background.py @@ -7,8 +7,8 @@ import torch -from quantem.core.models.base import Component, ModelContext, Overlay, Parameter -from quantem.core.models.diffraction import _origin_indices +from quantem.core.fitting.base import Component, ModelContext, Overlay, Parameter +from quantem.core.fitting.diffraction import _origin_indices class DCBackground(Component): diff --git a/src/quantem/core/models/base.py b/src/quantem/core/fitting/base.py similarity index 100% rename from src/quantem/core/models/base.py rename to src/quantem/core/fitting/base.py diff --git a/src/quantem/core/models/diffraction.py b/src/quantem/core/fitting/diffraction.py similarity index 71% rename from src/quantem/core/models/diffraction.py rename to src/quantem/core/fitting/diffraction.py index 7b6d00ae..78a45c14 100644 --- a/src/quantem/core/models/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -8,7 +8,7 @@ import numpy as np import torch -from quantem.core.models.base import Component, ModelContext, Overlay, Parameter +from quantem.core.fitting.base import Component, ModelContext, Overlay, Parameter def _as_t(x: Any, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor: @@ -342,12 +342,19 @@ class SyntheticDiskLattice(Component): Inclusive lattice index extents. intensity_0, intensity_row, intensity_col Intensity parameter specifications. Interpretation depends on - `per_disk_intensity` and `per_disk_slopes`. + `per_disk_intensity` and intensity-order settings. + intensity_row_row, intensity_col_col, intensity_row_col + Optional quadratic intensity terms for order-2 models. per_disk_intensity If True, allocate independent base intensities per lattice disk. per_disk_slopes - If True and `per_disk_intensity=True`, also allocate per-disk local - slope parameters for template-local coordinates. + Backward-compatible switch controlling whether linear terms are included + when `max_intensity_order` is not explicitly provided. + max_intensity_order + Maximum polynomial order used by this component (`0`, `1`, or `2`). + default_pattern_intensity_order + Default active intensity order used during rendering unless overridden + in `ctx.fields["lattice_intensity_order_override"]`. center_intensity_0 Optional override for the `(u, v) == (0, 0)` disk base intensity. exclude_indices @@ -362,10 +369,12 @@ class SyntheticDiskLattice(Component): Intensity models: - shared mode (`per_disk_intensity=False`): `max(i0 + ir*row_center + ic*col_center, 0)` - - per-disk scalar mode (`per_disk_intensity=True`, `per_disk_slopes=False`): + - per-disk scalar mode (`per_disk_intensity=True`, order=0): `max(i0_i, 0)` - - per-disk local affine mode (`per_disk_intensity=True`, `per_disk_slopes=True`): + - per-disk local affine mode (`per_disk_intensity=True`, order=1): `max(i0_i + ir_i*dr + ic_i*dc, 0)` where `dr/dc` are template-local offsets. + - per-disk local quadratic mode (`per_disk_intensity=True`, order=2): + `max(i0_i + ir_i*dr + ic_i*dc + irr_i*dr^2 + icc_i*dc^2 + irc_i*dr*dc, 0)`. """ def __init__( @@ -382,8 +391,13 @@ def __init__( intensity_0: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, intensity_row: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, intensity_col: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + intensity_row_row: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + intensity_col_col: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + intensity_row_col: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, per_disk_intensity: bool = False, per_disk_slopes: bool = True, + max_intensity_order: int | None = None, + default_pattern_intensity_order: int | None = None, center_intensity_0: float | tuple[float, float] | tuple[float, float, float | None] | None = None, exclude_indices: Iterable[tuple[int, int]] | None = None, boundary_px: float = 0.0, @@ -396,7 +410,18 @@ def __init__( self.boundary_px = float(boundary_px) self.origin_key = str(origin_key) self.per_disk_intensity = bool(per_disk_intensity) - self.per_disk_slopes = bool(per_disk_slopes) + if max_intensity_order is None: + self.max_intensity_order = 1 if bool(per_disk_slopes) else 0 + else: + self.max_intensity_order = int(max_intensity_order) + if self.max_intensity_order < 0 or self.max_intensity_order > 2: + raise ValueError("max_intensity_order must be 0, 1, or 2.") + if default_pattern_intensity_order is None: + self.default_pattern_intensity_order = int(self.max_intensity_order) + else: + self.default_pattern_intensity_order = int(default_pattern_intensity_order) + if self.default_pattern_intensity_order < 0 or self.default_pattern_intensity_order > self.max_intensity_order: + raise ValueError("default_pattern_intensity_order must be in [0, max_intensity_order].") if exclude_indices is None: self.exclude_indices = {(0, 0)} if bool(getattr(disk, "place_at_origin", False)) else set() @@ -421,27 +446,57 @@ def __init__( self.p_i0_list: list[Parameter] = [] self.p_ir_list: list[Parameter] = [] self.p_ic_list: list[Parameter] = [] + self.p_irr_list: list[Parameter] = [] + self.p_icc_list: list[Parameter] = [] + self.p_irc_list: list[Parameter] = [] if self.per_disk_intensity: for u, v in self.uv_indices: i0_val = center_intensity_0 if (center_intensity_0 is not None and (u, v) == (0, 0)) else intensity_0 - self.p_i0_list.append(Parameter(i0_val, lower_bound=0.0, tags={"role": "lat_int0", "u": u, "v": v})) - if self.per_disk_slopes: - self.p_ir_list.append(Parameter(intensity_row, tags={"role": "lat_int_row", "u": u, "v": v})) - self.p_ic_list.append(Parameter(intensity_col, tags={"role": "lat_int_col", "u": u, "v": v})) + self.p_i0_list.append( + Parameter(i0_val, lower_bound=0.0, tags={"role": "lat_int0", "u": u, "v": v, "intensity_order": 0}) + ) + if self.max_intensity_order >= 1: + self.p_ir_list.append( + Parameter(intensity_row, tags={"role": "lat_int_row", "u": u, "v": v, "intensity_order": 1}) + ) + self.p_ic_list.append( + Parameter(intensity_col, tags={"role": "lat_int_col", "u": u, "v": v, "intensity_order": 1}) + ) + if self.max_intensity_order >= 2: + self.p_irr_list.append( + Parameter(intensity_row_row, tags={"role": "lat_int_rr", "u": u, "v": v, "intensity_order": 2}) + ) + self.p_icc_list.append( + Parameter(intensity_col_col, tags={"role": "lat_int_cc", "u": u, "v": v, "intensity_order": 2}) + ) + self.p_irc_list.append( + Parameter(intensity_row_col, tags={"role": "lat_int_rc", "u": u, "v": v, "intensity_order": 2}) + ) else: - self.p_i0 = Parameter(intensity_0, lower_bound=0.0, tags={"role": "lat_int0"}) - self.p_ir = Parameter(intensity_row, tags={"role": "lat_int_row"}) - self.p_ic = Parameter(intensity_col, tags={"role": "lat_int_col"}) + self.p_i0 = Parameter(intensity_0, lower_bound=0.0, tags={"role": "lat_int0", "intensity_order": 0}) + self.p_ir = Parameter(intensity_row, tags={"role": "lat_int_row", "intensity_order": 1}) + self.p_ic = Parameter(intensity_col, tags={"role": "lat_int_col", "intensity_order": 1}) + self.p_irr = Parameter(intensity_row_row, tags={"role": "lat_int_rr", "intensity_order": 2}) + self.p_icc = Parameter(intensity_col_col, tags={"role": "lat_int_cc", "intensity_order": 2}) + self.p_irc = Parameter(intensity_row_col, tags={"role": "lat_int_rc", "intensity_order": 2}) def parameters(self) -> list[Parameter]: out = [self.p_u_row, self.p_u_col, self.p_v_row, self.p_v_col] if self.per_disk_intensity: out.extend(self.p_i0_list) - if self.per_disk_slopes: + if self.max_intensity_order >= 1: out.extend(self.p_ir_list) out.extend(self.p_ic_list) + if self.max_intensity_order >= 2: + out.extend(self.p_irr_list) + out.extend(self.p_icc_list) + out.extend(self.p_irc_list) else: - out.extend([self.p_i0, self.p_ir, self.p_ic]) + out.append(self.p_i0) + if self.max_intensity_order >= 1: + out.extend([self.p_ir, self.p_ic]) + if self.max_intensity_order >= 2: + out.extend([self.p_irr, self.p_icc, self.p_irc]) return out def prepare(self, ctx: ModelContext) -> Any: @@ -461,11 +516,14 @@ def prepare(self, ctx: ModelContext) -> Any: i_vr = self.p_v_row.index i_vc = self.p_v_col.index if self.per_disk_intensity: - i_i0 = i_ir = i_ic = None + i_i0 = i_ir = i_ic = i_irr = i_icc = i_irc = None else: i_i0 = self.p_i0.index - i_ir = self.p_ir.index - i_ic = self.p_ic.index + i_ir = None if self.max_intensity_order < 1 else self.p_ir.index + i_ic = None if self.max_intensity_order < 1 else self.p_ic.index + i_irr = None if self.max_intensity_order < 2 else self.p_irr.index + i_icc = None if self.max_intensity_order < 2 else self.p_icc.index + i_irc = None if self.max_intensity_order < 2 else self.p_irc.index uv = self.uv_indices uv_t = torch.as_tensor(uv, device=ctx.device, dtype=torch.long) if uv else None @@ -474,15 +532,23 @@ def prepare(self, ctx: ModelContext) -> Any: if self.per_disk_intensity: i0_idx = torch.as_tensor([p.index for p in self.p_i0_list], device=ctx.device, dtype=torch.long) - if self.per_disk_slopes: + if self.max_intensity_order >= 1: ir_idx = torch.as_tensor([p.index for p in self.p_ir_list], device=ctx.device, dtype=torch.long) ic_idx = torch.as_tensor([p.index for p in self.p_ic_list], device=ctx.device, dtype=torch.long) else: ir_idx = ic_idx = None + if self.max_intensity_order >= 2: + irr_idx = torch.as_tensor([p.index for p in self.p_irr_list], device=ctx.device, dtype=torch.long) + icc_idx = torch.as_tensor([p.index for p in self.p_icc_list], device=ctx.device, dtype=torch.long) + irc_idx = torch.as_tensor([p.index for p in self.p_irc_list], device=ctx.device, dtype=torch.long) + else: + irr_idx = icc_idx = irc_idx = None else: - i0_idx = ir_idx = ic_idx = None + i0_idx = ir_idx = ic_idx = irr_idx = icc_idx = irc_idx = None per_disk_intensity = self.per_disk_intensity - per_disk_slopes = self.per_disk_slopes + max_intensity_order = self.max_intensity_order + default_pattern_intensity_order = self.default_pattern_intensity_order + comp_name = self.name @dataclass class Prepared: @@ -525,26 +591,46 @@ def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: return vals = self._patch(x) + dr2 = dr * dr + dc2 = dc * dc + drdc = dr * dc + + order_override = ctx.fields.get("lattice_intensity_order_override", None) + if isinstance(order_override, dict): + active_order = int(order_override.get(comp_name, default_pattern_intensity_order)) + elif order_override is None: + active_order = int(default_pattern_intensity_order) + else: + active_order = int(order_override) + active_order = max(0, min(active_order, int(max_intensity_order))) if per_disk_intensity: keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) i0_keep = x[i0_idx[keep_idx]] - if per_disk_slopes: + if active_order >= 1: ir_keep = x[ir_idx[keep_idx]] ic_keep = x[ic_idx[keep_idx]] - for j, (rr0, cc0) in enumerate(zip(centers_r, centers_c)): - inten_local = torch.clamp(i0_keep[j] + ir_keep[j] * dr + ic_keep[j] * dc, min=0.0) - _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten_local) - else: - for j, (rr0, cc0) in enumerate(zip(centers_r, centers_c)): - inten_local = torch.clamp(i0_keep[j], min=0.0) - _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten_local) + if active_order >= 2: + irr_keep = x[irr_idx[keep_idx]] + icc_keep = x[icc_idx[keep_idx]] + irc_keep = x[irc_idx[keep_idx]] + for j, (rr0, cc0) in enumerate(zip(centers_r, centers_c)): + inten_local = i0_keep[j] + if active_order >= 1: + inten_local = inten_local + ir_keep[j] * dr + ic_keep[j] * dc + if active_order >= 2: + inten_local = inten_local + irr_keep[j] * dr2 + icc_keep[j] * dc2 + irc_keep[j] * drdc + inten_local = torch.clamp(inten_local, min=0.0) + _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten_local) else: i0 = x[i_i0] - ir = x[i_ir] - ic = x[i_ic] for rr0, cc0 in zip(centers_r, centers_c): - inten = torch.clamp(i0 + ir * rr0 + ic * cc0, min=0.0) + inten = i0 + if active_order >= 1: + inten = inten + x[i_ir] * rr0 + x[i_ic] * cc0 + if active_order >= 2: + inten = inten + x[i_irr] * rr0 * rr0 + x[i_icc] * cc0 * cc0 + x[i_irc] * rr0 * cc0 + inten = torch.clamp(inten, min=0.0) _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten) def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: diff --git a/src/quantem/core/models/__init__.py b/src/quantem/core/models/__init__.py deleted file mode 100644 index 7330baff..00000000 --- a/src/quantem/core/models/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from quantem.core.models.base import Component as Component -from quantem.core.models.base import Model as Model -from quantem.core.models.base import ModelContext as ModelContext -from quantem.core.models.base import Overlay as Overlay -from quantem.core.models.base import Parameter as Parameter -from quantem.core.models.base import PreparedModel as PreparedModel - -from quantem.core.models.diffraction import DiskTemplate as DiskTemplate -from quantem.core.models.diffraction import Origin2D as Origin2D -from quantem.core.models.diffraction import SyntheticDiskLattice as SyntheticDiskLattice - -from quantem.core.models.background import DCBackground as DCBackground -from quantem.core.models.background import GaussianBackground as GaussianBackground - -__all__ = [ - "Parameter", - "Overlay", - "ModelContext", - "Component", - "Model", - "PreparedModel", - "Origin2D", - "DiskTemplate", - "SyntheticDiskLattice", - "DCBackground", - "GaussianBackground", -] diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 3362d60c..5ed35999 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -7,7 +7,6 @@ import numpy as np import torch -import torch.nn.functional as F from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey @@ -16,8 +15,8 @@ from quantem.core.datastructures.dataset4d import Dataset4d from quantem.core.datastructures.dataset4dstem import Dataset4dstem from quantem.core.io.serialize import AutoSerialize -from quantem.core.models.base import Model, ModelContext, Overlay, PreparedModel -from quantem.core.models.diffraction import Origin2D +from quantem.core.fitting.base import Model, ModelContext, Overlay, PreparedModel +from quantem.core.fitting.diffraction import Origin2D from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.core.visualization import show_2d @@ -300,8 +299,9 @@ def _fit_target_image( power: float | None, fit_disk_pixels: bool | None, fit_only_disk_pixels: bool, - enforce_disk_max_one: bool, + intensity_order: int | None, enforce_disk_center_of_mass: bool, + normalize_disk_template_max: bool, intensity_transform: str, weak_softplus_scale: float, progress: bool = False, @@ -325,11 +325,15 @@ def _fit_target_image( for p in self.prepared.params: if p.tags.get("role") == "disk_pixel": disk_mask[p.index] = True + if intensity_order is not None and str(p.tags.get("role", "")).startswith("lat_int"): + p_ord = int(p.tags.get("intensity_order", 0)) + if p_ord > int(intensity_order): + freeze[p.index] = True - # Cache template groups for optional per-step projection constraints. - disk_templates = self.prepared.ctx.fields.get("disk_templates", {}) + # Group disk pixel parameters by disk template for optional projection. disk_param_groups: list[dict[str, Any]] = [] - if enforce_disk_max_one or enforce_disk_center_of_mass: + if enforce_disk_center_of_mass or normalize_disk_template_max: + disk_templates = self.prepared.ctx.fields.get("disk_templates", {}) grouped: dict[str, list[tuple[int, int]]] = {} for p in self.prepared.params: if p.tags.get("role") != "disk_pixel": @@ -337,28 +341,18 @@ def _fit_target_image( name = str(p.tags.get("disk")) i_flat = int(p.tags.get("i")) grouped.setdefault(name, []).append((i_flat, int(p.index))) - for name, pairs in grouped.items(): - if name not in disk_templates: - continue - dmeta = disk_templates[name] - shape = dmeta.get("shape", None) - if shape is None: + dmeta = disk_templates.get(name) + if dmeta is None: continue - Ht, Wt = int(shape[0]), int(shape[1]) order = sorted(pairs, key=lambda t: t[0]) - flat_i = torch.as_tensor([t[0] for t in order], device=ctx.device, dtype=torch.long) p_idx = torch.as_tensor([t[1] for t in order], device=ctx.device, dtype=torch.long) - dr = dmeta["dr"][flat_i] - dc = dmeta["dc"][flat_i] - disk_param_groups.append( - { - "param_idx": p_idx, - "shape": (Ht, Wt), - "dr": dr, - "dc": dc, - } - ) + g: dict[str, Any] = {"param_idx": p_idx} + if enforce_disk_center_of_mass: + flat_i = torch.as_tensor([t[0] for t in order], device=ctx.device, dtype=torch.long) + g["dr"] = dmeta["dr"][flat_i] + g["dc"] = dmeta["dc"][flat_i] + disk_param_groups.append(g) if fit_only_disk_pixels: if not fit_disk_pixels: @@ -369,6 +363,10 @@ def _fit_target_image( freeze[disk_mask] = True x_frozen = x.detach().clone() + old_order_override = ctx.fields.get("lattice_intensity_order_override", None) + if intensity_order is not None: + ctx.fields["lattice_intensity_order_override"] = int(intensity_order) + target_t = target.to(device=ctx.device, dtype=ctx.dtype) target_t = self._apply_intensity_transform( target_t, mode=intensity_transform, weak_softplus_scale=weak_softplus_scale @@ -381,42 +379,33 @@ def clamp_inplace() -> None: x.data = torch.max(torch.min(x.data, ub), lb) if torch.any(freeze): x.data[freeze] = x_frozen[freeze] - if (enforce_disk_max_one or enforce_disk_center_of_mass) and disk_param_groups: + if (enforce_disk_center_of_mass or normalize_disk_template_max) and disk_param_groups: eps = torch.as_tensor(1e-12, device=ctx.device, dtype=ctx.dtype) for g in disk_param_groups: p_idx = g["param_idx"] if torch.all(freeze[p_idx]): continue - vals = x.data[p_idx] - vals = torch.clamp(vals, min=0.0) - + vals = torch.clamp(x.data[p_idx], min=0.0) if enforce_disk_center_of_mass: - mass = torch.sum(vals) - if mass > eps: - r_com = torch.sum(vals * g["dr"]) / mass - c_com = torch.sum(vals * g["dc"]) / mass - Ht, Wt = g["shape"] - img = vals.reshape(Ht, Wt)[None, None, :, :] - yy = torch.linspace(-1.0, 1.0, Ht, device=ctx.device, dtype=ctx.dtype) - xx = torch.linspace(-1.0, 1.0, Wt, device=ctx.device, dtype=ctx.dtype) - gy, gx = torch.meshgrid(yy, xx, indexing="ij") - sx = gx + (2.0 * c_com / max(Wt - 1, 1)) - sy = gy + (2.0 * r_com / max(Ht - 1, 1)) - grid = torch.stack((sx, sy), dim=-1)[None, :, :, :] - img_shift = F.grid_sample( - img, - grid, - mode="bilinear", - padding_mode="zeros", - align_corners=True, - ) - vals = torch.clamp(img_shift[0, 0].reshape(-1), min=0.0) - - if enforce_disk_max_one: + dr = g["dr"] + dc = g["dc"] + # Project template moments toward zero COM by iterative multiplicative reweighting. + for _ in range(12): + mass = torch.sum(vals) + if mass <= eps: + break + r_com = torch.sum(vals * dr) / mass + c_com = torch.sum(vals * dc) / mass + if torch.abs(r_com) <= 1e-5 and torch.abs(c_com) <= 1e-5: + break + var_r = torch.sum(vals * dr * dr) / mass + eps + var_c = torch.sum(vals * dc * dc) / mass + eps + vals = vals * torch.exp(-(r_com / var_r) * dr - (c_com / var_c) * dc) + vals = torch.clamp(vals, min=0.0) + if normalize_disk_template_max: vmax = torch.max(vals) if vmax > eps: vals = vals / vmax - x.data[p_idx] = vals def loss_fn() -> torch.Tensor: @@ -435,39 +424,46 @@ def loss_fn() -> torch.Tensor: diff = pred - target_t return torch.mean(diff * diff) - if method == "adam": - opt = torch.optim.Adam([x], lr=lr) - step_iter: Any = range(int(n_steps)) - if progress: - try: - from tqdm.auto import trange - - step_iter = trange(int(n_steps), desc=progress_desc or "Refining", leave=False) - except Exception: - warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) - for _ in step_iter: - opt.zero_grad(set_to_none=True) - loss = loss_fn() - loss.backward() - opt.step() + try: + if method == "adam": + opt = torch.optim.Adam([x], lr=lr) + step_iter: Any = range(int(n_steps)) + if progress: + try: + from tqdm.auto import trange + + step_iter = trange(int(n_steps), desc=progress_desc or "Refining", leave=False) + except Exception: + warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) + for _ in step_iter: + opt.zero_grad(set_to_none=True) + loss = loss_fn() + loss.backward() + opt.step() + clamp_inplace() + elif method == "lbfgs": + opt = torch.optim.LBFGS([x], lr=lr, max_iter=int(n_steps), line_search_fn="strong_wolfe") + + def closure() -> torch.Tensor: + opt.zero_grad(set_to_none=True) + loss = loss_fn() + loss.backward() + return loss + + opt.step(closure) clamp_inplace() - elif method == "lbfgs": - opt = torch.optim.LBFGS([x], lr=lr, max_iter=int(n_steps), line_search_fn="strong_wolfe") - - def closure() -> torch.Tensor: - opt.zero_grad(set_to_none=True) - loss = loss_fn() - loss.backward() - return loss - - opt.step(closure) - clamp_inplace() - else: - raise ValueError("method must be one of: 'lbfgs', 'adam'.") + else: + raise ValueError("method must be one of: 'lbfgs', 'adam'.") - with torch.no_grad(): - final_loss = float(loss_fn().detach().cpu()) - return x.detach().clone(), final_loss + with torch.no_grad(): + final_loss = float(loss_fn().detach().cpu()) + return x.detach().clone(), final_loss + finally: + if intensity_order is not None: + if old_order_override is None: + ctx.fields.pop("lattice_intensity_order_override", None) + else: + ctx.fields["lattice_intensity_order_override"] = old_order_override def _resolve_pattern_indices(self, indices: Any, n: int, index_shape: tuple[int, ...]) -> np.ndarray: if indices is None: @@ -523,8 +519,9 @@ def refine_mean_model( power: float | None = 1.0, fit_disk_pixels: bool | None = None, fit_only_disk_pixels: bool = False, - enforce_disk_max_one: bool = True, + intensity_order: int = 0, enforce_disk_center_of_mass: bool = True, + normalize_disk_template_max: bool = True, warmup_disk_steps: int = 0, overwrite_initial: bool = True, intensity_transform: str = "none", @@ -588,8 +585,9 @@ def refine_mean_model( power=power, fit_disk_pixels=True, fit_only_disk_pixels=True, - enforce_disk_max_one=bool(enforce_disk_max_one), + intensity_order=int(intensity_order), enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), + normalize_disk_template_max=bool(normalize_disk_template_max), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=bool(progress), @@ -604,8 +602,9 @@ def refine_mean_model( power=power, fit_disk_pixels=fit_disk_pixels, fit_only_disk_pixels=bool(fit_only_disk_pixels), - enforce_disk_max_one=bool(enforce_disk_max_one), + intensity_order=int(intensity_order), enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), + normalize_disk_template_max=bool(normalize_disk_template_max), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=bool(progress), @@ -630,8 +629,9 @@ def fit_all_patterns( power: float | None = 1.0, fit_disk_pixels: bool | None = None, fit_only_disk_pixels: bool = False, - enforce_disk_max_one: bool = True, + intensity_order: int | None = None, enforce_disk_center_of_mass: bool = True, + normalize_disk_template_max: bool = False, intensity_transform: str = "none", weak_softplus_scale: float = 1e-3, progress: bool = False, @@ -722,8 +722,9 @@ def fit_all_patterns( power=power, fit_disk_pixels=fit_disk_pixels, fit_only_disk_pixels=bool(fit_only_disk_pixels), - enforce_disk_max_one=bool(enforce_disk_max_one), + intensity_order=None if intensity_order is None else int(intensity_order), enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), + normalize_disk_template_max=bool(normalize_disk_template_max), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=False, @@ -830,8 +831,9 @@ def plot_mean_model( axes = [ax] for a in axes[:2]: - a.set_xlim(-pad, (W - 1) + pad) - a.set_ylim((H - 1) + pad, -pad) + # Match imshow's pixel-edge convention so overlay markers land on pixel centers. + a.set_xlim(-0.5 - pad, (W - 0.5) + pad) + a.set_ylim((H - 0.5) + pad, -0.5 - pad) if show_overlays: ovs = self.prepared.overlays(self.x_mean) From 73d12366dfbe50ea2d7f8c27705870175d45a4a2 Mon Sep 17 00:00:00 2001 From: cophus Date: Sun, 22 Feb 2026 09:25:50 -0800 Subject: [PATCH 25/88] slight tweaks --- src/quantem/diffraction/model_fitting.py | 57 +++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 5ed35999..e7728997 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -302,6 +302,8 @@ def _fit_target_image( intensity_order: int | None, enforce_disk_center_of_mass: bool, normalize_disk_template_max: bool, + template_binary_weight: float, + template_binary_power: float, intensity_transform: str, weak_softplus_scale: float, progress: bool = False, @@ -332,7 +334,11 @@ def _fit_target_image( # Group disk pixel parameters by disk template for optional projection. disk_param_groups: list[dict[str, Any]] = [] - if enforce_disk_center_of_mass or normalize_disk_template_max: + if ( + enforce_disk_center_of_mass + or normalize_disk_template_max + or float(template_binary_weight) > 0.0 + ): disk_templates = self.prepared.ctx.fields.get("disk_templates", {}) grouped: dict[str, list[tuple[int, int]]] = {} for p in self.prepared.params: @@ -348,6 +354,9 @@ def _fit_target_image( order = sorted(pairs, key=lambda t: t[0]) p_idx = torch.as_tensor([t[1] for t in order], device=ctx.device, dtype=torch.long) g: dict[str, Any] = {"param_idx": p_idx} + shape = dmeta.get("shape", None) + if shape is not None: + g["shape"] = (int(shape[0]), int(shape[1])) if enforce_disk_center_of_mass: flat_i = torch.as_tensor([t[0] for t in order], device=ctx.device, dtype=torch.long) g["dr"] = dmeta["dr"][flat_i] @@ -379,7 +388,10 @@ def clamp_inplace() -> None: x.data = torch.max(torch.min(x.data, ub), lb) if torch.any(freeze): x.data[freeze] = x_frozen[freeze] - if (enforce_disk_center_of_mass or normalize_disk_template_max) and disk_param_groups: + if ( + enforce_disk_center_of_mass + or normalize_disk_template_max + ) and disk_param_groups: eps = torch.as_tensor(1e-12, device=ctx.device, dtype=ctx.dtype) for g in disk_param_groups: p_idx = g["param_idx"] @@ -420,9 +432,32 @@ def loss_fn() -> torch.Tensor: m = ctx.mask diff = (pred - target_t) * m denom = torch.clamp(torch.sum(m), min=1.0) - return torch.sum(diff * diff) / denom - diff = pred - target_t - return torch.mean(diff * diff) + loss = torch.sum(diff * diff) / denom + else: + diff = pred - target_t + loss = torch.mean(diff * diff) + + if float(template_binary_weight) > 0.0 and disk_param_groups: + bp = torch.as_tensor(0.0, device=ctx.device, dtype=ctx.dtype) + n_bp = 0 + eps = torch.as_tensor(1e-12, device=ctx.device, dtype=ctx.dtype) + pwr = float(template_binary_power) + for g in disk_param_groups: + p_idx = g["param_idx"] + vals = torch.clamp(x[p_idx], min=0.0) + vmax = torch.max(vals) + if vmax <= eps: + continue + vals_n = vals / (vmax + eps) + core = vals_n * (1.0 - vals_n) + if pwr != 1.0: + core = torch.pow(core + eps, pwr) + bp = bp + torch.mean(core) + n_bp += 1 + if n_bp > 0: + loss = loss + float(template_binary_weight) * (bp / n_bp) + + return loss try: if method == "adam": @@ -521,7 +556,9 @@ def refine_mean_model( fit_only_disk_pixels: bool = False, intensity_order: int = 0, enforce_disk_center_of_mass: bool = True, - normalize_disk_template_max: bool = True, + normalize_disk_template_max: bool = False, + template_binary_weight: float = 0.0, + template_binary_power: float = 1.0, warmup_disk_steps: int = 0, overwrite_initial: bool = True, intensity_transform: str = "none", @@ -588,6 +625,8 @@ def refine_mean_model( intensity_order=int(intensity_order), enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), normalize_disk_template_max=bool(normalize_disk_template_max), + template_binary_weight=float(template_binary_weight), + template_binary_power=float(template_binary_power), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=bool(progress), @@ -605,6 +644,8 @@ def refine_mean_model( intensity_order=int(intensity_order), enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), normalize_disk_template_max=bool(normalize_disk_template_max), + template_binary_weight=float(template_binary_weight), + template_binary_power=float(template_binary_power), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=bool(progress), @@ -632,6 +673,8 @@ def fit_all_patterns( intensity_order: int | None = None, enforce_disk_center_of_mass: bool = True, normalize_disk_template_max: bool = False, + template_binary_weight: float = 0.0, + template_binary_power: float = 1.0, intensity_transform: str = "none", weak_softplus_scale: float = 1e-3, progress: bool = False, @@ -725,6 +768,8 @@ def fit_all_patterns( intensity_order=None if intensity_order is None else int(intensity_order), enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), normalize_disk_template_max=bool(normalize_disk_template_max), + template_binary_weight=float(template_binary_weight), + template_binary_power=float(template_binary_power), intensity_transform=intensity_transform, weak_softplus_scale=float(weak_softplus_scale), progress=False, From 57563fbadb986e71cdd1d5e86f4269c302802eab Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Tue, 24 Feb 2026 16:00:11 -0800 Subject: [PATCH 26/88] dataset4dstem.from_file consistent with read_4dstem args --- src/quantem/core/datastructures/dataset4dstem.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/quantem/core/datastructures/dataset4dstem.py b/src/quantem/core/datastructures/dataset4dstem.py index dd92b914..ed66f6a2 100644 --- a/src/quantem/core/datastructures/dataset4dstem.py +++ b/src/quantem/core/datastructures/dataset4dstem.py @@ -1,3 +1,4 @@ +from os import PathLike from typing import Any, Self import matplotlib.pyplot as plt @@ -8,7 +9,6 @@ from quantem.core.datastructures.dataset2d import Dataset2d from quantem.core.datastructures.dataset4d import Dataset4d from quantem.core.datastructures.polar4dstem import dataset4dstem_polar_transform - from quantem.core.utils.validators import ensure_valid_array from quantem.core.visualization import show_2d from quantem.core.visualization.visualization_utils import ScalebarConfig @@ -74,7 +74,7 @@ def __init__( _token : object | None, optional Token to prevent direct instantiation, by default None """ - mdata_keys_4dstem = ["q_to_r_rotation_ccw_deg", 'q_transpose', "ellipticity"] + mdata_keys_4dstem = ["q_to_r_rotation_ccw_deg", "q_transpose", "ellipticity"] for k in mdata_keys_4dstem: if k not in metadata.keys(): metadata[k] = None @@ -93,13 +93,13 @@ def __init__( self._virtual_detectors = {} # Store detector information for regeneration @classmethod - def from_file(cls, file_path: str, file_type: str) -> "Dataset4dstem": + def from_file(cls, file_path: str | PathLike, file_type: str | None = None) -> "Dataset4dstem": """ Create a new Dataset4dstem from a file. Parameters ---------- - file_path : str + file_path : str | PathLike Path to the data file file_type : str The type of file reader needed. See rosettasciio for supported formats @@ -754,5 +754,4 @@ def median_filter_masked_pixels(self, mask: np.ndarray, kernel_width: int = 3): self.array[:, :, x_min:x_max, y_min:y_max], axis=(2, 3) ) - - polar_transform = dataset4dstem_polar_transform \ No newline at end of file + polar_transform = dataset4dstem_polar_transform From b2f5253165eab2aa6c67c785434bb4b2ad1fc4b8 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Thu, 26 Feb 2026 14:08:41 -0800 Subject: [PATCH 27/88] refactor in progress, base and rendering largely done, working not perfect --- src/quantem/core/fitting/__init__.py | 6 - src/quantem/core/fitting/background.py | 146 ++-- src/quantem/core/fitting/base.py | 293 +------ src/quantem/core/fitting/diffraction.py | 774 ++++++------------ src/quantem/diffraction/model_fitting.py | 994 ++++++----------------- 5 files changed, 605 insertions(+), 1608 deletions(-) diff --git a/src/quantem/core/fitting/__init__.py b/src/quantem/core/fitting/__init__.py index 5aa67459..f7d3c3e3 100644 --- a/src/quantem/core/fitting/__init__.py +++ b/src/quantem/core/fitting/__init__.py @@ -3,12 +3,9 @@ from quantem.core.fitting.base import Component as Component from quantem.core.fitting.base import Model as Model from quantem.core.fitting.base import ModelContext as ModelContext -from quantem.core.fitting.base import Overlay as Overlay from quantem.core.fitting.base import Parameter as Parameter -from quantem.core.fitting.base import PreparedModel as PreparedModel from quantem.core.fitting.diffraction import DiskTemplate as DiskTemplate -from quantem.core.fitting.diffraction import Origin2D as Origin2D from quantem.core.fitting.diffraction import SyntheticDiskLattice as SyntheticDiskLattice from quantem.core.fitting.background import DCBackground as DCBackground @@ -16,12 +13,9 @@ __all__ = [ "Parameter", - "Overlay", "ModelContext", "Component", "Model", - "PreparedModel", - "Origin2D", "DiskTemplate", "SyntheticDiskLattice", "DCBackground", diff --git a/src/quantem/core/fitting/background.py b/src/quantem/core/fitting/background.py index 2db88616..7c50ea42 100644 --- a/src/quantem/core/fitting/background.py +++ b/src/quantem/core/fitting/background.py @@ -1,107 +1,87 @@ from __future__ import annotations -"""Background components for diffraction model fitting.""" - -from dataclasses import dataclass -from typing import Any, Iterable +from typing import Sequence, cast +import numpy as np import torch +from torch import nn + +from quantem.core.fitting.base import RenderComponent, RenderContext +from quantem.core.fitting.diffraction import OriginND + -from quantem.core.fitting.base import Component, ModelContext, Overlay, Parameter -from quantem.core.fitting.diffraction import _origin_indices +def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: + if isinstance(value, (list, tuple, np.ndarray)): + if len(value) == 0: + raise ValueError(f"{name} cannot be empty.") + if value[0] is None: + raise ValueError(f"{name} initial value cannot be None.") + return float(value[0]) + return float(cast(float | int, value)) -class DCBackground(Component): - """ - Uniform additive background with nonnegative intensity. +def _softplus_pos(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: + return torch.nn.functional.softplus(x) + eps - Parameters - ---------- - intensity - Constant background intensity parameter specification. - name - Component name. - """ +def _relu_pos(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: + return torch.nn.functional.relu(x) + eps + + +class DCBackground(RenderComponent): def __init__( self, *, - intensity: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + intensity: float | int | Sequence[float | int | None] = 0.0, name: str = "dc_background", ): - super().__init__(name=name) - self.p_intensity = Parameter(intensity, lower_bound=0.0, tags={"role": "dc_bg"}) - - def parameters(self) -> list[Parameter]: - return [self.p_intensity] - - def prepare(self, ctx: ModelContext) -> Any: - idx = self.p_intensity.index - - @dataclass - class Prepared: - def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - out.add_(x[idx]) - - def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: - return [] - - return Prepared() - + super().__init__() + self.name = str(name) + self.intensity_raw = nn.Parameter( + torch.tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) + ) -class GaussianBackground(Component): - """ - Radial Gaussian background centered at a named model origin. + def forward(self, ctx: RenderContext) -> torch.Tensor: + inten = _relu_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + # inten = _softplus_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + return torch.ones(ctx.shape, device=ctx.device, dtype=ctx.dtype) * inten - Parameters - ---------- - sigma - Gaussian width parameter specification (constrained to `>= 1e-6`). - intensity - Nonnegative Gaussian amplitude parameter specification. - origin_key - Name of the origin to use from `ModelContext.fields["origins"]`. - name - Component name. - """ +class GaussianBackground(RenderComponent): def __init__( self, *, - sigma: float | tuple[float, float] | tuple[float, float, float | None] = (40.0, 5.0, None), - intensity: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + sigma: float | int | Sequence[float | int | None] = (40.0, 5.0, None), + intensity: float | int | Sequence[float | int | None] = 0.0, + origin: OriginND | None = None, origin_key: str = "origin", name: str = "gaussian_background", ): - super().__init__(name=name) + super().__init__() + self.name = str(name) + self.origin = origin self.origin_key = str(origin_key) - self.p_sigma = Parameter(sigma, lower_bound=1e-6, upper_bound=None, tags={"role": "gauss_sigma"}) - self.p_intensity = Parameter(intensity, lower_bound=0.0, tags={"role": "gauss_int"}) - - def parameters(self) -> list[Parameter]: - return [self.p_sigma, self.p_intensity] - - def prepare(self, ctx: ModelContext) -> Any: - i_sig = self.p_sigma.index - i_int = self.p_intensity.index - r_idx, c_idx = _origin_indices(ctx, self.origin_key) - - rr = torch.arange(ctx.H, device=ctx.device, dtype=ctx.dtype)[:, None] - cc = torch.arange(ctx.W, device=ctx.device, dtype=ctx.dtype)[None, :] - - @dataclass - class Prepared: - def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - sig = torch.clamp(x[i_sig], min=1e-6) - inten = x[i_int] - r0 = x[r_idx] - c0 = x[c_idx] - dr = rr - r0 - dc = cc - c0 - r2 = dr * dr + dc * dc - out.add_(inten * torch.exp(-0.5 * r2 / (sig * sig))) - - def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: - return [] - - return Prepared() + self.sigma_raw = nn.Parameter( + torch.tensor(_parse_init(sigma, name="sigma"), dtype=torch.float32) + ) + self.intensity_raw = nn.Parameter( + torch.tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) + ) + + def set_origin(self, origin: OriginND) -> None: + self.origin = origin + + def forward(self, ctx: RenderContext) -> torch.Tensor: + if self.origin is None: + raise RuntimeError("GaussianBackground requires an OriginND instance.") + + rr = torch.arange(ctx.shape[0], device=ctx.device, dtype=ctx.dtype)[:, None] + cc = torch.arange(ctx.shape[1], device=ctx.device, dtype=ctx.dtype)[None, :] + r0, c0 = self.origin.coords[0], self.origin.coords[1] + + sigma = _relu_pos(self.sigma_raw.to(device=ctx.device, dtype=ctx.dtype), eps=1e-6) + # sigma = _softplus_pos(self.sigma_raw.to(device=ctx.device, dtype=ctx.dtype), eps=1e-6) + inten = _relu_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + # inten = _softplus_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + r2 = (rr - r0) ** 2 + (cc - c0) ** 2 + return inten * torch.exp(-0.5 * r2 / (sigma * sigma)) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 53a4c0aa..c37ccef9 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -1,273 +1,54 @@ from __future__ import annotations -"""Composable model primitives for diffraction-style forward models.""" +from dataclasses import dataclass, field +from typing import Any, cast -from dataclasses import dataclass -from typing import Any, Iterable, Mapping, Sequence - -import numpy as np import torch +from torch import nn -@dataclass(frozen=True) -class Overlay: - """Lightweight plotting overlay emitted by prepared components.""" - - kind: str - data: dict[str, Any] - - -class Parameter: - """ - Scalar model parameter with optional bounds and metadata tags. - - Parameters - ---------- - value - Initial parameter specification. Supported forms: - - scalar: `v0` - - 2-sequence: `(v0, dev)` interpreted as bounds `(v0-dev, v0+dev)` - - 3-sequence: `(v0, lb, ub)` where either bound can be None - lower_bound, upper_bound - Optional explicit overrides for lower/upper bound. - tags - Optional metadata dictionary used by higher-level fitting code for - grouping/freezing/selecting parameters. - - Notes - ----- - Parameter indices are assigned during `Model.compile(...)`. - """ - - def __init__( - self, - value: float | Sequence[float], - *, - lower_bound: float | None = None, - upper_bound: float | None = None, - tags: Mapping[str, Any] | None = None, - ): - self.tags: dict[str, Any] = {} if tags is None else dict(tags) - self.initial_value, self.lower_bound, self.upper_bound = self._parse_value( - value, lower_bound, upper_bound - ) - self._index: int | None = None - - @staticmethod - def _parse_value( - value: float | Sequence[float], - lower_bound: float | None, - upper_bound: float | None, - ) -> tuple[float, float | None, float | None]: - if isinstance(value, (list, tuple, np.ndarray)): - v = list(value) - if len(v) == 2: - v0 = float(v[0]) - dev = float(v[1]) - lb = v0 - dev if lower_bound is None else float(lower_bound) - ub = v0 + dev if upper_bound is None else float(upper_bound) - return v0, lb, ub - if len(v) == 3: - v0 = float(v[0]) - lb = None if v[1] is None else float(v[1]) - ub = None if v[2] is None else float(v[2]) - if lower_bound is not None: - lb = float(lower_bound) - if upper_bound is not None: - ub = float(upper_bound) - return v0, lb, ub - raise ValueError("Parameter sequences must have length 2 or 3.") - v0 = float(value) - return v0, lower_bound, upper_bound - - def bind(self, index: int) -> None: - self._index = int(index) - - @property - def index(self) -> int: - if self._index is None: - raise RuntimeError("Parameter is not bound. Call Model.compile(...) first.") - return self._index - - -class ModelContext: - """ - Execution context shared by components during preparation and rendering. - - Parameters - ---------- - H, W - Output image height and width. - device, dtype - Torch device and dtype for parameter tensors/rendering. - mask - Optional per-pixel mask used by fitting loss functions. - fields - Mutable shared dictionary used by components to publish/consume - prepared state (for example origins or template metadata). - """ - - def __init__( - self, - *, - H: int, - W: int, - device: torch.device, - dtype: torch.dtype, - mask: torch.Tensor | None = None, - fields: Mapping[str, Any] | None = None, - ): - self.H = int(H) - self.W = int(W) - self.device = device - self.dtype = dtype - self.mask = mask - self.fields: dict[str, Any] = {} if fields is None else dict(fields) - - def __getattr__(self, name: str) -> Any: - if name in self.fields: - return self.fields[name] - raise AttributeError(name) - - -class Component: - """ - Abstract base class for additive model components. +@dataclass +class RenderContext: + shape: tuple[int, ...] + device: torch.device + dtype: torch.dtype + mask: torch.Tensor | None = None + fields: dict[str, Any] = field(default_factory=dict) - Subclasses implement: - - `parameters()` to expose fit parameters - - `prepare(ctx)` to build prepared render objects - """ - def __init__(self, *, name: str): - self.name = str(name) - - def parameters(self) -> list[Parameter]: - return [] - - def prepare(self, ctx: ModelContext) -> Any: +class RenderComponent(nn.Module): + def forward(self, ctx: RenderContext) -> torch.Tensor: raise NotImplementedError + def constraint_loss(self, ctx: RenderContext) -> torch.Tensor: + return torch.zeros((), device=ctx.device, dtype=ctx.dtype) -class PreparedModel: - """ - Compiled model bundle with bound parameter vectors and render helpers. - - Attributes - ---------- - x0, lb, ub - Initial parameter vector and bound vectors, all on `ctx.device`. - components - Prepared component objects called in sequence during `render`. - """ - def __init__( - self, - *, - components: list[Any], - ctx: ModelContext, - params: list[Parameter], - x0: torch.Tensor, - lb: torch.Tensor, - ub: torch.Tensor, - ): - self.components = components - self.ctx = ctx - self.params = params - self.x0 = x0 - self.lb = lb - self.ub = ub +class AdditiveRenderModel(nn.Module): + def __init__(self, *, origin: nn.Module, components: list[RenderComponent]): + super().__init__() + self.origin = origin + self.components = nn.ModuleList(components) - def render(self, x: torch.Tensor) -> torch.Tensor: - """Render a model image for parameter vector ``x``.""" - out = torch.zeros((self.ctx.H, self.ctx.W), device=self.ctx.device, dtype=self.ctx.dtype) - for c in self.components: - c.render(out, x, self.ctx) + def forward(self, ctx: RenderContext) -> torch.Tensor: + if len(self.components) == 0: + return torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) + first = cast(RenderComponent, self.components[0]) + out = first(ctx) + for module in self.components[1:]: + component = cast(RenderComponent, module) + out = out + component(ctx) return out - def render_initial(self) -> torch.Tensor: - """Render the model using the compile-time initial parameter vector.""" - return self.render(self.x0) - - def overlays(self, x: torch.Tensor | None = None) -> list[Overlay]: - """Collect component overlays for plotting/debugging.""" - out: list[Overlay] = [] - for c in self.components: - fn = getattr(c, "overlays", None) - if fn is None: - continue - ov = fn(self.x0 if x is None else x, self.ctx) - if ov: - out.extend(list(ov)) - return out - - -class Model: - """ - Container for additive components that compiles to a `PreparedModel`. - - Notes - ----- - Component order defines render order and can also define dependency order - when components share prepared state through `ModelContext.fields`. - """ - - def __init__(self): - self._components: list[Component] = [] - - def add(self, items: Iterable[Component]) -> "Model": - """ - Append components to the model in render order. - - Parameters - ---------- - items - Iterable of `Component` instances. - """ - for obj in items: - if not isinstance(obj, Component): - raise TypeError("Model.add expects Component objects.") - self._components.append(obj) - return self - - def parameter_list(self) -> list[Parameter]: - """Return the flattened parameter list across all components.""" - params: list[Parameter] = [] - for c in self._components: - params.extend(c.parameters()) - return params - - def compile(self, ctx: ModelContext) -> PreparedModel: - """ - Bind parameter indices and prepare components for fast rendering. - - Parameters - ---------- - ctx - Model execution context. - - Returns - ------- - PreparedModel - Compiled model with `x0`, bounds, and prepared component state. - """ - params = self.parameter_list() - for i, p in enumerate(params): - p.bind(i) - - x0 = torch.zeros((len(params),), device=ctx.device, dtype=ctx.dtype) - lb = torch.full((len(params),), -torch.inf, device=ctx.device, dtype=ctx.dtype) - ub = torch.full((len(params),), torch.inf, device=ctx.device, dtype=ctx.dtype) - - for p in params: - x0[p.index] = torch.as_tensor(p.initial_value, device=ctx.device, dtype=ctx.dtype) - if p.lower_bound is not None: - lb[p.index] = torch.as_tensor(p.lower_bound, device=ctx.device, dtype=ctx.dtype) - if p.upper_bound is not None: - ub[p.index] = torch.as_tensor(p.upper_bound, device=ctx.device, dtype=ctx.dtype) + def total_constraint_loss(self, ctx: RenderContext) -> torch.Tensor: + loss = torch.zeros((), device=ctx.device, dtype=ctx.dtype) + for module in self.components: + component = cast(RenderComponent, module) + loss = loss + component.constraint_loss(ctx) + return loss - prepared_components: list[Any] = [] - for c in self._components: - prepared_components.append(c.prepare(ctx)) - return PreparedModel(components=prepared_components, ctx=ctx, params=params, x0=x0, lb=lb, ub=ub) +Component = RenderComponent +ModelContext = RenderContext +Model = AdditiveRenderModel +Parameter = nn.Parameter diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 78a45c14..13224632 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -1,30 +1,30 @@ from __future__ import annotations -"""Diffraction-specific model components and low-level rendering helpers.""" - -from dataclasses import dataclass -from typing import Any, Iterable, Sequence +from typing import Iterable, Sequence, cast import numpy as np import torch +from torch import nn + +from quantem.core.fitting.base import RenderComponent, RenderContext + -from quantem.core.fitting.base import Component, ModelContext, Overlay, Parameter +def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: + if isinstance(value, (list, tuple, np.ndarray)): + if len(value) == 0: + raise ValueError(f"{name} cannot be empty.") + if value[0] is None: + raise ValueError(f"{name} initial value cannot be None.") + return float(value[0]) + return float(cast(float | int, value)) -def _as_t(x: Any, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor: - """ - Convert values to tensors on the requested device/dtype. +def _softplus_pos(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: + return torch.nn.functional.softplus(x) + eps - Parameters - ---------- - x - Input scalar/array/tensor. - device, dtype - Target torch device and dtype. - """ - if torch.is_tensor(x): - return x.to(device=device, dtype=dtype) - return torch.as_tensor(x, device=device, dtype=dtype) + +def _relu_pos(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: + return torch.nn.functional.relu(x) + eps def _splat_patch( @@ -37,34 +37,14 @@ def _splat_patch( dc: torch.Tensor, scale: torch.Tensor, ) -> None: - """ - Render a shifted patch into ``out`` with bilinear interpolation. - - Parameters - ---------- - out - Destination image `(H, W)` updated in-place. - r0, c0 - Patch center coordinates in output-image row/column space. - patch_vals - Flattened patch intensities. - dr, dc - Flattened local row/column offsets for each patch sample. - scale - Scalar or vector multiplier applied to `patch_vals`. - """ - H = out.shape[0] - W = out.shape[1] - + h, w = out.shape r = r0 + dr c = c0 + dc r_base = torch.floor(r) c_base = torch.floor(c) - fr = r - r_base fc = c - c_base - r0i = r_base.to(torch.long) c0i = c_base.to(torch.long) @@ -72,13 +52,12 @@ def _splat_patch( w01 = (1.0 - fr) * fc w10 = fr * (1.0 - fc) w11 = fr * fc - v = patch_vals * scale def put(rr: torch.Tensor, cc: torch.Tensor, ww: torch.Tensor) -> None: - m = (rr >= 0) & (rr < H) & (cc >= 0) & (cc < W) - if torch.any(m): - out.index_put_((rr[m], cc[m]), (v[m] * ww[m]), accumulate=True) + keep = (rr >= 0) & (rr < h) & (cc >= 0) & (cc < w) + if torch.any(keep): + out.index_put_((rr[keep], cc[keep]), v[keep] * ww[keep], accumulate=True) put(r0i, c0i, w00) put(r0i, c0i + 1, w01) @@ -86,111 +65,18 @@ def put(rr: torch.Tensor, cc: torch.Tensor, ww: torch.Tensor) -> None: put(r0i + 1, c0i + 1, w11) -def _origin_indices(ctx: ModelContext, origin_key: str) -> tuple[int, int]: - """ - Resolve origin parameter indices from ``ModelContext``. - - Parameters - ---------- - ctx - Model context with an `origins` registry in `ctx.fields`. - origin_key - Origin name to resolve. - """ - origins: dict[str, Any] = ctx.fields.get("origins", {}) - o = origins.get(origin_key) - if o is None or "row_param" not in o or "col_param" not in o: - raise RuntimeError(f"Origin '{origin_key}' not defined. Origin2D must be included first.") - return int(o["row_param"].index), int(o["col_param"].index) - - -class Origin2D(Component): - """ - Named 2D origin component that exposes row/column fit parameters. - - Parameters - ---------- - name - Component name. - origin_key - Key used to publish this origin into `ctx.fields["origins"]`. - row, col - Parameter specifications for origin row/column. - """ +class OriginND(nn.Module): + def __init__(self, *, ndim: int, init: Sequence[float]): + super().__init__() + if int(ndim) <= 0: + raise ValueError("ndim must be >= 1.") + if len(init) != int(ndim): + raise ValueError("init length must match ndim.") + self.ndim = int(ndim) + self.coords = nn.Parameter(torch.as_tensor(init, dtype=torch.float32).reshape(self.ndim)) - def __init__( - self, - *, - name: str = "origin", - origin_key: str = "origin", - row: float | tuple[float, float] | tuple[float, float, float | None] = (0.0, 0.0, None), - col: float | tuple[float, float] | tuple[float, float, float | None] = (0.0, 0.0, None), - ): - super().__init__(name=name) - self.origin_key = str(origin_key) - self.p_row = Parameter(row, tags={"role": "origin_row", "origin_key": self.origin_key}) - self.p_col = Parameter(col, tags={"role": "origin_col", "origin_key": self.origin_key}) - - def parameters(self) -> list[Parameter]: - return [self.p_row, self.p_col] - - def prepare(self, ctx: ModelContext) -> Any: - origins = ctx.fields.setdefault("origins", {}) - origins[self.origin_key] = {"row_param": self.p_row, "col_param": self.p_col} - - i_r = self.p_row.index - i_c = self.p_col.index - - @dataclass - class Prepared: - def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - return - - def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: - return [ - Overlay( - kind="points_rc", - data={ - "r": x[i_r].detach(), - "c": x[i_c].detach(), - "marker": "+", - "s": 100.0, - "color": "dodgerblue", - }, - ) - ] - - return Prepared() - - -class DiskTemplate(Component): - """ - Template patch component used for central disks and lattice motifs. - - Parameters - ---------- - name - Template name used for registry lookup by dependent components. - array - 2D template image. - refine_all_pixels - If True, every template pixel is exposed as a fit parameter. - place_at_origin - If True, render this template once at the named origin with a - separate nonnegative intensity parameter. - normalize - Optional array normalization mode: `"none"`, `"max"`, `"mean"`. - origin_key - Origin key used when `place_at_origin=True`. - intensity - Intensity parameter specification used when `place_at_origin=True`. - - Notes - ----- - The template is also published into `ctx.fields["disk_templates"]` so - lattice components can reuse its prepared sampling offsets. - """ +class DiskTemplate(RenderComponent): def __init__( self, *, @@ -199,48 +85,47 @@ def __init__( refine_all_pixels: bool = False, place_at_origin: bool = False, normalize: str = "none", + origin: OriginND | None = None, origin_key: str = "origin", - intensity: float | tuple[float, float] | tuple[float, float, float | None] = 1.0, + intensity: float | Sequence[float] = 1.0, ): - super().__init__(name=name) + super().__init__() + self.name = str(name) + self.refine_all_pixels = bool(refine_all_pixels) + self.place_at_origin = bool(place_at_origin) + self.origin = origin + self.origin_key = str(origin_key) a = np.asarray(array, dtype=np.float32) if a.ndim != 2: raise ValueError("DiskTemplate.array must be 2D.") - if normalize == "max": - mx = float(np.max(a)) - if mx > 0: - a = a / mx + s = float(np.max(a)) + if s > 0.0: + a = a / s elif normalize == "mean": - m = float(np.mean(a)) - if m != 0: - a = a / m - elif normalize == "none": - pass - else: + s = float(np.mean(a)) + if s != 0.0: + a = a / s + elif normalize != "none": raise ValueError("normalize must be one of: 'none', 'max', 'mean'.") - self.array = a - self.refine_all_pixels = bool(refine_all_pixels) - self.place_at_origin = bool(place_at_origin) - self.origin_key = str(origin_key) - - self.p_pixels: list[Parameter] = [] - if self.refine_all_pixels: - flat = self.array.ravel() - for i, v in enumerate(flat): - self.p_pixels.append( - Parameter( - float(v), - lower_bound=0.0, - tags={"role": "disk_pixel", "disk": self.name, "i": int(i)}, - ) - ) + template = torch.as_tensor(a, dtype=torch.float32) + self.template_raw = nn.Parameter(template.clone(), requires_grad=self.refine_all_pixels) - self.p_intensity: Parameter | None = None if self.place_at_origin: - self.p_intensity = Parameter(intensity, lower_bound=0.0, tags={"role": "disk_intensity", "disk": self.name}) + self.intensity_raw = nn.Parameter( + torch.as_tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) + ) + else: + self.intensity_raw = None + + ht, wt = int(template.shape[0]), int(template.shape[1]) + rr, cc = np.mgrid[0:ht, 0:wt] + rr = rr.astype(np.float32) - (ht - 1) * 0.5 + cc = cc.astype(np.float32) - (wt - 1) * 0.5 + self.register_buffer("dr", torch.as_tensor(rr.ravel(), dtype=torch.float32)) + self.register_buffer("dc", torch.as_tensor(cc.ravel(), dtype=torch.float32)) @classmethod def from_array( @@ -251,8 +136,9 @@ def from_array( refine_all_pixels: bool = False, place_at_origin: bool = False, normalize: str = "none", + origin: OriginND | None = None, origin_key: str = "origin", - intensity: float | tuple[float, float] | tuple[float, float, float | None] = 1.0, + intensity: float | Sequence[float] = 1.0, ) -> "DiskTemplate": return cls( name=name, @@ -260,396 +146,234 @@ def from_array( refine_all_pixels=refine_all_pixels, place_at_origin=place_at_origin, normalize=normalize, + origin=origin, origin_key=origin_key, intensity=intensity, ) - def parameters(self) -> list[Parameter]: - out = list(self.p_pixels) - if self.p_intensity is not None: - out.append(self.p_intensity) - return out - - def prepare(self, ctx: ModelContext) -> Any: - device = ctx.device - dtype = ctx.dtype - - Ht, Wt = self.array.shape - rr, cc = np.mgrid[0:Ht, 0:Wt] - rr = rr.astype(np.float32) - (Ht - 1) * 0.5 - cc = cc.astype(np.float32) - (Wt - 1) * 0.5 - - dr = _as_t(rr.ravel(), device=device, dtype=dtype) - dc = _as_t(cc.ravel(), device=device, dtype=dtype) + def set_origin(self, origin: OriginND) -> None: + self.origin = origin + def patch_values(self) -> torch.Tensor: if self.refine_all_pixels: - pix_idx = torch.as_tensor([p.index for p in self.p_pixels], device=device, dtype=torch.long) - base_vals = None - else: - pix_idx = None - base_vals = _as_t(self.array.ravel(), device=device, dtype=dtype) - - ctx.fields.setdefault("disk_templates", {})[self.name] = { - "dr": dr, - "dc": dc, - "pix_idx": pix_idx, - "base": base_vals, - "shape": (int(Ht), int(Wt)), - } - - i_int = None if self.p_intensity is None else self.p_intensity.index - if i_int is None: - r_idx = c_idx = None - else: - r_idx, c_idx = _origin_indices(ctx, self.origin_key) - - @dataclass - class Prepared: - def patch(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - if pix_idx is None: - vals = base_vals - else: - vals = x[pix_idx] - return vals, dr, dc - - def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - if i_int is None: - return - vals, drl, dcl = self.patch(x) - r0 = x[r_idx] - c0 = x[c_idx] - _splat_patch(out, r0=r0, c0=c0, patch_vals=vals, dr=drl, dc=dcl, scale=x[i_int]) - - def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: - return [] - - return Prepared() - - -class SyntheticDiskLattice(Component): - """ - Lattice of shifted ``DiskTemplate`` patches around a shared origin. - - Parameters - ---------- - name - Component name. - disk - `DiskTemplate` used as the motif for each lattice spot. - u_row, u_col, v_row, v_col - Basis vector parameter specifications in row/column coordinates. - u_max, v_max - Inclusive lattice index extents. - intensity_0, intensity_row, intensity_col - Intensity parameter specifications. Interpretation depends on - `per_disk_intensity` and intensity-order settings. - intensity_row_row, intensity_col_col, intensity_row_col - Optional quadratic intensity terms for order-2 models. - per_disk_intensity - If True, allocate independent base intensities per lattice disk. - per_disk_slopes - Backward-compatible switch controlling whether linear terms are included - when `max_intensity_order` is not explicitly provided. - max_intensity_order - Maximum polynomial order used by this component (`0`, `1`, or `2`). - default_pattern_intensity_order - Default active intensity order used during rendering unless overridden - in `ctx.fields["lattice_intensity_order_override"]`. - center_intensity_0 - Optional override for the `(u, v) == (0, 0)` disk base intensity. - exclude_indices - Optional set/list of lattice indices to skip. - boundary_px - Keep only lattice centers within `[boundary_px, size-1-boundary_px]`. - origin_key - Origin key used for center placement. - - Notes - ----- - Intensity models: - - shared mode (`per_disk_intensity=False`): - `max(i0 + ir*row_center + ic*col_center, 0)` - - per-disk scalar mode (`per_disk_intensity=True`, order=0): - `max(i0_i, 0)` - - per-disk local affine mode (`per_disk_intensity=True`, order=1): - `max(i0_i + ir_i*dr + ic_i*dc, 0)` where `dr/dc` are template-local offsets. - - per-disk local quadratic mode (`per_disk_intensity=True`, order=2): - `max(i0_i + ir_i*dr + ic_i*dc + irr_i*dr^2 + icc_i*dc^2 + irc_i*dr*dc, 0)`. - """ + return _relu_pos(self.template_raw).reshape(-1) + # return _softplus_pos(self.template_raw).reshape(-1) + return self.template_raw.reshape(-1) + + def patch_offsets(self) -> tuple[torch.Tensor, torch.Tensor]: + return cast(torch.Tensor, self.dr), cast(torch.Tensor, self.dc) + + def add_patch( + self, out: torch.Tensor, *, r0: torch.Tensor, c0: torch.Tensor, scale: torch.Tensor + ) -> None: + vals = self.patch_values().to(device=out.device, dtype=out.dtype) + dr = cast(torch.Tensor, self.dr).to(device=out.device, dtype=out.dtype) + dc = cast(torch.Tensor, self.dc).to(device=out.device, dtype=out.dtype) + _splat_patch(out, r0=r0, c0=c0, patch_vals=vals, dr=dr, dc=dc, scale=scale) + + def forward(self, ctx: RenderContext) -> torch.Tensor: + out = torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) + if not self.place_at_origin: + return out + if self.origin is None: + raise RuntimeError( + "DiskTemplate with place_at_origin=True requires an OriginND instance." + ) + r0, c0 = self.origin.coords[0], self.origin.coords[1] + if self.intensity_raw is None: + raise RuntimeError("DiskTemplate intensity parameter is missing.") + scale = _relu_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + # scale = _softplus_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + self.add_patch(out, r0=r0, c0=c0, scale=scale) + return out + +class SyntheticDiskLattice(RenderComponent): def __init__( self, *, name: str, disk: DiskTemplate, - u_row: float | tuple[float, float] | tuple[float, float, float | None], - u_col: float | tuple[float, float] | tuple[float, float, float | None], - v_row: float | tuple[float, float] | tuple[float, float, float | None], - v_col: float | tuple[float, float] | tuple[float, float, float | None], + u_row: float | Sequence[float], + u_col: float | Sequence[float], + v_row: float | Sequence[float], + v_col: float | Sequence[float], u_max: int = 0, v_max: int = 0, - intensity_0: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, - intensity_row: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, - intensity_col: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, - intensity_row_row: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, - intensity_col_col: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, - intensity_row_col: float | tuple[float, float] | tuple[float, float, float | None] = 0.0, + intensity_0: float | Sequence[float] = 0.0, + intensity_row: float | Sequence[float] = 0.0, + intensity_col: float | Sequence[float] = 0.0, + intensity_row_row: float | Sequence[float] = 0.0, + intensity_col_col: float | Sequence[float] = 0.0, + intensity_row_col: float | Sequence[float] = 0.0, per_disk_intensity: bool = False, per_disk_slopes: bool = True, max_intensity_order: int | None = None, default_pattern_intensity_order: int | None = None, - center_intensity_0: float | tuple[float, float] | tuple[float, float, float | None] | None = None, + center_intensity_0: float | Sequence[float] | None = None, exclude_indices: Iterable[tuple[int, int]] | None = None, boundary_px: float = 0.0, + origin: OriginND | None = None, origin_key: str = "origin", ): - super().__init__(name=name) + super().__init__() + self.name = str(name) self.disk = disk + self.origin = origin + self.origin_key = str(origin_key) + self.per_disk_intensity = bool(per_disk_intensity) self.u_max = int(u_max) self.v_max = int(v_max) self.boundary_px = float(boundary_px) - self.origin_key = str(origin_key) - self.per_disk_intensity = bool(per_disk_intensity) + if max_intensity_order is None: - self.max_intensity_order = 1 if bool(per_disk_slopes) else 0 - else: - self.max_intensity_order = int(max_intensity_order) + max_intensity_order = 1 if bool(per_disk_slopes) else 0 + self.max_intensity_order = int(max_intensity_order) if self.max_intensity_order < 0 or self.max_intensity_order > 2: raise ValueError("max_intensity_order must be 0, 1, or 2.") + if default_pattern_intensity_order is None: - self.default_pattern_intensity_order = int(self.max_intensity_order) - else: - self.default_pattern_intensity_order = int(default_pattern_intensity_order) - if self.default_pattern_intensity_order < 0 or self.default_pattern_intensity_order > self.max_intensity_order: - raise ValueError("default_pattern_intensity_order must be in [0, max_intensity_order].") + default_pattern_intensity_order = self.max_intensity_order + self.default_pattern_intensity_order = int(default_pattern_intensity_order) + + self.u_row = nn.Parameter( + torch.tensor(_parse_init(u_row, name="u_row"), dtype=torch.float32) + ) + self.u_col = nn.Parameter( + torch.tensor(_parse_init(u_col, name="u_col"), dtype=torch.float32) + ) + self.v_row = nn.Parameter( + torch.tensor(_parse_init(v_row, name="v_row"), dtype=torch.float32) + ) + self.v_col = nn.Parameter( + torch.tensor(_parse_init(v_col, name="v_col"), dtype=torch.float32) + ) if exclude_indices is None: - self.exclude_indices = {(0, 0)} if bool(getattr(disk, "place_at_origin", False)) else set() + exclude = {(0, 0)} if bool(getattr(disk, "place_at_origin", False)) else set() else: - self.exclude_indices = set(exclude_indices) - + exclude = set(exclude_indices) uv: list[tuple[int, int]] = [] for u in range(-self.u_max, self.u_max + 1): for v in range(-self.v_max, self.v_max + 1): - if (u, v) in self.exclude_indices: - continue - uv.append((u, v)) - self.uv_indices = uv - - self.p_u_row = Parameter(u_row, tags={"role": "lat_u_row"}) - self.p_u_col = Parameter(u_col, tags={"role": "lat_u_col"}) - self.p_v_row = Parameter(v_row, tags={"role": "lat_v_row"}) - self.p_v_col = Parameter(v_col, tags={"role": "lat_v_col"}) - self.p_i0 = None - self.p_ir = None - self.p_ic = None - self.p_i0_list: list[Parameter] = [] - self.p_ir_list: list[Parameter] = [] - self.p_ic_list: list[Parameter] = [] - self.p_irr_list: list[Parameter] = [] - self.p_icc_list: list[Parameter] = [] - self.p_irc_list: list[Parameter] = [] - if self.per_disk_intensity: - for u, v in self.uv_indices: - i0_val = center_intensity_0 if (center_intensity_0 is not None and (u, v) == (0, 0)) else intensity_0 - self.p_i0_list.append( - Parameter(i0_val, lower_bound=0.0, tags={"role": "lat_int0", "u": u, "v": v, "intensity_order": 0}) - ) - if self.max_intensity_order >= 1: - self.p_ir_list.append( - Parameter(intensity_row, tags={"role": "lat_int_row", "u": u, "v": v, "intensity_order": 1}) - ) - self.p_ic_list.append( - Parameter(intensity_col, tags={"role": "lat_int_col", "u": u, "v": v, "intensity_order": 1}) - ) - if self.max_intensity_order >= 2: - self.p_irr_list.append( - Parameter(intensity_row_row, tags={"role": "lat_int_rr", "u": u, "v": v, "intensity_order": 2}) - ) - self.p_icc_list.append( - Parameter(intensity_col_col, tags={"role": "lat_int_cc", "u": u, "v": v, "intensity_order": 2}) - ) - self.p_irc_list.append( - Parameter(intensity_row_col, tags={"role": "lat_int_rc", "u": u, "v": v, "intensity_order": 2}) - ) - else: - self.p_i0 = Parameter(intensity_0, lower_bound=0.0, tags={"role": "lat_int0", "intensity_order": 0}) - self.p_ir = Parameter(intensity_row, tags={"role": "lat_int_row", "intensity_order": 1}) - self.p_ic = Parameter(intensity_col, tags={"role": "lat_int_col", "intensity_order": 1}) - self.p_irr = Parameter(intensity_row_row, tags={"role": "lat_int_rr", "intensity_order": 2}) - self.p_icc = Parameter(intensity_col_col, tags={"role": "lat_int_cc", "intensity_order": 2}) - self.p_irc = Parameter(intensity_row_col, tags={"role": "lat_int_rc", "intensity_order": 2}) - - def parameters(self) -> list[Parameter]: - out = [self.p_u_row, self.p_u_col, self.p_v_row, self.p_v_col] - if self.per_disk_intensity: - out.extend(self.p_i0_list) - if self.max_intensity_order >= 1: - out.extend(self.p_ir_list) - out.extend(self.p_ic_list) - if self.max_intensity_order >= 2: - out.extend(self.p_irr_list) - out.extend(self.p_icc_list) - out.extend(self.p_irc_list) - else: - out.append(self.p_i0) - if self.max_intensity_order >= 1: - out.extend([self.p_ir, self.p_ic]) - if self.max_intensity_order >= 2: - out.extend([self.p_irr, self.p_icc, self.p_irc]) - return out - - def prepare(self, ctx: ModelContext) -> Any: - dt = ctx.fields.get("disk_templates", {}) - if self.disk.name not in dt: - raise RuntimeError("DiskTemplate must be included before SyntheticDiskLattice in components.") - d = dt[self.disk.name] - dr = d["dr"] - dc = d["dc"] - pix_idx = d["pix_idx"] - base_vals = d["base"] - - r_idx, c_idx = _origin_indices(ctx, self.origin_key) - - i_ur = self.p_u_row.index - i_uc = self.p_u_col.index - i_vr = self.p_v_row.index - i_vc = self.p_v_col.index - if self.per_disk_intensity: - i_i0 = i_ir = i_ic = i_irr = i_icc = i_irc = None - else: - i_i0 = self.p_i0.index - i_ir = None if self.max_intensity_order < 1 else self.p_ir.index - i_ic = None if self.max_intensity_order < 1 else self.p_ic.index - i_irr = None if self.max_intensity_order < 2 else self.p_irr.index - i_icc = None if self.max_intensity_order < 2 else self.p_icc.index - i_irc = None if self.max_intensity_order < 2 else self.p_irc.index - - uv = self.uv_indices - uv_t = torch.as_tensor(uv, device=ctx.device, dtype=torch.long) if uv else None - - boundary = torch.as_tensor(self.boundary_px, device=ctx.device, dtype=ctx.dtype) + if (u, v) not in exclude: + uv.append((u, v)) + uv_t = ( + torch.as_tensor(uv, dtype=torch.long) if uv else torch.zeros((0, 2), dtype=torch.long) + ) + self.register_buffer("uv_indices", uv_t) + + n_uv = int(uv_t.shape[0]) + i0_init = _parse_init(intensity_0, name="intensity_0") + i0_center = ( + i0_init + if center_intensity_0 is None + else _parse_init(center_intensity_0, name="center_intensity_0") + ) + ir_init = _parse_init(intensity_row, name="intensity_row") + ic_init = _parse_init(intensity_col, name="intensity_col") + irr_init = _parse_init(intensity_row_row, name="intensity_row_row") + icc_init = _parse_init(intensity_col_col, name="intensity_col_col") + irc_init = _parse_init(intensity_row_col, name="intensity_row_col") if self.per_disk_intensity: - i0_idx = torch.as_tensor([p.index for p in self.p_i0_list], device=ctx.device, dtype=torch.long) + i0_values = torch.full((n_uv,), float(i0_init), dtype=torch.float32) + if n_uv > 0: + center_mask = (uv_t[:, 0] == 0) & (uv_t[:, 1] == 0) + i0_values[center_mask] = float(i0_center) + self.i0_raw = nn.Parameter(i0_values) if self.max_intensity_order >= 1: - ir_idx = torch.as_tensor([p.index for p in self.p_ir_list], device=ctx.device, dtype=torch.long) - ic_idx = torch.as_tensor([p.index for p in self.p_ic_list], device=ctx.device, dtype=torch.long) + self.ir = nn.Parameter(torch.full((n_uv,), float(ir_init), dtype=torch.float32)) + self.ic = nn.Parameter(torch.full((n_uv,), float(ic_init), dtype=torch.float32)) else: - ir_idx = ic_idx = None + self.ir = None + self.ic = None if self.max_intensity_order >= 2: - irr_idx = torch.as_tensor([p.index for p in self.p_irr_list], device=ctx.device, dtype=torch.long) - icc_idx = torch.as_tensor([p.index for p in self.p_icc_list], device=ctx.device, dtype=torch.long) - irc_idx = torch.as_tensor([p.index for p in self.p_irc_list], device=ctx.device, dtype=torch.long) + self.irr = nn.Parameter(torch.full((n_uv,), float(irr_init), dtype=torch.float32)) + self.icc = nn.Parameter(torch.full((n_uv,), float(icc_init), dtype=torch.float32)) + self.irc = nn.Parameter(torch.full((n_uv,), float(irc_init), dtype=torch.float32)) else: - irr_idx = icc_idx = irc_idx = None + self.irr = None + self.icc = None + self.irc = None else: - i0_idx = ir_idx = ic_idx = irr_idx = icc_idx = irc_idx = None - per_disk_intensity = self.per_disk_intensity - max_intensity_order = self.max_intensity_order - default_pattern_intensity_order = self.default_pattern_intensity_order - comp_name = self.name - - @dataclass - class Prepared: - boundary_px: float = float(self.boundary_px) - - def _patch(self, x: torch.Tensor) -> torch.Tensor: - if pix_idx is None: - return base_vals - return x[pix_idx] - - def _centers(self, x: torch.Tensor, ctx: ModelContext) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - if uv_t is None: - z = torch.empty((0,), device=ctx.device, dtype=ctx.dtype) - return z, z, z - - r0 = x[r_idx] - c0 = x[c_idx] - - ur = x[i_ur] - uc = x[i_uc] - vr = x[i_vr] - vc = x[i_vc] - - u = uv_t[:, 0].to(ctx.dtype) - v = uv_t[:, 1].to(ctx.dtype) - - centers_r = r0 + u * ur + v * vr - centers_c = c0 + u * uc + v * vc - - keep = (centers_r >= boundary) & (centers_r <= (ctx.H - 1) - boundary) - keep &= (centers_c >= boundary) & (centers_c <= (ctx.W - 1) - boundary) - return centers_r[keep], centers_c[keep], keep - - def render(self, out: torch.Tensor, x: torch.Tensor, ctx: ModelContext) -> None: - if uv_t is None: - return - - centers_r, centers_c, keep = self._centers(x, ctx) - if centers_r.numel() == 0: - return - - vals = self._patch(x) - dr2 = dr * dr - dc2 = dc * dc - drdc = dr * dc - - order_override = ctx.fields.get("lattice_intensity_order_override", None) - if isinstance(order_override, dict): - active_order = int(order_override.get(comp_name, default_pattern_intensity_order)) - elif order_override is None: - active_order = int(default_pattern_intensity_order) - else: - active_order = int(order_override) - active_order = max(0, min(active_order, int(max_intensity_order))) - - if per_disk_intensity: - keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) - i0_keep = x[i0_idx[keep_idx]] - if active_order >= 1: - ir_keep = x[ir_idx[keep_idx]] - ic_keep = x[ic_idx[keep_idx]] - if active_order >= 2: - irr_keep = x[irr_idx[keep_idx]] - icc_keep = x[icc_idx[keep_idx]] - irc_keep = x[irc_idx[keep_idx]] - for j, (rr0, cc0) in enumerate(zip(centers_r, centers_c)): - inten_local = i0_keep[j] - if active_order >= 1: - inten_local = inten_local + ir_keep[j] * dr + ic_keep[j] * dc - if active_order >= 2: - inten_local = inten_local + irr_keep[j] * dr2 + icc_keep[j] * dc2 + irc_keep[j] * drdc - inten_local = torch.clamp(inten_local, min=0.0) - _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten_local) - else: - i0 = x[i_i0] - for rr0, cc0 in zip(centers_r, centers_c): - inten = i0 - if active_order >= 1: - inten = inten + x[i_ir] * rr0 + x[i_ic] * cc0 - if active_order >= 2: - inten = inten + x[i_irr] * rr0 * rr0 + x[i_icc] * cc0 * cc0 + x[i_irc] * rr0 * cc0 - inten = torch.clamp(inten, min=0.0) - _splat_patch(out, r0=rr0, c0=cc0, patch_vals=vals, dr=dr, dc=dc, scale=inten) - - def overlays(self, x: torch.Tensor, ctx: ModelContext) -> Iterable[Overlay]: - if uv_t is None: - return [] - centers_r, centers_c, _ = self._centers(x, ctx) - if centers_r.numel() == 0: - return [] - return [ - Overlay( - kind="points_rc", - data={ - "r": centers_r.detach(), - "c": centers_c.detach(), - "marker": "x", - "s": 60.0, - "color": "orange", - }, + self.i0_raw = nn.Parameter(torch.tensor(i0_init, dtype=torch.float32)) + self.ir = nn.Parameter(torch.tensor(ir_init, dtype=torch.float32)) + self.ic = nn.Parameter(torch.tensor(ic_init, dtype=torch.float32)) + self.irr = nn.Parameter(torch.tensor(irr_init, dtype=torch.float32)) + self.icc = nn.Parameter(torch.tensor(icc_init, dtype=torch.float32)) + self.irc = nn.Parameter(torch.tensor(irc_init, dtype=torch.float32)) + + def set_origin(self, origin: OriginND) -> None: + self.origin = origin + + def forward(self, ctx: RenderContext) -> torch.Tensor: + if self.origin is None: + raise RuntimeError("SyntheticDiskLattice requires an OriginND instance.") + + out = torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) + uv_indices = cast(torch.Tensor, self.uv_indices) + if torch.numel(uv_indices) == 0: + return out + + uv = torch.as_tensor(uv_indices, device=ctx.device) + u = uv[:, 0].to(dtype=ctx.dtype) + v = uv[:, 1].to(dtype=ctx.dtype) + r0, c0 = self.origin.coords[0], self.origin.coords[1] + centers_r = r0 + u * self.u_row + v * self.v_row + centers_c = c0 + u * self.u_col + v * self.v_col + + b = torch.as_tensor(self.boundary_px, device=ctx.device, dtype=ctx.dtype) + keep = (centers_r >= b) & (centers_r <= (ctx.shape[0] - 1) - b) + keep = keep & (centers_c >= b) & (centers_c <= (ctx.shape[1] - 1) - b) + keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) + if keep_idx.numel() == 0: + return out + + active_order = int( + ctx.fields.get( + "lattice_intensity_order_override", self.default_pattern_intensity_order + ) + ) + active_order = max(0, min(active_order, self.max_intensity_order)) + + dr, dc = self.disk.patch_offsets() + dr = dr.to(device=ctx.device, dtype=ctx.dtype) + dc = dc.to(device=ctx.device, dtype=ctx.dtype) + dr2 = dr * dr + dc2 = dc * dc + drdc = dr * dc + + for j in keep_idx: + rr0 = centers_r[j] + cc0 = centers_c[j] + + if self.per_disk_intensity: + inten = _relu_pos(self.i0_raw[j]) + # inten = _softplus_pos(self.i0_raw[j]) + if active_order >= 1 and self.ir is not None and self.ic is not None: + inten = inten + self.ir[j] * dr + self.ic[j] * dc + if ( + active_order >= 2 + and self.irr is not None + and self.icc is not None + and self.irc is not None + ): + inten = inten + self.irr[j] * dr2 + self.icc[j] * dc2 + self.irc[j] * drdc + inten = torch.clamp(inten, min=0.0) + else: + inten = _relu_pos(self.i0_raw) + # inten = _softplus_pos(self.i0_raw) + if active_order >= 1: + assert self.ir is not None and self.ic is not None + inten = inten + self.ir * rr0 + self.ic * cc0 + if active_order >= 2: + assert self.irr is not None and self.icc is not None and self.irc is not None + inten = ( + inten + self.irr * rr0 * rr0 + self.icc * cc0 * cc0 + self.irc * rr0 * cc0 ) - ] + inten = torch.clamp(inten, min=0.0) - return Prepared() + self.disk.add_patch(out, r0=rr0, c0=cc0, scale=inten) + + return out diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index e7728997..10477d4f 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -1,117 +1,75 @@ from __future__ import annotations -"""High-level diffraction model fitting workflow utilities.""" - import warnings -from typing import Any, Sequence +from typing import Any, Sequence, cast import numpy as np import torch from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey +from torch.nn.utils import parameters_to_vector -from quantem.core.datastructures.dataset2d import Dataset2d -from quantem.core.datastructures.dataset3d import Dataset3d -from quantem.core.datastructures.dataset4d import Dataset4d -from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem +from quantem.core.fitting.base import AdditiveRenderModel, RenderComponent, RenderContext +from quantem.core.fitting.diffraction import OriginND from quantem.core.io.serialize import AutoSerialize -from quantem.core.fitting.base import Model, ModelContext, Overlay, PreparedModel -from quantem.core.fitting.diffraction import Origin2D +from quantem.core.ml.optimizer_mixin import OptimizerMixin from quantem.core.utils.imaging_utils import cross_correlation_shift +from quantem.core.utils.utils import to_numpy from quantem.core.visualization import show_2d -def _to_numpy(x: Any) -> np.ndarray: - """Convert arrays/tensors to NumPy arrays.""" - if isinstance(x, np.ndarray): - return x - if torch.is_tensor(x): - return x.detach().cpu().numpy() - return np.asarray(x) - - -class ModelDiffraction(AutoSerialize): - """ - End-to-end helper for defining and fitting additive diffraction forward models. - - This class wraps a diffraction dataset, builds an average reference image - (`image_ref`), compiles a composable component model, and provides optimization - routines for: - - fitting to the mean reference image, - - fitting selected individual diffraction patterns. - - Features - -------- - - Build a mean reference image with optional stack alignment. - - Define a composable model from origin/background/template/lattice components. - - Refine a mean model with Adam or L-BFGS. - - Fit all or selected patterns with optional progress bars. - - Plot reference/model comparisons and component overlays. - - Typical workflow - ---------------- - >>> md = ModelDiffraction.from_dataset(ds).preprocess().define_model(...) - >>> md.refine_mean_model(...) - >>> md.fit_all_patterns(...) - >>> md.plot_mean_model(...) - """ +def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: + if isinstance(value, (list, tuple, np.ndarray)): + if len(value) == 0: + raise ValueError(f"{name} cannot be empty.") + if value[0] is None: + raise ValueError(f"{name} initial value cannot be None.") + return float(value[0]) + return float(cast(float | int, value)) + +class ModelDiffraction(OptimizerMixin, AutoSerialize): _token = object() + DEFAULT_LR = 1e-3 + DEFAULT_OPTIMIZER_TYPE = "adamw" def __init__(self, dataset: Any, _token: object | None = None): if _token is not self._token: raise RuntimeError("Use ModelDiffraction.from_dataset() or .from_file().") - super().__init__() + AutoSerialize.__init__(self) + OptimizerMixin.__init__(self) self.dataset = dataset self.metadata: dict[str, Any] = {} self.image_ref: np.ndarray | None = None self.preprocess_shifts: np.ndarray | None = None self.index_shape: tuple[int, ...] | None = None - self.model: Model | None = None - self.prepared: PreparedModel | None = None - self.x_mean: torch.Tensor | None = None + + self.ctx: RenderContext | None = None + self.model: AdditiveRenderModel | None = None + self.target_mean: torch.Tensor | None = None + self.x_defined: torch.Tensor | None = None self.x_initial: torch.Tensor | None = None + self.x_mean: torch.Tensor | None = None self.mean_refined: bool = False - self.x_patterns: torch.Tensor | None = None - self.pattern_fit_losses: np.ndarray | None = None - self.pattern_fit_linear_indices: np.ndarray | None = None - self.pattern_fit_indices: list[tuple[int, ...]] | None = None - - @staticmethod - def _weak_softplus(x: torch.Tensor, *, scale: float) -> torch.Tensor: - s = torch.as_tensor(float(scale), device=x.device, dtype=x.dtype) - return torch.nn.functional.softplus(x / s) * s - - @classmethod - def _apply_intensity_transform( - cls, x: torch.Tensor, *, mode: str, weak_softplus_scale: float - ) -> torch.Tensor: - m = str(mode).lower() - if m == "none": - return x - if m == "weak_softplus": - return cls._weak_softplus(x, scale=weak_softplus_scale) - raise ValueError("intensity_transform must be one of: 'none', 'weak_softplus'.") + self.mean_fit_history: list[float] = [] + self.mean_fit_lrs: list[float] = [] @classmethod - def from_dataset(cls, dataset: Dataset2d | Dataset3d | Dataset4d | Dataset4dstem | Any) -> "ModelDiffraction": - """ - Construct a ModelDiffraction object from a QuantEM dataset container. - - Parameters - ---------- - dataset - Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance. - - Returns - ------- - ModelDiffraction - New model-fitting helper bound to the provided dataset. - """ + def from_dataset( + cls, dataset: Dataset2d | Dataset3d | Dataset4d | Dataset4dstem | Any + ) -> "ModelDiffraction": if isinstance(dataset, (Dataset2d, Dataset3d, Dataset4d, Dataset4dstem)): return cls(dataset=dataset, _token=cls._token) - raise TypeError("from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance.") + raise TypeError( + "from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance." + ) + + def get_optimization_parameters(self) -> Any: + if self.model is None: + return [] + return self.model.parameters() def preprocess( self, @@ -122,73 +80,44 @@ def preprocess( max_shift: float | None = None, shift_order: int = 1, ) -> "ModelDiffraction": - """ - Precompute the mean reference image used for model fitting. - - Parameters - ---------- - align - If True, align the flattened pattern stack before averaging. - edge_blend - Tukey edge taper width (pixels) used for robust FFT alignment. - upsample_factor - Sub-pixel alignment upsampling factor for cross-correlation shift. - max_shift - Optional maximum shift magnitude during alignment. - shift_order - Interpolation order used when applying shifts to patterns. - - Returns - ------- - ModelDiffraction - Returns self. - - Notes - ----- - - `dataset.array` is interpreted as `(..., H, W)`, where leading dimensions - are flattened into a pattern stack. - - The computed stack-average is stored in `self.image_ref`. - - If `align=False`, preprocessing is a direct mean over stack elements. - """ arr = np.asarray(self.dataset.array) if arr.ndim < 2: raise ValueError("dataset.array must have at least 2 dimensions.") - H, W = arr.shape[-2], arr.shape[-1] + h, w = arr.shape[-2], arr.shape[-1] self.index_shape = tuple(arr.shape[:-2]) - stack = arr.reshape((-1, H, W)).astype(np.float32, copy=False) + stack = arr.reshape((-1, h, w)).astype(np.float32, copy=False) n = stack.shape[0] - if not align or n <= 1: self.image_ref = np.mean(stack, axis=0) self.preprocess_shifts = None return self - alpha_r = 0.0 if edge_blend <= 0 else min(1.0, 2.0 * float(edge_blend) / float(H)) - alpha_c = 0.0 if edge_blend <= 0 else min(1.0, 2.0 * float(edge_blend) / float(W)) - w = tukey(H, alpha=alpha_r)[:, None] * tukey(W, alpha=alpha_c)[None, :] - w = w.astype(np.float32, copy=False) + alpha_r = 0.0 if edge_blend <= 0 else min(1.0, 2.0 * float(edge_blend) / float(h)) + alpha_c = 0.0 if edge_blend <= 0 else min(1.0, 2.0 * float(edge_blend) / float(w)) + window = tukey(h, alpha=alpha_r)[:, None] * tukey(w, alpha=alpha_c)[None, :] + window = window.astype(np.float32, copy=False) shifts = np.zeros((n, 2), dtype=np.float32) - F_ref = np.fft.fft2(w * stack[0]) - + fft_ref = np.fft.fft2(window * stack[0]) for i in range(1, n): - F_i = np.fft.fft2(w * stack[i]) - drc, F_shift = cross_correlation_shift( - F_ref, - F_i, + fft_i = np.fft.fft2(window * stack[i]) + drc, fft_shift = cross_correlation_shift( + fft_ref, + fft_i, upsample_factor=int(upsample_factor), max_shift=max_shift, fft_input=True, fft_output=True, return_shifted_image=True, ) + if not isinstance(drc, (list, tuple, np.ndarray)) or len(drc) < 2: + raise RuntimeError("cross_correlation_shift returned an invalid shift vector.") shifts[i, 0] = float(drc[0]) shifts[i, 1] = float(drc[1]) - F_ref = F_ref * (i / (i + 1)) + F_shift / (i + 1) + fft_ref = fft_ref * (i / (i + 1)) + fft_shift / (i + 1) shifts -= np.mean(shifts, axis=0, keepdims=True) - aligned = np.empty_like(stack, dtype=np.float32) for i in range(n): aligned[i] = ndi_shift( @@ -206,646 +135,268 @@ def preprocess( def define_model( self, *, - origin_row: float | tuple[float, float] | tuple[float, float, float | None], - origin_col: float | tuple[float, float] | tuple[float, float, float | None], - components: list[Any], + origin_row: float | Sequence[float], + origin_col: float | Sequence[float], + components: list[RenderComponent], device: torch.device | str | None = None, dtype: torch.dtype | None = None, mask: np.ndarray | torch.Tensor | None = None, origin_key: str = "origin", ) -> "ModelDiffraction": - """ - Define and compile a diffraction model against `image_ref`. - - Parameters - ---------- - origin_row, origin_col - Initial origin parameter specification. Supported forms are: - - scalar: fixed initial value with no explicit bounds - - `(value, deviation)`: symmetric bounds `(value - deviation, value + deviation)` - - `(value, lower_bound, upper_bound)`: explicit bounds - components - Sequence of model components (e.g. `DiskTemplate`, backgrounds, lattice). - Components are rendered additively in the provided order. - device - Torch device used for compiled parameters and rendering. - dtype - Torch dtype used for compiled parameters and rendering. - mask - Optional `(H, W)` mask for weighted loss during optimization. - origin_key - Name used to register/retrieve the origin component in context fields. - - Returns - ------- - ModelDiffraction - Returns self with compiled model state. - - Notes - ----- - - If `image_ref` is missing, `preprocess()` is run automatically. - - `Origin2D` is inserted automatically before user components. - - Component dependency ordering still matters for shared context fields: - for example, `DiskTemplate` should appear before `SyntheticDiskLattice` - when the lattice references that template. - - This method resets fit state (`x_defined`, `x_initial`, `x_mean`, - `x_patterns`, and pattern-fit metadata). - """ if self.image_ref is None: self.preprocess() - if self.image_ref is None: raise RuntimeError("image_ref not available.") - H, W = int(self.image_ref.shape[0]), int(self.image_ref.shape[1]) + h, w = int(self.image_ref.shape[0]), int(self.image_ref.shape[1]) dev = torch.device(device) if device is not None else torch.device("cpu") dt = dtype if dtype is not None else torch.float32 mask_t = None if mask is not None: - if torch.is_tensor(mask): - mask_t = mask.to(device=dev, dtype=dt) - else: - m = np.asarray(mask) - if m.shape != (H, W): - raise ValueError("mask must have shape (H, W).") - mask_t = torch.as_tensor(m.astype(np.float32, copy=False), device=dev, dtype=dt) - - ctx = ModelContext(H=H, W=W, device=dev, dtype=dt, mask=mask_t, fields={}) - m = Model() - m.add([Origin2D(origin_key=str(origin_key), row=origin_row, col=origin_col)]) - m.add(list(components)) - - self.model = m - self.prepared = m.compile(ctx) - self.x_defined = self.prepared.x0.detach().clone() - self.x_initial = self.x_defined.detach().clone() - self.x_mean = self.x_initial.detach().clone() + mask_t = ( + mask.to(device=dev, dtype=dt) + if torch.is_tensor(mask) + else torch.as_tensor(mask, device=dev, dtype=dt) + ) + if tuple(mask_t.shape) != (h, w): + raise ValueError("mask must have shape (H, W).") + + origin = OriginND( + ndim=2, + init=[ + _parse_init(origin_row, name="origin_row"), + _parse_init(origin_col, name="origin_col"), + ], + ) + origin._quantem_origin_key = str(origin_key) # type: ignore[attr-defined] + + for component in components: + if hasattr(component, "set_origin"): + component.set_origin(origin) # type: ignore[misc] + elif hasattr(component, "origin") and getattr(component, "origin") is None: + component.origin = origin # type: ignore[attr-defined] + + self.model = AdditiveRenderModel(origin=origin, components=list(components)).to( + device=dev, dtype=dt + ) + self.ctx = RenderContext(shape=(h, w), device=dev, dtype=dt, mask=mask_t, fields={}) + self.target_mean = torch.as_tensor(self.image_ref, device=dev, dtype=dt) + + x0 = parameters_to_vector(self.model.parameters()).detach().clone() + self.x_defined = x0.detach().clone() + self.x_initial = x0.detach().clone() + self.x_mean = x0.detach().clone() self.mean_refined = False - self.x_patterns = None - self.pattern_fit_losses = None - self.pattern_fit_linear_indices = None - self.pattern_fit_indices = None + self.mean_fit_history = [] + self.mean_fit_lrs = [] + self.remove_optimizer() return self - def _fit_target_image( + def fit_mean_diffraction_pattern( self, *, - target: torch.Tensor, - x_start: torch.Tensor, - n_steps: int, - lr: float, - method: str, - power: float | None, - fit_disk_pixels: bool | None, - fit_only_disk_pixels: bool, - intensity_order: int | None, - enforce_disk_center_of_mass: bool, - normalize_disk_template_max: bool, - template_binary_weight: float, - template_binary_power: float, - intensity_transform: str, - weak_softplus_scale: float, + n_steps: int = 200, + optimizer_params: dict | None = None, + scheduler_params: dict | None = None, + constraint_weight: float = 1.0, + overwrite_initial: bool = True, progress: bool = False, - progress_desc: str | None = None, - ) -> tuple[torch.Tensor, float]: - if self.prepared is None: + **kwargs: Any, + ) -> "ModelDiffraction": + if self.model is None or self.ctx is None or self.target_mean is None: raise RuntimeError("Call .define_model(...) first.") - ctx = self.prepared.ctx - lb = self.prepared.lb - ub = self.prepared.ub - - x = x_start.detach().clone().to(device=ctx.device, dtype=ctx.dtype) - x.requires_grad_(True) - - if fit_disk_pixels is None: - fit_disk_pixels = any(p.tags.get("role") == "disk_pixel" for p in self.prepared.params) - - freeze = torch.zeros_like(x, dtype=torch.bool) - disk_mask = torch.zeros_like(x, dtype=torch.bool) - for p in self.prepared.params: - if p.tags.get("role") == "disk_pixel": - disk_mask[p.index] = True - if intensity_order is not None and str(p.tags.get("role", "")).startswith("lat_int"): - p_ord = int(p.tags.get("intensity_order", 0)) - if p_ord > int(intensity_order): - freeze[p.index] = True - - # Group disk pixel parameters by disk template for optional projection. - disk_param_groups: list[dict[str, Any]] = [] - if ( - enforce_disk_center_of_mass - or normalize_disk_template_max - or float(template_binary_weight) > 0.0 - ): - disk_templates = self.prepared.ctx.fields.get("disk_templates", {}) - grouped: dict[str, list[tuple[int, int]]] = {} - for p in self.prepared.params: - if p.tags.get("role") != "disk_pixel": - continue - name = str(p.tags.get("disk")) - i_flat = int(p.tags.get("i")) - grouped.setdefault(name, []).append((i_flat, int(p.index))) - for name, pairs in grouped.items(): - dmeta = disk_templates.get(name) - if dmeta is None: - continue - order = sorted(pairs, key=lambda t: t[0]) - p_idx = torch.as_tensor([t[1] for t in order], device=ctx.device, dtype=torch.long) - g: dict[str, Any] = {"param_idx": p_idx} - shape = dmeta.get("shape", None) - if shape is not None: - g["shape"] = (int(shape[0]), int(shape[1])) - if enforce_disk_center_of_mass: - flat_i = torch.as_tensor([t[0] for t in order], device=ctx.device, dtype=torch.long) - g["dr"] = dmeta["dr"][flat_i] - g["dc"] = dmeta["dc"][flat_i] - disk_param_groups.append(g) - - if fit_only_disk_pixels: - if not fit_disk_pixels: - raise ValueError("fit_only_disk_pixels=True requires fit_disk_pixels=True.") - freeze[:] = True - freeze[disk_mask] = False - elif not fit_disk_pixels: - freeze[disk_mask] = True - x_frozen = x.detach().clone() - - old_order_override = ctx.fields.get("lattice_intensity_order_override", None) - if intensity_order is not None: - ctx.fields["lattice_intensity_order_override"] = int(intensity_order) - - target_t = target.to(device=ctx.device, dtype=ctx.dtype) - target_t = self._apply_intensity_transform( - target_t, mode=intensity_transform, weak_softplus_scale=weak_softplus_scale - ) - if power is not None: - target_t = torch.clamp(target_t, min=0.0) ** float(power) - - def clamp_inplace() -> None: - with torch.no_grad(): - x.data = torch.max(torch.min(x.data, ub), lb) - if torch.any(freeze): - x.data[freeze] = x_frozen[freeze] - if ( - enforce_disk_center_of_mass - or normalize_disk_template_max - ) and disk_param_groups: - eps = torch.as_tensor(1e-12, device=ctx.device, dtype=ctx.dtype) - for g in disk_param_groups: - p_idx = g["param_idx"] - if torch.all(freeze[p_idx]): - continue - vals = torch.clamp(x.data[p_idx], min=0.0) - if enforce_disk_center_of_mass: - dr = g["dr"] - dc = g["dc"] - # Project template moments toward zero COM by iterative multiplicative reweighting. - for _ in range(12): - mass = torch.sum(vals) - if mass <= eps: - break - r_com = torch.sum(vals * dr) / mass - c_com = torch.sum(vals * dc) / mass - if torch.abs(r_com) <= 1e-5 and torch.abs(c_com) <= 1e-5: - break - var_r = torch.sum(vals * dr * dr) / mass + eps - var_c = torch.sum(vals * dc * dc) / mass + eps - vals = vals * torch.exp(-(r_com / var_r) * dr - (c_com / var_c) * dc) - vals = torch.clamp(vals, min=0.0) - if normalize_disk_template_max: - vmax = torch.max(vals) - if vmax > eps: - vals = vals / vmax - x.data[p_idx] = vals - - def loss_fn() -> torch.Tensor: - clamp_inplace() - pred = self.prepared.render(x) - pred = self._apply_intensity_transform( - pred, mode=intensity_transform, weak_softplus_scale=weak_softplus_scale - ) - if power is not None: - pred = torch.clamp(pred, min=0.0) ** float(power) - if ctx.mask is not None: - m = ctx.mask - diff = (pred - target_t) * m - denom = torch.clamp(torch.sum(m), min=1.0) - loss = torch.sum(diff * diff) / denom - else: - diff = pred - target_t - loss = torch.mean(diff * diff) - - if float(template_binary_weight) > 0.0 and disk_param_groups: - bp = torch.as_tensor(0.0, device=ctx.device, dtype=ctx.dtype) - n_bp = 0 - eps = torch.as_tensor(1e-12, device=ctx.device, dtype=ctx.dtype) - pwr = float(template_binary_power) - for g in disk_param_groups: - p_idx = g["param_idx"] - vals = torch.clamp(x[p_idx], min=0.0) - vmax = torch.max(vals) - if vmax <= eps: - continue - vals_n = vals / (vmax + eps) - core = vals_n * (1.0 - vals_n) - if pwr != 1.0: - core = torch.pow(core + eps, pwr) - bp = bp + torch.mean(core) - n_bp += 1 - if n_bp > 0: - loss = loss + float(template_binary_weight) * (bp / n_bp) - - return loss - - try: - if method == "adam": - opt = torch.optim.Adam([x], lr=lr) - step_iter: Any = range(int(n_steps)) - if progress: - try: - from tqdm.auto import trange - - step_iter = trange(int(n_steps), desc=progress_desc or "Refining", leave=False) - except Exception: - warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) - for _ in step_iter: - opt.zero_grad(set_to_none=True) - loss = loss_fn() - loss.backward() - opt.step() - clamp_inplace() - elif method == "lbfgs": - opt = torch.optim.LBFGS([x], lr=lr, max_iter=int(n_steps), line_search_fn="strong_wolfe") - - def closure() -> torch.Tensor: - opt.zero_grad(set_to_none=True) - loss = loss_fn() - loss.backward() - return loss - - opt.step(closure) - clamp_inplace() - else: - raise ValueError("method must be one of: 'lbfgs', 'adam'.") - - with torch.no_grad(): - final_loss = float(loss_fn().detach().cpu()) - return x.detach().clone(), final_loss - finally: - if intensity_order is not None: - if old_order_override is None: - ctx.fields.pop("lattice_intensity_order_override", None) - else: - ctx.fields["lattice_intensity_order_override"] = old_order_override - - def _resolve_pattern_indices(self, indices: Any, n: int, index_shape: tuple[int, ...]) -> np.ndarray: - if indices is None: - out = np.arange(n, dtype=np.int64) - elif isinstance(indices, (int, np.integer)): - out = np.asarray([int(indices)], dtype=np.int64) - elif isinstance(indices, slice): - out = np.arange(n, dtype=np.int64)[indices] - elif isinstance(indices, tuple) and all(isinstance(i, (int, np.integer)) for i in indices): - if len(index_shape) == 0: - if len(indices) != 0: - raise ValueError("indices tuple must be empty for single-pattern datasets.") - out = np.asarray([0], dtype=np.int64) + optimizer_rebuilt = False + if optimizer_params is not None: + self.set_optimizer(optimizer_params) + optimizer_rebuilt = True + elif self.optimizer is None: + if self.optimizer_params: + self.set_optimizer(self.optimizer_params) else: - out = np.asarray([np.ravel_multi_index(tuple(int(i) for i in indices), index_shape)], dtype=np.int64) - elif isinstance(indices, tuple): - if len(index_shape) == 0: - raise ValueError("slice tuple indices are not valid for single-pattern datasets.") - grid = np.arange(n, dtype=np.int64).reshape(index_shape) - out = np.asarray(grid[indices], dtype=np.int64).ravel() - elif isinstance(indices, np.ndarray) and indices.dtype == np.bool_: - if indices.shape != index_shape: - raise ValueError(f"Boolean mask must have shape {index_shape}.") - out = np.flatnonzero(indices.ravel()).astype(np.int64, copy=False) - elif isinstance(indices, Sequence) and not isinstance(indices, (str, bytes)): - seq = list(indices) - if len(seq) == 0: - out = np.asarray([], dtype=np.int64) - elif isinstance(seq[0], (tuple, list, np.ndarray)): - if len(index_shape) == 0: - raise ValueError("multi-index selection is not valid for single-pattern datasets.") - out = np.asarray( - [np.ravel_multi_index(tuple(int(j) for j in i), index_shape) for i in seq], - dtype=np.int64, - ) - else: - out = np.asarray([int(i) for i in seq], dtype=np.int64) - else: - raise TypeError("Unsupported indices type for fit_all_patterns.") + self.set_optimizer({"type": self.DEFAULT_OPTIMIZER_TYPE, "lr": self.DEFAULT_LR}) + optimizer_rebuilt = True - if out.ndim != 1: - out = out.ravel() - if np.any(out < 0) or np.any(out >= n): - raise IndexError(f"indices must be in [0, {n - 1}].") - return out + if scheduler_params is not None: + self.set_scheduler(scheduler_params, num_iter=int(n_steps)) + elif self.scheduler is None and self.scheduler_params: + self.set_scheduler(self.scheduler_params, num_iter=int(n_steps)) + elif optimizer_rebuilt and self.scheduler is not None and self.optimizer is not None: + self.scheduler.optimizer = self.optimizer - def refine_mean_model( - self, - *, - n_steps: int = 50, - lr: float = 1e-3, - method: str = "adam", - power: float | None = 1.0, - fit_disk_pixels: bool | None = None, - fit_only_disk_pixels: bool = False, - intensity_order: int = 0, - enforce_disk_center_of_mass: bool = True, - normalize_disk_template_max: bool = False, - template_binary_weight: float = 0.0, - template_binary_power: float = 1.0, - warmup_disk_steps: int = 0, - overwrite_initial: bool = True, - intensity_transform: str = "none", - weak_softplus_scale: float = 1e-3, - progress: bool = False, - ) -> "ModelDiffraction": - """ - Refine model parameters against the mean reference image. - - Parameters - ---------- - n_steps - Number of optimization steps/iterations for the main phase. - lr - Optimizer learning rate. - method - Optimizer name: `"adam"` or `"lbfgs"`. - power - Optional power-law transform applied to both target and prediction - before computing MSE loss. - fit_disk_pixels - Controls whether `disk_pixel` parameters are trainable. If None, - inferred from model parameters. - fit_only_disk_pixels - If True, freeze all non-disk parameters. - warmup_disk_steps - Optional number of disk-only warmup steps run before the main phase. - overwrite_initial - If True, store refined parameters as new initial parameters for - subsequent per-pattern fitting. - intensity_transform - Optional intensity transform (`"none"` or `"weak_softplus"`) applied - before the power transform and loss evaluation. - weak_softplus_scale - Scale parameter used when `intensity_transform="weak_softplus"`. - progress - If True, show a tqdm progress bar (when tqdm is available). - - Returns - ------- - ModelDiffraction - Returns self with updated `x_mean` (and optionally `x_initial`). - """ - method = str(method).lower() + iterator: Any = range(int(n_steps)) + if progress: + try: + from tqdm.auto import trange - if self.image_ref is None: - self.preprocess() - if self.image_ref is None or self.prepared is None or self.x_mean is None or self.x_initial is None: - raise RuntimeError("Call .define_model(...) first.") + iterator = trange(int(n_steps), desc="Fit mean diffraction", leave=False) + except Exception: + warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) - ctx = self.prepared.ctx - target = torch.as_tensor(self.image_ref, device=ctx.device, dtype=ctx.dtype) - x_start = self.x_initial if overwrite_initial else self.x_mean - if int(warmup_disk_steps) > 0: - x_start, _ = self._fit_target_image( - target=target, - x_start=x_start, - n_steps=int(warmup_disk_steps), - lr=float(lr), - method=method, - power=power, - fit_disk_pixels=True, - fit_only_disk_pixels=True, - intensity_order=int(intensity_order), - enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), - normalize_disk_template_max=bool(normalize_disk_template_max), - template_binary_weight=float(template_binary_weight), - template_binary_power=float(template_binary_power), - intensity_transform=intensity_transform, - weak_softplus_scale=float(weak_softplus_scale), - progress=bool(progress), - progress_desc="Refine mean model (disk warmup)", + self.mean_fit_history = [] + self.mean_fit_lrs = [] + for _ in iterator: + self.zero_optimizer_grad() + pred = self.model(self.ctx) + if self.ctx.mask is not None: + diff = (pred - self.target_mean) * self.ctx.mask + denom = torch.clamp(torch.sum(self.ctx.mask), min=1.0) + loss_data = torch.sum(diff * diff) / denom + else: + loss_data = torch.mean((pred - self.target_mean) ** 2) + loss = loss_data + float(constraint_weight) * self.model.total_constraint_loss( + self.ctx ) - x_fit, _ = self._fit_target_image( - target=target, - x_start=x_start, - n_steps=int(n_steps), - lr=float(lr), - method=method, - power=power, - fit_disk_pixels=fit_disk_pixels, - fit_only_disk_pixels=bool(fit_only_disk_pixels), - intensity_order=int(intensity_order), - enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), - normalize_disk_template_max=bool(normalize_disk_template_max), - template_binary_weight=float(template_binary_weight), - template_binary_power=float(template_binary_power), - intensity_transform=intensity_transform, - weak_softplus_scale=float(weak_softplus_scale), - progress=bool(progress), - progress_desc="Refine mean model", - ) - + loss.backward() + self.step_optimizer() + loss_value = float(loss.detach().cpu()) + self.step_scheduler(loss_value) + self.mean_fit_history.append(loss_value) + self.mean_fit_lrs.append(float(self.get_current_lr())) + + x_fit = parameters_to_vector(self.model.parameters()).detach().clone() self.x_mean = x_fit self.mean_refined = True if overwrite_initial: self.x_initial = x_fit.detach().clone() return self - def fit_all_patterns( - self, - *, - indices: Any = None, - use_refined_init: bool = True, - strict_refined_init: bool = False, - n_steps: int = 50, - lr: float = 1e-3, - method: str = "adam", - power: float | None = 1.0, - fit_disk_pixels: bool | None = None, - fit_only_disk_pixels: bool = False, - intensity_order: int | None = None, - enforce_disk_center_of_mass: bool = True, - normalize_disk_template_max: bool = False, - template_binary_weight: float = 0.0, - template_binary_power: float = 1.0, - intensity_transform: str = "none", - weak_softplus_scale: float = 1e-3, - progress: bool = False, - ) -> "ModelDiffraction": - """ - Fit selected diffraction patterns using the compiled model. - - Parameters - ---------- - indices - Pattern selector. Supported forms include None (all patterns), - integer, slice, tuple indexing, list of linear indices, list of - multi-indices, or boolean mask shaped like scan dimensions. - use_refined_init - If True, initialize per-pattern fitting from `x_initial`. - strict_refined_init - If True, raise when refined init is requested before mean refinement. - If False, emit warning and fall back to defined initialization. - n_steps, lr, method, power, fit_disk_pixels, fit_only_disk_pixels - Optimization settings analogous to `refine_mean_model`. - intensity_transform, weak_softplus_scale - Prediction/target intensity transform options. - progress - If True, show a tqdm progress bar over selected patterns. - - Returns - ------- - ModelDiffraction - Returns self with: - - `x_patterns`: fitted parameter vectors `(n_selected, n_params)` - - `pattern_fit_losses`: per-pattern final losses - - index bookkeeping for selected patterns. - """ - method = str(method).lower() - - if self.prepared is None or self.x_defined is None or self.x_initial is None: - raise RuntimeError("Call .define_model(...) first.") + def plot_losses( + self, figax: tuple[Any, Any] | None = None, plot_lrs: bool = True + ) -> tuple[Any, Any]: + import matplotlib.pyplot as plt - arr = np.asarray(self.dataset.array) - if arr.ndim < 2: - raise ValueError("dataset.array must have at least 2 dimensions.") - H, W = arr.shape[-2], arr.shape[-1] - index_shape = tuple(arr.shape[:-2]) - stack = arr.reshape((-1, H, W)).astype(np.float32, copy=False) - n = int(stack.shape[0]) - - linear = self._resolve_pattern_indices(indices=indices, n=n, index_shape=index_shape) - if linear.size == 0: - raise ValueError("No patterns selected for fitting.") - - if use_refined_init: - if not self.mean_refined: - msg = "fit_all_patterns(use_refined_init=True) was requested before refine_mean_model()." - if strict_refined_init: - raise RuntimeError(msg) - warnings.warn(f"{msg} Falling back to defined initial parameters.", stacklevel=2) - x_seed = self.x_defined - else: - x_seed = self.x_initial + if figax is None: + fig, ax = plt.subplots() else: - x_seed = self.x_defined + fig, ax = figax + + losses = np.asarray(self.mean_fit_history, dtype=np.float64) + if losses.size == 0: + ax.text( + 0.5, + 0.5, + "No fit history available", + ha="center", + va="center", + transform=ax.transAxes, + ) + ax.set_xlabel("Iterations") + ax.set_ylabel("Loss") + if figax is None: + plt.tight_layout() + plt.show() + return fig, ax - n_sel = int(linear.size) - x_fit_all = torch.empty( - (n_sel, self.x_defined.numel()), - device=self.prepared.ctx.device, - dtype=self.prepared.ctx.dtype, - ) - losses = np.empty((n_sel,), dtype=np.float32) + iters = np.arange(losses.size) + lines: list[Any] = [] + lines.extend(ax.semilogy(iters, losses, c="k", lw=2, label="loss")) + ax.set_xlabel("Iterations") + ax.set_ylabel("Loss", color="k") + ax.tick_params(axis="y", which="both", colors="k") + ax.spines["left"].set_color("k") + ax.set_xbound(-2, max(1, int(iters.max())) + 2) + + lrs = np.asarray(self.mean_fit_lrs, dtype=np.float64) + if plot_lrs and lrs.size > 0: + if lrs.size == losses.size and not np.allclose(lrs, lrs[0]): + ax_lr = ax.twinx() + ax.set_zorder(2) + ax_lr.set_zorder(1) + ax.patch.set_visible(False) + ax_lr.spines["left"].set_visible(False) + lines.extend( + ax_lr.semilogy( + np.arange(lrs.size), lrs, c="tab:blue", lw=2, ls="--", label="LR" + ) + ) + ax_lr.set_ylabel("LR", color="tab:blue") + ax_lr.tick_params(axis="y", which="both", colors="tab:blue") + ax_lr.spines["right"].set_color("tab:blue") + else: + ax.set_title(f"LR: {float(lrs[-1]):.2e}", fontsize=10) - pat_iter: Any = enumerate(linear) - if progress: - try: - from tqdm.auto import tqdm + labels = [line.get_label() for line in lines] + if len(labels) > 1: + ax.legend(lines, labels, loc="upper right") - pat_iter = enumerate(tqdm(linear, desc="Fit patterns", leave=False)) - except Exception: - warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) + if figax is None: + plt.tight_layout() + plt.show() + return fig, ax - for j, i_lin in pat_iter: - target = torch.as_tensor(stack[int(i_lin)], device=self.prepared.ctx.device, dtype=self.prepared.ctx.dtype) - x_fit, loss = self._fit_target_image( - target=target, - x_start=x_seed, - n_steps=int(n_steps), - lr=float(lr), - method=method, - power=power, - fit_disk_pixels=fit_disk_pixels, - fit_only_disk_pixels=bool(fit_only_disk_pixels), - intensity_order=None if intensity_order is None else int(intensity_order), - enforce_disk_center_of_mass=bool(enforce_disk_center_of_mass), - normalize_disk_template_max=bool(normalize_disk_template_max), - template_binary_weight=float(template_binary_weight), - template_binary_power=float(template_binary_power), - intensity_transform=intensity_transform, - weak_softplus_scale=float(weak_softplus_scale), - progress=False, - ) - x_fit_all[j] = x_fit - losses[j] = float(loss) - - self.x_patterns = x_fit_all - self.pattern_fit_losses = losses - self.pattern_fit_linear_indices = linear - if len(index_shape) == 0: - self.pattern_fit_indices = [tuple() for _ in linear] - else: - self.pattern_fit_indices = [tuple(int(k) for k in np.unravel_index(int(i), index_shape)) for i in linear] - return self + def visualize( + self, *, power: float = 0.25, cbar: bool = False, axsize: tuple[int, int] = (6, 6) + ) -> tuple[Any, Any]: + import matplotlib.pyplot as plt + from matplotlib import gridspec - def _apply_overlays(self, ax: Any, overlays: list[Overlay]) -> None: - for ov in overlays: - if ov.kind != "points_rc": - continue - d = dict(ov.data) - r = _to_numpy(d["r"]).ravel() - c = _to_numpy(d["c"]).ravel() - ax.scatter( - c, - r, - s=float(d.get("s", 60.0)), - marker=d.get("marker", "x"), - color=d.get("color", "orange"), + if self.image_ref is None: + self.preprocess() + if self.image_ref is None or self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + + fig = plt.figure(figsize=(12, 7)) + gs = gridspec.GridSpec(2, 1, height_ratios=[1, 2], hspace=0.3) + ax_top = fig.add_subplot(gs[0]) + self.plot_losses(figax=(fig, ax_top), plot_lrs=True) + + ref = np.asarray(self.image_ref, dtype=np.float32) + pred = to_numpy(self.model.forward(self.ctx)).astype(np.float32, copy=False) + refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) + predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) + vmin = float(min(refp.min(), predp.min())) + vmax = float(max(refp.max(), predp.max())) + + gs_bot = gridspec.GridSpecFromSubplotSpec(1, 2, subplot_spec=gs[1], wspace=0.15) + axs = np.array( + [fig.add_subplot(gs_bot[0, 0]), fig.add_subplot(gs_bot[0, 1])], dtype=object + ) + show_2d( + [refp, predp], + figax=(fig, axs), + title=["image_ref", "model"], + cmap="gray", + cbar=bool(cbar), + returnfig=False, + axsize=axsize, + vmin=vmin, + vmax=vmax, + ) + + if len(self.mean_fit_history) > 0: + fig.suptitle( + f"Final loss: {self.mean_fit_history[-1]:.3e} | Iters: {len(self.mean_fit_history)}", + fontsize=13, + y=0.98, ) + plt.show() + return fig, axs def plot_mean_model( self, *, power: float = 0.25, returnfig: bool = False, - show_overlays: bool = True, axsize: tuple[int, int] = (6, 6), + **_: Any, ) -> tuple[Any, Any] | None: - """ - Plot `image_ref` and the current mean-model prediction side-by-side. - - Parameters - ---------- - power - Display transform exponent applied to both reference and model images. - returnfig - If True, return `(fig, ax)` from `show_2d`. - show_overlays - If True, draw component overlays (e.g., origin and lattice markers). - axsize - Base axis size passed to the plotting helper. - - Returns - ------- - tuple[Any, Any] | None - `(fig, ax)` if `returnfig=True`, else None. - """ if self.image_ref is None: self.preprocess() - if self.image_ref is None or self.prepared is None: + if self.image_ref is None or self.model is None or self.ctx is None: raise RuntimeError("Call .define_model(...) first.") - if self.x_mean is None: - self.x_mean = self.prepared.x0.detach().clone() ref = np.asarray(self.image_ref, dtype=np.float32) - mod = _to_numpy(self.prepared.render(self.x_mean)).astype(np.float32, copy=False) + pred = self.model.forward(self.ctx).detach().cpu().numpy() refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) - modp = mod if power == 1.0 else np.maximum(mod, 0.0) ** float(power) - - vmin = float(min(refp.min(), modp.min())) - vmax = float(max(refp.max(), modp.max())) + predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) + vmin = float(min(refp.min(), predp.min())) + vmax = float(max(refp.max(), predp.max())) fig, ax = show_2d( - [refp, modp], + [refp, predp], title=["image_ref", "model"], cmap="gray", cbar=False, @@ -854,39 +405,6 @@ def plot_mean_model( vmin=vmin, vmax=vmax, ) - - H, W = ref.shape[-2], ref.shape[-1] - pad = 0 - boundaries = [] - for c in getattr(self.prepared, "components", []): - b = getattr(c, "boundary_px", None) - if b is not None: - boundaries.append(float(b)) - if boundaries: - min_b = float(np.min(boundaries)) - if min_b < 0.0: - pad = int(np.ceil(-min_b)) - - axes: list[Any] - if isinstance(ax, np.ndarray): - axes = list(ax.ravel()) - elif isinstance(ax, (list, tuple)): - axes = list(ax) - else: - axes = [ax] - - for a in axes[:2]: - # Match imshow's pixel-edge convention so overlay markers land on pixel centers. - a.set_xlim(-0.5 - pad, (W - 0.5) + pad) - a.set_ylim((H - 0.5) + pad, -0.5 - pad) - - if show_overlays: - ovs = self.prepared.overlays(self.x_mean) - if len(axes) >= 1: - self._apply_overlays(axes[0], ovs) - if len(axes) >= 2: - self._apply_overlays(axes[1], ovs) - if returnfig: return fig, ax return None From c7f32a564c366ffa91e193d448336fe153041fdc Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Fri, 27 Feb 2026 13:49:12 -0800 Subject: [PATCH 28/88] adding state saving to ModelDiffraction --- src/quantem/diffraction/model_fitting.py | 119 +++++++++++++++++++---- 1 file changed, 99 insertions(+), 20 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 10477d4f..81c3af66 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -1,13 +1,13 @@ from __future__ import annotations import warnings -from typing import Any, Sequence, cast +from typing import Any, Literal, Sequence, cast import numpy as np import torch from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey -from torch.nn.utils import parameters_to_vector +from torch.nn.utils import parameters_to_vector, vector_to_parameters from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem from quantem.core.fitting.base import AdditiveRenderModel, RenderComponent, RenderContext @@ -15,7 +15,6 @@ from quantem.core.io.serialize import AutoSerialize from quantem.core.ml.optimizer_mixin import OptimizerMixin from quantem.core.utils.imaging_utils import cross_correlation_shift -from quantem.core.utils.utils import to_numpy from quantem.core.visualization import show_2d @@ -49,9 +48,9 @@ def __init__(self, dataset: Any, _token: object | None = None): self.model: AdditiveRenderModel | None = None self.target_mean: torch.Tensor | None = None - self.x_defined: torch.Tensor | None = None - self.x_initial: torch.Tensor | None = None - self.x_mean: torch.Tensor | None = None + self.state_initialized: torch.Tensor | None = None + self.state_mean_refined: torch.Tensor | None = None + self.state_current: torch.Tensor | None = None self.mean_refined: bool = False self.mean_fit_history: list[float] = [] self.mean_fit_lrs: list[float] = [] @@ -71,6 +70,82 @@ def get_optimization_parameters(self) -> Any: return [] return self.model.parameters() + def _get_model_state_vector(self) -> torch.Tensor: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return parameters_to_vector(self.model.parameters()).detach().clone() + + def _load_model_state_vector(self, state: torch.Tensor) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + dst = next(self.model.parameters(), None) + if dst is None: + raise RuntimeError("Model has no parameters.") + vec = state.detach().clone().to(device=dst.device, dtype=dst.dtype) + vector_to_parameters(vec, self.model.parameters()) + + def _clear_histories(self) -> None: + for name in ("mean_fit_history", "mean_fit_lrs", "fit_history", "fit_lrs"): + if hasattr(self, name): + val = getattr(self, name) + if isinstance(val, list): + val.clear() + + def _render_state_array(self, state: torch.Tensor) -> np.ndarray: + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + live = self._get_model_state_vector() + try: + self._load_model_state_vector(state) + arr = self.model(self.ctx).cpu().detach().numpy() + # arr = to_numpy(self.model(self.ctx)).astype(np.float32, copy=False) + finally: + self._load_model_state_vector(live) + return arr + + @property + def render_initialized(self) -> np.ndarray: + if self.state_initialized is None: + raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") + return self._render_state_array(self.state_initialized) + + @property + def render_mean_refined(self) -> np.ndarray: + if self.state_mean_refined is None: + raise RuntimeError( + "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." + ) + return self._render_state_array(self.state_mean_refined) + + @property + def render_current(self) -> np.ndarray: + if self.state_current is None: + raise RuntimeError("current state is unavailable. Call .define_model(...) first.") + return self._render_state_array(self.state_current) + + def reset( + self, reset_to: Literal["initialized", "mean_refined"] = "mean_refined" + ) -> "ModelDiffraction": + if reset_to == "initialized": + state = self.state_initialized + if state is None: + raise RuntimeError( + "initialized state is unavailable. Call .define_model(...) first." + ) + elif reset_to == "mean_refined": + state = self.state_mean_refined + if state is None: + raise RuntimeError( + "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." + ) + else: + raise ValueError("reset_to must be 'initialized' or 'mean_refined'.") + + self._load_model_state_vector(state) + self.state_current = self._get_model_state_vector() + self._clear_histories() + return self + def preprocess( self, *, @@ -184,12 +259,11 @@ def define_model( self.target_mean = torch.as_tensor(self.image_ref, device=dev, dtype=dt) x0 = parameters_to_vector(self.model.parameters()).detach().clone() - self.x_defined = x0.detach().clone() - self.x_initial = x0.detach().clone() - self.x_mean = x0.detach().clone() + self.state_initialized = x0.detach().clone() + self.state_current = x0.detach().clone() + self.state_mean_refined = None self.mean_refined = False - self.mean_fit_history = [] - self.mean_fit_lrs = [] + self._clear_histories() self.remove_optimizer() return self @@ -197,15 +271,23 @@ def fit_mean_diffraction_pattern( self, *, n_steps: int = 200, + reset: bool | Literal["initialized", "mean_refined"] = False, optimizer_params: dict | None = None, scheduler_params: dict | None = None, constraint_weight: float = 1.0, - overwrite_initial: bool = True, progress: bool = False, **kwargs: Any, ) -> "ModelDiffraction": if self.model is None or self.ctx is None or self.target_mean is None: raise RuntimeError("Call .define_model(...) first.") + if reset is True: + self.reset("initialized") + elif isinstance(reset, str): + if reset not in ("initialized", "mean_refined"): + raise ValueError("reset must be False, True, 'initialized', or 'mean_refined'.") + self.reset(reset_to=cast(Literal["initialized", "mean_refined"], reset)) + elif reset not in (False,): + raise ValueError("reset must be False, True, 'initialized', or 'mean_refined'.") optimizer_rebuilt = False if optimizer_params is not None: @@ -234,8 +316,6 @@ def fit_mean_diffraction_pattern( except Exception: warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) - self.mean_fit_history = [] - self.mean_fit_lrs = [] for _ in iterator: self.zero_optimizer_grad() pred = self.model(self.ctx) @@ -255,11 +335,10 @@ def fit_mean_diffraction_pattern( self.mean_fit_history.append(loss_value) self.mean_fit_lrs.append(float(self.get_current_lr())) - x_fit = parameters_to_vector(self.model.parameters()).detach().clone() - self.x_mean = x_fit + x_fit = self._get_model_state_vector() + self.state_current = x_fit.detach().clone() + self.state_mean_refined = x_fit.detach().clone() self.mean_refined = True - if overwrite_initial: - self.x_initial = x_fit.detach().clone() return self def plot_losses( @@ -343,7 +422,7 @@ def visualize( self.plot_losses(figax=(fig, ax_top), plot_lrs=True) ref = np.asarray(self.image_ref, dtype=np.float32) - pred = to_numpy(self.model.forward(self.ctx)).astype(np.float32, copy=False) + pred = self.render_current refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) vmin = float(min(refp.min(), predp.min())) @@ -388,7 +467,7 @@ def plot_mean_model( raise RuntimeError("Call .define_model(...) first.") ref = np.asarray(self.image_ref, dtype=np.float32) - pred = self.model.forward(self.ctx).detach().cpu().numpy() + pred = self.render_current refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) From 2a9d6403b3c2b9cc0bbfa8c983e87aa0efc36eb3 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Fri, 27 Feb 2026 13:58:21 -0800 Subject: [PATCH 29/88] switching to state_dict saving --- src/quantem/diffraction/model_fitting.py | 54 ++++++++++++------------ 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 81c3af66..7fdd43e4 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -7,7 +7,6 @@ import torch from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey -from torch.nn.utils import parameters_to_vector, vector_to_parameters from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem from quantem.core.fitting.base import AdditiveRenderModel, RenderComponent, RenderContext @@ -48,9 +47,9 @@ def __init__(self, dataset: Any, _token: object | None = None): self.model: AdditiveRenderModel | None = None self.target_mean: torch.Tensor | None = None - self.state_initialized: torch.Tensor | None = None - self.state_mean_refined: torch.Tensor | None = None - self.state_current: torch.Tensor | None = None + self.state_initialized: dict[str, torch.Tensor] | None = None + self.state_mean_refined: dict[str, torch.Tensor] | None = None + self.state_current: dict[str, torch.Tensor] | None = None self.mean_refined: bool = False self.mean_fit_history: list[float] = [] self.mean_fit_lrs: list[float] = [] @@ -70,19 +69,18 @@ def get_optimization_parameters(self) -> Any: return [] return self.model.parameters() - def _get_model_state_vector(self) -> torch.Tensor: + def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: + return {k: v.detach().clone() for k, v in state.items()} + + def _get_model_state_dict_copy(self) -> dict[str, torch.Tensor]: if self.model is None: raise RuntimeError("Call .define_model(...) first.") - return parameters_to_vector(self.model.parameters()).detach().clone() + return self._clone_state_dict(self.model.state_dict()) - def _load_model_state_vector(self, state: torch.Tensor) -> None: + def _load_model_state_dict_copy(self, state: dict[str, torch.Tensor]) -> None: if self.model is None: raise RuntimeError("Call .define_model(...) first.") - dst = next(self.model.parameters(), None) - if dst is None: - raise RuntimeError("Model has no parameters.") - vec = state.detach().clone().to(device=dst.device, dtype=dst.dtype) - vector_to_parameters(vec, self.model.parameters()) + self.model.load_state_dict(self._clone_state_dict(state), strict=True) def _clear_histories(self) -> None: for name in ("mean_fit_history", "mean_fit_lrs", "fit_history", "fit_lrs"): @@ -91,16 +89,15 @@ def _clear_histories(self) -> None: if isinstance(val, list): val.clear() - def _render_state_array(self, state: torch.Tensor) -> np.ndarray: + def _render_state_array(self, state: dict[str, torch.Tensor]) -> np.ndarray: if self.model is None or self.ctx is None: raise RuntimeError("Call .define_model(...) first.") - live = self._get_model_state_vector() + live = self._get_model_state_dict_copy() try: - self._load_model_state_vector(state) + self._load_model_state_dict_copy(state) arr = self.model(self.ctx).cpu().detach().numpy() - # arr = to_numpy(self.model(self.ctx)).astype(np.float32, copy=False) finally: - self._load_model_state_vector(live) + self._load_model_state_dict_copy(live) return arr @property @@ -119,9 +116,10 @@ def render_mean_refined(self) -> np.ndarray: @property def render_current(self) -> np.ndarray: - if self.state_current is None: - raise RuntimeError("current state is unavailable. Call .define_model(...) first.") - return self._render_state_array(self.state_current) + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + # Current render follows live module parameters by design. + return self.model(self.ctx).cpu().detach().numpy() def reset( self, reset_to: Literal["initialized", "mean_refined"] = "mean_refined" @@ -141,8 +139,8 @@ def reset( else: raise ValueError("reset_to must be 'initialized' or 'mean_refined'.") - self._load_model_state_vector(state) - self.state_current = self._get_model_state_vector() + self._load_model_state_dict_copy(state) + self.state_current = self._get_model_state_dict_copy() self._clear_histories() return self @@ -258,9 +256,9 @@ def define_model( self.ctx = RenderContext(shape=(h, w), device=dev, dtype=dt, mask=mask_t, fields={}) self.target_mean = torch.as_tensor(self.image_ref, device=dev, dtype=dt) - x0 = parameters_to_vector(self.model.parameters()).detach().clone() - self.state_initialized = x0.detach().clone() - self.state_current = x0.detach().clone() + s0 = self._get_model_state_dict_copy() + self.state_initialized = s0 + self.state_current = self._clone_state_dict(s0) self.state_mean_refined = None self.mean_refined = False self._clear_histories() @@ -335,9 +333,9 @@ def fit_mean_diffraction_pattern( self.mean_fit_history.append(loss_value) self.mean_fit_lrs.append(float(self.get_current_lr())) - x_fit = self._get_model_state_vector() - self.state_current = x_fit.detach().clone() - self.state_mean_refined = x_fit.detach().clone() + s_fit = self._get_model_state_dict_copy() + self.state_current = s_fit + self.state_mean_refined = self._clone_state_dict(s_fit) self.mean_refined = True return self From a9b4eaabffa0d4e1d613383f939e25b4262cbb13 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Fri, 27 Feb 2026 14:43:58 -0800 Subject: [PATCH 30/88] first version of FitBase --- src/quantem/core/fitting/base.py | 103 +++++++++++++++++++++++ src/quantem/diffraction/model_fitting.py | 97 ++++++++++----------- 2 files changed, 148 insertions(+), 52 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index c37ccef9..fa3dbf25 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -1,10 +1,14 @@ from __future__ import annotations +from abc import abstractmethod from dataclasses import dataclass, field from typing import Any, cast import torch from torch import nn +from tqdm.auto import tqdm + +from quantem.core.ml.optimizer_mixin import OptimizerMixin @dataclass @@ -48,6 +52,105 @@ def total_constraint_loss(self, ctx: RenderContext) -> torch.Tensor: return loss +@dataclass +class FitResult: + losses: list[float] + lrs: list[float] + final_loss: float + num_steps: int + metrics: dict[str, list[float]] = field(default_factory=dict) + + +class FitBase(OptimizerMixin): + DEFAULT_LR = 1e-2 + DEFAULT_OPTIMIZER_TYPE = "adam" + + def __init__(self): + super().__init__() + self.fit_history_by_run: dict[str, FitResult] = {} + + @abstractmethod + def _forward_for_fit(self, *, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: + raise NotImplementedError + + @abstractmethod + def _fidelity_loss( + self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any + ) -> torch.Tensor: + raise NotImplementedError + + @abstractmethod + def _soft_losses( + self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any + ) -> dict[str, torch.Tensor]: + return {} + + def fit_render( + self, + *, + target: torch.Tensor, + n_steps: int, + optimizer_params: dict | None = None, + scheduler_params: dict | None = None, + progress: bool = False, + run_key: str = "default", + **kwargs: Any, + ) -> FitResult: + optimizer_rebuilt = False + if optimizer_params is not None: + self.set_optimizer(optimizer_params) + optimizer_rebuilt = True + elif self.optimizer is None: + if self.optimizer_params: + self.set_optimizer(self.optimizer_params) + else: + self.set_optimizer( + { + "type": getattr(self, "DEFAULT_OPTIMIZER_TYPE", "adamw"), + "lr": float(getattr(self, "DEFAULT_LR", self.DEFAULT_LR)), + } + ) + optimizer_rebuilt = True + + if scheduler_params is not None: + self.set_scheduler(scheduler_params, num_iter=int(n_steps)) + elif self.scheduler is None and self.scheduler_params: + self.set_scheduler(self.scheduler_params, num_iter=int(n_steps)) + elif optimizer_rebuilt and self.scheduler is not None and self.optimizer is not None: + self.scheduler.optimizer = self.optimizer + + pbar = tqdm(range(int(n_steps)), desc="Fit render", disable=not progress) + + losses: list[float] = [] + lrs: list[float] = [] + metrics: dict[str, list[float]] = {} + for _ in pbar: + self.zero_optimizer_grad() + pred = self._forward_for_fit(target=target, **kwargs) + fidelity_loss = self._fidelity_loss(pred, target, **kwargs) + soft_losses = self._soft_losses(pred, target, **kwargs) + total_loss = fidelity_loss + for k, v in soft_losses.items(): + metrics.setdefault(k, []).append(float(v.detach().cpu())) + total_loss = total_loss + v + total_loss.backward() + self.step_optimizer() + total_loss_value = float(total_loss.detach().cpu()) + self.step_scheduler(total_loss_value) + losses.append(total_loss_value) + lrs.append(float(self.get_current_lr())) + + result = FitResult( + losses=losses, + lrs=lrs, + final_loss=(losses[-1] if losses else float("nan")), + num_steps=int(n_steps), + metrics=metrics, + ) + self.fit_history_by_run[str(run_key)] = result + return result + + Component = RenderComponent ModelContext = RenderContext Model = AdditiveRenderModel diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 7fdd43e4..61fbef82 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from typing import Any, Literal, Sequence, cast import numpy as np @@ -9,10 +8,14 @@ from scipy.signal.windows import tukey from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem -from quantem.core.fitting.base import AdditiveRenderModel, RenderComponent, RenderContext +from quantem.core.fitting.base import ( + AdditiveRenderModel, + FitBase, + RenderComponent, + RenderContext, +) from quantem.core.fitting.diffraction import OriginND from quantem.core.io.serialize import AutoSerialize -from quantem.core.ml.optimizer_mixin import OptimizerMixin from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.core.visualization import show_2d @@ -27,16 +30,16 @@ def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) return float(cast(float | int, value)) -class ModelDiffraction(OptimizerMixin, AutoSerialize): +class ModelDiffraction(FitBase, AutoSerialize): _token = object() - DEFAULT_LR = 1e-3 - DEFAULT_OPTIMIZER_TYPE = "adamw" + DEFAULT_LR = 5e-2 + DEFAULT_OPTIMIZER_TYPE = "adam" def __init__(self, dataset: Any, _token: object | None = None): if _token is not self._token: raise RuntimeError("Use ModelDiffraction.from_dataset() or .from_file().") AutoSerialize.__init__(self) - OptimizerMixin.__init__(self) + FitBase.__init__(self) self.dataset = dataset self.metadata: dict[str, Any] = {} self.image_ref: np.ndarray | None = None @@ -69,6 +72,30 @@ def get_optimization_parameters(self) -> Any: return [] return self.model.parameters() + def _forward_for_fit(self, *, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + return self.model(self.ctx) + + def _fidelity_loss( + self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any + ) -> torch.Tensor: + if self.ctx is not None and self.ctx.mask is not None: + diff = (pred - target) * self.ctx.mask + denom = torch.clamp(torch.sum(self.ctx.mask), min=1.0) + return torch.sum(diff * diff) / denom + return torch.mean((pred - target) ** 2) + + def _soft_constraints( + self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any + ) -> dict[str, torch.Tensor]: + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + constraint_weight = float(kwargs.get("constraint_weight", 1.0)) + if constraint_weight == 0.0: + return {} + return {"constraint": constraint_weight * self.model.total_constraint_loss(self.ctx)} + def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: return {k: v.detach().clone() for k, v in state.items()} @@ -287,51 +314,17 @@ def fit_mean_diffraction_pattern( elif reset not in (False,): raise ValueError("reset must be False, True, 'initialized', or 'mean_refined'.") - optimizer_rebuilt = False - if optimizer_params is not None: - self.set_optimizer(optimizer_params) - optimizer_rebuilt = True - elif self.optimizer is None: - if self.optimizer_params: - self.set_optimizer(self.optimizer_params) - else: - self.set_optimizer({"type": self.DEFAULT_OPTIMIZER_TYPE, "lr": self.DEFAULT_LR}) - optimizer_rebuilt = True - - if scheduler_params is not None: - self.set_scheduler(scheduler_params, num_iter=int(n_steps)) - elif self.scheduler is None and self.scheduler_params: - self.set_scheduler(self.scheduler_params, num_iter=int(n_steps)) - elif optimizer_rebuilt and self.scheduler is not None and self.optimizer is not None: - self.scheduler.optimizer = self.optimizer - - iterator: Any = range(int(n_steps)) - if progress: - try: - from tqdm.auto import trange - - iterator = trange(int(n_steps), desc="Fit mean diffraction", leave=False) - except Exception: - warnings.warn("progress=True requested but tqdm is unavailable.", stacklevel=2) - - for _ in iterator: - self.zero_optimizer_grad() - pred = self.model(self.ctx) - if self.ctx.mask is not None: - diff = (pred - self.target_mean) * self.ctx.mask - denom = torch.clamp(torch.sum(self.ctx.mask), min=1.0) - loss_data = torch.sum(diff * diff) / denom - else: - loss_data = torch.mean((pred - self.target_mean) ** 2) - loss = loss_data + float(constraint_weight) * self.model.total_constraint_loss( - self.ctx - ) - loss.backward() - self.step_optimizer() - loss_value = float(loss.detach().cpu()) - self.step_scheduler(loss_value) - self.mean_fit_history.append(loss_value) - self.mean_fit_lrs.append(float(self.get_current_lr())) + fit_result = self.fit_render( + target=self.target_mean, + n_steps=int(n_steps), + optimizer_params=optimizer_params, + scheduler_params=scheduler_params, + progress=bool(progress), + run_key="mean", + constraint_weight=float(constraint_weight), + ) + self.mean_fit_history.extend(fit_result.losses) + self.mean_fit_lrs.extend(fit_result.lrs) s_fit = self._get_model_state_dict_copy() self.state_current = s_fit From b79e32c855a910349e1005cb65348e27ef2d321a Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Fri, 27 Feb 2026 16:29:05 -0800 Subject: [PATCH 31/88] cleaning up FitBase and ModelDiffraction --- src/quantem/core/fitting/__init__.py | 2 + src/quantem/core/fitting/background.py | 20 +--- src/quantem/core/fitting/base.py | 116 ++++++++++++++++------- src/quantem/core/fitting/diffraction.py | 33 +------ src/quantem/diffraction/model_fitting.py | 79 ++++----------- 5 files changed, 108 insertions(+), 142 deletions(-) diff --git a/src/quantem/core/fitting/__init__.py b/src/quantem/core/fitting/__init__.py index f7d3c3e3..4dae1661 100644 --- a/src/quantem/core/fitting/__init__.py +++ b/src/quantem/core/fitting/__init__.py @@ -3,6 +3,7 @@ from quantem.core.fitting.base import Component as Component from quantem.core.fitting.base import Model as Model from quantem.core.fitting.base import ModelContext as ModelContext +from quantem.core.fitting.base import OriginND as OriginND from quantem.core.fitting.base import Parameter as Parameter from quantem.core.fitting.diffraction import DiskTemplate as DiskTemplate @@ -16,6 +17,7 @@ "ModelContext", "Component", "Model", + "OriginND", "DiskTemplate", "SyntheticDiskLattice", "DCBackground", diff --git a/src/quantem/core/fitting/background.py b/src/quantem/core/fitting/background.py index 7c50ea42..7a15d37b 100644 --- a/src/quantem/core/fitting/background.py +++ b/src/quantem/core/fitting/background.py @@ -6,8 +6,7 @@ import torch from torch import nn -from quantem.core.fitting.base import RenderComponent, RenderContext -from quantem.core.fitting.diffraction import OriginND +from quantem.core.fitting.base import OriginND, RenderComponent, RenderContext def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: @@ -20,14 +19,6 @@ def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) return float(cast(float | int, value)) -def _softplus_pos(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: - return torch.nn.functional.softplus(x) + eps - - -def _relu_pos(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: - return torch.nn.functional.relu(x) + eps - - class DCBackground(RenderComponent): def __init__( self, @@ -42,8 +33,7 @@ def __init__( ) def forward(self, ctx: RenderContext) -> torch.Tensor: - inten = _relu_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) - # inten = _softplus_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + inten = torch.clamp(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype), min=0.0) return torch.ones(ctx.shape, device=ctx.device, dtype=ctx.dtype) * inten @@ -79,9 +69,7 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: cc = torch.arange(ctx.shape[1], device=ctx.device, dtype=ctx.dtype)[None, :] r0, c0 = self.origin.coords[0], self.origin.coords[1] - sigma = _relu_pos(self.sigma_raw.to(device=ctx.device, dtype=ctx.dtype), eps=1e-6) - # sigma = _softplus_pos(self.sigma_raw.to(device=ctx.device, dtype=ctx.dtype), eps=1e-6) - inten = _relu_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) - # inten = _softplus_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + sigma = torch.clamp(self.sigma_raw.to(device=ctx.device, dtype=ctx.dtype), min=1e-6) + inten = torch.clamp(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype), min=0.0) r2 = (rr - r0) ** 2 + (cc - c0) ** 2 return inten * torch.exp(-0.5 * r2 / (sigma * sigma)) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index fa3dbf25..d919a499 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -1,8 +1,7 @@ from __future__ import annotations -from abc import abstractmethod from dataclasses import dataclass, field -from typing import Any, cast +from typing import Any, Sequence, cast import torch from torch import nn @@ -20,6 +19,17 @@ class RenderContext: fields: dict[str, Any] = field(default_factory=dict) +class OriginND(nn.Module): + def __init__(self, *, ndim: int, init: Sequence[float]): + super().__init__() + if int(ndim) <= 0: + raise ValueError("ndim must be >= 1.") + if len(init) != int(ndim): + raise ValueError("init length must match ndim.") + self.ndim = int(ndim) + self.coords = nn.Parameter(torch.as_tensor(init, dtype=torch.float32).reshape(self.ndim)) + + class RenderComponent(nn.Module): def forward(self, ctx: RenderContext) -> torch.Tensor: raise NotImplementedError @@ -37,10 +47,8 @@ def __init__(self, *, origin: nn.Module, components: list[RenderComponent]): def forward(self, ctx: RenderContext) -> torch.Tensor: if len(self.components) == 0: return torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) - first = cast(RenderComponent, self.components[0]) - out = first(ctx) - for module in self.components[1:]: - component = cast(RenderComponent, module) + out = self.components[0](ctx) + for component in self.components[1:]: out = out + component(ctx) return out @@ -67,29 +75,62 @@ class FitBase(OptimizerMixin): def __init__(self): super().__init__() - self.fit_history_by_run: dict[str, FitResult] = {} + self.model: AdditiveRenderModel | None = None + self.ctx: RenderContext | None = None + self.fit_history: dict[str, FitResult] = {} + self.state_initialized: dict[str, torch.Tensor] | None = None + + def get_optimization_parameters(self) -> Any: + if self.model is None: + return [] + return self.model.parameters() + + def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: + return {k: v.detach().clone() for k, v in state.items()} + + def _get_model_state_dict_copy(self) -> dict[str, torch.Tensor]: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return self._clone_state_dict(self.model.state_dict()) + + def _load_model_state_dict_copy(self, state: dict[str, torch.Tensor]) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + self.model.load_state_dict(self._clone_state_dict(state), strict=True) + + @property + def state_current(self) -> dict[str, torch.Tensor] | None: + if self.model is None: + return None + return self._get_model_state_dict_copy() + + def _clear_fit_history_all(self) -> None: + self.fit_history.clear() + + def _clear_fit_history_run(self, run_key: str) -> None: + self.fit_history.pop(str(run_key), None) - @abstractmethod def _forward_for_fit(self, *, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: - raise NotImplementedError + if self.model is None or self.ctx is None: + raise RuntimeError("Model and context are not defined for fitting.") + return self.model(self.ctx) - @abstractmethod - def _fidelity_loss( - self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any - ) -> torch.Tensor: + def _data_loss(self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: raise NotImplementedError - @abstractmethod - def _soft_losses( + def _constraint_loss( self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any - ) -> dict[str, torch.Tensor]: - return {} + ) -> torch.Tensor: + if self.model is None or self.ctx is None: + raise RuntimeError("Model and context are not defined for fitting.") + return self.model.total_constraint_loss(self.ctx) def fit_render( self, *, target: torch.Tensor, n_steps: int, + constraint_weight: float = 1.0, optimizer_params: dict | None = None, scheduler_params: dict | None = None, progress: bool = False, @@ -112,27 +153,24 @@ def fit_render( ) optimizer_rebuilt = True + n_steps = int(n_steps) if scheduler_params is not None: - self.set_scheduler(scheduler_params, num_iter=int(n_steps)) + self.set_scheduler(scheduler_params, num_iter=n_steps) elif self.scheduler is None and self.scheduler_params: - self.set_scheduler(self.scheduler_params, num_iter=int(n_steps)) + self.set_scheduler(self.scheduler_params, num_iter=n_steps) elif optimizer_rebuilt and self.scheduler is not None and self.optimizer is not None: self.scheduler.optimizer = self.optimizer - pbar = tqdm(range(int(n_steps)), desc="Fit render", disable=not progress) + pbar = tqdm(range(n_steps), desc="Fit render", disable=not progress) losses: list[float] = [] lrs: list[float] = [] - metrics: dict[str, list[float]] = {} for _ in pbar: self.zero_optimizer_grad() pred = self._forward_for_fit(target=target, **kwargs) - fidelity_loss = self._fidelity_loss(pred, target, **kwargs) - soft_losses = self._soft_losses(pred, target, **kwargs) - total_loss = fidelity_loss - for k, v in soft_losses.items(): - metrics.setdefault(k, []).append(float(v.detach().cpu())) - total_loss = total_loss + v + data_loss = self._data_loss(pred, target, **kwargs) + constraint_loss = self._constraint_loss(pred, target, **kwargs) + total_loss = data_loss + constraint_weight * constraint_loss total_loss.backward() self.step_optimizer() total_loss_value = float(total_loss.detach().cpu()) @@ -140,14 +178,22 @@ def fit_render( losses.append(total_loss_value) lrs.append(float(self.get_current_lr())) - result = FitResult( - losses=losses, - lrs=lrs, - final_loss=(losses[-1] if losses else float("nan")), - num_steps=int(n_steps), - metrics=metrics, - ) - self.fit_history_by_run[str(run_key)] = result + key = str(run_key) + if key in self.fit_history: + prev = self.fit_history[key] + prev.losses.extend(losses) + prev.lrs.extend(lrs) + prev.final_loss = prev.losses[-1] if prev.losses else float("nan") + prev.num_steps = len(prev.losses) + result = prev + else: + result = FitResult( + losses=losses, + lrs=lrs, + final_loss=(losses[-1] if losses else float("nan")), + num_steps=n_steps, + ) + self.fit_history[key] = result return result diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 13224632..0872ffb5 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -6,7 +6,7 @@ import torch from torch import nn -from quantem.core.fitting.base import RenderComponent, RenderContext +from quantem.core.fitting.base import OriginND, RenderComponent, RenderContext def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: @@ -19,14 +19,6 @@ def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) return float(cast(float | int, value)) -def _softplus_pos(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: - return torch.nn.functional.softplus(x) + eps - - -def _relu_pos(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: - return torch.nn.functional.relu(x) + eps - - def _splat_patch( out: torch.Tensor, *, @@ -65,17 +57,6 @@ def put(rr: torch.Tensor, cc: torch.Tensor, ww: torch.Tensor) -> None: put(r0i + 1, c0i + 1, w11) -class OriginND(nn.Module): - def __init__(self, *, ndim: int, init: Sequence[float]): - super().__init__() - if int(ndim) <= 0: - raise ValueError("ndim must be >= 1.") - if len(init) != int(ndim): - raise ValueError("init length must match ndim.") - self.ndim = int(ndim) - self.coords = nn.Parameter(torch.as_tensor(init, dtype=torch.float32).reshape(self.ndim)) - - class DiskTemplate(RenderComponent): def __init__( self, @@ -156,8 +137,7 @@ def set_origin(self, origin: OriginND) -> None: def patch_values(self) -> torch.Tensor: if self.refine_all_pixels: - return _relu_pos(self.template_raw).reshape(-1) - # return _softplus_pos(self.template_raw).reshape(-1) + return torch.clamp(self.template_raw, min=0.0).reshape(-1) return self.template_raw.reshape(-1) def patch_offsets(self) -> tuple[torch.Tensor, torch.Tensor]: @@ -182,8 +162,7 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: r0, c0 = self.origin.coords[0], self.origin.coords[1] if self.intensity_raw is None: raise RuntimeError("DiskTemplate intensity parameter is missing.") - scale = _relu_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) - # scale = _softplus_pos(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype)) + scale = torch.clamp(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype), min=0.0) self.add_patch(out, r0=r0, c0=c0, scale=scale) return out @@ -349,8 +328,7 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: cc0 = centers_c[j] if self.per_disk_intensity: - inten = _relu_pos(self.i0_raw[j]) - # inten = _softplus_pos(self.i0_raw[j]) + inten = torch.clamp(self.i0_raw[j], min=0.0) if active_order >= 1 and self.ir is not None and self.ic is not None: inten = inten + self.ir[j] * dr + self.ic[j] * dc if ( @@ -362,8 +340,7 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: inten = inten + self.irr[j] * dr2 + self.icc[j] * dc2 + self.irc[j] * drdc inten = torch.clamp(inten, min=0.0) else: - inten = _relu_pos(self.i0_raw) - # inten = _softplus_pos(self.i0_raw) + inten = torch.clamp(self.i0_raw, min=0.0) if active_order >= 1: assert self.ir is not None and self.ic is not None inten = inten + self.ir * rr0 + self.ic * cc0 diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 61fbef82..8b6df7c3 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -11,10 +11,10 @@ from quantem.core.fitting.base import ( AdditiveRenderModel, FitBase, + OriginND, RenderComponent, RenderContext, ) -from quantem.core.fitting.diffraction import OriginND from quantem.core.io.serialize import AutoSerialize from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.core.visualization import show_2d @@ -46,16 +46,10 @@ def __init__(self, dataset: Any, _token: object | None = None): self.preprocess_shifts: np.ndarray | None = None self.index_shape: tuple[int, ...] | None = None - self.ctx: RenderContext | None = None - self.model: AdditiveRenderModel | None = None self.target_mean: torch.Tensor | None = None - self.state_initialized: dict[str, torch.Tensor] | None = None self.state_mean_refined: dict[str, torch.Tensor] | None = None - self.state_current: dict[str, torch.Tensor] | None = None self.mean_refined: bool = False - self.mean_fit_history: list[float] = [] - self.mean_fit_lrs: list[float] = [] @classmethod def from_dataset( @@ -67,55 +61,13 @@ def from_dataset( "from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance." ) - def get_optimization_parameters(self) -> Any: - if self.model is None: - return [] - return self.model.parameters() - - def _forward_for_fit(self, *, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: - if self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - return self.model(self.ctx) - - def _fidelity_loss( - self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any - ) -> torch.Tensor: + def _data_loss(self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: if self.ctx is not None and self.ctx.mask is not None: diff = (pred - target) * self.ctx.mask denom = torch.clamp(torch.sum(self.ctx.mask), min=1.0) return torch.sum(diff * diff) / denom return torch.mean((pred - target) ** 2) - def _soft_constraints( - self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any - ) -> dict[str, torch.Tensor]: - if self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - constraint_weight = float(kwargs.get("constraint_weight", 1.0)) - if constraint_weight == 0.0: - return {} - return {"constraint": constraint_weight * self.model.total_constraint_loss(self.ctx)} - - def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: - return {k: v.detach().clone() for k, v in state.items()} - - def _get_model_state_dict_copy(self) -> dict[str, torch.Tensor]: - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - return self._clone_state_dict(self.model.state_dict()) - - def _load_model_state_dict_copy(self, state: dict[str, torch.Tensor]) -> None: - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - self.model.load_state_dict(self._clone_state_dict(state), strict=True) - - def _clear_histories(self) -> None: - for name in ("mean_fit_history", "mean_fit_lrs", "fit_history", "fit_lrs"): - if hasattr(self, name): - val = getattr(self, name) - if isinstance(val, list): - val.clear() - def _render_state_array(self, state: dict[str, torch.Tensor]) -> np.ndarray: if self.model is None or self.ctx is None: raise RuntimeError("Call .define_model(...) first.") @@ -157,18 +109,21 @@ def reset( raise RuntimeError( "initialized state is unavailable. Call .define_model(...) first." ) + self._clear_fit_history_all() elif reset_to == "mean_refined": state = self.state_mean_refined if state is None: raise RuntimeError( "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." ) + mean_hist = self.fit_history.get("mean") + self._clear_fit_history_all() + if mean_hist is not None: + self.fit_history["mean"] = mean_hist else: raise ValueError("reset_to must be 'initialized' or 'mean_refined'.") self._load_model_state_dict_copy(state) - self.state_current = self._get_model_state_dict_copy() - self._clear_histories() return self def preprocess( @@ -285,10 +240,9 @@ def define_model( s0 = self._get_model_state_dict_copy() self.state_initialized = s0 - self.state_current = self._clone_state_dict(s0) self.state_mean_refined = None self.mean_refined = False - self._clear_histories() + self._clear_fit_history_all() self.remove_optimizer() return self @@ -314,20 +268,17 @@ def fit_mean_diffraction_pattern( elif reset not in (False,): raise ValueError("reset must be False, True, 'initialized', or 'mean_refined'.") - fit_result = self.fit_render( + self.fit_render( target=self.target_mean, n_steps=int(n_steps), + constraint_weight=float(constraint_weight), optimizer_params=optimizer_params, scheduler_params=scheduler_params, progress=bool(progress), run_key="mean", - constraint_weight=float(constraint_weight), ) - self.mean_fit_history.extend(fit_result.losses) - self.mean_fit_lrs.extend(fit_result.lrs) s_fit = self._get_model_state_dict_copy() - self.state_current = s_fit self.state_mean_refined = self._clone_state_dict(s_fit) self.mean_refined = True return self @@ -342,7 +293,8 @@ def plot_losses( else: fig, ax = figax - losses = np.asarray(self.mean_fit_history, dtype=np.float64) + mean_hist = self.fit_history.get("mean") + losses = np.asarray([] if mean_hist is None else mean_hist.losses, dtype=np.float64) if losses.size == 0: ax.text( 0.5, @@ -368,7 +320,7 @@ def plot_losses( ax.spines["left"].set_color("k") ax.set_xbound(-2, max(1, int(iters.max())) + 2) - lrs = np.asarray(self.mean_fit_lrs, dtype=np.float64) + lrs = np.asarray([] if mean_hist is None else mean_hist.lrs, dtype=np.float64) if plot_lrs and lrs.size > 0: if lrs.size == losses.size and not np.allclose(lrs, lrs[0]): ax_lr = ax.twinx() @@ -435,9 +387,10 @@ def visualize( vmax=vmax, ) - if len(self.mean_fit_history) > 0: + mean_hist = self.fit_history.get("mean") + if mean_hist is not None and len(mean_hist.losses) > 0: fig.suptitle( - f"Final loss: {self.mean_fit_history[-1]:.3e} | Iters: {len(self.mean_fit_history)}", + f"Final loss: {mean_hist.losses[-1]:.3e} | Iters: {len(mean_hist.losses)}", fontsize=13, y=0.98, ) From e0aeb0bfe1fe33a889c77ec64425d202fa2c5695 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Fri, 27 Feb 2026 16:43:11 -0800 Subject: [PATCH 32/88] moving more stuff to FitBase --- src/quantem/core/fitting/base.py | 53 ++++++++++++++++++++++-- src/quantem/diffraction/model_fitting.py | 34 +-------------- 2 files changed, 51 insertions(+), 36 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index d919a499..4b60fa5c 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -1,8 +1,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Sequence, cast +from typing import Any, Literal, Self, Sequence, cast +import numpy as np import torch from torch import nn from tqdm.auto import tqdm @@ -79,6 +80,7 @@ def __init__(self): self.ctx: RenderContext | None = None self.fit_history: dict[str, FitResult] = {} self.state_initialized: dict[str, torch.Tensor] | None = None + self.loss_fn = torch.nn.MSELoss(reduction="mean") def get_optimization_parameters(self) -> Any: if self.model is None: @@ -110,13 +112,56 @@ def _clear_fit_history_all(self) -> None: def _clear_fit_history_run(self, run_key: str) -> None: self.fit_history.pop(str(run_key), None) + def _render_state_array(self, state: dict[str, torch.Tensor]) -> np.ndarray: + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + live = self._get_model_state_dict_copy() + try: + self._load_model_state_dict_copy(state) + arr = self.model(self.ctx).detach().cpu().numpy() + finally: + self._load_model_state_dict_copy(live) + return arr + + @property + def render_initialized(self) -> np.ndarray: + if self.state_initialized is None: + raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") + return self._render_state_array(self.state_initialized) + + @property + def render_current(self) -> np.ndarray: + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + return self.model(self.ctx).detach().cpu().numpy() + + def reset( + self, + reset_to: Literal["initialized"] = "initialized", + ) -> Self: + if reset_to != "initialized": + raise ValueError("FitBase.reset only supports reset_to='initialized'.") + if self.state_initialized is None: + raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") + self._load_model_state_dict_copy(self.state_initialized) + self._clear_fit_history_all() + return self + def _forward_for_fit(self, *, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: if self.model is None or self.ctx is None: raise RuntimeError("Model and context are not defined for fitting.") return self.model(self.ctx) - def _data_loss(self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: - raise NotImplementedError + def _fidelity_loss( + self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any + ) -> torch.Tensor: + if self.ctx is not None and self.ctx.mask is not None: + # TODO -- use loss modules (currently implemented in tomo branch) + # and update them to allow for masking at module level + diff = (pred - target) * self.ctx.mask + denom = torch.clamp(torch.sum(self.ctx.mask), min=1.0) + return torch.sum(diff * diff) / denom + return self.loss_fn(pred, target) def _constraint_loss( self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any @@ -168,7 +213,7 @@ def fit_render( for _ in pbar: self.zero_optimizer_grad() pred = self._forward_for_fit(target=target, **kwargs) - data_loss = self._data_loss(pred, target, **kwargs) + data_loss = self._fidelity_loss(pred, target, **kwargs) constraint_loss = self._constraint_loss(pred, target, **kwargs) total_loss = data_loss + constraint_weight * constraint_loss total_loss.backward() diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 8b6df7c3..1cbef162 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -61,30 +61,6 @@ def from_dataset( "from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance." ) - def _data_loss(self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: - if self.ctx is not None and self.ctx.mask is not None: - diff = (pred - target) * self.ctx.mask - denom = torch.clamp(torch.sum(self.ctx.mask), min=1.0) - return torch.sum(diff * diff) / denom - return torch.mean((pred - target) ** 2) - - def _render_state_array(self, state: dict[str, torch.Tensor]) -> np.ndarray: - if self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - live = self._get_model_state_dict_copy() - try: - self._load_model_state_dict_copy(state) - arr = self.model(self.ctx).cpu().detach().numpy() - finally: - self._load_model_state_dict_copy(live) - return arr - - @property - def render_initialized(self) -> np.ndarray: - if self.state_initialized is None: - raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") - return self._render_state_array(self.state_initialized) - @property def render_mean_refined(self) -> np.ndarray: if self.state_mean_refined is None: @@ -93,15 +69,9 @@ def render_mean_refined(self) -> np.ndarray: ) return self._render_state_array(self.state_mean_refined) - @property - def render_current(self) -> np.ndarray: - if self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - # Current render follows live module parameters by design. - return self.model(self.ctx).cpu().detach().numpy() - def reset( - self, reset_to: Literal["initialized", "mean_refined"] = "mean_refined" + self, + reset_to: Literal["initialized", "mean_refined"] = "mean_refined", ) -> "ModelDiffraction": if reset_to == "initialized": state = self.state_initialized From 2c67dfebebce89abe14c25603b7ba94787da6ced Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Fri, 27 Feb 2026 16:54:11 -0800 Subject: [PATCH 33/88] reorganizing classes -- no functional change --- src/quantem/core/fitting/__init__.py | 18 ++-- src/quantem/core/fitting/base.py | 115 ++++++++++++----------- src/quantem/diffraction/model_fitting.py | 78 +++++++-------- 3 files changed, 108 insertions(+), 103 deletions(-) diff --git a/src/quantem/core/fitting/__init__.py b/src/quantem/core/fitting/__init__.py index 4dae1661..1bb92620 100644 --- a/src/quantem/core/fitting/__init__.py +++ b/src/quantem/core/fitting/__init__.py @@ -1,25 +1,21 @@ -from __future__ import annotations - +from quantem.core.fitting.background import DCBackground as DCBackground +from quantem.core.fitting.background import GaussianBackground as GaussianBackground from quantem.core.fitting.base import Component as Component from quantem.core.fitting.base import Model as Model from quantem.core.fitting.base import ModelContext as ModelContext from quantem.core.fitting.base import OriginND as OriginND from quantem.core.fitting.base import Parameter as Parameter - from quantem.core.fitting.diffraction import DiskTemplate as DiskTemplate from quantem.core.fitting.diffraction import SyntheticDiskLattice as SyntheticDiskLattice -from quantem.core.fitting.background import DCBackground as DCBackground -from quantem.core.fitting.background import GaussianBackground as GaussianBackground - __all__ = [ - "Parameter", - "ModelContext", "Component", + "DCBackground", + "DiskTemplate", + "GaussianBackground", "Model", + "ModelContext", "OriginND", - "DiskTemplate", + "Parameter", "SyntheticDiskLattice", - "DCBackground", - "GaussianBackground", ] diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 4b60fa5c..dfce2e7b 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -76,53 +76,28 @@ class FitBase(OptimizerMixin): def __init__(self): super().__init__() + # Core wiring + self.loss_fn = torch.nn.MSELoss(reduction="mean") self.model: AdditiveRenderModel | None = None self.ctx: RenderContext | None = None - self.fit_history: dict[str, FitResult] = {} + + # State/checkpoints self.state_initialized: dict[str, torch.Tensor] | None = None - self.loss_fn = torch.nn.MSELoss(reduction="mean") + + # Histories/results + self.fit_history: dict[str, FitResult] = {} def get_optimization_parameters(self) -> Any: if self.model is None: return [] return self.model.parameters() - def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: - return {k: v.detach().clone() for k, v in state.items()} - - def _get_model_state_dict_copy(self) -> dict[str, torch.Tensor]: - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - return self._clone_state_dict(self.model.state_dict()) - - def _load_model_state_dict_copy(self, state: dict[str, torch.Tensor]) -> None: - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - self.model.load_state_dict(self._clone_state_dict(state), strict=True) - @property def state_current(self) -> dict[str, torch.Tensor] | None: if self.model is None: return None return self._get_model_state_dict_copy() - def _clear_fit_history_all(self) -> None: - self.fit_history.clear() - - def _clear_fit_history_run(self, run_key: str) -> None: - self.fit_history.pop(str(run_key), None) - - def _render_state_array(self, state: dict[str, torch.Tensor]) -> np.ndarray: - if self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - live = self._get_model_state_dict_copy() - try: - self._load_model_state_dict_copy(state) - arr = self.model(self.ctx).detach().cpu().numpy() - finally: - self._load_model_state_dict_copy(live) - return arr - @property def render_initialized(self) -> np.ndarray: if self.state_initialized is None: @@ -147,29 +122,6 @@ def reset( self._clear_fit_history_all() return self - def _forward_for_fit(self, *, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: - if self.model is None or self.ctx is None: - raise RuntimeError("Model and context are not defined for fitting.") - return self.model(self.ctx) - - def _fidelity_loss( - self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any - ) -> torch.Tensor: - if self.ctx is not None and self.ctx.mask is not None: - # TODO -- use loss modules (currently implemented in tomo branch) - # and update them to allow for masking at module level - diff = (pred - target) * self.ctx.mask - denom = torch.clamp(torch.sum(self.ctx.mask), min=1.0) - return torch.sum(diff * diff) / denom - return self.loss_fn(pred, target) - - def _constraint_loss( - self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any - ) -> torch.Tensor: - if self.model is None or self.ctx is None: - raise RuntimeError("Model and context are not defined for fitting.") - return self.model.total_constraint_loss(self.ctx) - def fit_render( self, *, @@ -241,6 +193,59 @@ def fit_render( self.fit_history[key] = result return result + def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: + return {k: v.detach().clone() for k, v in state.items()} + + def _get_model_state_dict_copy(self) -> dict[str, torch.Tensor]: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return self._clone_state_dict(self.model.state_dict()) + + def _load_model_state_dict_copy(self, state: dict[str, torch.Tensor]) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + self.model.load_state_dict(self._clone_state_dict(state), strict=True) + + def _clear_fit_history_all(self) -> None: + self.fit_history.clear() + + def _clear_fit_history_run(self, run_key: str) -> None: + self.fit_history.pop(str(run_key), None) + + def _render_state_array(self, state: dict[str, torch.Tensor]) -> np.ndarray: + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + live = self._get_model_state_dict_copy() + try: + self._load_model_state_dict_copy(state) + arr = self.model(self.ctx).detach().cpu().numpy() + finally: + self._load_model_state_dict_copy(live) + return arr + + def _forward_for_fit(self, *, target: torch.Tensor, **kwargs: Any) -> torch.Tensor: + if self.model is None or self.ctx is None: + raise RuntimeError("Model and context are not defined for fitting.") + return self.model(self.ctx) + + def _fidelity_loss( + self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any + ) -> torch.Tensor: + if self.ctx is not None and self.ctx.mask is not None: + # TODO -- use loss modules (currently implemented in tomo branch) + # and update them to allow for masking at module level + diff = (pred - target) * self.ctx.mask + denom = torch.clamp(torch.sum(self.ctx.mask), min=1.0) + return torch.sum(diff * diff) / denom + return self.loss_fn(pred, target) + + def _constraint_loss( + self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any + ) -> torch.Tensor: + if self.model is None or self.ctx is None: + raise RuntimeError("Model and context are not defined for fitting.") + return self.model.total_constraint_loss(self.ctx) + Component = RenderComponent ModelContext = RenderContext diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 1cbef162..465bfdb8 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -40,17 +40,21 @@ def __init__(self, dataset: Any, _token: object | None = None): raise RuntimeError("Use ModelDiffraction.from_dataset() or .from_file().") AutoSerialize.__init__(self) FitBase.__init__(self) + + # Dataset/input references self.dataset = dataset - self.metadata: dict[str, Any] = {} self.image_ref: np.ndarray | None = None self.preprocess_shifts: np.ndarray | None = None self.index_shape: tuple[int, ...] | None = None - self.target_mean: torch.Tensor | None = None + # Diffraction-specific state/checkpoints self.state_mean_refined: dict[str, torch.Tensor] | None = None self.mean_refined: bool = False + # Misc metadata + self.metadata: dict[str, Any] = {} + @classmethod def from_dataset( cls, dataset: Dataset2d | Dataset3d | Dataset4d | Dataset4dstem | Any @@ -61,41 +65,6 @@ def from_dataset( "from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance." ) - @property - def render_mean_refined(self) -> np.ndarray: - if self.state_mean_refined is None: - raise RuntimeError( - "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." - ) - return self._render_state_array(self.state_mean_refined) - - def reset( - self, - reset_to: Literal["initialized", "mean_refined"] = "mean_refined", - ) -> "ModelDiffraction": - if reset_to == "initialized": - state = self.state_initialized - if state is None: - raise RuntimeError( - "initialized state is unavailable. Call .define_model(...) first." - ) - self._clear_fit_history_all() - elif reset_to == "mean_refined": - state = self.state_mean_refined - if state is None: - raise RuntimeError( - "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." - ) - mean_hist = self.fit_history.get("mean") - self._clear_fit_history_all() - if mean_hist is not None: - self.fit_history["mean"] = mean_hist - else: - raise ValueError("reset_to must be 'initialized' or 'mean_refined'.") - - self._load_model_state_dict_copy(state) - return self - def preprocess( self, *, @@ -253,6 +222,41 @@ def fit_mean_diffraction_pattern( self.mean_refined = True return self + def reset( + self, + reset_to: Literal["initialized", "mean_refined"] = "mean_refined", + ) -> "ModelDiffraction": + if reset_to == "initialized": + state = self.state_initialized + if state is None: + raise RuntimeError( + "initialized state is unavailable. Call .define_model(...) first." + ) + self._clear_fit_history_all() + elif reset_to == "mean_refined": + state = self.state_mean_refined + if state is None: + raise RuntimeError( + "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." + ) + mean_hist = self.fit_history.get("mean") + self._clear_fit_history_all() + if mean_hist is not None: + self.fit_history["mean"] = mean_hist + else: + raise ValueError("reset_to must be 'initialized' or 'mean_refined'.") + + self._load_model_state_dict_copy(state) + return self + + @property + def render_mean_refined(self) -> np.ndarray: + if self.state_mean_refined is None: + raise RuntimeError( + "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." + ) + return self._render_state_array(self.state_mean_refined) + def plot_losses( self, figax: tuple[Any, Any] | None = None, plot_lrs: bool = True ) -> tuple[Any, Any]: From fc283c7cfb4962cdffb5c34e3dbb6635a2f24914 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Fri, 27 Feb 2026 17:05:25 -0800 Subject: [PATCH 34/88] splitting off ModelDiffractionVisualizations into separate file --- src/quantem/diffraction/model_fitting.py | 153 +--------------- .../model_fitting_visualizations.py | 165 ++++++++++++++++++ 2 files changed, 167 insertions(+), 151 deletions(-) create mode 100644 src/quantem/diffraction/model_fitting_visualizations.py diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 465bfdb8..d5c87477 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -17,7 +17,7 @@ ) from quantem.core.io.serialize import AutoSerialize from quantem.core.utils.imaging_utils import cross_correlation_shift -from quantem.core.visualization import show_2d +from quantem.diffraction.model_fitting_visualizations import ModelDiffractionVisualizations def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: @@ -30,7 +30,7 @@ def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) return float(cast(float | int, value)) -class ModelDiffraction(FitBase, AutoSerialize): +class ModelDiffraction(ModelDiffractionVisualizations, FitBase, AutoSerialize): _token = object() DEFAULT_LR = 5e-2 DEFAULT_OPTIMIZER_TYPE = "adam" @@ -256,152 +256,3 @@ def render_mean_refined(self) -> np.ndarray: "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." ) return self._render_state_array(self.state_mean_refined) - - def plot_losses( - self, figax: tuple[Any, Any] | None = None, plot_lrs: bool = True - ) -> tuple[Any, Any]: - import matplotlib.pyplot as plt - - if figax is None: - fig, ax = plt.subplots() - else: - fig, ax = figax - - mean_hist = self.fit_history.get("mean") - losses = np.asarray([] if mean_hist is None else mean_hist.losses, dtype=np.float64) - if losses.size == 0: - ax.text( - 0.5, - 0.5, - "No fit history available", - ha="center", - va="center", - transform=ax.transAxes, - ) - ax.set_xlabel("Iterations") - ax.set_ylabel("Loss") - if figax is None: - plt.tight_layout() - plt.show() - return fig, ax - - iters = np.arange(losses.size) - lines: list[Any] = [] - lines.extend(ax.semilogy(iters, losses, c="k", lw=2, label="loss")) - ax.set_xlabel("Iterations") - ax.set_ylabel("Loss", color="k") - ax.tick_params(axis="y", which="both", colors="k") - ax.spines["left"].set_color("k") - ax.set_xbound(-2, max(1, int(iters.max())) + 2) - - lrs = np.asarray([] if mean_hist is None else mean_hist.lrs, dtype=np.float64) - if plot_lrs and lrs.size > 0: - if lrs.size == losses.size and not np.allclose(lrs, lrs[0]): - ax_lr = ax.twinx() - ax.set_zorder(2) - ax_lr.set_zorder(1) - ax.patch.set_visible(False) - ax_lr.spines["left"].set_visible(False) - lines.extend( - ax_lr.semilogy( - np.arange(lrs.size), lrs, c="tab:blue", lw=2, ls="--", label="LR" - ) - ) - ax_lr.set_ylabel("LR", color="tab:blue") - ax_lr.tick_params(axis="y", which="both", colors="tab:blue") - ax_lr.spines["right"].set_color("tab:blue") - else: - ax.set_title(f"LR: {float(lrs[-1]):.2e}", fontsize=10) - - labels = [line.get_label() for line in lines] - if len(labels) > 1: - ax.legend(lines, labels, loc="upper right") - - if figax is None: - plt.tight_layout() - plt.show() - return fig, ax - - def visualize( - self, *, power: float = 0.25, cbar: bool = False, axsize: tuple[int, int] = (6, 6) - ) -> tuple[Any, Any]: - import matplotlib.pyplot as plt - from matplotlib import gridspec - - if self.image_ref is None: - self.preprocess() - if self.image_ref is None or self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - - fig = plt.figure(figsize=(12, 7)) - gs = gridspec.GridSpec(2, 1, height_ratios=[1, 2], hspace=0.3) - ax_top = fig.add_subplot(gs[0]) - self.plot_losses(figax=(fig, ax_top), plot_lrs=True) - - ref = np.asarray(self.image_ref, dtype=np.float32) - pred = self.render_current - refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) - predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) - vmin = float(min(refp.min(), predp.min())) - vmax = float(max(refp.max(), predp.max())) - - gs_bot = gridspec.GridSpecFromSubplotSpec(1, 2, subplot_spec=gs[1], wspace=0.15) - axs = np.array( - [fig.add_subplot(gs_bot[0, 0]), fig.add_subplot(gs_bot[0, 1])], dtype=object - ) - show_2d( - [refp, predp], - figax=(fig, axs), - title=["image_ref", "model"], - cmap="gray", - cbar=bool(cbar), - returnfig=False, - axsize=axsize, - vmin=vmin, - vmax=vmax, - ) - - mean_hist = self.fit_history.get("mean") - if mean_hist is not None and len(mean_hist.losses) > 0: - fig.suptitle( - f"Final loss: {mean_hist.losses[-1]:.3e} | Iters: {len(mean_hist.losses)}", - fontsize=13, - y=0.98, - ) - plt.show() - return fig, axs - - def plot_mean_model( - self, - *, - power: float = 0.25, - returnfig: bool = False, - axsize: tuple[int, int] = (6, 6), - **_: Any, - ) -> tuple[Any, Any] | None: - if self.image_ref is None: - self.preprocess() - if self.image_ref is None or self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - - ref = np.asarray(self.image_ref, dtype=np.float32) - pred = self.render_current - - refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) - predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) - vmin = float(min(refp.min(), predp.min())) - vmax = float(max(refp.max(), predp.max())) - - fig, ax = show_2d( - [refp, predp], - title=["image_ref", "model"], - cmap="gray", - cbar=False, - returnfig=True, - axsize=axsize, - vmin=vmin, - vmax=vmax, - ) - if returnfig: - return fig, ax - return None diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py new file mode 100644 index 00000000..9bd26ef1 --- /dev/null +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -0,0 +1,165 @@ +from typing import TYPE_CHECKING, Any, cast + +import numpy as np +from matplotlib import gridspec +from matplotlib import pyplot as plt + +from quantem.core.visualization import show_2d + +if TYPE_CHECKING: + from quantem.diffraction.model_fitting import ModelDiffraction + + +class ModelDiffractionVisualizations: + def plot_losses( + self, figax: tuple[Any, Any] | None = None, plot_lrs: bool = True + ) -> tuple[Any, Any]: + md = cast("ModelDiffraction", self) + + if figax is None: + fig, ax = plt.subplots() + else: + fig, ax = figax + + mean_hist = md.fit_history.get("mean") + losses = np.asarray([] if mean_hist is None else mean_hist.losses, dtype=np.float64) + if losses.size == 0: + ax.text( + 0.5, + 0.5, + "No fit history available", + ha="center", + va="center", + transform=ax.transAxes, + ) + ax.set_xlabel("Iterations") + ax.set_ylabel("Loss") + if figax is None: + plt.tight_layout() + plt.show() + return fig, ax + + iters = np.arange(losses.size) + lines: list[Any] = [] + lines.extend(ax.semilogy(iters, losses, c="k", lw=2, label="loss")) + ax.set_xlabel("Iterations") + ax.set_ylabel("Loss", color="k") + ax.tick_params(axis="y", which="both", colors="k") + ax.spines["left"].set_color("k") + ax.set_xbound(-2, max(1, int(iters.max())) + 2) + + lrs = np.asarray([] if mean_hist is None else mean_hist.lrs, dtype=np.float64) + if plot_lrs and lrs.size > 0: + if lrs.size == losses.size and not np.allclose(lrs, lrs[0]): + ax_lr = ax.twinx() + ax.set_zorder(2) + ax_lr.set_zorder(1) + ax.patch.set_visible(False) + ax_lr.spines["left"].set_visible(False) + lines.extend( + ax_lr.semilogy( + np.arange(lrs.size), lrs, c="tab:blue", lw=2, ls="--", label="LR" + ) + ) + ax_lr.set_ylabel("LR", color="tab:blue") + ax_lr.tick_params(axis="y", which="both", colors="tab:blue") + ax_lr.spines["right"].set_color("tab:blue") + else: + ax.set_title(f"LR: {float(lrs[-1]):.2e}", fontsize=10) + + labels = [line.get_label() for line in lines] + if len(labels) > 1: + ax.legend(lines, labels, loc="upper right") + + if figax is None: + plt.tight_layout() + plt.show() + return fig, ax + + def visualize( + self, + *, + power: float = 0.25, + cbar: bool = False, + axsize: tuple[int, int] = (6, 6), + ) -> tuple[Any, Any]: + md = cast("ModelDiffraction", self) + + if md.image_ref is None: + md.preprocess() + if md.image_ref is None or md.model is None or md.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + + fig = plt.figure(figsize=(12, 7)) + gs = gridspec.GridSpec(2, 1, height_ratios=[1, 2], hspace=0.3) + ax_top = fig.add_subplot(gs[0]) + md.plot_losses(figax=(fig, ax_top), plot_lrs=True) + + ref = np.asarray(md.image_ref, dtype=np.float32) + pred = md.render_current + refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) + predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) + vmin = float(min(refp.min(), predp.min())) + vmax = float(max(refp.max(), predp.max())) + + gs_bot = gridspec.GridSpecFromSubplotSpec(1, 2, subplot_spec=gs[1], wspace=0.15) + axs = np.array( + [fig.add_subplot(gs_bot[0, 0]), fig.add_subplot(gs_bot[0, 1])], dtype=object + ) + show_2d( + [refp, predp], + figax=(fig, axs), + title=["image_ref", "model"], + cmap="gray", + cbar=bool(cbar), + returnfig=False, + axsize=axsize, + vmin=vmin, + vmax=vmax, + ) + + mean_hist = md.fit_history.get("mean") + if mean_hist is not None and len(mean_hist.losses) > 0: + fig.suptitle( + f"Final loss: {mean_hist.losses[-1]:.3e} | Iters: {len(mean_hist.losses)}", + fontsize=13, + y=0.98, + ) + plt.show() + return fig, axs + + def plot_mean_model( + self, + *, + power: float = 0.25, + returnfig: bool = False, + axsize: tuple[int, int] = (6, 6), + **_: Any, + ) -> tuple[Any, Any] | None: + md = cast("ModelDiffraction", self) + if md.image_ref is None: + md.preprocess() + if md.image_ref is None or md.model is None or md.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + + ref = np.asarray(md.image_ref, dtype=np.float32) + pred = md.render_current + + refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) + predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) + vmin = float(min(refp.min(), predp.min())) + vmax = float(max(refp.max(), predp.max())) + + fig, ax = show_2d( + [refp, predp], + title=["image_ref", "model"], + cmap="gray", + cbar=False, + returnfig=True, + axsize=axsize, + vmin=vmin, + vmax=vmax, + ) + if returnfig: + return fig, ax + return None From 59aba0861a28f54d138d0f6ccb4a5c29376a53b6 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 15:00:02 -0800 Subject: [PATCH 35/88] adding hard constraints like force_center for DiskTemplate --- src/quantem/core/fitting/background.py | 8 +- src/quantem/core/fitting/base.py | 163 ++++++++++++++++++++++- src/quantem/core/fitting/diffraction.py | 87 +++++++++++- src/quantem/diffraction/model_fitting.py | 78 ++++++++++- 4 files changed, 324 insertions(+), 12 deletions(-) diff --git a/src/quantem/core/fitting/background.py b/src/quantem/core/fitting/background.py index 7a15d37b..47333a29 100644 --- a/src/quantem/core/fitting/background.py +++ b/src/quantem/core/fitting/background.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Sequence, cast +from typing import Any, Sequence, cast import numpy as np import torch @@ -25,12 +25,15 @@ def __init__( *, intensity: float | int | Sequence[float | int | None] = 0.0, name: str = "dc_background", + constraint_params: dict[str, Any] | None = None, ): super().__init__() self.name = str(name) self.intensity_raw = nn.Parameter( torch.tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) ) + if constraint_params is not None: + self.apply_constraint_params(constraint_params, strict=True) def forward(self, ctx: RenderContext) -> torch.Tensor: inten = torch.clamp(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype), min=0.0) @@ -46,6 +49,7 @@ def __init__( origin: OriginND | None = None, origin_key: str = "origin", name: str = "gaussian_background", + constraint_params: dict[str, Any] | None = None, ): super().__init__() self.name = str(name) @@ -57,6 +61,8 @@ def __init__( self.intensity_raw = nn.Parameter( torch.tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) ) + if constraint_params is not None: + self.apply_constraint_params(constraint_params, strict=True) def set_origin(self, origin: OriginND) -> None: self.origin = origin diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index dfce2e7b..9217ddf6 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -32,10 +32,91 @@ def __init__(self, *, ndim: int, init: Sequence[float]): class RenderComponent(nn.Module): + DEFAULT_HARD_CONSTRAINTS: dict[str, Any] = {} + DEFAULT_SOFT_CONSTRAINTS: dict[str, Any] = {} + + def __init__(self) -> None: + super().__init__() + self.hard_constraints: dict[str, Any] = dict(self.DEFAULT_HARD_CONSTRAINTS) + self.soft_constraints: dict[str, Any] = dict(self.DEFAULT_SOFT_CONSTRAINTS) + + def _set_constraints( + self, + current: dict[str, Any], + defaults: dict[str, Any], + constraints: dict[str, Any], + *, + strict: bool, + ) -> None: + if strict: + unknown = [k for k in constraints if k not in defaults] + if unknown: + keys = ", ".join(str(k) for k in unknown) + raise KeyError(f"Unknown constraint keys: {keys}") + current.update(constraints) + + def set_hard_constraints(self, constraints: dict[str, Any], strict: bool = True) -> None: + self._set_constraints( + self.hard_constraints, self.DEFAULT_HARD_CONSTRAINTS, constraints, strict=strict + ) + + def set_soft_constraints(self, constraints: dict[str, Any], strict: bool = True) -> None: + self._set_constraints( + self.soft_constraints, self.DEFAULT_SOFT_CONSTRAINTS, constraints, strict=strict + ) + + def apply_constraint_params(self, params: dict[str, Any], strict: bool = True) -> None: + if not isinstance(params, dict): + raise TypeError("constraint params must be a dict.") + if "hard" in params or "soft" in params: + hard = params.get("hard") + soft = params.get("soft") + if hard is not None: + if not isinstance(hard, dict): + raise TypeError("constraint params 'hard' value must be a dict.") + self.set_hard_constraints(hard, strict=strict) + if soft is not None: + if not isinstance(soft, dict): + raise TypeError("constraint params 'soft' value must be a dict.") + self.set_soft_constraints(soft, strict=strict) + return + + hard_updates: dict[str, Any] = {} + soft_updates: dict[str, Any] = {} + unknown: dict[str, Any] = {} + for k, v in params.items(): + if k in self.DEFAULT_HARD_CONSTRAINTS: + hard_updates[k] = v + elif k in self.DEFAULT_SOFT_CONSTRAINTS: + soft_updates[k] = v + else: + unknown[k] = v + + if unknown and strict: + keys = ", ".join(str(k) for k in unknown.keys()) + raise KeyError(f"Unknown constraint keys for {self.__class__.__name__}: {keys}") + if unknown: + soft_updates.update(unknown) + if hard_updates: + self.set_hard_constraints(hard_updates, strict=strict) + if soft_updates: + self.set_soft_constraints(soft_updates, strict=strict) + + def effective_soft_constraints(self, params: dict[str, Any] | None = None) -> dict[str, Any]: + effective = dict(self.soft_constraints) + if isinstance(params, dict): + effective.update(params) + return effective + + def enforce_hard_constraints(self, ctx: RenderContext) -> None: + return None + def forward(self, ctx: RenderContext) -> torch.Tensor: raise NotImplementedError - def constraint_loss(self, ctx: RenderContext) -> torch.Tensor: + def constraint_loss( + self, ctx: RenderContext, params: dict[str, Any] | None = None + ) -> torch.Tensor: return torch.zeros((), device=ctx.device, dtype=ctx.dtype) @@ -53,11 +134,65 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: out = out + component(ctx) return out - def total_constraint_loss(self, ctx: RenderContext) -> torch.Tensor: - loss = torch.zeros((), device=ctx.device, dtype=ctx.dtype) + def _component_constraint_name(self, component: RenderComponent, idx: int) -> str: + name = getattr(component, "name", None) + if isinstance(name, str) and name: + return name + class_name = component.__class__.__name__ + if class_name: + return class_name + return f"component_{idx}" + + def apply_constraint_params( + self, constraint_params: dict[str, Any], strict: bool = True + ) -> None: + if not isinstance(constraint_params, dict): + raise TypeError("constraint_params must be a dict.") + source = constraint_params.get("components") + component_map = source if isinstance(source, dict) else constraint_params + for target, params in component_map.items(): + if not isinstance(params, dict): + if strict: + raise TypeError(f"Constraint params for '{target}' must be a dict.") + continue + target_str = str(target) + name_matches: list[RenderComponent] = [] + class_matches: list[RenderComponent] = [] + for idx, module in enumerate(self.components): + component = cast(RenderComponent, module) + if self._component_constraint_name(component, idx) == target_str: + name_matches.append(component) + if component.__class__.__name__ == target_str: + class_matches.append(component) + targets = name_matches if name_matches else class_matches + if not targets: + if strict: + raise KeyError(f"No matching component for constraint target '{target_str}'.") + continue + for component in targets: + component.apply_constraint_params(params, strict=strict) + + def apply_hard_constraints(self, ctx: RenderContext) -> None: for module in self.components: component = cast(RenderComponent, module) - loss = loss + component.constraint_loss(ctx) + component.enforce_hard_constraints(ctx) + + def total_constraint_loss( + self, ctx: RenderContext, constraint_params: dict[str, Any] | None = None + ) -> torch.Tensor: + loss = torch.zeros((), device=ctx.device, dtype=ctx.dtype) + per_component = None + if isinstance(constraint_params, dict): + comp_cfg = constraint_params.get("components") + if isinstance(comp_cfg, dict): + per_component = comp_cfg + for idx, module in enumerate(self.components): + component = cast(RenderComponent, module) + resolved_name = self._component_constraint_name(component, idx) + component_params = per_component.get(resolved_name) if per_component else None + if not isinstance(component_params, dict): + component_params = None + loss = loss + component.constraint_loss(ctx, params=component_params) return loss @@ -128,12 +263,18 @@ def fit_render( target: torch.Tensor, n_steps: int, constraint_weight: float = 1.0, + constraint_params: dict[str, Any] | None = None, optimizer_params: dict | None = None, scheduler_params: dict | None = None, progress: bool = False, run_key: str = "default", **kwargs: Any, ) -> FitResult: + if self.model is None or self.ctx is None: + raise RuntimeError("Model and context are not defined for fitting.") + if constraint_params is not None: + self.model.apply_constraint_params(constraint_params, strict=True) + optimizer_rebuilt = False if optimizer_params is not None: self.set_optimizer(optimizer_params) @@ -166,10 +307,13 @@ def fit_render( self.zero_optimizer_grad() pred = self._forward_for_fit(target=target, **kwargs) data_loss = self._fidelity_loss(pred, target, **kwargs) - constraint_loss = self._constraint_loss(pred, target, **kwargs) + constraint_loss = self._constraint_loss(pred, target, constraint_params=None, **kwargs) total_loss = data_loss + constraint_weight * constraint_loss total_loss.backward() self.step_optimizer() + if self.model is None or self.ctx is None: + raise RuntimeError("Model and context are not defined for fitting.") + self.model.apply_hard_constraints(self.ctx) total_loss_value = float(total_loss.detach().cpu()) self.step_scheduler(total_loss_value) losses.append(total_loss_value) @@ -240,11 +384,16 @@ def _fidelity_loss( return self.loss_fn(pred, target) def _constraint_loss( - self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any + self, + pred: torch.Tensor, + target: torch.Tensor, + *, + constraint_params: dict[str, Any] | None = None, + **kwargs: Any, ) -> torch.Tensor: if self.model is None or self.ctx is None: raise RuntimeError("Model and context are not defined for fitting.") - return self.model.total_constraint_loss(self.ctx) + return self.model.total_constraint_loss(self.ctx, constraint_params=constraint_params) Component = RenderComponent diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 0872ffb5..204b8803 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import Iterable, Sequence, cast +from typing import Any, Iterable, Sequence, cast import numpy as np import torch +import torch.nn.functional as F from torch import nn from quantem.core.fitting.base import OriginND, RenderComponent, RenderContext @@ -58,6 +59,12 @@ def put(rr: torch.Tensor, cc: torch.Tensor, ww: torch.Tensor) -> None: class DiskTemplate(RenderComponent): + DEFAULT_HARD_CONSTRAINTS: dict[str, bool] = { + "force_center": False, + "force_positive": True, + } + DEFAULT_SOFT_CONSTRAINTS: dict[str, float] = {"tv_weight": 0.0} + def __init__( self, *, @@ -69,6 +76,7 @@ def __init__( origin: OriginND | None = None, origin_key: str = "origin", intensity: float | Sequence[float] = 1.0, + constraint_params: dict[str, Any] | None = None, ): super().__init__() self.name = str(name) @@ -107,6 +115,10 @@ def __init__( cc = cc.astype(np.float32) - (wt - 1) * 0.5 self.register_buffer("dr", torch.as_tensor(rr.ravel(), dtype=torch.float32)) self.register_buffer("dc", torch.as_tensor(cc.ravel(), dtype=torch.float32)) + if constraint_params is not None: + self.apply_constraint_params(constraint_params, strict=True) + if bool(self.hard_constraints.get("force_positive", False)): + self._enforce_positivity() @classmethod def from_array( @@ -120,6 +132,7 @@ def from_array( origin: OriginND | None = None, origin_key: str = "origin", intensity: float | Sequence[float] = 1.0, + constraint_params: dict[str, Any] | None = None, ) -> "DiskTemplate": return cls( name=name, @@ -130,14 +143,13 @@ def from_array( origin=origin, origin_key=origin_key, intensity=intensity, + constraint_params=constraint_params, ) def set_origin(self, origin: OriginND) -> None: self.origin = origin def patch_values(self) -> torch.Tensor: - if self.refine_all_pixels: - return torch.clamp(self.template_raw, min=0.0).reshape(-1) return self.template_raw.reshape(-1) def patch_offsets(self) -> tuple[torch.Tensor, torch.Tensor]: @@ -166,6 +178,72 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: self.add_patch(out, r0=r0, c0=c0, scale=scale) return out + def _center_disk(self) -> None: + with torch.no_grad(): + template = self.template_raw + h, w = int(template.shape[0]), int(template.shape[1]) + weights = torch.clamp(template, min=0.0) + mass = torch.sum(weights) + if float(mass.detach().cpu()) <= 1e-12: + return + rr = torch.arange(h, device=template.device, dtype=template.dtype)[:, None] + cc = torch.arange(w, device=template.device, dtype=template.dtype)[None, :] + com_r = torch.sum(weights * rr) / mass + com_c = torch.sum(weights * cc) / mass + target_r = torch.as_tensor((h - 1) * 0.5, device=template.device, dtype=template.dtype) + target_c = torch.as_tensor((w - 1) * 0.5, device=template.device, dtype=template.dtype) + shift_r = target_r - com_r + shift_c = target_c - com_c + denom_h = max(h - 1, 1) + denom_w = max(w - 1, 1) + ty = -2.0 * shift_r / float(denom_h) + tx = -2.0 * shift_c / float(denom_w) + theta = torch.as_tensor( + [[1.0, 0.0, tx], [0.0, 1.0, ty]], + device=template.device, + dtype=template.dtype, + )[None, ...] + src = template[None, None, :, :] + grid = F.affine_grid(theta, [1, 1, h, w], align_corners=True) + shifted = F.grid_sample( + src, + grid, + mode="bilinear", + padding_mode="zeros", + align_corners=True, + )[0, 0] + self.template_raw.copy_(shifted) + + def _enforce_positivity(self) -> None: + with torch.no_grad(): + self.template_raw.clamp_(min=0.0) + + def enforce_hard_constraints(self, ctx: RenderContext) -> None: + if bool(self.hard_constraints.get("force_center", False)): + self._center_disk() + if bool(self.hard_constraints.get("force_positive", False)): + self._enforce_positivity() + + def constraint_loss( + self, ctx: RenderContext, params: dict[str, object] | None = None + ) -> torch.Tensor: + cfg = self.effective_soft_constraints(cast(dict[str, object] | None, params)) + tv_weight = float(cfg.get("tv_weight", 0.0)) + if tv_weight <= 0.0: + return torch.zeros((), device=ctx.device, dtype=ctx.dtype) + template = self.template_raw.to(device=ctx.device, dtype=ctx.dtype) + tv_r = ( + torch.mean(torch.abs(template[1:, :] - template[:-1, :])) + if template.shape[0] > 1 + else torch.zeros((), device=ctx.device, dtype=ctx.dtype) + ) + tv_c = ( + torch.mean(torch.abs(template[:, 1:] - template[:, :-1])) + if template.shape[1] > 1 + else torch.zeros((), device=ctx.device, dtype=ctx.dtype) + ) + return torch.as_tensor(tv_weight, device=ctx.device, dtype=ctx.dtype) * (tv_r + tv_c) + class SyntheticDiskLattice(RenderComponent): def __init__( @@ -194,6 +272,7 @@ def __init__( boundary_px: float = 0.0, origin: OriginND | None = None, origin_key: str = "origin", + constraint_params: dict[str, Any] | None = None, ): super().__init__() self.name = str(name) @@ -282,6 +361,8 @@ def __init__( self.irr = nn.Parameter(torch.tensor(irr_init, dtype=torch.float32)) self.icc = nn.Parameter(torch.tensor(icc_init, dtype=torch.float32)) self.irc = nn.Parameter(torch.tensor(irc_init, dtype=torch.float32)) + if constraint_params is not None: + self.apply_constraint_params(constraint_params, strict=True) def set_origin(self, origin: OriginND) -> None: self.origin = origin diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index d5c87477..5578e25f 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -65,6 +65,81 @@ def from_dataset( "from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance." ) + def _resolve_component_by_name(self, name: str) -> RenderComponent: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + target = str(name) + for idx, module in enumerate(self.model.components): + component = cast(RenderComponent, module) + resolved_name = self.model._component_constraint_name(component, idx) + if resolved_name == target: + return component + raise KeyError(f"Component not found: {target}") + + def get_component(self, name: str) -> RenderComponent: + return self._resolve_component_by_name(name) + + def _render_disk_template_component( + self, component: RenderComponent, ctx: RenderContext + ) -> torch.Tensor: + from quantem.core.fitting.diffraction import DiskTemplate + + if not isinstance(component, DiskTemplate): + raise TypeError("Component is not a DiskTemplate.") + out = torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) + r0 = torch.as_tensor((ctx.shape[0] - 1) * 0.5, device=ctx.device, dtype=ctx.dtype) + c0 = torch.as_tensor((ctx.shape[1] - 1) * 0.5, device=ctx.device, dtype=ctx.dtype) + scale = torch.as_tensor(1.0, device=ctx.device, dtype=ctx.dtype) + component.add_patch(out, r0=r0, c0=c0, scale=scale) + return out + + def get_rendered_component( + self, name: str, apply_hard_constraints: bool = False + ) -> np.ndarray: + if self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + component = self._resolve_component_by_name(name) + if apply_hard_constraints: + component.enforce_hard_constraints(self.ctx) + from quantem.core.fitting.diffraction import DiskTemplate + + if isinstance(component, DiskTemplate): + rendered = self._render_disk_template_component(component, self.ctx) + else: + rendered = component(self.ctx) + return rendered.detach().cpu().numpy() + + def get_rendered_disk_template( + self, apply_hard_constraints: bool = True, name: str | None = None + ) -> np.ndarray: + if self.ctx is None or self.model is None: + raise RuntimeError("Call .define_model(...) first.") + from quantem.core.fitting.diffraction import DiskTemplate + + if name is not None: + component = self._resolve_component_by_name(name) + if not isinstance(component, DiskTemplate): + raise TypeError(f"Component '{name}' is not a DiskTemplate.") + if apply_hard_constraints: + component.enforce_hard_constraints(self.ctx) + return component.template_raw.detach().cpu().numpy() + matches = [m for m in self.model.components if isinstance(m, DiskTemplate)] + if len(matches) == 0: + raise RuntimeError("No DiskTemplate components found.") + if len(matches) > 1: + raise RuntimeError("Multiple DiskTemplate components found; pass name explicitly.") + disk = matches[0] + if apply_hard_constraints: + disk.enforce_hard_constraints(self.ctx) + return disk.template_raw.detach().cpu().numpy() + + def get_component_constraints(self, name: str) -> dict[str, dict[str, Any]]: + component = self._resolve_component_by_name(name) + return { + "hard": dict(component.hard_constraints), + "soft": dict(component.soft_constraints), + } + def preprocess( self, *, @@ -193,8 +268,8 @@ def fit_mean_diffraction_pattern( optimizer_params: dict | None = None, scheduler_params: dict | None = None, constraint_weight: float = 1.0, + constraint_params: dict[str, Any] | None = None, progress: bool = False, - **kwargs: Any, ) -> "ModelDiffraction": if self.model is None or self.ctx is None or self.target_mean is None: raise RuntimeError("Call .define_model(...) first.") @@ -211,6 +286,7 @@ def fit_mean_diffraction_pattern( target=self.target_mean, n_steps=int(n_steps), constraint_weight=float(constraint_weight), + constraint_params=constraint_params, optimizer_params=optimizer_params, scheduler_params=scheduler_params, progress=bool(progress), From 7443869cf8398de07a8d88094d016f98a62c7941 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 15:50:38 -0800 Subject: [PATCH 36/88] adding docstrings and cleaning --- src/quantem/core/fitting/base.py | 62 +++++++--- src/quantem/core/fitting/diffraction.py | 24 +--- src/quantem/diffraction/model_fitting.py | 138 +++++++++++++++++------ 3 files changed, 151 insertions(+), 73 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 9217ddf6..898ad90a 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -177,22 +177,11 @@ def apply_hard_constraints(self, ctx: RenderContext) -> None: component = cast(RenderComponent, module) component.enforce_hard_constraints(ctx) - def total_constraint_loss( - self, ctx: RenderContext, constraint_params: dict[str, Any] | None = None - ) -> torch.Tensor: + def total_constraint_loss(self, ctx: RenderContext) -> torch.Tensor: loss = torch.zeros((), device=ctx.device, dtype=ctx.dtype) - per_component = None - if isinstance(constraint_params, dict): - comp_cfg = constraint_params.get("components") - if isinstance(comp_cfg, dict): - per_component = comp_cfg - for idx, module in enumerate(self.components): + for module in self.components: component = cast(RenderComponent, module) - resolved_name = self._component_constraint_name(component, idx) - component_params = per_component.get(resolved_name) if per_component else None - if not isinstance(component_params, dict): - component_params = None - loss = loss + component.constraint_loss(ctx, params=component_params) + loss = loss + component.constraint_loss(ctx) return loss @@ -270,6 +259,45 @@ def fit_render( run_key: str = "default", **kwargs: Any, ) -> FitResult: + """ + Fit model parameters to a target render. + + Parameters + ---------- + target : torch.Tensor + Target tensor to fit. + n_steps : int + Number of optimization steps. + constraint_weight : float, optional + Multiplier applied to the summed soft-constraint loss. + constraint_params : dict[str, Any] | None, optional + Optional constraint updates applied once to matching components before + optimization starts. If ``None``, existing component constraints are reused. + optimizer_params : dict | None, optional + Optimizer configuration override for this call. + scheduler_params : dict | None, optional + Scheduler configuration override for this call. + progress : bool, optional + If ``True``, display a progress bar. + run_key : str, optional + History key used to store/append fit metrics. + **kwargs : Any + Forwarded to internal forward/loss hooks. + + Returns + ------- + FitResult + Fit history and final loss metadata for this run key. + + Raises + ------ + RuntimeError + If model/context are undefined. + + Notes + ----- + Hard constraints are applied after each optimizer step. + """ if self.model is None or self.ctx is None: raise RuntimeError("Model and context are not defined for fitting.") if constraint_params is not None: @@ -307,7 +335,7 @@ def fit_render( self.zero_optimizer_grad() pred = self._forward_for_fit(target=target, **kwargs) data_loss = self._fidelity_loss(pred, target, **kwargs) - constraint_loss = self._constraint_loss(pred, target, constraint_params=None, **kwargs) + constraint_loss = self._constraint_loss(pred, target, **kwargs) total_loss = data_loss + constraint_weight * constraint_loss total_loss.backward() self.step_optimizer() @@ -387,13 +415,11 @@ def _constraint_loss( self, pred: torch.Tensor, target: torch.Tensor, - *, - constraint_params: dict[str, Any] | None = None, **kwargs: Any, ) -> torch.Tensor: if self.model is None or self.ctx is None: raise RuntimeError("Model and context are not defined for fitting.") - return self.model.total_constraint_loss(self.ctx, constraint_params=constraint_params) + return self.model.total_constraint_loss(self.ctx) Component = RenderComponent diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 204b8803..90070106 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -71,7 +71,6 @@ def __init__( name: str, array: np.ndarray, refine_all_pixels: bool = False, - place_at_origin: bool = False, normalize: str = "none", origin: OriginND | None = None, origin_key: str = "origin", @@ -81,7 +80,6 @@ def __init__( super().__init__() self.name = str(name) self.refine_all_pixels = bool(refine_all_pixels) - self.place_at_origin = bool(place_at_origin) self.origin = origin self.origin_key = str(origin_key) @@ -102,13 +100,6 @@ def __init__( template = torch.as_tensor(a, dtype=torch.float32) self.template_raw = nn.Parameter(template.clone(), requires_grad=self.refine_all_pixels) - if self.place_at_origin: - self.intensity_raw = nn.Parameter( - torch.as_tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) - ) - else: - self.intensity_raw = None - ht, wt = int(template.shape[0]), int(template.shape[1]) rr, cc = np.mgrid[0:ht, 0:wt] rr = rr.astype(np.float32) - (ht - 1) * 0.5 @@ -127,7 +118,6 @@ def from_array( name: str, array: np.ndarray, refine_all_pixels: bool = False, - place_at_origin: bool = False, normalize: str = "none", origin: OriginND | None = None, origin_key: str = "origin", @@ -138,7 +128,6 @@ def from_array( name=name, array=array, refine_all_pixels=refine_all_pixels, - place_at_origin=place_at_origin, normalize=normalize, origin=origin, origin_key=origin_key, @@ -165,17 +154,10 @@ def add_patch( def forward(self, ctx: RenderContext) -> torch.Tensor: out = torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) - if not self.place_at_origin: - return out if self.origin is None: - raise RuntimeError( - "DiskTemplate with place_at_origin=True requires an OriginND instance." - ) + raise RuntimeError("DiskTemplate.forward() requires an OriginND instance.") r0, c0 = self.origin.coords[0], self.origin.coords[1] - if self.intensity_raw is None: - raise RuntimeError("DiskTemplate intensity parameter is missing.") - scale = torch.clamp(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype), min=0.0) - self.add_patch(out, r0=r0, c0=c0, scale=scale) + self.add_patch(out, r0=r0, c0=c0, scale=torch.tensor(1.0)) # scale learned directly return out def _center_disk(self) -> None: @@ -308,7 +290,7 @@ def __init__( ) if exclude_indices is None: - exclude = {(0, 0)} if bool(getattr(disk, "place_at_origin", False)) else set() + exclude = set() else: exclude = set(exclude_indices) uv: list[tuple[int, int]] = [] diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 5578e25f..9fd487e0 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -15,6 +15,7 @@ RenderComponent, RenderContext, ) +from quantem.core.fitting.diffraction import DiskTemplate from quantem.core.io.serialize import AutoSerialize from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.diffraction.model_fitting_visualizations import ModelDiffractionVisualizations @@ -77,60 +78,92 @@ def _resolve_component_by_name(self, name: str) -> RenderComponent: raise KeyError(f"Component not found: {target}") def get_component(self, name: str) -> RenderComponent: + """ + Return a live model component by resolved name. + + Parameters + ---------- + name : str + Resolved component name. + + Returns + ------- + RenderComponent + The live component object. + + Raises + ------ + RuntimeError + If the model is not defined. + KeyError + If no component matches ``name``. + """ return self._resolve_component_by_name(name) - def _render_disk_template_component( - self, component: RenderComponent, ctx: RenderContext - ) -> torch.Tensor: - from quantem.core.fitting.diffraction import DiskTemplate - - if not isinstance(component, DiskTemplate): - raise TypeError("Component is not a DiskTemplate.") - out = torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) - r0 = torch.as_tensor((ctx.shape[0] - 1) * 0.5, device=ctx.device, dtype=ctx.dtype) - c0 = torch.as_tensor((ctx.shape[1] - 1) * 0.5, device=ctx.device, dtype=ctx.dtype) - scale = torch.as_tensor(1.0, device=ctx.device, dtype=ctx.dtype) - component.add_patch(out, r0=r0, c0=c0, scale=scale) - return out - - def get_rendered_component( - self, name: str, apply_hard_constraints: bool = False - ) -> np.ndarray: + def get_rendered_component(self, name: str) -> np.ndarray: + """ + Render a component and return a NumPy array. + + Parameters + ---------- + name : str + Resolved component name. + + Returns + ------- + np.ndarray + Rendered component image. + + Raises + ------ + RuntimeError + If model/context are not defined. + KeyError + If no component matches ``name``. + """ if self.ctx is None: raise RuntimeError("Call .define_model(...) first.") + ctx = self.ctx component = self._resolve_component_by_name(name) - if apply_hard_constraints: - component.enforce_hard_constraints(self.ctx) - from quantem.core.fitting.diffraction import DiskTemplate - - if isinstance(component, DiskTemplate): - rendered = self._render_disk_template_component(component, self.ctx) - else: - rendered = component(self.ctx) + rendered = component(ctx) return rendered.detach().cpu().numpy() - def get_rendered_disk_template( - self, apply_hard_constraints: bool = True, name: str | None = None - ) -> np.ndarray: + def get_rendered_disk_template(self, name: str | None = None) -> np.ndarray: + """ + Return a DiskTemplate patch as a numpy array--not rendered onto the full frame. + + Parameters + ---------- + name : str | None, optional + DiskTemplate component name. If omitted, requires exactly one DiskTemplate. + + Returns + ------- + np.ndarray + Template-sized array from ``template_raw``. + + Raises + ------ + RuntimeError + If model/context are not defined, no DiskTemplate exists, or multiple + DiskTemplates exist when ``name`` is omitted. + TypeError + If a named component exists but is not a DiskTemplate. + """ if self.ctx is None or self.model is None: raise RuntimeError("Call .define_model(...) first.") - from quantem.core.fitting.diffraction import DiskTemplate if name is not None: component = self._resolve_component_by_name(name) if not isinstance(component, DiskTemplate): raise TypeError(f"Component '{name}' is not a DiskTemplate.") - if apply_hard_constraints: - component.enforce_hard_constraints(self.ctx) return component.template_raw.detach().cpu().numpy() matches = [m for m in self.model.components if isinstance(m, DiskTemplate)] if len(matches) == 0: raise RuntimeError("No DiskTemplate components found.") if len(matches) > 1: raise RuntimeError("Multiple DiskTemplate components found; pass name explicitly.") - disk = matches[0] - if apply_hard_constraints: - disk.enforce_hard_constraints(self.ctx) + disk = cast(DiskTemplate, matches[0]) return disk.template_raw.detach().cpu().numpy() def get_component_constraints(self, name: str) -> dict[str, dict[str, Any]]: @@ -271,6 +304,43 @@ def fit_mean_diffraction_pattern( constraint_params: dict[str, Any] | None = None, progress: bool = False, ) -> "ModelDiffraction": + """ + Fit the mean diffraction pattern. + + Parameters + ---------- + n_steps : int, optional + Number of optimization steps. + reset : bool | Literal["initialized", "mean_refined"], optional + Reset behavior before fitting. + optimizer_params : dict | None, optional + Optimizer override for this fit call. + scheduler_params : dict | None, optional + Scheduler override for this fit call. + constraint_weight : float, optional + Global multiplier for soft-constraint loss. + constraint_params : dict[str, Any] | None, optional + Optional constraint updates applied once to components before fitting. + If ``None``, previously assigned constraints are reused. + progress : bool, optional + If ``True``, show progress bar. + + Returns + ------- + ModelDiffraction + Self, with updated fit state and history. + + Raises + ------ + RuntimeError + If model/context/target are not defined. + ValueError + If ``reset`` has an unsupported value. + + Notes + ----- + Constraint assignments persist on components across fit calls. + """ if self.model is None or self.ctx is None or self.target_mean is None: raise RuntimeError("Call .define_model(...) first.") if reset is True: From 136f626f0a182875e1526b4f787327f9433f56f0 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 20:42:05 -0800 Subject: [PATCH 37/88] adding visualizations and overlays --- src/quantem/diffraction/model_fitting.py | 80 +++++- .../model_fitting_visualizations.py | 264 +++++++++++++++++- 2 files changed, 342 insertions(+), 2 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 9fd487e0..12f8235d 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -15,7 +15,7 @@ RenderComponent, RenderContext, ) -from quantem.core.fitting.diffraction import DiskTemplate +from quantem.core.fitting.diffraction import DiskTemplate, SyntheticDiskLattice from quantem.core.io.serialize import AutoSerialize from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.diffraction.model_fitting_visualizations import ModelDiffractionVisualizations @@ -66,6 +66,18 @@ def from_dataset( "from_dataset expects a Dataset2d, Dataset3d, Dataset4d, or Dataset4dstem instance." ) + @property + def components(self) -> torch.nn.ModuleList: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return self.model.components + + @property + def component_names(self) -> list[str]: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return [cast(str, c.name) for c in self.components] + def _resolve_component_by_name(self, name: str) -> RenderComponent: if self.model is None: raise RuntimeError("Call .define_model(...) first.") @@ -173,6 +185,72 @@ def get_component_constraints(self, name: str) -> dict[str, dict[str, Any]]: "soft": dict(component.soft_constraints), } + def get_overlay_coordinates(self) -> tuple[np.ndarray, np.ndarray]: + """ + Return origin and lattice disk-center coordinates for overlay plotting. + + Parameters + ---------- + None + + Returns + ------- + origin_rc : np.ndarray + Origin coordinate array with shape ``(2,)`` as ``(row, col)``. + disk_centers_rc : np.ndarray + Disk-center array with shape ``(N, 2)`` as ``(row, col)``. + + Raises + ------ + RuntimeError + If model/context are not defined. + + Notes + ----- + Coordinates are computed from current model parameters without mutating state. + Boundary filtering matches ``SyntheticDiskLattice.forward`` behavior. + """ + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + + with torch.no_grad(): + origin = cast(OriginND, self.model.origin) + origin_rc = origin.coords[:2].detach().cpu().numpy().astype(np.float32, copy=False) + + centers: list[np.ndarray] = [] + for module in self.model.components: + component = cast(RenderComponent, module) + if not isinstance(component, SyntheticDiskLattice): + continue + if component.origin is None: + continue + uv_indices = cast(torch.Tensor, component.uv_indices) + if torch.numel(uv_indices) == 0: + continue + + uv = torch.as_tensor(uv_indices, device=self.ctx.device) + u = uv[:, 0].to(dtype=self.ctx.dtype) + v = uv[:, 1].to(dtype=self.ctx.dtype) + r0, c0 = component.origin.coords[0], component.origin.coords[1] + centers_r = r0 + u * component.u_row + v * component.v_row + centers_c = c0 + u * component.u_col + v * component.v_col + + b = torch.as_tensor( + component.boundary_px, device=self.ctx.device, dtype=self.ctx.dtype + ) + keep = (centers_r >= b) & (centers_r <= (self.ctx.shape[0] - 1) - b) + keep = keep & (centers_c >= b) & (centers_c <= (self.ctx.shape[1] - 1) - b) + if torch.any(keep): + rc = torch.stack((centers_r[keep], centers_c[keep]), dim=1) + centers.append(rc.detach().cpu().numpy().astype(np.float32, copy=False)) + + if centers: + disk_centers_rc = np.concatenate(centers, axis=0) + else: + disk_centers_rc = np.zeros((0, 2), dtype=np.float32) + + return origin_rc, disk_centers_rc + def preprocess( self, *, diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py index 9bd26ef1..95245aa1 100644 --- a/src/quantem/diffraction/model_fitting_visualizations.py +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast import numpy as np from matplotlib import gridspec @@ -11,6 +11,65 @@ class ModelDiffractionVisualizations: + def _plot_overlays( + self, + ax: Any, + origin_rc: np.ndarray, + disk_centers_rc: np.ndarray, + *, + overlay_origin: bool = True, + overlay_disks: bool = True, + origin_marker_kwargs: dict[str, Any] | None = None, + disk_marker_kwargs: dict[str, Any] | None = None, + ) -> None: + """ + Plot origin and disk-center overlays on an axis. + + Parameters + ---------- + ax : Any + Matplotlib axis receiving overlays. + origin_rc : np.ndarray + Origin coordinate as ``(row, col)``. + disk_centers_rc : np.ndarray + Disk centers as ``(N, 2)`` in ``(row, col)`` order. + overlay_origin : bool, optional + If ``True``, plot origin marker. + overlay_disks : bool, optional + If ``True``, plot disk-center markers. + origin_marker_kwargs : dict[str, Any] | None, optional + Matplotlib kwargs merged onto origin marker defaults. + disk_marker_kwargs : dict[str, Any] | None, optional + Matplotlib kwargs merged onto disk marker defaults. + + Returns + ------- + None + """ + if overlay_origin and origin_rc.shape == (2,): + kw_origin = { + "marker": "+", + "color": "red", + "markersize": 10, + "markeredgewidth": 1.5, + "linestyle": "None", + } + if origin_marker_kwargs is not None: + kw_origin.update(origin_marker_kwargs) + ax.plot(float(origin_rc[1]), float(origin_rc[0]), **kw_origin) + + if overlay_disks and disk_centers_rc.ndim == 2 and disk_centers_rc.shape[0] > 0: + kw_disks = { + "marker": "x", + "color": "cyan", + "markersize": 5, + "markeredgewidth": 1.0, + "linestyle": "None", + } + if disk_marker_kwargs is not None: + kw_disks.update(disk_marker_kwargs) + ax.plot(disk_centers_rc[:, 1], disk_centers_rc[:, 0], **kw_disks) + def plot_losses( self, figax: tuple[Any, Any] | None = None, plot_lrs: bool = True ) -> tuple[Any, Any]: @@ -82,7 +141,49 @@ def visualize( power: float = 0.25, cbar: bool = False, axsize: tuple[int, int] = (6, 6), + overlay: bool = True, + overlay_origin: bool = True, + overlay_disks: bool = True, + overlay_on: Literal["model", "both"] = "model", + origin_marker_kwargs: dict[str, Any] | None = None, + disk_marker_kwargs: dict[str, Any] | None = None, ) -> tuple[Any, Any]: + """ + Visualize fit losses with reference/model image panels. + + Parameters + ---------- + power : float, optional + Power-law display scaling. + cbar : bool, optional + If ``True``, draw colorbars. + axsize : tuple[int, int], optional + Axis size passed through to ``show_2d``. + overlay : bool, optional + If ``True``, draw coordinate overlays. + overlay_origin : bool, optional + If ``True``, include origin marker in overlays. + overlay_disks : bool, optional + If ``True``, include disk-center markers in overlays. + overlay_on : {"model", "both"}, optional + Which image panel(s) receive overlays. + origin_marker_kwargs : dict[str, Any] | None, optional + Marker kwargs override for origin marker. + disk_marker_kwargs : dict[str, Any] | None, optional + Marker kwargs override for disk-center markers. + + Returns + ------- + tuple[Any, Any] + ``(fig, axs)`` for further editing. + + Raises + ------ + RuntimeError + If model/context are not defined. + ValueError + If ``overlay_on`` is invalid. + """ md = cast("ModelDiffraction", self) if md.image_ref is None: @@ -118,6 +219,22 @@ def visualize( vmax=vmax, ) + if overlay: + if overlay_on not in ("model", "both"): + raise ValueError("overlay_on must be 'model' or 'both'.") + origin_rc, disk_centers_rc = md.get_overlay_coordinates() + axes = [axs[1]] if overlay_on == "model" else [axs[0], axs[1]] + for ax in axes: + self._plot_overlays( + ax, + origin_rc, + disk_centers_rc, + overlay_origin=overlay_origin, + overlay_disks=overlay_disks, + origin_marker_kwargs=origin_marker_kwargs, + disk_marker_kwargs=disk_marker_kwargs, + ) + mean_hist = md.fit_history.get("mean") if mean_hist is not None and len(mean_hist.losses) > 0: fig.suptitle( @@ -134,8 +251,52 @@ def plot_mean_model( power: float = 0.25, returnfig: bool = False, axsize: tuple[int, int] = (6, 6), + overlay: bool = True, + overlay_origin: bool = True, + overlay_disks: bool = True, + overlay_on: Literal["model", "both"] = "model", + origin_marker_kwargs: dict[str, Any] | None = None, + disk_marker_kwargs: dict[str, Any] | None = None, **_: Any, ) -> tuple[Any, Any] | None: + """ + Plot reference and model mean diffraction images. + + Parameters + ---------- + power : float, optional + Power-law display scaling. + returnfig : bool, optional + If ``True``, return ``(fig, ax)``. + axsize : tuple[int, int], optional + Axis size passed through to ``show_2d``. + overlay : bool, optional + If ``True``, draw coordinate overlays. + overlay_origin : bool, optional + If ``True``, include origin marker in overlays. + overlay_disks : bool, optional + If ``True``, include disk-center markers in overlays. + overlay_on : {"model", "both"}, optional + Which image panel(s) receive overlays. + origin_marker_kwargs : dict[str, Any] | None, optional + Marker kwargs override for origin marker. + disk_marker_kwargs : dict[str, Any] | None, optional + Marker kwargs override for disk-center markers. + **_ : Any + Ignored extra kwargs for backward compatibility. + + Returns + ------- + tuple[Any, Any] | None + Figure/axes tuple when ``returnfig=True``; otherwise ``None``. + + Raises + ------ + RuntimeError + If model/context are not defined. + ValueError + If ``overlay_on`` is invalid. + """ md = cast("ModelDiffraction", self) if md.image_ref is None: md.preprocess() @@ -160,6 +321,107 @@ def plot_mean_model( vmin=vmin, vmax=vmax, ) + if overlay: + if overlay_on not in ("model", "both"): + raise ValueError("overlay_on must be 'model' or 'both'.") + origin_rc, disk_centers_rc = md.get_overlay_coordinates() + axs_arr = np.asarray(ax, dtype=object).reshape(-1) + axes = [axs_arr[1]] if overlay_on == "model" else [axs_arr[0], axs_arr[1]] + for a in axes: + self._plot_overlays( + a, + origin_rc, + disk_centers_rc, + overlay_origin=overlay_origin, + overlay_disks=overlay_disks, + origin_marker_kwargs=origin_marker_kwargs, + disk_marker_kwargs=disk_marker_kwargs, + ) + if returnfig: + return fig, ax + return None + + def visualize_components( + self, + components: str | list[str], + *, + power: float = 0.25, + cbar: bool = False, + axsize: tuple[int, int] = (6, 6), + returnfig: bool = False, + overlay: bool = True, + overlay_origin: bool = True, + overlay_disks: bool = True, + origin_marker_kwargs: dict[str, Any] | None = None, + disk_marker_kwargs: dict[str, Any] | None = None, + ) -> tuple[Any, Any] | None: + """ + Render and display one or more named components. + + Parameters + ---------- + components : str | list[str] + Component name or list of component names. + power : float, optional + Power-law display scaling. + cbar : bool, optional + If ``True``, draw colorbars. + axsize : tuple[int, int], optional + Axis size passed through to ``show_2d``. + returnfig : bool, optional + If ``True``, return ``(fig, ax)``. + overlay : bool, optional + If ``True``, draw coordinate overlays on all component panels. + overlay_origin : bool, optional + If ``True``, include origin marker in overlays. + overlay_disks : bool, optional + If ``True``, include disk-center markers in overlays. + origin_marker_kwargs : dict[str, Any] | None, optional + Marker kwargs override for origin marker. + disk_marker_kwargs : dict[str, Any] | None, optional + Marker kwargs override for disk-center markers. + + Returns + ------- + tuple[Any, Any] | None + Figure/axes tuple when ``returnfig=True``; otherwise ``None``. + """ + md = cast("ModelDiffraction", self) + names = [components] if isinstance(components, str) else list(components) + if len(names) == 0: + raise ValueError("components must contain at least one component name.") + + rendered = [md.get_rendered_component(name) for name in names] + rendered_scaled = [ + arr + if power == 1.0 + else np.maximum(np.asarray(arr, dtype=np.float32), 0.0) ** float(power) + for arr in rendered + ] + + fig, ax = show_2d( + rendered_scaled, + title=names, + cmap="gray", + cbar=bool(cbar), + returnfig=True, + axsize=axsize, + ) + + if overlay: + origin_rc, disk_centers_rc = md.get_overlay_coordinates() + axs_arr = np.asarray(ax, dtype=object).reshape(-1) + for a in axs_arr: + self._plot_overlays( + a, + origin_rc, + disk_centers_rc, + overlay_origin=overlay_origin, + overlay_disks=overlay_disks, + origin_marker_kwargs=origin_marker_kwargs, + disk_marker_kwargs=disk_marker_kwargs, + ) + if returnfig: return fig, ax return None From 6429e255e576e17622a54537b1fc59d4a10e98a0 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 21:45:35 -0800 Subject: [PATCH 38/88] adding turning on/off individual components and parameters --- src/quantem/core/fitting/base.py | 202 ++++++++++++++++++++++- src/quantem/diffraction/model_fitting.py | 82 +++++++-- 2 files changed, 271 insertions(+), 13 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 898ad90a..f4136cdd 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -214,7 +214,7 @@ def __init__(self): def get_optimization_parameters(self) -> Any: if self.model is None: return [] - return self.model.parameters() + return [p for p in self.model.parameters() if p.requires_grad] @property def state_current(self) -> dict[str, torch.Tensor] | None: @@ -246,6 +246,164 @@ def reset( self._clear_fit_history_all() return self + def set_component_trainable( + self, component_name: str, enabled: bool, rebuild_optimizer: bool = True + ) -> None: + """ + Enable or disable optimization for all parameters in one component. + + Parameters + ---------- + component_name : str + Resolved component name. + enabled : bool + If ``True``, mark component parameters trainable. + rebuild_optimizer : bool, optional + If ``True``, rebuild optimizer param groups after toggling. + + Returns + ------- + None + + Raises + ------ + RuntimeError + If the model is not defined. + KeyError + If ``component_name`` is unknown. + + Notes + ----- + When rebuilding, the optimizer is reconstructed from stored optimizer + parameters if available, otherwise inferred from the current optimizer + type and learning rate, else defaults. Scheduler state is cleared + predictably by setting scheduler type to ``"none"``. + """ + component = self._resolve_component_by_name(component_name) + for _, param in component.named_parameters(recurse=True): + param.requires_grad_(bool(enabled)) + if rebuild_optimizer: + self._rebuild_optimizer_after_trainability_change() + + def set_parameter_trainable( + self, + component_name: str, + parameter_name: str, + enabled: bool, + rebuild_optimizer: bool = True, + ) -> None: + """ + Enable or disable optimization for one component parameter. + + Parameters + ---------- + component_name : str + Resolved component name. + parameter_name : str + Parameter name from ``component.named_parameters()``. + enabled : bool + If ``True``, mark parameter trainable. + rebuild_optimizer : bool, optional + If ``True``, rebuild optimizer param groups after toggling. + + Returns + ------- + None + + Raises + ------ + RuntimeError + If the model is not defined. + KeyError + If ``component_name`` or ``parameter_name`` is unknown. + + Notes + ----- + When rebuilding, scheduler state is cleared by setting scheduler type + to ``"none"``. + """ + component = self._resolve_component_by_name(component_name) + params = dict(component.named_parameters(recurse=True)) + if parameter_name not in params: + known = ", ".join(sorted(params.keys())) + raise KeyError( + f"Parameter '{parameter_name}' not found in component '{component_name}'. " + f"Known parameters: {known}" + ) + params[parameter_name].requires_grad_(bool(enabled)) + if rebuild_optimizer: + self._rebuild_optimizer_after_trainability_change() + + def set_parameters_trainable( + self, + component_name: str, + parameter_names: list[str], + enabled: bool, + rebuild_optimizer: bool = True, + ) -> None: + """ + Enable or disable optimization for multiple component parameters. + + Parameters + ---------- + component_name : str + Resolved component name. + parameter_names : list[str] + Parameter names from ``component.named_parameters()``. + enabled : bool + If ``True``, mark parameters trainable. + rebuild_optimizer : bool, optional + If ``True``, rebuild optimizer param groups after toggling. + + Returns + ------- + None + + Raises + ------ + RuntimeError + If the model is not defined. + KeyError + If any parameter name is unknown. + """ + component = self._resolve_component_by_name(component_name) + params = dict(component.named_parameters(recurse=True)) + missing = [name for name in parameter_names if name not in params] + if missing: + known = ", ".join(sorted(params.keys())) + raise KeyError( + f"Unknown parameters for component '{component_name}': {', '.join(missing)}. " + f"Known parameters: {known}" + ) + for name in parameter_names: + params[name].requires_grad_(bool(enabled)) + if rebuild_optimizer: + self._rebuild_optimizer_after_trainability_change() + + def get_component_trainable(self, component_name: str) -> dict[str, bool]: + """ + Return trainability flags for one component's parameters. + + Parameters + ---------- + component_name : str + Resolved component name. + + Returns + ------- + dict[str, bool] + Mapping of parameter name to ``requires_grad``. + + Raises + ------ + RuntimeError + If the model is not defined. + KeyError + If ``component_name`` is unknown. + """ + component = self._resolve_component_by_name(component_name) + return {name: bool(param.requires_grad) for name, param in component.named_parameters()} + def fit_render( self, *, @@ -365,6 +523,48 @@ def fit_render( self.fit_history[key] = result return result + def _resolve_component_by_name(self, component_name: str) -> RenderComponent: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + target = str(component_name) + for idx, module in enumerate(self.model.components): + component = cast(RenderComponent, module) + resolved_name = self.model._component_constraint_name(component, idx) + if resolved_name == target: + return component + raise KeyError(f"Component not found: {target}") + + def _infer_optimizer_rebuild_params(self) -> dict[str, Any]: + if self.optimizer_params: + return dict(self.optimizer_params) + if self.optimizer is not None: + opt_type: str | type[torch.optim.Optimizer] + if isinstance(self.optimizer, torch.optim.AdamW): + opt_type = "adamw" + elif isinstance(self.optimizer, torch.optim.Adam): + opt_type = "adam" + elif isinstance(self.optimizer, torch.optim.SGD): + opt_type = "sgd" + else: + opt_type = type(self.optimizer) + lr = float( + self.optimizer.param_groups[0].get( + "lr", getattr(self, "DEFAULT_LR", self.DEFAULT_LR) + ) + ) + return {"type": opt_type, "lr": lr} + return { + "type": getattr(self, "DEFAULT_OPTIMIZER_TYPE", self.DEFAULT_OPTIMIZER_TYPE), + "lr": float(getattr(self, "DEFAULT_LR", self.DEFAULT_LR)), + } + + def _rebuild_optimizer_after_trainability_change(self) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + rebuild_params = self._infer_optimizer_rebuild_params() + self.set_optimizer(rebuild_params) + self.set_scheduler({"type": "none"}) + def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: return {k: v.detach().clone() for k, v in state.items()} diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 12f8235d..991653e9 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -78,17 +78,6 @@ def component_names(self) -> list[str]: raise RuntimeError("Call .define_model(...) first.") return [cast(str, c.name) for c in self.components] - def _resolve_component_by_name(self, name: str) -> RenderComponent: - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - target = str(name) - for idx, module in enumerate(self.model.components): - component = cast(RenderComponent, module) - resolved_name = self.model._component_constraint_name(component, idx) - if resolved_name == target: - return component - raise KeyError(f"Component not found: {target}") - def get_component(self, name: str) -> RenderComponent: """ Return a live model component by resolved name. @@ -178,6 +167,75 @@ def get_rendered_disk_template(self, name: str | None = None) -> np.ndarray: disk = cast(DiskTemplate, matches[0]) return disk.template_raw.detach().cpu().numpy() + def set_disk_template_trainable( + self, enabled: bool, name: str | None = None, rebuild_optimizer: bool = True + ) -> None: + """ + Toggle DiskTemplate ``template_raw`` trainability. + + Parameters + ---------- + enabled : bool + If ``True``, enable optimization of ``template_raw``. + name : str | None, optional + DiskTemplate component name. If ``None``, applies to all DiskTemplate + components in the current model. + rebuild_optimizer : bool, optional + If ``True``, rebuild optimizer param groups after toggling. + + Returns + ------- + None + + Raises + ------ + KeyError + If ``name`` does not match any component. + RuntimeError + If model is not defined or no DiskTemplate components are found. + TypeError + If ``name`` resolves to a non-DiskTemplate component. + + Notes + ----- + This toggles only ``template_raw.requires_grad``. Other DiskTemplate + parameters (for example ``intensity_raw``) are unchanged. When + ``rebuild_optimizer=True``, optimizer param groups are rebuilt to match + current ``requires_grad`` flags. + """ + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + + if name is not None: + component = self._resolve_component_by_name(name) + if not isinstance(component, DiskTemplate): + raise TypeError(f"Component '{name}' is not a DiskTemplate.") + self.set_parameter_trainable( + name, + "template_raw", + enabled=enabled, + rebuild_optimizer=rebuild_optimizer, + ) + return + + disk_names: list[str] = [] + for idx, module in enumerate(self.model.components): + component = cast(RenderComponent, module) + if isinstance(component, DiskTemplate): + disk_names.append(self.model._component_constraint_name(component, idx)) + if len(disk_names) == 0: + raise RuntimeError("No DiskTemplate components found.") + + for disk_name in disk_names: + self.set_parameter_trainable( + disk_name, + "template_raw", + enabled=enabled, + rebuild_optimizer=False, + ) + if rebuild_optimizer: + self._rebuild_optimizer_after_trainability_change() + def get_component_constraints(self, name: str) -> dict[str, dict[str, Any]]: component = self._resolve_component_by_name(name) return { @@ -380,7 +438,7 @@ def fit_mean_diffraction_pattern( scheduler_params: dict | None = None, constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, - progress: bool = False, + progress: bool = True, ) -> "ModelDiffraction": """ Fit the mean diffraction pattern. From 55886091bb3572d508775b4f1d2f232a2a4d4e7e Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 22:22:38 -0800 Subject: [PATCH 39/88] fixing center disk duplication and a couple viz bugs --- src/quantem/core/fitting/diffraction.py | 126 ++++++++++++++++-- .../model_fitting_visualizations.py | 40 +++--- 2 files changed, 136 insertions(+), 30 deletions(-) diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 90070106..a38c358d 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -77,11 +77,36 @@ def __init__( intensity: float | Sequence[float] = 1.0, constraint_params: dict[str, Any] | None = None, ): + """ + Build a disk template renderer centered at the shared origin. + + Parameters + ---------- + intensity : float | Sequence[float], optional + Trainable scalar amplitude applied to the rendered template. + + Returns + ------- + None + + Raises + ------ + ValueError + If ``array`` is not 2D or if ``normalize`` is unsupported. + + Notes + ----- + ``template_raw`` controls template shape and ``intensity_raw`` controls + center-disk amplitude. + """ super().__init__() self.name = str(name) self.refine_all_pixels = bool(refine_all_pixels) self.origin = origin self.origin_key = str(origin_key) + self.intensity_raw = nn.Parameter( + torch.tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) + ) a = np.asarray(array, dtype=np.float32) if a.ndim != 2: @@ -138,6 +163,11 @@ def from_array( def set_origin(self, origin: OriginND) -> None: self.origin = origin + def set_intensity(self, value: float | int) -> None: + """Assign ``intensity_raw`` in-place.""" + with torch.no_grad(): + self.intensity_raw.copy_(torch.as_tensor(float(value), dtype=self.intensity_raw.dtype)) + def patch_values(self) -> torch.Tensor: return self.template_raw.reshape(-1) @@ -153,11 +183,25 @@ def add_patch( _splat_patch(out, r0=r0, c0=c0, patch_vals=vals, dr=dr, dc=dc, scale=scale) def forward(self, ctx: RenderContext) -> torch.Tensor: + """ + Render template at origin with scalar amplitude ``intensity_raw``. + + Parameters + ---------- + ctx : RenderContext + Rendering context. + + Returns + ------- + torch.Tensor + Rendered center disk image. + """ out = torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) if self.origin is None: raise RuntimeError("DiskTemplate.forward() requires an OriginND instance.") r0, c0 = self.origin.coords[0], self.origin.coords[1] - self.add_patch(out, r0=r0, c0=c0, scale=torch.tensor(1.0)) # scale learned directly + scale = self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype) + self.add_patch(out, r0=r0, c0=c0, scale=scale) return out def _center_disk(self) -> None: @@ -199,6 +243,7 @@ def _center_disk(self) -> None: def _enforce_positivity(self) -> None: with torch.no_grad(): self.template_raw.clamp_(min=0.0) + self.intensity_raw.clamp_(min=0.0) def enforce_hard_constraints(self, ctx: RenderContext) -> None: if bool(self.hard_constraints.get("force_center", False)): @@ -228,6 +273,10 @@ def constraint_loss( class SyntheticDiskLattice(RenderComponent): + DEFAULT_HARD_CONSTRAINTS: dict[str, bool] = { + "force_positive_intensity": True, + } + def __init__( self, *, @@ -256,6 +305,41 @@ def __init__( origin_key: str = "origin", constraint_params: dict[str, Any] | None = None, ): + """ + Build a synthetic disk lattice renderer. + + Parameters + ---------- + intensity_0 : float | Sequence[float], optional + Baseline intensity for included lattice disks. + center_intensity_0 : float | Sequence[float] | None, optional + Optional center-disk baseline used only when ``(0, 0)`` is included + in the lattice index set. + exclude_indices : Iterable[tuple[int, int]] | None, optional + Lattice indices excluded from rendering. By default, ``(0, 0)`` is + excluded. To include center explicitly, pass ``exclude_indices`` that + does not contain ``(0, 0)``. + + Returns + ------- + None + + Raises + ------ + ValueError + If ``center_intensity_0`` is provided with center included while + ``per_disk_intensity=False``. + + Notes + ----- + Center-intensity routing is explicit: + - Center excluded (default): ``center_intensity_0`` maps to + ``disk.intensity_raw``. + - Center included with ``per_disk_intensity=True``: + ``center_intensity_0`` maps to lattice center ``i0_raw`` entry. + In this case ``disk.intensity_raw`` is set to ``0`` to avoid + duplicate center ownership when disk is rendered standalone. + """ super().__init__() self.name = str(name) self.disk = disk @@ -289,10 +373,13 @@ def __init__( torch.tensor(_parse_init(v_col, name="v_col"), dtype=torch.float32) ) - if exclude_indices is None: - exclude = set() - else: - exclude = set(exclude_indices) + exclude = {(0, 0)} if exclude_indices is None else set(exclude_indices) + center_included = (0, 0) not in exclude + if center_intensity_0 is not None and center_included and not self.per_disk_intensity: + raise ValueError( + "center_intensity_0 with center included requires per_disk_intensity=True, " + "or exclude (0,0) and use DiskTemplate intensity ownership." + ) uv: list[tuple[int, int]] = [] for u in range(-self.u_max, self.u_max + 1): for v in range(-self.v_max, self.v_max + 1): @@ -310,6 +397,8 @@ def __init__( if center_intensity_0 is None else _parse_init(center_intensity_0, name="center_intensity_0") ) + if center_intensity_0 is not None and not center_included: + self.disk.set_intensity(float(i0_center)) ir_init = _parse_init(intensity_row, name="intensity_row") ic_init = _parse_init(intensity_col, name="intensity_col") irr_init = _parse_init(intensity_row_row, name="intensity_row_row") @@ -322,6 +411,8 @@ def __init__( center_mask = (uv_t[:, 0] == 0) & (uv_t[:, 1] == 0) i0_values[center_mask] = float(i0_center) self.i0_raw = nn.Parameter(i0_values) + if center_intensity_0 is not None and center_included: + self.disk.set_intensity(0.0) if self.max_intensity_order >= 1: self.ir = nn.Parameter(torch.full((n_uv,), float(ir_init), dtype=torch.float32)) self.ic = nn.Parameter(torch.full((n_uv,), float(ic_init), dtype=torch.float32)) @@ -345,10 +436,29 @@ def __init__( self.irc = nn.Parameter(torch.tensor(irc_init, dtype=torch.float32)) if constraint_params is not None: self.apply_constraint_params(constraint_params, strict=True) + if bool(self.hard_constraints.get("force_positive_intensity", False)): + self._enforce_positive_intensity_params() def set_origin(self, origin: OriginND) -> None: self.origin = origin + def _enforce_positive_intensity_params(self) -> None: + """ + Project base intensity parameter(s) to nonnegative values. + + Notes + ----- + Positivity is enforced as a hard projection after optimizer steps. + The forward path intentionally avoids clamp-based dead gradients. + Only ``i0_raw`` is projected; slope terms remain unconstrained. + """ + with torch.no_grad(): + self.i0_raw.clamp_(min=0.0) + + def enforce_hard_constraints(self, ctx: RenderContext) -> None: + if bool(self.hard_constraints.get("force_positive_intensity", False)): + self._enforce_positive_intensity_params() + def forward(self, ctx: RenderContext) -> torch.Tensor: if self.origin is None: raise RuntimeError("SyntheticDiskLattice requires an OriginND instance.") @@ -391,7 +501,7 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: cc0 = centers_c[j] if self.per_disk_intensity: - inten = torch.clamp(self.i0_raw[j], min=0.0) + inten = self.i0_raw[j] if active_order >= 1 and self.ir is not None and self.ic is not None: inten = inten + self.ir[j] * dr + self.ic[j] * dc if ( @@ -401,9 +511,8 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: and self.irc is not None ): inten = inten + self.irr[j] * dr2 + self.icc[j] * dc2 + self.irc[j] * drdc - inten = torch.clamp(inten, min=0.0) else: - inten = torch.clamp(self.i0_raw, min=0.0) + inten = self.i0_raw if active_order >= 1: assert self.ir is not None and self.ic is not None inten = inten + self.ir * rr0 + self.ic * cc0 @@ -412,7 +521,6 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: inten = ( inten + self.irr * rr0 * rr0 + self.icc * cc0 * cc0 + self.irc * rr0 * cc0 ) - inten = torch.clamp(inten, min=0.0) self.disk.add_patch(out, r0=rr0, c0=cc0, scale=inten) diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py index 95245aa1..7be1f470 100644 --- a/src/quantem/diffraction/model_fitting_visualizations.py +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -356,12 +356,13 @@ def visualize_components( disk_marker_kwargs: dict[str, Any] | None = None, ) -> tuple[Any, Any] | None: """ - Render and display one or more named components. + Render and display a summed component image. Parameters ---------- components : str | list[str] - Component name or list of component names. + Component name or list of component names. Multiple names are + composited by summing component renders. power : float, optional Power-law display scaling. cbar : bool, optional @@ -391,17 +392,16 @@ def visualize_components( if len(names) == 0: raise ValueError("components must contain at least one component name.") - rendered = [md.get_rendered_component(name) for name in names] - rendered_scaled = [ - arr - if power == 1.0 - else np.maximum(np.asarray(arr, dtype=np.float32), 0.0) ** float(power) - for arr in rendered + rendered = [ + np.asarray(md.get_rendered_component(name), dtype=np.float32) for name in names ] + summed = np.sum(np.stack(rendered, axis=0), axis=0) + summed_scaled = summed if power == 1.0 else np.maximum(summed, 0.0) ** float(power) + title = names[0] if len(names) == 1 else " + ".join(names) fig, ax = show_2d( - rendered_scaled, - title=names, + summed_scaled, + title=title, cmap="gray", cbar=bool(cbar), returnfig=True, @@ -410,17 +410,15 @@ def visualize_components( if overlay: origin_rc, disk_centers_rc = md.get_overlay_coordinates() - axs_arr = np.asarray(ax, dtype=object).reshape(-1) - for a in axs_arr: - self._plot_overlays( - a, - origin_rc, - disk_centers_rc, - overlay_origin=overlay_origin, - overlay_disks=overlay_disks, - origin_marker_kwargs=origin_marker_kwargs, - disk_marker_kwargs=disk_marker_kwargs, - ) + self._plot_overlays( + ax, + origin_rc, + disk_centers_rc, + overlay_origin=overlay_origin, + overlay_disks=overlay_disks, + origin_marker_kwargs=origin_marker_kwargs, + disk_marker_kwargs=disk_marker_kwargs, + ) if returnfig: return fig, ax From 1fbc76c9589a3ced07ba55e1bfcf2797d31d93ad Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 22:41:02 -0800 Subject: [PATCH 40/88] adding back parameter bounds --- src/quantem/core/fitting/background.py | 36 +++--- src/quantem/core/fitting/base.py | 153 +++++++++++++++++++++++- src/quantem/core/fitting/diffraction.py | 130 ++++++++++++++------ 3 files changed, 260 insertions(+), 59 deletions(-) diff --git a/src/quantem/core/fitting/background.py b/src/quantem/core/fitting/background.py index 47333a29..31878dec 100644 --- a/src/quantem/core/fitting/background.py +++ b/src/quantem/core/fitting/background.py @@ -1,24 +1,13 @@ from __future__ import annotations -from typing import Any, Sequence, cast +from typing import Any, Sequence -import numpy as np import torch from torch import nn from quantem.core.fitting.base import OriginND, RenderComponent, RenderContext -def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: - if isinstance(value, (list, tuple, np.ndarray)): - if len(value) == 0: - raise ValueError(f"{name} cannot be empty.") - if value[0] is None: - raise ValueError(f"{name} initial value cannot be None.") - return float(value[0]) - return float(cast(float | int, value)) - - class DCBackground(RenderComponent): def __init__( self, @@ -29,9 +18,12 @@ def __init__( ): super().__init__() self.name = str(name) - self.intensity_raw = nn.Parameter( - torch.tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) + intensity_init, intensity_lo, intensity_hi = self.parse_bounded_init( + intensity, name="intensity" ) + self.intensity_raw = nn.Parameter(torch.tensor(intensity_init, dtype=torch.float32)) + if intensity_lo is not None or intensity_hi is not None: + self.register_parameter_bounds("intensity_raw", intensity_lo, intensity_hi) if constraint_params is not None: self.apply_constraint_params(constraint_params, strict=True) @@ -40,7 +32,7 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: return torch.ones(ctx.shape, device=ctx.device, dtype=ctx.dtype) * inten -class GaussianBackground(RenderComponent): +class GaussianBackground(RenderComponent): # TODO this should be N dimensional by default def __init__( self, *, @@ -55,12 +47,16 @@ def __init__( self.name = str(name) self.origin = origin self.origin_key = str(origin_key) - self.sigma_raw = nn.Parameter( - torch.tensor(_parse_init(sigma, name="sigma"), dtype=torch.float32) - ) - self.intensity_raw = nn.Parameter( - torch.tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) + sigma_init, sigma_lo, sigma_hi = self.parse_bounded_init(sigma, name="sigma") + intensity_init, intensity_lo, intensity_hi = self.parse_bounded_init( + intensity, name="intensity" ) + self.sigma_raw = nn.Parameter(torch.tensor(sigma_init, dtype=torch.float32)) + if sigma_lo is not None or sigma_hi is not None: + self.register_parameter_bounds("sigma_raw", sigma_lo, sigma_hi) + self.intensity_raw = nn.Parameter(torch.tensor(intensity_init, dtype=torch.float32)) + if intensity_lo is not None or intensity_hi is not None: + self.register_parameter_bounds("intensity_raw", intensity_lo, intensity_hi) if constraint_params is not None: self.apply_constraint_params(constraint_params, strict=True) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index f4136cdd..29933e14 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -11,6 +11,65 @@ from quantem.core.ml.optimizer_mixin import OptimizerMixin +def parse_bounded_init( + value: float | int | Sequence[float | int | None], *, name: str +) -> tuple[float, float | None, float | None]: + """ + Parse a scalar or bounded initializer specification. + + Parameters + ---------- + value : float | int | Sequence[float | int | None] + Accepted forms: + - ``x`` -> init ``x`` with no bounds. + - ``(x0, delta)`` -> init ``x0`` with bounds ``[x0-|delta|, x0+|delta|]``. + - ``(x0, lo, hi)`` -> init ``x0`` with explicit bounds. + name : str + Parameter name used in error messages. + + Returns + ------- + tuple[float, float | None, float | None] + Parsed ``(init, lo, hi)``. + + Raises + ------ + ValueError + If the sequence form is invalid, contains required ``None`` entries, + has invalid ordering, or ``init`` lies outside explicit bounds. + """ + if not isinstance(value, (list, tuple, np.ndarray)): + x = float(cast(float | int, value)) + return x, None, None + + seq = list(value) + if len(seq) == 0: + raise ValueError(f"{name} cannot be empty.") + if seq[0] is None: + raise ValueError(f"{name} initial value cannot be None.") + x0 = float(cast(float | int, seq[0])) + + if len(seq) == 1: + return x0, None, None + if len(seq) == 2: + if seq[1] is None: + raise ValueError(f"{name} delta cannot be None.") + delta = abs(float(cast(float | int, seq[1]))) + return x0, x0 - delta, x0 + delta + if len(seq) == 3: + if seq[1] is None or seq[2] is None: + raise ValueError(f"{name} bounds cannot contain None.") + lo = float(cast(float | int, seq[1])) + hi = float(cast(float | int, seq[2])) + if lo > hi: + raise ValueError(f"{name} has invalid bounds: lo ({lo}) > hi ({hi}).") + if x0 < lo or x0 > hi: + raise ValueError(f"{name} initial value {x0} is outside bounds [{lo}, {hi}].") + return x0, lo, hi + + raise ValueError(f"{name} must be scalar, (x0, delta), or (x0, lo, hi).") + + @dataclass class RenderContext: shape: tuple[int, ...] @@ -39,6 +98,98 @@ def __init__(self) -> None: super().__init__() self.hard_constraints: dict[str, Any] = dict(self.DEFAULT_HARD_CONSTRAINTS) self.soft_constraints: dict[str, Any] = dict(self.DEFAULT_SOFT_CONSTRAINTS) + self.parameter_bounds: dict[str, tuple[float | None, float | None]] = {} + + @staticmethod + def parse_bounded_init( + value: float | int | Sequence[float | int | None], *, name: str + ) -> tuple[float, float | None, float | None]: + """ + Parse bounded initializer forms into ``(init, lo, hi)``. + + Parameters + ---------- + value : float | int | Sequence[float | int | None] + Scalar, ``(x0, delta)``, or ``(x0, lo, hi)``. + name : str + Parameter name used in error messages. + + Returns + ------- + tuple[float, float | None, float | None] + Parsed ``(init, lo, hi)``. + """ + return parse_bounded_init(value, name=name) + + def register_parameter_bounds( + self, parameter_name: str, lo: float | None, hi: float | None + ) -> None: + """ + Register hard bounds for a trainable parameter. + + Parameters + ---------- + parameter_name : str + Name of an ``nn.Parameter`` attribute on this component. + lo : float | None + Lower bound, or ``None`` for unbounded lower side. + hi : float | None + Upper bound, or ``None`` for unbounded upper side. + + Returns + ------- + None + + Raises + ------ + ValueError + If ``lo > hi``. + """ + if lo is not None and hi is not None and float(lo) > float(hi): + raise ValueError(f"Invalid bounds for {parameter_name}: lo ({lo}) > hi ({hi}).") + self.parameter_bounds[str(parameter_name)] = ( + None if lo is None else float(lo), + None if hi is None else float(hi), + ) + + def _enforce_parameter_bounds(self) -> None: + """ + Clamp registered parameters in-place to configured bounds. + + Returns + ------- + None + + Raises + ------ + AttributeError + If a registered parameter attribute is missing. + TypeError + If a registered attribute is not an ``nn.Parameter``. + """ + if not self.parameter_bounds: + return + with torch.no_grad(): + for param_name, (lo, hi) in self.parameter_bounds.items(): + if not hasattr(self, param_name): + raise AttributeError( + f"Parameter '{param_name}' is not an attribute of {self.__class__.__name__}." + ) + param = getattr(self, param_name) + if not isinstance(param, nn.Parameter): + raise TypeError( + f"Attribute '{param_name}' on {self.__class__.__name__} is not an nn.Parameter." + ) + if lo is None and hi is None: + continue + if lo is None: + assert hi is not None + param.clamp_(max=float(hi)) + elif hi is None: + assert lo is not None + param.clamp_(min=float(lo)) + else: + param.clamp_(min=float(lo), max=float(hi)) def _set_constraints( self, @@ -109,7 +260,7 @@ def effective_soft_constraints(self, params: dict[str, Any] | None = None) -> di return effective def enforce_hard_constraints(self, ctx: RenderContext) -> None: - return None + self._enforce_parameter_bounds() def forward(self, ctx: RenderContext) -> torch.Tensor: raise NotImplementedError diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index a38c358d..30934bf5 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -10,16 +10,6 @@ from quantem.core.fitting.base import OriginND, RenderComponent, RenderContext -def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: - if isinstance(value, (list, tuple, np.ndarray)): - if len(value) == 0: - raise ValueError(f"{name} cannot be empty.") - if value[0] is None: - raise ValueError(f"{name} initial value cannot be None.") - return float(value[0]) - return float(cast(float | int, value)) - - def _splat_patch( out: torch.Tensor, *, @@ -84,6 +74,7 @@ def __init__( ---------- intensity : float | Sequence[float], optional Trainable scalar amplitude applied to the rendered template. + Accepts ``x``, ``(x0, delta)``, or ``(x0, lo, hi)``. Returns ------- @@ -104,9 +95,12 @@ def __init__( self.refine_all_pixels = bool(refine_all_pixels) self.origin = origin self.origin_key = str(origin_key) - self.intensity_raw = nn.Parameter( - torch.tensor(_parse_init(intensity, name="intensity"), dtype=torch.float32) + intensity_init, intensity_lo, intensity_hi = self.parse_bounded_init( + intensity, name="intensity" ) + self.intensity_raw = nn.Parameter(torch.tensor(intensity_init, dtype=torch.float32)) + if intensity_lo is not None or intensity_hi is not None: + self.register_parameter_bounds("intensity_raw", intensity_lo, intensity_hi) a = np.asarray(array, dtype=np.float32) if a.ndim != 2: @@ -250,6 +244,7 @@ def enforce_hard_constraints(self, ctx: RenderContext) -> None: self._center_disk() if bool(self.hard_constraints.get("force_positive", False)): self._enforce_positivity() + super().enforce_hard_constraints(ctx) def constraint_loss( self, ctx: RenderContext, params: dict[str, object] | None = None @@ -310,11 +305,19 @@ def __init__( Parameters ---------- + u_row, u_col, v_row, v_col : float | Sequence[float | int | None] + Lattice basis parameters. Accept ``x``, ``(x0, delta)``, or + ``(x0, lo, hi)``. intensity_0 : float | Sequence[float], optional - Baseline intensity for included lattice disks. + Baseline intensity for included lattice disks. Accepts ``x``, + ``(x0, delta)``, or ``(x0, lo, hi)``. + intensity_row, intensity_col, intensity_row_row, intensity_col_col, intensity_row_col : + float | Sequence[float | int | None] + Intensity polynomial parameters. Accept ``x``, ``(x0, delta)``, or + ``(x0, lo, hi)``. center_intensity_0 : float | Sequence[float] | None, optional - Optional center-disk baseline used only when ``(0, 0)`` is included - in the lattice index set. + Optional center-disk baseline. Accepts ``x``, ``(x0, delta)``, or + ``(x0, lo, hi)`` and routes by center ownership rules. exclude_indices : Iterable[tuple[int, int]] | None, optional Lattice indices excluded from rendering. By default, ``(0, 0)`` is excluded. To include center explicitly, pass ``exclude_indices`` that @@ -360,18 +363,22 @@ def __init__( default_pattern_intensity_order = self.max_intensity_order self.default_pattern_intensity_order = int(default_pattern_intensity_order) - self.u_row = nn.Parameter( - torch.tensor(_parse_init(u_row, name="u_row"), dtype=torch.float32) - ) - self.u_col = nn.Parameter( - torch.tensor(_parse_init(u_col, name="u_col"), dtype=torch.float32) - ) - self.v_row = nn.Parameter( - torch.tensor(_parse_init(v_row, name="v_row"), dtype=torch.float32) - ) - self.v_col = nn.Parameter( - torch.tensor(_parse_init(v_col, name="v_col"), dtype=torch.float32) - ) + u_row_init, u_row_lo, u_row_hi = self.parse_bounded_init(u_row, name="u_row") + u_col_init, u_col_lo, u_col_hi = self.parse_bounded_init(u_col, name="u_col") + v_row_init, v_row_lo, v_row_hi = self.parse_bounded_init(v_row, name="v_row") + v_col_init, v_col_lo, v_col_hi = self.parse_bounded_init(v_col, name="v_col") + self.u_row = nn.Parameter(torch.tensor(u_row_init, dtype=torch.float32)) + if u_row_lo is not None or u_row_hi is not None: + self.register_parameter_bounds("u_row", u_row_lo, u_row_hi) + self.u_col = nn.Parameter(torch.tensor(u_col_init, dtype=torch.float32)) + if u_col_lo is not None or u_col_hi is not None: + self.register_parameter_bounds("u_col", u_col_lo, u_col_hi) + self.v_row = nn.Parameter(torch.tensor(v_row_init, dtype=torch.float32)) + if v_row_lo is not None or v_row_hi is not None: + self.register_parameter_bounds("v_row", v_row_lo, v_row_hi) + self.v_col = nn.Parameter(torch.tensor(v_col_init, dtype=torch.float32)) + if v_col_lo is not None or v_col_hi is not None: + self.register_parameter_bounds("v_col", v_col_lo, v_col_hi) exclude = {(0, 0)} if exclude_indices is None else set(exclude_indices) center_included = (0, 0) not in exclude @@ -391,31 +398,51 @@ def __init__( self.register_buffer("uv_indices", uv_t) n_uv = int(uv_t.shape[0]) - i0_init = _parse_init(intensity_0, name="intensity_0") - i0_center = ( - i0_init - if center_intensity_0 is None - else _parse_init(center_intensity_0, name="center_intensity_0") - ) + i0_init, i0_lo, i0_hi = self.parse_bounded_init(intensity_0, name="intensity_0") + if center_intensity_0 is None: + i0_center, i0_center_lo, i0_center_hi = i0_init, None, None + else: + i0_center, i0_center_lo, i0_center_hi = self.parse_bounded_init( + center_intensity_0, name="center_intensity_0" + ) if center_intensity_0 is not None and not center_included: self.disk.set_intensity(float(i0_center)) - ir_init = _parse_init(intensity_row, name="intensity_row") - ic_init = _parse_init(intensity_col, name="intensity_col") - irr_init = _parse_init(intensity_row_row, name="intensity_row_row") - icc_init = _parse_init(intensity_col_col, name="intensity_col_col") - irc_init = _parse_init(intensity_row_col, name="intensity_row_col") + if i0_center_lo is not None or i0_center_hi is not None: + self.disk.register_parameter_bounds("intensity_raw", i0_center_lo, i0_center_hi) + ir_init, ir_lo, ir_hi = self.parse_bounded_init(intensity_row, name="intensity_row") + ic_init, ic_lo, ic_hi = self.parse_bounded_init(intensity_col, name="intensity_col") + irr_init, irr_lo, irr_hi = self.parse_bounded_init( + intensity_row_row, name="intensity_row_row" + ) + icc_init, icc_lo, icc_hi = self.parse_bounded_init( + intensity_col_col, name="intensity_col_col" + ) + irc_init, irc_lo, irc_hi = self.parse_bounded_init( + intensity_row_col, name="intensity_row_col" + ) + self._center_i0_bounds: tuple[float | None, float | None] | None = None + self._center_i0_index: int | None = None if self.per_disk_intensity: i0_values = torch.full((n_uv,), float(i0_init), dtype=torch.float32) if n_uv > 0: center_mask = (uv_t[:, 0] == 0) & (uv_t[:, 1] == 0) i0_values[center_mask] = float(i0_center) + if center_intensity_0 is not None and bool(torch.any(center_mask)): + self._center_i0_index = int(torch.nonzero(center_mask, as_tuple=False)[0, 0]) + self._center_i0_bounds = (i0_center_lo, i0_center_hi) self.i0_raw = nn.Parameter(i0_values) + if i0_lo is not None or i0_hi is not None: + self.register_parameter_bounds("i0_raw", i0_lo, i0_hi) if center_intensity_0 is not None and center_included: self.disk.set_intensity(0.0) if self.max_intensity_order >= 1: self.ir = nn.Parameter(torch.full((n_uv,), float(ir_init), dtype=torch.float32)) self.ic = nn.Parameter(torch.full((n_uv,), float(ic_init), dtype=torch.float32)) + if ir_lo is not None or ir_hi is not None: + self.register_parameter_bounds("ir", ir_lo, ir_hi) + if ic_lo is not None or ic_hi is not None: + self.register_parameter_bounds("ic", ic_lo, ic_hi) else: self.ir = None self.ic = None @@ -423,17 +450,35 @@ def __init__( self.irr = nn.Parameter(torch.full((n_uv,), float(irr_init), dtype=torch.float32)) self.icc = nn.Parameter(torch.full((n_uv,), float(icc_init), dtype=torch.float32)) self.irc = nn.Parameter(torch.full((n_uv,), float(irc_init), dtype=torch.float32)) + if irr_lo is not None or irr_hi is not None: + self.register_parameter_bounds("irr", irr_lo, irr_hi) + if icc_lo is not None or icc_hi is not None: + self.register_parameter_bounds("icc", icc_lo, icc_hi) + if irc_lo is not None or irc_hi is not None: + self.register_parameter_bounds("irc", irc_lo, irc_hi) else: self.irr = None self.icc = None self.irc = None else: self.i0_raw = nn.Parameter(torch.tensor(i0_init, dtype=torch.float32)) + if i0_lo is not None or i0_hi is not None: + self.register_parameter_bounds("i0_raw", i0_lo, i0_hi) self.ir = nn.Parameter(torch.tensor(ir_init, dtype=torch.float32)) + if ir_lo is not None or ir_hi is not None: + self.register_parameter_bounds("ir", ir_lo, ir_hi) self.ic = nn.Parameter(torch.tensor(ic_init, dtype=torch.float32)) + if ic_lo is not None or ic_hi is not None: + self.register_parameter_bounds("ic", ic_lo, ic_hi) self.irr = nn.Parameter(torch.tensor(irr_init, dtype=torch.float32)) + if irr_lo is not None or irr_hi is not None: + self.register_parameter_bounds("irr", irr_lo, irr_hi) self.icc = nn.Parameter(torch.tensor(icc_init, dtype=torch.float32)) + if icc_lo is not None or icc_hi is not None: + self.register_parameter_bounds("icc", icc_lo, icc_hi) self.irc = nn.Parameter(torch.tensor(irc_init, dtype=torch.float32)) + if irc_lo is not None or irc_hi is not None: + self.register_parameter_bounds("irc", irc_lo, irc_hi) if constraint_params is not None: self.apply_constraint_params(constraint_params, strict=True) if bool(self.hard_constraints.get("force_positive_intensity", False)): @@ -458,6 +503,15 @@ def _enforce_positive_intensity_params(self) -> None: def enforce_hard_constraints(self, ctx: RenderContext) -> None: if bool(self.hard_constraints.get("force_positive_intensity", False)): self._enforce_positive_intensity_params() + if self._center_i0_bounds is not None and self._center_i0_index is not None: + with torch.no_grad(): + lo, hi = self._center_i0_bounds + idx = self._center_i0_index + if lo is not None: + self.i0_raw[idx].clamp_(min=float(lo)) + if hi is not None: + self.i0_raw[idx].clamp_(max=float(hi)) + super().enforce_hard_constraints(ctx) def forward(self, ctx: RenderContext) -> torch.Tensor: if self.origin is None: From 20b2c58f2c26b1e5c271dca7e5216033df4f2762 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 22:48:17 -0800 Subject: [PATCH 41/88] updating colormaps --- .../model_fitting_visualizations.py | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py index 7be1f470..6dfb4f62 100644 --- a/src/quantem/diffraction/model_fitting_visualizations.py +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -4,6 +4,7 @@ from matplotlib import gridspec from matplotlib import pyplot as plt +from quantem.core import config from quantem.core.visualization import show_2d if TYPE_CHECKING: @@ -46,12 +47,13 @@ def _plot_overlays( ------- None """ + colors = config.get("viz.colors.set") if overlay_origin and origin_rc.shape == (2,): kw_origin = { "marker": "+", - "color": "red", + "color": colors[0], "markersize": 10, - "markeredgewidth": 1.5, + "markeredgewidth": 3, "linestyle": "None", } if origin_marker_kwargs is not None: @@ -61,9 +63,9 @@ def _plot_overlays( if overlay_disks and disk_centers_rc.ndim == 2 and disk_centers_rc.shape[0] > 0: kw_disks = { "marker": "x", - "color": "cyan", + "color": colors[1], "markersize": 5, - "markeredgewidth": 1.0, + "markeredgewidth": 2.0, "linestyle": "None", } if disk_marker_kwargs is not None: @@ -74,6 +76,9 @@ def plot_losses( self, figax: tuple[Any, Any] | None = None, plot_lrs: bool = True ) -> tuple[Any, Any]: md = cast("ModelDiffraction", self) + colors = config.get("viz.colors.set") + loss_color = "k" + lr_color = colors[8] if figax is None: fig, ax = plt.subplots() @@ -100,11 +105,11 @@ def plot_losses( iters = np.arange(losses.size) lines: list[Any] = [] - lines.extend(ax.semilogy(iters, losses, c="k", lw=2, label="loss")) + lines.extend(ax.semilogy(iters, losses, c=loss_color, lw=2, label="loss")) ax.set_xlabel("Iterations") - ax.set_ylabel("Loss", color="k") - ax.tick_params(axis="y", which="both", colors="k") - ax.spines["left"].set_color("k") + ax.set_ylabel("Loss", color=loss_color) + ax.tick_params(axis="y", which="both", colors=loss_color) + ax.spines["left"].set_color(loss_color) ax.set_xbound(-2, max(1, int(iters.max())) + 2) lrs = np.asarray([] if mean_hist is None else mean_hist.lrs, dtype=np.float64) @@ -116,13 +121,11 @@ def plot_losses( ax.patch.set_visible(False) ax_lr.spines["left"].set_visible(False) lines.extend( - ax_lr.semilogy( - np.arange(lrs.size), lrs, c="tab:blue", lw=2, ls="--", label="LR" - ) + ax_lr.semilogy(np.arange(lrs.size), lrs, c=lr_color, lw=2, ls="--", label="LR") ) - ax_lr.set_ylabel("LR", color="tab:blue") - ax_lr.tick_params(axis="y", which="both", colors="tab:blue") - ax_lr.spines["right"].set_color("tab:blue") + ax_lr.set_ylabel("LR", color=lr_color) + ax_lr.tick_params(axis="y", which="both", colors=lr_color) + ax_lr.spines["right"].set_color(lr_color) else: ax.set_title(f"LR: {float(lrs[-1]):.2e}", fontsize=10) @@ -211,7 +214,7 @@ def visualize( [refp, predp], figax=(fig, axs), title=["image_ref", "model"], - cmap="gray", + cmap=config.get("viz.cmap"), cbar=bool(cbar), returnfig=False, axsize=axsize, @@ -314,7 +317,7 @@ def plot_mean_model( fig, ax = show_2d( [refp, predp], title=["image_ref", "model"], - cmap="gray", + cmap=config.get("viz.cmap"), cbar=False, returnfig=True, axsize=axsize, @@ -402,7 +405,7 @@ def visualize_components( fig, ax = show_2d( summed_scaled, title=title, - cmap="gray", + cmap=config.get("viz.cmap"), cbar=bool(cbar), returnfig=True, axsize=axsize, From f68742ea93e735c4b76f0365631eea81c91f5bba Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 22:58:21 -0800 Subject: [PATCH 42/88] more consistent hard constraints of ranges --- src/quantem/core/fitting/background.py | 53 ++++++++++++++++++++---- src/quantem/diffraction/model_fitting.py | 5 ++- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/quantem/core/fitting/background.py b/src/quantem/core/fitting/background.py index 31878dec..000d4a01 100644 --- a/src/quantem/core/fitting/background.py +++ b/src/quantem/core/fitting/background.py @@ -16,19 +16,36 @@ def __init__( name: str = "dc_background", constraint_params: dict[str, Any] | None = None, ): + """ + Build a constant background component. + + Notes + ----- + Validity is enforced via hard constraints/parameter bounds. Forward + intentionally avoids hard clamps for gradient flow. + """ super().__init__() self.name = str(name) intensity_init, intensity_lo, intensity_hi = self.parse_bounded_init( intensity, name="intensity" ) self.intensity_raw = nn.Parameter(torch.tensor(intensity_init, dtype=torch.float32)) - if intensity_lo is not None or intensity_hi is not None: - self.register_parameter_bounds("intensity_raw", intensity_lo, intensity_hi) + bounded_lo = 0.0 if intensity_lo is None else max(float(intensity_lo), 0.0) + self.register_parameter_bounds("intensity_raw", bounded_lo, intensity_hi) if constraint_params is not None: self.apply_constraint_params(constraint_params, strict=True) + self._enforce_parameter_bounds() def forward(self, ctx: RenderContext) -> torch.Tensor: - inten = torch.clamp(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype), min=0.0) + """ + Render constant background from raw trainable intensity. + + Notes + ----- + Validity is enforced via hard constraints/parameter bounds, not via + forward-time hard clamps. + """ + inten = self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype) return torch.ones(ctx.shape, device=ctx.device, dtype=ctx.dtype) * inten @@ -43,6 +60,15 @@ def __init__( name: str = "gaussian_background", constraint_params: dict[str, Any] | None = None, ): + """ + Build a Gaussian background component centered at origin. + + Notes + ----- + ``sigma_raw`` and ``intensity_raw`` validity is enforced via hard + constraints/parameter bounds. Forward intentionally avoids hard clamps + for gradient flow. + """ super().__init__() self.name = str(name) self.origin = origin @@ -52,18 +78,27 @@ def __init__( intensity, name="intensity" ) self.sigma_raw = nn.Parameter(torch.tensor(sigma_init, dtype=torch.float32)) - if sigma_lo is not None or sigma_hi is not None: - self.register_parameter_bounds("sigma_raw", sigma_lo, sigma_hi) + sigma_bounded_lo = 1e-6 if sigma_lo is None else max(float(sigma_lo), 1e-6) + self.register_parameter_bounds("sigma_raw", sigma_bounded_lo, sigma_hi) self.intensity_raw = nn.Parameter(torch.tensor(intensity_init, dtype=torch.float32)) - if intensity_lo is not None or intensity_hi is not None: - self.register_parameter_bounds("intensity_raw", intensity_lo, intensity_hi) + intensity_bounded_lo = 0.0 if intensity_lo is None else max(float(intensity_lo), 0.0) + self.register_parameter_bounds("intensity_raw", intensity_bounded_lo, intensity_hi) if constraint_params is not None: self.apply_constraint_params(constraint_params, strict=True) + self._enforce_parameter_bounds() def set_origin(self, origin: OriginND) -> None: self.origin = origin def forward(self, ctx: RenderContext) -> torch.Tensor: + """ + Render Gaussian background from raw trainable parameters. + + Notes + ----- + Validity is enforced via hard constraints/parameter bounds, not via + forward-time hard clamps. + """ if self.origin is None: raise RuntimeError("GaussianBackground requires an OriginND instance.") @@ -71,7 +106,7 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: cc = torch.arange(ctx.shape[1], device=ctx.device, dtype=ctx.dtype)[None, :] r0, c0 = self.origin.coords[0], self.origin.coords[1] - sigma = torch.clamp(self.sigma_raw.to(device=ctx.device, dtype=ctx.dtype), min=1e-6) - inten = torch.clamp(self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype), min=0.0) + sigma = self.sigma_raw.to(device=ctx.device, dtype=ctx.dtype) + inten = self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype) r2 = (rr - r0) ** 2 + (cc - c0) ** 2 return inten * torch.exp(-0.5 * r2 / (sigma * sigma)) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 991653e9..70928022 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -76,7 +76,10 @@ def components(self) -> torch.nn.ModuleList: def component_names(self) -> list[str]: if self.model is None: raise RuntimeError("Call .define_model(...) first.") - return [cast(str, c.name) for c in self.components] + return [ + self.model._component_constraint_name(cast(RenderComponent, module), idx) + for idx, module in enumerate(self.model.components) + ] def get_component(self, name: str) -> RenderComponent: """ From b787564507e39e50c8c8537740d8fde2ae878385 Mon Sep 17 00:00:00 2001 From: arthurmccray Date: Mon, 2 Mar 2026 23:05:56 -0800 Subject: [PATCH 43/88] cleaning up naming of Components --- src/quantem/core/fitting/base.py | 41 +++++++++++++++++++++--- src/quantem/diffraction/model_fitting.py | 19 +++-------- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 29933e14..b21e1126 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -674,16 +674,49 @@ def fit_render( self.fit_history[key] = result return result - def _resolve_component_by_name(self, component_name: str) -> RenderComponent: + def _iter_named_components(self) -> list[tuple[str, RenderComponent]]: + """ + Return canonical component names paired with components. + + Returns + ------- + list[tuple[str, RenderComponent]] + ``(name, component)`` entries using the model's canonical naming + rule. Names fall back to class-name/index behavior when ``.name`` is + missing. + + Raises + ------ + RuntimeError + If the model is not defined. + """ if self.model is None: raise RuntimeError("Call .define_model(...) first.") - target = str(component_name) + entries: list[tuple[str, RenderComponent]] = [] for idx, module in enumerate(self.model.components): component = cast(RenderComponent, module) - resolved_name = self.model._component_constraint_name(component, idx) + name = self.model._component_constraint_name(component, idx) + entries.append((name, component)) + return entries + + def get_component_names(self) -> list[str]: + """ + Return canonical component names. + + Returns + ------- + list[str] + Canonical component names. + """ + return [name for name, _ in self._iter_named_components()] + + def _resolve_component_by_name(self, component_name: str) -> RenderComponent: + target = str(component_name) + for resolved_name, component in self._iter_named_components(): if resolved_name == target: return component - raise KeyError(f"Component not found: {target}") + known = ", ".join(self.get_component_names()) + raise KeyError(f"Component not found: {target}. Known components: {known}") def _infer_optimizer_rebuild_params(self) -> dict[str, Any]: if self.optimizer_params: diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 70928022..55cddfcb 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -72,15 +72,6 @@ def components(self) -> torch.nn.ModuleList: raise RuntimeError("Call .define_model(...) first.") return self.model.components - @property - def component_names(self) -> list[str]: - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - return [ - self.model._component_constraint_name(cast(RenderComponent, module), idx) - for idx, module in enumerate(self.model.components) - ] - def get_component(self, name: str) -> RenderComponent: """ Return a live model component by resolved name. @@ -221,11 +212,11 @@ def set_disk_template_trainable( ) return - disk_names: list[str] = [] - for idx, module in enumerate(self.model.components): - component = cast(RenderComponent, module) - if isinstance(component, DiskTemplate): - disk_names.append(self.model._component_constraint_name(component, idx)) + disk_names = [ + component_name + for component_name, component in self._iter_named_components() + if isinstance(component, DiskTemplate) + ] if len(disk_names) == 0: raise RuntimeError("No DiskTemplate components found.") From 41b5e909af7d646bb8c03ddf9683e0811924f769 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Fri, 6 Mar 2026 11:48:12 -0800 Subject: [PATCH 44/88] Individual pattern fitting function for fitting_models --- src/quantem/diffraction/model_fitting.py | 143 +++++++++++++++++- .../model_fitting_visualizations.py | 32 +++- .../diffraction/strain_autocorrelation.py | 2 +- 3 files changed, 162 insertions(+), 15 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 55cddfcb..9008ebbd 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -6,6 +6,7 @@ import torch from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey +from tqdm import tqdm from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem from quantem.core.fitting.base import ( @@ -53,6 +54,9 @@ def __init__(self, dataset: Any, _token: object | None = None): self.state_mean_refined: dict[str, torch.Tensor] | None = None self.mean_refined: bool = False + self.state_individual_refined: np.ndarray | None = None + self.individual_refined: bool = False + # Misc metadata self.metadata: dict[str, Any] = {} @@ -500,7 +504,10 @@ def fit_mean_diffraction_pattern( def reset( self, - reset_to: Literal["initialized", "mean_refined"] = "mean_refined", + reset_to: Literal["initialized", "mean_refined", "individual"] = "mean_refined", + reset_history: bool = True, + individual_row: int = 0, + individual_col: int = 0, ) -> "ModelDiffraction": if reset_to == "initialized": state = self.state_initialized @@ -508,22 +515,144 @@ def reset( raise RuntimeError( "initialized state is unavailable. Call .define_model(...) first." ) - self._clear_fit_history_all() + if reset_history: + self._clear_fit_history_all() elif reset_to == "mean_refined": state = self.state_mean_refined if state is None: raise RuntimeError( "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." ) - mean_hist = self.fit_history.get("mean") - self._clear_fit_history_all() - if mean_hist is not None: - self.fit_history["mean"] = mean_hist + if reset_history: + mean_hist = self.fit_history.get("mean") + self._clear_fit_history_all() + if mean_hist is not None: + self.fit_history["mean"] = mean_hist + elif reset_to == "individual": + if self.state_individual_refined is None: + raise ValueError("individual states is unavalible. Run fit_individual_diffraction_pattern(....) first") + if (individual_row >= self.state_individual_refined.shape[0]) or (individual_col >= self.state_individual_refined.shape[1]): + raise ValueError("row and column values not in range") + state = self.state_individual_refined[individual_row, individual_col] + if reset_history: + self._clear_fit_history_all() else: - raise ValueError("reset_to must be 'initialized' or 'mean_refined'.") + raise ValueError("reset_to must be 'initialized' or 'mean_refined' or 'individual'.") self._load_model_state_dict_copy(state) return self + + def fit_individual_diffraction_pattern( + self, + *, + rows=None, + cols = None, + n_steps: int = 200, + reset: bool | Literal["initialized", "mean_refined"], + optimizer_params: dict | None = None, + scheduler_params: dict | None = None, + constraint_weight: float = 1.0, + constraint_params: dict[str, Any] | None = None, + progress: bool = True, + ) -> "ModelDiffraction": + + if self.model is None or self.ctx is None or self.target_mean is None: + raise RuntimeError("Call .define_model(...) first.") + if reset not in ("initialized", "mean_refined"): + raise ValueError("reset must be initialized', or 'mean_refined'.") + self.reset(reset_to=cast(Literal["initialized", "mean_refined"], reset)) + if not isinstance(self.dataset, Dataset4d): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem for fit_individual_diffraction_pattern") + + scan_r = self.dataset.shape[0] + scan_c = self.dataset.shape[1] + + if rows is None and cols is None: + rows = range(self.dataset.shape[0]) + cols = range(self.dataset.shape[1]) + elif rows is not None and cols is None: + cols = range(self.dataset.shape[1]) + elif rows is None and cols is not None: + rows = range(self.dataset.shape[0]) + else: + rows = rows + cols = cols + + if isinstance(rows, int): + rows = [rows] + if isinstance(cols, int): + cols = [cols] + + self.state_individual_refined = np.full(shape=(scan_r, scan_c), fill_value=None, dtype=object) + + if progress: + pbar = tqdm(total=len(rows) * len(cols), desc="Fit individual") + + for r in rows: + for c in cols: + # print(self.dataset.array[r,c].shape) + self.fit_render( + target=torch.as_tensor(self.dataset.array[r,c],device=self.ctx.device,dtype=self.ctx.dtype), + n_steps=int(n_steps), + constraint_weight=float(constraint_weight), + constraint_params=constraint_params, + optimizer_params=optimizer_params, + scheduler_params=scheduler_params, + progress=False, + run_key=f"individual_{r}_{c}", + ) + + s_fit = self._get_model_state_dict_copy() + self.state_individual_refined[r,c] = self._clone_state_dict(s_fit) + self.reset(reset_to=cast(Literal["initialized", "mean_refined"], reset), reset_history=False) + + if progress: + pbar.update(1) + if progress: + pbar.close() + self.individual_refined=True + return self + + def get_individual_uv_vectors(self) ->tuple[np.ndarray, np.ndarray]: + scan_r = self.dataset.shape[0] + scan_c = self.dataset.shape[1] + # print(scan_r) + + u_array = np.empty(shape=(scan_r, scan_c, 2)) + v_array = np.empty(shape=(scan_r, scan_c, 2)) + if self.state_individual_refined is None: + raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") + for r in range(scan_r): + for c in range(scan_c): + pos_state = self.state_individual_refined[r,c] + if pos_state is None: + u_array[r,c,:] = None + v_array[r,c,:] = None + for key in pos_state.keys(): + key_parts = key.split('.') + if(key_parts[-1] == 'u_row'): + u_array[r,c,0] = pos_state[key] + if(key_parts[-1] == 'u_col'): + u_array[r,c,1] = pos_state[key] + if(key_parts[-1] == 'v_row'): + v_array[r,c,0] = pos_state[key] + if(key_parts[-1] == 'v_col'): + v_array[r,c,1] = pos_state[key] + + return u_array, v_array + + def render_indivdual_pattern(self, row, col): + if self.state_individual_refined is None: + raise RuntimeError( + "individual_refined_state is unavalible. Run fit_individual_diffraction_pattern(...) first." + ) + if self.dataset.shape[0] <= row or self.dataset.shape[1] <= col: + raise ValueError("individual row or column outside bounds of dataset") + if self.state_individual_refined[row, col] is None: + raise RuntimeError( + "individual_refined_state is not avalible for given row and column. Run fit_individual_diffraction_pattern(...) for that row and column." + ) + return self._render_state_array(self.state_individual_refined[row, col]) @property def render_mean_refined(self) -> np.ndarray: diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py index 6dfb4f62..8c897833 100644 --- a/src/quantem/diffraction/model_fitting_visualizations.py +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -73,7 +73,12 @@ def _plot_overlays( ax.plot(disk_centers_rc[:, 1], disk_centers_rc[:, 0], **kw_disks) def plot_losses( - self, figax: tuple[Any, Any] | None = None, plot_lrs: bool = True + self, + figax: tuple[Any, Any] | None = None, + plot_lrs: bool = True, + plot_individual: bool = False, + individual_row: int = 0, + individual_col: int = 0, ) -> tuple[Any, Any]: md = cast("ModelDiffraction", self) colors = config.get("viz.colors.set") @@ -84,9 +89,12 @@ def plot_losses( fig, ax = plt.subplots() else: fig, ax = figax - - mean_hist = md.fit_history.get("mean") - losses = np.asarray([] if mean_hist is None else mean_hist.losses, dtype=np.float64) + if plot_individual: + mean_hist = md.fit_history.get(f"individual_{individual_row}_{individual_col}") + losses = np.asarray([] if mean_hist is None else mean_hist.losses, dtype=np.float64) + else: + mean_hist = md.fit_history.get("mean") + losses = np.asarray([] if mean_hist is None else mean_hist.losses, dtype=np.float64) if losses.size == 0: ax.text( 0.5, @@ -141,6 +149,9 @@ def plot_losses( def visualize( self, *, + plot_individual: bool = False, + individual_row: int = 0, + individual_col: int = 0, power: float = 0.25, cbar: bool = False, axsize: tuple[int, int] = (6, 6), @@ -193,14 +204,21 @@ def visualize( md.preprocess() if md.image_ref is None or md.model is None or md.ctx is None: raise RuntimeError("Call .define_model(...) first.") + if md.dataset.shape[0] <= individual_row or md.dataset.shape[1] <= individual_col: + raise ValueError("individual row or column outside bounds of dataset") fig = plt.figure(figsize=(12, 7)) gs = gridspec.GridSpec(2, 1, height_ratios=[1, 2], hspace=0.3) ax_top = fig.add_subplot(gs[0]) - md.plot_losses(figax=(fig, ax_top), plot_lrs=True) + md.plot_losses(figax=(fig, ax_top), plot_lrs=True, plot_individual=plot_individual,individual_row=individual_row,individual_col=individual_col) - ref = np.asarray(md.image_ref, dtype=np.float32) - pred = md.render_current + if plot_individual: + ref = np.asarray(md.dataset.array[individual_row, individual_col], dtype=np.float32) + pred = md.render_indivdual_pattern(row=individual_row, col = individual_col) + else: + ref = np.asarray(md.image_ref, dtype=np.float32) + pred = md.render_current + refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) vmin = float(min(refp.min(), predp.min())) diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index a084b32d..105d1d98 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -1170,4 +1170,4 @@ def _refine_one(vec: NDArray) -> NDArray: return np.array((r_fit, c_fit, amp, sig, bg), dtype=float) - return _refine_one(u_rc), _refine_one(v_rc) + return _refine_one(u_rc), _refine_one(v_rc) \ No newline at end of file From b22d4cd4ed5d9c915fb7e8ef07bb485650867fa5 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Mon, 9 Mar 2026 18:29:00 -0700 Subject: [PATCH 45/88] adding normalization constraint, updating individual fitting to fit intensity per disk --- src/quantem/core/fitting/base.py | 3 ++- src/quantem/core/fitting/diffraction.py | 11 ++++++++++- src/quantem/diffraction/model_fitting.py | 12 ++++++------ .../diffraction/model_fitting_visualizations.py | 10 ++++++---- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index b21e1126..d1df4d03 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -352,7 +352,8 @@ class FitBase(OptimizerMixin): def __init__(self): super().__init__() # Core wiring - self.loss_fn = torch.nn.MSELoss(reduction="mean") + self.loss_fn = torch.nn.L1Loss(reduction="mean") + # self.loss_fn = torch.nn.MSELoss(reduction="mean") self.model: AdditiveRenderModel | None = None self.ctx: RenderContext | None = None diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 30934bf5..bdd28f36 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -52,6 +52,7 @@ class DiskTemplate(RenderComponent): DEFAULT_HARD_CONSTRAINTS: dict[str, bool] = { "force_center": False, "force_positive": True, + "force_norm": True, # force range [0,1] } DEFAULT_SOFT_CONSTRAINTS: dict[str, float] = {"tv_weight": 0.0} @@ -238,12 +239,20 @@ def _enforce_positivity(self) -> None: with torch.no_grad(): self.template_raw.clamp_(min=0.0) self.intensity_raw.clamp_(min=0.0) + + def _enforce_norm(self) -> None: + with torch.no_grad(): + self.template_raw -= self.template_raw.min() + self.template_raw /= self.template_raw.max() def enforce_hard_constraints(self, ctx: RenderContext) -> None: if bool(self.hard_constraints.get("force_center", False)): self._center_disk() if bool(self.hard_constraints.get("force_positive", False)): self._enforce_positivity() + if bool(self.hard_constraints.get("force_norm", False)): # could be put in positivity + self._enforce_norm() + super().enforce_hard_constraints(ctx) def constraint_loss( @@ -291,7 +300,7 @@ def __init__( intensity_row_col: float | Sequence[float] = 0.0, per_disk_intensity: bool = False, per_disk_slopes: bool = True, - max_intensity_order: int | None = None, + max_intensity_order: int | None = 0, default_pattern_intensity_order: int | None = None, center_intensity_0: float | Sequence[float] | None = None, exclude_indices: Iterable[tuple[int, int]] | None = None, diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 9008ebbd..0a163c82 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -487,6 +487,7 @@ def fit_mean_diffraction_pattern( raise ValueError("reset must be False, True, 'initialized', or 'mean_refined'.") self.fit_render( + # target=torch.tensor(self.dataset[30,30].array.astype("float32")), target=self.target_mean, n_steps=int(n_steps), constraint_weight=float(constraint_weight), @@ -578,10 +579,8 @@ def fit_individual_diffraction_pattern( rows = rows cols = cols - if isinstance(rows, int): - rows = [rows] - if isinstance(cols, int): - cols = [cols] + rows = np.asarray(rows) + cols = np.asarray(cols) self.state_individual_refined = np.full(shape=(scan_r, scan_c), fill_value=None, dtype=object) @@ -591,6 +590,7 @@ def fit_individual_diffraction_pattern( for r in rows: for c in cols: # print(self.dataset.array[r,c].shape) + self.reset(reset_to=cast(Literal["initialized", "mean_refined"], reset), reset_history=False) self.fit_render( target=torch.as_tensor(self.dataset.array[r,c],device=self.ctx.device,dtype=self.ctx.dtype), n_steps=int(n_steps), @@ -604,12 +604,12 @@ def fit_individual_diffraction_pattern( s_fit = self._get_model_state_dict_copy() self.state_individual_refined[r,c] = self._clone_state_dict(s_fit) - self.reset(reset_to=cast(Literal["initialized", "mean_refined"], reset), reset_history=False) - + # self.reset(reset_to=cast(Literal["initialized", "mean_refined"], reset), reset_history=False) if progress: pbar.update(1) if progress: pbar.close() + self.individual_refined=True return self diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py index 8c897833..63238eaa 100644 --- a/src/quantem/diffraction/model_fitting_visualizations.py +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -278,7 +278,7 @@ def plot_mean_model( overlay_on: Literal["model", "both"] = "model", origin_marker_kwargs: dict[str, Any] | None = None, disk_marker_kwargs: dict[str, Any] | None = None, - **_: Any, + **kwargs, ) -> tuple[Any, Any] | None: """ Plot reference and model mean diffraction images. @@ -325,22 +325,24 @@ def plot_mean_model( raise RuntimeError("Call .define_model(...) first.") ref = np.asarray(md.image_ref, dtype=np.float32) - pred = md.render_current + pred = md.render_mean_refined refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) vmin = float(min(refp.min(), predp.min())) vmax = float(max(refp.max(), predp.max())) + t1 = kwargs.pop("title", "") fig, ax = show_2d( [refp, predp], - title=["image_ref", "model"], + title=[t1 + " image_ref", t1 + " model"], cmap=config.get("viz.cmap"), - cbar=False, + cbar=True, returnfig=True, axsize=axsize, vmin=vmin, vmax=vmax, + **kwargs, ) if overlay: if overlay_on not in ("model", "both"): From 451ca80e98fdea903a2afc12314d7d70f217bcce Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Thu, 26 Mar 2026 14:05:12 -0700 Subject: [PATCH 46/88] independant pattern fitting and inital strain calculation --- src/quantem/core/fitting/diffraction.py | 98 +++++++- src/quantem/diffraction/model_fitting.py | 224 ++++++++++++++++-- .../model_fitting_visualizations.py | 40 +++- 3 files changed, 331 insertions(+), 31 deletions(-) diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index bdd28f36..b736c970 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -53,8 +53,22 @@ class DiskTemplate(RenderComponent): "force_center": False, "force_positive": True, "force_norm": True, # force range [0,1] + "force_shrinkage": False, + "force_cutoff": False, + "force_circular_mask": True, + } + DEFAULT_SOFT_CONSTRAINTS: dict[str, float] = { + "tv_weight": 0.0, + "cutoff_weight": 0.0, + "circular_weight": 0.0, + } + DEFAULT_CONSTRAINT_CONFIG: dict[str, float] = { + "soft_cutoff_threshold": 0.0, + "soft_cutoff_target_ratio": 0.1, + "hard_cutoff_threshold": 0.35, + "shrinkage_amount": 0.25, + "circular_mask_radius_fraction": 1, } - DEFAULT_SOFT_CONSTRAINTS: dict[str, float] = {"tv_weight": 0.0} def __init__( self, @@ -67,6 +81,7 @@ def __init__( origin_key: str = "origin", intensity: float | Sequence[float] = 1.0, constraint_params: dict[str, Any] | None = None, + constraint_config: dict[str, Any] | None = None, ): """ Build a disk template renderer centered at the shared origin. @@ -126,10 +141,17 @@ def __init__( cc = cc.astype(np.float32) - (wt - 1) * 0.5 self.register_buffer("dr", torch.as_tensor(rr.ravel(), dtype=torch.float32)) self.register_buffer("dc", torch.as_tensor(cc.ravel(), dtype=torch.float32)) + + self.constraint_config = self.DEFAULT_CONSTRAINT_CONFIG.copy() + if constraint_config is not None: + self.constraint_config.update(constraint_config) + if constraint_params is not None: self.apply_constraint_params(constraint_params, strict=True) if bool(self.hard_constraints.get("force_positive", False)): self._enforce_positivity() + if bool(self.hard_constraints.get("force_shrinkage", False)) and bool(self.hard_constraints.get("force_positive", True)): + raise RuntimeWarning("Setting shrinkage true and positivity false might cause negative values in disk template") @classmethod def from_array( @@ -143,6 +165,8 @@ def from_array( origin_key: str = "origin", intensity: float | Sequence[float] = 1.0, constraint_params: dict[str, Any] | None = None, + constraint_config: dict[str, Any] | None = None, + ) -> "DiskTemplate": return cls( name=name, @@ -153,6 +177,8 @@ def from_array( origin_key=origin_key, intensity=intensity, constraint_params=constraint_params, + constraint_config=constraint_config, + ) def set_origin(self, origin: OriginND) -> None: @@ -240,14 +266,43 @@ def _enforce_positivity(self) -> None: self.template_raw.clamp_(min=0.0) self.intensity_raw.clamp_(min=0.0) - def _enforce_norm(self) -> None: + def _enforce_norm(self) -> None: # pick value and cut off 5 percent of mean, or every iteration shrinkage, every iteration just subtract a valye of 0.01 with torch.no_grad(): self.template_raw -= self.template_raw.min() self.template_raw /= self.template_raw.max() + + def _enforce_shrinkage(self) -> None: + with torch.no_grad(): + self.template_raw -= self.constraint_config["shrinkage_amount"] + + def _enforce_cutoff(self) -> None: + with torch.no_grad(): + mean_val = torch.max(self.template_raw) * self.constraint_config["hard_cutoff_threshold"] + self.template_raw[self.template_raw <= mean_val] = 0 + + def _enforce_circular_mask(self) -> None: + with torch.no_grad(): + h, w = self.template_raw.shape + radius = (min(h, w) / 2.0) * self.constraint_config["circular_mask_radius_fraction"] + + r = torch.arange(-h/2, h/2, device=self.template_raw.device, dtype=self.template_raw.dtype) + c = torch.arange(-w/2, w/2, device=self.template_raw.device, dtype=self.template_raw.dtype) + rr, cc = torch.meshgrid(r, c, indexing='ij') + circle_matrix = torch.sqrt(rr**2 + cc**2) + + mask = circle_matrix <= radius + self.template_raw[~mask] = 0.0 + def enforce_hard_constraints(self, ctx: RenderContext) -> None: if bool(self.hard_constraints.get("force_center", False)): self._center_disk() + if bool(self.hard_constraints.get("force_cutoff", False)): + self._enforce_cutoff() + if bool(self.hard_constraints.get("force_circular_mask", False)): + self._enforce_circular_mask() + if bool(self.hard_constraints.get("force_shrinkage", False)): + self._enforce_shrinkage() if bool(self.hard_constraints.get("force_positive", False)): self._enforce_positivity() if bool(self.hard_constraints.get("force_norm", False)): # could be put in positivity @@ -260,8 +315,17 @@ def constraint_loss( ) -> torch.Tensor: cfg = self.effective_soft_constraints(cast(dict[str, object] | None, params)) tv_weight = float(cfg.get("tv_weight", 0.0)) + cutoff_weight = float(cfg.get("cutoff_weight", 0.0)) + circular_weight = float(cfg.get("circular_weight", 0.0)) + if tv_weight <= 0.0: - return torch.zeros((), device=ctx.device, dtype=ctx.dtype) + tv_weight = 0.0 + if cutoff_weight <= 0.0: + cutoff_weight = 0.0 + if circular_weight <= 0.0: + circular_weight = 0.0 + + # tv loss calculation template = self.template_raw.to(device=ctx.device, dtype=ctx.dtype) tv_r = ( torch.mean(torch.abs(template[1:, :] - template[:-1, :])) @@ -273,7 +337,33 @@ def constraint_loss( if template.shape[1] > 1 else torch.zeros((), device=ctx.device, dtype=ctx.dtype) ) - return torch.as_tensor(tv_weight, device=ctx.device, dtype=ctx.dtype) * (tv_r + tv_c) + tv_loss = torch.as_tensor(tv_weight, device=ctx.device, dtype=ctx.dtype) * (tv_r + tv_c) + + # Cutoff loss calculation + num_px = torch.prod(torch.tensor(template.shape)) + px_under_threshold = torch.sum(template <= torch.mean(template)*self.constraint_config["soft_cutoff_threshold"])/num_px + cutoff_loss = torch.as_tensor(cutoff_weight, device=ctx.device, dtype=ctx.dtype) * torch.maximum( + px_under_threshold-self.constraint_config["soft_cutoff_target_ratio"], torch.as_tensor(0.0, device=ctx.device, dtype=ctx.dtype)) + + # circular loss calculation + h, w = template.shape + radius = (min(h, w) / 2.0) * self.constraint_config["circular_mask_radius_fraction"] + + r = torch.arange(h, device=ctx.device, dtype=ctx.dtype) - h / 2.0 + c = torch.arange(w, device=ctx.device, dtype=ctx.dtype) - w / 2.0 + rr, cc = torch.meshgrid(r, c, indexing='ij') + + circle_mask = torch.sqrt(rr**2 + cc**2) + + dist_from_radius = torch.abs(circle_mask - radius) + dist_from_radius = torch.relu(dist_from_radius) + circular_err = torch.mean(dist_from_radius * template) + + circular_loss = torch.as_tensor(circular_weight, device=ctx.device, dtype=ctx.dtype) * circular_err + + return cutoff_loss + tv_loss + circular_loss + + class SyntheticDiskLattice(RenderComponent): diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 0a163c82..c2ae4e8e 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -9,7 +9,7 @@ from tqdm import tqdm from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem -from quantem.core.fitting.base import ( +from quantem.core.fitting.basev2 import ( AdditiveRenderModel, FitBase, OriginND, @@ -579,13 +579,20 @@ def fit_individual_diffraction_pattern( rows = rows cols = cols - rows = np.asarray(rows) - cols = np.asarray(cols) + if isinstance(rows, int): + rows = np.array([rows]).astype(int) + else: + rows = np.asarray(rows).astype(int) + + if isinstance(cols, int): + cols = np.array([cols]).astype(int) + else: + cols = np.asarray(cols).astype(int) self.state_individual_refined = np.full(shape=(scan_r, scan_c), fill_value=None, dtype=object) if progress: - pbar = tqdm(total=len(rows) * len(cols), desc="Fit individual") + pbar = tqdm(total=rows.shape[0] * cols.shape[0], desc="Fit individual") for r in rows: for c in cols: @@ -613,33 +620,33 @@ def fit_individual_diffraction_pattern( self.individual_refined=True return self - def get_individual_uv_vectors(self) ->tuple[np.ndarray, np.ndarray]: + def get_individual_uv_vectors(self) -> "ModelDiffraction": scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] # print(scan_r) - u_array = np.empty(shape=(scan_r, scan_c, 2)) - v_array = np.empty(shape=(scan_r, scan_c, 2)) + self.u_array = np.empty(shape=(scan_r, scan_c, 2)) + self.v_array = np.empty(shape=(scan_r, scan_c, 2)) if self.state_individual_refined is None: raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") for r in range(scan_r): for c in range(scan_c): pos_state = self.state_individual_refined[r,c] if pos_state is None: - u_array[r,c,:] = None - v_array[r,c,:] = None + self.u_array[r,c,:] = None + self.v_array[r,c,:] = None for key in pos_state.keys(): key_parts = key.split('.') if(key_parts[-1] == 'u_row'): - u_array[r,c,0] = pos_state[key] + self.u_array[r,c,0] = pos_state[key] if(key_parts[-1] == 'u_col'): - u_array[r,c,1] = pos_state[key] + self.u_array[r,c,1] = pos_state[key] if(key_parts[-1] == 'v_row'): - v_array[r,c,0] = pos_state[key] + self.v_array[r,c,0] = pos_state[key] if(key_parts[-1] == 'v_col'): - v_array[r,c,1] = pos_state[key] + self.v_array[r,c,1] = pos_state[key] - return u_array, v_array + return self def render_indivdual_pattern(self, row, col): if self.state_individual_refined is None: @@ -648,12 +655,201 @@ def render_indivdual_pattern(self, row, col): ) if self.dataset.shape[0] <= row or self.dataset.shape[1] <= col: raise ValueError("individual row or column outside bounds of dataset") + if row < 0 or col < 0: + raise ValueError("individual row or column outside bounds of dataset") if self.state_individual_refined[row, col] is None: raise RuntimeError( "individual_refined_state is not avalible for given row and column. Run fit_individual_diffraction_pattern(...) for that row and column." ) return self._render_state_array(self.state_individual_refined[row, col]) + def fit_strain(self) -> "ModelDiffraction": + u_fit = self.u_array + v_fit = self.v_array + u_ref = np.median(u_fit.reshape(-1, 2), axis=0) + v_ref = np.median(v_fit.reshape(-1, 2), axis=0) + + scan_r = self.dataset.shape[0] + scan_c = self.dataset.shape[1] + + Uref = np.stack((u_ref, v_ref), axis=1).astype(float) + det = np.linalg.det(Uref) + if not np.isfinite(det) or abs(det) < 1e-12: + Uref_inv = np.linalg.pinv(Uref) + else: + Uref_inv = np.linalg.inv(Uref) + + strain_trans = np.zeros((scan_r, scan_c, 2, 2)) + + for r in range(scan_r): + for c in range(scan_c): + U = np.stack((u_fit[r, c, :], v_fit[r, c, :]), axis=1) + strain_trans[r, c, :, :] = U @ Uref_inv + + self.strain_raw_err = Dataset2d.from_array( + strain_trans[:, :, 0, 0] - 1, + name="strain err", + signal_units="fractional", + ) + self.strain_raw_ecc = Dataset2d.from_array( + strain_trans[:, :, 1, 1] - 1, + name="strain ecc", + signal_units="fractional", + ) + self.strain_raw_erc = Dataset2d.from_array( + strain_trans[:, :, 1, 0] * 0.5 + strain_trans[:, :, 0, 1] * 0.5, + name="strain erc", + signal_units="fractional", + ) + self.strain_rotation = Dataset2d.from_array( + strain_trans[:, :, 1, 0] * -0.5 + strain_trans[:, :, 0, 1] * 0.5, + name="strain rotation", + signal_units="fractional", + ) + + return self + + + def plot_strain( + self, + rotation_angle=20, + strain_range_percent=(-3.0, 3.0), + rotation_range_degrees=(-2.0, 2.0), + plot_rotation=True, + cmap_strain="RdBu_r", + cmap_rotation=None, + layout="horizontal", + figsize=(6, 6), + ): + import matplotlib.pyplot as plt + + if cmap_rotation is None: + cmap_rotation = cmap_strain + + angle = rotation_angle + c = np.cos(angle) + s = np.sin(angle) + + err = self.strain_raw_err.array + ecc = self.strain_raw_ecc.array + erc = self.strain_raw_erc.array + + euu = err * (c * c) + 2.0 * erc * (c * s) + ecc * (s * s) + evv = err * (s * s) - 2.0 * erc * (c * s) + ecc * (c * c) + euv = (ecc - err) * (c * s) + erc * (c * c - s * s) + + strain_euu = self.strain_raw_err.copy() + strain_evv = self.strain_raw_ecc.copy() + strain_euv = self.strain_raw_erc.copy() + strain_euu.array[...] = euu + strain_evv.array[...] = evv + strain_euv.array[...] = euv + + alpha = None + good = None + alpha_im = None + + if layout != "horizontal": + raise ValueError("layout must be 'horizontal'") + + ncols = 4 if plot_rotation else 3 + fig, ax = plt.subplots(1, ncols, figsize=figsize) + + cm_strain = plt.get_cmap(cmap_strain).copy() + cm_strain.set_bad(color="black") + cm_rot = plt.get_cmap(cmap_rotation).copy() + cm_rot.set_bad(color="black") + + euu_pct = strain_euu.array * 100 + evv_pct = strain_evv.array * 100 + euv_pct = strain_euv.array * 100 + rot_deg = np.rad2deg(self.strain_rotation.array) + + if good is not None and np.any(good): + euu_m = np.ma.array(euu_pct, mask=~good) + evv_m = np.ma.array(evv_pct, mask=~good) + euv_m = np.ma.array(euv_pct, mask=~good) + rot_m = np.ma.array(rot_deg, mask=~good) + else: + euu_m = euu_pct + evv_m = evv_pct + euv_m = euv_pct + rot_m = rot_deg + + title_fs = 16 + im0 = ax[0].imshow( + euu_m, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cm_strain, + alpha=alpha_im, + ) + ax[1].imshow( + evv_m, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cm_strain, + alpha=alpha_im, + ) + ax[2].imshow( + euv_m, + vmin=strain_range_percent[0], + vmax=strain_range_percent[1], + cmap=cm_strain, + alpha=alpha_im, + ) + + ax[0].set_title(r"$\epsilon_{uu}$", fontsize=title_fs) + ax[1].set_title(r"$\epsilon_{vv}$", fontsize=title_fs) + ax[2].set_title(r"$\epsilon_{uv}$", fontsize=title_fs) + + if plot_rotation: + im3 = ax[3].imshow( + rot_m, + vmin=rotation_range_degrees[0], + vmax=rotation_range_degrees[1], + cmap=cm_rot, + alpha=alpha_im, + ) + ax[3].set_title("Rotation", fontsize=title_fs) + + for a in ax: + a.set_xticks([]) + a.set_yticks([]) + a.set_facecolor("black") + + fig.subplots_adjust(left=0.02, right=0.98, top=0.90, bottom=0.16, wspace=0.03) + + b0 = ax[0].get_position() + b2 = ax[2].get_position() + left = b0.x0 + right = b2.x1 + width = right - left + + b3 = ax[3].get_position() if plot_rotation else None + + cb_height = 0.04 + cb_pad = 0.03 + y = b0.y0 - cb_pad - cb_height + + cax1 = fig.add_axes([left, y, width, cb_height]) + cbar1 = fig.colorbar(im0, cax=cax1, orientation="horizontal") + cbar1.set_label("Strain (%)", fontsize=title_fs) + cbar1.ax.tick_params(labelsize=12) + + if plot_rotation: + left_r = b3.x0 + width_r = b3.x1 - b3.x0 + cax2 = fig.add_axes([left_r, y, width_r, cb_height]) + cbar2 = fig.colorbar(im3, cax=cax2, orientation="horizontal") + cbar2.set_label("Rotation (deg)", fontsize=title_fs) + cbar2.ax.tick_params(labelsize=12) + + for a in ax: + a.set_aspect("equal") + + return fig, ax + @property def render_mean_refined(self) -> np.ndarray: if self.state_mean_refined is None: diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py index 63238eaa..542d5592 100644 --- a/src/quantem/diffraction/model_fitting_visualizations.py +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -149,9 +149,9 @@ def plot_losses( def visualize( self, *, - plot_individual: bool = False, - individual_row: int = 0, - individual_col: int = 0, + individual_loss: bool = False, + pattern_row: int = 0, + pattern_col: int = 0, power: float = 0.25, cbar: bool = False, axsize: tuple[int, int] = (6, 6), @@ -204,17 +204,17 @@ def visualize( md.preprocess() if md.image_ref is None or md.model is None or md.ctx is None: raise RuntimeError("Call .define_model(...) first.") - if md.dataset.shape[0] <= individual_row or md.dataset.shape[1] <= individual_col: + if md.dataset.shape[0] <= pattern_row or md.dataset.shape[1] <= pattern_col: raise ValueError("individual row or column outside bounds of dataset") fig = plt.figure(figsize=(12, 7)) gs = gridspec.GridSpec(2, 1, height_ratios=[1, 2], hspace=0.3) ax_top = fig.add_subplot(gs[0]) - md.plot_losses(figax=(fig, ax_top), plot_lrs=True, plot_individual=plot_individual,individual_row=individual_row,individual_col=individual_col) + md.plot_losses(figax=(fig, ax_top), plot_lrs=True, plot_individual=individual_loss,individual_row=pattern_row,individual_col=pattern_col) - if plot_individual: - ref = np.asarray(md.dataset.array[individual_row, individual_col], dtype=np.float32) - pred = md.render_indivdual_pattern(row=individual_row, col = individual_col) + if individual_loss: + pred = md.render_indivdual_pattern(row=pattern_row, col=pattern_col) + ref = np.asarray(md.dataset.array[pattern_row, pattern_col], dtype=np.float32) else: ref = np.asarray(md.image_ref, dtype=np.float32) pred = md.render_current @@ -257,6 +257,8 @@ def visualize( ) mean_hist = md.fit_history.get("mean") + if individual_loss: + mean_hist = md.fit_history.get(f"individual_{pattern_row}_{pattern_row}") if mean_hist is not None and len(mean_hist.losses) > 0: fig.suptitle( f"Final loss: {mean_hist.losses[-1]:.3e} | Iters: {len(mean_hist.losses)}", @@ -266,9 +268,13 @@ def visualize( plt.show() return fig, axs - def plot_mean_model( + def plot_model( self, *, + plot_mean_model: bool = False, + plot_individual_model: bool = False, + pattern_row: int = 0, + pattern_col: int= 0, power: float = 0.25, returnfig: bool = False, axsize: tuple[int, int] = (6, 6), @@ -281,7 +287,7 @@ def plot_mean_model( **kwargs, ) -> tuple[Any, Any] | None: """ - Plot reference and model mean diffraction images. + Plot reference and indiviual diffraction images. Parameters ---------- @@ -323,9 +329,17 @@ def plot_mean_model( md.preprocess() if md.image_ref is None or md.model is None or md.ctx is None: raise RuntimeError("Call .define_model(...) first.") - - ref = np.asarray(md.image_ref, dtype=np.float32) - pred = md.render_mean_refined + if plot_mean_model and plot_individual_model: + raise RuntimeError("can only plot mean or plot individual, not both") + if plot_individual_model: + pred = md.render_indivdual_pattern(pattern_row, pattern_col) + ref = np.asarray(md.dataset.array[pattern_row, pattern_col], dtype=np.float32) + elif plot_mean_model: + ref = np.asarray(md.image_ref, dtype=np.float32) + pred = md.render_mean_refined + else: + ref = np.asarray(md.image_ref, dtype=np.float32) + pred = md.render_current refp = ref if power == 1.0 else np.maximum(ref, 0.0) ** float(power) predp = pred if power == 1.0 else np.maximum(pred, 0.0) ** float(power) From 9aff08f5a536f6d88af69f6c483bd17547a638bf Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Thu, 26 Mar 2026 14:07:32 -0700 Subject: [PATCH 47/88] adding independant optimizers --- src/quantem/core/fitting/base.py | 530 +++++++++++++++++++++---------- 1 file changed, 363 insertions(+), 167 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index d1df4d03..270383f3 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -90,16 +90,36 @@ def __init__(self, *, ndim: int, init: Sequence[float]): self.coords = nn.Parameter(torch.as_tensor(init, dtype=torch.float32).reshape(self.ndim)) -class RenderComponent(nn.Module): +class RenderComponent(OptimizerMixin,nn.Module): DEFAULT_HARD_CONSTRAINTS: dict[str, Any] = {} DEFAULT_SOFT_CONSTRAINTS: dict[str, Any] = {} + DEFAULT_OPTIMIZER: str = "adam" + DEFAULT_LR: float = 1e-2 + DEFAULT_SCHEDULER_TYPE: str = "none" + def __init__(self) -> None: - super().__init__() + nn.Module.__init__(self) + + self._optimizer = None + self._scheduler = None + self._optimizer_params = {} + self._scheduler_params = {} self.hard_constraints: dict[str, Any] = dict(self.DEFAULT_HARD_CONSTRAINTS) self.soft_constraints: dict[str, Any] = dict(self.DEFAULT_SOFT_CONSTRAINTS) self.parameter_bounds: dict[str, tuple[float | None, float | None]] = {} + optimizer_params = { + "type": self.DEFAULT_OPTIMIZER, + "lr": self.DEFAULT_LR + } + self.optimizer_params = optimizer_params + + scheduler_params = { + "type": self.DEFAULT_SCHEDULER_TYPE + } + self.scheduler_params = scheduler_params + @staticmethod def parse_bounded_init( value: float | int | Sequence[float | int | None], *, name: str @@ -270,8 +290,82 @@ def constraint_loss( ) -> torch.Tensor: return torch.zeros((), device=ctx.device, dtype=ctx.dtype) + def get_optimization_parameters(self) -> Any: # make copy in synthetic disklattice which only refs lattice not disk lattice params, reoeat for origin byt in dusk teplate + return [p for p in self.parameters() if p.requires_grad] + + def initialize_optimizer(self, + optimizer_params: dict[str, Any] | None = None, + scheduler_params: dict[str, Any] | None = None, + num_iter: int | None = None, + ) -> None: + trainable_params = list(self.get_optimization_parameters()) + if not trainable_params: + self._optimizer = None + self._scheduler = None + return + if optimizer_params is not None: + self.optimizer_params = optimizer_params + else: + self.optimizer_params = { + "type": self.DEFAULT_OPTIMIZER, + "lr": self.DEFAULT_LR + } + self.set_optimizer(self.optimizer_params) -class AdditiveRenderModel(nn.Module): + if scheduler_params is not None: + self.scheduler_params = scheduler_params + else: + self.scheduler_params = { + "type": self.DEFAULT_SCHEDULER_TYPE + } + self.set_scheduler(self.scheduler_params, num_iter = num_iter) + return + + + def _infer_optimizer_rebuild_params(self) -> dict[str, Any]: + if self.optimizer_params: + return dict(self.optimizer_params) + if self.optimizer is not None: + opt_type: str | type[torch.optim.Optimizer] + if isinstance(self.optimizer, torch.optim.AdamW): + opt_type = "adamw" + elif isinstance(self.optimizer, torch.optim.Adam): + opt_type = "adam" + elif isinstance(self.optimizer, torch.optim.SGD): + opt_type = "sgd" + else: + opt_type = type(self.optimizer) + lr = float( + self.optimizer.param_groups[0].get( + "lr", getattr(self, "DEFAULT_LR", self.DEFAULT_LR) + ) + ) + return {"type": opt_type, "lr": lr} + return { + "type": getattr(self, "DEFAULT_OPTIMIZER_TYPE", self.DEFAULT_OPTIMIZER), + "lr": float(getattr(self, "DEFAULT_LR", self.DEFAULT_LR)), + } + + def _infer_scheduler_rebuild_params(self) -> dict[str, Any]: + if self.scheduler_params: + return dict(self.scheduler_params) + return { + "type": self.DEFAULT_SCHEDULER_TYPE, + } + + def _rebuild_optimizer_after_trainability_change(self) -> None: + trainable_params = list(self.get_optimization_parameters()) + if not trainable_params: + self._optimizer = None + self._scheduler = None + return + rebuild_params = self._infer_optimizer_rebuild_params() + rebuild_params_scheduler = self._infer_scheduler_rebuild_params() + self.set_optimizer(rebuild_params) + self.set_scheduler(rebuild_params_scheduler) + + +class AdditiveRenderModel(nn.Module): # step all otpimzers def __init__(self, *, origin: nn.Module, components: list[RenderComponent]): super().__init__() self.origin = origin @@ -335,71 +429,125 @@ def total_constraint_loss(self, ctx: RenderContext) -> torch.Tensor: loss = loss + component.constraint_loss(ctx) return loss + def initilize_independant_optimizers(self, + individual_optimizers: dict[str, dict[str, Any]] | None = None, + individual_schedulers: dict[str, dict[str, Any]] | None = None, + num_iter: int | None = None, + ) -> None: + for idx, module in enumerate(self.components): + component = cast(RenderComponent, module) + component_name = self._component_constraint_name(component, idx) + + component_optimizer_params = None + component_scheduler_params = None + if individual_optimizers is not None: + if component_name in individual_optimizers: + component_optimizer_params = individual_optimizers[component_name] + elif component.__class__.__name__ in individual_optimizers : + component_optimizer_params = individual_optimizers[component.__class__.__name__] + + if individual_schedulers is not None: + if component_name in individual_schedulers: + component_scheduler_params = individual_schedulers[component_name] + elif component.__class__.__name__ in individual_schedulers: + component_scheduler_params = individual_schedulers[component.__class__.__name__] + + component.initialize_optimizer(component_optimizer_params, component_scheduler_params, num_iter=num_iter) + + def set_independant_optimizer_params(self, + individual_optimizer_params: dict[str, dict[str, Any]], + individual_scheduler_params: dict[str, dict[str, Any]] | None = None, + num_iter: int | None = None, + ) -> None: + for component_name, param in individual_optimizer_params.items(): + scheduler_params = None + if individual_scheduler_params is not None and component_name in individual_scheduler_params: + scheduler_params = individual_scheduler_params[component_name] + + component = self._resolve_component_by_name(str(component_name)) + component.initialize_optimizer(param,scheduler_params,num_iter) + + def rebuild_independant_optimizers(self) -> None: + for module in self.components: + component = cast(RenderComponent, module) + component.initialize_optimizer() -@dataclass -class FitResult: - losses: list[float] - lrs: list[float] - final_loss: float - num_steps: int - metrics: dict[str, list[float]] = field(default_factory=dict) - - -class FitBase(OptimizerMixin): - DEFAULT_LR = 1e-2 - DEFAULT_OPTIMIZER_TYPE = "adam" - - def __init__(self): - super().__init__() - # Core wiring - self.loss_fn = torch.nn.L1Loss(reduction="mean") - # self.loss_fn = torch.nn.MSELoss(reduction="mean") - self.model: AdditiveRenderModel | None = None - self.ctx: RenderContext | None = None - - # State/checkpoints - self.state_initialized: dict[str, torch.Tensor] | None = None - - # Histories/results - self.fit_history: dict[str, FitResult] = {} - - def get_optimization_parameters(self) -> Any: - if self.model is None: - return [] - return [p for p in self.model.parameters() if p.requires_grad] - - @property - def state_current(self) -> dict[str, torch.Tensor] | None: - if self.model is None: - return None - return self._get_model_state_dict_copy() + def step_optimizers(self) -> None: + for module in self.components: + component = cast(RenderComponent, module) + component.step_optimizer() + + def step_schedulers(self, loss: float | None = None) -> None: + for module in self.components: + component = cast(RenderComponent, module) + if hasattr(component, 'step_scheduler'): + try: + component.step_scheduler(loss) + except (AttributeError, TypeError): + pass + + def zero_grad_optimizers(self) -> None: + for module in self.components: + component = cast(RenderComponent, module) + component.zero_optimizer_grad() + + def _iter_named_components(self) -> list[tuple[str, RenderComponent]]: + """ + Return canonical component names paired with components. - @property - def render_initialized(self) -> np.ndarray: - if self.state_initialized is None: - raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") - return self._render_state_array(self.state_initialized) + Returns + ------- + list[tuple[str, RenderComponent]] + ``(name, component)`` entries using the model's canonical naming + rule. Names fall back to class-name/index behavior when ``.name`` is + missing. - @property - def render_current(self) -> np.ndarray: - if self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - return self.model(self.ctx).detach().cpu().numpy() + Raises + ------ + RuntimeError + If the model is not defined. + """ + entries: list[tuple[str, RenderComponent]] = [] + for idx, module in enumerate(self.components): + component = cast(RenderComponent, module) + name = self._component_constraint_name(component, idx) + entries.append((name, component)) + return entries + + def _resolve_component_by_name(self, component_name: str) -> RenderComponent: + target = str(component_name) + for resolved_name, component in self._iter_named_components(): + if resolved_name == target: + return component + + for resolved_name, component in self._iter_named_components(): + if component.__class__.__name__ == target: + return component + + known = ", ".join(self.get_component_names()) + raise KeyError(f"Component not found: {target}. Known components: {known}") + + def get_component_names(self) -> list[str]: + """ + Return canonical component names. - def reset( - self, - reset_to: Literal["initialized"] = "initialized", - ) -> Self: - if reset_to != "initialized": - raise ValueError("FitBase.reset only supports reset_to='initialized'.") - if self.state_initialized is None: - raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") - self._load_model_state_dict_copy(self.state_initialized) - self._clear_fit_history_all() - return self + Returns + ------- + list[str] + Canonical component names. + """ + return [name for name, _ in self._iter_named_components()] + + def get_component_by_name(self, component_name: str) -> RenderComponent: + return self._resolve_component_by_name(component_name) + def set_component_trainable( - self, component_name: str, enabled: bool, rebuild_optimizer: bool = True + self, + component_name: str, + enabled: bool, + rebuild_optimizer: bool = True, + num_iter: int | None = None, ) -> None: """ Enable or disable optimization for all parameters in one component. @@ -435,7 +583,7 @@ def set_component_trainable( for _, param in component.named_parameters(recurse=True): param.requires_grad_(bool(enabled)) if rebuild_optimizer: - self._rebuild_optimizer_after_trainability_change() + component._rebuild_optimizer_after_trainability_change() def set_parameter_trainable( self, @@ -443,6 +591,7 @@ def set_parameter_trainable( parameter_name: str, enabled: bool, rebuild_optimizer: bool = True, + num_iter: int | None = None, ) -> None: """ Enable or disable optimization for one component parameter. @@ -484,7 +633,7 @@ def set_parameter_trainable( ) params[parameter_name].requires_grad_(bool(enabled)) if rebuild_optimizer: - self._rebuild_optimizer_after_trainability_change() + component._rebuild_optimizer_after_trainability_change() def set_parameters_trainable( self, @@ -492,6 +641,7 @@ def set_parameters_trainable( parameter_names: list[str], enabled: bool, rebuild_optimizer: bool = True, + num_iter: int | None = None, ) -> None: """ Enable or disable optimization for multiple component parameters. @@ -530,7 +680,7 @@ def set_parameters_trainable( for name in parameter_names: params[name].requires_grad_(bool(enabled)) if rebuild_optimizer: - self._rebuild_optimizer_after_trainability_change() + component._rebuild_optimizer_after_trainability_change() def get_component_trainable(self, component_name: str) -> dict[str, bool]: """ @@ -556,6 +706,77 @@ def get_component_trainable(self, component_name: str) -> dict[str, bool]: component = self._resolve_component_by_name(component_name) return {name: bool(param.requires_grad) for name, param in component.named_parameters()} + + + + + + + +@dataclass +class FitResult: + losses: list[float] + lrs: list[float] + final_loss: float + num_steps: int + metrics: dict[str, list[float]] = field(default_factory=dict) + + +class FitBase(OptimizerMixin): + + def __init__(self): + super().__init__() + # Core wiring + self.loss_fn = torch.nn.L1Loss(reduction="mean") + # self.loss_fn = torch.nn.MSELoss(reduction="mean") + self.model: AdditiveRenderModel | None = None + self.ctx: RenderContext | None = None + + # State/checkpoints + self.state_initialized: dict[str, torch.Tensor] | None = None + + # Histories/results + self.fit_history: dict[str, FitResult] = {} + + # self.multi_optimizers: dict[str, torch.optim.Optimizer] | None = None + # self.use_mutiple_optimizers: bool = False + + def get_optimization_parameters(self) -> Any: + if self.model is None: + return [] + + return [p for p in self.model.parameters() if p.requires_grad] + + @property + def state_current(self) -> dict[str, torch.Tensor] | None: + if self.model is None: + return None + return self._get_model_state_dict_copy() + + @property + def render_initialized(self) -> np.ndarray: + if self.state_initialized is None: + raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") + return self._render_state_array(self.state_initialized) + + @property + def render_current(self) -> np.ndarray: + if self.model is None or self.ctx is None: + raise RuntimeError("Call .define_model(...) first.") + return self.model(self.ctx).detach().cpu().numpy() + + def reset( + self, + reset_to: Literal["initialized"] = "initialized", + ) -> Self: + if reset_to != "initialized": + raise ValueError("FitBase.reset only supports reset_to='initialized'.") + if self.state_initialized is None: + raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") + self._load_model_state_dict_copy(self.state_initialized) + self._clear_fit_history_all() + return self + def fit_render( self, *, @@ -563,8 +784,8 @@ def fit_render( n_steps: int, constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, - optimizer_params: dict | None = None, - scheduler_params: dict | None = None, + optimizer_params: dict[str, dict[str, Any]] | None = None, + scheduler_params: dict[str, dict[str, Any]] | None = None, progress: bool = False, run_key: str = "default", **kwargs: Any, @@ -613,49 +834,41 @@ def fit_render( if constraint_params is not None: self.model.apply_constraint_params(constraint_params, strict=True) - optimizer_rebuilt = False - if optimizer_params is not None: - self.set_optimizer(optimizer_params) - optimizer_rebuilt = True - elif self.optimizer is None: - if self.optimizer_params: - self.set_optimizer(self.optimizer_params) - else: - self.set_optimizer( - { - "type": getattr(self, "DEFAULT_OPTIMIZER_TYPE", "adamw"), - "lr": float(getattr(self, "DEFAULT_LR", self.DEFAULT_LR)), - } - ) - optimizer_rebuilt = True - n_steps = int(n_steps) - if scheduler_params is not None: - self.set_scheduler(scheduler_params, num_iter=n_steps) - elif self.scheduler is None and self.scheduler_params: - self.set_scheduler(self.scheduler_params, num_iter=n_steps) - elif optimizer_rebuilt and self.scheduler is not None and self.optimizer is not None: - self.scheduler.optimizer = self.optimizer + self.model.initilize_independant_optimizers( + optimizer_params, + scheduler_params, + num_iter=n_steps + ) pbar = tqdm(range(n_steps), desc="Fit render", disable=not progress) losses: list[float] = [] lrs: list[float] = [] for _ in pbar: - self.zero_optimizer_grad() + self.model.zero_grad_optimizers() pred = self._forward_for_fit(target=target, **kwargs) data_loss = self._fidelity_loss(pred, target, **kwargs) constraint_loss = self._constraint_loss(pred, target, **kwargs) total_loss = data_loss + constraint_weight * constraint_loss total_loss.backward() - self.step_optimizer() + self.model.step_optimizers() if self.model is None or self.ctx is None: raise RuntimeError("Model and context are not defined for fitting.") self.model.apply_hard_constraints(self.ctx) total_loss_value = float(total_loss.detach().cpu()) - self.step_scheduler(total_loss_value) + self.model.step_schedulers(total_loss_value) losses.append(total_loss_value) - lrs.append(float(self.get_current_lr())) + + first_lr = 0.0 + if len(self.model.components) > 0: + first_comp = cast(RenderComponent, self.model.components[0]) + if hasattr(first_comp, 'get_current_lr'): + try: + first_lr = float(first_comp.get_current_lr()) + except (AttributeError, TypeError): + first_lr = 0.0 + lrs.append(first_lr) key = str(run_key) if key in self.fit_history: @@ -675,80 +888,6 @@ def fit_render( self.fit_history[key] = result return result - def _iter_named_components(self) -> list[tuple[str, RenderComponent]]: - """ - Return canonical component names paired with components. - - Returns - ------- - list[tuple[str, RenderComponent]] - ``(name, component)`` entries using the model's canonical naming - rule. Names fall back to class-name/index behavior when ``.name`` is - missing. - - Raises - ------ - RuntimeError - If the model is not defined. - """ - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - entries: list[tuple[str, RenderComponent]] = [] - for idx, module in enumerate(self.model.components): - component = cast(RenderComponent, module) - name = self.model._component_constraint_name(component, idx) - entries.append((name, component)) - return entries - - def get_component_names(self) -> list[str]: - """ - Return canonical component names. - - Returns - ------- - list[str] - Canonical component names. - """ - return [name for name, _ in self._iter_named_components()] - - def _resolve_component_by_name(self, component_name: str) -> RenderComponent: - target = str(component_name) - for resolved_name, component in self._iter_named_components(): - if resolved_name == target: - return component - known = ", ".join(self.get_component_names()) - raise KeyError(f"Component not found: {target}. Known components: {known}") - - def _infer_optimizer_rebuild_params(self) -> dict[str, Any]: - if self.optimizer_params: - return dict(self.optimizer_params) - if self.optimizer is not None: - opt_type: str | type[torch.optim.Optimizer] - if isinstance(self.optimizer, torch.optim.AdamW): - opt_type = "adamw" - elif isinstance(self.optimizer, torch.optim.Adam): - opt_type = "adam" - elif isinstance(self.optimizer, torch.optim.SGD): - opt_type = "sgd" - else: - opt_type = type(self.optimizer) - lr = float( - self.optimizer.param_groups[0].get( - "lr", getattr(self, "DEFAULT_LR", self.DEFAULT_LR) - ) - ) - return {"type": opt_type, "lr": lr} - return { - "type": getattr(self, "DEFAULT_OPTIMIZER_TYPE", self.DEFAULT_OPTIMIZER_TYPE), - "lr": float(getattr(self, "DEFAULT_LR", self.DEFAULT_LR)), - } - - def _rebuild_optimizer_after_trainability_change(self) -> None: - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - rebuild_params = self._infer_optimizer_rebuild_params() - self.set_optimizer(rebuild_params) - self.set_scheduler({"type": "none"}) def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: return {k: v.detach().clone() for k, v in state.items()} @@ -806,6 +945,63 @@ def _constraint_loss( raise RuntimeError("Model and context are not defined for fitting.") return self.model.total_constraint_loss(self.ctx) + def set_component_trainable( + self, + component_name: str, + enabled: bool, + rebuild_optimizer: bool = True, + num_iter: int | None = None + ) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + self.model.set_component_trainable(component_name, enabled, rebuild_optimizer, num_iter) + + def set_parameter_trainable( + self, + component_name: str, + parameter_name: str, + enabled: bool, + rebuild_optimizer: bool = True, + num_iter: int | None = None + ) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + self.model.set_parameter_trainable(component_name, parameter_name, enabled, rebuild_optimizer, num_iter) + + def set_parameters_trainable( + self, + component_name: str, + parameter_names: list[str], + enabled: bool, + rebuild_optimizer: bool = True, + num_iter: int | None = None + ) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + self.model.set_parameters_trainable(component_name, parameter_names, enabled, rebuild_optimizer, num_iter) + + def get_component_trainable(self, component_name: str) -> dict[str, bool]: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return self.model.get_component_trainable(component_name) + + def get_component_names(self) -> list[str]: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return self.model.get_component_names() + + def _iter_named_components(self) -> list[tuple[str, RenderComponent]]: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return self.model._iter_named_components() + + def _resolve_component_by_name(self, component_name: str) -> RenderComponent: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + return self.model._resolve_component_by_name(component_name) + + def get_component_by_name(self, component_name: str) -> RenderComponent: + return self._resolve_component_by_name(component_name) Component = RenderComponent ModelContext = RenderContext From 1f99fe513224c73df95d00399fccd3246f620af1 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Thu, 26 Mar 2026 15:13:32 -0700 Subject: [PATCH 48/88] updated strain plotting, fixed hard circular mask constraint, fixed bug for get_optimization_parameters() so it doesn't include subcomponents --- src/quantem/core/fitting/base.py | 8 +++- src/quantem/core/fitting/diffraction.py | 8 ++-- src/quantem/diffraction/model_fitting.py | 47 +++++++----------------- 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 270383f3..68ee4b62 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -291,7 +291,7 @@ def constraint_loss( return torch.zeros((), device=ctx.device, dtype=ctx.dtype) def get_optimization_parameters(self) -> Any: # make copy in synthetic disklattice which only refs lattice not disk lattice params, reoeat for origin byt in dusk teplate - return [p for p in self.parameters() if p.requires_grad] + return [p for p in self.parameters(recurse=False) if p.requires_grad] def initialize_optimizer(self, optimizer_params: dict[str, Any] | None = None, @@ -1002,6 +1002,12 @@ def _resolve_component_by_name(self, component_name: str) -> RenderComponent: def get_component_by_name(self, component_name: str) -> RenderComponent: return self._resolve_component_by_name(component_name) + + def _rebuild_optimizer_after_trainability_change(self) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + self.model.rebuild_independant_optimizers() + Component = RenderComponent ModelContext = RenderContext diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index b736c970..e8c9d3e8 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -67,7 +67,8 @@ class DiskTemplate(RenderComponent): "soft_cutoff_target_ratio": 0.1, "hard_cutoff_threshold": 0.35, "shrinkage_amount": 0.25, - "circular_mask_radius_fraction": 1, + "circular_mask_radius_fraction": 1, + "circular_mask_sharpness": 1.0, } def __init__( @@ -290,8 +291,9 @@ def _enforce_circular_mask(self) -> None: rr, cc = torch.meshgrid(r, c, indexing='ij') circle_matrix = torch.sqrt(rr**2 + cc**2) - mask = circle_matrix <= radius - self.template_raw[~mask] = 0.0 + # mask = circle_matrix <= radius + mask = torch.sigmoid(self.constraint_config["circular_mask_sharpness"]*(radius-circle_matrix)) + self.template_raw *= mask def enforce_hard_constraints(self, ctx: RenderContext) -> None: diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index c2ae4e8e..45b6f5f8 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -9,7 +9,7 @@ from tqdm import tqdm from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem -from quantem.core.fitting.basev2 import ( +from quantem.core.fitting.base import ( AdditiveRenderModel, FitBase, OriginND, @@ -673,18 +673,16 @@ def fit_strain(self) -> "ModelDiffraction": scan_c = self.dataset.shape[1] Uref = np.stack((u_ref, v_ref), axis=1).astype(float) - det = np.linalg.det(Uref) - if not np.isfinite(det) or abs(det) < 1e-12: - Uref_inv = np.linalg.pinv(Uref) - else: - Uref_inv = np.linalg.inv(Uref) - strain_trans = np.zeros((scan_r, scan_c, 2, 2)) - for r in range(scan_r): for c in range(scan_c): U = np.stack((u_fit[r, c, :], v_fit[r, c, :]), axis=1) - strain_trans[r, c, :, :] = U @ Uref_inv + det = np.linalg.det(U) + if not np.isfinite(det) or abs(det) < 1e-12: + U_inv = np.linalg.pinv(U) + else: + U_inv = np.linalg.inv(U) + strain_trans[r, c, :, :] = Uref @ U_inv self.strain_raw_err = Dataset2d.from_array( strain_trans[:, :, 0, 0] - 1, @@ -716,7 +714,7 @@ def plot_strain( strain_range_percent=(-3.0, 3.0), rotation_range_degrees=(-2.0, 2.0), plot_rotation=True, - cmap_strain="RdBu_r", + cmap_strain="RdBu", cmap_rotation=None, layout="horizontal", figsize=(6, 6), @@ -726,7 +724,7 @@ def plot_strain( if cmap_rotation is None: cmap_rotation = cmap_strain - angle = rotation_angle + angle = np.deg2rad(rotation_angle) c = np.cos(angle) s = np.sin(angle) @@ -745,10 +743,6 @@ def plot_strain( strain_evv.array[...] = evv strain_euv.array[...] = euv - alpha = None - good = None - alpha_im = None - if layout != "horizontal": raise ValueError("layout must be 'horizontal'") @@ -765,38 +759,24 @@ def plot_strain( euv_pct = strain_euv.array * 100 rot_deg = np.rad2deg(self.strain_rotation.array) - if good is not None and np.any(good): - euu_m = np.ma.array(euu_pct, mask=~good) - evv_m = np.ma.array(evv_pct, mask=~good) - euv_m = np.ma.array(euv_pct, mask=~good) - rot_m = np.ma.array(rot_deg, mask=~good) - else: - euu_m = euu_pct - evv_m = evv_pct - euv_m = euv_pct - rot_m = rot_deg - title_fs = 16 im0 = ax[0].imshow( - euu_m, + euu_pct, vmin=strain_range_percent[0], vmax=strain_range_percent[1], cmap=cm_strain, - alpha=alpha_im, ) ax[1].imshow( - evv_m, + evv_pct, vmin=strain_range_percent[0], vmax=strain_range_percent[1], cmap=cm_strain, - alpha=alpha_im, ) ax[2].imshow( - euv_m, + euv_pct, vmin=strain_range_percent[0], vmax=strain_range_percent[1], cmap=cm_strain, - alpha=alpha_im, ) ax[0].set_title(r"$\epsilon_{uu}$", fontsize=title_fs) @@ -805,11 +785,10 @@ def plot_strain( if plot_rotation: im3 = ax[3].imshow( - rot_m, + rot_deg, vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1], cmap=cm_rot, - alpha=alpha_im, ) ax[3].set_title("Rotation", fontsize=title_fs) From f993261950f8425c2703328a85ca032dcaa79072 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Fri, 27 Mar 2026 15:59:05 -0700 Subject: [PATCH 49/88] editing strain code for better cmaps, changing diffraction and base for allowing user setting of constraint parameters, fixing bug in get_parameters so it doesn't include submodules --- src/quantem/core/fitting/base.py | 45 ++++++++++++++++++++++-- src/quantem/core/fitting/diffraction.py | 25 ++++++++++--- src/quantem/diffraction/model_fitting.py | 6 ++-- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 68ee4b62..1c7d3b64 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -93,6 +93,7 @@ def __init__(self, *, ndim: int, init: Sequence[float]): class RenderComponent(OptimizerMixin,nn.Module): DEFAULT_HARD_CONSTRAINTS: dict[str, Any] = {} DEFAULT_SOFT_CONSTRAINTS: dict[str, Any] = {} + DEFAULT_CONSTRAINT_CONFIG: dict[str, Any] = {} DEFAULT_OPTIMIZER: str = "adam" DEFAULT_LR: float = 1e-2 @@ -107,6 +108,7 @@ def __init__(self) -> None: self._scheduler_params = {} self.hard_constraints: dict[str, Any] = dict(self.DEFAULT_HARD_CONSTRAINTS) self.soft_constraints: dict[str, Any] = dict(self.DEFAULT_SOFT_CONSTRAINTS) + self.constraint_config: dict[str, Any] = dict(self.DEFAULT_CONSTRAINT_CONFIG) self.parameter_bounds: dict[str, tuple[float | None, float | None]] = {} optimizer_params = { @@ -290,8 +292,8 @@ def constraint_loss( ) -> torch.Tensor: return torch.zeros((), device=ctx.device, dtype=ctx.dtype) - def get_optimization_parameters(self) -> Any: # make copy in synthetic disklattice which only refs lattice not disk lattice params, reoeat for origin byt in dusk teplate - return [p for p in self.parameters(recurse=False) if p.requires_grad] + def get_optimization_parameters(self) -> Any: + return [p for p in self.parameters() if p.requires_grad] def initialize_optimizer(self, optimizer_params: dict[str, Any] | None = None, @@ -363,6 +365,28 @@ def _rebuild_optimizer_after_trainability_change(self) -> None: rebuild_params_scheduler = self._infer_scheduler_rebuild_params() self.set_optimizer(rebuild_params) self.set_scheduler(rebuild_params_scheduler) + + def initialize_constraint_config(self, config: dict[str, Any], strict: bool = True) -> None: + if not hasattr(self, 'constraint_config'): + if strict: + raise AttributeError( + f"{self.__class__.__name__} does not have constraint_config attribute" + ) + return + if not isinstance(config, dict): + raise TypeError("constraint config must be a dict.") + + unknown: dict[str, Any] = {} + for k, v in config.items(): + if k in self.DEFAULT_CONSTRAINT_CONFIG: + self.constraint_config[k] = v + else: + unknown[k] = v + + if unknown and strict: + keys = ", ".join(str(k) for k in unknown.keys()) + raise KeyError(f"Unknown constraint keys for {self.__class__.__name__}: {keys}") + return class AdditiveRenderModel(nn.Module): # step all otpimzers @@ -705,6 +729,13 @@ def get_component_trainable(self, component_name: str) -> dict[str, bool]: """ component = self._resolve_component_by_name(component_name) return {name: bool(param.requires_grad) for name, param in component.named_parameters()} + + def apply_constraint_configs( + self, constraint_configs: dict[str, Any], strict: bool = True + ) -> None: + for component_name, param in constraint_configs.items(): + component = self._resolve_component_by_name(str(component_name)) + component.initialize_constraint_config(param, strict=strict) @@ -784,6 +815,7 @@ def fit_render( n_steps: int, constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, + constraint_config_params: dict[str, Any] | None = None, optimizer_params: dict[str, dict[str, Any]] | None = None, scheduler_params: dict[str, dict[str, Any]] | None = None, progress: bool = False, @@ -833,6 +865,8 @@ def fit_render( raise RuntimeError("Model and context are not defined for fitting.") if constraint_params is not None: self.model.apply_constraint_params(constraint_params, strict=True) + if constraint_config_params is not None: + self.model.apply_constraint_configs(constraint_config_params, strict=True) n_steps = int(n_steps) self.model.initilize_independant_optimizers( @@ -1007,6 +1041,13 @@ def _rebuild_optimizer_after_trainability_change(self) -> None: if self.model is None: raise RuntimeError("Call .define_model(...) first.") self.model.rebuild_independant_optimizers() + + def apply_constraint_config_params( + self, constraint_configs: dict[str, Any], strict: bool = True + ) -> None: + if self.model is None: + raise RuntimeError("Call .define_model(...) first.") + self.model.apply_constraint_configs(constraint_configs, strict=strict) Component = RenderComponent diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index e8c9d3e8..78a15104 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -67,8 +67,9 @@ class DiskTemplate(RenderComponent): "soft_cutoff_target_ratio": 0.1, "hard_cutoff_threshold": 0.35, "shrinkage_amount": 0.25, - "circular_mask_radius_fraction": 1, - "circular_mask_sharpness": 1.0, + "circular_mask_radius_fraction": 0.95, + "circular_mask_sharpness": 0, + "soft_circular_mask": False, } def __init__( @@ -291,8 +292,10 @@ def _enforce_circular_mask(self) -> None: rr, cc = torch.meshgrid(r, c, indexing='ij') circle_matrix = torch.sqrt(rr**2 + cc**2) - # mask = circle_matrix <= radius - mask = torch.sigmoid(self.constraint_config["circular_mask_sharpness"]*(radius-circle_matrix)) + if self.constraint_config["soft_circular_mask"]: + mask = torch.sigmoid(self.constraint_config["circular_mask_sharpness"]*(radius-circle_matrix)) + else: + mask = circle_matrix <= radius self.template_raw *= mask @@ -364,6 +367,13 @@ def constraint_loss( circular_loss = torch.as_tensor(circular_weight, device=ctx.device, dtype=ctx.dtype) * circular_err return cutoff_loss + tv_loss + circular_loss + + def get_optimization_parameters(self) -> Any: + params = [] + for name, param in self.named_parameters(recurse=True): + if not name.startswith('origin.') and param.requires_grad: + params.append(param) + return params @@ -680,3 +690,10 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: self.disk.add_patch(out, r0=rr0, c0=cc0, scale=inten) return out + + def get_optimization_parameters(self) -> Any: + params = [] + for name, param in self.named_parameters(recurse=True): + if not name.startswith('disk.') and param.requires_grad: + params.append(param) + return params diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 45b6f5f8..cdd8801e 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -436,6 +436,7 @@ def fit_mean_diffraction_pattern( scheduler_params: dict | None = None, constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, + constraint_config_params: dict[str, Any] | None = None, progress: bool = True, ) -> "ModelDiffraction": """ @@ -492,6 +493,7 @@ def fit_mean_diffraction_pattern( n_steps=int(n_steps), constraint_weight=float(constraint_weight), constraint_params=constraint_params, + constraint_config_params=constraint_config_params, optimizer_params=optimizer_params, scheduler_params=scheduler_params, progress=bool(progress), @@ -714,8 +716,8 @@ def plot_strain( strain_range_percent=(-3.0, 3.0), rotation_range_degrees=(-2.0, 2.0), plot_rotation=True, - cmap_strain="RdBu", - cmap_rotation=None, + cmap_strain="RdBu_r", + cmap_rotation="PiYG", layout="horizontal", figsize=(6, 6), ): From 1b1c30eb4b3e5afcb2be8accda553993757ddc74 Mon Sep 17 00:00:00 2001 From: Miti Date: Wed, 15 Apr 2026 19:33:46 -0700 Subject: [PATCH 50/88] updated multiple optimizer --- src/quantem/core/fitting/base.py | 4 ++-- src/quantem/diffraction/model_fitting.py | 26 +++++++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 1c7d3b64..f9c87b58 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -758,8 +758,8 @@ class FitBase(OptimizerMixin): def __init__(self): super().__init__() # Core wiring - self.loss_fn = torch.nn.L1Loss(reduction="mean") - # self.loss_fn = torch.nn.MSELoss(reduction="mean") + # self.loss_fn = torch.nn.L1Loss(reduction="mean") + self.loss_fn = torch.nn.MSELoss(reduction="mean") self.model: AdditiveRenderModel | None = None self.ctx: RenderContext | None = None diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index cdd8801e..43090ef2 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -665,11 +665,31 @@ def render_indivdual_pattern(self, row, col): ) return self._render_state_array(self.state_individual_refined[row, col]) - def fit_strain(self) -> "ModelDiffraction": + def fit_strain(self, + mask_reference=None, + ) -> "ModelDiffraction": u_fit = self.u_array v_fit = self.v_array - u_ref = np.median(u_fit.reshape(-1, 2), axis=0) - v_ref = np.median(v_fit.reshape(-1, 2), axis=0) + + if mask_reference is None: + u_ref = np.median(u_fit.reshape(-1, 2), axis=0) + v_ref = np.median(v_fit.reshape(-1, 2), axis=0) + else: + m = np.asarray(mask_reference, dtype=bool) + u_ref = np.array( + ( + np.median(u_fit[m, 0]), + np.median(u_fit[m, 1]), + ), + dtype=float, + ) + v_ref = np.array( + ( + np.median(v_fit[m, 0]), + np.median(v_fit[m, 1]), + ), + dtype=float, + ) scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] From dce5546ada936b4ba6bb59a899f5ea266366d73c Mon Sep 17 00:00:00 2001 From: Miti Date: Wed, 15 Apr 2026 20:37:52 -0700 Subject: [PATCH 51/88] new loss function --- src/quantem/core/fitting/base.py | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index f9c87b58..23d5f994 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -752,6 +752,38 @@ class FitResult: num_steps: int metrics: dict[str, list[float]] = field(default_factory=dict) +class BCEMSELoss(nn.Module): + + def __init__( + self, + gamma: float = 1, + percentile: float = 0.95, + ): + super().__init__() + self.gamma = gamma + self.percentile = percentile + + def forward(self, pred, target): + mse_loss = torch.mean((pred - target) ** 2) + + threshold_pred = torch.quantile(pred, self.percentile) + threshold_target = torch.quantile(target, self.percentile) + + # Create binary masks + target_binary = (target > threshold_target).float() + pred_binary = (pred > threshold_pred).float() + + epsilon = 1e-7 + pred_binary_clamped = torch.clamp(pred_binary, epsilon, 1.0 - epsilon) + bce_loss = -torch.mean( + target_binary * torch.log(pred_binary_clamped) + + (1 - target_binary) * torch.log(1 - pred_binary_clamped) + ) + + # Combined loss + total_loss = mse_loss + self.gamma ** bce_loss + + return total_loss class FitBase(OptimizerMixin): @@ -760,6 +792,7 @@ def __init__(self): # Core wiring # self.loss_fn = torch.nn.L1Loss(reduction="mean") self.loss_fn = torch.nn.MSELoss(reduction="mean") + self.loss_fn = BCEMSELoss() self.model: AdditiveRenderModel | None = None self.ctx: RenderContext | None = None From 496f86c6393a8c716068b45b961b87a1d8ae7b18 Mon Sep 17 00:00:00 2001 From: Miti Date: Wed, 15 Apr 2026 20:38:51 -0700 Subject: [PATCH 52/88] cosine optimizer --- src/quantem/core/ml/optimizer_mixin.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/quantem/core/ml/optimizer_mixin.py b/src/quantem/core/ml/optimizer_mixin.py index e2d2e89a..033a0538 100644 --- a/src/quantem/core/ml/optimizer_mixin.py +++ b/src/quantem/core/ml/optimizer_mixin.py @@ -62,6 +62,7 @@ def scheduler_params(self, params: dict): "gamma", "linear", "none", + "cosine", ]: raise ValueError( f"Unknown scheduler type: {params['type']}, expected one of ['cyclic', 'plateau', 'exp', 'gamma', 'none']" @@ -163,7 +164,7 @@ def set_scheduler( min_lr=params.get("min_lr", base_LR / 20), cooldown=params.get("cooldown", 50), ) - elif sched_type in ["exp", "gamma", "exponential"]: + elif sched_type in ["exp", "gamma", "exponential","cosine"]: if "gamma" in params: gamma = params["gamma"] elif num_iter is not None: @@ -179,6 +180,11 @@ def set_scheduler( end_factor=params.get("end_factor", 1.0), total_iters=params.get("total_iters", num_iter), ) + elif sched_type == "cosine": + self._scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, + T_max=params.get("t_max", 10), + ) else: raise ValueError(f"Unknown scheduler type: {sched_type}") From 9820b6bc390dfe7fa02d3d6cc3a2193b306b5af3 Mon Sep 17 00:00:00 2001 From: Miti Date: Thu, 16 Apr 2026 12:41:54 -0700 Subject: [PATCH 53/88] changing chosen loss function --- src/quantem/core/fitting/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 23d5f994..f6fe429c 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -792,7 +792,7 @@ def __init__(self): # Core wiring # self.loss_fn = torch.nn.L1Loss(reduction="mean") self.loss_fn = torch.nn.MSELoss(reduction="mean") - self.loss_fn = BCEMSELoss() + # self.loss_fn = BCEMSELoss() self.model: AdditiveRenderModel | None = None self.ctx: RenderContext | None = None From 5c33eade42172b13a39999e3478bed72daa92a4a Mon Sep 17 00:00:00 2001 From: Miti Date: Fri, 17 Apr 2026 09:18:05 -0700 Subject: [PATCH 54/88] adding preprocess square root of image --- src/quantem/diffraction/model_fitting.py | 32 ++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 43090ef2..a4b65dc3 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -315,10 +315,37 @@ def preprocess( upsample_factor: int = 32, max_shift: float | None = None, shift_order: int = 1, + gamma: float = 0.5, + mode: str = "linear", ) -> "ModelDiffraction": arr = np.asarray(self.dataset.array) if arr.ndim < 2: raise ValueError("dataset.array must have at least 2 dimensions.") + mode_in = mode.strip().lower() + if mode_in in {"linear", "patterson", "paterson", "acf", "autocorrelation"}: + mode_norm = "linear" + elif mode_in in {"log", "cepstrum", "cepstral"}: + mode_norm = "log" + elif mode_in in {"gamma", "power", "sqrt"}: + mode_norm = "gamma" + else: + raise ValueError( + "mode must be 'linear', 'log', or 'gamma' (aliases: 'patterson'->'linear', 'cepstrum'/'cepstral'->'log')." + ) + + self.metadata["mode"] = mode_norm + if mode_norm == "gamma": + self.metadata["gamma"] = gamma + + if mode_norm == "linear": + arr = arr + elif mode_norm == "log": + arr = np.log1p(arr) + elif mode_norm == "gamma": + arr = np.power(np.clip(arr, 0.0, None), self.metadata["gamma"]) + else: + raise RuntimeError("Unreachable: normalized mode mapping failed.") + h, w = arr.shape[-2], arr.shape[-1] self.index_shape = tuple(arr.shape[:-2]) @@ -665,8 +692,9 @@ def render_indivdual_pattern(self, row, col): ) return self._render_state_array(self.state_individual_refined[row, col]) - def fit_strain(self, - mask_reference=None, + def fit_strain( + self, + mask_reference=None, ) -> "ModelDiffraction": u_fit = self.u_array v_fit = self.v_array From 5ffe5055f525495689802ef981a9c1690286940a Mon Sep 17 00:00:00 2001 From: Miti Date: Sun, 19 Apr 2026 00:16:39 -0700 Subject: [PATCH 55/88] updated preprocessing --- src/quantem/diffraction/model_fitting.py | 2 +- src/quantem/diffraction/model_fitting_visualizations.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index a4b65dc3..e874cdec 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -345,7 +345,7 @@ def preprocess( arr = np.power(np.clip(arr, 0.0, None), self.metadata["gamma"]) else: raise RuntimeError("Unreachable: normalized mode mapping failed.") - + self.dataset.array = np.asarray(arr) h, w = arr.shape[-2], arr.shape[-1] self.index_shape = tuple(arr.shape[:-2]) diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py index 542d5592..b8cac881 100644 --- a/src/quantem/diffraction/model_fitting_visualizations.py +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -152,7 +152,7 @@ def visualize( individual_loss: bool = False, pattern_row: int = 0, pattern_col: int = 0, - power: float = 0.25, + power: float = 1, cbar: bool = False, axsize: tuple[int, int] = (6, 6), overlay: bool = True, From b74fa939a8cd5a6fdfb0644247bd0d3fb8832f45 Mon Sep 17 00:00:00 2001 From: Miti Date: Thu, 23 Apr 2026 19:08:49 -0700 Subject: [PATCH 56/88] new loss functions --- src/quantem/core/fitting/base.py | 41 ++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index f6fe429c..5e4eedef 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -785,14 +785,36 @@ def forward(self, pred, target): return total_loss +class SqrtMSELoss(nn.Module): + def __init__( + self, + gamma: float = 0.25, + ): + super().__init__() + self.gamma = gamma + self.mse_fn = torch.nn.MSELoss(reduction="mean") + + def forward(self, pred, target): + return self.mse_fn(pred ** self.gamma, target ** self.gamma) + +class LogMSELoss(nn.Module): + def __init__( + self, + ): + super().__init__() + self.mse_fn = torch.nn.MSELoss(reduction="mean") + + def forward(self, pred, target): + return self.mse_fn(torch.log(1+pred), torch.log(1+target)) + class FitBase(OptimizerMixin): def __init__(self): super().__init__() # Core wiring # self.loss_fn = torch.nn.L1Loss(reduction="mean") - self.loss_fn = torch.nn.MSELoss(reduction="mean") - # self.loss_fn = BCEMSELoss() + # self.loss_fn = torch.nn.MSELoss(reduction="mean") + self.loss_fn = SqrtMSELoss() self.model: AdditiveRenderModel | None = None self.ctx: RenderContext | None = None @@ -828,6 +850,21 @@ def render_current(self) -> np.ndarray: if self.model is None or self.ctx is None: raise RuntimeError("Call .define_model(...) first.") return self.model(self.ctx).detach().cpu().numpy() + + def set_loss(self, loss_fn: str = "sqrtmse", gamma: float = 0.25): + mode_in = loss_fn.strip().lower() + if mode_in in {"mse"}: + self.loss_fn = torch.nn.MSELoss(reduction="mean") + elif mode_in in {"sqrtmse"}: + self.loss_fn = SqrtMSELoss(gamma = gamma) + elif mode_in in {"logmse"}: + self.loss_fn = LogMSELoss() + elif mode_in in {"l1"}: + self.loss_fn = torch.nn.L1Loss(reduction="mean") + else: + raise ValueError( + "loss function must be mse, sqrtmse, logmse or l1" + ) def reset( self, From 2991a1aad2da467d3dfcaef16b0c5927fc56a1dc Mon Sep 17 00:00:00 2001 From: Miti Date: Fri, 24 Apr 2026 14:47:27 -0700 Subject: [PATCH 57/88] new loss functions --- src/quantem/core/fitting/base.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 5e4eedef..f9421519 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -795,7 +795,15 @@ def __init__( self.mse_fn = torch.nn.MSELoss(reduction="mean") def forward(self, pred, target): - return self.mse_fn(pred ** self.gamma, target ** self.gamma) + eps = 1 + pred_modified = (pred-torch.min(pred)+eps)**self.gamma + # pred_modified = pred_modified / torch.linalg.norm(pred_modified) + + target_modified = (target-torch.min(target)+eps)**self.gamma + # target_modified = target_modified / torch.linalg.norm(target_modified) + + loss = self.mse_fn(pred_modified, target_modified) + return loss class LogMSELoss(nn.Module): def __init__( From 712617453fdaae2936bbe43ced638e174bedded4 Mon Sep 17 00:00:00 2001 From: Miti Date: Thu, 30 Apr 2026 15:48:46 -0700 Subject: [PATCH 58/88] fixed sloped intensity peak fitting --- src/quantem/core/fitting/diffraction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 78a15104..d603e871 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -402,7 +402,7 @@ def __init__( intensity_row_col: float | Sequence[float] = 0.0, per_disk_intensity: bool = False, per_disk_slopes: bool = True, - max_intensity_order: int | None = 0, + max_intensity_order: int | None = None, default_pattern_intensity_order: int | None = None, center_intensity_0: float | Sequence[float] | None = None, exclude_indices: Iterable[tuple[int, int]] | None = None, From 173c3ce096b55ed96390093fa427805278486eda Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Mon, 4 May 2026 14:48:56 -0700 Subject: [PATCH 59/88] changing synthetic disk lattice plotting and lr save to speed up model training --- src/quantem/core/fitting/base.py | 10 +- src/quantem/core/fitting/diffraction.py | 150 ++++++++++++++++++------ 2 files changed, 121 insertions(+), 39 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index f9421519..c1c0f9cf 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from types import NoneType from typing import Any, Literal, Self, Sequence, cast import numpy as np @@ -954,10 +955,11 @@ def fit_render( ) pbar = tqdm(range(n_steps), desc="Fit render", disable=not progress) + loss_vals = torch.empty(n_steps, device=self.ctx.device) losses: list[float] = [] lrs: list[float] = [] - for _ in pbar: + for step in pbar: self.model.zero_grad_optimizers() pred = self._forward_for_fit(target=target, **kwargs) data_loss = self._fidelity_loss(pred, target, **kwargs) @@ -968,9 +970,10 @@ def fit_render( if self.model is None or self.ctx is None: raise RuntimeError("Model and context are not defined for fitting.") self.model.apply_hard_constraints(self.ctx) - total_loss_value = float(total_loss.detach().cpu()) + total_loss_value = (total_loss.detach()) self.model.step_schedulers(total_loss_value) - losses.append(total_loss_value) + # losses.append(total_loss_value) + loss_vals[step] = total_loss_value first_lr = 0.0 if len(self.model.components) > 0: @@ -981,6 +984,7 @@ def fit_render( except (AttributeError, TypeError): first_lr = 0.0 lrs.append(first_lr) + losses = loss_vals.cpu().tolist() key = str(run_key) if key in self.fit_history: diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index d603e871..92e8ce60 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -624,6 +624,73 @@ def enforce_hard_constraints(self, ctx: RenderContext) -> None: self.i0_raw[idx].clamp_(max=float(hi)) super().enforce_hard_constraints(ctx) + # def forward(self, ctx: RenderContext) -> torch.Tensor: + # if self.origin is None: + # raise RuntimeError("SyntheticDiskLattice requires an OriginND instance.") + + # out = torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) + # uv_indices = cast(torch.Tensor, self.uv_indices) + # if torch.numel(uv_indices) == 0: + # return out + + # uv = torch.as_tensor(uv_indices, device=ctx.device) + # u = uv[:, 0].to(dtype=ctx.dtype) + # v = uv[:, 1].to(dtype=ctx.dtype) + # r0, c0 = self.origin.coords[0], self.origin.coords[1] + # centers_r = r0 + u * self.u_row + v * self.v_row + # centers_c = c0 + u * self.u_col + v * self.v_col + + # b = torch.as_tensor(self.boundary_px, device=ctx.device, dtype=ctx.dtype) + # keep = (centers_r >= b) & (centers_r <= (ctx.shape[0] - 1) - b) + # keep = keep & (centers_c >= b) & (centers_c <= (ctx.shape[1] - 1) - b) + # keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) + # if keep_idx.numel() == 0: + # return out + + # active_order = int( + # ctx.fields.get( + # "lattice_intensity_order_override", self.default_pattern_intensity_order + # ) + # ) + # active_order = max(0, min(active_order, self.max_intensity_order)) + + # dr, dc = self.disk.patch_offsets() + # dr = dr.to(device=ctx.device, dtype=ctx.dtype) + # dc = dc.to(device=ctx.device, dtype=ctx.dtype) + # dr2 = dr * dr + # dc2 = dc * dc + # drdc = dr * dc + + # for j in keep_idx: + # rr0 = centers_r[j] + # cc0 = centers_c[j] + + # if self.per_disk_intensity: + # inten = self.i0_raw[j] + # if active_order >= 1 and self.ir is not None and self.ic is not None: + # inten = inten + self.ir[j] * dr + self.ic[j] * dc + # if ( + # active_order >= 2 + # and self.irr is not None + # and self.icc is not None + # and self.irc is not None + # ): + # inten = inten + self.irr[j] * dr2 + self.icc[j] * dc2 + self.irc[j] * drdc + # else: + # inten = self.i0_raw + # if active_order >= 1: + # assert self.ir is not None and self.ic is not None + # inten = inten + self.ir * rr0 + self.ic * cc0 + # if active_order >= 2: + # assert self.irr is not None and self.icc is not None and self.irc is not None + # inten = ( + # inten + self.irr * rr0 * rr0 + self.icc * cc0 * cc0 + self.irc * rr0 * cc0 + # ) + + # self.disk.add_patch(out, r0=rr0, c0=cc0, scale=inten) + + # return out + def forward(self, ctx: RenderContext) -> torch.Tensor: if self.origin is None: raise RuntimeError("SyntheticDiskLattice requires an OriginND instance.") @@ -643,9 +710,13 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: b = torch.as_tensor(self.boundary_px, device=ctx.device, dtype=ctx.dtype) keep = (centers_r >= b) & (centers_r <= (ctx.shape[0] - 1) - b) keep = keep & (centers_c >= b) & (centers_c <= (ctx.shape[1] - 1) - b) - keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) - if keep_idx.numel() == 0: + if not torch.any(keep): return out + + centers_r = centers_r[keep] + centers_c = centers_c[keep] + keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) + num_disks = centers_r.shape[0] active_order = int( ctx.fields.get( @@ -654,41 +725,48 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: ) active_order = max(0, min(active_order, self.max_intensity_order)) - dr, dc = self.disk.patch_offsets() - dr = dr.to(device=ctx.device, dtype=ctx.dtype) - dc = dc.to(device=ctx.device, dtype=ctx.dtype) - dr2 = dr * dr - dc2 = dc * dc - drdc = dr * dc - - for j in keep_idx: - rr0 = centers_r[j] - cc0 = centers_c[j] - - if self.per_disk_intensity: - inten = self.i0_raw[j] - if active_order >= 1 and self.ir is not None and self.ic is not None: - inten = inten + self.ir[j] * dr + self.ic[j] * dc - if ( - active_order >= 2 - and self.irr is not None - and self.icc is not None - and self.irc is not None - ): - inten = inten + self.irr[j] * dr2 + self.icc[j] * dc2 + self.irc[j] * drdc - else: - inten = self.i0_raw - if active_order >= 1: - assert self.ir is not None and self.ic is not None - inten = inten + self.ir * rr0 + self.ic * cc0 - if active_order >= 2: - assert self.irr is not None and self.icc is not None and self.irc is not None - inten = ( - inten + self.irr * rr0 * rr0 + self.icc * cc0 * cc0 + self.irc * rr0 * cc0 - ) - - self.disk.add_patch(out, r0=rr0, c0=cc0, scale=inten) + dr = cast(torch.Tensor, self.disk.dr).to(device=ctx.device, dtype=ctx.dtype) + dc = cast(torch.Tensor, self.disk.dc).to(device=ctx.device, dtype=ctx.dtype) + patch_vals = self.disk.patch_values().to(device=ctx.device, dtype=ctx.dtype) + num_pixels = patch_vals.shape[0] + if self.per_disk_intensity: + i0 = self.i0_raw[keep_idx][:, None] + inten = i0.expand(-1, num_pixels) + + if active_order >= 1 and self.ir is not None: + ir = self.ir[keep_idx][:, None] + ic = self.ic[keep_idx][:, None] + inten = inten + ir * dr[None, :] + ic * dc[None, :] + + if active_order >= 2 and self.irr is not None: + irr = self.irr[keep_idx][:, None] + icc = self.icc[keep_idx][:, None] + irc = self.irc[keep_idx][:, None] + inten = inten + irr * (dr*dr)[None, :] + icc * (dc*dc)[None, :] + irc * (dr*dc)[None, :] + else: + inten = self.i0_raw if isinstance(self.i0_raw, torch.Tensor) else self.i0_raw + if active_order >= 1: + inten = inten + self.ir * centers_r + self.ic * centers_c + if active_order >= 2: + inten = inten + self.irr * centers_r**2 + self.icc * centers_c**2 + self.irc * centers_r * centers_c + + inten = inten[:, None].expand(-1, num_pixels) if inten.ndim == 1 else inten.expand(num_disks, num_pixels) + total_pixels = num_disks * num_pixels + r0_all = centers_r[:, None].expand(-1, num_pixels).reshape(total_pixels) + c0_all = centers_c[:, None].expand(-1, num_pixels).reshape(total_pixels) + dr_all = dr[None, :].expand(num_disks, -1).reshape(total_pixels) + dc_all = dc[None, :].expand(num_disks, -1).reshape(total_pixels) + vals_all = (patch_vals[None, :] * inten).reshape(total_pixels) + _splat_patch( + out, + r0=r0_all, + c0=c0_all, + patch_vals=vals_all, + dr=dr_all, + dc=dc_all, + scale=torch.ones_like(vals_all) + ) return out def get_optimization_parameters(self) -> Any: From 77e4715c93bc1e5d9cc4f7d276ed2b2353fa16dd Mon Sep 17 00:00:00 2001 From: Miti Date: Tue, 5 May 2026 13:53:25 -0700 Subject: [PATCH 60/88] updated multi optimizer to match upstream --- src/quantem/core/fitting/base.py | 250 ------------------------------- 1 file changed, 250 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 339262bf..8f1b3e5a 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -1,20 +1,12 @@ from __future__ import annotations from dataclasses import dataclass, field -<<<<<<< HEAD from types import NoneType -======= ->>>>>>> upstream/fitting_models_clean from typing import Any, Literal, Self, Sequence, cast import numpy as np import torch from torch import nn -<<<<<<< HEAD -from tqdm.auto import tqdm - -from quantem.core.ml.optimizer_mixin import OptimizerMixin -======= from tqdm import tqdm from quantem.core.ml.optimizer_mixin import ( @@ -23,7 +15,6 @@ OptimizerType, SchedulerType, ) ->>>>>>> upstream/fitting_models_clean def parse_bounded_init( @@ -105,7 +96,6 @@ def __init__(self, *, ndim: int, init: Sequence[float]): self.coords = nn.Parameter(torch.as_tensor(init, dtype=torch.float32).reshape(self.ndim)) -<<<<<<< HEAD class RenderComponent(OptimizerMixin,nn.Module): DEFAULT_HARD_CONSTRAINTS: dict[str, Any] = {} DEFAULT_SOFT_CONSTRAINTS: dict[str, Any] = {} @@ -138,18 +128,6 @@ def __init__(self) -> None: } self.scheduler_params = scheduler_params -======= -class RenderComponent(nn.Module): - DEFAULT_HARD_CONSTRAINTS: dict[str, Any] = {} - DEFAULT_SOFT_CONSTRAINTS: dict[str, Any] = {} - - def __init__(self) -> None: - super().__init__() - self.hard_constraints: dict[str, Any] = dict(self.DEFAULT_HARD_CONSTRAINTS) - self.soft_constraints: dict[str, Any] = dict(self.DEFAULT_SOFT_CONSTRAINTS) - self.parameter_bounds: dict[str, tuple[float | None, float | None]] = {} - ->>>>>>> upstream/fitting_models_clean @staticmethod def parse_bounded_init( value: float | int | Sequence[float | int | None], *, name: str @@ -320,7 +298,6 @@ def constraint_loss( ) -> torch.Tensor: return torch.zeros((), device=ctx.device, dtype=ctx.dtype) -<<<<<<< HEAD def get_optimization_parameters(self) -> Any: return [p for p in self.parameters() if p.requires_grad] @@ -419,10 +396,6 @@ def initialize_constraint_config(self, config: dict[str, Any], strict: bool = Tr class AdditiveRenderModel(nn.Module): # step all otpimzers -======= - -class AdditiveRenderModel(nn.Module): ->>>>>>> upstream/fitting_models_clean def __init__(self, *, origin: nn.Module, components: list[RenderComponent]): super().__init__() self.origin = origin @@ -486,7 +459,6 @@ def total_constraint_loss(self, ctx: RenderContext) -> torch.Tensor: loss = loss + component.constraint_loss(ctx) return loss -<<<<<<< HEAD def initilize_independant_optimizers(self, individual_optimizers: dict[str, dict[str, Any]] | None = None, individual_schedulers: dict[str, dict[str, Any]] | None = None, @@ -606,72 +578,6 @@ def set_component_trainable( enabled: bool, rebuild_optimizer: bool = True, num_iter: int | None = None, -======= - -@dataclass -class FitResult: - losses: list[float] - lrs: list[float] - final_loss: float - num_steps: int - metrics: dict[str, list[float]] = field(default_factory=dict) - - -class FitBase(OptimizerMixin): - DEFAULT_LR = 1e-2 - DEFAULT_OPTIMIZER_TYPE = "adam" - - def __init__(self): - super().__init__() - # Core wiring - self.loss_fn = torch.nn.MSELoss(reduction="mean") - self.model: AdditiveRenderModel | None = None - self.ctx: RenderContext | None = None - - # State/checkpoints - self.state_initialized: dict[str, torch.Tensor] | None = None - - # Histories/results - self.fit_history: dict[str, FitResult] = {} - - def get_optimization_parameters(self) -> Any: - if self.model is None: - return [] - return [p for p in self.model.parameters() if p.requires_grad] - - @property - def state_current(self) -> dict[str, torch.Tensor] | None: - if self.model is None: - return None - return self._get_model_state_dict_copy() - - @property - def render_initialized(self) -> np.ndarray: - if self.state_initialized is None: - raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") - return self._render_state_array(self.state_initialized) - - @property - def render_current(self) -> np.ndarray: - if self.model is None or self.ctx is None: - raise RuntimeError("Call .define_model(...) first.") - return self.model(self.ctx).detach().cpu().numpy() - - def reset( - self, - reset_to: Literal["initialized"] = "initialized", - ) -> Self: - if reset_to != "initialized": - raise ValueError("FitBase.reset only supports reset_to='initialized'.") - if self.state_initialized is None: - raise RuntimeError("initialized state is unavailable. Call .define_model(...) first.") - self._load_model_state_dict_copy(self.state_initialized) - self._clear_fit_history_all() - return self - - def set_component_trainable( - self, component_name: str, enabled: bool, rebuild_optimizer: bool = True ->>>>>>> upstream/fitting_models_clean ) -> None: """ Enable or disable optimization for all parameters in one component. @@ -707,11 +613,7 @@ def set_component_trainable( for _, param in component.named_parameters(recurse=True): param.requires_grad_(bool(enabled)) if rebuild_optimizer: -<<<<<<< HEAD component._rebuild_optimizer_after_trainability_change() -======= - self._rebuild_optimizer_after_trainability_change() ->>>>>>> upstream/fitting_models_clean def set_parameter_trainable( self, @@ -719,10 +621,7 @@ def set_parameter_trainable( parameter_name: str, enabled: bool, rebuild_optimizer: bool = True, -<<<<<<< HEAD num_iter: int | None = None, -======= ->>>>>>> upstream/fitting_models_clean ) -> None: """ Enable or disable optimization for one component parameter. @@ -764,11 +663,7 @@ def set_parameter_trainable( ) params[parameter_name].requires_grad_(bool(enabled)) if rebuild_optimizer: -<<<<<<< HEAD component._rebuild_optimizer_after_trainability_change() -======= - self._rebuild_optimizer_after_trainability_change() ->>>>>>> upstream/fitting_models_clean def set_parameters_trainable( self, @@ -776,10 +671,7 @@ def set_parameters_trainable( parameter_names: list[str], enabled: bool, rebuild_optimizer: bool = True, -<<<<<<< HEAD num_iter: int | None = None, -======= ->>>>>>> upstream/fitting_models_clean ) -> None: """ Enable or disable optimization for multiple component parameters. @@ -818,11 +710,7 @@ def set_parameters_trainable( for name in parameter_names: params[name].requires_grad_(bool(enabled)) if rebuild_optimizer: -<<<<<<< HEAD component._rebuild_optimizer_after_trainability_change() -======= - self._rebuild_optimizer_after_trainability_change() ->>>>>>> upstream/fitting_models_clean def get_component_trainable(self, component_name: str) -> dict[str, bool]: """ @@ -847,7 +735,6 @@ def get_component_trainable(self, component_name: str) -> dict[str, bool]: """ component = self._resolve_component_by_name(component_name) return {name: bool(param.requires_grad) for name, param in component.named_parameters()} -<<<<<<< HEAD def apply_constraint_configs( self, constraint_configs: dict[str, Any], strict: bool = True @@ -1004,8 +891,6 @@ def reset( self._load_model_state_dict_copy(self.state_initialized) self._clear_fit_history_all() return self -======= ->>>>>>> upstream/fitting_models_clean def fit_render( self, @@ -1014,14 +899,9 @@ def fit_render( n_steps: int, constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, -<<<<<<< HEAD constraint_config_params: dict[str, Any] | None = None, optimizer_params: dict[str, dict[str, Any]] | None = None, scheduler_params: dict[str, dict[str, Any]] | None = None, -======= - optimizer_params: OptimizerType | dict | None = None, - scheduler_params: SchedulerType | dict | None = None, ->>>>>>> upstream/fitting_models_clean progress: bool = False, run_key: str = "default", **kwargs: Any, @@ -1069,7 +949,6 @@ def fit_render( raise RuntimeError("Model and context are not defined for fitting.") if constraint_params is not None: self.model.apply_constraint_params(constraint_params, strict=True) -<<<<<<< HEAD if constraint_config_params is not None: self.model.apply_constraint_configs(constraint_config_params, strict=True) @@ -1087,45 +966,11 @@ def fit_render( lrs: list[float] = [] for step in pbar: self.model.zero_grad_optimizers() -======= - - optimizer_rebuilt = False - if optimizer_params is not None: - self.set_optimizer(optimizer_params) - optimizer_rebuilt = True - elif self.optimizer is None: - if self.optimizer_params: - self.set_optimizer(self.optimizer_params) - else: - self.set_optimizer( - { - "type": getattr(self, "DEFAULT_OPTIMIZER_TYPE", "adamw"), - "lr": float(getattr(self, "DEFAULT_LR", self.DEFAULT_LR)), - } - ) - optimizer_rebuilt = True - - n_steps = int(n_steps) - if scheduler_params is not None: - self.set_scheduler(scheduler_params, num_iter=n_steps) - elif self.scheduler is None and self.scheduler_params: - self.set_scheduler(self.scheduler_params, num_iter=n_steps) - elif optimizer_rebuilt and self.scheduler is not None and self.optimizer is not None: - self.scheduler.optimizer = self.optimizer - - pbar = tqdm(range(n_steps), desc="Fit render", disable=not progress) - - losses: list[float] = [] - lrs: list[float] = [] - for _ in pbar: - self.zero_optimizer_grad() ->>>>>>> upstream/fitting_models_clean pred = self._forward_for_fit(target=target, **kwargs) data_loss = self._fidelity_loss(pred, target, **kwargs) constraint_loss = self._constraint_loss(pred, target, **kwargs) total_loss = data_loss + constraint_weight * constraint_loss total_loss.backward() -<<<<<<< HEAD self.model.step_optimizers() if self.model is None or self.ctx is None: raise RuntimeError("Model and context are not defined for fitting.") @@ -1145,16 +990,6 @@ def fit_render( first_lr = 0.0 lrs.append(first_lr) losses = loss_vals.cpu().tolist() -======= - self.step_optimizer() - if self.model is None or self.ctx is None: - raise RuntimeError("Model and context are not defined for fitting.") - self.model.apply_hard_constraints(self.ctx) - total_loss_value = float(total_loss.detach().cpu()) - self.step_scheduler(total_loss_value) - losses.append(total_loss_value) - lrs.append(float(self.get_current_lr())) ->>>>>>> upstream/fitting_models_clean key = str(run_key) if key in self.fit_history: @@ -1174,88 +1009,6 @@ def fit_render( self.fit_history[key] = result return result -<<<<<<< HEAD -======= - def _iter_named_components(self) -> list[tuple[str, RenderComponent]]: - """ - Return canonical component names paired with components. - - Returns - ------- - list[tuple[str, RenderComponent]] - ``(name, component)`` entries using the model's canonical naming - rule. Names fall back to class-name/index behavior when ``.name`` is - missing. - - Raises - ------ - RuntimeError - If the model is not defined. - """ - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - entries: list[tuple[str, RenderComponent]] = [] - for idx, module in enumerate(self.model.components): - component = cast(RenderComponent, module) - name = self.model._component_constraint_name(component, idx) - entries.append((name, component)) - return entries - - def get_component_names(self) -> list[str]: - """ - Return canonical component names. - - Returns - ------- - list[str] - Canonical component names. - """ - return [name for name, _ in self._iter_named_components()] - - def _resolve_component_by_name(self, component_name: str) -> RenderComponent: - target = str(component_name) - for resolved_name, component in self._iter_named_components(): - if resolved_name == target: - return component - known = ", ".join(self.get_component_names()) - raise KeyError(f"Component not found: {target}. Known components: {known}") - - def _infer_optimizer_rebuild_params(self) -> dict[str, Any]: - if self.optimizer_params: - op = self.optimizer_params - if isinstance(op, OptimizerParams.NoneOptimizer): - return {"type": "none"} - out: dict[str, Any] = dict(op.params()) - out["type"] = op._name - return out - if self.optimizer is not None: - opt_type: str | type[torch.optim.Optimizer] - if isinstance(self.optimizer, torch.optim.AdamW): - opt_type = "adamw" - elif isinstance(self.optimizer, torch.optim.Adam): - opt_type = "adam" - elif isinstance(self.optimizer, torch.optim.SGD): - opt_type = "sgd" - else: - opt_type = type(self.optimizer) - lr = float( - self.optimizer.param_groups[0].get( - "lr", getattr(self, "DEFAULT_LR", self.DEFAULT_LR) - ) - ) - return {"type": opt_type, "lr": lr} - return { - "type": getattr(self, "DEFAULT_OPTIMIZER_TYPE", self.DEFAULT_OPTIMIZER_TYPE), - "lr": float(getattr(self, "DEFAULT_LR", self.DEFAULT_LR)), - } - - def _rebuild_optimizer_after_trainability_change(self) -> None: - if self.model is None: - raise RuntimeError("Call .define_model(...) first.") - rebuild_params = self._infer_optimizer_rebuild_params() - self.set_optimizer(rebuild_params) - self.set_scheduler({"type": "none"}) ->>>>>>> upstream/fitting_models_clean def _clone_state_dict(self, state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: return {k: v.detach().clone() for k, v in state.items()} @@ -1313,7 +1066,6 @@ def _constraint_loss( raise RuntimeError("Model and context are not defined for fitting.") return self.model.total_constraint_loss(self.ctx) -<<<<<<< HEAD def set_component_trainable( self, component_name: str, @@ -1384,8 +1136,6 @@ def apply_constraint_config_params( raise RuntimeError("Call .define_model(...) first.") self.model.apply_constraint_configs(constraint_configs, strict=strict) -======= ->>>>>>> upstream/fitting_models_clean Component = RenderComponent ModelContext = RenderContext From 9db1d9cb0401ddd4bb6de5c8a64c248f42eda98e Mon Sep 17 00:00:00 2001 From: Miti Date: Tue, 5 May 2026 14:02:26 -0700 Subject: [PATCH 61/88] post-merge cleanup --- pyproject.toml | 5 -- src/quantem/core/fitting/diffraction.py | 85 ------------------------ src/quantem/diffraction/__init__.py | 3 - src/quantem/diffraction/model_fitting.py | 48 ------------- 4 files changed, 141 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 753f5c46..5ca6787b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,9 +82,4 @@ dev = [ "pre-commit>=4.2.0", "ruff>=0.11.5", "tomli>=2.2.1", -<<<<<<< HEAD - "hdf5plugin>=6.0.0", ] -======= -] ->>>>>>> upstream/fitting_models_clean diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 01eceb0d..92e8ce60 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -52,7 +52,6 @@ class DiskTemplate(RenderComponent): DEFAULT_HARD_CONSTRAINTS: dict[str, bool] = { "force_center": False, "force_positive": True, -<<<<<<< HEAD "force_norm": True, # force range [0,1] "force_shrinkage": False, "force_cutoff": False, @@ -72,10 +71,6 @@ class DiskTemplate(RenderComponent): "circular_mask_sharpness": 0, "soft_circular_mask": False, } -======= - } - DEFAULT_SOFT_CONSTRAINTS: dict[str, float] = {"tv_weight": 0.0} ->>>>>>> upstream/fitting_models_clean def __init__( self, @@ -88,10 +83,7 @@ def __init__( origin_key: str = "origin", intensity: float | Sequence[float] = 1.0, constraint_params: dict[str, Any] | None = None, -<<<<<<< HEAD constraint_config: dict[str, Any] | None = None, -======= ->>>>>>> upstream/fitting_models_clean ): """ Build a disk template renderer centered at the shared origin. @@ -151,23 +143,17 @@ def __init__( cc = cc.astype(np.float32) - (wt - 1) * 0.5 self.register_buffer("dr", torch.as_tensor(rr.ravel(), dtype=torch.float32)) self.register_buffer("dc", torch.as_tensor(cc.ravel(), dtype=torch.float32)) -<<<<<<< HEAD self.constraint_config = self.DEFAULT_CONSTRAINT_CONFIG.copy() if constraint_config is not None: self.constraint_config.update(constraint_config) -======= ->>>>>>> upstream/fitting_models_clean if constraint_params is not None: self.apply_constraint_params(constraint_params, strict=True) if bool(self.hard_constraints.get("force_positive", False)): self._enforce_positivity() -<<<<<<< HEAD if bool(self.hard_constraints.get("force_shrinkage", False)) and bool(self.hard_constraints.get("force_positive", True)): raise RuntimeWarning("Setting shrinkage true and positivity false might cause negative values in disk template") -======= ->>>>>>> upstream/fitting_models_clean @classmethod def from_array( @@ -181,11 +167,8 @@ def from_array( origin_key: str = "origin", intensity: float | Sequence[float] = 1.0, constraint_params: dict[str, Any] | None = None, -<<<<<<< HEAD constraint_config: dict[str, Any] | None = None, -======= ->>>>>>> upstream/fitting_models_clean ) -> "DiskTemplate": return cls( name=name, @@ -196,11 +179,8 @@ def from_array( origin_key=origin_key, intensity=intensity, constraint_params=constraint_params, -<<<<<<< HEAD constraint_config=constraint_config, -======= ->>>>>>> upstream/fitting_models_clean ) def set_origin(self, origin: OriginND) -> None: @@ -287,7 +267,6 @@ def _enforce_positivity(self) -> None: with torch.no_grad(): self.template_raw.clamp_(min=0.0) self.intensity_raw.clamp_(min=0.0) -<<<<<<< HEAD def _enforce_norm(self) -> None: # pick value and cut off 5 percent of mean, or every iteration shrinkage, every iteration just subtract a valye of 0.01 with torch.no_grad(): @@ -319,13 +298,10 @@ def _enforce_circular_mask(self) -> None: mask = circle_matrix <= radius self.template_raw *= mask -======= ->>>>>>> upstream/fitting_models_clean def enforce_hard_constraints(self, ctx: RenderContext) -> None: if bool(self.hard_constraints.get("force_center", False)): self._center_disk() -<<<<<<< HEAD if bool(self.hard_constraints.get("force_cutoff", False)): self._enforce_cutoff() if bool(self.hard_constraints.get("force_circular_mask", False)): @@ -337,10 +313,6 @@ def enforce_hard_constraints(self, ctx: RenderContext) -> None: if bool(self.hard_constraints.get("force_norm", False)): # could be put in positivity self._enforce_norm() -======= - if bool(self.hard_constraints.get("force_positive", False)): - self._enforce_positivity() ->>>>>>> upstream/fitting_models_clean super().enforce_hard_constraints(ctx) def constraint_loss( @@ -348,7 +320,6 @@ def constraint_loss( ) -> torch.Tensor: cfg = self.effective_soft_constraints(cast(dict[str, object] | None, params)) tv_weight = float(cfg.get("tv_weight", 0.0)) -<<<<<<< HEAD cutoff_weight = float(cfg.get("cutoff_weight", 0.0)) circular_weight = float(cfg.get("circular_weight", 0.0)) @@ -360,10 +331,6 @@ def constraint_loss( circular_weight = 0.0 # tv loss calculation -======= - if tv_weight <= 0.0: - return torch.zeros((), device=ctx.device, dtype=ctx.dtype) ->>>>>>> upstream/fitting_models_clean template = self.template_raw.to(device=ctx.device, dtype=ctx.dtype) tv_r = ( torch.mean(torch.abs(template[1:, :] - template[:-1, :])) @@ -375,7 +342,6 @@ def constraint_loss( if template.shape[1] > 1 else torch.zeros((), device=ctx.device, dtype=ctx.dtype) ) -<<<<<<< HEAD tv_loss = torch.as_tensor(tv_weight, device=ctx.device, dtype=ctx.dtype) * (tv_r + tv_c) # Cutoff loss calculation @@ -410,9 +376,6 @@ def get_optimization_parameters(self) -> Any: return params -======= - return torch.as_tensor(tv_weight, device=ctx.device, dtype=ctx.dtype) * (tv_r + tv_c) ->>>>>>> upstream/fitting_models_clean class SyntheticDiskLattice(RenderComponent): @@ -661,7 +624,6 @@ def enforce_hard_constraints(self, ctx: RenderContext) -> None: self.i0_raw[idx].clamp_(max=float(hi)) super().enforce_hard_constraints(ctx) -<<<<<<< HEAD # def forward(self, ctx: RenderContext) -> torch.Tensor: # if self.origin is None: # raise RuntimeError("SyntheticDiskLattice requires an OriginND instance.") @@ -729,8 +691,6 @@ def enforce_hard_constraints(self, ctx: RenderContext) -> None: # return out -======= ->>>>>>> upstream/fitting_models_clean def forward(self, ctx: RenderContext) -> torch.Tensor: if self.origin is None: raise RuntimeError("SyntheticDiskLattice requires an OriginND instance.") @@ -750,7 +710,6 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: b = torch.as_tensor(self.boundary_px, device=ctx.device, dtype=ctx.dtype) keep = (centers_r >= b) & (centers_r <= (ctx.shape[0] - 1) - b) keep = keep & (centers_c >= b) & (centers_c <= (ctx.shape[1] - 1) - b) -<<<<<<< HEAD if not torch.any(keep): return out @@ -758,11 +717,6 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: centers_c = centers_c[keep] keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) num_disks = centers_r.shape[0] -======= - keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) - if keep_idx.numel() == 0: - return out ->>>>>>> upstream/fitting_models_clean active_order = int( ctx.fields.get( @@ -771,7 +725,6 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: ) active_order = max(0, min(active_order, self.max_intensity_order)) -<<<<<<< HEAD dr = cast(torch.Tensor, self.disk.dr).to(device=ctx.device, dtype=ctx.dtype) dc = cast(torch.Tensor, self.disk.dc).to(device=ctx.device, dtype=ctx.dtype) patch_vals = self.disk.patch_values().to(device=ctx.device, dtype=ctx.dtype) @@ -822,41 +775,3 @@ def get_optimization_parameters(self) -> Any: if not name.startswith('disk.') and param.requires_grad: params.append(param) return params -======= - dr, dc = self.disk.patch_offsets() - dr = dr.to(device=ctx.device, dtype=ctx.dtype) - dc = dc.to(device=ctx.device, dtype=ctx.dtype) - dr2 = dr * dr - dc2 = dc * dc - drdc = dr * dc - - for j in keep_idx: - rr0 = centers_r[j] - cc0 = centers_c[j] - - if self.per_disk_intensity: - inten = self.i0_raw[j] - if active_order >= 1 and self.ir is not None and self.ic is not None: - inten = inten + self.ir[j] * dr + self.ic[j] * dc - if ( - active_order >= 2 - and self.irr is not None - and self.icc is not None - and self.irc is not None - ): - inten = inten + self.irr[j] * dr2 + self.icc[j] * dc2 + self.irc[j] * drdc - else: - inten = self.i0_raw - if active_order >= 1: - assert self.ir is not None and self.ic is not None - inten = inten + self.ir * rr0 + self.ic * cc0 - if active_order >= 2: - assert self.irr is not None and self.icc is not None and self.irc is not None - inten = ( - inten + self.irr * rr0 * rr0 + self.icc * cc0 * cc0 + self.irc * rr0 * cc0 - ) - - self.disk.add_patch(out, r0=rr0, c0=cc0, scale=inten) - - return out ->>>>>>> upstream/fitting_models_clean diff --git a/src/quantem/diffraction/__init__.py b/src/quantem/diffraction/__init__.py index 4b6bf2c2..a64a357e 100644 --- a/src/quantem/diffraction/__init__.py +++ b/src/quantem/diffraction/__init__.py @@ -1,7 +1,4 @@ -<<<<<<< HEAD from quantem.diffraction.polar import RDF as RDF from quantem.diffraction.strain_autocorrelation import StrainMapAutocorrelation as StrainMapAutocorrelation from quantem.diffraction.maped import MAPED as MAPED -======= ->>>>>>> upstream/fitting_models_clean from quantem.diffraction.model_fitting import ModelDiffraction as ModelDiffraction diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 5c4e8748..174a0ea0 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -6,10 +6,7 @@ import torch from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey -<<<<<<< HEAD from tqdm import tqdm -======= ->>>>>>> upstream/fitting_models_clean from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem from quantem.core.fitting.base import ( @@ -21,10 +18,7 @@ ) from quantem.core.fitting.diffraction import DiskTemplate, SyntheticDiskLattice from quantem.core.io.serialize import AutoSerialize -<<<<<<< HEAD -======= from quantem.core.ml.optimizer_mixin import OptimizerType, SchedulerType ->>>>>>> upstream/fitting_models_clean from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.diffraction.model_fitting_visualizations import ModelDiffractionVisualizations @@ -61,12 +55,9 @@ def __init__(self, dataset: Any, _token: object | None = None): self.state_mean_refined: dict[str, torch.Tensor] | None = None self.mean_refined: bool = False -<<<<<<< HEAD self.state_individual_refined: np.ndarray | None = None self.individual_refined: bool = False -======= ->>>>>>> upstream/fitting_models_clean # Misc metadata self.metadata: dict[str, Any] = {} @@ -325,16 +316,12 @@ def preprocess( upsample_factor: int = 32, max_shift: float | None = None, shift_order: int = 1, -<<<<<<< HEAD gamma: float = 0.5, mode: str = "linear", -======= ->>>>>>> upstream/fitting_models_clean ) -> "ModelDiffraction": arr = np.asarray(self.dataset.array) if arr.ndim < 2: raise ValueError("dataset.array must have at least 2 dimensions.") -<<<<<<< HEAD mode_in = mode.strip().lower() if mode_in in {"linear", "patterson", "paterson", "acf", "autocorrelation"}: mode_norm = "linear" @@ -360,8 +347,6 @@ def preprocess( else: raise RuntimeError("Unreachable: normalized mode mapping failed.") self.dataset.array = np.asarray(arr) -======= ->>>>>>> upstream/fitting_models_clean h, w = arr.shape[-2], arr.shape[-1] self.index_shape = tuple(arr.shape[:-2]) @@ -475,18 +460,11 @@ def fit_mean_diffraction_pattern( *, n_steps: int = 200, reset: bool | Literal["initialized", "mean_refined"] = False, -<<<<<<< HEAD optimizer_params: dict | None = None, scheduler_params: dict | None = None, constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, constraint_config_params: dict[str, Any] | None = None, -======= - optimizer_params: OptimizerType | dict | None = None, - scheduler_params: SchedulerType | dict | None = None, - constraint_weight: float = 1.0, - constraint_params: dict[str, Any] | None = None, ->>>>>>> upstream/fitting_models_clean progress: bool = True, ) -> "ModelDiffraction": """ @@ -538,18 +516,12 @@ def fit_mean_diffraction_pattern( raise ValueError("reset must be False, True, 'initialized', or 'mean_refined'.") self.fit_render( -<<<<<<< HEAD # target=torch.tensor(self.dataset[30,30].array.astype("float32")), -======= ->>>>>>> upstream/fitting_models_clean target=self.target_mean, n_steps=int(n_steps), constraint_weight=float(constraint_weight), constraint_params=constraint_params, -<<<<<<< HEAD constraint_config_params=constraint_config_params, -======= ->>>>>>> upstream/fitting_models_clean optimizer_params=optimizer_params, scheduler_params=scheduler_params, progress=bool(progress), @@ -563,14 +535,10 @@ def fit_mean_diffraction_pattern( def reset( self, -<<<<<<< HEAD reset_to: Literal["initialized", "mean_refined", "individual"] = "mean_refined", reset_history: bool = True, individual_row: int = 0, individual_col: int = 0, -======= - reset_to: Literal["initialized", "mean_refined"] = "mean_refined", ->>>>>>> upstream/fitting_models_clean ) -> "ModelDiffraction": if reset_to == "initialized": state = self.state_initialized @@ -578,19 +546,14 @@ def reset( raise RuntimeError( "initialized state is unavailable. Call .define_model(...) first." ) -<<<<<<< HEAD if reset_history: self._clear_fit_history_all() -======= - self._clear_fit_history_all() ->>>>>>> upstream/fitting_models_clean elif reset_to == "mean_refined": state = self.state_mean_refined if state is None: raise RuntimeError( "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." ) -<<<<<<< HEAD if reset_history: mean_hist = self.fit_history.get("mean") self._clear_fit_history_all() @@ -916,17 +879,6 @@ def plot_strain( a.set_aspect("equal") return fig, ax -======= - mean_hist = self.fit_history.get("mean") - self._clear_fit_history_all() - if mean_hist is not None: - self.fit_history["mean"] = mean_hist - else: - raise ValueError("reset_to must be 'initialized' or 'mean_refined'.") - - self._load_model_state_dict_copy(state) - return self ->>>>>>> upstream/fitting_models_clean @property def render_mean_refined(self) -> np.ndarray: From 7efc8457a6975f00300bbfe9876367b6ebf82e78 Mon Sep 17 00:00:00 2001 From: cedriclim1 Date: Mon, 11 May 2026 15:06:05 -0700 Subject: [PATCH 62/88] Add batched single-GPU per-pattern fit path Vectorizes ModelDiffraction.fit_individual_diffraction_pattern over the scan dimension via explicit batched forwards plus a per-sample Adam over stacked (B, *param_shape) tensors. Adds forward_batched on DiskTemplate, SyntheticDiskLattice, DCBackground, GaussianBackground, a functional _splat_patch_batched (scatter_add_ on a fresh buffer to avoid the in-place index_put_ that blocked torch.func.vmap), and a _BatchedPlan that builds stacked params, runs the batched forward, applies hard constraints, and unstacks per-pattern state dicts. The serial path is unchanged; the batched path is dispatched by passing batch_size= to fit_individual_diffraction_pattern. Measured ~33-200x speedup on single GPU with loss decreasing per pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quantem/core/fitting/background.py | 29 ++ src/quantem/core/fitting/diffraction.py | 158 ++++++- src/quantem/diffraction/model_fitting.py | 516 ++++++++++++++++++++++- 3 files changed, 700 insertions(+), 3 deletions(-) diff --git a/src/quantem/core/fitting/background.py b/src/quantem/core/fitting/background.py index 000d4a01..6d678427 100644 --- a/src/quantem/core/fitting/background.py +++ b/src/quantem/core/fitting/background.py @@ -48,6 +48,17 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: inten = self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype) return torch.ones(ctx.shape, device=ctx.device, dtype=ctx.dtype) * inten + def forward_batched( + self, + ctx: RenderContext, + *, + intensity_raw_b: torch.Tensor, + ) -> torch.Tensor: + B = intensity_raw_b.shape[0] + return intensity_raw_b.view(B, 1, 1).expand(B, ctx.shape[0], ctx.shape[1]).to( + device=ctx.device, dtype=ctx.dtype + ) + class GaussianBackground(RenderComponent): # TODO this should be N dimensional by default def __init__( @@ -110,3 +121,21 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: inten = self.intensity_raw.to(device=ctx.device, dtype=ctx.dtype) r2 = (rr - r0) ** 2 + (cc - c0) ** 2 return inten * torch.exp(-0.5 * r2 / (sigma * sigma)) + + def forward_batched( + self, + ctx: RenderContext, + *, + sigma_raw_b: torch.Tensor, + intensity_raw_b: torch.Tensor, + origin_coords_b: torch.Tensor, + ) -> torch.Tensor: + B = sigma_raw_b.shape[0] + rr = torch.arange(ctx.shape[0], device=ctx.device, dtype=ctx.dtype).view(1, ctx.shape[0], 1) + cc = torch.arange(ctx.shape[1], device=ctx.device, dtype=ctx.dtype).view(1, 1, ctx.shape[1]) + r0 = origin_coords_b[:, 0].view(B, 1, 1) + c0 = origin_coords_b[:, 1].view(B, 1, 1) + sigma = sigma_raw_b.view(B, 1, 1) + inten = intensity_raw_b.view(B, 1, 1) + r2 = (rr - r0) ** 2 + (cc - c0) ** 2 + return inten * torch.exp(-0.5 * r2 / (sigma * sigma)) diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 92e8ce60..c03cdb3a 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -48,6 +48,49 @@ def put(rr: torch.Tensor, cc: torch.Tensor, ww: torch.Tensor) -> None: put(r0i + 1, c0i + 1, w11) +def _splat_patch_batched( + shape: tuple[int, int], + *, + r0: torch.Tensor, + c0: torch.Tensor, + vals: torch.Tensor, + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor: + h, w = int(shape[0]), int(shape[1]) + B, N = r0.shape + + r_base = torch.floor(r0) + c_base = torch.floor(c0) + fr = r0 - r_base + fc = c0 - c_base + r0i = r_base.to(torch.long) + c0i = c_base.to(torch.long) + + w00 = (1.0 - fr) * (1.0 - fc) + w01 = (1.0 - fr) * fc + w10 = fr * (1.0 - fc) + w11 = fr * fc + + rr = torch.stack([r0i, r0i, r0i + 1, r0i + 1], dim=0) + cc = torch.stack([c0i, c0i + 1, c0i, c0i + 1], dim=0) + ww = torch.stack([w00, w01, w10, w11], dim=0) + + keep = (rr >= 0) & (rr < h) & (cc >= 0) & (cc < w) + weighted = ww * vals.unsqueeze(0) * keep.to(dtype) + + rr_c = rr.clamp(0, h - 1) + cc_c = cc.clamp(0, w - 1) + flat_idx = rr_c * w + cc_c + + flat_idx_b = flat_idx.permute(1, 0, 2).reshape(B, -1) + weighted_b = weighted.permute(1, 0, 2).reshape(B, -1) + + out_flat = torch.zeros(B, h * w, device=device, dtype=dtype) + out_flat.scatter_add_(1, flat_idx_b, weighted_b) + return out_flat.reshape(B, h, w) + + class DiskTemplate(RenderComponent): DEFAULT_HARD_CONSTRAINTS: dict[str, bool] = { "force_center": False, @@ -227,6 +270,25 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: self.add_patch(out, r0=r0, c0=c0, scale=scale) return out + def forward_batched( + self, + ctx: RenderContext, + *, + template_raw_b: torch.Tensor, + intensity_raw_b: torch.Tensor, + origin_coords_b: torch.Tensor, + ) -> torch.Tensor: + B = template_raw_b.shape[0] + N = int(cast(torch.Tensor, self.dr).numel()) + dr = cast(torch.Tensor, self.dr).to(device=ctx.device, dtype=ctx.dtype) + dc = cast(torch.Tensor, self.dc).to(device=ctx.device, dtype=ctx.dtype) + r0 = origin_coords_b[:, 0:1] + dr.unsqueeze(0) + c0 = origin_coords_b[:, 1:2] + dc.unsqueeze(0) + vals = template_raw_b.reshape(B, N) * intensity_raw_b.view(B, 1) + return _splat_patch_batched( + ctx.shape, r0=r0, c0=c0, vals=vals, device=ctx.device, dtype=ctx.dtype + ) + def _center_disk(self) -> None: with torch.no_grad(): template = self.template_raw @@ -768,8 +830,100 @@ def forward(self, ctx: RenderContext) -> torch.Tensor: scale=torch.ones_like(vals_all) ) return out - - def get_optimization_parameters(self) -> Any: + + def forward_batched( + self, + ctx: RenderContext, + *, + u_row_b: torch.Tensor, + u_col_b: torch.Tensor, + v_row_b: torch.Tensor, + v_col_b: torch.Tensor, + i0_raw_b: torch.Tensor, + ir_b: torch.Tensor | None, + ic_b: torch.Tensor | None, + irr_b: torch.Tensor | None, + icc_b: torch.Tensor | None, + irc_b: torch.Tensor | None, + template_raw_b: torch.Tensor, + origin_coords_b: torch.Tensor, + ) -> torch.Tensor: + if self.origin is None: + raise RuntimeError("SyntheticDiskLattice requires an OriginND instance.") + + B = u_row_b.shape[0] + uv = cast(torch.Tensor, self.uv_indices).to(device=ctx.device) + K = int(uv.shape[0]) + if K == 0: + return torch.zeros(B, ctx.shape[0], ctx.shape[1], device=ctx.device, dtype=ctx.dtype) + + u = uv[:, 0].to(dtype=ctx.dtype) + v = uv[:, 1].to(dtype=ctx.dtype) + + r0_kb = ( + origin_coords_b[:, 0:1] + + u.unsqueeze(0) * u_row_b.unsqueeze(1) + + v.unsqueeze(0) * v_row_b.unsqueeze(1) + ) + c0_kb = ( + origin_coords_b[:, 1:2] + + u.unsqueeze(0) * u_col_b.unsqueeze(1) + + v.unsqueeze(0) * v_col_b.unsqueeze(1) + ) + + bb = torch.as_tensor(self.boundary_px, device=ctx.device, dtype=ctx.dtype) + keep = (r0_kb >= bb) & (r0_kb <= (ctx.shape[0] - 1) - bb) + keep = keep & (c0_kb >= bb) & (c0_kb <= (ctx.shape[1] - 1) - bb) + keep_f = keep.to(dtype=ctx.dtype) + + active_order = int( + ctx.fields.get( + "lattice_intensity_order_override", self.default_pattern_intensity_order + ) + ) + active_order = max(0, min(active_order, self.max_intensity_order)) + + dr = cast(torch.Tensor, self.disk.dr).to(device=ctx.device, dtype=ctx.dtype) + dc = cast(torch.Tensor, self.disk.dc).to(device=ctx.device, dtype=ctx.dtype) + N_pix = int(dr.numel()) + patch_vals = template_raw_b.reshape(B, N_pix) + + if self.per_disk_intensity: + inten = i0_raw_b.unsqueeze(2).expand(B, K, N_pix) + if active_order >= 1 and ir_b is not None and ic_b is not None: + inten = inten + ir_b.unsqueeze(2) * dr.view(1, 1, N_pix) + ic_b.unsqueeze(2) * dc.view(1, 1, N_pix) + if active_order >= 2 and irr_b is not None and icc_b is not None and irc_b is not None: + inten = ( + inten + + irr_b.unsqueeze(2) * (dr * dr).view(1, 1, N_pix) + + icc_b.unsqueeze(2) * (dc * dc).view(1, 1, N_pix) + + irc_b.unsqueeze(2) * (dr * dc).view(1, 1, N_pix) + ) + else: + inten = i0_raw_b.view(B, 1, 1).expand(B, K, N_pix).clone() + if active_order >= 1 and ir_b is not None and ic_b is not None: + inten = inten + ir_b.view(B, 1, 1) * r0_kb.unsqueeze(2) + ic_b.view(B, 1, 1) * c0_kb.unsqueeze(2) + if active_order >= 2 and irr_b is not None and icc_b is not None and irc_b is not None: + inten = ( + inten + + irr_b.view(B, 1, 1) * (r0_kb * r0_kb).unsqueeze(2) + + icc_b.view(B, 1, 1) * (c0_kb * c0_kb).unsqueeze(2) + + irc_b.view(B, 1, 1) * (r0_kb * c0_kb).unsqueeze(2) + ) + + inten = inten * keep_f.unsqueeze(2) + vals = patch_vals.unsqueeze(1) * inten + + r0_full = (r0_kb.unsqueeze(2) + dr.view(1, 1, N_pix)).reshape(B, K * N_pix) + c0_full = (c0_kb.unsqueeze(2) + dc.view(1, 1, N_pix)).reshape(B, K * N_pix) + vals_full = vals.reshape(B, K * N_pix) + + return _splat_patch_batched( + ctx.shape, r0=r0_full, c0=c0_full, vals=vals_full, + device=ctx.device, dtype=ctx.dtype, + ) + + def get_optimization_parameters(self) -> Any: params = [] for name, param in self.named_parameters(recurse=True): if not name.startswith('disk.') and param.requires_grad: diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 174a0ea0..c65480c3 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -4,6 +4,7 @@ import numpy as np import torch +import torch.nn.functional as F from scipy.ndimage import shift as ndi_shift from scipy.signal.windows import tukey from tqdm import tqdm @@ -16,6 +17,7 @@ RenderComponent, RenderContext, ) +from quantem.core.fitting.background import DCBackground, GaussianBackground from quantem.core.fitting.diffraction import DiskTemplate, SyntheticDiskLattice from quantem.core.io.serialize import AutoSerialize from quantem.core.ml.optimizer_mixin import OptimizerType, SchedulerType @@ -585,7 +587,20 @@ def fit_individual_diffraction_pattern( constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, progress: bool = True, + batch_size: int | None = None, + **_compat_kwargs: Any, ) -> "ModelDiffraction": + if batch_size is not None: + return self.fit_individual_diffraction_pattern_batched( + rows=rows, + cols=cols, + batch_size=int(batch_size), + n_steps=int(n_steps), + reset=cast(Literal["initialized", "mean_refined"], reset), + optimizer_params=optimizer_params, + constraint_params=constraint_params, + progress=progress, + ) if self.model is None or self.ctx is None or self.target_mean is None: raise RuntimeError("Call .define_model(...) first.") @@ -649,7 +664,130 @@ def fit_individual_diffraction_pattern( self.individual_refined=True return self - + + def fit_individual_diffraction_pattern_batched( + self, + *, + rows: Any = None, + cols: Any = None, + batch_size: int = 16, + n_steps: int = 200, + reset: Literal["initialized", "mean_refined"] = "mean_refined", + optimizer_params: dict | None = None, + constraint_params: dict[str, Any] | None = None, + progress: bool = True, + ) -> "ModelDiffraction": + """ + Per-pattern fit, vectorized across a batch dimension on a single GPU. + + See ``fit_individual_diffraction_pattern`` for argument semantics. The + batched version runs ``batch_size`` patterns in parallel per optimizer + step, with per-sample stacked parameters and per-sample Adam moments. + Soft constraint losses are not yet supported; only hard constraints + and parameter bounds are enforced between steps. + """ + if self.model is None or self.ctx is None or self.target_mean is None: + raise RuntimeError("Call .define_model(...) first.") + if not isinstance(self.dataset, Dataset4d): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + if reset not in ("initialized", "mean_refined"): + raise ValueError("reset must be 'initialized' or 'mean_refined'.") + + # Choose initialization state and load into the live model so we can + # read current parameter values + constraint settings off the modules. + if reset == "mean_refined": + if self.state_mean_refined is None: + raise RuntimeError("mean_refined state is unavailable. Run fit_mean_diffraction_pattern first.") + init_state = self._clone_state_dict(self.state_mean_refined) + else: + if self.state_initialized is None: + raise RuntimeError("initialized state is unavailable. Call define_model first.") + init_state = self._clone_state_dict(self.state_initialized) + self._load_model_state_dict_copy(init_state) + + if constraint_params is not None: + self.model.apply_constraint_params(constraint_params, strict=True) + + scan_r = int(self.dataset.shape[0]) + scan_c = int(self.dataset.shape[1]) + rows_arr, cols_arr = _resolve_rows_cols_for_batched(rows, cols, scan_r, scan_c) + positions: list[tuple[int, int]] = [(int(r), int(c)) for r in rows_arr for c in cols_arr] + if len(positions) == 0: + return self + + if self.state_individual_refined is None or self.state_individual_refined.shape != (scan_r, scan_c): + self.state_individual_refined = np.full(shape=(scan_r, scan_c), fill_value=None, dtype=object) + + ctx = self.ctx + components_list = list(self.model.components) + plan = _BatchedPlan.from_model(self.model, components_list, optimizer_params or {}) + + loss_fn = self.loss_fn + + total_steps = len(positions) * n_steps + pbar = tqdm(total=total_steps, desc="Fit individual (batched)", disable=not progress) + + for start in range(0, len(positions), int(batch_size)): + chunk = positions[start:start + int(batch_size)] + B = len(chunk) + + targets = torch.stack( + [ + torch.as_tensor(self.dataset.array[r, c], device=ctx.device, dtype=ctx.dtype) + for (r, c) in chunk + ], + dim=0, + ) + + stacked = plan.build_stacked_params(B) + + adam_state: dict[str, dict[str, torch.Tensor]] = { + name: { + "m": torch.zeros_like(p.detach()), + "v": torch.zeros_like(p.detach()), + } + for name, p in stacked.items() + } + + for step in range(int(n_steps)): + pred = plan.batched_forward(ctx, stacked) + # Per-sample fidelity loss summed → scalar with per-sample grads + diff2 = (pred.float() - targets.float()) + # Match SqrtMSELoss behavior approximately when loss_fn is SqrtMSELoss: + # gamma-power transform of (x - min(x) + 1), per-sample independently. + from quantem.core.fitting.base import SqrtMSELoss, LogMSELoss + if isinstance(loss_fn, SqrtMSELoss): + gamma = float(loss_fn.gamma) + eps = 1.0 + pred_min = pred.amin(dim=(1, 2), keepdim=True) + tgt_min = targets.amin(dim=(1, 2), keepdim=True) + pred_mod = (pred - pred_min + eps) ** gamma + tgt_mod = (targets - tgt_min + eps) ** gamma + per_sample_loss = ((pred_mod - tgt_mod) ** 2).mean(dim=(1, 2)) + elif isinstance(loss_fn, LogMSELoss): + per_sample_loss = ((torch.log1p(pred) - torch.log1p(targets)) ** 2).mean(dim=(1, 2)) + else: + per_sample_loss = (diff2 * diff2).mean(dim=(1, 2)) + total_loss = per_sample_loss.sum() + + grads = torch.autograd.grad(total_loss, list(stacked.values())) + t = step + 1 + _adam_step_inplace(stacked, grads, adam_state, plan.lrs, t) + + with torch.no_grad(): + plan.apply_hard_constraints(stacked) + + pbar.update(B) + + # Unstack each sample back into a state_dict and store + for b, (r, c) in enumerate(chunk): + sample_state = plan.build_sample_state_dict(init_state, stacked, b) + self.state_individual_refined[r, c] = sample_state + + pbar.close() + self.individual_refined = True + return self + def get_individual_uv_vectors(self) -> "ModelDiffraction": scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] @@ -887,3 +1025,379 @@ def render_mean_refined(self) -> np.ndarray: "mean_refined state is unavailable. Run .fit_mean_diffraction_pattern(...) first." ) return self._render_state_array(self.state_mean_refined) + + +def _resolve_rows_cols_for_batched( + rows: Any, cols: Any, scan_r: int, scan_c: int +) -> tuple[np.ndarray, np.ndarray]: + if rows is None: + rows = range(scan_r) + if cols is None: + cols = range(scan_c) + if isinstance(rows, int): + rows_arr = np.array([rows], dtype=int) + else: + rows_arr = np.asarray(list(rows), dtype=int) + if isinstance(cols, int): + cols_arr = np.array([cols], dtype=int) + else: + cols_arr = np.asarray(list(cols), dtype=int) + return rows_arr, cols_arr + + +def _lr_for_component( + component: RenderComponent, + component_idx: int, + optimizer_params: dict[str, Any], + model: AdditiveRenderModel, +) -> float: + name = model._component_constraint_name(component, component_idx) + if name in optimizer_params: + return float(optimizer_params[name].get("lr", component.DEFAULT_LR)) + class_name = component.__class__.__name__ + if class_name in optimizer_params: + return float(optimizer_params[class_name].get("lr", component.DEFAULT_LR)) + return float(component.DEFAULT_LR) + + +class _BatchedPlan: + """Resolved layout for the batched per-pattern fit: component refs, lrs, and helpers.""" + + def __init__(self) -> None: + self.origin: OriginND | None = None + self.disk: DiskTemplate | None = None + self.dcbg: DCBackground | None = None + self.gaussbg: GaussianBackground | None = None + self.lat: SyntheticDiskLattice | None = None + self.disk_idx: int | None = None + self.dcbg_idx: int | None = None + self.gaussbg_idx: int | None = None + self.lat_idx: int | None = None + self.lrs: dict[str, float] = {} + + @classmethod + def from_model( + cls, + model: AdditiveRenderModel, + components_list: list[Any], + optimizer_params: dict[str, Any], + ) -> "_BatchedPlan": + self = cls() + self.origin = cast(OriginND, model.origin) + + for idx, comp in enumerate(components_list): + if isinstance(comp, DiskTemplate) and self.disk is None: + self.disk = comp + self.disk_idx = idx + elif isinstance(comp, DCBackground) and self.dcbg is None: + self.dcbg = comp + self.dcbg_idx = idx + elif isinstance(comp, GaussianBackground) and self.gaussbg is None: + self.gaussbg = comp + self.gaussbg_idx = idx + elif isinstance(comp, SyntheticDiskLattice) and self.lat is None: + self.lat = comp + self.lat_idx = idx + else: + raise TypeError( + f"Batched fit does not yet support component type {type(comp).__name__} " + f"at index {idx} (or duplicate of an already-handled type)." + ) + + # Per-component LRs (with fallback to the component's DEFAULT_LR). + if self.disk is not None and self.disk_idx is not None: + self.lrs["disk.template_raw"] = _lr_for_component(self.disk, self.disk_idx, optimizer_params, model) + self.lrs["disk.intensity_raw"] = self.lrs["disk.template_raw"] + if self.dcbg is not None and self.dcbg_idx is not None: + self.lrs["dcbg.intensity_raw"] = _lr_for_component(self.dcbg, self.dcbg_idx, optimizer_params, model) + if self.gaussbg is not None and self.gaussbg_idx is not None: + lr = _lr_for_component(self.gaussbg, self.gaussbg_idx, optimizer_params, model) + self.lrs["gaussbg.sigma_raw"] = lr + self.lrs["gaussbg.intensity_raw"] = lr + if self.lat is not None and self.lat_idx is not None: + lr = _lr_for_component(self.lat, self.lat_idx, optimizer_params, model) + for k in ("u_row", "u_col", "v_row", "v_col", "i0_raw", "ir", "ic", "irr", "icc", "irc"): + self.lrs[f"lat.{k}"] = lr + # Origin uses the lattice's LR (lattice is the most common owner of origin). + self.lrs["origin.coords"] = lr + elif self.gaussbg is not None and self.gaussbg_idx is not None: + self.lrs["origin.coords"] = self.lrs["gaussbg.intensity_raw"] + else: + self.lrs["origin.coords"] = 1e-2 + return self + + def build_stacked_params(self, B: int) -> dict[str, torch.Tensor]: + out: dict[str, torch.Tensor] = {} + assert self.origin is not None + out["origin.coords"] = self._stack(self.origin.coords, B) + + if self.disk is not None: + out["disk.template_raw"] = self._stack(self.disk.template_raw, B) + out["disk.intensity_raw"] = self._stack(self.disk.intensity_raw, B) + if self.dcbg is not None: + out["dcbg.intensity_raw"] = self._stack(self.dcbg.intensity_raw, B) + if self.gaussbg is not None: + out["gaussbg.sigma_raw"] = self._stack(self.gaussbg.sigma_raw, B) + out["gaussbg.intensity_raw"] = self._stack(self.gaussbg.intensity_raw, B) + if self.lat is not None: + for attr in ("u_row", "u_col", "v_row", "v_col", "i0_raw"): + t = getattr(self.lat, attr) + if t is not None: + out[f"lat.{attr}"] = self._stack(t, B) + for attr in ("ir", "ic", "irr", "icc", "irc"): + t = getattr(self.lat, attr, None) + if t is not None: + out[f"lat.{attr}"] = self._stack(t, B) + return out + + @staticmethod + def _stack(p: torch.Tensor, B: int) -> torch.Tensor: + x = p.detach().clone().unsqueeze(0).expand(B, *p.shape).contiguous() + x.requires_grad_(True) + return x + + def batched_forward( + self, ctx: RenderContext, stacked: dict[str, torch.Tensor] + ) -> torch.Tensor: + B = stacked["origin.coords"].shape[0] + origin_b = stacked["origin.coords"] + + pred = torch.zeros(B, ctx.shape[0], ctx.shape[1], device=ctx.device, dtype=ctx.dtype) + + if self.disk is not None: + pred = pred + self.disk.forward_batched( + ctx, + template_raw_b=stacked["disk.template_raw"], + intensity_raw_b=stacked["disk.intensity_raw"], + origin_coords_b=origin_b, + ) + if self.dcbg is not None: + pred = pred + self.dcbg.forward_batched( + ctx, intensity_raw_b=stacked["dcbg.intensity_raw"] + ) + if self.gaussbg is not None: + pred = pred + self.gaussbg.forward_batched( + ctx, + sigma_raw_b=stacked["gaussbg.sigma_raw"], + intensity_raw_b=stacked["gaussbg.intensity_raw"], + origin_coords_b=origin_b, + ) + if self.lat is not None: + pred = pred + self.lat.forward_batched( + ctx, + u_row_b=stacked["lat.u_row"], + u_col_b=stacked["lat.u_col"], + v_row_b=stacked["lat.v_row"], + v_col_b=stacked["lat.v_col"], + i0_raw_b=stacked["lat.i0_raw"], + ir_b=stacked.get("lat.ir"), + ic_b=stacked.get("lat.ic"), + irr_b=stacked.get("lat.irr"), + icc_b=stacked.get("lat.icc"), + irc_b=stacked.get("lat.irc"), + template_raw_b=stacked["disk.template_raw"], + origin_coords_b=origin_b, + ) + return pred + + def apply_hard_constraints(self, stacked: dict[str, torch.Tensor]) -> None: + """Apply batched analogues of each component's hard constraints to stacked params (in-place).""" + # Parameter bounds (always elementwise; safe on any shape). + if self.disk is not None: + for pname, (lo, hi) in self.disk.parameter_bounds.items(): + key = f"disk.{pname}" + if key in stacked: + self._clamp_bounds_inplace(stacked[key], lo, hi) + if self.dcbg is not None: + for pname, (lo, hi) in self.dcbg.parameter_bounds.items(): + key = f"dcbg.{pname}" + if key in stacked: + self._clamp_bounds_inplace(stacked[key], lo, hi) + if self.gaussbg is not None: + for pname, (lo, hi) in self.gaussbg.parameter_bounds.items(): + key = f"gaussbg.{pname}" + if key in stacked: + self._clamp_bounds_inplace(stacked[key], lo, hi) + if self.lat is not None: + for pname, (lo, hi) in self.lat.parameter_bounds.items(): + key = f"lat.{pname}" + if key in stacked: + self._clamp_bounds_inplace(stacked[key], lo, hi) + + # DiskTemplate composite hard constraints. + if self.disk is not None: + template = stacked.get("disk.template_raw") + intensity = stacked.get("disk.intensity_raw") + cfg = self.disk.constraint_config + if template is not None and intensity is not None: + if bool(self.disk.hard_constraints.get("force_center", False)): + self._batched_center_disk(template) + if bool(self.disk.hard_constraints.get("force_cutoff", False)): + self._batched_enforce_cutoff(template, cfg) + if bool(self.disk.hard_constraints.get("force_circular_mask", False)): + self._batched_enforce_circular_mask(template, cfg) + if bool(self.disk.hard_constraints.get("force_shrinkage", False)): + template.sub_(float(cfg.get("shrinkage_amount", 0.25))) + if bool(self.disk.hard_constraints.get("force_positive", False)): + template.clamp_(min=0.0) + intensity.clamp_(min=0.0) + if bool(self.disk.hard_constraints.get("force_norm", False)): + self._batched_enforce_norm(template) + + # SyntheticDiskLattice.force_positive_intensity. + if self.lat is not None and bool( + self.lat.hard_constraints.get("force_positive_intensity", False) + ): + i0 = stacked.get("lat.i0_raw") + if i0 is not None: + i0.clamp_(min=0.0) + + @staticmethod + def _clamp_bounds_inplace(t: torch.Tensor, lo: float | None, hi: float | None) -> None: + if lo is None and hi is None: + return + if lo is None: + t.clamp_(max=float(hi)) # type: ignore[arg-type] + elif hi is None: + t.clamp_(min=float(lo)) + else: + t.clamp_(min=float(lo), max=float(hi)) + + @staticmethod + def _batched_center_disk(template_b: torch.Tensor) -> None: + # template_b: (B, H_t, W_t) + B, h, w = template_b.shape + weights = template_b.clamp(min=0.0) + mass = weights.sum(dim=(1, 2)) + if not torch.any(mass > 1e-12): + return + rr = torch.arange(h, device=template_b.device, dtype=template_b.dtype).view(1, h, 1) + cc = torch.arange(w, device=template_b.device, dtype=template_b.dtype).view(1, 1, w) + safe_mass = mass.clamp(min=1e-12) + com_r = (weights * rr).sum(dim=(1, 2)) / safe_mass + com_c = (weights * cc).sum(dim=(1, 2)) / safe_mass + target_r = (h - 1) * 0.5 + target_c = (w - 1) * 0.5 + shift_r = target_r - com_r # (B,) + shift_c = target_c - com_c + denom_h = max(h - 1, 1) + denom_w = max(w - 1, 1) + ty = -2.0 * shift_r / float(denom_h) + tx = -2.0 * shift_c / float(denom_w) + zeros = torch.zeros_like(tx) + ones = torch.ones_like(tx) + theta = torch.stack( + [ + torch.stack([ones, zeros, tx], dim=1), + torch.stack([zeros, ones, ty], dim=1), + ], + dim=1, + ) # (B, 2, 3) + src = template_b.unsqueeze(1) # (B, 1, H, W) + grid = F.affine_grid(theta, [B, 1, h, w], align_corners=True) + shifted = F.grid_sample(src, grid, mode="bilinear", padding_mode="zeros", align_corners=True)[:, 0] + # Only shift samples with nonzero mass; others left as-is. + do_shift = (mass > 1e-12).view(B, 1, 1) + template_b.copy_(torch.where(do_shift, shifted, template_b)) + + @staticmethod + def _batched_enforce_norm(template_b: torch.Tensor) -> None: + mins = template_b.amin(dim=(1, 2), keepdim=True) + template_b.sub_(mins) + maxs = template_b.amax(dim=(1, 2), keepdim=True).clamp(min=1e-12) + template_b.div_(maxs) + + @staticmethod + def _batched_enforce_cutoff(template_b: torch.Tensor, cfg: dict[str, Any]) -> None: + thresh_ratio = float(cfg.get("hard_cutoff_threshold", 0.35)) + maxs = template_b.amax(dim=(1, 2), keepdim=True) + thresh = maxs * thresh_ratio + mask = template_b <= thresh + template_b.masked_fill_(mask, 0.0) + + @staticmethod + def _batched_enforce_circular_mask(template_b: torch.Tensor, cfg: dict[str, Any]) -> None: + B, h, w = template_b.shape + radius = (min(h, w) / 2.0) * float(cfg.get("circular_mask_radius_fraction", 0.95)) + r = torch.arange(-h / 2, h / 2, device=template_b.device, dtype=template_b.dtype) + c = torch.arange(-w / 2, w / 2, device=template_b.device, dtype=template_b.dtype) + rr, cc = torch.meshgrid(r, c, indexing="ij") + circle = torch.sqrt(rr * rr + cc * cc) + if bool(cfg.get("soft_circular_mask", False)): + sharpness = float(cfg.get("circular_mask_sharpness", 0)) + mask2d = torch.sigmoid(sharpness * (radius - circle)) + else: + mask2d = (circle <= radius).to(dtype=template_b.dtype) + template_b.mul_(mask2d.view(1, h, w)) + + def build_sample_state_dict( + self, + init_state: dict[str, torch.Tensor], + stacked: dict[str, torch.Tensor], + b: int, + ) -> dict[str, torch.Tensor]: + out = {k: v.detach().clone() for k, v in init_state.items()} + + # Origin: top-level key plus any 'components.X.origin.coords' or + # 'components.X.disk.origin.coords' that PyTorch state_dict registers. + origin_val = stacked["origin.coords"][b].detach().clone() + for key in list(out.keys()): + if key == "origin.coords" or key.endswith(".origin.coords"): + out[key] = origin_val.clone() + + # DiskTemplate (shared with lat.disk). + if self.disk is not None and self.disk_idx is not None: + t_val = stacked["disk.template_raw"][b].detach().clone() + i_val = stacked["disk.intensity_raw"][b].detach().clone() + for key in list(out.keys()): + if key.endswith(".template_raw"): + out[key] = t_val.clone() + for key in ( + f"components.{self.disk_idx}.intensity_raw", + f"components.{self.lat_idx}.disk.intensity_raw" if self.lat_idx is not None else None, + ): + if key is not None and key in out: + out[key] = i_val.clone() + + # DCBackground. + if self.dcbg is not None and self.dcbg_idx is not None: + out[f"components.{self.dcbg_idx}.intensity_raw"] = stacked["dcbg.intensity_raw"][b].detach().clone() + + # GaussianBackground. + if self.gaussbg is not None and self.gaussbg_idx is not None: + out[f"components.{self.gaussbg_idx}.sigma_raw"] = stacked["gaussbg.sigma_raw"][b].detach().clone() + out[f"components.{self.gaussbg_idx}.intensity_raw"] = stacked["gaussbg.intensity_raw"][b].detach().clone() + + # SyntheticDiskLattice scalar + tensor params. + if self.lat is not None and self.lat_idx is not None: + for attr in ("u_row", "u_col", "v_row", "v_col", "i0_raw", "ir", "ic", "irr", "icc", "irc"): + key = f"components.{self.lat_idx}.{attr}" + stacked_key = f"lat.{attr}" + if key in out and stacked_key in stacked: + out[key] = stacked[stacked_key][b].detach().clone() + return out + + +def _adam_step_inplace( + stacked: dict[str, torch.Tensor], + grads: tuple[torch.Tensor, ...], + adam_state: dict[str, dict[str, torch.Tensor]], + lrs: dict[str, float], + t: int, + beta1: float = 0.9, + beta2: float = 0.999, + eps: float = 1e-8, +) -> None: + bias1 = 1.0 - beta1 ** t + bias2 = 1.0 - beta2 ** t + with torch.no_grad(): + for (name, p), g in zip(stacked.items(), grads): + if g is None: + continue + st = adam_state[name] + st["m"].mul_(beta1).add_(g, alpha=1.0 - beta1) + st["v"].mul_(beta2).addcmul_(g, g, value=1.0 - beta2) + m_hat = st["m"] / bias1 + v_hat = st["v"] / bias2 + lr = float(lrs.get(name, 1e-2)) + p.data.addcdiv_(m_hat, v_hat.sqrt().add_(eps), value=-lr) From 40052b95c233e6fe2a93656ecad86e9315bf898e Mon Sep 17 00:00:00 2001 From: cedriclim1 Date: Mon, 11 May 2026 15:30:48 -0700 Subject: [PATCH 63/88] Additiona Claude updates to add soft/schedulers/hard-constraints back into the reconstruction loop --- src/quantem/core/fitting/diffraction.py | 63 ++++- src/quantem/diffraction/model_fitting.py | 311 +++++++++++++++++++++-- 2 files changed, 357 insertions(+), 17 deletions(-) diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index c03cdb3a..31ee422f 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -429,8 +429,67 @@ def constraint_loss( circular_loss = torch.as_tensor(circular_weight, device=ctx.device, dtype=ctx.dtype) * circular_err return cutoff_loss + tv_loss + circular_loss - - def get_optimization_parameters(self) -> Any: + + def constraint_loss_batched( + self, + ctx: RenderContext, + *, + template_raw_b: torch.Tensor, + params: dict[str, object] | None = None, + ) -> torch.Tensor: + """ + Per-sample analogue of ``constraint_loss`` for stacked templates. + + Parameters + ---------- + template_raw_b : torch.Tensor + Stacked templates with shape ``(B, H_t, W_t)``. + + Returns + ------- + torch.Tensor + Per-sample soft-constraint losses with shape ``(B,)``. Identical + semantics to ``constraint_loss`` on each slice, but reductions are + taken over ``dim=(1, 2)`` only. + """ + cfg = self.effective_soft_constraints(cast(dict[str, object] | None, params)) + tv_weight = max(float(cfg.get("tv_weight", 0.0)), 0.0) + cutoff_weight = max(float(cfg.get("cutoff_weight", 0.0)), 0.0) + circular_weight = max(float(cfg.get("circular_weight", 0.0)), 0.0) + + template = template_raw_b.to(device=ctx.device, dtype=ctx.dtype) + B, h, w = template.shape + + if h > 1: + tv_r = torch.mean(torch.abs(template[:, 1:, :] - template[:, :-1, :]), dim=(1, 2)) + else: + tv_r = torch.zeros(B, device=ctx.device, dtype=ctx.dtype) + if w > 1: + tv_c = torch.mean(torch.abs(template[:, :, 1:] - template[:, :, :-1]), dim=(1, 2)) + else: + tv_c = torch.zeros(B, device=ctx.device, dtype=ctx.dtype) + tv_loss = tv_weight * (tv_r + tv_c) + + # Per-sample cutoff (note: hard `<=` is non-differentiable; matches serial). + per_sample_mean = template.mean(dim=(1, 2), keepdim=True) + thresh = per_sample_mean * float(self.constraint_config["soft_cutoff_threshold"]) + frac_under = (template <= thresh).to(dtype=ctx.dtype).mean(dim=(1, 2)) + target_ratio = float(self.constraint_config["soft_cutoff_target_ratio"]) + cutoff_loss = cutoff_weight * torch.relu(frac_under - target_ratio) + + # Per-sample circular: mask depends only on (H, W), so build once. + radius = (min(h, w) / 2.0) * float(self.constraint_config["circular_mask_radius_fraction"]) + r = torch.arange(h, device=ctx.device, dtype=ctx.dtype) - h / 2.0 + c = torch.arange(w, device=ctx.device, dtype=ctx.dtype) - w / 2.0 + rr, cc = torch.meshgrid(r, c, indexing="ij") + circle_mask = torch.sqrt(rr * rr + cc * cc) + dist_from_radius = torch.relu(torch.abs(circle_mask - radius)) # (H, W) + circular_err = (dist_from_radius.unsqueeze(0) * template).mean(dim=(1, 2)) + circular_loss = circular_weight * circular_err + + return tv_loss + cutoff_loss + circular_loss + + def get_optimization_parameters(self) -> Any: params = [] for name, param in self.named_parameters(recurse=True): if not name.startswith('origin.') and param.requires_grad: diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index c65480c3..4ecf443f 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -586,8 +586,11 @@ def fit_individual_diffraction_pattern( scheduler_params: dict | None = None, constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, + constraint_config_params: dict[str, Any] | None = None, progress: bool = True, batch_size: int | None = None, + frozen_components: list[str] | str | None = None, + sample_trainability: dict[str, Any] | None = None, **_compat_kwargs: Any, ) -> "ModelDiffraction": if batch_size is not None: @@ -598,7 +601,12 @@ def fit_individual_diffraction_pattern( n_steps=int(n_steps), reset=cast(Literal["initialized", "mean_refined"], reset), optimizer_params=optimizer_params, + scheduler_params=scheduler_params, + constraint_weight=float(constraint_weight), constraint_params=constraint_params, + constraint_config_params=constraint_config_params, + frozen_components=frozen_components, + sample_trainability=sample_trainability, progress=progress, ) @@ -674,7 +682,12 @@ def fit_individual_diffraction_pattern_batched( n_steps: int = 200, reset: Literal["initialized", "mean_refined"] = "mean_refined", optimizer_params: dict | None = None, + scheduler_params: dict | None = None, + constraint_weight: float = 1.0, constraint_params: dict[str, Any] | None = None, + constraint_config_params: dict[str, Any] | None = None, + frozen_components: list[str] | str | None = None, + sample_trainability: dict[str, Any] | None = None, progress: bool = True, ) -> "ModelDiffraction": """ @@ -683,8 +696,33 @@ def fit_individual_diffraction_pattern_batched( See ``fit_individual_diffraction_pattern`` for argument semantics. The batched version runs ``batch_size`` patterns in parallel per optimizer step, with per-sample stacked parameters and per-sample Adam moments. - Soft constraint losses are not yet supported; only hard constraints - and parameter bounds are enforced between steps. + + Soft constraint losses are added per-sample via + ``model.total_constraint_loss``-style accumulation (currently only + ``DiskTemplate`` contributes). Hard constraints and parameter bounds + are still enforced between steps. + + Parameters + ---------- + scheduler_params : dict | None, optional + Either a single spec dict (``{"type": "cosine", "t_max": N}``) + applied to every parameter group, or a dict keyed by component + name/class. Supported types: ``none``, ``cosine`` (a.k.a. + ``cosine_annealing``), ``linear``, ``exponential``. ``plateau`` + and ``cyclic`` fall back to constant LR with a warning. + constraint_weight : float + Global multiplier on the per-sample soft-constraint loss. + constraint_config_params : dict | None + Applied to the model once before the fit (``apply_constraint_configs``). + frozen_components : list[str] | str | None + Component name(s) whose parameters are frozen across all samples. + Resolved via ``AdditiveRenderModel._component_constraint_name``; + both instance names (``"disk0"``) and class names (``"DiskTemplate"``) + work. + sample_trainability : dict[str, ArrayLike] | None + Per-canonical-key (e.g. ``"disk.template_raw"``) boolean array of + length ``len(positions)``; ``False`` entries freeze that parameter + for the matching scan position. """ if self.model is None or self.ctx is None or self.target_mean is None: raise RuntimeError("Call .define_model(...) first.") @@ -707,6 +745,8 @@ def fit_individual_diffraction_pattern_batched( if constraint_params is not None: self.model.apply_constraint_params(constraint_params, strict=True) + if constraint_config_params is not None: + self.model.apply_constraint_configs(constraint_config_params, strict=True) scan_r = int(self.dataset.shape[0]) scan_c = int(self.dataset.shape[1]) @@ -721,6 +761,32 @@ def fit_individual_diffraction_pattern_batched( ctx = self.ctx components_list = list(self.model.components) plan = _BatchedPlan.from_model(self.model, components_list, optimizer_params or {}) + plan.set_scheduler_params(scheduler_params) + + # Build per-position trainability arrays (default True everywhere). + n_positions = len(positions) + is_trainable_pos: dict[str, np.ndarray] = { + key: np.ones(n_positions, dtype=bool) for key in plan.lrs + } + frozen_keys = plan.resolve_component_keys(frozen_components) + for key in frozen_keys: + is_trainable_pos[key][:] = False + # Keys that are frozen for ALL samples skip hard constraints too. + hard_skip_keys: set[str] = set(frozen_keys) + if sample_trainability is not None: + for key, arr in sample_trainability.items(): + if key not in is_trainable_pos: + raise KeyError( + f"sample_trainability key '{key}' is not a known stacked-param. " + f"Known: {sorted(is_trainable_pos)}" + ) + mask = np.asarray(arr, dtype=bool).reshape(-1) + if mask.shape != (n_positions,): + raise ValueError( + f"sample_trainability['{key}'] has shape {mask.shape}, " + f"expected ({n_positions},)." + ) + is_trainable_pos[key] = mask loss_fn = self.loss_fn @@ -749,6 +815,23 @@ def fit_individual_diffraction_pattern_batched( for name, p in stacked.items() } + # Per-chunk trainability slice as (B,) bool tensors on device. + chunk_slice = slice(start, start + B) + chunk_trainable: dict[str, torch.Tensor] = {} + mixed_trainable_keys: list[str] = [] + init_snapshots: dict[str, torch.Tensor] = {} + for key in stacked: + if key in is_trainable_pos: + mask_np = is_trainable_pos[key][chunk_slice] + mask = torch.as_tensor(mask_np, device=ctx.device, dtype=torch.bool) + chunk_trainable[key] = mask + # A "mixed" key has some frozen samples and some trainable. + # We snapshot the init value so frozen samples can be + # restored after hard constraints / Adam. + if not bool(mask.all()) and not bool((~mask).all()): + mixed_trainable_keys.append(key) + init_snapshots[key] = stacked[key].detach().clone() + for step in range(int(n_steps)): pred = plan.batched_forward(ctx, stacked) # Per-sample fidelity loss summed → scalar with per-sample grads @@ -768,14 +851,45 @@ def fit_individual_diffraction_pattern_batched( per_sample_loss = ((torch.log1p(pred) - torch.log1p(targets)) ** 2).mean(dim=(1, 2)) else: per_sample_loss = (diff2 * diff2).mean(dim=(1, 2)) + + # Add per-sample soft constraint loss. + if float(constraint_weight) != 0.0: + constraint_per_sample = plan.batched_constraint_loss(ctx, stacked) + per_sample_loss = per_sample_loss + float(constraint_weight) * constraint_per_sample + total_loss = per_sample_loss.sum() - grads = torch.autograd.grad(total_loss, list(stacked.values())) + grads_list = list(torch.autograd.grad(total_loss, list(stacked.values()))) + + # Apply trainability masks: zero out per-sample gradient entries + # for frozen samples/params before the Adam step. + for i, (name, g) in enumerate(zip(stacked.keys(), grads_list)): + if g is None: + continue + mask_b = chunk_trainable.get(name) + if mask_b is None: + continue + if bool(mask_b.all()): + continue + # Broadcast (B,) over remaining dims. + view_shape = (g.shape[0],) + (1,) * (g.ndim - 1) + grads_list[i] = g * mask_b.view(view_shape).to(dtype=g.dtype) + t = step + 1 - _adam_step_inplace(stacked, grads, adam_state, plan.lrs, t) + current_lrs = plan.lr_at_step(t, int(n_steps)) + _adam_step_inplace(stacked, tuple(grads_list), adam_state, current_lrs, t) with torch.no_grad(): - plan.apply_hard_constraints(stacked) + plan.apply_hard_constraints(stacked, skip_keys=hard_skip_keys) + # For mixed-trainability keys, restore frozen-sample values + # from the init snapshot so they stay at their starting state. + for key in mixed_trainable_keys: + mask = chunk_trainable[key] + view_shape = (mask.shape[0],) + (1,) * (stacked[key].ndim - 1) + keep = mask.view(view_shape) + stacked[key].data.copy_( + torch.where(keep, stacked[key].data, init_snapshots[key]) + ) pbar.update(B) @@ -1074,6 +1188,11 @@ def __init__(self) -> None: self.gaussbg_idx: int | None = None self.lat_idx: int | None = None self.lrs: dict[str, float] = {} + # Per-param normalized scheduler spec; keys match self.lrs. + # Each value: {"type": "none|cosine|linear|exponential", ...numerics}. + self.scheduler_specs: dict[str, dict[str, Any]] = {} + # Mapping from canonical component name -> stacked-param keys. + self.component_keys: dict[str, list[str]] = {} @classmethod def from_model( @@ -1108,24 +1227,171 @@ def from_model( if self.disk is not None and self.disk_idx is not None: self.lrs["disk.template_raw"] = _lr_for_component(self.disk, self.disk_idx, optimizer_params, model) self.lrs["disk.intensity_raw"] = self.lrs["disk.template_raw"] + name = model._component_constraint_name(self.disk, self.disk_idx) + self.component_keys[name] = ["disk.template_raw", "disk.intensity_raw"] + self.component_keys[self.disk.__class__.__name__] = list(self.component_keys[name]) if self.dcbg is not None and self.dcbg_idx is not None: self.lrs["dcbg.intensity_raw"] = _lr_for_component(self.dcbg, self.dcbg_idx, optimizer_params, model) + name = model._component_constraint_name(self.dcbg, self.dcbg_idx) + self.component_keys[name] = ["dcbg.intensity_raw"] + self.component_keys[self.dcbg.__class__.__name__] = list(self.component_keys[name]) if self.gaussbg is not None and self.gaussbg_idx is not None: lr = _lr_for_component(self.gaussbg, self.gaussbg_idx, optimizer_params, model) self.lrs["gaussbg.sigma_raw"] = lr self.lrs["gaussbg.intensity_raw"] = lr + name = model._component_constraint_name(self.gaussbg, self.gaussbg_idx) + self.component_keys[name] = ["gaussbg.sigma_raw", "gaussbg.intensity_raw"] + self.component_keys[self.gaussbg.__class__.__name__] = list(self.component_keys[name]) if self.lat is not None and self.lat_idx is not None: lr = _lr_for_component(self.lat, self.lat_idx, optimizer_params, model) + lat_keys = [] for k in ("u_row", "u_col", "v_row", "v_col", "i0_raw", "ir", "ic", "irr", "icc", "irc"): self.lrs[f"lat.{k}"] = lr + lat_keys.append(f"lat.{k}") # Origin uses the lattice's LR (lattice is the most common owner of origin). self.lrs["origin.coords"] = lr + name = model._component_constraint_name(self.lat, self.lat_idx) + self.component_keys[name] = lat_keys + self.component_keys[self.lat.__class__.__name__] = list(lat_keys) elif self.gaussbg is not None and self.gaussbg_idx is not None: self.lrs["origin.coords"] = self.lrs["gaussbg.intensity_raw"] else: self.lrs["origin.coords"] = 1e-2 + + # Default schedulers: constant LR for every key. + self.scheduler_specs = {k: {"type": "none"} for k in self.lrs} return self + def set_scheduler_params(self, scheduler_params: Any) -> None: + """ + Configure per-key scheduler specs. + + Accepts either a single spec dict (applied to all params) or a dict + keyed by component name / class name (matching ``optimizer_params``). + Unknown scheduler types fall back to constant LR. + """ + if scheduler_params is None: + return + if not isinstance(scheduler_params, dict): + return + + def _normalize(spec: dict[str, Any]) -> dict[str, Any]: + out = dict(spec) + t = str(out.pop("type", out.pop("name", "none"))).lower() + # Aliases. + if t in ("cosine", "cosineannealing"): + t = "cosine_annealing" + if t == "cosine_annealing": + # Normalize T_max alias and cast numeric strings. + if "t_max" in out: + out["T_max"] = out.pop("t_max") + if "T_max" in out and out["T_max"] is not None: + out["T_max"] = int(float(out["T_max"])) + if "eta_min" in out: + out["eta_min"] = float(out["eta_min"]) + elif t == "exponential": + if "gamma" in out: + out["gamma"] = float(out["gamma"]) + elif t == "linear": + for k in ("start_factor", "end_factor", "total_iters"): + if k in out and out[k] is not None: + out[k] = float(out[k]) if k != "total_iters" else int(float(out[k])) + out["type"] = t + return out + + # Single-spec form: {"type": ..., ...}. + if "type" in scheduler_params or "name" in scheduler_params: + spec = _normalize(cast(dict[str, Any], scheduler_params)) + for k in self.scheduler_specs: + self.scheduler_specs[k] = dict(spec) + return + + # Per-component form: {component_name_or_class: spec_dict}. + for comp_name, spec in scheduler_params.items(): + if not isinstance(spec, dict): + continue + norm = _normalize(spec) + param_keys = self.component_keys.get(str(comp_name), []) + for k in param_keys: + if k in self.scheduler_specs: + self.scheduler_specs[k] = dict(norm) + + def lr_at_step(self, step: int, n_steps: int) -> dict[str, float]: + """ + Evaluate the analytic LR schedule for each stacked-param key at + ``step`` (1-indexed, matches the bias correction). + """ + out: dict[str, float] = {} + for key, base_lr in self.lrs.items(): + spec = self.scheduler_specs.get(key, {"type": "none"}) + t = str(spec.get("type", "none")).lower() + if t == "cosine_annealing": + T_max = int(spec.get("T_max") or n_steps) + if T_max < 1: + T_max = 1 + eta_min = float(spec.get("eta_min", 1e-7)) + # PyTorch CosineAnnealingLR formula at step s (0-indexed): + # lr(s) = eta_min + 0.5*(base - eta_min)*(1 + cos(pi*s/T_max)) + s = max(step - 1, 0) + import math + lr = eta_min + 0.5 * (base_lr - eta_min) * (1.0 + math.cos(math.pi * s / T_max)) + out[key] = float(lr) + elif t == "exponential": + gamma = float(spec.get("gamma", 1.0)) + out[key] = float(base_lr * (gamma ** max(step - 1, 0))) + elif t == "linear": + start = float(spec.get("start_factor", 1.0 / 3.0)) + end = float(spec.get("end_factor", 1.0)) + total = int(spec.get("total_iters", n_steps) or n_steps) + if total < 1: + total = 1 + s = min(max(step - 1, 0), total) + factor = start + (end - start) * (s / total) + out[key] = float(base_lr * factor) + else: + # none / plateau / cyclic / unknown -> constant LR. + out[key] = float(base_lr) + return out + + def batched_constraint_loss( + self, + ctx: RenderContext, + stacked: dict[str, torch.Tensor], + ) -> torch.Tensor: + """ + Per-sample soft-constraint loss summed across components. + + Returns a tensor of shape ``(B,)``. Only ``DiskTemplate`` contributes + today; other components inherit the zero-returning base + ``constraint_loss``. + """ + B = stacked["origin.coords"].shape[0] + out = torch.zeros(B, device=ctx.device, dtype=ctx.dtype) + if self.disk is not None: + template_b = stacked.get("disk.template_raw") + if template_b is not None: + out = out + self.disk.constraint_loss_batched(ctx, template_raw_b=template_b) + return out + + def resolve_component_keys(self, components: Any) -> list[str]: + """Resolve a name/list of component names to the stacked-param keys they own.""" + if components is None: + return [] + if isinstance(components, str): + components = [components] + keys: list[str] = [] + for name in components: + ks = self.component_keys.get(str(name)) + if ks is None: + raise KeyError( + f"Unknown component name '{name}' for batched fit. " + f"Known: {sorted(self.component_keys)}" + ) + for k in ks: + if k not in keys: + keys.append(k) + return keys + def build_stacked_params(self, B: int) -> dict[str, torch.Tensor]: out: dict[str, torch.Tensor] = {} assert self.origin is not None @@ -1200,32 +1466,44 @@ def batched_forward( ) return pred - def apply_hard_constraints(self, stacked: dict[str, torch.Tensor]) -> None: - """Apply batched analogues of each component's hard constraints to stacked params (in-place).""" + def apply_hard_constraints( + self, + stacked: dict[str, torch.Tensor], + skip_keys: set[str] | None = None, + ) -> None: + """Apply batched analogues of each component's hard constraints to stacked params (in-place). + + ``skip_keys`` is a set of stacked-param keys that should not be mutated + by any hard-constraint op (used for fully-frozen components). + """ + skip_keys = skip_keys or set() # Parameter bounds (always elementwise; safe on any shape). if self.disk is not None: for pname, (lo, hi) in self.disk.parameter_bounds.items(): key = f"disk.{pname}" - if key in stacked: + if key in stacked and key not in skip_keys: self._clamp_bounds_inplace(stacked[key], lo, hi) if self.dcbg is not None: for pname, (lo, hi) in self.dcbg.parameter_bounds.items(): key = f"dcbg.{pname}" - if key in stacked: + if key in stacked and key not in skip_keys: self._clamp_bounds_inplace(stacked[key], lo, hi) if self.gaussbg is not None: for pname, (lo, hi) in self.gaussbg.parameter_bounds.items(): key = f"gaussbg.{pname}" - if key in stacked: + if key in stacked and key not in skip_keys: self._clamp_bounds_inplace(stacked[key], lo, hi) if self.lat is not None: for pname, (lo, hi) in self.lat.parameter_bounds.items(): key = f"lat.{pname}" - if key in stacked: + if key in stacked and key not in skip_keys: self._clamp_bounds_inplace(stacked[key], lo, hi) + disk_template_frozen = "disk.template_raw" in skip_keys + disk_intensity_frozen = "disk.intensity_raw" in skip_keys + # DiskTemplate composite hard constraints. - if self.disk is not None: + if self.disk is not None and not disk_template_frozen: template = stacked.get("disk.template_raw") intensity = stacked.get("disk.intensity_raw") cfg = self.disk.constraint_config @@ -1240,13 +1518,16 @@ def apply_hard_constraints(self, stacked: dict[str, torch.Tensor]) -> None: template.sub_(float(cfg.get("shrinkage_amount", 0.25))) if bool(self.disk.hard_constraints.get("force_positive", False)): template.clamp_(min=0.0) - intensity.clamp_(min=0.0) + if not disk_intensity_frozen: + intensity.clamp_(min=0.0) if bool(self.disk.hard_constraints.get("force_norm", False)): self._batched_enforce_norm(template) # SyntheticDiskLattice.force_positive_intensity. - if self.lat is not None and bool( - self.lat.hard_constraints.get("force_positive_intensity", False) + if ( + self.lat is not None + and "lat.i0_raw" not in skip_keys + and bool(self.lat.hard_constraints.get("force_positive_intensity", False)) ): i0 = stacked.get("lat.i0_raw") if i0 is not None: From 2f4e69e569e8aea6da0d28129bbc440549066a74 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Mon, 11 May 2026 15:58:22 -0700 Subject: [PATCH 64/88] updating base to match with new optimizer mixin --- src/quantem/core/fitting/base.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 8f1b3e5a..8a64cb3b 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -330,9 +330,9 @@ def initialize_optimizer(self, return - def _infer_optimizer_rebuild_params(self) -> dict[str, Any]: + def _infer_optimizer_rebuild_params(self) -> Any: if self.optimizer_params: - return dict(self.optimizer_params) + return self.optimizer_params if self.optimizer is not None: opt_type: str | type[torch.optim.Optimizer] if isinstance(self.optimizer, torch.optim.AdamW): @@ -354,9 +354,9 @@ def _infer_optimizer_rebuild_params(self) -> dict[str, Any]: "lr": float(getattr(self, "DEFAULT_LR", self.DEFAULT_LR)), } - def _infer_scheduler_rebuild_params(self) -> dict[str, Any]: + def _infer_scheduler_rebuild_params(self) -> Any: if self.scheduler_params: - return dict(self.scheduler_params) + return self.scheduler_params return { "type": self.DEFAULT_SCHEDULER_TYPE, } @@ -367,10 +367,10 @@ def _rebuild_optimizer_after_trainability_change(self) -> None: self._optimizer = None self._scheduler = None return - rebuild_params = self._infer_optimizer_rebuild_params() - rebuild_params_scheduler = self._infer_scheduler_rebuild_params() - self.set_optimizer(rebuild_params) - self.set_scheduler(rebuild_params_scheduler) + # rebuild_params = self._infer_optimizer_rebuild_params() + # rebuild_params_scheduler = self._infer_scheduler_rebuild_params() + self.set_optimizer(self.optimizer_params) + self.set_scheduler(self.scheduler_params) def initialize_constraint_config(self, config: dict[str, Any], strict: bool = True) -> None: if not hasattr(self, 'constraint_config'): From f69e0bf59cae6803bb0347ff27b75ec21ac814c7 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Wed, 13 May 2026 17:14:19 -0700 Subject: [PATCH 65/88] adding windowing for mean pattern calculation --- src/quantem/diffraction/model_fitting.py | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 4ecf443f..05272eed 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -320,6 +320,8 @@ def preprocess( shift_order: int = 1, gamma: float = 0.5, mode: str = "linear", + rows=None, + cols = None, ) -> "ModelDiffraction": arr = np.asarray(self.dataset.array) if arr.ndim < 2: @@ -349,6 +351,30 @@ def preprocess( else: raise RuntimeError("Unreachable: normalized mode mapping failed.") self.dataset.array = np.asarray(arr) + + if rows is None and cols is None: + rows = range(self.dataset.shape[0]) + cols = range(self.dataset.shape[1]) + elif rows is not None and cols is None: + cols = range(self.dataset.shape[1]) + elif rows is None and cols is not None: + rows = range(self.dataset.shape[0]) + else: + rows = rows + cols = cols + + if isinstance(rows, int): + rows = np.array([rows]).astype(int) + else: + rows = np.asarray(rows).astype(int) + + if isinstance(cols, int): + cols = np.array([cols]).astype(int) + else: + cols = np.asarray(cols).astype(int) + + arr = arr[rows, cols] + h, w = arr.shape[-2], arr.shape[-1] self.index_shape = tuple(arr.shape[:-2]) From 2067546efc487304a2672e0897569e4dd4490b2a Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Thu, 14 May 2026 19:31:42 -0700 Subject: [PATCH 66/88] adding plot masking --- src/quantem/diffraction/model_fitting.py | 88 ++++++++++++++++++------ 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 05272eed..111c93fd 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -324,6 +324,8 @@ def preprocess( cols = None, ) -> "ModelDiffraction": arr = np.asarray(self.dataset.array) + self.mask = np.ones(arr.shape[:2]) + if arr.ndim < 2: raise ValueError("dataset.array must have at least 2 dimensions.") mode_in = mode.strip().lower() @@ -373,7 +375,7 @@ def preprocess( else: cols = np.asarray(cols).astype(int) - arr = arr[rows, cols] + arr = arr[np.ix_(rows, cols)] h, w = arr.shape[-2], arr.shape[-1] self.index_shape = tuple(arr.shape[:-2]) @@ -1035,6 +1037,54 @@ def fit_strain( ) return self + + def create_mask( + self, + min_threshold: float = 0.4, + max_threshold: float = 0.6, + smooth: bool = True, + + ): + if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + scan_r = self.dataset.shape[0] + scan_c = self.dataset.shape[1] + self.mask = np.zeros(self.dataset.shape[:2]) + + self.i0_sum_array = np.empty(shape=(scan_r, scan_c)) + + if self.state_individual_refined is None: + raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") + + for r in range(scan_r): + for c in range(scan_c): + pos_state = self.state_individual_refined[r, c] + if pos_state is None: + self.i0_sum_array[r, c] = 0.0 + continue + + i0_raw = None + uv_indices = None + for key in pos_state.keys(): + if key.endswith('i0_raw'): + i0_raw = pos_state[key].cpu().numpy() + if key.endswith('uv_indices'): + uv_indices = pos_state[key].cpu().numpy() + + if i0_raw is None or uv_indices is None: + self.i0_sum_array[r, c] = 0.0 + continue + + is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) + self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) + max_intensity = np.max(self.i0_sum_array) + if max_intensity == 0: + return np.zeros_like(self.i0_sum_array) + self.mask = self.i0_sum_array / max_intensity + self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) + if smooth: + self.mask = np.sin(np.pi / 2 * self.mask) ** 2 + return self def plot_strain( @@ -1088,24 +1138,21 @@ def plot_strain( euv_pct = strain_euv.array * 100 rot_deg = np.rad2deg(self.strain_rotation.array) + from matplotlib.colors import Normalize + norm_strain = Normalize(vmin=strain_range_percent[0], vmax=strain_range_percent[1]) + euu_rgb = cm_strain(norm_strain(euu_pct))[:, :, :3] + evv_rgb = cm_strain(norm_strain(evv_pct))[:, :, :3] + euv_rgb = cm_strain(norm_strain(euv_pct))[:, :, :3] + title_fs = 16 im0 = ax[0].imshow( - euu_pct, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cm_strain, + euu_rgb * self.mask[:, :, np.newaxis], ) ax[1].imshow( - evv_pct, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cm_strain, + evv_rgb * self.mask[:, :, np.newaxis], ) ax[2].imshow( - euv_pct, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cm_strain, + euv_rgb * self.mask[:, :, np.newaxis], ) ax[0].set_title(r"$\epsilon_{uu}$", fontsize=title_fs) @@ -1113,11 +1160,10 @@ def plot_strain( ax[2].set_title(r"$\epsilon_{uv}$", fontsize=title_fs) if plot_rotation: + norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) + rot_rgb = cm_rot(norm_rot(rot_deg))[:, :, :3] im3 = ax[3].imshow( - rot_deg, - vmin=rotation_range_degrees[0], - vmax=rotation_range_degrees[1], - cmap=cm_rot, + rot_rgb * self.mask[:, :, np.newaxis], ) ax[3].set_title("Rotation", fontsize=title_fs) @@ -1139,9 +1185,10 @@ def plot_strain( cb_height = 0.04 cb_pad = 0.03 y = b0.y0 - cb_pad - cb_height - + from matplotlib.cm import ScalarMappable cax1 = fig.add_axes([left, y, width, cb_height]) - cbar1 = fig.colorbar(im0, cax=cax1, orientation="horizontal") + sm_strain = ScalarMappable(norm=norm_strain, cmap=cm_strain) + cbar1 = fig.colorbar(sm_strain, cax=cax1, orientation="horizontal") cbar1.set_label("Strain (%)", fontsize=title_fs) cbar1.ax.tick_params(labelsize=12) @@ -1149,7 +1196,8 @@ def plot_strain( left_r = b3.x0 width_r = b3.x1 - b3.x0 cax2 = fig.add_axes([left_r, y, width_r, cb_height]) - cbar2 = fig.colorbar(im3, cax=cax2, orientation="horizontal") + sm_rot = ScalarMappable(norm=norm_rot, cmap=cm_rot) + cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation="horizontal") cbar2.set_label("Rotation (deg)", fontsize=title_fs) cbar2.ax.tick_params(labelsize=12) From 04e91bdedbd1ef075e219211583182df3c62390e Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Tue, 19 May 2026 16:09:38 -0700 Subject: [PATCH 67/88] changing plotting of strain map and labeling --- src/quantem/diffraction/model_fitting.py | 26 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 111c93fd..acf4f2aa 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -63,6 +63,9 @@ def __init__(self, dataset: Any, _token: object | None = None): # Misc metadata self.metadata: dict[str, Any] = {} + self.u_ref: np.ndarray | None = None + self.v_ref: np.ndarray | None = None + @classmethod def from_dataset( cls, dataset: Dataset2d | Dataset3d | Dataset4d | Dataset4dstem | Any @@ -999,6 +1002,8 @@ def fit_strain( ), dtype=float, ) + self.u_ref = u_ref + self.v_ref = v_ref scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] @@ -1084,7 +1089,7 @@ def create_mask( self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) if smooth: self.mask = np.sin(np.pi / 2 * self.mask) ** 2 - return self + return self def plot_strain( @@ -1155,9 +1160,9 @@ def plot_strain( euv_rgb * self.mask[:, :, np.newaxis], ) - ax[0].set_title(r"$\epsilon_{uu}$", fontsize=title_fs) - ax[1].set_title(r"$\epsilon_{vv}$", fontsize=title_fs) - ax[2].set_title(r"$\epsilon_{uv}$", fontsize=title_fs) + ax[0].set_title(r"$\epsilon_{uu}$ $\updownarrow$", fontsize=title_fs) + ax[1].set_title(r"$\epsilon_{vv}$ $\leftrightarrow$", fontsize=title_fs) + ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow$", fontsize=title_fs) if plot_rotation: norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) @@ -1165,14 +1170,17 @@ def plot_strain( im3 = ax[3].imshow( rot_rgb * self.mask[:, :, np.newaxis], ) - ax[3].set_title("Rotation", fontsize=title_fs) + ax[3].set_title(r"Rotation $\circlearrowleft$", fontsize=title_fs) for a in ax: a.set_xticks([]) a.set_yticks([]) a.set_facecolor("black") - fig.subplots_adjust(left=0.02, right=0.98, top=0.90, bottom=0.16, wspace=0.03) + fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) + if plot_rotation: + pos3 = ax[3].get_position() + ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) b0 = ax[0].get_position() b2 = ax[2].get_position() @@ -1182,8 +1190,8 @@ def plot_strain( b3 = ax[3].get_position() if plot_rotation else None - cb_height = 0.04 - cb_pad = 0.03 + cb_height = 0.02 + cb_pad = 0.02 y = b0.y0 - cb_pad - cb_height from matplotlib.cm import ScalarMappable cax1 = fig.add_axes([left, y, width, cb_height]) @@ -1200,7 +1208,7 @@ def plot_strain( cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation="horizontal") cbar2.set_label("Rotation (deg)", fontsize=title_fs) cbar2.ax.tick_params(labelsize=12) - + for a in ax: a.set_aspect("equal") From df7729952553f09e2139321481064c93e10ec7b7 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Tue, 19 May 2026 22:51:57 -0700 Subject: [PATCH 68/88] adding radial masking function and scalebar option --- src/quantem/diffraction/model_fitting.py | 174 +++++++++++++++++------ 1 file changed, 131 insertions(+), 43 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index acf4f2aa..5d86fc15 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -10,6 +10,7 @@ from tqdm import tqdm from quantem.core.datastructures import Dataset2d, Dataset3d, Dataset4d, Dataset4dstem +from quantem.core.fitting.background import DCBackground, GaussianBackground from quantem.core.fitting.base import ( AdditiveRenderModel, FitBase, @@ -17,11 +18,11 @@ RenderComponent, RenderContext, ) -from quantem.core.fitting.background import DCBackground, GaussianBackground from quantem.core.fitting.diffraction import DiskTemplate, SyntheticDiskLattice from quantem.core.io.serialize import AutoSerialize from quantem.core.ml.optimizer_mixin import OptimizerType, SchedulerType from quantem.core.utils.imaging_utils import cross_correlation_shift +from quantem.core.visualization.visualization_utils import ScalebarConfig, add_scalebar_to_ax from quantem.diffraction.model_fitting_visualizations import ModelDiffractionVisualizations @@ -1045,13 +1046,12 @@ def fit_strain( def create_mask( self, + use_radial_method: bool = False, min_threshold: float = 0.4, max_threshold: float = 0.6, + exclusion_radius_fraction: float = 0.1, smooth: bool = True, - ): - if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): - raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] self.mask = np.zeros(self.dataset.shape[:2]) @@ -1060,28 +1060,41 @@ def create_mask( if self.state_individual_refined is None: raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") - - for r in range(scan_r): - for c in range(scan_c): - pos_state = self.state_individual_refined[r, c] - if pos_state is None: - self.i0_sum_array[r, c] = 0.0 - continue + if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + + if use_radial_method: + center_y, center_x = np.array(self.dataset.shape[:-2]) / 2 + y, x = np.ogrid[:self.dataset.shape[-2], :self.dataset.shape[-1]] + radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) + exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] + for r in range(scan_r): + for c in range(scan_c): + dp = self.dataset.array[r, c] + outside_mask = radius_map > exclusion_radius + self.i0_sum_array[r, c] = np.sum(dp[outside_mask]) + else: + for r in range(scan_r): + for c in range(scan_c): + pos_state = self.state_individual_refined[r, c] + if pos_state is None: + self.i0_sum_array[r, c] = 0.0 + continue - i0_raw = None - uv_indices = None - for key in pos_state.keys(): - if key.endswith('i0_raw'): - i0_raw = pos_state[key].cpu().numpy() - if key.endswith('uv_indices'): - uv_indices = pos_state[key].cpu().numpy() - - if i0_raw is None or uv_indices is None: - self.i0_sum_array[r, c] = 0.0 - continue - - is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) - self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) + i0_raw = None + uv_indices = None + for key in pos_state.keys(): + if key.endswith('i0_raw'): + i0_raw = pos_state[key].cpu().numpy() + if key.endswith('uv_indices'): + uv_indices = pos_state[key].cpu().numpy() + + if i0_raw is None or uv_indices is None: + self.i0_sum_array[r, c] = 0.0 + continue + + is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) + self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) max_intensity = np.max(self.i0_sum_array) if max_intensity == 0: return np.zeros_like(self.i0_sum_array) @@ -1098,10 +1111,13 @@ def plot_strain( strain_range_percent=(-3.0, 3.0), rotation_range_degrees=(-2.0, 2.0), plot_rotation=True, + plot_gvecs=True, + plot_scalebar=True, cmap_strain="RdBu_r", cmap_rotation="PiYG", layout="horizontal", figsize=(6, 6), + **scalebar_kwargs, ): import matplotlib.pyplot as plt @@ -1150,44 +1166,76 @@ def plot_strain( euv_rgb = cm_strain(norm_strain(euv_pct))[:, :, :3] title_fs = 16 - im0 = ax[0].imshow( - euu_rgb * self.mask[:, :, np.newaxis], - ) - ax[1].imshow( - evv_rgb * self.mask[:, :, np.newaxis], - ) - ax[2].imshow( - euv_rgb * self.mask[:, :, np.newaxis], - ) + ax[0].imshow(euu_rgb * self.mask[:, :, np.newaxis],) + ax[1].imshow(evv_rgb * self.mask[:, :, np.newaxis],) + ax[2].imshow(euv_rgb * self.mask[:, :, np.newaxis],) ax[0].set_title(r"$\epsilon_{uu}$ $\updownarrow$", fontsize=title_fs) ax[1].set_title(r"$\epsilon_{vv}$ $\leftrightarrow$", fontsize=title_fs) - ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow$", fontsize=title_fs) + ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow\!\swarrow$", fontsize=title_fs) if plot_rotation: norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) rot_rgb = cm_rot(norm_rot(rot_deg))[:, :, :3] - im3 = ax[3].imshow( - rot_rgb * self.mask[:, :, np.newaxis], - ) + ax[3].imshow(rot_rgb * self.mask[:, :, np.newaxis],) ax[3].set_title(r"Rotation $\circlearrowleft$", fontsize=title_fs) for a in ax: a.set_xticks([]) a.set_yticks([]) a.set_facecolor("black") + a.set_aspect("equal") + + if plot_scalebar and isinstance(self.dataset, Dataset4dstem): + default_sampling = 1.0 + default_units = 'pixels' + + if hasattr(self.dataset, 'units'): + if isinstance(self.dataset.units, (tuple, list)): + default_units = str(self.dataset.units[0]) + else: + default_units = str(self.dataset.units) + if hasattr(self.dataset, 'sampling'): + if isinstance(self.dataset.sampling, (tuple, list, np.ndarray)): + default_sampling = float(self.dataset.sampling[0]) + else: + default_sampling = float(self.dataset.sampling) + scalebar_defaults = { + 'sampling': default_sampling, + 'units': default_units, + 'length': None, + 'width_px': 1, + 'pad_px': 0.5, + 'color': 'black', + 'loc': 'lower left', + 'fontsize': 12, + 'bold': True, + } + scalebar_defaults.update(scalebar_kwargs) + scalebar_config = ScalebarConfig(**scalebar_defaults) + add_scalebar_to_ax( + ax[0], + array_size=int(self.dataset.shape[0]), + sampling=scalebar_config.sampling, + length_units=scalebar_config.length, + units=scalebar_config.units, + width_px=scalebar_config.width_px, + pad_px=scalebar_config.pad_px, + color=scalebar_config.color, + loc=scalebar_config.loc, + fontsize=scalebar_config.fontsize, + bold=scalebar_config.bold, + ) fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) if plot_rotation: pos3 = ax[3].get_position() ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) - b0 = ax[0].get_position() b2 = ax[2].get_position() left = b0.x0 right = b2.x1 width = right - left - b3 = ax[3].get_position() if plot_rotation else None cb_height = 0.02 @@ -1208,9 +1256,49 @@ def plot_strain( cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation="horizontal") cbar2.set_label("Rotation (deg)", fontsize=title_fs) cbar2.ax.tick_params(labelsize=12) - - for a in ax: - a.set_aspect("equal") + + if plot_gvecs: + from matplotlib.patches import FancyArrowPatch + if not hasattr(self, 'u_ref') or not hasattr(self, 'v_ref'): + print("Warning: u_ref and v_ref not found. Call fit_strain() first.") + return fig, ax + + last_pos = b3 if plot_rotation else b2 + + ref_width = last_pos.width * 0.8 + ref_left = last_pos.x1 - 0.035 + + ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) + ref_ax.set_xlim(-1.5, 1.5) + ref_ax.set_ylim(-1.5, 1.5) + ref_ax.set_aspect('equal') + ref_ax.axis('off') + u_norm = self.u_ref / np.linalg.norm(self.u_ref) + v_norm = self.v_ref / np.linalg.norm(self.v_ref) + + u_row, u_col = u_norm + v_row, v_col = v_norm + arrow_props_ref = dict(arrowstyle='->', lw=3, mutation_scale=25) + + u_arrow = FancyArrowPatch( + (0, 0), (u_col, -u_row), + color='darkred', **arrow_props_ref + ) + ref_ax.add_patch(u_arrow) + + v_arrow = FancyArrowPatch( + (0, 0), (v_col, -v_row), + color='darkblue', **arrow_props_ref + ) + ref_ax.add_patch(v_arrow) + ref_ax.text(u_col * 1.3, -u_row * 1.3, r'$\mathbf{u}_{ref}$', + fontsize=14, fontweight='bold', color='darkred', + ha='center', va='center') + + ref_ax.text(v_col * 1.3, -v_row * 1.3, r'$\mathbf{v}_{ref}$', + fontsize=14, fontweight='bold', color='darkblue', + ha='center', va='center') + return fig, ax From 4a27332a9bed909b7149aa6bad08ea24967121cd Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Wed, 20 May 2026 11:14:16 -0700 Subject: [PATCH 69/88] updated scalebar plotting --- src/quantem/diffraction/model_fitting.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 5d86fc15..e14465a5 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -1117,7 +1117,7 @@ def plot_strain( cmap_rotation="PiYG", layout="horizontal", figsize=(6, 6), - **scalebar_kwargs, + **kwargs, ): import matplotlib.pyplot as plt @@ -1189,6 +1189,11 @@ def plot_strain( if plot_scalebar and isinstance(self.dataset, Dataset4dstem): default_sampling = 1.0 default_units = 'pixels' + scalebar_kwargs = {} + for key, value in kwargs.items(): + if key.startswith('scalebar_'): + scalebar_key = key[len('scalebar_'):] + scalebar_kwargs[scalebar_key] = value if hasattr(self.dataset, 'units'): if isinstance(self.dataset.units, (tuple, list)): From 945ee2d3f197f65f6012036eff1d0de71f999c87 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Wed, 20 May 2026 11:40:27 -0700 Subject: [PATCH 70/88] updated dev merge --- src/quantem/core/io/file_readers.py | 6 ------ uv.lock | 18 ------------------ 2 files changed, 24 deletions(-) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index 543d1d62..6e970172 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -16,13 +16,7 @@ def read_4dstem( file_path: str | PathLike, file_type: str | None = None, dataset_index: int | None = None, -<<<<<<< HEAD - scan_length: int | None = None, - scan_axis: int = 0, - transpose_scan_axes: bool = False, -======= hot_pixel_filter: bool = False, ->>>>>>> dev **kwargs, ) -> Dataset4dstem: """ diff --git a/uv.lock b/uv.lock index 71a8b52e..cad9aa3b 100644 --- a/uv.lock +++ b/uv.lock @@ -1467,15 +1467,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -<<<<<<< HEAD -sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221 }, -======= sdist = { url = "https://files.pythonhosted.org/packages/ca/15/1eacb0fcb79ef86e8a0a79a708e6ad7435f6f223097dd29a4ce861fabc44/jupyter_server-2.18.2.tar.gz", hash = "sha256:06b4f40d8a7a00bb39d5216859c81374a0e7cfefe6d8a5a7facc5a5c37c679a7", size = 753177, upload-time = "2026-05-06T07:04:36.274Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e2/50/ecf4f70d65bdb7519b28a33d1b2fee8a4b4ba1ae1a92f15d97e877c5de21/jupyter_server-2.18.2-py3-none-any.whl", hash = "sha256:fa5e46539ded65791838035a2b6001f13e54d5f64b8b3752eb1e91fdd641a5b8", size = 391907, upload-time = "2026-05-06T07:04:34.014Z" }, ->>>>>>> dev ] [[package]] @@ -1866,15 +1860,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -<<<<<<< HEAD -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516 }, -======= sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ->>>>>>> dev ] [[package]] @@ -3720,15 +3708,9 @@ wheels = [ name = "traitlets" version = "5.15.0" source = { registry = "https://pypi.org/simple" } -<<<<<<< HEAD -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, -======= sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, ->>>>>>> dev ] [[package]] From 3887cca5ba2c2323025e6e73222d0753f99a7993 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Fri, 22 May 2026 10:58:35 -0700 Subject: [PATCH 71/88] adding cepstral multi peak implementation --- .../diffraction/strain_autocorrelation.py | 238 ++++++++++++++++-- 1 file changed, 213 insertions(+), 25 deletions(-) diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index 105d1d98..a62e6318 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -4,6 +4,7 @@ import matplotlib.pyplot as plt import numpy as np +import torch from numpy.typing import NDArray from scipy.ndimage import distance_transform_edt @@ -41,6 +42,8 @@ def __init__( self.u: NDArray | None = None self.v: NDArray | None = None + self.mean_img_peaks: NDArray | None = None + self.mean_img_weights: NDArray | None = None self.u_fit: Dataset3d | None = None self.v_fit: Dataset3d | None = None @@ -292,6 +295,81 @@ def plot_transform( _apply_center_crop_limits(a, self.transform.shape, cropping_factor) return fig, ax + + def plot_single_transform( + self, + row: int = 0, + col: int = 0, + cropping_factor: float = 0.25, + scalebar_fraction: float = 0.25, + **plot_kwargs: Any, + ): + if self.transform is None or self.transform_rotated is None: + raise ValueError("Run preprocess() first to compute transform images.") + + sampling = np.mean(self.metadata["sampling_real"]) + units = self.metadata.get("real_units", r"$\mathrm{\AA}$") + + W = self.transform.shape[1] + view_w_px = W * cropping_factor + target_units = scalebar_fraction * view_w_px * sampling + sb_len = _nice_length_units(target_units) + + kr = (np.arange(self.transform.shape[0], dtype=float) - self.transform.shape[0] // 2)[:, None] + kc = (np.arange(self.transform.shape[1], dtype=float) - self.transform.shape[1] // 2)[None, :] + qmag = np.sqrt(kr * kr + kc * kc) + im0 = self.transform.array + tmp = im0 * qmag + i0 = np.unravel_index(np.nanargmax(tmp), tmp.shape) + vmin = 0.0 + vmax = im0[i0] + + defaults = dict( + vmin=vmin, + vmax=vmax, + title=(f"Row={row} Col={col} Transform"), + scalebar=ScalebarConfig( + sampling=sampling, + units=units, + length=sb_len if sb_len > 0 else None, + ), + ) + defaults.update(plot_kwargs) + if row >= 0 and row < self.dataset.array.shape[0] and col >= 0 and col < self.dataset.array.shape[1]: + dp = self.dataset.array[row, col] * self.mask_diffraction + self.mask_diffraction_inv + else: + raise ValueError("row or column value out of bounds") + mode = self.metadata.get("mode", "linear").lower() + if mode == "gamma": + g = self.metadata["gamma"] + + + if mode == "linear": + im = np.fft.fftshift(np.abs(np.fft.fft2(dp))) + elif mode == "log": + im = np.fft.fftshift(np.abs(np.fft.fft2(np.log1p(dp)))) + elif mode == "gamma": + im = np.fft.fftshift(np.abs(np.fft.fft2(np.power(np.clip(dp, 0.0, None), g)))) + else: + raise ValueError("metadata['mode'] must be 'linear', 'log', or 'gamma'") + + + fig, ax = show_2d(im, **defaults) + rot_ccw = self.metadata["q_to_r_rotation_ccw_deg"] + q_transpose = self.metadata["q_transpose"] + + _overlay_lattice_vectors( + ax=ax, + shape=self.transform.shape, + u_rc= self.u_fit.array[row, col, :], + v_rc=self.v_fit.array[row, col, :], + rot_ccw_deg=rot_ccw, + q_transpose=q_transpose, + peaks_plot=self.mean_img_peaks, + ) + + for a in _flatten_axes(ax): + _apply_center_crop_limits(a, self.transform.shape, cropping_factor) def choose_lattice_vector( self, @@ -301,9 +379,12 @@ def choose_lattice_vector( define_in_rotated: bool = False, refine_gaussian: bool = True, refine_dft: bool = False, + refine_all_peaks: bool = False, refine_radius_px: float = 2.0, upsample: int = 16, gaussian_maxfev: int = 100, + threshold_percentile: float = 0.9975, + min_peak_spacing: float = 0, plot: bool = True, cropping_factor: float = 0.25, **plot_kwargs: Any, @@ -321,29 +402,36 @@ def choose_lattice_vector( u_rc = _display_vec_to_raw(u_rc, rotation_ccw_deg=rot_ccw, transpose=q_transpose) v_rc = _display_vec_to_raw(v_rc, rotation_ccw_deg=rot_ccw, transpose=q_transpose) - u_fit_abs, v_fit_abs = _refine_lattice_vectors( + u_fit_abs, v_fit_abs, peaks, weights = _refine_lattice_vectors( self.transform.array, u_rc=u_rc, v_rc=v_rc, radius_px=refine_radius_px, refine_gaussian=refine_gaussian, refine_dft=refine_dft, + refine_all_peaks=refine_all_peaks, + peaks=None, + weights=None, upsample=upsample, maxfev=gaussian_maxfev, + threshold_percentile=threshold_percentile, + min_peak_spacing = min_peak_spacing, ) - H, W = self.transform.array.shape - center = np.array((H // 2, W // 2), dtype=float) - - self.u = u_fit_abs[:2] - center - self.v = v_fit_abs[:2] - center + self.u = u_fit_abs[:2] + self.v = v_fit_abs[:2] + if refine_all_peaks: + self.mean_img_peaks = peaks + self.mean_img_weights = weights self.metadata["choose_define_in_rotated"] = define_in_rotated self.metadata["choose_refine_gaussian"] = refine_gaussian self.metadata["choose_refine_dft"] = refine_dft + self.metadata["choose_refine_all_peaks"] = refine_all_peaks self.metadata["choose_refine_radius_px"] = refine_radius_px self.metadata["choose_upsample"] = upsample self.metadata["choose_gaussian_maxfev"] = gaussian_maxfev + self.metadata["choose_threshold_percentile"] = threshold_percentile if plot: fig, ax = self.plot_transform(cropping_factor=cropping_factor, **plot_kwargs) @@ -354,6 +442,7 @@ def choose_lattice_vector( v_rc=self.v, rot_ccw_deg=rot_ccw, q_transpose=q_transpose, + peaks_plot=self.mean_img_peaks, ) return self @@ -363,6 +452,7 @@ def fit_lattice_vectors( self, refine_gaussian: bool = True, refine_dft: bool = False, + refine_all_peaks: bool = False, refine_radius_px: float = 2.0, upsample: int = 16, gaussian_maxfev: int = 100, @@ -370,7 +460,10 @@ def fit_lattice_vectors( ) -> "StrainMapAutocorrelation": if self.u is None or self.v is None: raise ValueError("Run choose_lattice_vector() first to set initial lattice vectors (self.u, self.v).") - + if refine_all_peaks: + if self.mean_img_peaks is None or self.mean_img_weights is None: + raise ValueError("Run choose_lattice_vector() with refine_all_peaks=True to determine which peaks to fit") + scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] @@ -428,13 +521,16 @@ def fit_lattice_vectors( else: raise ValueError("metadata['mode'] must be 'linear', 'log', or 'gamma'") - u_fit_abs, v_fit_abs = _refine_lattice_vectors( + u_fit_abs, v_fit_abs, _, _ = _refine_lattice_vectors( im, u_rc=u0, v_rc=v0, radius_px=refine_radius_px, refine_gaussian=refine_gaussian, refine_dft=refine_dft, + refine_all_peaks=refine_all_peaks, + peaks=self.mean_img_peaks, + weights=self.mean_img_weights, upsample=upsample, maxfev=gaussian_maxfev, ) @@ -442,13 +538,14 @@ def fit_lattice_vectors( self.u_peak_fit.array[r, c, :] = u_fit_abs self.v_peak_fit.array[r, c, :] = v_fit_abs - self.u_fit.array[r, c, 0] = u_fit_abs[0] - r_center - self.u_fit.array[r, c, 1] = u_fit_abs[1] - c_center - self.v_fit.array[r, c, 0] = v_fit_abs[0] - r_center - self.v_fit.array[r, c, 1] = v_fit_abs[1] - c_center + self.u_fit.array[r, c, 0] = u_fit_abs[0] + self.u_fit.array[r, c, 1] = u_fit_abs[1] + self.v_fit.array[r, c, 0] = v_fit_abs[0] + self.v_fit.array[r, c, 1] = v_fit_abs[1] self.metadata["fit_refine_gaussian"] = refine_gaussian self.metadata["fit_refine_dft"] = refine_dft + self.metadata["fit_refine_all_peaks"] = refine_all_peaks self.metadata["fit_refine_radius_px"] = refine_radius_px self.metadata["fit_upsample"] = upsample self.metadata["fit_gaussian_maxfev"] = gaussian_maxfev @@ -622,27 +719,30 @@ def fit_strain( def plot_strain( self, ref_u_v=(1.0, 0.0), - ref_angle_degrees=None, + rotation_angle=None, strain_range_percent=(-3.0, 3.0), rotation_range_degrees=(-2.0, 2.0), plot_rotation=True, + plot_scalebar=True, + plot_gvecs=False, cmap_strain="RdBu_r", cmap_rotation=None, layout="horizontal", figsize=(6, 6), max_shift: tuple[float, float] | None = None, amp_range: tuple[float, float] | None = None, + **kwargs, ): import matplotlib.pyplot as plt if cmap_rotation is None: cmap_rotation = cmap_strain - if ref_angle_degrees is None: + if rotation_angle is None: ref_vec = self.u_ref * ref_u_v[0] + self.v_ref * ref_u_v[1] ref_angle = np.arctan2(ref_vec[1], ref_vec[0]) else: - ref_angle = np.deg2rad(ref_angle_degrees) + ref_angle = np.deg2rad(rotation_angle) angle = ref_angle + np.deg2rad(self.metadata["q_to_r_rotation_ccw_deg"]) c = np.cos(angle) @@ -897,6 +997,15 @@ def _draw(vec: NDArray, label: str, color: tuple[float, float, float]) -> None: _draw(np.asarray(u_rc, dtype=float).reshape(2), "u", (1.0, 0.0, 0.0)) _draw(np.asarray(v_rc, dtype=float).reshape(2), "v", (0.0, 0.7, 1.0)) +def _plot_peaks(ax: Any, center_rc: tuple[float, float], peaks_plot: NDArray) -> None: + r0, c0 = center_rc + + def _draw(vec: NDArray, color: tuple[float, float, float]) -> None: + dr, dc = vec[0], vec[1] + ax.plot([c0 + dc], [r0 + dr], marker="o", markersize=6.0, color=color) + + for p in peaks_plot: + _draw(np.asarray(p, dtype=float).reshape(2), (0.0, 1.0, 0.0)) def _overlay_lattice_vectors( *, @@ -906,6 +1015,7 @@ def _overlay_lattice_vectors( v_rc: NDArray, rot_ccw_deg: float, q_transpose: bool, + peaks_plot: NDArray | None = None, ) -> None: axs = _flatten_axes(ax) if not axs: @@ -915,6 +1025,8 @@ def _overlay_lattice_vectors( center_rc = (H // 2, W // 2) _plot_lattice_vectors(axs[0], center_rc, u_rc, v_rc) + if peaks_plot is not None: + _plot_peaks(axs[0], center_rc, peaks_plot) if len(axs) >= 2: u_disp = _raw_vec_to_display(u_rc, rotation_ccw_deg=rot_ccw_deg, transpose=q_transpose) @@ -985,12 +1097,15 @@ def _refine_peak_subpixel_dft( return r0, c0 im = np.asarray(im, dtype=float) - F = np.fft.fft2(im) + if torch.is_tensor(r0): + r0 = float(r0.item()) + if torch.is_tensor(c0): + c0 = float(c0.item()) + F = np.fft.fft2(np.fft.fftshift(im)) up = upsample - du = int(np.ceil(1.5 * up)) - - patch = dft_upsample(F, up=up, shift=(r0, c0), device="cpu") + du = int(np.fix(np.ceil(1.5 * up))) + patch = np.abs(dft_upsample(F, up=up, shift=(r0, c0), device="cpu")) patch = np.asarray(patch, dtype=float) i0, j0 = np.unravel_index(np.argmax(patch), patch.shape) @@ -1006,9 +1121,9 @@ def _refine_peak_subpixel_dft( dj = _parabolic_vertex_delta(row[0], row[1], row[2]) else: dj = 0.0 - - dr = (i0 - du + di) / up - dc = (j0 - du + dj) / up + M, N = im.shape + dr = ((float(i0) - du + di)) / up + dc = ((float(j0) - du + dj)) / up return r0 + dr, c0 + dc @@ -1021,9 +1136,14 @@ def _refine_lattice_vectors( radius_px: float = 2.0, refine_gaussian: bool = True, refine_dft: bool = False, + refine_all_peaks: bool = False, + peaks: NDArray | None = None, + weights: NDArray | None = None, upsample: int = 16, maxfev: int = 100, -) -> tuple[NDArray, NDArray]: + threshold_percentile: float = 0.9975, + min_peak_spacing: float = 0, +) -> tuple[NDArray, NDArray, NDArray, NDArray]: from scipy.optimize import curve_fit im = np.asarray(im, dtype=float) @@ -1168,6 +1288,74 @@ def _refine_one(vec: NDArray) -> NDArray: ) r_fit, c_fit = r_dft, c_dft - return np.array((r_fit, c_fit, amp, sig, bg), dtype=float) + return np.array((r_fit - r_center, c_fit - c_center, amp, sig, bg), dtype=float) + + def _find_initial_peaks_weights(initial_peaks: NDArray) -> tuple[NDArray, NDArray, NDArray]: + if initial_peaks is None: + threshold = np.percentile(im, threshold_percentile*100) + p = np.logical_and.reduce(( + im>np.roll(im,(-1,-1),axis = (0,1)), + im>np.roll(im,(-1, 0),axis = (0,1)), + im>np.roll(im,(-1, 1),axis = (0,1)), + im>np.roll(im,( 0,-1),axis = (0,1)), + im>np.roll(im,( 0, 1),axis = (0,1)), + im>np.roll(im,( 1,-1),axis = (0,1)), + im>np.roll(im,( 1, 0),axis = (0,1)), + im>np.roll(im,( 1, 1),axis = (0,1)), + im>threshold, + ) ) + initial_peaks = np.argwhere(p) + r0 = (r_center, c_center) + initial_peaks = initial_peaks - r0 + if min_peak_spacing > 0: + intensities = im[initial_peaks[:, 0] + r_center, initial_peaks[:, 1] + c_center] + sorted_indices = np.argsort(intensities)[::-1] + initial_peaks_sorted = initial_peaks[sorted_indices] + accepted_peaks = [] + + for peak in initial_peaks_sorted: + if len(accepted_peaks) == 0: + accepted_peaks.append(peak) + else: + distances = np.sqrt(np.sum((np.array(accepted_peaks) - peak)**2, axis=1)) + if np.all(distances >= min_peak_spacing): + accepted_peaks.append(peak) + + initial_peaks = np.array(accepted_peaks) + + peak_sp = np.zeros(initial_peaks.shape) + weights = np.zeros((initial_peaks.shape[0], 1)) + it = 0 + for peak in initial_peaks: + refined_peak = _refine_one(peak) + peak_sp[it, :] = refined_peak[:2] + weights[it] = refined_peak[2] + it += 1 + return peak_sp, weights + + + if refine_all_peaks: + if peaks is None or weights is None: + pts, weights = _find_initial_peaks_weights(None) + else: + pts,_ = _find_initial_peaks_weights(peaks) + + A = np.column_stack((u_rc, v_rc)) + ab0_float = np.linalg.lstsq(A, pts.T, rcond=None)[0] + ab0 = (np.round(ab0_float)).T + + weights /= weights.sum() + A = np.ones((pts.shape[0], 3)) + A[:,:2] = ab0 + pts_weighted = pts * np.sqrt(weights) + A_weighted = A * np.sqrt(weights) + uvr0 = np.linalg.lstsq(A_weighted, pts_weighted, rcond=None)[0] + u_refined = uvr0[0,:] + v_refined = uvr0[1,:] + _,_, amp_par_u = _parabolic_peak_rc_amp(r_guess=u_refined[0], c_guess=u_refined[1]) + _,_, amp_par_v = _parabolic_peak_rc_amp(r_guess=v_refined[0], c_guess=v_refined[1]) + + return np.array((u_refined[0], u_refined[1], amp_par_u, 0, 0), dtype=float), np.array((v_refined[0], v_refined[1], amp_par_v, 0, 0), dtype=float), pts, weights + + return _refine_one(u_rc), _refine_one(v_rc), None, None - return _refine_one(u_rc), _refine_one(v_rc) \ No newline at end of file From 864bd571496d6bfc1488fec30420e941c98cf997 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Sun, 24 May 2026 17:46:25 -0700 Subject: [PATCH 72/88] creating strain fitting mixin for plotting and calculation --- src/quantem/diffraction/model_fitting.py | 383 +++-------------- .../diffraction/strain_autocorrelation.py | 310 +------------- src/quantem/diffraction/strain_fitting.py | 400 ++++++++++++++++++ 3 files changed, 478 insertions(+), 615 deletions(-) create mode 100644 src/quantem/diffraction/strain_fitting.py diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index e14465a5..5f46a379 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -66,6 +66,10 @@ def __init__(self, dataset: Any, _token: object | None = None): self.u_ref: np.ndarray | None = None self.v_ref: np.ndarray | None = None + self.u_array: np.ndarray | None = None + self.v_array: np.ndarray | None = None + + self.real_space = False @classmethod def from_dataset( @@ -977,335 +981,66 @@ def render_indivdual_pattern(self, row, col): ) return self._render_state_array(self.state_individual_refined[row, col]) - def fit_strain( - self, - mask_reference=None, - ) -> "ModelDiffraction": - u_fit = self.u_array - v_fit = self.v_array - - if mask_reference is None: - u_ref = np.median(u_fit.reshape(-1, 2), axis=0) - v_ref = np.median(v_fit.reshape(-1, 2), axis=0) - else: - m = np.asarray(mask_reference, dtype=bool) - u_ref = np.array( - ( - np.median(u_fit[m, 0]), - np.median(u_fit[m, 1]), - ), - dtype=float, - ) - v_ref = np.array( - ( - np.median(v_fit[m, 0]), - np.median(v_fit[m, 1]), - ), - dtype=float, - ) - self.u_ref = u_ref - self.v_ref = v_ref - - scan_r = self.dataset.shape[0] - scan_c = self.dataset.shape[1] - - Uref = np.stack((u_ref, v_ref), axis=1).astype(float) - strain_trans = np.zeros((scan_r, scan_c, 2, 2)) - for r in range(scan_r): - for c in range(scan_c): - U = np.stack((u_fit[r, c, :], v_fit[r, c, :]), axis=1) - det = np.linalg.det(U) - if not np.isfinite(det) or abs(det) < 1e-12: - U_inv = np.linalg.pinv(U) - else: - U_inv = np.linalg.inv(U) - strain_trans[r, c, :, :] = Uref @ U_inv - - self.strain_raw_err = Dataset2d.from_array( - strain_trans[:, :, 0, 0] - 1, - name="strain err", - signal_units="fractional", - ) - self.strain_raw_ecc = Dataset2d.from_array( - strain_trans[:, :, 1, 1] - 1, - name="strain ecc", - signal_units="fractional", - ) - self.strain_raw_erc = Dataset2d.from_array( - strain_trans[:, :, 1, 0] * 0.5 + strain_trans[:, :, 0, 1] * 0.5, - name="strain erc", - signal_units="fractional", - ) - self.strain_rotation = Dataset2d.from_array( - strain_trans[:, :, 1, 0] * -0.5 + strain_trans[:, :, 0, 1] * 0.5, - name="strain rotation", - signal_units="fractional", - ) - - return self - def create_mask( - self, - use_radial_method: bool = False, - min_threshold: float = 0.4, - max_threshold: float = 0.6, - exclusion_radius_fraction: float = 0.1, - smooth: bool = True, - ): - scan_r = self.dataset.shape[0] - scan_c = self.dataset.shape[1] - self.mask = np.zeros(self.dataset.shape[:2]) + # def create_mask( + # self, + # use_radial_method: bool = False, + # min_threshold: float = 0.4, + # max_threshold: float = 0.6, + # exclusion_radius_fraction: float = 0.1, + # smooth: bool = True, + # ): + # scan_r = self.dataset.shape[0] + # scan_c = self.dataset.shape[1] + # self.mask = np.zeros(self.dataset.shape[:2]) - self.i0_sum_array = np.empty(shape=(scan_r, scan_c)) + # self.i0_sum_array = np.empty(shape=(scan_r, scan_c)) - if self.state_individual_refined is None: - raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") - if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): - raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") - - if use_radial_method: - center_y, center_x = np.array(self.dataset.shape[:-2]) / 2 - y, x = np.ogrid[:self.dataset.shape[-2], :self.dataset.shape[-1]] - radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) - exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] - for r in range(scan_r): - for c in range(scan_c): - dp = self.dataset.array[r, c] - outside_mask = radius_map > exclusion_radius - self.i0_sum_array[r, c] = np.sum(dp[outside_mask]) - else: - for r in range(scan_r): - for c in range(scan_c): - pos_state = self.state_individual_refined[r, c] - if pos_state is None: - self.i0_sum_array[r, c] = 0.0 - continue - - i0_raw = None - uv_indices = None - for key in pos_state.keys(): - if key.endswith('i0_raw'): - i0_raw = pos_state[key].cpu().numpy() - if key.endswith('uv_indices'): - uv_indices = pos_state[key].cpu().numpy() + # if self.state_individual_refined is None: + # raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") + # if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): + # raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + + # if use_radial_method: + # center_y, center_x = np.array(self.dataset.shape[:-2]) / 2 + # y, x = np.ogrid[:self.dataset.shape[-2], :self.dataset.shape[-1]] + # radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) + # exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] + # for r in range(scan_r): + # for c in range(scan_c): + # dp = self.dataset.array[r, c] + # outside_mask = radius_map > exclusion_radius + # self.i0_sum_array[r, c] = np.sum(dp[outside_mask]) + # else: + # for r in range(scan_r): + # for c in range(scan_c): + # pos_state = self.state_individual_refined[r, c] + # if pos_state is None: + # self.i0_sum_array[r, c] = 0.0 + # continue + + # i0_raw = None + # uv_indices = None + # for key in pos_state.keys(): + # if key.endswith('i0_raw'): + # i0_raw = pos_state[key].cpu().numpy() + # if key.endswith('uv_indices'): + # uv_indices = pos_state[key].cpu().numpy() - if i0_raw is None or uv_indices is None: - self.i0_sum_array[r, c] = 0.0 - continue + # if i0_raw is None or uv_indices is None: + # self.i0_sum_array[r, c] = 0.0 + # continue - is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) - self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) - max_intensity = np.max(self.i0_sum_array) - if max_intensity == 0: - return np.zeros_like(self.i0_sum_array) - self.mask = self.i0_sum_array / max_intensity - self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) - if smooth: - self.mask = np.sin(np.pi / 2 * self.mask) ** 2 - return self - - - def plot_strain( - self, - rotation_angle=20, - strain_range_percent=(-3.0, 3.0), - rotation_range_degrees=(-2.0, 2.0), - plot_rotation=True, - plot_gvecs=True, - plot_scalebar=True, - cmap_strain="RdBu_r", - cmap_rotation="PiYG", - layout="horizontal", - figsize=(6, 6), - **kwargs, - ): - import matplotlib.pyplot as plt - - if cmap_rotation is None: - cmap_rotation = cmap_strain - - angle = np.deg2rad(rotation_angle) - c = np.cos(angle) - s = np.sin(angle) - - err = self.strain_raw_err.array - ecc = self.strain_raw_ecc.array - erc = self.strain_raw_erc.array - - euu = err * (c * c) + 2.0 * erc * (c * s) + ecc * (s * s) - evv = err * (s * s) - 2.0 * erc * (c * s) + ecc * (c * c) - euv = (ecc - err) * (c * s) + erc * (c * c - s * s) - - strain_euu = self.strain_raw_err.copy() - strain_evv = self.strain_raw_ecc.copy() - strain_euv = self.strain_raw_erc.copy() - strain_euu.array[...] = euu - strain_evv.array[...] = evv - strain_euv.array[...] = euv - - if layout != "horizontal": - raise ValueError("layout must be 'horizontal'") - - ncols = 4 if plot_rotation else 3 - fig, ax = plt.subplots(1, ncols, figsize=figsize) - - cm_strain = plt.get_cmap(cmap_strain).copy() - cm_strain.set_bad(color="black") - cm_rot = plt.get_cmap(cmap_rotation).copy() - cm_rot.set_bad(color="black") - - euu_pct = strain_euu.array * 100 - evv_pct = strain_evv.array * 100 - euv_pct = strain_euv.array * 100 - rot_deg = np.rad2deg(self.strain_rotation.array) - - from matplotlib.colors import Normalize - norm_strain = Normalize(vmin=strain_range_percent[0], vmax=strain_range_percent[1]) - euu_rgb = cm_strain(norm_strain(euu_pct))[:, :, :3] - evv_rgb = cm_strain(norm_strain(evv_pct))[:, :, :3] - euv_rgb = cm_strain(norm_strain(euv_pct))[:, :, :3] - - title_fs = 16 - ax[0].imshow(euu_rgb * self.mask[:, :, np.newaxis],) - ax[1].imshow(evv_rgb * self.mask[:, :, np.newaxis],) - ax[2].imshow(euv_rgb * self.mask[:, :, np.newaxis],) - - ax[0].set_title(r"$\epsilon_{uu}$ $\updownarrow$", fontsize=title_fs) - ax[1].set_title(r"$\epsilon_{vv}$ $\leftrightarrow$", fontsize=title_fs) - ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow\!\swarrow$", fontsize=title_fs) - - if plot_rotation: - norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) - rot_rgb = cm_rot(norm_rot(rot_deg))[:, :, :3] - ax[3].imshow(rot_rgb * self.mask[:, :, np.newaxis],) - ax[3].set_title(r"Rotation $\circlearrowleft$", fontsize=title_fs) - - for a in ax: - a.set_xticks([]) - a.set_yticks([]) - a.set_facecolor("black") - a.set_aspect("equal") - - if plot_scalebar and isinstance(self.dataset, Dataset4dstem): - default_sampling = 1.0 - default_units = 'pixels' - scalebar_kwargs = {} - for key, value in kwargs.items(): - if key.startswith('scalebar_'): - scalebar_key = key[len('scalebar_'):] - scalebar_kwargs[scalebar_key] = value - - if hasattr(self.dataset, 'units'): - if isinstance(self.dataset.units, (tuple, list)): - default_units = str(self.dataset.units[0]) - else: - default_units = str(self.dataset.units) - if hasattr(self.dataset, 'sampling'): - if isinstance(self.dataset.sampling, (tuple, list, np.ndarray)): - default_sampling = float(self.dataset.sampling[0]) - else: - default_sampling = float(self.dataset.sampling) - scalebar_defaults = { - 'sampling': default_sampling, - 'units': default_units, - 'length': None, - 'width_px': 1, - 'pad_px': 0.5, - 'color': 'black', - 'loc': 'lower left', - 'fontsize': 12, - 'bold': True, - } - scalebar_defaults.update(scalebar_kwargs) - scalebar_config = ScalebarConfig(**scalebar_defaults) - add_scalebar_to_ax( - ax[0], - array_size=int(self.dataset.shape[0]), - sampling=scalebar_config.sampling, - length_units=scalebar_config.length, - units=scalebar_config.units, - width_px=scalebar_config.width_px, - pad_px=scalebar_config.pad_px, - color=scalebar_config.color, - loc=scalebar_config.loc, - fontsize=scalebar_config.fontsize, - bold=scalebar_config.bold, - ) - - fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) - if plot_rotation: - pos3 = ax[3].get_position() - ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) - b0 = ax[0].get_position() - b2 = ax[2].get_position() - left = b0.x0 - right = b2.x1 - width = right - left - b3 = ax[3].get_position() if plot_rotation else None - - cb_height = 0.02 - cb_pad = 0.02 - y = b0.y0 - cb_pad - cb_height - from matplotlib.cm import ScalarMappable - cax1 = fig.add_axes([left, y, width, cb_height]) - sm_strain = ScalarMappable(norm=norm_strain, cmap=cm_strain) - cbar1 = fig.colorbar(sm_strain, cax=cax1, orientation="horizontal") - cbar1.set_label("Strain (%)", fontsize=title_fs) - cbar1.ax.tick_params(labelsize=12) - - if plot_rotation: - left_r = b3.x0 - width_r = b3.x1 - b3.x0 - cax2 = fig.add_axes([left_r, y, width_r, cb_height]) - sm_rot = ScalarMappable(norm=norm_rot, cmap=cm_rot) - cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation="horizontal") - cbar2.set_label("Rotation (deg)", fontsize=title_fs) - cbar2.ax.tick_params(labelsize=12) - - if plot_gvecs: - from matplotlib.patches import FancyArrowPatch - if not hasattr(self, 'u_ref') or not hasattr(self, 'v_ref'): - print("Warning: u_ref and v_ref not found. Call fit_strain() first.") - return fig, ax - - last_pos = b3 if plot_rotation else b2 - - ref_width = last_pos.width * 0.8 - ref_left = last_pos.x1 - 0.035 - - ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) - ref_ax.set_xlim(-1.5, 1.5) - ref_ax.set_ylim(-1.5, 1.5) - ref_ax.set_aspect('equal') - ref_ax.axis('off') - u_norm = self.u_ref / np.linalg.norm(self.u_ref) - v_norm = self.v_ref / np.linalg.norm(self.v_ref) - - u_row, u_col = u_norm - v_row, v_col = v_norm - arrow_props_ref = dict(arrowstyle='->', lw=3, mutation_scale=25) - - u_arrow = FancyArrowPatch( - (0, 0), (u_col, -u_row), - color='darkred', **arrow_props_ref - ) - ref_ax.add_patch(u_arrow) - - v_arrow = FancyArrowPatch( - (0, 0), (v_col, -v_row), - color='darkblue', **arrow_props_ref - ) - ref_ax.add_patch(v_arrow) - ref_ax.text(u_col * 1.3, -u_row * 1.3, r'$\mathbf{u}_{ref}$', - fontsize=14, fontweight='bold', color='darkred', - ha='center', va='center') - - ref_ax.text(v_col * 1.3, -v_row * 1.3, r'$\mathbf{v}_{ref}$', - fontsize=14, fontweight='bold', color='darkblue', - ha='center', va='center') - - - return fig, ax + # is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) + # self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) + # max_intensity = np.max(self.i0_sum_array) + # if max_intensity == 0: + # return np.zeros_like(self.i0_sum_array) + # self.mask = self.i0_sum_array / max_intensity + # self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) + # if smooth: + # self.mask = np.sin(np.pi / 2 * self.mask) ** 2 + # return self @property def render_mean_refined(self) -> np.ndarray: diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index a62e6318..e42d7723 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -40,19 +40,19 @@ def __init__( self.transform: Dataset2d | None = None self.transform_rotated: Dataset2d | None = None - self.u: NDArray | None = None - self.v: NDArray | None = None self.mean_img_peaks: NDArray | None = None self.mean_img_weights: NDArray | None = None - self.u_fit: Dataset3d | None = None - self.v_fit: Dataset3d | None = None - self.u_peak_fit: Dataset3d | None = None - self.v_peak_fit: Dataset3d | None = None - self.mask_diffraction = np.ones(self.dataset.array.shape[2:]) self.mask_diffraction_inv = np.zeros(self.dataset.array.shape[2:]) + self.u_ref: np.ndarray | None = None + self.v_ref: np.ndarray | None = None + self.u_array: np.ndarray | None = None + self.v_array: np.ndarray | None = None + + self.real_space = True + @classmethod def from_dataset(cls, dataset: Dataset4dstem, *, name: str | None = None) -> "StrainMapAutocorrelation": if not isinstance(dataset, Dataset4dstem): @@ -478,16 +478,8 @@ def fit_lattice_vectors( signal_units="mixed", ) - self.u_fit = Dataset3d.from_shape( - (scan_r, scan_c, 2), - name="u_fit", - signal_units="pixels", - ) - self.v_fit = Dataset3d.from_shape( - (scan_r, scan_c, 2), - name="v_fit", - signal_units="pixels", - ) + self.u_array = np.zeros((scan_r, scan_c, 2)) + self.v_array = np.zeros((scan_r, scan_c, 2)) mode = self.metadata.get("mode", "linear").lower() if mode == "gamma": @@ -505,9 +497,6 @@ def fit_lattice_vectors( u0 = np.asarray(self.u, dtype=float).reshape(2) v0 = np.asarray(self.v, dtype=float).reshape(2) - dp_shape = self.dataset.array.shape[2:] - r_center = dp_shape[0] // 2 - c_center = dp_shape[1] // 2 for r, c in it: dp = self.dataset.array[r, c] * self.mask_diffraction + self.mask_diffraction_inv @@ -538,10 +527,10 @@ def fit_lattice_vectors( self.u_peak_fit.array[r, c, :] = u_fit_abs self.v_peak_fit.array[r, c, :] = v_fit_abs - self.u_fit.array[r, c, 0] = u_fit_abs[0] - self.u_fit.array[r, c, 1] = u_fit_abs[1] - self.v_fit.array[r, c, 0] = v_fit_abs[0] - self.v_fit.array[r, c, 1] = v_fit_abs[1] + self.u_array[r, c, 0] = u_fit_abs[0] + self.u_array[r, c, 1] = u_fit_abs[1] + self.v_array[r, c, 0] = v_fit_abs[0] + self.v_array[r, c, 1] = v_fit_abs[1] self.metadata["fit_refine_gaussian"] = refine_gaussian self.metadata["fit_refine_dft"] = refine_dft @@ -561,15 +550,15 @@ def plot_lattice_vectors( figsize: tuple[float, float] | None = None, **imshow_kwargs: Any, ): - if self.u_fit is None or self.v_fit is None: - raise ValueError("Run fit_lattice_vectors() first to compute u_fit and v_fit.") + if self.u_array is None or self.v_array is None: + raise ValueError("Run fit_lattice_vectors() first to compute u_array and v_array.") if self.u is None or self.v is None: raise ValueError("Run choose_lattice_vector() first to set self.u and self.v.") - im0 = self.u_fit.array[:, :, 0] - im1 = self.u_fit.array[:, :, 1] - im2 = self.v_fit.array[:, :, 0] - im3 = self.v_fit.array[:, :, 1] + im0 = self.u_array[:, :, 0] + im1 = self.u_array[:, :, 1] + im2 = self.v_array[:, :, 0] + im3 = self.v_array[:, :, 1] du0 = im0 - self.u[0] du1 = im1 - self.u[1] @@ -642,267 +631,6 @@ def plot_lattice_vectors( return fig, ax - def fit_strain( - self, - mask_reference=None, - plot_strain=True, - ): - if self.u_fit is None or self.v_fit is None: - raise ValueError("Run fit_lattice_vectors() first to compute u_fit and v_fit.") - - u_fit = self.u_fit.array - v_fit = self.v_fit.array - scan_r, scan_c = u_fit.shape[0], u_fit.shape[1] - - if mask_reference is None: - self.u_ref = np.median(u_fit.reshape(-1, 2), axis=0) - self.v_ref = np.median(v_fit.reshape(-1, 2), axis=0) - else: - m = np.asarray(mask_reference, dtype=bool) - self.u_ref = np.array( - ( - np.median(u_fit[m, 0]), - np.median(u_fit[m, 1]), - ), - dtype=float, - ) - self.v_ref = np.array( - ( - np.median(v_fit[m, 0]), - np.median(v_fit[m, 1]), - ), - dtype=float, - ) - - Uref = np.stack((self.u_ref, self.v_ref), axis=1).astype(float) - det = np.linalg.det(Uref) - if not np.isfinite(det) or abs(det) < 1e-12: - Uref_inv = np.linalg.pinv(Uref) - else: - Uref_inv = np.linalg.inv(Uref) - - self.strain_trans = Dataset4d.from_shape( - (scan_r, scan_c, 2, 2), - name="transformation matrix", - signal_units="fractional", - ) - - for r in range(scan_r): - for c in range(scan_c): - U = np.stack((u_fit[r, c, :], v_fit[r, c, :]), axis=1) - self.strain_trans.array[r, c, :, :] = U @ Uref_inv - - self.strain_raw_err = Dataset2d.from_array( - self.strain_trans.array[:, :, 0, 0] - 1, - name="strain err", - signal_units="fractional", - ) - self.strain_raw_ecc = Dataset2d.from_array( - self.strain_trans.array[:, :, 1, 1] - 1, - name="strain ecc", - signal_units="fractional", - ) - self.strain_raw_erc = Dataset2d.from_array( - self.strain_trans.array[:, :, 1, 0] * 0.5 + self.strain_trans.array[:, :, 0, 1] * 0.5, - name="strain erc", - signal_units="fractional", - ) - self.strain_rotation = Dataset2d.from_array( - self.strain_trans.array[:, :, 1, 0] * -0.5 + self.strain_trans.array[:, :, 0, 1] * 0.5, - name="strain rotation", - signal_units="fractional", - ) - - return self - - - def plot_strain( - self, - ref_u_v=(1.0, 0.0), - rotation_angle=None, - strain_range_percent=(-3.0, 3.0), - rotation_range_degrees=(-2.0, 2.0), - plot_rotation=True, - plot_scalebar=True, - plot_gvecs=False, - cmap_strain="RdBu_r", - cmap_rotation=None, - layout="horizontal", - figsize=(6, 6), - max_shift: tuple[float, float] | None = None, - amp_range: tuple[float, float] | None = None, - **kwargs, - ): - import matplotlib.pyplot as plt - - if cmap_rotation is None: - cmap_rotation = cmap_strain - - if rotation_angle is None: - ref_vec = self.u_ref * ref_u_v[0] + self.v_ref * ref_u_v[1] - ref_angle = np.arctan2(ref_vec[1], ref_vec[0]) - else: - ref_angle = np.deg2rad(rotation_angle) - - angle = ref_angle + np.deg2rad(self.metadata["q_to_r_rotation_ccw_deg"]) - c = np.cos(angle) - s = np.sin(angle) - - err = self.strain_raw_err.array - ecc = self.strain_raw_ecc.array - erc = self.strain_raw_erc.array - - euu = err * (c * c) + 2.0 * erc * (c * s) + ecc * (s * s) - evv = err * (s * s) - 2.0 * erc * (c * s) + ecc * (c * c) - euv = (ecc - err) * (c * s) + erc * (c * c - s * s) - - self.strain_euu = self.strain_raw_err.copy() - self.strain_evv = self.strain_raw_ecc.copy() - self.strain_euv = self.strain_raw_erc.copy() - self.strain_euu.array[...] = euu - self.strain_evv.array[...] = evv - self.strain_euv.array[...] = euv - - alpha = None - if max_shift is not None: - if self.u_fit is None or self.v_fit is None or self.u is None or self.v is None: - raise ValueError("max_shift masking requires u_fit, v_fit, u, v to be available.") - - ur = self.u_fit.array[:, :, 0] - uc = self.u_fit.array[:, :, 1] - vr = self.v_fit.array[:, :, 0] - vc = self.v_fit.array[:, :, 1] - - du0 = ur - self.u[0] - du1 = uc - self.u[1] - dv0 = vr - self.v[0] - dv1 = vc - self.v[1] - - su = du0 * du0 + du1 * du1 - sv = dv0 * dv0 + dv1 * dv1 - sdist2 = 0.5 * (su + sv) - - smin, smax = max_shift - mask = np.clip((sdist2 - smin) / (smax - smin), 0.0, 1.0) - alpha = 1.0 - mask - - if amp_range is not None: - if self.u_peak_fit is None or self.v_peak_fit is None: - raise ValueError("amp_range masking requires u_peak_fit and v_peak_fit to be available.") - a = 0.5 * (self.u_peak_fit.array[:, :, 2] + self.v_peak_fit.array[:, :, 2]) - amin, amax = amp_range - a_mask = np.clip((a - amin) / (amax - amin), 0.0, 1.0) - alpha = a_mask if alpha is None else alpha * a_mask - - if alpha is not None: - alpha = np.asarray(alpha, dtype=float) - good = alpha > 0 - alpha_im = np.where(good, alpha, 1.0) - else: - good = None - alpha_im = None - - if layout != "horizontal": - raise ValueError("layout must be 'horizontal'") - - ncols = 4 if plot_rotation else 3 - fig, ax = plt.subplots(1, ncols, figsize=figsize) - - cm_strain = plt.get_cmap(cmap_strain).copy() - cm_strain.set_bad(color="black") - cm_rot = plt.get_cmap(cmap_rotation).copy() - cm_rot.set_bad(color="black") - - euu_pct = self.strain_euu.array * 100 - evv_pct = self.strain_evv.array * 100 - euv_pct = self.strain_euv.array * 100 - rot_deg = np.rad2deg(self.strain_rotation.array) - - if good is not None and np.any(good): - euu_m = np.ma.array(euu_pct, mask=~good) - evv_m = np.ma.array(evv_pct, mask=~good) - euv_m = np.ma.array(euv_pct, mask=~good) - rot_m = np.ma.array(rot_deg, mask=~good) - else: - euu_m = euu_pct - evv_m = evv_pct - euv_m = euv_pct - rot_m = rot_deg - - title_fs = 16 - im0 = ax[0].imshow( - euu_m, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cm_strain, - alpha=alpha_im, - ) - ax[1].imshow( - evv_m, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cm_strain, - alpha=alpha_im, - ) - ax[2].imshow( - euv_m, - vmin=strain_range_percent[0], - vmax=strain_range_percent[1], - cmap=cm_strain, - alpha=alpha_im, - ) - - ax[0].set_title(r"$\epsilon_{uu}$", fontsize=title_fs) - ax[1].set_title(r"$\epsilon_{vv}$", fontsize=title_fs) - ax[2].set_title(r"$\epsilon_{uv}$", fontsize=title_fs) - - if plot_rotation: - im3 = ax[3].imshow( - rot_m, - vmin=rotation_range_degrees[0], - vmax=rotation_range_degrees[1], - cmap=cm_rot, - alpha=alpha_im, - ) - ax[3].set_title("Rotation", fontsize=title_fs) - - for a in ax: - a.set_xticks([]) - a.set_yticks([]) - a.set_facecolor("black") - - fig.subplots_adjust(left=0.02, right=0.98, top=0.90, bottom=0.16, wspace=0.03) - - b0 = ax[0].get_position() - b2 = ax[2].get_position() - left = b0.x0 - right = b2.x1 - width = right - left - - b3 = ax[3].get_position() if plot_rotation else None - - cb_height = 0.04 - cb_pad = 0.03 - y = b0.y0 - cb_pad - cb_height - - cax1 = fig.add_axes([left, y, width, cb_height]) - cbar1 = fig.colorbar(im0, cax=cax1, orientation="horizontal") - cbar1.set_label("Strain (%)", fontsize=title_fs) - cbar1.ax.tick_params(labelsize=12) - - if plot_rotation: - left_r = b3.x0 - width_r = b3.x1 - b3.x0 - cax2 = fig.add_axes([left_r, y, width_r, cb_height]) - cbar2 = fig.colorbar(im3, cax=cax2, orientation="horizontal") - cbar2.set_label("Rotation (deg)", fontsize=title_fs) - cbar2.ax.tick_params(labelsize=12) - - for a in ax: - a.set_aspect("equal") - - return fig, ax - def _nice_length_units(target: float) -> float: if not np.isfinite(target) or target <= 0: diff --git a/src/quantem/diffraction/strain_fitting.py b/src/quantem/diffraction/strain_fitting.py new file mode 100644 index 00000000..5610abb9 --- /dev/null +++ b/src/quantem/diffraction/strain_fitting.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypeVar, cast + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.cm import ScalarMappable +from matplotlib.colors import Normalize +from matplotlib.patches import FancyArrowPatch + +from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.datastructures.dataset4d import Dataset4d +from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from quantem.core.io.serialize import AutoSerialize +from quantem.core.visualization.visualization_utils import ScalebarConfig, add_scalebar_to_ax + +if TYPE_CHECKING: + from quantem.diffraction.model_fitting import ModelDiffraction + from quantem.diffraction.strain_autocorrelation import StrainMapAutocorrelation + +StrainParent = TypeVar('StrainParent', bound='StrainFittingMixin') + +class StrainFittingMixin(AutoSerialize): + mask: np.ndarray | None = None + i0_sum_array: np.ndarray + strain_raw_err: Dataset2d + strain_raw_ecc: Dataset2d + strain_raw_erc: Dataset2d + strain_rotation: Dataset2d + + def fit_strain( + self, + ) -> "StrainFittingMixin": + parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) + + if not hasattr(parent, 'u_array') or parent.u_array is None: + raise RuntimeError("u_array not available. Set u_array before calling fit_strain().") + if not hasattr(parent, 'v_array') or parent.v_array is None: + raise RuntimeError("v_array not available. Set v_array before calling fit_strain().") + if not hasattr(parent, 'dataset'): + raise RuntimeError("Parent must have 'dataset' attribute.") + + u_fit = parent.u_array + v_fit = parent.v_array + + if self.mask is None: + u_ref = np.median(u_fit.reshape(-1, 2), axis=0) + v_ref = np.median(v_fit.reshape(-1, 2), axis=0) + else: + m = np.asarray(self.mask, dtype=bool) + u_ref = np.array( + ( + np.median(u_fit[m, 0]), + np.median(u_fit[m, 1]), + ), + dtype=float, + ) + v_ref = np.array( + ( + np.median(v_fit[m, 0]), + np.median(v_fit[m, 1]), + ), + dtype=float, + ) + self.u_ref = u_ref + self.v_ref = v_ref + + scan_r = parent.dataset.shape[0] + scan_c = parent.dataset.shape[1] + + Uref = np.stack((u_ref, v_ref), axis=1).astype(float) + strain_trans = np.zeros((scan_r, scan_c, 2, 2)) + for r in range(scan_r): + for c in range(scan_c): + U = np.stack((u_fit[r, c, :], v_fit[r, c, :]), axis=1) + det = np.linalg.det(U) + if not np.isfinite(det) or abs(det) < 1e-12: + U_inv = np.linalg.pinv(U) + else: + U_inv = np.linalg.inv(U) + strain_trans[r, c, :, :] = Uref @ U_inv + const = 1 + if parent.real_space is False: + const = -1 + + self.strain_raw_err = Dataset2d.from_array( + strain_trans[:, :, 0, 0] - 1, + name="strain err", + signal_units="fractional", + ) + self.strain_raw_ecc = Dataset2d.from_array( + strain_trans[:, :, 1, 1] - 1, + name="strain ecc", + signal_units="fractional", + ) + self.strain_raw_erc = Dataset2d.from_array( + strain_trans[:, :, 1, 0] * 0.5 * const + strain_trans[:, :, 0, 1] * 0.5 * const, + name="strain erc", + signal_units="fractional", + ) + self.strain_rotation = Dataset2d.from_array( + strain_trans[:, :, 1, 0] * -0.5 * const + strain_trans[:, :, 0, 1] * 0.5 * const, + name="strain rotation", + signal_units="fractional", + ) + + return self + + def create_mask( + self, + use_radial_method: bool = False, + min_threshold: float = 0.4, + max_threshold: float = 0.6, + exclusion_radius_fraction: float = 0.1, + smooth: bool = True, + peak_intensity_array: np.ndarray | None = None + ): + parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) + + if not hasattr(parent, 'dataset'): + raise RuntimeError("Parent must have 'dataset' attribute.") + + if not isinstance(parent.dataset, (Dataset4d, Dataset4dstem)): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + + scan_r = parent.dataset.shape[0] + scan_c = parent.dataset.shape[1] + self.mask = np.zeros(parent.dataset.shape[:2]) + + i0_sum_array = np.empty(shape=(scan_r, scan_c)) + + if not isinstance(parent.dataset, ("Dataset4dstem")): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + + if use_radial_method: + center_y, center_x = np.array(parent.dataset.shape[:-2]) / 2 + y, x = np.ogrid[:parent.dataset.shape[-2], :parent.dataset.shape[-1]] + radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) + exclusion_radius = exclusion_radius_fraction * parent.dataset.shape[-1] + for r in range(scan_r): + for c in range(scan_c): + dp = parent.dataset.array[r, c] + outside_mask = radius_map > exclusion_radius + i0_sum_array[r, c] = np.sum(dp[outside_mask]) + else: + md = cast('ModelDiffraction', parent) + if not hasattr(md, 'state_individual_refined') or md.state_individual_refined is None: + raise RuntimeError( + "For non-radial masking, ModelDiffraction must have state_individual_refined. " + "Call fit_individual_diffraction_pattern() first, or use use_radial_method=True." + ) + + for r in range(scan_r): + for c in range(scan_c): + pos_state = md.state_individual_refined[r, c] + if pos_state is None: + i0_sum_array[r, c] = 0.0 + continue + + i0_raw = None + uv_indices = None + for key in pos_state.keys(): + if key.endswith('i0_raw'): + i0_raw = pos_state[key].cpu().numpy() + if key.endswith('uv_indices'): + uv_indices = pos_state[key].cpu().numpy() + + if i0_raw is None or uv_indices is None: + i0_sum_array[r, c] = 0.0 + continue + + is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) + i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) + self.i0_sum_array = i0_sum_array + max_intensity = np.max(i0_sum_array) + if max_intensity == 0: + return np.zeros_like(i0_sum_array) + self.mask = i0_sum_array / max_intensity + self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) + if smooth: + self.mask = np.sin(np.pi / 2 * self.mask) ** 2 + return self + + + def plot_strain( + self, + rotation_angle=20, + strain_range_percent=(-3.0, 3.0), + rotation_range_degrees=(-2.0, 2.0), + plot_rotation=True, + plot_gvecs=True, + plot_scalebar=False, + cmap_strain="RdBu_r", + cmap_rotation="PiYG", + layout="horizontal", + figsize=(6, 6), + **kwargs, + ): + parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) + if not hasattr(self, 'strain_raw_err') or self.strain_raw_err is None: + raise RuntimeError("Call fit_strain() first.") + if not hasattr(self, 'strain_raw_ecc') or self.strain_raw_ecc is None: + raise RuntimeError("Call fit_strain() first.") + if not hasattr(self, 'strain_raw_erc') or self.strain_raw_erc is None: + raise RuntimeError("Call fit_strain() first.") + if not hasattr(self, 'strain_rotation') or self.strain_rotation is None: + raise RuntimeError("Call fit_strain() first.") + + if self.mask is None: + self.mask = np.zeros(parent.dataset.shape[:2]) + + if cmap_rotation is None: + cmap_rotation = cmap_strain + + angle = np.deg2rad(rotation_angle) + c = np.cos(angle) + s = np.sin(angle) + + err = self.strain_raw_err.array + ecc = self.strain_raw_ecc.array + erc = self.strain_raw_erc.array + + euu = err * (c * c) + 2.0 * erc * (c * s) + ecc * (s * s) + evv = err * (s * s) - 2.0 * erc * (c * s) + ecc * (c * c) + euv = (ecc - err) * (c * s) + erc * (c * c - s * s) + + strain_euu = self.strain_raw_err.copy() + strain_evv = self.strain_raw_ecc.copy() + strain_euv = self.strain_raw_erc.copy() + strain_euu.array[...] = euu + strain_evv.array[...] = evv + strain_euv.array[...] = euv + + if layout != "horizontal": + raise ValueError("layout must be 'horizontal'") + + ncols = 4 if plot_rotation else 3 + fig, ax = plt.subplots(1, ncols, figsize=figsize) + + cm_strain = plt.get_cmap(cmap_strain).copy() + cm_strain.set_bad(color="black") + cm_rot = plt.get_cmap(cmap_rotation).copy() + cm_rot.set_bad(color="black") + + euu_pct = strain_euu.array * 100 + evv_pct = strain_evv.array * 100 + euv_pct = strain_euv.array * 100 + rot_deg = np.rad2deg(self.strain_rotation.array) + + from matplotlib.colors import Normalize + norm_strain = Normalize(vmin=strain_range_percent[0], vmax=strain_range_percent[1]) + euu_rgb = cm_strain(norm_strain(euu_pct))[:, :, :3] + evv_rgb = cm_strain(norm_strain(evv_pct))[:, :, :3] + euv_rgb = cm_strain(norm_strain(euv_pct))[:, :, :3] + + title_fs = 16 + ax[0].imshow(euu_rgb * self.mask[:, :, np.newaxis],) + ax[1].imshow(evv_rgb * self.mask[:, :, np.newaxis],) + ax[2].imshow(euv_rgb * self.mask[:, :, np.newaxis],) + + ax[0].set_title(r"$\epsilon_{uu}$ $\updownarrow$", fontsize=title_fs) + ax[1].set_title(r"$\epsilon_{vv}$ $\leftrightarrow$", fontsize=title_fs) + ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow\!\swarrow$", fontsize=title_fs) + + if plot_rotation: + norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) + rot_rgb = cm_rot(norm_rot(rot_deg))[:, :, :3] + ax[3].imshow(rot_rgb * self.mask[:, :, np.newaxis],) + ax[3].set_title(r"Rotation $\circlearrowleft$", fontsize=title_fs) + + for a in ax: + a.set_xticks([]) + a.set_yticks([]) + a.set_facecolor("black") + a.set_aspect("equal") + + if plot_scalebar and isinstance(parent.dataset, Dataset4dstem): + default_sampling = 1.0 + default_units = 'pixels' + scalebar_kwargs = {} + for key, value in kwargs.items(): + if key.startswith('scalebar_'): + scalebar_key = key[len('scalebar_'):] + scalebar_kwargs[scalebar_key] = value + + if hasattr(parent.dataset, 'units'): + if isinstance(parent.dataset.units, (tuple, list)): + default_units = str(parent.dataset.units[0]) + else: + default_units = str(parent.dataset.units) + if hasattr(parent.dataset, 'sampling'): + if isinstance(parent.dataset.sampling, (tuple, list, np.ndarray)): + default_sampling = float(parent.dataset.sampling[0]) + else: + default_sampling = float(parent.dataset.sampling) + scalebar_defaults = { + 'sampling': default_sampling, + 'units': default_units, + 'length': None, + 'width_px': 1, + 'pad_px': 0.5, + 'color': 'black', + 'loc': 'lower left', + 'fontsize': 12, + 'bold': True, + } + scalebar_defaults.update(scalebar_kwargs) + scalebar_config = ScalebarConfig(**scalebar_defaults) + add_scalebar_to_ax( + ax[0], + array_size=int(parent.dataset.shape[0]), + sampling=scalebar_config.sampling, + length_units=scalebar_config.length, + units=scalebar_config.units, + width_px=scalebar_config.width_px, + pad_px=scalebar_config.pad_px, + color=scalebar_config.color, + loc=scalebar_config.loc, + fontsize=scalebar_config.fontsize, + bold=scalebar_config.bold, + ) + + fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) + if plot_rotation: + pos3 = ax[3].get_position() + ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) + b0 = ax[0].get_position() + b2 = ax[2].get_position() + left = b0.x0 + right = b2.x1 + width = right - left + b3 = ax[3].get_position() if plot_rotation else None + + cb_height = 0.02 + cb_pad = 0.02 + y = b0.y0 - cb_pad - cb_height + from matplotlib.cm import ScalarMappable + cax1 = fig.add_axes([left, y, width, cb_height]) + sm_strain = ScalarMappable(norm=norm_strain, cmap=cm_strain) + cbar1 = fig.colorbar(sm_strain, cax=cax1, orientation="horizontal") + cbar1.set_label("Strain (%)", fontsize=title_fs) + cbar1.ax.tick_params(labelsize=12) + + if plot_rotation: + left_r = b3.x0 + width_r = b3.x1 - b3.x0 + cax2 = fig.add_axes([left_r, y, width_r, cb_height]) + sm_rot = ScalarMappable(norm=norm_rot, cmap=cm_rot) + cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation="horizontal") + cbar2.set_label("Rotation (deg)", fontsize=title_fs) + cbar2.ax.tick_params(labelsize=12) + + if plot_gvecs: + from matplotlib.patches import FancyArrowPatch + if not hasattr(parent, 'u_ref') or not hasattr(parent, 'v_ref'): + print("Warning: u_ref and v_ref not found. Call fit_strain() first.") + return fig, ax + if parent.u_ref is None or parent.v_ref is None: + print("Warning: u_ref and v_ref not found. Call fit_strain() first.") + return fig, ax + + last_pos = b3 if plot_rotation else b2 + + ref_width = last_pos.width * 0.8 + ref_left = last_pos.x1 - 0.035 + + ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) + ref_ax.set_xlim(-1.5, 1.5) + ref_ax.set_ylim(-1.5, 1.5) + ref_ax.set_aspect('equal') + ref_ax.axis('off') + u_norm = parent.u_ref / np.linalg.norm(parent.u_ref) + v_norm = parent.v_ref / np.linalg.norm(parent.v_ref) + + u_row, u_col = u_norm + v_row, v_col = v_norm + arrow_props_ref = dict(arrowstyle='->', lw=3, mutation_scale=25) + + u_arrow = FancyArrowPatch( + (0, 0), (u_col, -u_row), + color='darkred', **arrow_props_ref + ) + ref_ax.add_patch(u_arrow) + + v_arrow = FancyArrowPatch( + (0, 0), (v_col, -v_row), + color='darkblue', **arrow_props_ref + ) + ref_ax.add_patch(v_arrow) + ref_ax.text(u_col * 1.3, -u_row * 1.3, r'$\mathbf{u}_{ref}$', + fontsize=14, fontweight='bold', color='darkred', + ha='center', va='center') + + ref_ax.text(v_col * 1.3, -v_row * 1.3, r'$\mathbf{v}_{ref}$', + fontsize=14, fontweight='bold', color='darkblue', + ha='center', va='center') + + + return fig, ax + From 3062f150eb7203171241590bd5f934d914b66132 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Mon, 25 May 2026 21:29:30 -0700 Subject: [PATCH 73/88] strain fitting class, updated cepstral and plotting --- src/quantem/diffraction/model_fitting.py | 4 +- .../diffraction/strain_autocorrelation.py | 9 +- ...ain_fitting.py => strain_fitting_mixin.py} | 113 +++++++++++------- 3 files changed, 81 insertions(+), 45 deletions(-) rename src/quantem/diffraction/{strain_fitting.py => strain_fitting_mixin.py} (83%) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 5f46a379..66197ead 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -22,8 +22,8 @@ from quantem.core.io.serialize import AutoSerialize from quantem.core.ml.optimizer_mixin import OptimizerType, SchedulerType from quantem.core.utils.imaging_utils import cross_correlation_shift -from quantem.core.visualization.visualization_utils import ScalebarConfig, add_scalebar_to_ax from quantem.diffraction.model_fitting_visualizations import ModelDiffractionVisualizations +from quantem.diffraction.strain_fitting_mixin import StrainFittingMixin def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: @@ -36,7 +36,7 @@ def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) return float(cast(float | int, value)) -class ModelDiffraction(ModelDiffractionVisualizations, FitBase, AutoSerialize): +class ModelDiffraction(ModelDiffractionVisualizations, StrainFittingMixin, FitBase, AutoSerialize): _token = object() DEFAULT_LR = 5e-2 DEFAULT_OPTIMIZER_TYPE = "adam" diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index e42d7723..bbe1294a 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -17,9 +17,10 @@ from quantem.core.utils.utils import electron_wavelength_angstrom from quantem.core.utils.validators import ensure_valid_array from quantem.core.visualization import ScalebarConfig, show_2d +from quantem.diffraction.strain_fitting_mixin import StrainFittingMixin -class StrainMapAutocorrelation(AutoSerialize): +class StrainMapAutocorrelation(AutoSerialize, StrainFittingMixin): _token = object() def __init__( @@ -306,6 +307,8 @@ def plot_single_transform( ): if self.transform is None or self.transform_rotated is None: raise ValueError("Run preprocess() first to compute transform images.") + if self.u_peak_fit is None or self.v_peak_fit is None: + raise ValueError("Run fit_lattice_vectors() first.") sampling = np.mean(self.metadata["sampling_real"]) units = self.metadata.get("real_units", r"$\mathrm{\AA}$") @@ -361,8 +364,8 @@ def plot_single_transform( _overlay_lattice_vectors( ax=ax, shape=self.transform.shape, - u_rc= self.u_fit.array[row, col, :], - v_rc=self.v_fit.array[row, col, :], + u_rc= self.u_peak_fit.array[row, col, :2], + v_rc=self.v_peak_fit.array[row, col, :2], rot_ccw_deg=rot_ccw, q_transpose=q_transpose, peaks_plot=self.mean_img_peaks, diff --git a/src/quantem/diffraction/strain_fitting.py b/src/quantem/diffraction/strain_fitting_mixin.py similarity index 83% rename from src/quantem/diffraction/strain_fitting.py rename to src/quantem/diffraction/strain_fitting_mixin.py index 5610abb9..32300e89 100644 --- a/src/quantem/diffraction/strain_fitting.py +++ b/src/quantem/diffraction/strain_fitting_mixin.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, TypeVar, cast import matplotlib.pyplot as plt import numpy as np @@ -11,7 +11,6 @@ from quantem.core.datastructures.dataset2d import Dataset2d from quantem.core.datastructures.dataset4d import Dataset4d from quantem.core.datastructures.dataset4dstem import Dataset4dstem -from quantem.core.io.serialize import AutoSerialize from quantem.core.visualization.visualization_utils import ScalebarConfig, add_scalebar_to_ax if TYPE_CHECKING: @@ -20,7 +19,7 @@ StrainParent = TypeVar('StrainParent', bound='StrainFittingMixin') -class StrainFittingMixin(AutoSerialize): +class StrainFittingMixin: mask: np.ndarray | None = None i0_sum_array: np.ndarray strain_raw_err: Dataset2d @@ -30,6 +29,7 @@ class StrainFittingMixin(AutoSerialize): def fit_strain( self, + plot_strain = False, ) -> "StrainFittingMixin": parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) @@ -103,7 +103,8 @@ def fit_strain( name="strain rotation", signal_units="fractional", ) - + if plot_strain: + self.plot_strain() return self def create_mask( @@ -113,7 +114,6 @@ def create_mask( max_threshold: float = 0.6, exclusion_radius_fraction: float = 0.1, smooth: bool = True, - peak_intensity_array: np.ndarray | None = None ): parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) @@ -193,7 +193,7 @@ def plot_strain( cmap_strain="RdBu_r", cmap_rotation="PiYG", layout="horizontal", - figsize=(6, 6), + figsize=None, **kwargs, ): parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) @@ -212,6 +212,9 @@ def plot_strain( if cmap_rotation is None: cmap_rotation = cmap_strain + if layout not in ["horizontal", "vertical"]: + raise ValueError("layout must be 'horizontal' or 'vertical'") + angle = np.deg2rad(rotation_angle) c = np.cos(angle) s = np.sin(angle) @@ -231,11 +234,16 @@ def plot_strain( strain_evv.array[...] = evv strain_euv.array[...] = euv - if layout != "horizontal": - raise ValueError("layout must be 'horizontal'") - ncols = 4 if plot_rotation else 3 - fig, ax = plt.subplots(1, ncols, figsize=figsize) + is_horizontal = layout == "horizontal" + + if figsize is None: + figsize = (6, 6) if is_horizontal else (6, 8) + + if is_horizontal: + fig, ax = plt.subplots(1, ncols, figsize=figsize) + else: + fig, ax = plt.subplots(ncols, 1, figsize=figsize) cm_strain = plt.get_cmap(cmap_strain).copy() cm_strain.set_bad(color="black") @@ -247,7 +255,6 @@ def plot_strain( euv_pct = strain_euv.array * 100 rot_deg = np.rad2deg(self.strain_rotation.array) - from matplotlib.colors import Normalize norm_strain = Normalize(vmin=strain_range_percent[0], vmax=strain_range_percent[1]) euu_rgb = cm_strain(norm_strain(euu_pct))[:, :, :3] evv_rgb = cm_strain(norm_strain(evv_pct))[:, :, :3] @@ -260,7 +267,7 @@ def plot_strain( ax[0].set_title(r"$\epsilon_{uu}$ $\updownarrow$", fontsize=title_fs) ax[1].set_title(r"$\epsilon_{vv}$ $\leftrightarrow$", fontsize=title_fs) - ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow\!\swarrow$", fontsize=title_fs) + ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow\!\!\!\!\swarrow$", fontsize=title_fs) if plot_rotation: norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) @@ -320,38 +327,61 @@ def plot_strain( bold=scalebar_config.bold, ) - fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) - if plot_rotation: - pos3 = ax[3].get_position() - ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) - b0 = ax[0].get_position() - b2 = ax[2].get_position() - left = b0.x0 - right = b2.x1 - width = right - left - b3 = ax[3].get_position() if plot_rotation else None - - cb_height = 0.02 - cb_pad = 0.02 - y = b0.y0 - cb_pad - cb_height - from matplotlib.cm import ScalarMappable - cax1 = fig.add_axes([left, y, width, cb_height]) + if is_horizontal: + fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) + if plot_rotation: + pos3 = ax[3].get_position() + ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) + + cb_orientation = "horizontal" + cb_size = 0.02 + cb_pad = 0.02 + + b0 = ax[0].get_position() + b2 = ax[2].get_position() + strain_cb_pos = [b0.x0, b0.y0 - cb_pad - cb_size, b2.x1 - b0.x0, cb_size] + + if plot_rotation: + b3 = ax[3].get_position() + rot_cb_pos = [b3.x0, b0.y0 - cb_pad - cb_size, b3.x1 - b3.x0, cb_size] + last_pos = b3 + else: + rot_cb_pos = None + last_pos = b2 + + else: + fig.subplots_adjust(left=0.04, right=0.80, top=0.98, bottom=0.04, hspace=0.15) + + cb_orientation = "vertical" + cb_size = 0.02 + cb_pad = 0.02 + + b0 = ax[0].get_position() + b2 = ax[2].get_position() + strain_cb_pos = [b0.x1 + cb_pad, b2.y0, cb_size, b0.y1 - b2.y0] + + if plot_rotation: + b3 = ax[3].get_position() + rot_cb_pos = [b0.x1 + cb_pad, b3.y0, cb_size, b3.y1 - b3.y0] + last_pos = b3 + else: + rot_cb_pos = None + last_pos = b2 + + cax1 = fig.add_axes(strain_cb_pos) sm_strain = ScalarMappable(norm=norm_strain, cmap=cm_strain) - cbar1 = fig.colorbar(sm_strain, cax=cax1, orientation="horizontal") + cbar1 = fig.colorbar(sm_strain, cax=cax1, orientation=cb_orientation) cbar1.set_label("Strain (%)", fontsize=title_fs) cbar1.ax.tick_params(labelsize=12) - if plot_rotation: - left_r = b3.x0 - width_r = b3.x1 - b3.x0 - cax2 = fig.add_axes([left_r, y, width_r, cb_height]) + if plot_rotation and rot_cb_pos is not None: + cax2 = fig.add_axes(rot_cb_pos) sm_rot = ScalarMappable(norm=norm_rot, cmap=cm_rot) - cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation="horizontal") + cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation=cb_orientation) cbar2.set_label("Rotation (deg)", fontsize=title_fs) cbar2.ax.tick_params(labelsize=12) if plot_gvecs: - from matplotlib.patches import FancyArrowPatch if not hasattr(parent, 'u_ref') or not hasattr(parent, 'v_ref'): print("Warning: u_ref and v_ref not found. Call fit_strain() first.") return fig, ax @@ -359,12 +389,15 @@ def plot_strain( print("Warning: u_ref and v_ref not found. Call fit_strain() first.") return fig, ax - last_pos = b3 if plot_rotation else b2 - - ref_width = last_pos.width * 0.8 - ref_left = last_pos.x1 - 0.035 + if is_horizontal: + ref_width = last_pos.width * 0.8 + ref_left = last_pos.x1 - 0.035 + ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) + else: + ref_height = last_pos.height * 0.8 + ref_bottom = last_pos.y0 + 0.02 + ref_ax = fig.add_axes([last_pos.x0, ref_bottom, last_pos.width, ref_height]) - ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) ref_ax.set_xlim(-1.5, 1.5) ref_ax.set_ylim(-1.5, 1.5) ref_ax.set_aspect('equal') From a9df33596f776e24d1193093c0336c410a342eb3 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Tue, 26 May 2026 23:31:42 -0700 Subject: [PATCH 74/88] updated strain class and implementation in model_fitting --- src/quantem/diffraction/model_fitting.py | 162 ++++++++----- .../diffraction/strain_autocorrelation.py | 96 +++++++- ...tting_mixin.py => strain_visualization.py} | 226 +++++++----------- 3 files changed, 287 insertions(+), 197 deletions(-) rename src/quantem/diffraction/{strain_fitting_mixin.py => strain_visualization.py} (62%) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 66197ead..1600f421 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -20,10 +20,9 @@ ) from quantem.core.fitting.diffraction import DiskTemplate, SyntheticDiskLattice from quantem.core.io.serialize import AutoSerialize -from quantem.core.ml.optimizer_mixin import OptimizerType, SchedulerType from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.diffraction.model_fitting_visualizations import ModelDiffractionVisualizations -from quantem.diffraction.strain_fitting_mixin import StrainFittingMixin +from quantem.diffraction.strain_visualization import StrainFitting def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: @@ -36,7 +35,7 @@ def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) return float(cast(float | int, value)) -class ModelDiffraction(ModelDiffractionVisualizations, StrainFittingMixin, FitBase, AutoSerialize): +class ModelDiffraction(ModelDiffractionVisualizations, FitBase, AutoSerialize): _token = object() DEFAULT_LR = 5e-2 DEFAULT_OPTIMIZER_TYPE = "adam" @@ -982,65 +981,112 @@ def render_indivdual_pattern(self, row, col): return self._render_state_array(self.state_individual_refined[row, col]) - # def create_mask( - # self, - # use_radial_method: bool = False, - # min_threshold: float = 0.4, - # max_threshold: float = 0.6, - # exclusion_radius_fraction: float = 0.1, - # smooth: bool = True, - # ): - # scan_r = self.dataset.shape[0] - # scan_c = self.dataset.shape[1] - # self.mask = np.zeros(self.dataset.shape[:2]) + def create_mask( + self, + use_radial_method: bool = False, + min_threshold: float = 0.4, + max_threshold: float = 0.6, + exclusion_radius_fraction: float = 0.1, + smooth: bool = True, + ): + scan_r = self.dataset.shape[0] + scan_c = self.dataset.shape[1] + self.mask = np.zeros(self.dataset.shape[:2]) - # self.i0_sum_array = np.empty(shape=(scan_r, scan_c)) + self.i0_sum_array = np.empty(shape=(scan_r, scan_c)) - # if self.state_individual_refined is None: - # raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") - # if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): - # raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") - - # if use_radial_method: - # center_y, center_x = np.array(self.dataset.shape[:-2]) / 2 - # y, x = np.ogrid[:self.dataset.shape[-2], :self.dataset.shape[-1]] - # radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) - # exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] - # for r in range(scan_r): - # for c in range(scan_c): - # dp = self.dataset.array[r, c] - # outside_mask = radius_map > exclusion_radius - # self.i0_sum_array[r, c] = np.sum(dp[outside_mask]) - # else: - # for r in range(scan_r): - # for c in range(scan_c): - # pos_state = self.state_individual_refined[r, c] - # if pos_state is None: - # self.i0_sum_array[r, c] = 0.0 - # continue - - # i0_raw = None - # uv_indices = None - # for key in pos_state.keys(): - # if key.endswith('i0_raw'): - # i0_raw = pos_state[key].cpu().numpy() - # if key.endswith('uv_indices'): - # uv_indices = pos_state[key].cpu().numpy() + if self.state_individual_refined is None: + raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") + if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + + if use_radial_method: + center_y, center_x = np.array(self.dataset.shape[:-2]) / 2 + y, x = np.ogrid[:self.dataset.shape[-2], :self.dataset.shape[-1]] + radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) + exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] + for r in range(scan_r): + for c in range(scan_c): + dp = self.dataset.array[r, c] + outside_mask = radius_map > exclusion_radius + self.i0_sum_array[r, c] = np.sum(dp[outside_mask]) + else: + for r in range(scan_r): + for c in range(scan_c): + pos_state = self.state_individual_refined[r, c] + if pos_state is None: + self.i0_sum_array[r, c] = 0.0 + continue + + i0_raw = None + uv_indices = None + for key in pos_state.keys(): + if key.endswith('i0_raw'): + i0_raw = pos_state[key].cpu().numpy() + if key.endswith('uv_indices'): + uv_indices = pos_state[key].cpu().numpy() - # if i0_raw is None or uv_indices is None: - # self.i0_sum_array[r, c] = 0.0 - # continue + if i0_raw is None or uv_indices is None: + self.i0_sum_array[r, c] = 0.0 + continue - # is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) - # self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) - # max_intensity = np.max(self.i0_sum_array) - # if max_intensity == 0: - # return np.zeros_like(self.i0_sum_array) - # self.mask = self.i0_sum_array / max_intensity - # self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) - # if smooth: - # self.mask = np.sin(np.pi / 2 * self.mask) ** 2 - # return self + is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) + self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) + max_intensity = np.max(self.i0_sum_array) + if max_intensity == 0: + return np.zeros_like(self.i0_sum_array) + self.mask = self.i0_sum_array / max_intensity + self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) + if smooth: + self.mask = np.sin(np.pi / 2 * self.mask) ** 2 + return self + + def initilize_strain_class( + self, + u_ref: np.ndarray | None = None, + v_ref: np.ndarray | None = None, + )->StrainFitting: + if self.u_array is None or self.v_array is None: + raise RuntimeWarning("Need to run fit_lattice_vectors before initilizing strain class") + if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + + default_units = None + default_sampling = None + if hasattr(self.dataset, 'units'): + if isinstance(self.dataset.units, (tuple, list)): + default_units = str(self.dataset.units[0]) + else: + default_units = str(self.dataset.units) + if hasattr(self.dataset, 'sampling'): + if isinstance(self.dataset.sampling, (tuple, list, np.ndarray)): + default_sampling = float(self.dataset.sampling[0]) + else: + default_sampling = float(self.dataset.sampling) + + if self.mask is not None: + return StrainFitting( + u_array = self.u_array, + v_array = self.v_array, + ds_shape = self.dataset.shape, + real_space = self.real_space, + u_ref = u_ref, + v_ref = v_ref, + mask = self.mask, + ds_sampling=default_sampling, + ds_units = default_units, + ) + else: + return StrainFitting( + u_array = self.u_array, + v_array = self.v_array, + ds_shape = self.dataset.shape, + real_space = self.real_space, + u_ref = u_ref, + v_ref = v_ref, + ds_sampling=default_sampling, + ds_units = default_units, + ) @property def render_mean_refined(self) -> np.ndarray: diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index bbe1294a..79fa4de0 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -17,10 +17,10 @@ from quantem.core.utils.utils import electron_wavelength_angstrom from quantem.core.utils.validators import ensure_valid_array from quantem.core.visualization import ScalebarConfig, show_2d -from quantem.diffraction.strain_fitting_mixin import StrainFittingMixin +from quantem.diffraction.strain_visualization import StrainFitting -class StrainMapAutocorrelation(AutoSerialize, StrainFittingMixin): +class StrainMapAutocorrelation(AutoSerialize): _token = object() def __init__( @@ -51,6 +51,7 @@ def __init__( self.v_ref: np.ndarray | None = None self.u_array: np.ndarray | None = None self.v_array: np.ndarray | None = None + self.mask: np.ndarray | None = None self.real_space = True @@ -544,6 +545,97 @@ def fit_lattice_vectors( return self + def create_mask( + self, + use_radial_method: bool = False, + min_threshold: float = 0.4, + max_threshold: float = 0.6, + exclusion_radius_fraction: float = 0.1, + smooth: bool = True, + ): + if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + + scan_r = self.dataset.shape[0] + scan_c = self.dataset.shape[1] + self.mask = np.ones(self.dataset.shape[:2]) + + i0_sum_array = np.empty(shape=(scan_r, scan_c)) + + if use_radial_method: + center_y, center_x = np.array(self.dataset.shape[:-2]) / 2 + y, x = np.ogrid[:self.dataset.shape[-2], :self.dataset.shape[-1]] + radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) + exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] + for r in range(scan_r): + for c in range(scan_c): + dp = self.dataset.array[r, c] + outside_mask = radius_map > exclusion_radius + i0_sum_array[r, c] = np.sum(dp[outside_mask]) + else: + if self.u_peak_fit is None or self.v_peak_fit is None: + raise RuntimeError("For intensity-based masking, run fit_lattice_vectors() first.") + u_amplitudes = self.u_peak_fit.array[:, :, 2] + v_amplitudes = self.v_peak_fit.array[:, :, 2] + i0_sum_array = (u_amplitudes + v_amplitudes) / 2.0 + + max_intensity = np.max(i0_sum_array) + if max_intensity == 0: + return np.ones_like(i0_sum_array) + self.mask = i0_sum_array / max_intensity + self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) + if smooth: + self.mask = np.sin(np.pi / 2 * self.mask) ** 2 + return self + + + def initilize_strain_class( + self, + u_ref: np.ndarray | None = None, + v_ref: np.ndarray | None = None, + )->StrainFitting: + if self.u_array is None or self.v_array is None: + raise RuntimeWarning("Need to run fit_lattice_vectors before initilizing strain class") + if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + + default_units = None + default_sampling = None + if hasattr(self.dataset, 'units'): + if isinstance(self.dataset.units, (tuple, list)): + default_units = str(self.dataset.units[0]) + else: + default_units = str(self.dataset.units) + if hasattr(self.dataset, 'sampling'): + if isinstance(self.dataset.sampling, (tuple, list, np.ndarray)): + default_sampling = float(self.dataset.sampling[0]) + else: + default_sampling = float(self.dataset.sampling) + + if self.mask is not None: + return StrainFitting( + u_array = self.u_array, + v_array = self.v_array, + ds_shape = self.dataset.shape, + real_space = self.real_space, + u_ref = u_ref, + v_ref = v_ref, + mask = self.mask, + ds_sampling=default_sampling, + ds_units = default_units, + ) + else: + return StrainFitting( + u_array = self.u_array, + v_array = self.v_array, + ds_shape = self.dataset.shape, + real_space = self.real_space, + u_ref = u_ref, + v_ref = v_ref, + ds_sampling=default_sampling, + ds_units = default_units, + ) + def plot_lattice_vectors( self, subtract_mean: bool = True, diff --git a/src/quantem/diffraction/strain_fitting_mixin.py b/src/quantem/diffraction/strain_visualization.py similarity index 62% rename from src/quantem/diffraction/strain_fitting_mixin.py rename to src/quantem/diffraction/strain_visualization.py index 32300e89..f5c7ee35 100644 --- a/src/quantem/diffraction/strain_fitting_mixin.py +++ b/src/quantem/diffraction/strain_visualization.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeVar, cast - import matplotlib.pyplot as plt import numpy as np from matplotlib.cm import ScalarMappable @@ -9,64 +7,106 @@ from matplotlib.patches import FancyArrowPatch from quantem.core.datastructures.dataset2d import Dataset2d -from quantem.core.datastructures.dataset4d import Dataset4d -from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from quantem.core.io.serialize import AutoSerialize from quantem.core.visualization.visualization_utils import ScalebarConfig, add_scalebar_to_ax -if TYPE_CHECKING: - from quantem.diffraction.model_fitting import ModelDiffraction - from quantem.diffraction.strain_autocorrelation import StrainMapAutocorrelation - -StrainParent = TypeVar('StrainParent', bound='StrainFittingMixin') -class StrainFittingMixin: +class StrainFitting(AutoSerialize): mask: np.ndarray | None = None - i0_sum_array: np.ndarray + real_space: bool = False + strain_raw_err: Dataset2d strain_raw_ecc: Dataset2d strain_raw_erc: Dataset2d strain_rotation: Dataset2d + u_ref: np.ndarray | None = None + v_ref: np.ndarray | None = None + u_array: np.ndarray + v_array: np.ndarray + + ds_sampling = 1.0 + ds_units = 'pixels' + ds_shape: tuple[int, ...] + + + def __init__( + self, + u_array: np.ndarray, + v_array: np.ndarray, + ds_shape: tuple[int, ...], + real_space: bool, + + u_ref: np.ndarray | None = None, + v_ref: np.ndarray | None = None, + mask: np.ndarray | None = None, + + ds_sampling: float | None = None, + ds_units: str | None = None, + ): + super().__init__() + self.u_array = u_array + self.v_array = v_array + + self.ds_shape = ds_shape + self.real_space = real_space + + self.ds_sampling = 1.0 if ds_sampling is None else ds_sampling + self.ds_units = 'pixels' if ds_units is None else ds_units + + self.mask = np.ones(ds_shape[:2]) if mask is None else mask + self.mask = (self.mask - np.min(self.mask))/np.linalg.norm(self.mask) + + self.u_ref = u_ref if u_ref is not None else None + self.v_ref = v_ref if v_ref is not None else None + + + def fit_strain( self, plot_strain = False, - ) -> "StrainFittingMixin": - parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) + strain_mask: np.ndarray | None = None, + ) -> "StrainFitting": - if not hasattr(parent, 'u_array') or parent.u_array is None: + if self.u_array is None: raise RuntimeError("u_array not available. Set u_array before calling fit_strain().") - if not hasattr(parent, 'v_array') or parent.v_array is None: + if self.v_array is None: raise RuntimeError("v_array not available. Set v_array before calling fit_strain().") - if not hasattr(parent, 'dataset'): - raise RuntimeError("Parent must have 'dataset' attribute.") - u_fit = parent.u_array - v_fit = parent.v_array + u_fit = self.u_array + v_fit = self.v_array - if self.mask is None: + if strain_mask is not None: + m = np.asarray((strain_mask==1), dtype=bool) + u_ref = np.array( + (np.median(u_fit[m, 0]), np.median(u_fit[m, 1])), + dtype=float, + ) + v_ref = np.array( + (np.median(v_fit[m, 0]), np.median(v_fit[m, 1])), + dtype=float, + ) + elif self.mask is None: u_ref = np.median(u_fit.reshape(-1, 2), axis=0) v_ref = np.median(v_fit.reshape(-1, 2), axis=0) else: - m = np.asarray(self.mask, dtype=bool) + m = np.asarray((self.mask==1), dtype=bool) u_ref = np.array( - ( - np.median(u_fit[m, 0]), - np.median(u_fit[m, 1]), - ), + (np.median(u_fit[m, 0]), np.median(u_fit[m, 1])), dtype=float, ) v_ref = np.array( - ( - np.median(v_fit[m, 0]), - np.median(v_fit[m, 1]), - ), + (np.median(v_fit[m, 0]), np.median(v_fit[m, 1])), dtype=float, ) + if self.u_ref is not None: + u_ref = self.u_ref + if self.v_ref is not None: + v_ref = self.v_ref self.u_ref = u_ref self.v_ref = v_ref - - scan_r = parent.dataset.shape[0] - scan_c = parent.dataset.shape[1] + scan_r = self.ds_shape[0] + scan_c = self.ds_shape[1] Uref = np.stack((u_ref, v_ref), axis=1).astype(float) strain_trans = np.zeros((scan_r, scan_c, 2, 2)) @@ -80,7 +120,7 @@ def fit_strain( U_inv = np.linalg.inv(U) strain_trans[r, c, :, :] = Uref @ U_inv const = 1 - if parent.real_space is False: + if self.real_space is False: const = -1 self.strain_raw_err = Dataset2d.from_array( @@ -106,80 +146,6 @@ def fit_strain( if plot_strain: self.plot_strain() return self - - def create_mask( - self, - use_radial_method: bool = False, - min_threshold: float = 0.4, - max_threshold: float = 0.6, - exclusion_radius_fraction: float = 0.1, - smooth: bool = True, - ): - parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) - - if not hasattr(parent, 'dataset'): - raise RuntimeError("Parent must have 'dataset' attribute.") - - if not isinstance(parent.dataset, (Dataset4d, Dataset4dstem)): - raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") - - scan_r = parent.dataset.shape[0] - scan_c = parent.dataset.shape[1] - self.mask = np.zeros(parent.dataset.shape[:2]) - - i0_sum_array = np.empty(shape=(scan_r, scan_c)) - - if not isinstance(parent.dataset, ("Dataset4dstem")): - raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") - - if use_radial_method: - center_y, center_x = np.array(parent.dataset.shape[:-2]) / 2 - y, x = np.ogrid[:parent.dataset.shape[-2], :parent.dataset.shape[-1]] - radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) - exclusion_radius = exclusion_radius_fraction * parent.dataset.shape[-1] - for r in range(scan_r): - for c in range(scan_c): - dp = parent.dataset.array[r, c] - outside_mask = radius_map > exclusion_radius - i0_sum_array[r, c] = np.sum(dp[outside_mask]) - else: - md = cast('ModelDiffraction', parent) - if not hasattr(md, 'state_individual_refined') or md.state_individual_refined is None: - raise RuntimeError( - "For non-radial masking, ModelDiffraction must have state_individual_refined. " - "Call fit_individual_diffraction_pattern() first, or use use_radial_method=True." - ) - - for r in range(scan_r): - for c in range(scan_c): - pos_state = md.state_individual_refined[r, c] - if pos_state is None: - i0_sum_array[r, c] = 0.0 - continue - - i0_raw = None - uv_indices = None - for key in pos_state.keys(): - if key.endswith('i0_raw'): - i0_raw = pos_state[key].cpu().numpy() - if key.endswith('uv_indices'): - uv_indices = pos_state[key].cpu().numpy() - - if i0_raw is None or uv_indices is None: - i0_sum_array[r, c] = 0.0 - continue - - is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) - i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) - self.i0_sum_array = i0_sum_array - max_intensity = np.max(i0_sum_array) - if max_intensity == 0: - return np.zeros_like(i0_sum_array) - self.mask = i0_sum_array / max_intensity - self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) - if smooth: - self.mask = np.sin(np.pi / 2 * self.mask) ** 2 - return self def plot_strain( @@ -196,7 +162,6 @@ def plot_strain( figsize=None, **kwargs, ): - parent = cast('ModelDiffraction | StrainMapAutocorrelation', self) if not hasattr(self, 'strain_raw_err') or self.strain_raw_err is None: raise RuntimeError("Call fit_strain() first.") if not hasattr(self, 'strain_raw_ecc') or self.strain_raw_ecc is None: @@ -207,7 +172,7 @@ def plot_strain( raise RuntimeError("Call fit_strain() first.") if self.mask is None: - self.mask = np.zeros(parent.dataset.shape[:2]) + self.mask = np.zeros(self.ds_shape[:2]) if cmap_rotation is None: cmap_rotation = cmap_strain @@ -267,7 +232,7 @@ def plot_strain( ax[0].set_title(r"$\epsilon_{uu}$ $\updownarrow$", fontsize=title_fs) ax[1].set_title(r"$\epsilon_{vv}$ $\leftrightarrow$", fontsize=title_fs) - ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow\!\!\!\!\swarrow$", fontsize=title_fs) + ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow\!\!\!\!\!\!\!\!\!\:\swarrow$", fontsize=title_fs) if plot_rotation: norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) @@ -281,28 +246,16 @@ def plot_strain( a.set_facecolor("black") a.set_aspect("equal") - if plot_scalebar and isinstance(parent.dataset, Dataset4dstem): - default_sampling = 1.0 - default_units = 'pixels' + if plot_scalebar: scalebar_kwargs = {} for key, value in kwargs.items(): if key.startswith('scalebar_'): scalebar_key = key[len('scalebar_'):] scalebar_kwargs[scalebar_key] = value - if hasattr(parent.dataset, 'units'): - if isinstance(parent.dataset.units, (tuple, list)): - default_units = str(parent.dataset.units[0]) - else: - default_units = str(parent.dataset.units) - if hasattr(parent.dataset, 'sampling'): - if isinstance(parent.dataset.sampling, (tuple, list, np.ndarray)): - default_sampling = float(parent.dataset.sampling[0]) - else: - default_sampling = float(parent.dataset.sampling) scalebar_defaults = { - 'sampling': default_sampling, - 'units': default_units, + 'sampling': self.ds_sampling, + 'units': self.ds_units, 'length': None, 'width_px': 1, 'pad_px': 0.5, @@ -315,7 +268,7 @@ def plot_strain( scalebar_config = ScalebarConfig(**scalebar_defaults) add_scalebar_to_ax( ax[0], - array_size=int(parent.dataset.shape[0]), + array_size=int(self.ds_shape[0]), sampling=scalebar_config.sampling, length_units=scalebar_config.length, units=scalebar_config.units, @@ -382,10 +335,7 @@ def plot_strain( cbar2.ax.tick_params(labelsize=12) if plot_gvecs: - if not hasattr(parent, 'u_ref') or not hasattr(parent, 'v_ref'): - print("Warning: u_ref and v_ref not found. Call fit_strain() first.") - return fig, ax - if parent.u_ref is None or parent.v_ref is None: + if self.u_ref is None or self.v_ref is None: print("Warning: u_ref and v_ref not found. Call fit_strain() first.") return fig, ax @@ -394,16 +344,18 @@ def plot_strain( ref_left = last_pos.x1 - 0.035 ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) else: - ref_height = last_pos.height * 0.8 - ref_bottom = last_pos.y0 + 0.02 - ref_ax = fig.add_axes([last_pos.x0, ref_bottom, last_pos.width, ref_height]) + ref_height = last_pos.height * 0.5 + ref_bottom = last_pos.y0 - ref_height - 0.05 + ref_width = last_pos.width * 0.8 + ref_left = last_pos.x0 + (last_pos.width - ref_width) / 2 # Center it + ref_ax = fig.add_axes([ref_left, ref_bottom, ref_width, ref_height]) ref_ax.set_xlim(-1.5, 1.5) ref_ax.set_ylim(-1.5, 1.5) ref_ax.set_aspect('equal') ref_ax.axis('off') - u_norm = parent.u_ref / np.linalg.norm(parent.u_ref) - v_norm = parent.v_ref / np.linalg.norm(parent.v_ref) + u_norm = self.u_ref / np.linalg.norm(self.u_ref) + v_norm = self.v_ref / np.linalg.norm(self.v_ref) u_row, u_col = u_norm v_row, v_col = v_norm @@ -420,11 +372,11 @@ def plot_strain( color='darkblue', **arrow_props_ref ) ref_ax.add_patch(v_arrow) - ref_ax.text(u_col * 1.3, -u_row * 1.3, r'$\mathbf{u}_{ref}$', + ref_ax.text(u_col * 1.3, -u_row * 1.3, r'$\mathbf{g}_{1}$', fontsize=14, fontweight='bold', color='darkred', ha='center', va='center') - ref_ax.text(v_col * 1.3, -v_row * 1.3, r'$\mathbf{v}_{ref}$', + ref_ax.text(v_col * 1.3, -v_row * 1.3, r'$\mathbf{g}_{2}$', fontsize=14, fontweight='bold', color='darkblue', ha='center', va='center') From a3d9a25498b65604f63acfe7c34c365c92bc6e72 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Wed, 27 May 2026 11:58:21 -0700 Subject: [PATCH 75/88] editing model fitting to match with strain visualization class --- src/quantem/diffraction/model_fitting.py | 39 +++++++------------ .../diffraction/strain_visualization.py | 2 +- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 1600f421..a5ccb4ed 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -940,12 +940,11 @@ def fit_individual_diffraction_pattern_batched( def get_individual_uv_vectors(self) -> "ModelDiffraction": scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] - # print(scan_r) self.u_array = np.empty(shape=(scan_r, scan_c, 2)) self.v_array = np.empty(shape=(scan_r, scan_c, 2)) if self.state_individual_refined is None: - raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") + raise RuntimeError("Call .fit_individual_diffraction_pattern(...) on all patterns first.") for r in range(scan_r): for c in range(scan_c): pos_state = self.state_individual_refined[r,c] @@ -1047,7 +1046,7 @@ def initilize_strain_class( v_ref: np.ndarray | None = None, )->StrainFitting: if self.u_array is None or self.v_array is None: - raise RuntimeWarning("Need to run fit_lattice_vectors before initilizing strain class") + self.get_individual_uv_vectors() if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") @@ -1064,29 +1063,17 @@ def initilize_strain_class( else: default_sampling = float(self.dataset.sampling) - if self.mask is not None: - return StrainFitting( - u_array = self.u_array, - v_array = self.v_array, - ds_shape = self.dataset.shape, - real_space = self.real_space, - u_ref = u_ref, - v_ref = v_ref, - mask = self.mask, - ds_sampling=default_sampling, - ds_units = default_units, - ) - else: - return StrainFitting( - u_array = self.u_array, - v_array = self.v_array, - ds_shape = self.dataset.shape, - real_space = self.real_space, - u_ref = u_ref, - v_ref = v_ref, - ds_sampling=default_sampling, - ds_units = default_units, - ) + return StrainFitting( + u_array = self.u_array, + v_array = self.v_array, + ds_shape = self.dataset.shape, + real_space = self.real_space, + u_ref = u_ref, + v_ref = v_ref, + mask = self.mask, + ds_sampling=default_sampling, + ds_units = default_units, + ) @property def render_mean_refined(self) -> np.ndarray: diff --git a/src/quantem/diffraction/strain_visualization.py b/src/quantem/diffraction/strain_visualization.py index f5c7ee35..b080f3d3 100644 --- a/src/quantem/diffraction/strain_visualization.py +++ b/src/quantem/diffraction/strain_visualization.py @@ -55,7 +55,7 @@ def __init__( self.ds_units = 'pixels' if ds_units is None else ds_units self.mask = np.ones(ds_shape[:2]) if mask is None else mask - self.mask = (self.mask - np.min(self.mask))/np.linalg.norm(self.mask) + self.mask = (self.mask - np.min(self.mask))/np.max(self.mask) self.u_ref = u_ref if u_ref is not None else None self.v_ref = v_ref if v_ref is not None else None From be4cf85437ec1be32d6404485da7319a13714929 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Wed, 27 May 2026 16:15:09 -0700 Subject: [PATCH 76/88] merge with dev --- uv.lock | 6 ------ 1 file changed, 6 deletions(-) diff --git a/uv.lock b/uv.lock index 8c7e72e8..05cf8378 100644 --- a/uv.lock +++ b/uv.lock @@ -764,15 +764,9 @@ wheels = [ name = "decorator" version = "5.3.1" source = { registry = "https://pypi.org/simple" } -<<<<<<< HEAD -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, -======= sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ->>>>>>> dev ] [[package]] From 5e6a74cf8afa86fb4761defdf56f3f767dfcf7d4 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Thu, 28 May 2026 23:04:04 -0700 Subject: [PATCH 77/88] fixing typos --- src/quantem/diffraction/model_fitting.py | 6 +++--- src/quantem/diffraction/model_fitting_visualizations.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index a5ccb4ed..b5d5ea0d 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -964,7 +964,7 @@ def get_individual_uv_vectors(self) -> "ModelDiffraction": return self - def render_indivdual_pattern(self, row, col): + def render_individual_pattern(self, row, col): if self.state_individual_refined is None: raise RuntimeError( "individual_refined_state is unavalible. Run fit_individual_diffraction_pattern(...) first." @@ -1040,7 +1040,7 @@ def create_mask( self.mask = np.sin(np.pi / 2 * self.mask) ** 2 return self - def initilize_strain_class( + def initialize_strain_class( self, u_ref: np.ndarray | None = None, v_ref: np.ndarray | None = None, @@ -1624,4 +1624,4 @@ def _adam_step_inplace( m_hat = st["m"] / bias1 v_hat = st["v"] / bias2 lr = float(lrs.get(name, 1e-2)) - p.data.addcdiv_(m_hat, v_hat.sqrt().add_(eps), value=-lr) + p.data.addcdiv_(m_hat, v_hat.sqrt().add_(eps), value=-lr) \ No newline at end of file diff --git a/src/quantem/diffraction/model_fitting_visualizations.py b/src/quantem/diffraction/model_fitting_visualizations.py index b8cac881..39de26b5 100644 --- a/src/quantem/diffraction/model_fitting_visualizations.py +++ b/src/quantem/diffraction/model_fitting_visualizations.py @@ -213,7 +213,7 @@ def visualize( md.plot_losses(figax=(fig, ax_top), plot_lrs=True, plot_individual=individual_loss,individual_row=pattern_row,individual_col=pattern_col) if individual_loss: - pred = md.render_indivdual_pattern(row=pattern_row, col=pattern_col) + pred = md.render_individual_pattern(row=pattern_row, col=pattern_col) ref = np.asarray(md.dataset.array[pattern_row, pattern_col], dtype=np.float32) else: ref = np.asarray(md.image_ref, dtype=np.float32) @@ -332,7 +332,7 @@ def plot_model( if plot_mean_model and plot_individual_model: raise RuntimeError("can only plot mean or plot individual, not both") if plot_individual_model: - pred = md.render_indivdual_pattern(pattern_row, pattern_col) + pred = md.render_individual_pattern(pattern_row, pattern_col) ref = np.asarray(md.dataset.array[pattern_row, pattern_col], dtype=np.float32) elif plot_mean_model: ref = np.asarray(md.image_ref, dtype=np.float32) From 925b44cbb637cf8e719743ead39f725415a25d0b Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Fri, 29 May 2026 00:02:31 -0700 Subject: [PATCH 78/88] updated model fitting so it doesn't train parameters where grad=False --- src/quantem/diffraction/model_fitting.py | 640 +++++++++++------- .../diffraction/strain_visualization.py | 2 +- 2 files changed, 404 insertions(+), 238 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index b5d5ea0d..af673043 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -912,14 +912,18 @@ def fit_individual_diffraction_pattern_batched( t = step + 1 current_lrs = plan.lr_at_step(t, int(n_steps)) - _adam_step_inplace(stacked, tuple(grads_list), adam_state, current_lrs, t) + _adam_step_inplace(stacked, tuple(grads_list), adam_state, current_lrs, chunk_trainable, t) with torch.no_grad(): plan.apply_hard_constraints(stacked, skip_keys=hard_skip_keys) - # For mixed-trainability keys, restore frozen-sample values - # from the init snapshot so they stay at their starting state. - for key in mixed_trainable_keys: + for key in stacked: + if key not in chunk_trainable: + continue mask = chunk_trainable[key] + if bool(mask.all()): + continue + if key not in init_snapshots: + init_snapshots[key] = stacked[key].detach().clone() view_shape = (mask.shape[0],) + (1,) * (stacked[key].ndim - 1) keep = mask.view(view_shape) stacked[key].data.copy_( @@ -1116,6 +1120,51 @@ def _lr_for_component( return float(optimizer_params[class_name].get("lr", component.DEFAULT_LR)) return float(component.DEFAULT_LR) +def _adam_step_inplace( + stacked: dict[str, torch.Tensor], + grads: tuple[torch.Tensor, ...], + adam_state: dict[str, dict[str, torch.Tensor]], + lrs: dict[str, float], + chunk_trainable: dict[str, torch.Tensor], # NEW parameter + t: int, + beta1: float = 0.9, + beta2: float = 0.999, + eps: float = 1e-8, +) -> None: + """ + In-place Adam step with per-sample trainability support. + + Frozen samples (mask=False) will: + 1. Not accumulate gradient statistics in moments + 2. Not receive parameter updates + """ + bias1 = 1.0 - beta1 ** t + bias2 = 1.0 - beta2 ** t + + with torch.no_grad(): + for (name, p), g in zip(stacked.items(), grads): + if g is None: + continue + + st = adam_state[name] + mask_b = chunk_trainable.get(name) + + if mask_b is not None and not bool(mask_b.all()): + view_shape = (g.shape[0],) + (1,) * (g.ndim - 1) + mask_expanded = mask_b.view(view_shape).to(dtype=g.dtype) + g_masked = g * mask_expanded + + st["m"].mul_(beta1).add_(g_masked, alpha=1.0 - beta1) + st["v"].mul_(beta2).addcmul_(g_masked, g_masked, value=1.0 - beta2) + else: + st["m"].mul_(beta1).add_(g, alpha=1.0 - beta1) + st["v"].mul_(beta2).addcmul_(g, g, value=1.0 - beta2) + + m_hat = st["m"] / bias1 + v_hat = st["v"] / bias2 + + lr = float(lrs.get(name, 1e-2)) + p.data.addcdiv_(m_hat, v_hat.sqrt().add_(eps), value=-lr) class _BatchedPlan: """Resolved layout for the batched per-pattern fit: component refs, lrs, and helpers.""" @@ -1131,11 +1180,11 @@ def __init__(self) -> None: self.gaussbg_idx: int | None = None self.lat_idx: int | None = None self.lrs: dict[str, float] = {} - # Per-param normalized scheduler spec; keys match self.lrs. - # Each value: {"type": "none|cosine|linear|exponential", ...numerics}. self.scheduler_specs: dict[str, dict[str, Any]] = {} - # Mapping from canonical component name -> stacked-param keys. self.component_keys: dict[str, list[str]] = {} + + # NEW: Track which params are trainable + self._trainable_flags: dict[str, bool] = {} @classmethod def from_model( @@ -1166,197 +1215,122 @@ def from_model( f"at index {idx} (or duplicate of an already-handled type)." ) - # Per-component LRs (with fallback to the component's DEFAULT_LR). + # Track trainability for each parameter if self.disk is not None and self.disk_idx is not None: - self.lrs["disk.template_raw"] = _lr_for_component(self.disk, self.disk_idx, optimizer_params, model) - self.lrs["disk.intensity_raw"] = self.lrs["disk.template_raw"] + lr = _lr_for_component(self.disk, self.disk_idx, optimizer_params, model) name = model._component_constraint_name(self.disk, self.disk_idx) + + # Check if parameters are trainable + self._trainable_flags["disk.template_raw"] = self.disk.template_raw.requires_grad + self._trainable_flags["disk.intensity_raw"] = self.disk.intensity_raw.requires_grad + + # Only add to lrs if trainable + if self._trainable_flags["disk.template_raw"]: + self.lrs["disk.template_raw"] = lr + if self._trainable_flags["disk.intensity_raw"]: + self.lrs["disk.intensity_raw"] = lr + self.component_keys[name] = ["disk.template_raw", "disk.intensity_raw"] self.component_keys[self.disk.__class__.__name__] = list(self.component_keys[name]) + if self.dcbg is not None and self.dcbg_idx is not None: - self.lrs["dcbg.intensity_raw"] = _lr_for_component(self.dcbg, self.dcbg_idx, optimizer_params, model) + lr = _lr_for_component(self.dcbg, self.dcbg_idx, optimizer_params, model) name = model._component_constraint_name(self.dcbg, self.dcbg_idx) + + self._trainable_flags["dcbg.intensity_raw"] = self.dcbg.intensity_raw.requires_grad + if self._trainable_flags["dcbg.intensity_raw"]: + self.lrs["dcbg.intensity_raw"] = lr + self.component_keys[name] = ["dcbg.intensity_raw"] self.component_keys[self.dcbg.__class__.__name__] = list(self.component_keys[name]) + if self.gaussbg is not None and self.gaussbg_idx is not None: lr = _lr_for_component(self.gaussbg, self.gaussbg_idx, optimizer_params, model) - self.lrs["gaussbg.sigma_raw"] = lr - self.lrs["gaussbg.intensity_raw"] = lr name = model._component_constraint_name(self.gaussbg, self.gaussbg_idx) + + self._trainable_flags["gaussbg.sigma_raw"] = self.gaussbg.sigma_raw.requires_grad + self._trainable_flags["gaussbg.intensity_raw"] = self.gaussbg.intensity_raw.requires_grad + + if self._trainable_flags["gaussbg.sigma_raw"]: + self.lrs["gaussbg.sigma_raw"] = lr + if self._trainable_flags["gaussbg.intensity_raw"]: + self.lrs["gaussbg.intensity_raw"] = lr + self.component_keys[name] = ["gaussbg.sigma_raw", "gaussbg.intensity_raw"] self.component_keys[self.gaussbg.__class__.__name__] = list(self.component_keys[name]) + if self.lat is not None and self.lat_idx is not None: lr = _lr_for_component(self.lat, self.lat_idx, optimizer_params, model) + name = model._component_constraint_name(self.lat, self.lat_idx) lat_keys = [] + for k in ("u_row", "u_col", "v_row", "v_col", "i0_raw", "ir", "ic", "irr", "icc", "irc"): - self.lrs[f"lat.{k}"] = lr - lat_keys.append(f"lat.{k}") - # Origin uses the lattice's LR (lattice is the most common owner of origin). - self.lrs["origin.coords"] = lr - name = model._component_constraint_name(self.lat, self.lat_idx) + full_key = f"lat.{k}" + param = getattr(self.lat, k, None) + if param is not None: + is_trainable = param.requires_grad if isinstance(param, torch.nn.Parameter) else False + self._trainable_flags[full_key] = is_trainable + if is_trainable: + self.lrs[full_key] = lr + lat_keys.append(full_key) + + # Origin uses the lattice's LR + self._trainable_flags["origin.coords"] = self.origin.coords.requires_grad + if self._trainable_flags["origin.coords"]: + self.lrs["origin.coords"] = lr + self.component_keys[name] = lat_keys self.component_keys[self.lat.__class__.__name__] = list(lat_keys) - elif self.gaussbg is not None and self.gaussbg_idx is not None: - self.lrs["origin.coords"] = self.lrs["gaussbg.intensity_raw"] + elif self.gaussbg is not None: + if self._trainable_flags.get("gaussbg.intensity_raw", False): + self.lrs["origin.coords"] = self.lrs["gaussbg.intensity_raw"] else: - self.lrs["origin.coords"] = 1e-2 + if self.origin.coords.requires_grad: + self.lrs["origin.coords"] = 1e-2 - # Default schedulers: constant LR for every key. + # Default schedulers: constant LR for every key self.scheduler_specs = {k: {"type": "none"} for k in self.lrs} return self - def set_scheduler_params(self, scheduler_params: Any) -> None: - """ - Configure per-key scheduler specs. - - Accepts either a single spec dict (applied to all params) or a dict - keyed by component name / class name (matching ``optimizer_params``). - Unknown scheduler types fall back to constant LR. - """ - if scheduler_params is None: - return - if not isinstance(scheduler_params, dict): - return - - def _normalize(spec: dict[str, Any]) -> dict[str, Any]: - out = dict(spec) - t = str(out.pop("type", out.pop("name", "none"))).lower() - # Aliases. - if t in ("cosine", "cosineannealing"): - t = "cosine_annealing" - if t == "cosine_annealing": - # Normalize T_max alias and cast numeric strings. - if "t_max" in out: - out["T_max"] = out.pop("t_max") - if "T_max" in out and out["T_max"] is not None: - out["T_max"] = int(float(out["T_max"])) - if "eta_min" in out: - out["eta_min"] = float(out["eta_min"]) - elif t == "exponential": - if "gamma" in out: - out["gamma"] = float(out["gamma"]) - elif t == "linear": - for k in ("start_factor", "end_factor", "total_iters"): - if k in out and out[k] is not None: - out[k] = float(out[k]) if k != "total_iters" else int(float(out[k])) - out["type"] = t - return out - - # Single-spec form: {"type": ..., ...}. - if "type" in scheduler_params or "name" in scheduler_params: - spec = _normalize(cast(dict[str, Any], scheduler_params)) - for k in self.scheduler_specs: - self.scheduler_specs[k] = dict(spec) - return - - # Per-component form: {component_name_or_class: spec_dict}. - for comp_name, spec in scheduler_params.items(): - if not isinstance(spec, dict): - continue - norm = _normalize(spec) - param_keys = self.component_keys.get(str(comp_name), []) - for k in param_keys: - if k in self.scheduler_specs: - self.scheduler_specs[k] = dict(norm) - - def lr_at_step(self, step: int, n_steps: int) -> dict[str, float]: - """ - Evaluate the analytic LR schedule for each stacked-param key at - ``step`` (1-indexed, matches the bias correction). - """ - out: dict[str, float] = {} - for key, base_lr in self.lrs.items(): - spec = self.scheduler_specs.get(key, {"type": "none"}) - t = str(spec.get("type", "none")).lower() - if t == "cosine_annealing": - T_max = int(spec.get("T_max") or n_steps) - if T_max < 1: - T_max = 1 - eta_min = float(spec.get("eta_min", 1e-7)) - # PyTorch CosineAnnealingLR formula at step s (0-indexed): - # lr(s) = eta_min + 0.5*(base - eta_min)*(1 + cos(pi*s/T_max)) - s = max(step - 1, 0) - import math - lr = eta_min + 0.5 * (base_lr - eta_min) * (1.0 + math.cos(math.pi * s / T_max)) - out[key] = float(lr) - elif t == "exponential": - gamma = float(spec.get("gamma", 1.0)) - out[key] = float(base_lr * (gamma ** max(step - 1, 0))) - elif t == "linear": - start = float(spec.get("start_factor", 1.0 / 3.0)) - end = float(spec.get("end_factor", 1.0)) - total = int(spec.get("total_iters", n_steps) or n_steps) - if total < 1: - total = 1 - s = min(max(step - 1, 0), total) - factor = start + (end - start) * (s / total) - out[key] = float(base_lr * factor) - else: - # none / plateau / cyclic / unknown -> constant LR. - out[key] = float(base_lr) - return out - - def batched_constraint_loss( - self, - ctx: RenderContext, - stacked: dict[str, torch.Tensor], - ) -> torch.Tensor: - """ - Per-sample soft-constraint loss summed across components. - - Returns a tensor of shape ``(B,)``. Only ``DiskTemplate`` contributes - today; other components inherit the zero-returning base - ``constraint_loss``. - """ - B = stacked["origin.coords"].shape[0] - out = torch.zeros(B, device=ctx.device, dtype=ctx.dtype) - if self.disk is not None: - template_b = stacked.get("disk.template_raw") - if template_b is not None: - out = out + self.disk.constraint_loss_batched(ctx, template_raw_b=template_b) - return out - - def resolve_component_keys(self, components: Any) -> list[str]: - """Resolve a name/list of component names to the stacked-param keys they own.""" - if components is None: - return [] - if isinstance(components, str): - components = [components] - keys: list[str] = [] - for name in components: - ks = self.component_keys.get(str(name)) - if ks is None: - raise KeyError( - f"Unknown component name '{name}' for batched fit. " - f"Known: {sorted(self.component_keys)}" - ) - for k in ks: - if k not in keys: - keys.append(k) - return keys + # ... (keep set_scheduler_params, lr_at_step, batched_constraint_loss, resolve_component_keys as-is) def build_stacked_params(self, B: int) -> dict[str, torch.Tensor]: + """Build stacked params only for trainable parameters, expand frozen ones as needed.""" out: dict[str, torch.Tensor] = {} assert self.origin is not None - out["origin.coords"] = self._stack(self.origin.coords, B) + + # Only stack trainable params + if self._trainable_flags.get("origin.coords", False): + out["origin.coords"] = self._stack(self.origin.coords, B) if self.disk is not None: - out["disk.template_raw"] = self._stack(self.disk.template_raw, B) - out["disk.intensity_raw"] = self._stack(self.disk.intensity_raw, B) + if self._trainable_flags.get("disk.template_raw", False): + out["disk.template_raw"] = self._stack(self.disk.template_raw, B) + if self._trainable_flags.get("disk.intensity_raw", False): + out["disk.intensity_raw"] = self._stack(self.disk.intensity_raw, B) + if self.dcbg is not None: - out["dcbg.intensity_raw"] = self._stack(self.dcbg.intensity_raw, B) + if self._trainable_flags.get("dcbg.intensity_raw", False): + out["dcbg.intensity_raw"] = self._stack(self.dcbg.intensity_raw, B) + if self.gaussbg is not None: - out["gaussbg.sigma_raw"] = self._stack(self.gaussbg.sigma_raw, B) - out["gaussbg.intensity_raw"] = self._stack(self.gaussbg.intensity_raw, B) + if self._trainable_flags.get("gaussbg.sigma_raw", False): + out["gaussbg.sigma_raw"] = self._stack(self.gaussbg.sigma_raw, B) + if self._trainable_flags.get("gaussbg.intensity_raw", False): + out["gaussbg.intensity_raw"] = self._stack(self.gaussbg.intensity_raw, B) + if self.lat is not None: for attr in ("u_row", "u_col", "v_row", "v_col", "i0_raw"): t = getattr(self.lat, attr) - if t is not None: - out[f"lat.{attr}"] = self._stack(t, B) + key = f"lat.{attr}" + if t is not None and self._trainable_flags.get(key, False): + out[key] = self._stack(t, B) for attr in ("ir", "ic", "irr", "icc", "irc"): t = getattr(self.lat, attr, None) - if t is not None: - out[f"lat.{attr}"] = self._stack(t, B) + key = f"lat.{attr}" + if t is not None and self._trainable_flags.get(key, False): + out[key] = self._stack(t, B) + return out @staticmethod @@ -1368,43 +1342,117 @@ def _stack(p: torch.Tensor, B: int) -> torch.Tensor: def batched_forward( self, ctx: RenderContext, stacked: dict[str, torch.Tensor] ) -> torch.Tensor: - B = stacked["origin.coords"].shape[0] - origin_b = stacked["origin.coords"] + """Batched forward with frozen parameter support.""" + B = next(iter(stacked.values())).shape[0] if stacked else 1 + + # Get origin (might be frozen) + origin_b = stacked.get("origin.coords") + if origin_b is None: + # Origin is frozen - expand from component + origin_b = self.origin.coords.unsqueeze(0).expand(B, *self.origin.coords.shape).clone() pred = torch.zeros(B, ctx.shape[0], ctx.shape[1], device=ctx.device, dtype=ctx.dtype) if self.disk is not None: + # Handle potentially frozen disk params + template_b = stacked.get("disk.template_raw") + if template_b is None: + template_b = self.disk.template_raw.unsqueeze(0).expand(B, *self.disk.template_raw.shape).clone() + + intensity_b = stacked.get("disk.intensity_raw") + if intensity_b is None: + intensity_b = self.disk.intensity_raw.unsqueeze(0).expand(B).clone() + pred = pred + self.disk.forward_batched( ctx, - template_raw_b=stacked["disk.template_raw"], - intensity_raw_b=stacked["disk.intensity_raw"], + template_raw_b=template_b, + intensity_raw_b=intensity_b, origin_coords_b=origin_b, ) + if self.dcbg is not None: - pred = pred + self.dcbg.forward_batched( - ctx, intensity_raw_b=stacked["dcbg.intensity_raw"] - ) + intensity_b = stacked.get("dcbg.intensity_raw") + if intensity_b is None: + intensity_b = self.dcbg.intensity_raw.unsqueeze(0).expand(B).clone() + pred = pred + self.dcbg.forward_batched(ctx, intensity_raw_b=intensity_b) + if self.gaussbg is not None: + sigma_b = stacked.get("gaussbg.sigma_raw") + if sigma_b is None: + sigma_b = self.gaussbg.sigma_raw.unsqueeze(0).expand(B).clone() + + intensity_b = stacked.get("gaussbg.intensity_raw") + if intensity_b is None: + intensity_b = self.gaussbg.intensity_raw.unsqueeze(0).expand(B).clone() + pred = pred + self.gaussbg.forward_batched( ctx, - sigma_raw_b=stacked["gaussbg.sigma_raw"], - intensity_raw_b=stacked["gaussbg.intensity_raw"], + sigma_raw_b=sigma_b, + intensity_raw_b=intensity_b, origin_coords_b=origin_b, ) + if self.lat is not None: + # Lattice requires all params - expand frozen ones + u_row_b = stacked.get("lat.u_row") + if u_row_b is None: + u_row_b = self.lat.u_row.unsqueeze(0).expand(B).clone() + + u_col_b = stacked.get("lat.u_col") + if u_col_b is None: + u_col_b = self.lat.u_col.unsqueeze(0).expand(B).clone() + + v_row_b = stacked.get("lat.v_row") + if v_row_b is None: + v_row_b = self.lat.v_row.unsqueeze(0).expand(B).clone() + + v_col_b = stacked.get("lat.v_col") + if v_col_b is None: + v_col_b = self.lat.v_col.unsqueeze(0).expand(B).clone() + + i0_raw_b = stacked.get("lat.i0_raw") + if i0_raw_b is None: + i0_raw_b = self.lat.i0_raw.unsqueeze(0).expand(B, *self.lat.i0_raw.shape).clone() + + # Optional params + ir_b = stacked.get("lat.ir") + if ir_b is None and self.lat.ir is not None: + ir_b = self.lat.ir.unsqueeze(0).expand(B, *self.lat.ir.shape).clone() + + ic_b = stacked.get("lat.ic") + if ic_b is None and self.lat.ic is not None: + ic_b = self.lat.ic.unsqueeze(0).expand(B, *self.lat.ic.shape).clone() + + irr_b = stacked.get("lat.irr") + if irr_b is None and self.lat.irr is not None: + irr_b = self.lat.irr.unsqueeze(0).expand(B, *self.lat.irr.shape).clone() + + icc_b = stacked.get("lat.icc") + if icc_b is None and self.lat.icc is not None: + icc_b = self.lat.icc.unsqueeze(0).expand(B, *self.lat.icc.shape).clone() + + irc_b = stacked.get("lat.irc") + if irc_b is None and self.lat.irc is not None: + irc_b = self.lat.irc.unsqueeze(0).expand(B, *self.lat.irc.shape).clone() + + # Template (from disk) + template_b = stacked.get("disk.template_raw") + if template_b is None and self.disk is not None: + template_b = self.disk.template_raw.unsqueeze(0).expand(B, *self.disk.template_raw.shape).clone() + pred = pred + self.lat.forward_batched( ctx, - u_row_b=stacked["lat.u_row"], - u_col_b=stacked["lat.u_col"], - v_row_b=stacked["lat.v_row"], - v_col_b=stacked["lat.v_col"], - i0_raw_b=stacked["lat.i0_raw"], - ir_b=stacked.get("lat.ir"), - ic_b=stacked.get("lat.ic"), - irr_b=stacked.get("lat.irr"), - icc_b=stacked.get("lat.icc"), - irc_b=stacked.get("lat.irc"), - template_raw_b=stacked["disk.template_raw"], + u_row_b=u_row_b, + u_col_b=u_col_b, + v_row_b=v_row_b, + v_col_b=v_col_b, + i0_raw_b=i0_raw_b, + ir_b=ir_b, + ic_b=ic_b, + irr_b=irr_b, + icc_b=icc_b, + irc_b=irc_b, + template_raw_b=template_b, origin_coords_b=origin_b, ) return pred @@ -1414,13 +1462,14 @@ def apply_hard_constraints( stacked: dict[str, torch.Tensor], skip_keys: set[str] | None = None, ) -> None: - """Apply batched analogues of each component's hard constraints to stacked params (in-place). - - ``skip_keys`` is a set of stacked-param keys that should not be mutated - by any hard-constraint op (used for fully-frozen components). - """ + """Apply batched hard constraints, respecting frozen parameters.""" skip_keys = skip_keys or set() - # Parameter bounds (always elementwise; safe on any shape). + + # Add frozen params to skip_keys + frozen_keys = {k for k, trainable in self._trainable_flags.items() if not trainable} + skip_keys = skip_keys | frozen_keys + + # Parameter bounds (always elementwise; safe on any shape) if self.disk is not None: for pname, (lo, hi) in self.disk.parameter_bounds.items(): key = f"disk.{pname}" @@ -1445,7 +1494,7 @@ def apply_hard_constraints( disk_template_frozen = "disk.template_raw" in skip_keys disk_intensity_frozen = "disk.intensity_raw" in skip_keys - # DiskTemplate composite hard constraints. + # DiskTemplate composite hard constraints if self.disk is not None and not disk_template_frozen: template = stacked.get("disk.template_raw") intensity = stacked.get("disk.intensity_raw") @@ -1461,12 +1510,12 @@ def apply_hard_constraints( template.sub_(float(cfg.get("shrinkage_amount", 0.25))) if bool(self.disk.hard_constraints.get("force_positive", False)): template.clamp_(min=0.0) - if not disk_intensity_frozen: + if not disk_intensity_frozen and intensity is not None: intensity.clamp_(min=0.0) if bool(self.disk.hard_constraints.get("force_norm", False)): self._batched_enforce_norm(template) - # SyntheticDiskLattice.force_positive_intensity. + # SyntheticDiskLattice.force_positive_intensity if ( self.lat is not None and "lat.i0_raw" not in skip_keys @@ -1520,7 +1569,7 @@ def _batched_center_disk(template_b: torch.Tensor) -> None: src = template_b.unsqueeze(1) # (B, 1, H, W) grid = F.affine_grid(theta, [B, 1, h, w], align_corners=True) shifted = F.grid_sample(src, grid, mode="bilinear", padding_mode="zeros", align_corners=True)[:, 0] - # Only shift samples with nonzero mass; others left as-is. + # Only shift samples with nonzero mass; others left as-is do_shift = (mass > 1e-12).view(B, 1, 1) template_b.copy_(torch.where(do_shift, shifted, template_b)) @@ -1560,68 +1609,185 @@ def build_sample_state_dict( stacked: dict[str, torch.Tensor], b: int, ) -> dict[str, torch.Tensor]: + """Extract sample b from stacked params, handling frozen params.""" out = {k: v.detach().clone() for k, v in init_state.items()} # Origin: top-level key plus any 'components.X.origin.coords' or - # 'components.X.disk.origin.coords' that PyTorch state_dict registers. - origin_val = stacked["origin.coords"][b].detach().clone() - for key in list(out.keys()): - if key == "origin.coords" or key.endswith(".origin.coords"): - out[key] = origin_val.clone() + # 'components.X.disk.origin.coords' that PyTorch state_dict registers + if "origin.coords" in stacked: + origin_val = stacked["origin.coords"][b].detach().clone() + else: + # Origin was frozen - use the init value + origin_val = init_state.get("origin.coords") + if origin_val is not None: + origin_val = origin_val.detach().clone() + + if origin_val is not None: + for key in list(out.keys()): + if key == "origin.coords" or key.endswith(".origin.coords"): + out[key] = origin_val.clone() - # DiskTemplate (shared with lat.disk). + # DiskTemplate (shared with lat.disk) if self.disk is not None and self.disk_idx is not None: - t_val = stacked["disk.template_raw"][b].detach().clone() - i_val = stacked["disk.intensity_raw"][b].detach().clone() - for key in list(out.keys()): - if key.endswith(".template_raw"): - out[key] = t_val.clone() - for key in ( - f"components.{self.disk_idx}.intensity_raw", - f"components.{self.lat_idx}.disk.intensity_raw" if self.lat_idx is not None else None, - ): - if key is not None and key in out: - out[key] = i_val.clone() - - # DCBackground. + if "disk.template_raw" in stacked: + t_val = stacked["disk.template_raw"][b].detach().clone() + else: + # Template was frozen + t_val = init_state.get(f"components.{self.disk_idx}.template_raw") + if t_val is not None: + t_val = t_val.detach().clone() + + if "disk.intensity_raw" in stacked: + i_val = stacked["disk.intensity_raw"][b].detach().clone() + else: + # Intensity was frozen + i_val = init_state.get(f"components.{self.disk_idx}.intensity_raw") + if i_val is not None: + i_val = i_val.detach().clone() + + if t_val is not None: + for key in list(out.keys()): + if key.endswith(".template_raw"): + out[key] = t_val.clone() + + if i_val is not None: + for key in ( + f"components.{self.disk_idx}.intensity_raw", + f"components.{self.lat_idx}.disk.intensity_raw" if self.lat_idx is not None else None, + ): + if key is not None and key in out: + out[key] = i_val.clone() + + # DCBackground if self.dcbg is not None and self.dcbg_idx is not None: - out[f"components.{self.dcbg_idx}.intensity_raw"] = stacked["dcbg.intensity_raw"][b].detach().clone() + if "dcbg.intensity_raw" in stacked: + out[f"components.{self.dcbg_idx}.intensity_raw"] = stacked["dcbg.intensity_raw"][b].detach().clone() + else: + # Was frozen - keep init value (already in out) + pass - # GaussianBackground. + # GaussianBackground if self.gaussbg is not None and self.gaussbg_idx is not None: - out[f"components.{self.gaussbg_idx}.sigma_raw"] = stacked["gaussbg.sigma_raw"][b].detach().clone() - out[f"components.{self.gaussbg_idx}.intensity_raw"] = stacked["gaussbg.intensity_raw"][b].detach().clone() + if "gaussbg.sigma_raw" in stacked: + out[f"components.{self.gaussbg_idx}.sigma_raw"] = stacked["gaussbg.sigma_raw"][b].detach().clone() + if "gaussbg.intensity_raw" in stacked: + out[f"components.{self.gaussbg_idx}.intensity_raw"] = stacked["gaussbg.intensity_raw"][b].detach().clone() - # SyntheticDiskLattice scalar + tensor params. + # SyntheticDiskLattice scalar + tensor params if self.lat is not None and self.lat_idx is not None: for attr in ("u_row", "u_col", "v_row", "v_col", "i0_raw", "ir", "ic", "irr", "icc", "irc"): key = f"components.{self.lat_idx}.{attr}" stacked_key = f"lat.{attr}" if key in out and stacked_key in stacked: out[key] = stacked[stacked_key][b].detach().clone() + # else: was frozen, keep init value + return out + + def set_scheduler_params(self, scheduler_params: Any) -> None: + """Configure per-key scheduler specs.""" + if scheduler_params is None: + return + if not isinstance(scheduler_params, dict): + return + + def _normalize(spec: dict[str, Any]) -> dict[str, Any]: + out = dict(spec) + t = str(out.pop("type", out.pop("name", "none"))).lower() + if t in ("cosine", "cosineannealing"): + t = "cosine_annealing" + if t == "cosine_annealing": + if "t_max" in out: + out["T_max"] = out.pop("t_max") + if "T_max" in out and out["T_max"] is not None: + out["T_max"] = int(float(out["T_max"])) + if "eta_min" in out: + out["eta_min"] = float(out["eta_min"]) + elif t == "exponential": + if "gamma" in out: + out["gamma"] = float(out["gamma"]) + elif t == "linear": + for k in ("start_factor", "end_factor", "total_iters"): + if k in out and out[k] is not None: + out[k] = float(out[k]) if k != "total_iters" else int(float(out[k])) + out["type"] = t + return out + if "type" in scheduler_params or "name" in scheduler_params: + spec = _normalize(cast(dict[str, Any], scheduler_params)) + for k in self.scheduler_specs: + self.scheduler_specs[k] = dict(spec) + return -def _adam_step_inplace( - stacked: dict[str, torch.Tensor], - grads: tuple[torch.Tensor, ...], - adam_state: dict[str, dict[str, torch.Tensor]], - lrs: dict[str, float], - t: int, - beta1: float = 0.9, - beta2: float = 0.999, - eps: float = 1e-8, -) -> None: - bias1 = 1.0 - beta1 ** t - bias2 = 1.0 - beta2 ** t - with torch.no_grad(): - for (name, p), g in zip(stacked.items(), grads): - if g is None: + for comp_name, spec in scheduler_params.items(): + if not isinstance(spec, dict): continue - st = adam_state[name] - st["m"].mul_(beta1).add_(g, alpha=1.0 - beta1) - st["v"].mul_(beta2).addcmul_(g, g, value=1.0 - beta2) - m_hat = st["m"] / bias1 - v_hat = st["v"] / bias2 - lr = float(lrs.get(name, 1e-2)) - p.data.addcdiv_(m_hat, v_hat.sqrt().add_(eps), value=-lr) \ No newline at end of file + norm = _normalize(spec) + param_keys = self.component_keys.get(str(comp_name), []) + for k in param_keys: + if k in self.scheduler_specs: + self.scheduler_specs[k] = dict(norm) + + def lr_at_step(self, step: int, n_steps: int) -> dict[str, float]: + """Evaluate the analytic LR schedule for each stacked-param key at step.""" + out: dict[str, float] = {} + for key, base_lr in self.lrs.items(): + spec = self.scheduler_specs.get(key, {"type": "none"}) + t = str(spec.get("type", "none")).lower() + if t == "cosine_annealing": + T_max = int(spec.get("T_max") or n_steps) + if T_max < 1: + T_max = 1 + eta_min = float(spec.get("eta_min", 1e-7)) + s = max(step - 1, 0) + import math + lr = eta_min + 0.5 * (base_lr - eta_min) * (1.0 + math.cos(math.pi * s / T_max)) + out[key] = float(lr) + elif t == "exponential": + gamma = float(spec.get("gamma", 1.0)) + out[key] = float(base_lr * (gamma ** max(step - 1, 0))) + elif t == "linear": + start = float(spec.get("start_factor", 1.0 / 3.0)) + end = float(spec.get("end_factor", 1.0)) + total = int(spec.get("total_iters", n_steps) or n_steps) + if total < 1: + total = 1 + s = min(max(step - 1, 0), total) + factor = start + (end - start) * (s / total) + out[key] = float(base_lr * factor) + else: + out[key] = float(base_lr) + return out + + def batched_constraint_loss( + self, + ctx: RenderContext, + stacked: dict[str, torch.Tensor], + ) -> torch.Tensor: + """Per-sample soft-constraint loss summed across components.""" + B = next(iter(stacked.values())).shape[0] if stacked else 1 + out = torch.zeros(B, device=ctx.device, dtype=ctx.dtype) + if self.disk is not None: + template_b = stacked.get("disk.template_raw") + if template_b is not None: + out = out + self.disk.constraint_loss_batched(ctx, template_raw_b=template_b) + return out + + def resolve_component_keys(self, components: Any) -> list[str]: + """Resolve a name/list of component names to the stacked-param keys they own.""" + if components is None: + return [] + if isinstance(components, str): + components = [components] + keys: list[str] = [] + for name in components: + ks = self.component_keys.get(str(name)) + if ks is None: + raise KeyError( + f"Unknown component name '{name}' for batched fit. " + f"Known: {sorted(self.component_keys)}" + ) + for k in ks: + if k not in keys: + keys.append(k) + return keys \ No newline at end of file diff --git a/src/quantem/diffraction/strain_visualization.py b/src/quantem/diffraction/strain_visualization.py index b080f3d3..e5b02d50 100644 --- a/src/quantem/diffraction/strain_visualization.py +++ b/src/quantem/diffraction/strain_visualization.py @@ -203,7 +203,7 @@ def plot_strain( is_horizontal = layout == "horizontal" if figsize is None: - figsize = (6, 6) if is_horizontal else (6, 8) + figsize = (6, 6) if is_horizontal else (6, 6) if is_horizontal: fig, ax = plt.subplots(1, ncols, figsize=figsize) From 85de2fdefaf4fa540f81c07f69f04305b046ff9c Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Fri, 29 May 2026 15:02:23 -0700 Subject: [PATCH 79/88] remote additional comments --- src/quantem/core/fitting/diffraction.py | 66 ------------------------ src/quantem/diffraction/model_fitting.py | 3 -- 2 files changed, 69 deletions(-) diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 31ee422f..2da095d7 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -745,72 +745,6 @@ def enforce_hard_constraints(self, ctx: RenderContext) -> None: self.i0_raw[idx].clamp_(max=float(hi)) super().enforce_hard_constraints(ctx) - # def forward(self, ctx: RenderContext) -> torch.Tensor: - # if self.origin is None: - # raise RuntimeError("SyntheticDiskLattice requires an OriginND instance.") - - # out = torch.zeros(ctx.shape, device=ctx.device, dtype=ctx.dtype) - # uv_indices = cast(torch.Tensor, self.uv_indices) - # if torch.numel(uv_indices) == 0: - # return out - - # uv = torch.as_tensor(uv_indices, device=ctx.device) - # u = uv[:, 0].to(dtype=ctx.dtype) - # v = uv[:, 1].to(dtype=ctx.dtype) - # r0, c0 = self.origin.coords[0], self.origin.coords[1] - # centers_r = r0 + u * self.u_row + v * self.v_row - # centers_c = c0 + u * self.u_col + v * self.v_col - - # b = torch.as_tensor(self.boundary_px, device=ctx.device, dtype=ctx.dtype) - # keep = (centers_r >= b) & (centers_r <= (ctx.shape[0] - 1) - b) - # keep = keep & (centers_c >= b) & (centers_c <= (ctx.shape[1] - 1) - b) - # keep_idx = torch.nonzero(keep, as_tuple=False).reshape(-1) - # if keep_idx.numel() == 0: - # return out - - # active_order = int( - # ctx.fields.get( - # "lattice_intensity_order_override", self.default_pattern_intensity_order - # ) - # ) - # active_order = max(0, min(active_order, self.max_intensity_order)) - - # dr, dc = self.disk.patch_offsets() - # dr = dr.to(device=ctx.device, dtype=ctx.dtype) - # dc = dc.to(device=ctx.device, dtype=ctx.dtype) - # dr2 = dr * dr - # dc2 = dc * dc - # drdc = dr * dc - - # for j in keep_idx: - # rr0 = centers_r[j] - # cc0 = centers_c[j] - - # if self.per_disk_intensity: - # inten = self.i0_raw[j] - # if active_order >= 1 and self.ir is not None and self.ic is not None: - # inten = inten + self.ir[j] * dr + self.ic[j] * dc - # if ( - # active_order >= 2 - # and self.irr is not None - # and self.icc is not None - # and self.irc is not None - # ): - # inten = inten + self.irr[j] * dr2 + self.icc[j] * dc2 + self.irc[j] * drdc - # else: - # inten = self.i0_raw - # if active_order >= 1: - # assert self.ir is not None and self.ic is not None - # inten = inten + self.ir * rr0 + self.ic * cc0 - # if active_order >= 2: - # assert self.irr is not None and self.icc is not None and self.irc is not None - # inten = ( - # inten + self.irr * rr0 * rr0 + self.icc * cc0 * cc0 + self.irc * rr0 * cc0 - # ) - - # self.disk.add_patch(out, r0=rr0, c0=cc0, scale=inten) - - # return out def forward(self, ctx: RenderContext) -> torch.Tensor: if self.origin is None: diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index af673043..185154ed 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -806,7 +806,6 @@ def fit_individual_diffraction_pattern_batched( frozen_keys = plan.resolve_component_keys(frozen_components) for key in frozen_keys: is_trainable_pos[key][:] = False - # Keys that are frozen for ALL samples skip hard constraints too. hard_skip_keys: set[str] = set(frozen_keys) if sample_trainability is not None: for key, arr in sample_trainability.items(): @@ -1183,7 +1182,6 @@ def __init__(self) -> None: self.scheduler_specs: dict[str, dict[str, Any]] = {} self.component_keys: dict[str, list[str]] = {} - # NEW: Track which params are trainable self._trainable_flags: dict[str, bool] = {} @classmethod @@ -1515,7 +1513,6 @@ def apply_hard_constraints( if bool(self.disk.hard_constraints.get("force_norm", False)): self._batched_enforce_norm(template) - # SyntheticDiskLattice.force_positive_intensity if ( self.lat is not None and "lat.i0_raw" not in skip_keys From 703e631e14a6d20526d0dc5fedbb4aae490099ed Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 1 Jun 2026 09:44:18 +0200 Subject: [PATCH 80/88] current strain --- src/quantem/diffraction/__init__.py | 2 + src/quantem/diffraction/bragg_vectors.py | 1394 +++++++++++++++++ .../bragg_vectors_visualization.py | 768 +++++++++ src/quantem/diffraction/disk_detection.py | 1093 +++++++++++++ src/quantem/diffraction/model_fitting.py | 6 +- src/quantem/diffraction/strain.py | 762 +++++++++ .../diffraction/strain_autocorrelation.py | 46 +- .../diffraction/strain_visualization.py | 664 ++++---- 8 files changed, 4342 insertions(+), 393 deletions(-) create mode 100644 src/quantem/diffraction/bragg_vectors.py create mode 100644 src/quantem/diffraction/bragg_vectors_visualization.py create mode 100644 src/quantem/diffraction/disk_detection.py create mode 100644 src/quantem/diffraction/strain.py diff --git a/src/quantem/diffraction/__init__.py b/src/quantem/diffraction/__init__.py index a64a357e..fed057f1 100644 --- a/src/quantem/diffraction/__init__.py +++ b/src/quantem/diffraction/__init__.py @@ -1,4 +1,6 @@ from quantem.diffraction.polar import RDF as RDF +from quantem.diffraction.bragg_vectors import BraggVectors as BraggVectors +from quantem.diffraction.strain import StrainMap as StrainMap from quantem.diffraction.strain_autocorrelation import StrainMapAutocorrelation as StrainMapAutocorrelation from quantem.diffraction.maped import MAPED as MAPED from quantem.diffraction.model_fitting import ModelDiffraction as ModelDiffraction diff --git a/src/quantem/diffraction/bragg_vectors.py b/src/quantem/diffraction/bragg_vectors.py new file mode 100644 index 00000000..ed36ede4 --- /dev/null +++ b/src/quantem/diffraction/bragg_vectors.py @@ -0,0 +1,1394 @@ +from __future__ import annotations + +from typing import Any + +import numpy as np +import torch +from numpy.typing import NDArray + +from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.datastructures.dataset4dstem import Dataset4dstem +from quantem.core.datastructures.vector import Vector +from quantem.core.io.serialize import AutoSerialize +from quantem.diffraction.bragg_vectors_visualization import ( + plot_basis_vectors, + plot_bvm, + plot_detection, + plot_diffraction_grid, + plot_lattice_fit, + plot_reference_lattice, + plot_template, +) +from quantem.diffraction.disk_detection import ( + cross_correlation, + detect_disks_batch, + estimate_central_beam, + make_template, + probe_centroid, + synthetic_probe, + template_fourier, +) +from quantem.diffraction.strain import StrainMap + +PEAK_FIELDS = ("q_row", "q_col", "intensity") + + +class BraggVectors(AutoSerialize): + """Correlation-based Bragg disk detection and lattice fitting for 4D-STEM. + + Workflow (each step writes state consumed by the next): + + 1. ``make_template_*`` – build a cross-correlation template, either from a + synthetic soft disk (:meth:`make_template_synthetic`), by averaging data + over an ROI (:meth:`make_template_from_data`), or from an explicit probe + image (:meth:`make_template_from_probe`). + 2. :meth:`detect_disks` – template-match every scan position; detected peaks + are stored in :attr:`peaks` (a :class:`Vector` of ``[q_row, q_col, + intensity]``, numpy-backed) and accumulated into the Bragg vector map + :attr:`bvm`. + 3. :meth:`choose_basis_vectors` – pick the lattice basis ``(origin, g1, g2)`` + from the BVM, automatically or by hand; also stores the numbered candidate + peaks. + 4. :meth:`index_peaks` – quick, lightweight: index just the picked candidate + peaks into a reference lattice (``reference_ab``/``reference_qpos``). + 5. :meth:`fit_lattice` – the heavy step: at every scan position, match the + detections to the reference within ``max_peak_shift``, intensity-weighted + least-squares fit the lattice vectors into ``u_array``/``v_array`` of shape + ``(scan_row, scan_col, 2)``, and compute the per-position ``mask_weight``. + 6. :meth:`calculate_strain_map` – hand the lattice vectors (and + ``mask_weight``) to a :class:`~quantem.diffraction.strain.StrainMap`. + + Detection runs in torch (CPU now, CUDA later); the ragged peak table is held + in a numpy-backed :class:`Vector`. The detector→scan rotation is read from + the parent dataset metadata (``q_to_r_rotation_ccw_deg`` + ``q_transpose``), + the single source of truth shared with the DPC/CoM workflow. + + Use :meth:`from_dataset` to construct an instance. + + Parameters + ---------- + dataset : Dataset4dstem + The 4D-STEM dataset to analyze. + device : str, default="cpu" + Torch device used for detection (e.g. ``"cpu"`` or ``"cuda"``). + """ + + _token = object() + + # nanobeam lattice vectors are measured in reciprocal space -> sign flip + real_space: bool = False + + def __init__( + self, + dataset: Dataset4dstem, + device: str = "cpu", + _token: object | None = None, + ): + if _token is not self._token: + raise RuntimeError("Use BraggVectors.from_dataset() to instantiate this class.") + super().__init__() + self.dataset = dataset + self.device = device + + self.peaks: Vector | None = None + self.bvm: Dataset2d | None = None + + self.origin: np.ndarray | None = None + self.g1: np.ndarray | None = None + self.g2: np.ndarray | None = None + + # candidate peaks picked from the BVM in choose_basis_vectors(), and the + # reference lattice (those candidates indexed) set by index_peaks(). + self.candidates_rc: np.ndarray | None = None + self.candidates_intensity: np.ndarray | None = None + self.reference_ab: np.ndarray | None = None + self.reference_qpos: np.ndarray | None = None + self.reference_intensity: np.ndarray | None = None + + self.u_array: np.ndarray | None = None + self.v_array: np.ndarray | None = None + # per-position diagnostics from fit_lattice() + self.mask_weight: np.ndarray | None = None + self.fit_error: np.ndarray | None = None + + self._template: torch.Tensor | None = None + self._template_ft: torch.Tensor | None = None + self.metadata: dict[str, Any] = {} + + @classmethod + def from_dataset( + cls, dataset: Dataset4dstem, *, device: str = "cpu", name: str | None = None + ) -> "BraggVectors": + """Create a BraggVectors workflow bound to a 4D-STEM dataset. + + Parameters + ---------- + dataset : Dataset4dstem + The 4D-STEM dataset to analyze. + device : str, default="cpu" + Torch device used for detection (e.g. ``"cpu"`` or ``"cuda"``). + name : str, optional + If given, sets ``dataset.name``. + + Returns + ------- + BraggVectors + A new workflow instance bound to ``dataset``. + """ + if not isinstance(dataset, Dataset4dstem): + raise TypeError("BraggVectors.from_dataset expects a Dataset4dstem instance.") + if name is not None: + dataset.name = name + return cls(dataset=dataset, device=device, _token=cls._token) + + # ---- main methods ---- + + def make_template_synthetic( + self, + radius: float | None = None, + edge: float = 1.0, + center: tuple[float, float] | None = None, + subtract_mean: bool = True, + ) -> "BraggVectors": + """Build the template from a synthetic soft-edged disk. + + Parameters + ---------- + radius : float, optional + Disk radius in pixels. Defaults to a rough estimate from the mean + diffraction pattern + (:func:`~quantem.diffraction.disk_detection.estimate_central_beam`); + pass it explicitly when several disks share comparable intensity. + edge : float, default=1.0 + Width in pixels of the ``tanh`` edge falloff. + center : tuple of float, optional + ``(row, col)`` disk center; defaults to the detector center + ``(H // 2, W // 2)``. + subtract_mean : bool, default=True + If ``True``, make the template zero-sum — a band-pass kernel that + suppresses uniform background in the correlation. + + Returns + ------- + BraggVectors + ``self``, for method chaining. + """ + H, W = int(self.dataset.shape[-2]), int(self.dataset.shape[-1]) + if radius is None: + dp_mean = torch.as_tensor( + np.asarray(self.dataset.dp_mean.array), dtype=torch.float, device=self.device + ) + _, radius = estimate_central_beam(dp_mean) + if center is None: + center = (H // 2, W // 2) + probe = synthetic_probe((H, W), float(radius), edge=edge, center=center) + self._set_template(probe, center=center, subtract_mean=subtract_mean) + self.metadata["template"] = { + "kind": "synthetic", + "radius": float(radius), + "edge": float(edge), + "center": (float(center[0]), float(center[1])), + } + return self + + def make_template_from_data( + self, + roi: NDArray | None = None, + subtract_mean: bool = True, + center: tuple[float, float] | None = None, + ) -> "BraggVectors": + """Build the template by averaging diffraction patterns from the data. + + Parameters + ---------- + roi : np.ndarray, optional + ``(scan_row, scan_col)`` mask selecting scan positions to average — + ideally a vacuum / single-disk region so the unscattered probe is + isolated. ``None`` (default) averages the whole scan (the mean + diffraction pattern). + subtract_mean : bool, default=True + If ``True``, make the template zero-sum — a band-pass kernel that + suppresses uniform background in the correlation. + center : tuple of float, optional + ``(row, col)`` probe center rolled to the origin; defaults to the + probe's intensity centroid. + + Returns + ------- + BraggVectors + ``self``, for method chaining. + """ + data = torch.as_tensor( + np.asarray(self.dataset.array), dtype=torch.float, device=self.device + ) + if roi is None: + probe = data.mean(dim=(0, 1)) + else: + m = torch.as_tensor(np.asarray(roi) > 0, device=self.device) + if not bool(m.any()): + raise ValueError("roi selects no scan positions.") + probe = data[m].mean(dim=0) + if center is None: + center = probe_centroid(probe) + self._set_template(probe, center=center, subtract_mean=subtract_mean) + self.metadata["template"] = { + "kind": "from_data", + "roi": roi is not None, + "center": (float(center[0]), float(center[1])), + } + return self + + def make_template_from_probe( + self, + probe: NDArray | torch.Tensor, + center: tuple[float, float] | None = None, + subtract_mean: bool = True, + ) -> "BraggVectors": + """Build the template from an explicit probe image (e.g. a measured vacuum probe). + + Parameters + ---------- + probe : np.ndarray or torch.Tensor + Probe image; must match the diffraction-pattern shape. + center : tuple of float, optional + ``(row, col)`` probe center rolled to the origin; defaults to the + probe's intensity centroid. + subtract_mean : bool, default=True + If ``True``, make the template zero-sum — a band-pass kernel that + suppresses uniform background in the correlation. + + Returns + ------- + BraggVectors + ``self``, for method chaining. + """ + probe_t = torch.as_tensor(probe, dtype=torch.float, device=self.device) + if center is None and tuple(probe_t.shape) == tuple(self.dataset.shape[-2:]): + center = probe_centroid(probe_t) + self._set_template(probe_t, center=center, subtract_mean=subtract_mean) + self.metadata["template"] = { + "kind": "from_probe", + "center": (float(center[0]), float(center[1])), + } + return self + + @property + def template(self) -> np.ndarray | None: + """The correlation template, fftshifted to the image center for display (numpy). + + The ``make_template_*`` methods store the template corner-shifted (center + at the ``[0, 0]`` FFT origin) so correlation peaks land at absolute disk + positions; this property shifts it back to the center for plotting. + + Returns + ------- + np.ndarray or None + ``(H, W)`` center-shifted template, or ``None`` if no template has been + built yet. + """ + if self._template is None: + return None + return torch.fft.fftshift(self._template).detach().cpu().numpy() + + def correlation_map(self, row: int, col: int) -> np.ndarray: + """Cross-correlation map of one diffraction pattern with the template (numpy). + + Peaks in the returned map sit at absolute disk positions (no fftshift + needed), matching what :meth:`detect_disks` searches. + + Parameters + ---------- + row : int + Scan row of the diffraction pattern to correlate. + col : int + Scan column of the diffraction pattern to correlate. + + Returns + ------- + np.ndarray + ``(H, W)`` real-space correlation map. + """ + if self._template_ft is None: + raise ValueError("Run a make_template_* method before correlation_map().") + dp = torch.as_tensor( + np.asarray(self.dataset.array[row, col]), dtype=torch.float, device=self.device + ) + corr, _ = cross_correlation(dp, self._template_ft) + return corr.detach().cpu().numpy() + + def detect_disks( + self, + *, + positions: list[tuple[int, int]] | None = None, + min_abs_intensity: float = 0.0, + min_spacing: float = 0.0, + edge_boundary: int = 1, + subpixel: str = "upsample", + upsample_factor: int = 16, + max_num_peaks: int = 1000, + batch_size: int | None = None, + progressbar: bool = True, + ) -> Vector: + """Detect Bragg disks at every scan position (or a subset for testing). + + Pass ``positions`` to test detection hyperparameters on a handful of + patterns without scanning the full grid; the returned :class:`Vector` then + has shape ``(len(positions),)`` and the workflow state is left untouched. + With ``positions=None`` the full scan is processed, :attr:`peaks` and + :attr:`bvm` are populated, and the same Vector is returned. Patterns are + processed in batches (the FFTs and subpixel refinement run together across + the batch, which is far faster on a GPU); results are identical to detecting + each pattern on its own. + + Parameters + ---------- + positions : list of tuple of int, optional + ``(row, col)`` scan positions to test on. ``None`` (default) processes + the full scan and updates the workflow state. + min_abs_intensity : float, default=0.0 + Drop correlation peaks below this absolute intensity. + min_spacing : float, default=0.0 + Minimum spacing in pixels between kept peaks; closer / dimmer peaks are + suppressed. + edge_boundary : int, default=1 + Width in pixels of the border in which peaks are ignored. + subpixel : {"none", "parabolic", "upsample"}, default="upsample" + Subpixel refinement mode; see + :func:`~quantem.diffraction.disk_detection.detect_disks`. + upsample_factor : int, default=16 + Upsampling factor for the ``"upsample"`` subpixel refinement. + max_num_peaks : int, default=1000 + Maximum number of peaks to keep per pattern. + batch_size : int, optional + Number of patterns per batch. ``None`` (default) picks a size from the + detector dimensions. + progressbar : bool, default=True + If ``True``, show a tqdm progress bar over the full-scan detection. + + Returns + ------- + Vector + Detected peaks (``[q_row, q_col, intensity]``): shape ``(scan_row, + scan_col)`` for the full scan, or ``(len(positions),)`` for a test run. + """ + if self._template_ft is None: + raise ValueError("Run a make_template_* method before detect_disks().") + + detect_kwargs = dict( + min_abs_intensity=min_abs_intensity, + min_spacing=min_spacing, + edge_boundary=edge_boundary, + subpixel=subpixel, + upsample_factor=upsample_factor, + max_num_peaks=max_num_peaks, + ) + + if positions is not None: + if len(positions) == 0: + raise ValueError("positions must contain at least one (row, col) to test on.") + coords = [(int(r), int(c)) for r, c in positions] + results = self._detect_positions(coords, detect_kwargs, batch_size, progressbar=False) + return Vector.from_data(results, fields=PEAK_FIELDS, name="bragg_peaks_test") + + scan_r, scan_c = int(self.dataset.shape[0]), int(self.dataset.shape[1]) + coords = list(np.ndindex(scan_r, scan_c)) + results = self._detect_positions( + coords, detect_kwargs, batch_size, progressbar=progressbar + ) + # Store every cell in a single bulk pass. Per-cell assignment + # (peaks[r, c] = arr) re-concatenates the entire backing buffer on every + # write -- O(N^2) over the scan, which is the stall at the end of + # detection. from_data stacks all cells with one _replace_cells call. + nested = [results[r * scan_c : (r + 1) * scan_c] for r in range(scan_r)] + peaks = Vector.from_data(nested, fields=PEAK_FIELDS, name="bragg_peaks") + + self.peaks = peaks + self.metadata["detect"] = detect_kwargs + self.compute_bvm() + return peaks + + def compute_bvm(self, sampling: float = 1.0) -> Dataset2d: + """Accumulate all detected peaks into a Bragg vector map (intensity histogram). + + Parameters + ---------- + sampling : float, default=1.0 + Reciprocal-space sampling (per pixel) stored on the returned dataset. + + Returns + ------- + Dataset2d + ``(H, W)`` Bragg vector map, also stored on :attr:`bvm`. + """ + if self.peaks is None: + raise ValueError("Run detect_disks() before compute_bvm().") + H, W = (int(self.dataset.shape[-2]), int(self.dataset.shape[-1])) + flat = self.peaks.select_fields("q_row", "q_col", "intensity").flatten() + + bvm = np.zeros((H, W), dtype=float) + if flat.shape[0] > 0: + rows = np.clip(np.round(flat[:, 0]).astype(int), 0, H - 1) + cols = np.clip(np.round(flat[:, 1]).astype(int), 0, W - 1) + np.add.at(bvm, (rows, cols), flat[:, 2]) + + self.bvm = Dataset2d.from_array( + bvm, name="bragg_vector_map", sampling=(sampling, sampling), signal_units="intensity" + ) + return self.bvm + + def choose_basis_vectors( + self, + origin: int | tuple[float, float] | NDArray | None = None, + g1: int | tuple[float, float] | NDArray | None = None, + g2: int | tuple[float, float] | NDArray | None = None, + *, + num_candidates: int = 100, + min_spacing: float = 2.0, + min_abs_intensity: float = 0.0, + plot: bool = True, + returnfig: bool = False, + **show_kwargs, + ): + """Select the lattice basis ``(origin, g1, g2)`` from the Bragg vector map. + + Any of ``origin``/``g1``/``g2`` may be given explicitly; the rest are picked + automatically. The origin is the **brightest** candidate peak (the + unscattered central beam). With ``quality = intensity / distance`` rewarding + short, bright vectors, ``g1`` is then the highest-``quality`` peak and ``g2`` + is the highest ``quality * sin^2(theta)`` peak, where ``theta`` is its angle + to ``g1`` (the ``sin^2`` factor vanishes for peaks collinear with ``g1``). + + Each override accepts **either** form, told apart by shape: + + - a scalar **candidate index** (an ``int``) picks one of the numbered + candidate peaks drawn on the plot; + - a **``(row, col)`` vector** is taken literally — an absolute position for + ``origin``, an offset *from the origin* for ``g1``/``g2``. + + With ``plot=True`` (default) the Bragg vector map is shown with the + candidate peaks numbered and the chosen basis overlaid, so the index to + pass back here can be read straight off the figure. + + Parameters + ---------- + origin : int or tuple of float or np.ndarray, optional + Candidate index, or absolute ``(row, col)`` lattice origin. Picked + automatically if omitted. + g1 : int or tuple of float or np.ndarray, optional + Candidate index (vector taken as ``peak - origin``), or a ``(row, col)`` + offset *from the origin*. Picked automatically if omitted. + g2 : int or tuple of float or np.ndarray, optional + Candidate index (vector taken as ``peak - origin``), or a ``(row, col)`` + offset *from the origin*. Picked automatically if omitted. + num_candidates : int, default=100 + Number of brightest candidate peaks to consider (and to number on the + plot). + min_spacing : float, default=2.0 + Minimum spacing in pixels between candidate peaks. + min_abs_intensity : float, default=0.0 + Drop candidate peaks below this absolute intensity. + plot : bool, default=True + If ``True``, show the Bragg vector map with the numbered candidates and + the chosen basis overlaid via + :func:`~quantem.diffraction.bragg_vectors_visualization.plot_basis_vectors`. + returnfig : bool, default=False + If ``True``, return ``(fig, ax)`` from the overlay plot instead of + ``self`` (implies ``plot=True``). + **show_kwargs + Display-scaling options forwarded to the overlay plot's + :func:`~quantem.core.visualization.show_2d` call (e.g. ``norm``, + ``vmin``, ``vmax``, ``cmap``, ``lower_quantile``, ``upper_quantile``). + + Returns + ------- + BraggVectors or tuple + ``self`` for method chaining; or ``(fig, ax)`` when ``returnfig=True``. + """ + if self.bvm is None: + raise ValueError("Run detect_disks()/compute_bvm() before choosing basis vectors.") + + cand_rc, cand_int = self._bvm_candidates(num_candidates, min_spacing, min_abs_intensity) + if cand_rc.shape[0] == 0: + raise RuntimeError("No candidate peaks found in the Bragg vector map.") + # remember the numbered candidates so index_peaks() reuses this exact set. + self.candidates_rc = cand_rc + self.candidates_intensity = cand_int + + def _candidate(idx: int) -> NDArray: + i = int(idx) + if not -cand_rc.shape[0] <= i < cand_rc.shape[0]: + raise IndexError( + f"candidate index {i} out of range for {cand_rc.shape[0]} candidates " + f"(increase num_candidates or loosen min_spacing/min_abs_intensity)." + ) + return cand_rc[i] + + if origin is None: + # candidates are returned brightest-first, so [0] is the central beam. + origin_rc = cand_rc[0] + elif np.ndim(origin) == 0: + origin_rc = _candidate(origin) + else: + origin_rc = np.asarray(origin, dtype=float).reshape(2) + + rel = cand_rc - origin_rc + dist = np.linalg.norm(rel, axis=1) + valid = dist > max(1e-6, min_spacing) + # "shortest/brightest": reward bright peaks close to the origin. + quality = cand_int / (dist + 1e-12) + + if g1 is None: + if not valid.any(): + raise RuntimeError("Could not find a g1 candidate distinct from the origin.") + g1_rc = rel[int(np.argmax(np.where(valid, quality, -np.inf)))] + elif np.ndim(g1) == 0: + g1_rc = _candidate(g1) - origin_rc + else: + g1_rc = np.asarray(g1, dtype=float).reshape(2) + + if g2 is None: + # g2 = highest quality * sin^2(theta) where theta is the angle to g1; + # sin^2 = 1 - cos^2 vanishes for peaks collinear with g1. + g1n = g1_rc / (np.linalg.norm(g1_rc) + 1e-12) + cos = rel @ g1n / (dist + 1e-12) + sin2 = np.clip(1.0 - cos**2, 0.0, 1.0) + g2_score = np.where(valid, quality * sin2, -np.inf) + if g2_score.max() <= 0.0: + raise RuntimeError("Could not find a g2 candidate non-collinear with g1.") + g2_rc = rel[int(np.argmax(g2_score))] + elif np.ndim(g2) == 0: + g2_rc = _candidate(g2) - origin_rc + else: + g2_rc = np.asarray(g2, dtype=float).reshape(2) + + self.origin = np.asarray(origin_rc, dtype=float).reshape(2) + self.g1 = np.asarray(g1_rc, dtype=float).reshape(2) + self.g2 = np.asarray(g2_rc, dtype=float).reshape(2) + + if plot or returnfig: + fig, ax = plot_basis_vectors( + np.asarray(self.bvm.array), + cand_rc, + cand_int, + self.origin, + self.g1, + self.g2, + **show_kwargs, + ) + if returnfig: + return fig, ax + return self + + def index_peaks( + self, + *, + plot: bool = True, + returnfig: bool = False, + **show_kwargs, + ): + """Index the chosen candidate peaks into a reference lattice. + + Assigns integer Miller indices ``(a, b)`` to the numbered candidate peaks + picked in :meth:`choose_basis_vectors` — not the full per-position + detections; that heavy lifting happens in :meth:`fit_lattice`. Each + candidate gets ``[a, b] = round(B^-1 (q - origin))`` with ``B`` columns + ``g1, g2``; when two candidates round to the same ``(a, b)`` only the + brightest is kept. The result is a compact reference lattice stored on + :attr:`reference_ab` / :attr:`reference_qpos` / :attr:`reference_intensity` + which :meth:`fit_lattice` matches against at every scan position. This step + is deliberately quick and lightweight. + + With ``plot=True`` (default) the reference lattice is drawn over the Bragg + vector map, each site ringed and labelled with its ``(a, b)`` index. The ring + color encodes how far the picked candidate sits from its ideal lattice site + ``origin + a*g1 + b*g2``, so a mis-picked or duplicate candidate (which rings + far from zero offset) is easy to spot. + + Parameters + ---------- + plot : bool, default=True + If ``True``, show the reference lattice via + :func:`~quantem.diffraction.bragg_vectors_visualization.plot_reference_lattice`. + returnfig : bool, default=False + If ``True``, return ``(fig, ax)`` instead of ``self`` (implies ``plot``). + **show_kwargs + Display-scaling options forwarded to the plot's + :func:`~quantem.core.visualization.show_2d` call (e.g. ``norm``, + ``vmin``, ``vmax``, ``cmap``). + + Returns + ------- + BraggVectors or tuple + ``self`` for method chaining; or ``(fig, ax)`` when ``returnfig=True``. + """ + if self.candidates_rc is None or self.candidates_intensity is None: + raise ValueError("Run choose_basis_vectors() before index_peaks().") + if self.origin is None or self.g1 is None or self.g2 is None: + raise ValueError("Run choose_basis_vectors() before index_peaks().") + + cand_rc = self.candidates_rc + cand_int = self.candidates_intensity + ab = _index_directions(cand_rc, self.origin, self.g1, self.g2) + + # Dedupe by (a, b): candidates are brightest-first, so the first occurrence + # of each index is the brightest -- keep it, drop the dimmer duplicates. + seen: dict[tuple[int, int], int] = {} + for i, (a, b) in enumerate(ab): + key = (int(a), int(b)) + if key not in seen: + seen[key] = i + keep = np.array(sorted(seen.values()), dtype=int) + + self.reference_ab = ab[keep] + self.reference_qpos = cand_rc[keep] + self.reference_intensity = cand_int[keep] + + if not (plot or returnfig): + return self + + fig, ax = plot_reference_lattice( + np.asarray(self.bvm.array), + self.reference_qpos, + self.reference_ab, + self.origin, + self.g1, + self.g2, + **show_kwargs, + ) + if returnfig: + return fig, ax + return self + + def fit_lattice( + self, + min_num_peaks: int = 5, + max_peak_shift: float | None = None, + *, + progressbar: bool = True, + plot: bool = True, + returnfig: bool = False, + ): + """Per-position weighted least-squares fit of the lattice vectors. + + This is the heavy step. At every scan position the detected peaks are + matched to the *ideal* lattice sites from :meth:`index_peaks` — + ``origin + a*g1 + b*g2`` for each reference ``(a, b)`` — keeping a peak only + when it lands within ``max_peak_shift`` of its nearest ideal site (not the + measured candidate position), then ``q = x0 + a*g1 + b*g2`` is fit by + intensity-weighted least squares over the matched peaks. The fitted ``g1``/``g2`` go into :attr:`u_array`/ + :attr:`v_array` (shape ``(scan_row, scan_col, 2)``, row/col components); + positions with fewer than ``min_num_peaks`` matched peaks are left ``nan``. + + Two diagnostics are stored per position. :attr:`fit_error` is the RMS fit + residual over the *matched* peaks, in pixels. :attr:`mask_weight` is a lattice + *order parameter* in ``0``–``1``: every detected peak is snapped to the + nearest site of the just-fitted lattice and the intensity-weighted RMS of + those displacements (the zero beam excluded) is normalized by + ``sqrt(|g1 x g2| / 2*pi)`` — the RMS displacement expected from intensity + scattered at random with no lattice — as ``1 - RMS_all / rms_rand`` clipped to + ``[0, 1]``. Because it weighs *all* detected intensity against the lattice + (not just the matched peaks, as :attr:`fit_error` does), a clean single + crystal approaches ``1`` while positions with strong off-lattice intensity (a + second grain, a mis-index, many spurious peaks) fall toward ``0``; weak false + positives carry little intensity and barely move it. Positions with no valid + fit (vacuum, fewer than ``min_num_peaks``) are ``0``. It is the default + reference weighting handed to :meth:`calculate_strain_map`. + + Parameters + ---------- + min_num_peaks : int, default=5 + Minimum number of matched peaks required to fit a position; positions + with fewer are left ``nan``. + max_peak_shift : float, optional + Inclusion radius in pixels: a detected peak is kept only if it lands + within this distance of its nearest *ideal* lattice site + (``origin + a*g1 + b*g2``), excluding peaks that stray too far from where + the best-fit lattice predicts. Defaults to ``0.5 * min(|g1|, |g2|)`` — + half the shorter lattice spacing. + progressbar : bool, default=True + If ``True``, show a tqdm progress bar over the scan positions. + plot : bool, default=True + If ``True``, show the fit diagnostics (mask weight + RMS error) via + :func:`~quantem.diffraction.bragg_vectors_visualization.plot_lattice_fit`. + returnfig : bool, default=False + If ``True``, return ``(fig, ax)`` instead of ``self`` (implies ``plot``). + + Returns + ------- + BraggVectors or tuple + ``self`` for method chaining; or ``(fig, ax)`` when ``returnfig=True``. + """ + if self.peaks is None: + raise ValueError("Run detect_disks() before fit_lattice().") + if self.reference_ab is None or self.reference_qpos is None: + raise ValueError("Run index_peaks() before fit_lattice().") + + ref_ab = self.reference_ab.astype(float) + + # Ideal lattice sites for the reference (a, b) set: origin + a*g1 + b*g2. + # Per-position detections are matched to these *ideal* points (not the measured + # candidate positions), so a peak is included only when it lands within + # max_peak_shift of where the best-fit lattice predicts it should be. + o = np.asarray(self.origin, dtype=float).reshape(2) + g1 = np.asarray(self.g1, dtype=float).reshape(2) + g2 = np.asarray(self.g2, dtype=float).reshape(2) + ideal_qpos = o[None, :] + ref_ab[:, 0:1] * g1[None, :] + ref_ab[:, 1:2] * g2[None, :] + + if max_peak_shift is None: + max_peak_shift = 0.5 * float(min(np.linalg.norm(g1), np.linalg.norm(g2))) + + # Normalization scale for the mask weight: the intensity-weighted RMS + # peak-to-nearest-site displacement expected from intensity scattered at + # random (uniformly over a unit cell of area |g1 x g2|) -- i.e. with no + # lattice order at all. sqrt(A / 2pi) is the equal-area-disk value; the mask + # weight is then 1 - RMS_all / rms_rand, a lattice order parameter in [0, 1]. + cell_area = abs(float(g1[0] * g2[1] - g1[1] * g2[0])) + rms_rand = float(np.sqrt(cell_area / (2.0 * np.pi))) if cell_area > 0 else 1.0 + + scan_r, scan_c = int(self.dataset.shape[0]), int(self.dataset.shape[1]) + u_array = np.full((scan_r, scan_c, 2), np.nan, dtype=float) + v_array = np.full((scan_r, scan_c, 2), np.nan, dtype=float) + mask_weight = np.zeros((scan_r, scan_c), dtype=float) + fit_error = np.full((scan_r, scan_c), np.nan, dtype=float) + + fields = self.peaks.fields + i_qr, i_qc = fields.index("q_row"), fields.index("q_col") + i_int = fields.index("intensity") + + coords: Any = list(np.ndindex(scan_r, scan_c)) + if progressbar: + try: + from tqdm.auto import tqdm + + coords = tqdm(coords, desc="fit_lattice", leave=True) + except Exception: + pass + + for r, c in coords: + cell = self.peaks[r, c].array + if cell.shape[0] == 0: + continue + qpos = cell[:, [i_qr, i_qc]] + inten = cell[:, i_int] + + # nearest *ideal* lattice site for each detected peak, kept if close enough + d = np.linalg.norm(qpos[:, None, :] - ideal_qpos[None, :, :], axis=2) + nearest = np.argmin(d, axis=1) + matched = d[np.arange(d.shape[0]), nearest] <= max_peak_shift + + if int(matched.sum()) < min_num_peaks: + continue + + beta, rms = _fit_lattice_vectors( + qpos[matched, 0], + qpos[matched, 1], + ref_ab[nearest[matched], 0], + ref_ab[nearest[matched], 1], + inten[matched], + ) + if beta is None: + continue + u_array[r, c] = beta[1] + v_array[r, c] = beta[2] + fit_error[r, c] = rms + + # mask weight = lattice "order parameter": snap EVERY detected peak to the + # nearest site of the just-fitted lattice (beta = [x0, g1, g2]) and take + # the intensity-weighted RMS of those displacements, excluding the zero + # beam. Unlike fit_error (matched peaks only), this sees OFF-lattice + # intensity -- a second grain, a mis-index, or many spurious peaks drive + # it up -- while weak false positives, carrying little intensity, barely + # move it. Normalized by rms_rand: 1 (all intensity on the lattice) down + # to 0 (scattered as if there were no lattice). + x0 = beta[0] + mat = np.stack([beta[1], beta[2]]) # (2, 2): rows g1, g2 + ab = np.rint((qpos - x0[None, :]) @ np.linalg.inv(mat)) + sites = x0[None, :] + ab @ mat + disp = np.linalg.norm(qpos - sites, axis=1) + nonzero = ~np.all(ab == 0, axis=1) # drop the central (zero) beam + w = np.clip(inten[nonzero], 0.0, None) + wsum = float(w.sum()) + if wsum > 0: + rms_all = float(np.sqrt(np.sum(w * disp[nonzero] ** 2) / wsum)) + mask_weight[r, c] = float(np.clip(1.0 - rms_all / rms_rand, 0.0, 1.0)) + + self.u_array = u_array + self.v_array = v_array + self.mask_weight = mask_weight + self.fit_error = fit_error + self.metadata["fit"] = { + "min_num_peaks": int(min_num_peaks), + "max_peak_shift": float(max_peak_shift), + } + + if not (plot or returnfig): + return self + + fig, ax = plot_lattice_fit(mask_weight, fit_error) + if returnfig: + return fig, ax + return self + + def calculate_strain_map( + self, + u_ref: np.ndarray | None = None, + v_ref: np.ndarray | None = None, + mask: np.ndarray | None = None, + ) -> StrainMap: + """Build a :class:`StrainMap` from the fitted per-position lattice vectors. + + Parameters + ---------- + u_ref : np.ndarray, optional + ``(2,)`` reference for the first lattice vector. Defaults to the median + over the scan inside :class:`StrainMap`. + v_ref : np.ndarray, optional + ``(2,)`` reference for the second lattice vector. Defaults to the median + over the scan inside :class:`StrainMap`. + mask : np.ndarray, optional + ``(scan_row, scan_col)`` per-position weighting used when computing the + reference lattice. Defaults to :attr:`mask_weight` from + :meth:`fit_lattice` (the lattice order parameter — how well all detected + intensity snaps to the fitted lattice), so clean single-crystal positions + dominate the reference and positions with off-lattice intensity are + down-weighted. + + Returns + ------- + StrainMap + A strain map initialized from the fitted lattice vectors. + """ + if self.u_array is None or self.v_array is None: + raise ValueError("Run fit_lattice() before calculate_strain_map().") + + if mask is None: + mask = self.mask_weight + + ds_sampling = float(self.dataset.sampling[0]) + ds_units = str(self.dataset.units[0]) + + return StrainMap( + u_array=self.u_array, + v_array=self.v_array, + ds_shape=tuple(self.dataset.shape), + real_space=self.real_space, + u_ref=u_ref, + v_ref=v_ref, + mask=mask, + ds_sampling=ds_sampling, + ds_units=ds_units, + ) + + # ---- visualization ---- + + def show_template( + self, + position: tuple[int, int] = (0, 0), + *, + crop_factor: float | None = None, + returnfig: bool = False, + **kwargs, + ): + """Plot the mean diffraction pattern, the template, and one correlation map. + + Parameters + ---------- + position : tuple of int, default=(0, 0) + ``(row, col)`` scan position whose correlation map is shown. + crop_factor : float, optional + If given, zoom all three panels to a square window of half-width + ``crop_factor * radius`` about the pattern center, where ``radius`` is + the central-beam radius (the synthetic template radius if known, else + estimated from the mean diffraction pattern). For example, + ``crop_factor=2.0`` shows two beam radii either side of the center. The + window is clamped to the detector, so a large factor shows the full + image. ``None`` (default) shows the full panels. + returnfig : bool, default=False + If ``True``, return the ``(fig, ax)`` for further customization. + **kwargs + Extra keyword arguments forwarded to + :func:`~quantem.diffraction.bragg_vectors_visualization.plot_template`. + + Returns + ------- + tuple + ``(fig, ax)`` when ``returnfig=True``; otherwise nothing. + """ + if self._template is None: + raise ValueError("Run a make_template_* method before show_template().") + r, c = int(position[0]), int(position[1]) + dp_mean = np.asarray(self.dataset.dp_mean.array) + + crop = None + if crop_factor is not None: + H, W = int(self.dataset.shape[-2]), int(self.dataset.shape[-1]) + radius = self.metadata.get("template", {}).get("radius") + if radius is None: + _, radius = estimate_central_beam(dp_mean) + crop = (H // 2, W // 2, float(crop_factor) * float(radius)) + + fig, ax = plot_template( + dp_mean, + self.template, + self.correlation_map(r, c), + (r, c), + crop=crop, + **kwargs, + ) + if returnfig: + return fig, ax + + def show_diffraction( + self, + inds: list[tuple[int, int]], + image: np.ndarray | None = None, + *, + ncols: int = 4, + image_kwargs: dict | None = None, + marker_radius: float | None = None, + linewidth: float = 0.5, + sigma_plot: float | None = None, + returnfig: bool = False, + **show_kwargs, + ): + """Preview the diffraction patterns at ``inds``, optionally beside a navigation image. + + No detection is run; this is for choosing scan positions to tune on. The + patterns are tiled ``ncols`` wide and rendered with + :func:`~quantem.core.visualization.show_2d`; the navigation image is styled + independently (real space and reciprocal space rarely want the same + scaling). + + Parameters + ---------- + inds : list of tuple of int + ``(row, col)`` scan positions to preview. + image : np.ndarray or Dataset2d, optional + Real-space navigation image (e.g. a virtual dark-field image) shown on + the left with the positions marked. ``None`` (default) shows only the + diffraction tiles. + ncols : int, default=4 + Number of diffraction tiles per row. + image_kwargs : dict, optional + Keyword arguments styling the navigation image (passed to + :func:`~quantem.core.visualization.show_2d`). + marker_radius : float, optional + Radius in image pixels of the scan-position marker rings. + linewidth : float, default=0.5 + Stroke width of the scan-position markers. + sigma_plot : float, optional + Gaussian blur (sigma) applied to the *displayed* patterns only. + returnfig : bool, default=False + If ``True``, return the ``(fig, ax)`` for further customization. + **show_kwargs + Extra keyword arguments (e.g. ``norm``, ``cmap``, ``cbar``, ``axsize``) + styling the diffraction tiles via + :func:`~quantem.core.visualization.show_2d`. + + Returns + ------- + tuple + ``(fig, ax)`` when ``returnfig=True``; otherwise nothing. + """ + if image is None: + image_arr = None + image_title = "navigation image" + else: + image_title = getattr(image, "name", None) or "navigation image" + image_arr = np.asarray(image.array if hasattr(image, "array") else image) + dps = [np.asarray(self.dataset.array[r, c], dtype=float) for r, c in inds] + fig, ax = plot_diffraction_grid( + image_arr, + dps, + inds, + ncols=ncols, + image_title=image_title, + image_kwargs=image_kwargs, + marker_radius=marker_radius, + linewidth=linewidth, + sigma_plot=sigma_plot, + **show_kwargs, + ) + if returnfig: + return fig, ax + + def show_detection( + self, + positions: list[tuple[int, int]] | None = None, + *, + min_abs_intensity: float = 0.0, + min_spacing: float = 0.0, + edge_boundary: int = 1, + subpixel: str = "upsample", + upsample_factor: int = 16, + max_num_peaks: int = 1000, + image: np.ndarray | None = None, + peak_radius: float = 6.0, + marker_radius: float | None = None, + linewidth: float = 1.0, + sigma_plot: float | None = None, + image_kwargs: dict | None = None, + returnfig: bool = False, + **plot_kwargs, + ): + """Detect on a few patterns and overlay the peaks, for tuning hyperparameters. + + The detection keywords match :meth:`detect_disks`. The workflow state + (:attr:`peaks`/:attr:`bvm`) is left untouched, so this is safe to re-run + while tuning; for the raw peaks, call :meth:`detect_disks` with the same + ``positions``. + + Parameters + ---------- + positions : list of tuple of int, optional + ``(row, col)`` scan positions to detect on. ``None`` (default) + auto-samples four positions spread across the scan. + min_abs_intensity : float, default=0.0 + Drop correlation peaks below this absolute intensity. + min_spacing : float, default=0.0 + Minimum spacing in pixels between kept peaks. + edge_boundary : int, default=1 + Width in pixels of the border in which peaks are ignored. + subpixel : {"none", "parabolic", "upsample"}, default="upsample" + Subpixel refinement mode; see :meth:`detect_disks`. + upsample_factor : int, default=16 + Upsampling factor for the ``"upsample"`` subpixel refinement. + max_num_peaks : int, default=1000 + Maximum number of peaks to keep per pattern. + image : np.ndarray or Dataset2d, optional + Real-space navigation image (e.g. a virtual dark-field image) shown on + the left with the chosen positions marked. ``None`` (default) shows only + the diffraction tiles. + peak_radius : float, default=6.0 + Radius in diffraction pixels of the cyan rings drawn at detected peaks. + marker_radius : float, optional + Radius in image pixels of the scan-position marker rings. + linewidth : float, default=1.0 + Stroke width of the peak and scan-position rings. + sigma_plot : float, optional + Gaussian blur (sigma) applied to the *displayed* patterns only; + detection still uses the raw data. + image_kwargs : dict, optional + Keyword arguments styling the navigation image (passed to + :func:`~quantem.core.visualization.show_2d`). + returnfig : bool, default=False + If ``True``, return the ``(fig, ax)`` for further customization. + **plot_kwargs + Extra keyword arguments (e.g. ``ncols``, ``norm``, ``cmap``, ``cbar``, + ``axsize``) styling the diffraction tiles via + :func:`~quantem.core.visualization.show_2d`. + + Returns + ------- + tuple + ``(fig, ax)`` when ``returnfig=True``; otherwise nothing. + """ + if positions is None: + positions = self._sample_positions() + sub = self.detect_disks( + positions=positions, + min_abs_intensity=min_abs_intensity, + min_spacing=min_spacing, + edge_boundary=edge_boundary, + subpixel=subpixel, + upsample_factor=upsample_factor, + max_num_peaks=max_num_peaks, + progressbar=False, + ) + if image is None: + image_arr = None + image_title = "virtual image" + else: + image_title = getattr(image, "name", None) or "virtual image" + image_arr = np.asarray(image.array if hasattr(image, "array") else image) + dps = [np.asarray(self.dataset.array[r, c], dtype=float) for r, c in positions] + peaks = [sub[i].array for i in range(len(positions))] + fig, ax = plot_detection( + image_arr, + dps, + peaks, + positions, + peak_radius=peak_radius, + marker_radius=marker_radius, + linewidth=linewidth, + sigma_plot=sigma_plot, + image_title=image_title, + image_kwargs=image_kwargs, + **plot_kwargs, + ) + if returnfig: + return fig, ax + + def peak_histogram(self, *, returnfig: bool = False, **kwargs): + """Plot the Bragg vector map beside the per-position peak count. + + The Bragg vector map is the 2-D histogram of all detected peak positions + (intensity-weighted) accumulated over the scan; the second panel shows the + number of peaks detected at each scan position. + + Parameters + ---------- + returnfig : bool, default=False + If ``True``, return the ``(fig, ax)`` for further customization. + **kwargs + Extra keyword arguments forwarded to + :func:`~quantem.diffraction.bragg_vectors_visualization.plot_bvm`. + + Returns + ------- + tuple + ``(fig, ax)`` when ``returnfig=True``; otherwise nothing. + """ + if self.peaks is None or self.bvm is None: + raise ValueError("Run detect_disks() before peak_histogram().") + scan_r, scan_c = int(self.dataset.shape[0]), int(self.dataset.shape[1]) + # row_counts() reads cell_lengths directly; far cheaper than building a + # per-cell view (self.peaks[r, c]) just to read its row count. + counts = np.asarray(self.peaks.row_counts(), dtype=int).reshape(scan_r, scan_c) + fig, ax = plot_bvm(np.asarray(self.bvm.array), counts, **kwargs) + if returnfig: + return fig, ax + + # ---- helpers ---- + + def _set_template( + self, + probe: torch.Tensor, + center: tuple[float, float] | None, + subtract_mean: bool, + ) -> None: + """Validate the probe shape, then store the template and its conjugate FT. + + Parameters + ---------- + probe : torch.Tensor + Probe image; must match the diffraction-pattern shape. + center : tuple of float or None + ``(row, col)`` probe center rolled to the origin, or ``None`` for the + geometric center. + subtract_mean : bool + If ``True``, make the template zero-sum. + """ + dp_shape = tuple(self.dataset.shape[-2:]) + probe_t = torch.as_tensor(probe, dtype=torch.float, device=self.device) + if tuple(probe_t.shape) != dp_shape: + raise ValueError( + f"probe shape {tuple(probe_t.shape)} does not match diffraction pattern " + f"shape {dp_shape}." + ) + self._template = make_template(probe_t, center=center, subtract_mean=subtract_mean) + self._template_ft = template_fourier(self._template) + + def _sample_positions(self) -> list[tuple[int, int]]: + """Four scan positions spread across the field (quadrant centers). + + Returns + ------- + list of tuple of int + Up to four ``(row, col)`` scan positions at the quadrant centers. + """ + R, C = int(self.dataset.shape[0]), int(self.dataset.shape[1]) + rs = sorted({min(max(R // 4, 0), R - 1), min(max(3 * R // 4, 0), R - 1)}) + cs = sorted({min(max(C // 4, 0), C - 1), min(max(3 * C // 4, 0), C - 1)}) + return [(r, c) for r in rs for c in cs] + + def _detect_positions( + self, + coords: list[tuple[int, int]], + detect_kwargs: dict[str, Any], + batch_size: int | None, + *, + progressbar: bool, + ) -> list[NDArray]: + """Batched detection over a list of ``(row, col)`` scan positions. + + Patterns are stacked into chunks of ``batch_size`` and passed to + :func:`~quantem.diffraction.disk_detection.detect_disks_batch`. + + Parameters + ---------- + coords : list of tuple of int + ``(row, col)`` scan positions to detect on. + detect_kwargs : dict + Keyword arguments forwarded to + :func:`~quantem.diffraction.disk_detection.detect_disks_batch`. + batch_size : int or None + Number of patterns per batch. ``None`` picks a size from the detector + dimensions. + progressbar : bool + If ``True``, show a tqdm progress bar over the patterns. + + Returns + ------- + list of np.ndarray + One ``(M, 3)`` array of ``[q_row, q_col, intensity]`` per position, in + ``coords`` order. + """ + H, W = int(self.dataset.shape[-2]), int(self.dataset.shape[-1]) + if batch_size is None: + batch_size = int(min(1024, max(1, 16_000_000 // (H * W)))) + + it = range(0, len(coords), batch_size) + if progressbar: + try: + from tqdm.auto import tqdm + + bar = tqdm(total=len(coords), desc="detect_disks", leave=True) + except Exception: + bar = None + else: + bar = None + + results: list[NDArray] = [] + for start in it: + chunk = coords[start : start + batch_size] + dps = torch.stack( + [ + torch.as_tensor( + np.asarray(self.dataset.array[r, c]), + dtype=torch.float, + device=self.device, + ) + for r, c in chunk + ], + dim=0, + ) + out = detect_disks_batch(dps, self._template_ft, **detect_kwargs) + results.extend( + arr if arr.shape[0] else np.empty((0, len(PEAK_FIELDS)), dtype=float) + for arr in out + ) + if bar is not None: + bar.update(len(chunk)) + + if bar is not None: + bar.close() + return results + + def _bvm_candidates( + self, num_candidates: int, min_spacing: float, min_abs_intensity: float + ) -> tuple[NDArray, NDArray]: + """Find the brightest, well-separated local maxima in the Bragg vector map. + + Parameters + ---------- + num_candidates : int + Maximum number of candidate peaks to return. + min_spacing : float + Minimum spacing in pixels between candidates. + min_abs_intensity : float + Drop candidates below this absolute intensity. + + Returns + ------- + cand_rc : np.ndarray + ``(N, 2)`` ``[row, col]`` candidate positions, brightest first. + cand_int : np.ndarray + ``(N,)`` candidate intensities. + """ + from quantem.diffraction.disk_detection import _filter_maxima, _local_maxima + + bvm = torch.as_tensor(self.bvm.array, dtype=torch.float) + peaks = _local_maxima(bvm, edge_boundary=1) + peaks = _filter_maxima(peaks, min_abs_intensity, min_spacing, num_candidates) + arr = peaks.detach().cpu().numpy() + return arr[:, :2].astype(float), arr[:, 2].astype(float) + + +def _fractional_indices(q: NDArray, origin: NDArray, g1: NDArray, g2: NDArray) -> NDArray: + """Continuous (unrounded) ``(a, b)`` lattice coordinates of peaks ``q``. + + Solves ``B [a, b]^T = q - origin`` (least squares), with + ``B = [[g1_row, g2_row], [g1_col, g2_col]]``. + + Parameters + ---------- + q : np.ndarray + ``(N, 2)`` ``[row, col]`` peak positions. + origin : np.ndarray + ``(2,)`` lattice origin ``[row, col]``. + g1 : np.ndarray + ``(2,)`` first lattice vector ``[row, col]``. + g2 : np.ndarray + ``(2,)`` second lattice vector ``[row, col]``. + + Returns + ------- + np.ndarray + ``(N, 2)`` float array of continuous ``[a, b]`` coordinates. + """ + if q.shape[0] == 0: + return np.empty((0, 2), dtype=float) + beta = np.array([[g1[0], g2[0]], [g1[1], g2[1]]], dtype=float) + alpha = (q - origin[None, :]).T # (2, N) + return np.linalg.lstsq(beta, alpha, rcond=None)[0].T # (N, 2) + + +def _index_directions(q: NDArray, origin: NDArray, g1: NDArray, g2: NDArray) -> NDArray: + """Integer Miller indices for peaks ``q`` against basis ``(g1, g2)`` about ``origin``. + + Rounds the continuous coordinates from :func:`_fractional_indices`. + + Parameters + ---------- + q : np.ndarray + ``(N, 2)`` ``[row, col]`` peak positions. + origin : np.ndarray + ``(2,)`` lattice origin ``[row, col]``. + g1 : np.ndarray + ``(2,)`` first lattice vector ``[row, col]``. + g2 : np.ndarray + ``(2,)`` second lattice vector ``[row, col]``. + + Returns + ------- + np.ndarray + ``(N, 2)`` int array of ``[a, b]`` Miller indices. + """ + if q.shape[0] == 0: + return np.empty((0, 2), dtype=int) + return np.round(_fractional_indices(q, origin, g1, g2)).astype(int) + + +def _fit_lattice_vectors( + q_row: NDArray, + q_col: NDArray, + a: NDArray, + b: NDArray, + intensity: NDArray, +) -> tuple[NDArray | None, NDArray | None]: + """Intensity-weighted lattice fit ``q = x0 + a*g1 + b*g2`` for one pattern. + + Parameters + ---------- + q_row : np.ndarray + ``(N,)`` peak row positions. + q_col : np.ndarray + ``(N,)`` peak column positions. + a : np.ndarray + ``(N,)`` Miller index along ``g1``. + b : np.ndarray + ``(N,)`` Miller index along ``g2``. + intensity : np.ndarray + ``(N,)`` peak intensities, used as fit weights (``sqrt`` of the clamped + intensity). + + Returns + ------- + beta : np.ndarray or None + ``(3, 2)`` fit ``[x0; g1; g2]`` (row/col components per row), or ``None`` if + the fit is rank-deficient (e.g. all peaks share one lattice row). + rms : float + RMS fit residual in pixels (``nan`` when ``beta`` is ``None``). + """ + design = np.stack([np.ones_like(a), a, b], axis=1) # (N, 3) + target = np.stack([q_row, q_col], axis=1) # (N, 2) + w = np.sqrt(np.clip(intensity, 0.0, None))[:, None] + + if np.linalg.matrix_rank(design * w) < 3: + return None, float("nan") + + beta = np.linalg.lstsq(design * w, target * w, rcond=None)[0] # (3, 2): x0, g1, g2 + resid = target - design @ beta + rms = float(np.sqrt(np.mean(np.sum(resid**2, axis=1)))) + return beta, rms diff --git a/src/quantem/diffraction/bragg_vectors_visualization.py b/src/quantem/diffraction/bragg_vectors_visualization.py new file mode 100644 index 00000000..891f2e72 --- /dev/null +++ b/src/quantem/diffraction/bragg_vectors_visualization.py @@ -0,0 +1,768 @@ +from __future__ import annotations + +import matplotlib.pyplot as plt +import numpy as np + + +def plot_template( + dp_mean: np.ndarray, + template: np.ndarray, + corr_map: np.ndarray, + position: tuple[int, int], + *, + crop: tuple[float, float, float] | None = None, + figsize: tuple[float, float] = (13, 4), +): + """Mean diffraction pattern, the (centered) template, and one correlation map. + + Parameters + ---------- + dp_mean : np.ndarray + Mean diffraction pattern. + template : np.ndarray + The correlation template, centered for display. + corr_map : np.ndarray + Correlation map computed at ``position``. + position : tuple of int + ``(row, col)`` scan position the correlation map was computed at. + crop : tuple of float, optional + ``(center_row, center_col, half_width)`` zoom window (in pixels) applied to + all three panels. The view spans ``half_width`` either side of the center, + clamped to each image's bounds, so an over-large ``half_width`` just shows + the full image. ``None`` (default) shows the full panels. + figsize : tuple of float, default=(13, 4) + Figure size in inches. + + Returns + ------- + tuple + ``(fig, ax)`` with ``ax`` a length-3 array of axes. + """ + fig, ax = plt.subplots(1, 3, figsize=figsize) + ax[0].imshow(dp_mean, cmap="gray") + ax[0].set_title("mean diffraction") + ax[1].imshow(template, cmap="gray") + ax[1].set_title("template (centered)") + ax[2].imshow(corr_map, cmap="viridis") + ax[2].set_title(f"correlation @ {tuple(position)}") + for a in ax: + a.set_xticks([]) + a.set_yticks([]) + if crop is not None: + cr, cc, hw = float(crop[0]), float(crop[1]), float(crop[2]) + for a, img in zip(ax, (dp_mean, template, corr_map)): + h, w = img.shape[:2] + a.set_xlim(max(cc - hw, -0.5), min(cc + hw, w - 0.5)) + a.set_ylim(min(cr + hw, h - 0.5), max(cr - hw, -0.5)) + fig.tight_layout() + return fig, ax + + +def _mark_positions(ax, positions, *, radius=None, linewidth=0.5): + """Overlay numbered red markers at each ``(row, col)`` scan position. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Axis to draw the markers on. + positions : sequence of tuple of int + ``(row, col)`` scan positions to mark, numbered in order. + radius : float, optional + Marker radius in image pixels. If given, each marker is a ring of that + radius drawn as a circle patch in data coordinates; if ``None``, a fixed + screen-size scatter marker is used instead. + linewidth : float, default=0.5 + Ring stroke width. + """ + from matplotlib.patches import Circle + + for i, (r, c) in enumerate(positions): + if radius is None: + ax.scatter(c, r, s=70, facecolors="none", edgecolors="red", linewidths=linewidth) + else: + ax.add_patch( + Circle((c, r), radius=radius, fill=False, edgecolor="red", linewidth=linewidth) + ) + ax.annotate( + str(i), + (c, r), + color="red", + fontsize=11, + fontweight="bold", + xytext=(4, 4), + textcoords="offset points", + ) + + +def _blur(dp: np.ndarray, sigma: float | None) -> np.ndarray: + """Gaussian-blur a diffraction pattern for display only (passthrough if ``sigma`` falsy). + + Smoothing the *displayed* pattern (the detection still runs on the raw data) + makes it easier to judge by eye which correlation peaks sit on real disks and + which are noise. + + Parameters + ---------- + dp : np.ndarray + Diffraction pattern to blur. + sigma : float or None + Gaussian blur width in pixels. If falsy (``None``, ``0``, or negative), + ``dp`` is returned unchanged. + + Returns + ------- + np.ndarray + The blurred pattern, or ``dp`` unchanged when ``sigma`` is falsy. + """ + if not sigma or sigma <= 0: + return dp + from scipy.ndimage import gaussian_filter + + return gaussian_filter(np.asarray(dp, dtype=float), float(sigma)) + + +def _grid_axes(n, ncols, *, show_image, axsize=None, figsize=None): + """Figure with an optional left nav-image axis and a right ``nrows x ncols`` tile grid. + + With ``show_image`` a navigation-image axis is placed to the left of the tile + grid; otherwise the figure is just the grid. + + Parameters + ---------- + n : int + Number of tiles to lay out. + ncols : int + Number of tile columns, clamped to ``n``. + show_image : bool + If ``True``, add a navigation-image axis to the left of the tile grid. + axsize : tuple of float, optional + Per-tile size in inches, ``(w, h)``. Sizes the figure when ``figsize`` is + not given, so a larger ``axsize`` zooms every tile. + figsize : tuple of float, optional + Explicit figure size in inches; overrides the ``axsize``-derived size. + + Returns + ------- + tuple + ``(fig, ax_image, dp_axes, ncols)`` where ``ax_image`` is ``None`` when + ``show_image`` is ``False``, ``dp_axes`` is an ``(nrows, ncols)`` object + array of axes (trailing unused tiles already turned off), and ``ncols`` is + clamped to ``n``. + """ + ncols = max(1, min(ncols, n)) + nrows = int(np.ceil(n / ncols)) + + tile_w, tile_h = (float(axsize[0]), float(axsize[1])) if axsize is not None else (3.0, 3.2) + nav_w = (tile_w if axsize is not None else 4.0) if show_image else 0.0 + if figsize is None: + figsize = (nav_w + tile_w * ncols, max(tile_h, tile_h * nrows)) + + fig = plt.figure(figsize=figsize) + if show_image: + outer = fig.add_gridspec(1, 2, width_ratios=[nav_w, tile_w * ncols], wspace=0.12) + ax_image = fig.add_subplot(outer[0, 0]) + grid = outer[0, 1].subgridspec(nrows, ncols, wspace=0.08, hspace=0.2) + else: + ax_image = None + grid = fig.add_gridspec(nrows, ncols, wspace=0.08, hspace=0.2) + + dp_axes = np.empty((nrows, ncols), dtype=object) + for i in range(nrows * ncols): + a = fig.add_subplot(grid[i // ncols, i % ncols]) + dp_axes[i // ncols, i % ncols] = a + if i >= n: + a.axis("off") + return fig, ax_image, dp_axes, ncols + + +def plot_diffraction_grid( + image: np.ndarray | None, + dps: list[np.ndarray], + positions: list[tuple[int, int]], + *, + ncols: int = 4, + image_title: str = "navigation image", + image_kwargs: dict | None = None, + marker_radius: float | None = None, + linewidth: float = 0.5, + sigma_plot: float | None = None, + axsize: tuple[float, float] | None = None, + figsize: tuple[float, float] | None = None, + **show_kwargs, +): + """A tiled grid of the diffraction patterns at ``positions``, optionally beside a nav image. + + When ``image`` is given (e.g. a virtual dark-field image) it is drawn on the + left with each scan position marked as a numbered red marker at ``(x=col, + y=row)``; pass ``image=None`` to omit it and show only the grid. Right: the + diffraction patterns tiled ``ncols`` wide. Both are rendered with + :func:`~quantem.core.visualization.show_2d`, and the navigation image and the + diffraction tiles take separate, independent styling (``image_kwargs`` vs + ``show_kwargs``). + + Parameters + ---------- + image : np.ndarray or None + Navigation image (e.g. a virtual dark-field image) drawn at left, with the + scan positions marked. Pass ``None`` to show only the diffraction grid. + dps : list of np.ndarray + Diffraction patterns to tile, one per entry in ``positions``. + positions : list of tuple of int + ``(row, col)`` scan positions, used for the tile titles and the nav-image + markers. + ncols : int, default=4 + Number of columns in the diffraction-pattern tile grid. + image_title : str, default="navigation image" + Title for the navigation image. + image_kwargs : dict, optional + Extra keyword arguments (e.g. ``norm``, ``cmap``, ``scalebar``) for the + navigation image's :func:`show_2d` call, styling it independently of the + tiles. + marker_radius : float, optional + Scan-position marker radius in image pixels; ``None`` uses a fixed + screen-size marker. + linewidth : float, default=0.5 + Stroke width of the scan-position markers. + sigma_plot : float, optional + Gaussian blur width (pixels) applied to the *displayed* patterns only (the + data is untouched) to ease judging real features. + axsize : tuple of float, optional + Per-tile size in inches; a larger value zooms the tiles. + figsize : tuple of float, optional + Explicit figure size in inches. + **show_kwargs + Forwarded to :func:`show_2d` for the diffraction-pattern tiles (e.g. + ``norm``, ``cmap``, ``cbar``). + + Returns + ------- + tuple + ``(fig, (ax_image, dp_axes))`` with ``ax_image`` ``None`` when no image is + shown. + """ + from quantem.core.visualization import show_2d + + n = len(positions) + show_image = image is not None + fig, ax_image, dp_axes, ncols = _grid_axes( + n, ncols, show_image=show_image, axsize=axsize, figsize=figsize + ) + + if show_image: + image_show_kwargs = {"cmap": "gray", "title": image_title, **(image_kwargs or {})} + show_2d(np.asarray(image), figax=(fig, ax_image), **image_show_kwargs) + _mark_positions(ax_image, positions, radius=marker_radius, linewidth=linewidth) + + user_title = show_kwargs.pop("title", None) + for i in range(n): + r, c = positions[i] + title = user_title if user_title is not None else f"{i}: ({r},{c})" + show_2d( + _blur(dps[i], sigma_plot), + figax=(fig, dp_axes[i // ncols, i % ncols]), + title=title, + **show_kwargs, + ) + return fig, (ax_image, dp_axes) + + +def plot_detection( + image: np.ndarray | None, + dps: list[np.ndarray], + peaks: list[np.ndarray], + positions: list[tuple[int, int]], + *, + ncols: int = 4, + peak_radius: float = 6.0, + marker_radius: float | None = None, + linewidth: float = 0.5, + sigma_plot: float | None = None, + image_title: str = "virtual image", + image_kwargs: dict | None = None, + axsize: tuple[float, float] | None = None, + figsize: tuple[float, float] | None = None, + **show_kwargs, +): + """The diffraction patterns with detected peaks overlaid, optionally beside a nav image. + + When ``image`` is given (e.g. a virtual dark-field image) it is drawn on the + left with each chosen scan position drawn as a numbered red marker at ``(x=col, + y=row)``; pass ``image=None`` to omit it and show only the tiles. Right: the + diffraction patterns tiled ``ncols`` wide, each rendered with + :func:`~quantem.core.visualization.show_2d`, with the detected peaks overlaid as + cyan rings that trace the disks rather than obscuring them. + + Parameters + ---------- + image : np.ndarray or None + Navigation image (e.g. a virtual dark-field image) drawn at left, with the + scan positions marked. Pass ``None`` to show only the diffraction tiles. + dps : list of np.ndarray + Diffraction patterns to tile, one per entry in ``positions``. + peaks : list of np.ndarray + Detected peaks per pattern; ``peaks[i]`` is an ``(M, 3)`` array of + ``[q_row, q_col, intensity]``. Each peak is drawn as a cyan ring at + ``(x=q_col, y=q_row)``. + positions : list of tuple of int + ``(row, col)`` scan positions, used for the tile titles and the nav-image + markers. + ncols : int, default=4 + Number of columns in the diffraction-pattern tile grid. + peak_radius : float, default=6.0 + Radius of the cyan peak rings, in diffraction pixels. + marker_radius : float, optional + Scan-position marker radius in image pixels; ``None`` uses a fixed + screen-size marker. + linewidth : float, default=0.5 + Stroke width of both the scan-position markers and the peak rings. + sigma_plot : float, optional + Gaussian blur width (pixels) applied to the *displayed* patterns only + (detection still uses the raw data) to ease telling real disks from false + positives. + image_title : str, default="virtual image" + Title for the navigation image. + image_kwargs : dict, optional + Extra keyword arguments for the navigation image's :func:`show_2d` call, + styling it independently of the tiles. + axsize : tuple of float, optional + Per-tile size in inches; a larger value zooms the tiles. + figsize : tuple of float, optional + Explicit figure size in inches. + **show_kwargs + Forwarded to :func:`show_2d` for the diffraction-pattern tiles (e.g. + ``norm``, ``cmap``, ``cbar``). + + Returns + ------- + tuple + ``(fig, (ax_image, dp_axes))`` with ``ax_image`` ``None`` when no image is + shown. + """ + from matplotlib.patches import Circle + + from quantem.core.visualization import show_2d + + n = len(positions) + show_image = image is not None + fig, ax_image, dp_axes, ncols = _grid_axes( + n, ncols, show_image=show_image, axsize=axsize, figsize=figsize + ) + + if show_image: + image_show_kwargs = {"cmap": "gray", "title": image_title, **(image_kwargs or {})} + show_2d(np.asarray(image), figax=(fig, ax_image), **image_show_kwargs) + _mark_positions(ax_image, positions, radius=marker_radius, linewidth=linewidth) + + user_title = show_kwargs.pop("title", None) + for i in range(n): + a = dp_axes[i // ncols, i % ncols] + r, c = positions[i] + pk = peaks[i] + title = user_title if user_title is not None else f"{i}: ({r},{c}) n={pk.shape[0]}" + show_2d(_blur(dps[i], sigma_plot), figax=(fig, a), title=title, **show_kwargs) + for q in pk: + a.add_patch( + Circle( + (q[1], q[0]), + radius=peak_radius, + fill=False, + edgecolor="cyan", + linewidth=linewidth, + ) + ) + + return fig, (ax_image, dp_axes) + + +def plot_basis_vectors( + bvm: np.ndarray, + cand_rc: np.ndarray, + cand_int: np.ndarray, + origin: np.ndarray, + g1: np.ndarray, + g2: np.ndarray, + *, + cmap: str = "gray", + norm: str | dict = "log_auto", + zoom: bool = True, + figsize: tuple[float, float] = (6, 6), + **show_kwargs, +): + """The Bragg vector map with the candidate peaks, origin, and basis vectors overlaid. + + Every candidate peak is drawn as a numbered cyan ring; those numbers are the + indices accepted by + :meth:`~quantem.diffraction.bragg_vectors.BraggVectors.choose_basis_vectors` + for overriding ``origin``/``g1``/``g2`` by peak. The chosen origin is a green + marker and ``g1`` (red) / ``g2`` (blue) are arrows drawn from it, labelled at + their midpoints so the labels never sit on top of the candidate numbers. + + Parameters + ---------- + bvm : np.ndarray + Bragg vector map; rendered through + :func:`~quantem.core.visualization.show_2d` so its display scaling is + controlled by ``norm`` and ``show_kwargs``. + cand_rc : np.ndarray + ``(N, 2)`` ``[row, col]`` candidate peak positions, brightest first; the + ring labels are their row indices. + cand_int : np.ndarray + ``(N,)`` candidate intensities (unused for drawing; kept for parity with + the candidate API). + origin : np.ndarray + ``(row, col)`` chosen lattice origin. + g1 : np.ndarray + First lattice vector as a ``(row, col)`` offset from ``origin``. + g2 : np.ndarray + Second lattice vector as a ``(row, col)`` offset from ``origin``. + cmap : str, default="gray" + Colormap for the Bragg vector map; gray keeps the colored overlays legible. + norm : str or dict, default="log_auto" + Intensity scaling forwarded to :func:`show_2d` (e.g. ``"linear_auto"``, + ``"log_auto"``, ``"power_sqrt"``, or ``{"power": 0.5}``). + zoom : bool, default=True + If ``True``, frame the view to the candidate bounding box (plus a margin) + so the numbered peaks are large enough to read. + figsize : tuple of float, default=(6, 6) + Figure size in inches. + **show_kwargs + Extra keyword arguments forwarded to :func:`show_2d` for fine display + control (e.g. ``vmin``, ``vmax``, ``lower_quantile``, ``upper_quantile``). + + Returns + ------- + tuple + ``(fig, ax)``. + """ + import matplotlib.patheffects as path_effects + + from quantem.core.visualization import show_2d + + stroke = [path_effects.withStroke(linewidth=2.5, foreground="black")] + origin_color = (0.0, 0.7, 0.0) + g1_color = (1.0, 0.0, 0.0) + g2_color = (0.0, 0.7, 1.0) + + fig, ax = plt.subplots(figsize=figsize) + show_2d(np.asarray(bvm), figax=(fig, ax), cmap=cmap, norm=norm, **show_kwargs) + + cand_rc = np.asarray(cand_rc, dtype=float).reshape(-1, 2) + for i, (r, c) in enumerate(cand_rc): + ax.scatter(c, r, s=60, facecolors="none", edgecolors="cyan", linewidths=1.0, zorder=3) + ax.annotate( + str(i), + (c, r), + color="cyan", + fontsize=9, + fontweight="bold", + xytext=(4, 4), + textcoords="offset points", + path_effects=stroke, + zorder=4, + ) + + o = np.asarray(origin, dtype=float).reshape(2) + ax.scatter( + o[1], + o[0], + s=160, + marker="P", + facecolors=[origin_color], + edgecolors="white", + linewidths=1.5, + zorder=6, + ) + for g, label, color in ( + (np.asarray(g1, float), "g1", g1_color), + (np.asarray(g2, float), "g2", g2_color), + ): + tip = (o[1] + g[1], o[0] + g[0]) + ax.annotate( + "", + xy=tip, + xytext=(o[1], o[0]), + arrowprops=dict(arrowstyle="-|>", color=color, lw=2.4, shrinkA=0, shrinkB=0), + zorder=5, + ) + # Label at the arrow midpoint, nudged perpendicular to the shaft (in screen + # space) so it clears both the arrow and the candidate numbers on the peaks. + gnorm = float(np.hypot(g[0], g[1])) + 1e-12 + perp = (g[0] / gnorm * 15.0, g[1] / gnorm * 15.0) + mid = (o[1] + g[1] / 2.0, o[0] + g[0] / 2.0) + ax.annotate( + label, + mid, + color=color, + fontsize=14, + fontweight="bold", + xytext=perp, + textcoords="offset points", + ha="center", + va="center", + path_effects=stroke, + zorder=7, + ) + + if zoom and cand_rc.shape[0]: + rmin, cmin = cand_rc.min(axis=0) + rmax, cmax = cand_rc.max(axis=0) + margin = 0.12 * max(rmax - rmin, cmax - cmin, 1.0) + 6.0 + h, w = bvm.shape[:2] + ax.set_xlim(max(cmin - margin, -0.5), min(cmax + margin, w - 0.5)) + ax.set_ylim(min(rmax + margin, h - 0.5), max(rmin - margin, -0.5)) + + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_title("lattice basis (origin +, g1, g2; cyan = candidate index)") + fig.tight_layout() + return fig, ax + + +def plot_bvm( + bvm: np.ndarray, + counts: np.ndarray, + *, + figsize: tuple[float, float] = (10, 4), +): + """The Bragg vector map (log-scaled) beside the per-position peak count. + + Parameters + ---------- + bvm : np.ndarray + Bragg vector map; displayed log-scaled (``log1p``). + counts : np.ndarray + Per-position peak count, shape ``(scan_row, scan_col)``. + figsize : tuple of float, default=(10, 4) + Figure size in inches. + + Returns + ------- + tuple + ``(fig, ax)`` with ``ax`` a length-2 array of axes. + """ + fig, ax = plt.subplots(1, 2, figsize=figsize) + ax[0].imshow(np.log1p(bvm), cmap="inferno") + ax[0].set_title("Bragg vector map (log)") + im = ax[1].imshow(counts, cmap="viridis") + ax[1].set_title("peaks per position") + for a in ax: + a.set_xticks([]) + a.set_yticks([]) + fig.colorbar(im, ax=ax[1], fraction=0.046, pad=0.04) + fig.tight_layout() + return fig, ax + + +def plot_reference_lattice( + bvm: np.ndarray, + ref_qpos: np.ndarray, + ref_ab: np.ndarray, + origin: np.ndarray, + g1: np.ndarray, + g2: np.ndarray, + *, + cmap: str = "gray", + norm: str | dict = "log_auto", + zoom: bool = True, + figsize: tuple[float, float] = (6, 6), + **show_kwargs, +): + """The reference lattice from :meth:`BraggVectors.index_peaks`, drawn over the BVM. + + Each indexed reference site is a ring labelled with its ``(a, b)`` Miller index; + the chosen origin is a green marker and ``g1`` (red) / ``g2`` (blue) are arrows + from it. The ring color encodes how far the picked candidate sits from its *ideal* + lattice site ``origin + a*g1 + b*g2`` (the colorbar reads pixels), scaled to half + the shorter lattice spacing — the default :meth:`fit_lattice` match radius. A + mis-picked or duplicate candidate stands out as a ring far from zero offset (bright + color), sitting off the regular grid, or carrying an index that breaks the pattern. + + Parameters + ---------- + bvm : np.ndarray + Bragg vector map; rendered through + :func:`~quantem.core.visualization.show_2d` so its display scaling is + controlled by ``norm`` and ``show_kwargs``. + ref_qpos : np.ndarray + ``(N, 2)`` ``[row, col]`` reference site positions. + ref_ab : np.ndarray + ``(N, 2)`` integer ``[a, b]`` Miller indices for each site. + origin : np.ndarray + ``(row, col)`` chosen lattice origin. + g1 : np.ndarray + First lattice vector as a ``(row, col)`` offset from ``origin``. + g2 : np.ndarray + Second lattice vector as a ``(row, col)`` offset from ``origin``. + cmap : str, default="gray" + Colormap for the Bragg vector map; gray keeps the colored overlays legible. + norm : str or dict, default="log_auto" + Intensity scaling forwarded to :func:`show_2d` (e.g. ``"linear_auto"``, + ``"log_auto"``, ``"power_sqrt"``, or ``{"power": 0.5}``). + zoom : bool, default=True + If ``True``, frame the view to the reference bounding box (plus a margin) + so the labelled sites are large enough to read. + figsize : tuple of float, default=(6, 6) + Figure size in inches. + **show_kwargs + Extra keyword arguments forwarded to :func:`show_2d` for fine display + control (e.g. ``vmin``, ``vmax``, ``lower_quantile``, ``upper_quantile``). + + Returns + ------- + tuple + ``(fig, ax)``. + """ + import matplotlib.patheffects as path_effects + from matplotlib.cm import ScalarMappable + from matplotlib.colors import Normalize + + from quantem.core.visualization import show_2d + + stroke = [path_effects.withStroke(linewidth=2.5, foreground="black")] + origin_color = (0.0, 0.7, 0.0) + g1_color = (1.0, 0.0, 0.0) + g2_color = (0.0, 0.7, 1.0) + + ref_qpos = np.asarray(ref_qpos, dtype=float).reshape(-1, 2) + ref_ab = np.asarray(ref_ab, dtype=int).reshape(-1, 2) + o = np.asarray(origin, dtype=float).reshape(2) + g1 = np.asarray(g1, dtype=float).reshape(2) + g2 = np.asarray(g2, dtype=float).reshape(2) + + fig, ax = plt.subplots(figsize=figsize) + show_2d(np.asarray(bvm), figax=(fig, ax), cmap=cmap, norm=norm, **show_kwargs) + + # offset of each picked candidate from its ideal lattice site origin + a*g1 + b*g2; + # this is the QC indicator -- a mis-picked or strongly strained candidate rings + # far from zero, while a clean pick rings near it. + ideal_qpos = o[None, :] + ref_ab[:, 0:1] * g1[None, :] + ref_ab[:, 1:2] * g2[None, :] + offset = np.linalg.norm(ref_qpos - ideal_qpos, axis=1) + + # color the rings by that offset, scaled to half the shorter lattice spacing (the + # default fit_lattice match radius) so the colorbar previews which candidates sit + # near the inclusion tolerance. + radius = 0.5 * float(min(np.hypot(*g1), np.hypot(*g2))) + cmap_offset = plt.get_cmap("plasma") + norm_offset = Normalize(vmin=0.0, vmax=radius if radius > 0 else 1.0) + + ax.scatter( + ref_qpos[:, 1], + ref_qpos[:, 0], + s=80, + facecolors="none", + edgecolors=cmap_offset(norm_offset(offset)), + linewidths=1.8, + zorder=3, + ) + for (r, c), (a, b) in zip(ref_qpos, ref_ab): + ax.annotate( + f"{int(a)},{int(b)}", + (c, r), + color="white", + fontsize=9, + fontweight="bold", + xytext=(4, 4), + textcoords="offset points", + path_effects=stroke, + zorder=4, + ) + + ax.scatter( + o[1], + o[0], + s=160, + marker="P", + facecolors=[origin_color], + edgecolors="white", + linewidths=1.5, + zorder=6, + ) + for g, label, color in ((g1, "g1", g1_color), (g2, "g2", g2_color)): + ax.annotate( + "", + xy=(o[1] + g[1], o[0] + g[0]), + xytext=(o[1], o[0]), + arrowprops=dict(arrowstyle="-|>", color=color, lw=2.4, shrinkA=0, shrinkB=0), + zorder=5, + ) + gnorm = float(np.hypot(g[0], g[1])) + 1e-12 + ax.annotate( + label, + (o[1] + g[1] / 2.0, o[0] + g[0] / 2.0), + color=color, + fontsize=13, + fontweight="bold", + xytext=(g[0] / gnorm * 15.0, g[1] / gnorm * 15.0), + textcoords="offset points", + ha="center", + va="center", + path_effects=stroke, + zorder=7, + ) + + if zoom and ref_qpos.shape[0]: + rmin, cmin = ref_qpos.min(axis=0) + rmax, cmax = ref_qpos.max(axis=0) + margin = 0.12 * max(rmax - rmin, cmax - cmin, 1.0) + 6.0 + h, w = bvm.shape[:2] + ax.set_xlim(max(cmin - margin, -0.5), min(cmax + margin, w - 0.5)) + ax.set_ylim(min(rmax + margin, h - 0.5), max(rmin - margin, -0.5)) + + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_title("reference lattice (ring color = offset from ideal index; +origin, g1, g2)") + + sm = ScalarMappable(norm=norm_offset, cmap=cmap_offset) + sm.set_array([]) + cbar = fig.colorbar(sm, ax=ax, fraction=0.046, pad=0.04, extend="max") + cbar.set_label("peak offset from ideal index (px)") + + fig.tight_layout() + return fig, ax + + +def plot_lattice_fit( + mask_weight: np.ndarray, + fit_error: np.ndarray, + *, + figsize: tuple[float, float] = (11, 4.5), +): + """Per-position diagnostics from :meth:`BraggVectors.fit_lattice`. + + Left: the mask weight — a lattice *order parameter* per position (how well all + detected intensity snaps to the fitted lattice, intensity-weighted) — so ``0`` is + a position dominated by off-lattice intensity and ``1`` a clean single crystal. + This is the weighting handed to the strain reference. Right: the RMS lattice-fit + residual over the matched peaks, in pixels (low = a clean fit; high = a poorly + fit, overlapping, or strongly strained position). + + Parameters + ---------- + mask_weight : np.ndarray + ``(scan_row, scan_col)`` lattice-order-parameter weight in ``[0, 1]``. + fit_error : np.ndarray + ``(scan_row, scan_col)`` RMS fit residual in pixels (``nan`` where no fit + was made). + figsize : tuple of float, default=(11, 4.5) + Figure size in inches. + + Returns + ------- + tuple + ``(fig, ax)`` with ``ax`` a length-2 array of axes. + """ + fig, ax = plt.subplots(1, 2, figsize=figsize) + + im0 = ax[0].imshow(np.asarray(mask_weight), cmap="viridis", vmin=0.0, vmax=1.0) + ax[0].set_title("mask weight (lattice order)") + fig.colorbar(im0, ax=ax[0], fraction=0.046, pad=0.04) + + im1 = ax[1].imshow(np.asarray(fit_error), cmap="magma") + ax[1].set_title("fit RMS error (px)") + fig.colorbar(im1, ax=ax[1], fraction=0.046, pad=0.04) + + for a in ax: + a.set_xticks([]) + a.set_yticks([]) + fig.tight_layout() + return fig, ax diff --git a/src/quantem/diffraction/disk_detection.py b/src/quantem/diffraction/disk_detection.py new file mode 100644 index 00000000..05c5600c --- /dev/null +++ b/src/quantem/diffraction/disk_detection.py @@ -0,0 +1,1093 @@ +from __future__ import annotations + +import numpy as np +import torch + +SUBPIXEL_MODES = ("none", "parabolic", "upsample") + + +def make_template( + probe: torch.Tensor, + center: tuple[float, float] | None = None, + subtract_mean: bool = False, +) -> torch.Tensor: + """Build a cross-correlation template from a (vacuum) probe image. + + The probe is normalized to unit sum and rolled so its center sits at the array + origin ``[0, 0]`` (FFT corner), so correlation peaks land at absolute disk + positions. + + Parameters + ---------- + probe : torch.Tensor + ``(H, W)`` probe / vacuum disk image. + center : tuple of float, optional + ``(row, col)`` probe center rolled to the origin; defaults to the geometric + center ``(H // 2, W // 2)``. + subtract_mean : bool, default=False + If ``True``, make the template zero-sum — a band-pass kernel that suppresses + the uniform background in the correlation. + + Returns + ------- + torch.Tensor + ``(H, W)`` template, corner-centered (and zero-sum when ``subtract_mean``). + """ + probe = torch.as_tensor(probe) + total = probe.sum() + if total != 0: + probe = probe / total + + H, W = probe.shape + if center is None: + cr, cc = H // 2, W // 2 + else: + cr, cc = int(round(float(center[0]))), int(round(float(center[1]))) + + template = torch.roll(probe, shifts=(-cr, -cc), dims=(0, 1)) + if subtract_mean: + template = template - template.mean() + return template + + +def synthetic_probe( + shape: tuple[int, int], + radius: float, + edge: float = 1.0, + center: tuple[float, float] | None = None, +) -> torch.Tensor: + """Soft-edged disk for a synthetic correlation template. + + Returns ``0.5 - 0.5*tanh((r - radius)/edge)``: a disk of the given ``radius`` + (pixels) with a ``tanh`` falloff over ``edge`` pixels. + + Parameters + ---------- + shape : tuple of int + ``(H, W)`` output shape in pixels. + radius : float + Disk radius in pixels. + edge : float, default=1.0 + Width in pixels of the ``tanh`` edge falloff. + center : tuple of float, optional + ``(row, col)`` disk center; defaults to the geometric center + ``((H - 1) / 2, (W - 1) / 2)``. + + Returns + ------- + torch.Tensor + ``(H, W)`` soft-edged disk image. + """ + H, W = int(shape[0]), int(shape[1]) + if center is None: + cr, cc = (H - 1) / 2.0, (W - 1) / 2.0 + else: + cr, cc = float(center[0]), float(center[1]) + rows = torch.arange(H, dtype=torch.float).view(H, 1) + cols = torch.arange(W, dtype=torch.float).view(1, W) + rr = torch.sqrt((rows - cr) ** 2 + (cols - cc) ** 2) + edge = max(float(edge), 1e-6) + return 0.5 - 0.5 * torch.tanh((rr - float(radius)) / edge) + + +def _central_blob(image: torch.Tensor, threshold: float) -> tuple[np.ndarray | None, np.ndarray]: + """Mask of the connected bright region containing the brightest pixel. + + Thresholds ``image`` at ``threshold * max`` and keeps only the connected + component holding the brightest pixel — normally the central (unscattered) + disk of a mean diffraction pattern or vacuum probe — so other diffracted disks + are excluded. + + Parameters + ---------- + image : torch.Tensor + ``(H, W)`` image, e.g. a mean diffraction pattern or vacuum probe. + threshold : float + Fraction of the peak intensity (after min-subtraction) used to threshold + the image before connected-component labeling. + + Returns + ------- + blob_mask : np.ndarray or None + Boolean ``(H, W)`` mask of the central component, or ``None`` for an empty + / flat image. + img : np.ndarray + The min-subtracted image. + """ + from scipy import ndimage + + img = np.asarray(torch.as_tensor(image, dtype=torch.float).detach().cpu()) + img = img - img.min() + peak = float(img.max()) + if peak <= 0: + return None, img + labels, n = ndimage.label(img >= threshold * peak) + if n == 0: + return None, img + peak_label = int(labels[np.unravel_index(int(np.argmax(img)), img.shape)]) + return labels == peak_label, img + + +def estimate_central_beam( + image: torch.Tensor, + threshold: float = 0.5, + plot_result: bool = False, + **kwargs, +) -> tuple[tuple[float, float], float]: + """Center ``(row, col)`` and radius (pixels) of the central (direct) beam. + + Locates the connected bright region containing the brightest pixel (see + :func:`_central_blob`) — the unscattered / direct beam of a mean diffraction + pattern — and returns its intensity-weighted center together with an + area-equivalent radius (``A = pi r^2``). Other diffracted disks are excluded, so + the estimate holds whether the pattern shows one disk or many. Falls back to the + geometric center and unit radius for an empty / flat image. + + Parameters + ---------- + image : torch.Tensor or Dataset2d + ``(H, W)`` image — a raw array / tensor or a :class:`Dataset2d` (e.g. + ``dataset.dp_mean``). + threshold : float, default=0.5 + Fraction of the peak intensity used to threshold the image when isolating + the central beam (see :func:`_central_blob`). + plot_result : bool, default=False + If ``True``, show the image in greyscale with the fitted beam drawn as a red + circle. + **kwargs + Extra keyword arguments (e.g. ``norm``, ``cbar``, ``scalebar``) forwarded to + :func:`~quantem.core.visualization.show_2d` when ``plot_result=True``. + + Returns + ------- + center : tuple of float + ``(row, col)`` intensity-weighted center of the central beam. + radius : float + Area-equivalent radius in pixels (``A = pi r^2``). + """ + arr = image.array if hasattr(image, "array") else image + blob, img = _central_blob(arr, threshold) + if blob is None: + center = (img.shape[0] / 2.0, img.shape[1] / 2.0) + radius = 1.0 + else: + radius = float(np.sqrt(max(float(blob.sum()), 1.0) / np.pi)) + w = img * blob + total = float(w.sum()) + if total <= 0: + rr, cc = np.nonzero(blob) + center = (float(rr.mean()), float(cc.mean())) + else: + rows = np.arange(img.shape[0])[:, None] + cols = np.arange(img.shape[1])[None, :] + center = (float((w * rows).sum() / total), float((w * cols).sum() / total)) + + if plot_result: + from matplotlib.patches import Circle + + from quantem.core.visualization import show_2d + + show_kwargs = {"cmap": "gray", "title": "central beam", **kwargs} + _fig, ax = show_2d(arr, **show_kwargs) + ax.add_patch( + Circle((center[1], center[0]), radius, fill=False, edgecolor="red", linewidth=1.5) + ) + + return center, radius + + +def probe_centroid(probe: torch.Tensor) -> tuple[float, float]: + """Intensity-weighted ``(row, col)`` centroid of a probe image. + + Parameters + ---------- + probe : torch.Tensor + ``(H, W)`` probe image. Negative values are clamped to zero before + weighting. + + Returns + ------- + tuple of float + ``(row, col)`` intensity-weighted centroid; the geometric center for a + non-positive image. + """ + p = torch.clamp(torch.as_tensor(probe, dtype=torch.float), min=0.0) + total = p.sum() + if total <= 0: + return (p.shape[0] / 2.0, p.shape[1] / 2.0) + rows = torch.arange(p.shape[0], dtype=torch.float).view(-1, 1) + cols = torch.arange(p.shape[1], dtype=torch.float).view(1, -1) + return (float((p * rows).sum() / total), float((p * cols).sum() / total)) + + +def template_fourier(template: torch.Tensor) -> torch.Tensor: + """Pre-compute the conjugate FT of a template for repeated correlation. + + Parameters + ---------- + template : torch.Tensor + ``(H, W)`` corner-centered correlation template. + + Returns + ------- + torch.Tensor + ``(H, W)`` complex ``conj(fft2(template))``, ready to multiply against + ``fft2(dp)``. + """ + return torch.conj(torch.fft.fft2(template)) + + +def cross_correlation( + dp: torch.Tensor, + template_ft: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """Cross-correlate a diffraction pattern with a template. + + Parameters + ---------- + dp : torch.Tensor + ``(H, W)`` diffraction pattern. + template_ft : torch.Tensor + ``(H, W)`` pre-computed template FT from :func:`template_fourier`. + + Returns + ------- + corr_map : torch.Tensor + ``(H, W)`` real-space correlation map ``relu(real(ifft2(m)))`` (used for + peak finding). + m : torch.Tensor + ``(H, W)`` Fourier-domain product ``fft2(dp) * template_ft`` (used for DFT + subpixel refinement). + """ + dp = torch.as_tensor(dp) + m = torch.fft.fft2(dp) * template_ft + corr_map = torch.clamp(torch.fft.ifft2(m).real, min=0.0) + return corr_map, m + + +def cross_correlation_batch( + dps: torch.Tensor, + template_ft: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """Cross-correlate a stack of diffraction patterns with one template. + + Batched form of :func:`cross_correlation`. ``fft2`` acts on the trailing two + axes and the ``(H, W)`` ``template_ft`` broadcasts over the batch, so the result + for each pattern is bit-identical to :func:`cross_correlation`. + + Parameters + ---------- + dps : torch.Tensor + ``(B, H, W)`` stack of diffraction patterns. + template_ft : torch.Tensor + ``(H, W)`` pre-computed template FT from :func:`template_fourier`. + + Returns + ------- + corr_map : torch.Tensor + ``(B, H, W)`` real-space correlation maps. + m : torch.Tensor + ``(B, H, W)`` Fourier-domain products. + """ + dps = torch.as_tensor(dps) + m = torch.fft.fft2(dps) * template_ft + corr_map = torch.clamp(torch.fft.ifft2(m).real, min=0.0) + return corr_map, m + + +def _corr_map_rfft(dps: torch.Tensor, template_ft: torch.Tensor) -> torch.Tensor: + """Real-FFT correlation map(s), used when no Fourier product is needed downstream. + + For real ``dps`` and a real template the Fourier product is conjugate-symmetric, + so ``relu(real(ifft2(fft2(dps) * template_ft)))`` is reproduced exactly by an + ``rfft2`` / ``irfft2`` pair at roughly half the FFT cost. Only the correlation map + is returned — the complex product (needed solely for DFT upsampling) is skipped. + + Parameters + ---------- + dps : torch.Tensor + ``(H, W)`` or ``(B, H, W)`` diffraction pattern(s). + template_ft : torch.Tensor + ``(H, W)`` pre-computed template FT from :func:`template_fourier`. + + Returns + ------- + torch.Tensor + ``(H, W)`` or ``(B, H, W)`` real-space correlation map(s), ``relu``-clamped. + """ + dps = torch.as_tensor(dps) + H, W = dps.shape[-2], dps.shape[-1] + prod = torch.fft.rfft2(dps) * template_ft[..., : W // 2 + 1] + corr_map = torch.fft.irfft2(prod, s=(H, W)) + return torch.clamp(corr_map, min=0.0) + + +def detect_disks( + dp: torch.Tensor, + template_ft: torch.Tensor, + *, + min_abs_intensity: float = 0.0, + min_spacing: float = 0.0, + edge_boundary: int = 1, + subpixel: str = "upsample", + upsample_factor: int = 16, + max_num_peaks: int = 1000, +) -> np.ndarray: + """Detect Bragg disks in one diffraction pattern by template matching. + + Parameters + ---------- + dp : torch.Tensor + ``(H, W)`` diffraction pattern. + template_ft : torch.Tensor + ``(H, W)`` pre-computed template FT from :func:`template_fourier`. + min_abs_intensity : float, default=0.0 + Drop correlation peaks below this absolute intensity. + min_spacing : float, default=0.0 + Minimum spacing in pixels between kept peaks; closer / dimmer peaks are + suppressed. + edge_boundary : int, default=1 + Width in pixels of the border in which peaks are ignored. + subpixel : {"none", "parabolic", "upsample"}, default="upsample" + ``"none"`` returns pixel-resolution peaks; ``"parabolic"`` adds a 3-point + quadratic refinement; ``"upsample"`` further refines each peak by + Guizar-Sicairos DFT upsampling. + upsample_factor : int, default=16 + Upsampling factor for the ``"upsample"`` subpixel refinement. + max_num_peaks : int, default=1000 + Maximum number of peaks to keep (after intensity sorting). + + Returns + ------- + np.ndarray + ``(M, 3)`` array of ``[q_row, q_col, intensity]`` rows, sorted by descending + intensity. + """ + if subpixel not in SUBPIXEL_MODES: + raise ValueError(f"subpixel must be in {SUBPIXEL_MODES}, got {subpixel!r}") + + corr_map, m = cross_correlation(dp, template_ft) + + peaks = _local_maxima(corr_map, edge_boundary) + peaks = _filter_maxima(peaks, min_abs_intensity, min_spacing, max_num_peaks) + + if peaks.shape[0] == 0 or subpixel == "none": + return _to_numpy(peaks) + + peaks = _refine_parabolic(corr_map, peaks) + + if subpixel == "parabolic": + return _to_numpy(peaks) + + peaks = _refine_dft(m, peaks, upsample_factor) + return _to_numpy(peaks) + + +def detect_disks_batch( + dps: torch.Tensor, + template_ft: torch.Tensor, + *, + min_abs_intensity: float = 0.0, + min_spacing: float = 0.0, + edge_boundary: int = 1, + subpixel: str = "upsample", + upsample_factor: int = 16, + max_num_peaks: int = 1000, +) -> list[np.ndarray]: + """Detect Bragg disks across a stack of diffraction patterns (batched). + + Batched equivalent of :func:`detect_disks`: cross-correlation, peak extraction, + ``min_spacing`` suppression, and subpixel refinement are all batched across + patterns. The local-maxima search and greedy ``min_spacing`` suppression are + vectorized over the whole stack (no per-pattern Python loop) but reproduce the + per-pattern greedy result bit-for-bit, so each output matches :func:`detect_disks` + for that pattern. When ``subpixel`` is not ``"upsample"`` the correlation maps are + formed with a real FFT (``rfft2``), skipping the complex Fourier product that only + DFT upsampling needs. + + Parameters + ---------- + dps : torch.Tensor + ``(B, H, W)`` stack of diffraction patterns. + template_ft : torch.Tensor + ``(H, W)`` pre-computed template FT from :func:`template_fourier`. + min_abs_intensity : float, default=0.0 + Drop correlation peaks below this absolute intensity. + min_spacing : float, default=0.0 + Minimum spacing in pixels between kept peaks; closer / dimmer peaks are + suppressed. + edge_boundary : int, default=1 + Width in pixels of the border in which peaks are ignored. + subpixel : {"none", "parabolic", "upsample"}, default="upsample" + ``"none"`` returns pixel-resolution peaks; ``"parabolic"`` adds a 3-point + quadratic refinement; ``"upsample"`` further refines each peak by + Guizar-Sicairos DFT upsampling. + upsample_factor : int, default=16 + Upsampling factor for the ``"upsample"`` subpixel refinement. + max_num_peaks : int, default=1000 + Maximum number of peaks to keep per pattern (after intensity sorting). + + Returns + ------- + list of np.ndarray + Length-``B`` list of ``(M, 3)`` arrays of ``[q_row, q_col, intensity]`` + rows, each sorted by descending intensity. + """ + if subpixel not in SUBPIXEL_MODES: + raise ValueError(f"subpixel must be in {SUBPIXEL_MODES}, got {subpixel!r}") + + if subpixel == "upsample": + corr_map, m = cross_correlation_batch(dps, template_ft) + else: + corr_map = _corr_map_rfft(dps, template_ft) + m = None + + peaks_all, bidx, counts = _detect_peaks_batched( + corr_map, edge_boundary, min_abs_intensity, min_spacing, max_num_peaks + ) + + if subpixel == "none" or peaks_all.shape[0] == 0: + return _split_by_counts(peaks_all, counts) + + peaks_all = _refine_parabolic_batched(corr_map, peaks_all, bidx) + if subpixel == "upsample": + peaks_all = _refine_dft_batched(m, peaks_all, bidx, upsample_factor) + + return _split_by_counts(peaks_all, counts) + + +# ---- helpers ---- + + +def _local_maxima(corr_map: torch.Tensor, edge_boundary: int) -> torch.Tensor: + """Find 8-neighbor local maxima, sorted by descending intensity. + + Parameters + ---------- + corr_map : torch.Tensor + ``(H, W)`` correlation map. + edge_boundary : int + Width in pixels of the border in which maxima are ignored. + + Returns + ------- + torch.Tensor + ``(K, 3)`` tensor of ``[row, col, intensity]`` maxima, sorted by descending + intensity. + """ + is_max = _local_maxima_mask(corr_map, edge_boundary) + return _extract_maxima(corr_map, is_max) + + +def _local_maxima_mask(a: torch.Tensor, edge_boundary: int) -> torch.Tensor: + """Boolean 8-neighbor local-maxima mask of ``a``. + + Works on a single ``(H, W)`` map or a batch ``(B, H, W)`` — the neighbor + comparisons and edge masking use the trailing two axes — so the same code drives + the single-pattern and batched detection paths bit-identically. + + Parameters + ---------- + a : torch.Tensor + ``(H, W)`` or ``(B, H, W)`` correlation map(s). + edge_boundary : int + Width in pixels of the border (clamped to at least 1) set to ``False``. + + Returns + ------- + torch.Tensor + Boolean mask the same shape as ``a``, ``True`` at 8-neighbor local maxima. + """ + is_max = ( + (a >= torch.roll(a, (-1, 0), dims=(-2, -1))) + & (a > torch.roll(a, (1, 0), dims=(-2, -1))) + & (a >= torch.roll(a, (0, -1), dims=(-2, -1))) + & (a > torch.roll(a, (0, 1), dims=(-2, -1))) + & (a >= torch.roll(a, (-1, -1), dims=(-2, -1))) + & (a > torch.roll(a, (-1, 1), dims=(-2, -1))) + & (a >= torch.roll(a, (1, -1), dims=(-2, -1))) + & (a > torch.roll(a, (1, 1), dims=(-2, -1))) + ) + + eb = max(1, int(edge_boundary)) + is_max[..., :eb, :] = False + is_max[..., -eb:, :] = False + is_max[..., :, :eb] = False + is_max[..., :, -eb:] = False + return is_max + + +def _extract_maxima(a: torch.Tensor, is_max: torch.Tensor) -> torch.Tensor: + """Gather masked maxima of one ``(H, W)`` map into a descending-sorted ``(K, 3)``. + + Rows are taken in row-major ``nonzero`` order, then stably sorted by descending + intensity — matching the original single-pattern behaviour. + + Parameters + ---------- + a : torch.Tensor + ``(H, W)`` correlation map. + is_max : torch.Tensor + Boolean ``(H, W)`` local-maxima mask from :func:`_local_maxima_mask`. + + Returns + ------- + torch.Tensor + ``(K, 3)`` tensor of ``[row, col, intensity]`` maxima, sorted by descending + intensity. + """ + rows, cols = torch.nonzero(is_max, as_tuple=True) + intensity = a[rows, cols] + order = torch.argsort(intensity, descending=True) + return torch.stack((rows[order].to(a.dtype), cols[order].to(a.dtype), intensity[order]), dim=1) + + +def _filter_maxima( + peaks: torch.Tensor, + min_abs_intensity: float, + min_spacing: float, + max_num_peaks: int, +) -> torch.Tensor: + """Drop dim peaks, suppress peaks closer than ``min_spacing``, cap the count. + + Parameters + ---------- + peaks : torch.Tensor + ``(K, 3)`` ``[row, col, intensity]`` peaks, sorted by descending intensity. + min_abs_intensity : float + Drop peaks below this absolute intensity (ignored when ``<= 0``). + min_spacing : float + Minimum spacing in pixels; for each kept peak, dimmer peaks within this + distance are suppressed (ignored when ``<= 0``). + max_num_peaks : int + Maximum number of peaks to keep; the brightest are retained. + + Returns + ------- + torch.Tensor + ``(M, 3)`` filtered peaks. + """ + if peaks.shape[0] == 0: + return peaks + + if min_abs_intensity > 0: + peaks = peaks[peaks[:, 2] >= min_abs_intensity] + + if min_spacing > 0 and peaks.shape[0] > 1: + keep = torch.ones(peaks.shape[0], dtype=torch.bool, device=peaks.device) + rc = peaks[:, :2] + for i in range(peaks.shape[0]): + if not keep[i]: + continue + d2 = ((rc - rc[i]) ** 2).sum(dim=1) + too_close = d2 < min_spacing**2 + too_close[: i + 1] = False + keep[too_close] = False + peaks = peaks[keep] + + if max_num_peaks is not None and peaks.shape[0] > max_num_peaks: + peaks = peaks[:max_num_peaks] + + return peaks + + +def _detect_peaks_batched( + corr: torch.Tensor, + edge_boundary: int, + min_abs_intensity: float, + min_spacing: float, + max_num_peaks: int, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Extract, suppress, and cap peaks across a whole ``(B, H, W)`` stack at once. + + Vectorized replacement for the per-pattern ``_extract_maxima`` + ``_filter_maxima`` + loop. Local maxima are found and intensity-thresholded for the whole stack, then + the greedy ``min_spacing`` suppression runs as a single loop over the *rank* axis + (brightest-first) that is batched over every pattern. Suppressing peak ``k``'s + fainter neighbours only when ``k`` is still kept reproduces the per-pattern greedy + result of :func:`_filter_maxima` exactly, with no Python loop over patterns. + + Parameters + ---------- + corr : torch.Tensor + ``(B, H, W)`` correlation maps. + edge_boundary : int + Width in pixels of the border in which maxima are ignored. + min_abs_intensity : float + Drop peaks below this absolute intensity (ignored when ``<= 0``). + min_spacing : float + Minimum spacing in pixels; for each kept peak, fainter peaks within this + distance are suppressed (ignored when ``<= 0``). + max_num_peaks : int + Maximum number of peaks kept per pattern (the brightest are retained). + + Returns + ------- + peaks : torch.Tensor + ``(T, 3)`` ``[row, col, intensity]`` peaks pooled over all patterns, grouped + by ascending pattern index and sorted by descending intensity within a pattern. + bidx : torch.Tensor + ``(T,)`` pattern index of each peak. + counts : torch.Tensor + ``(B,)`` number of peaks kept per pattern. + """ + B = corr.shape[0] + device = corr.device + dtype = corr.dtype + + mask = _local_maxima_mask(corr, edge_boundary) + if min_abs_intensity > 0: + mask = mask & (corr >= min_abs_intensity) + + idx = torch.nonzero(mask) # (T0, 3): [b, row, col] + counts = torch.zeros(B, dtype=torch.long, device=device) + if idx.shape[0] == 0: + return corr.new_zeros((0, 3)), torch.zeros(0, dtype=torch.long, device=device), counts + + bcand, rcand, ccand = idx[:, 0], idx[:, 1], idx[:, 2] + inten = corr[bcand, rcand, ccand] + + # Group candidates by pattern, descending intensity within each pattern. A global + # descending-intensity sort followed by a stable sort on the pattern index keeps the + # within-pattern order identical to the per-pattern argsort in _extract_maxima. + o1 = torch.argsort(inten, descending=True) + order = o1[torch.argsort(bcand[o1], stable=True)] + bcand, rcand, ccand, inten = bcand[order], rcand[order], ccand[order], inten[order] + + counts = torch.bincount(bcand, minlength=B) + kmax = int(counts.max()) + + # Pack candidates into a dense (B, kmax) grid indexed by [pattern, brightness rank]. + starts = torch.zeros(B, dtype=torch.long, device=device) + starts[1:] = torch.cumsum(counts, 0)[:-1] + rank = torch.arange(bcand.shape[0], device=device) - starts[bcand] + flat = bcand * kmax + rank + + rows = torch.zeros(B * kmax, dtype=dtype, device=device) + cols = torch.zeros(B * kmax, dtype=dtype, device=device) + vals = torch.zeros(B * kmax, dtype=dtype, device=device) + valid = torch.zeros(B * kmax, dtype=torch.bool, device=device) + rows[flat] = rcand.to(dtype) + cols[flat] = ccand.to(dtype) + vals[flat] = inten + valid[flat] = True + rows, cols, valid = rows.view(B, kmax), cols.view(B, kmax), valid.view(B, kmax) + + keep = valid.clone() + if min_spacing > 0 and kmax > 1: + s2 = float(min_spacing) ** 2 + for k in range(kmax - 1): + active = keep[:, k] & valid[:, k] # (B,) brightest unsuppressed peak at rank k + dr = rows[:, k + 1 :] - rows[:, k : k + 1] + dc = cols[:, k + 1 :] - cols[:, k : k + 1] + suppress = ((dr * dr + dc * dc) < s2) & active[:, None] & valid[:, k + 1 :] + keep[:, k + 1 :] &= ~suppress + + if max_num_peaks is not None: + kept_rank = torch.cumsum(keep.to(torch.long), dim=1) - 1 + keep &= kept_rank < max_num_peaks + + final = (keep & valid).view(-1) + sel = torch.nonzero(final, as_tuple=True)[0] # row-major: pattern-major, rank-minor + peaks = torch.stack((rows.view(-1)[sel], cols.view(-1)[sel], vals[sel]), dim=1) + bidx = torch.div(sel, kmax, rounding_mode="floor") + counts = torch.bincount(bidx, minlength=B) + return peaks, bidx, counts + + +def _split_by_counts(peaks: torch.Tensor, counts: torch.Tensor) -> list[np.ndarray]: + """Split a pattern-grouped ``(T, 3)`` peak stack into one ``(M, 3)`` array per pattern. + + Parameters + ---------- + peaks : torch.Tensor + ``(T, 3)`` peaks ordered by ascending pattern index (contiguous per pattern). + counts : torch.Tensor + ``(B,)`` number of peaks belonging to each pattern, in order. + + Returns + ------- + list of np.ndarray + Length-``B`` list of ``(M, 3)`` arrays. + """ + out = [] + start = 0 + for cnt in counts.tolist(): + out.append(_to_numpy(peaks[start : start + cnt])) + start += cnt + return out + + +def _refine_parabolic(corr_map: torch.Tensor, peaks: torch.Tensor) -> torch.Tensor: + """3-point quadratic subpixel refinement of every peak (vectorized over peaks). + + Parameters + ---------- + corr_map : torch.Tensor + ``(H, W)`` correlation map the peaks were found in. + peaks : torch.Tensor + ``(M, 3)`` ``[row, col, intensity]`` peaks to refine. + + Returns + ------- + torch.Tensor + ``(M, 3)`` peaks with subpixel ``[row, col]`` and bilinearly interpolated + intensity. + """ + if peaks.shape[0] == 0: + return peaks + a = corr_map + H, W = a.shape + out = peaks.clone() + zero = torch.zeros(out.shape[0], device=a.device, dtype=a.dtype) + + r = out[:, 0].round().long().clamp(0, H - 1) + c = out[:, 1].round().long().clamp(0, W - 1) + + r_in = (r > 0) & (r < H - 1) + ix0, ix1, ix2 = a[(r - 1).clamp(0, H - 1), c], a[r, c], a[(r + 1).clamp(0, H - 1), c] + denom_r = 4.0 * ix1 - 2.0 * ix2 - 2.0 * ix0 + dr = torch.where(r_in & (denom_r != 0), (ix2 - ix0) / denom_r, zero) + + c_in = (c > 0) & (c < W - 1) + iy0, iy1, iy2 = a[r, (c - 1).clamp(0, W - 1)], a[r, c], a[r, (c + 1).clamp(0, W - 1)] + denom_c = 4.0 * iy1 - 2.0 * iy2 - 2.0 * iy0 + dc = torch.where(c_in & (denom_c != 0), (iy2 - iy0) / denom_c, zero) + + r_sub = r.to(a.dtype) + dr + c_sub = c.to(a.dtype) + dc + out[:, 0] = r_sub + out[:, 1] = c_sub + out[:, 2] = _bilinear(a, r_sub, c_sub) + return out + + +def _refine_parabolic_batched( + corr: torch.Tensor, peaks: torch.Tensor, bidx: torch.Tensor +) -> torch.Tensor: + """3-point quadratic refinement of peaks pooled across a batch of correlation maps. + + Batched form of :func:`_refine_parabolic`. Reading ``corr[bidx, r, c]`` gathers + exactly the values the single-pattern path would read, so each peak refines + identically. + + Parameters + ---------- + corr : torch.Tensor + ``(B, H, W)`` correlation maps. + peaks : torch.Tensor + ``(T, 3)`` ``[row, col, intensity]`` peaks pooled over all patterns. + bidx : torch.Tensor + ``(T,)`` batch index selecting each peak's correlation map. + + Returns + ------- + torch.Tensor + ``(T, 3)`` peaks with subpixel ``[row, col]`` and bilinearly interpolated + intensity. + """ + if peaks.shape[0] == 0: + return peaks + H, W = corr.shape[-2], corr.shape[-1] + dtype = corr.dtype + out = peaks.clone() + zero = torch.zeros(out.shape[0], device=corr.device, dtype=dtype) + + r = out[:, 0].round().long().clamp(0, H - 1) + c = out[:, 1].round().long().clamp(0, W - 1) + + r_in = (r > 0) & (r < H - 1) + ix0 = corr[bidx, (r - 1).clamp(0, H - 1), c] + ix1 = corr[bidx, r, c] + ix2 = corr[bidx, (r + 1).clamp(0, H - 1), c] + denom_r = 4.0 * ix1 - 2.0 * ix2 - 2.0 * ix0 + dr = torch.where(r_in & (denom_r != 0), (ix2 - ix0) / denom_r, zero) + + c_in = (c > 0) & (c < W - 1) + iy0 = corr[bidx, r, (c - 1).clamp(0, W - 1)] + iy1 = corr[bidx, r, c] + iy2 = corr[bidx, r, (c + 1).clamp(0, W - 1)] + denom_c = 4.0 * iy1 - 2.0 * iy2 - 2.0 * iy0 + dc = torch.where(c_in & (denom_c != 0), (iy2 - iy0) / denom_c, zero) + + r_sub = r.to(dtype) + dr + c_sub = c.to(dtype) + dc + out[:, 0] = r_sub + out[:, 1] = c_sub + out[:, 2] = _bilinear_batched(corr, bidx, r_sub, c_sub) + return out + + +def _refine_dft(m: torch.Tensor, peaks: torch.Tensor, upsample_factor: int) -> torch.Tensor: + """Guizar-Sicairos DFT upsampling refinement of every peak (vectorized over peaks). + + Each peak is rounded to half-pixel precision (matching py4DSTEM multicorr) before + upsampling, then all peaks are refined together with one batched DFT upsampling. + + Parameters + ---------- + m : torch.Tensor + ``(H, W)`` Fourier-domain correlation product from :func:`cross_correlation`. + peaks : torch.Tensor + ``(M, 3)`` ``[row, col, intensity]`` peaks to refine. + upsample_factor : int + DFT upsampling factor. + + Returns + ------- + torch.Tensor + ``(M, 3)`` peaks with DFT-refined ``[row, col]`` (intensity unchanged). + """ + if peaks.shape[0] == 0: + return peaks + out = peaks.clone() + xy = torch.round(out[:, :2] * 2.0) / 2.0 + refined = _upsampled_correlation_batch(m, int(upsample_factor), xy) + out[:, :2] = refined + return out + + +def _refine_dft_batched( + m: torch.Tensor, peaks: torch.Tensor, bidx: torch.Tensor, upsample_factor: int +) -> torch.Tensor: + """DFT-upsampling refinement of peaks pooled across a batch of correlation products. + + Batched form of :func:`_refine_dft`. Peaks are processed in sub-chunks so the + gathered per-peak product ``m[bidx]`` (``(chunk, H, W)`` complex) stays within a + fixed memory budget regardless of how many peaks were found. + + Parameters + ---------- + m : torch.Tensor + ``(B, H, W)`` Fourier-domain correlation products. + peaks : torch.Tensor + ``(T, 3)`` ``[row, col, intensity]`` peaks pooled over all patterns. + bidx : torch.Tensor + ``(T,)`` batch index selecting each peak's correlation product. + upsample_factor : int + DFT upsampling factor. + + Returns + ------- + torch.Tensor + ``(T, 3)`` peaks with DFT-refined ``[row, col]`` (intensity unchanged). + """ + if peaks.shape[0] == 0: + return peaks + out = peaks.clone() + M, N = m.shape[-2], m.shape[-1] + xy = torch.round(out[:, :2] * 2.0) / 2.0 + cap = max(1, 8_000_000 // (M * N)) + total = peaks.shape[0] + for start in range(0, total, cap): + stop = min(start + cap, total) + b = bidx[start:stop] + out[start:stop, :2] = _upsampled_correlation_batch( + m[b], int(upsample_factor), xy[start:stop] + ) + return out + + +def _upsampled_correlation_batch( + m: torch.Tensor, upsample_factor: int, xy: torch.Tensor +) -> torch.Tensor: + """Batched DFT upsampling of the correlation peak for many shifts at once. + + Vectorizes :func:`~quantem.core.utils.imaging_utils.upsampled_correlation_torch` + over the shifts ``xy`` with batched matmuls. Two exact speedups are applied: (1) ``m`` + is conjugate-symmetric (the diffraction pattern and template are real), so only the + non-negative half of the row frequencies is contracted — halving the dominant matmul — + with a rank-1 correction on the Nyquist column; (2) the per-peak DFT kernels are + factored into shared base kernels times per-peak phases, so ``torch.exp`` runs on far + fewer elements. Both are algebraically identical to the full-spectrum DFT upsample. + + Parameters + ---------- + m : torch.Tensor + ``(M, N)`` or ``(K, M, N)`` Fourier-domain correlation product(s). A single + ``(M, N)`` product broadcasts over all ``K`` shifts. + upsample_factor : int + DFT upsampling factor. + xy : torch.Tensor + ``(K, 2)`` ``[row, col]`` peak shifts at half-pixel precision. + + Returns + ------- + torch.Tensor + ``(K, 2)`` DFT-refined ``[row, col]`` positions. + """ + import math + + device = m.device + dtype = torch.get_default_dtype() + uf = float(upsample_factor) + M, N = m.shape[-2], m.shape[-1] + + xy = torch.round(xy * uf) / uf + global_shift = math.floor(math.ceil(uf * 1.5) / 2.0) + upsample_center = global_shift - uf * xy # (K, 2): [row, col] + + num = int(math.ceil(1.5 * uf)) + half = M // 2 + 1 + col_freq = (torch.fft.ifftshift(torch.arange(N, device=device)) - math.floor(N / 2)).to(dtype) + row_freq = (torch.fft.ifftshift(torch.arange(M, device=device)) - math.floor(M / 2)).to(dtype) + + # ``m = F(dp) * conj(F(template))`` is 2D conjugate-symmetric for real ``dp`` and + # template, so the upper-half row frequencies are redundant. Contract only the + # non-negative half (rows ``u = 0 .. M // 2``) and fold the conjugate upper rows + # back in with a weight of 2 — 1 for the self-paired DC row and, when ``M`` is even, + # the Nyquist row. This halves the dominant matmul over the row axis. + row_freq = row_freq[:half] + row_weight = torch.full((half,), 2.0, device=device, dtype=dtype) + row_weight[0] = 1.0 + if M % 2 == 0: + row_weight[-1] = 1.0 + + base = torch.arange(num, device=device, dtype=dtype) + factor_col = -2j * math.pi / (N * uf) + factor_row = -2j * math.pi / (M * uf) + + # Factor each kernel ``exp(f · freq · (base - center))`` into a peak-independent base + # kernel ``exp(f · freq · base)`` times a per-peak phase ``exp(-f · freq · center)``. + # The base kernels are tiny and built once; the phases fold into ``m`` and the + # intermediate product, so ``torch.exp`` runs on ~20x fewer elements. + base_col = torch.exp(factor_col * (col_freq[:, None] * base[None, :])) # (N, num) + base_row = torch.exp(factor_row * (base[:, None] * row_freq[None, :]))[None] # (1, num, half) + phase_col = torch.exp(-factor_col * (col_freq[None, :] * upsample_center[:, 1:2])) # (K, N) + phase_row = torch.exp(-factor_row * (upsample_center[:, 0:1] * row_freq[None, :])) # (K, half) + + mc_half = m.conj()[..., :half, :] # (K, half, N) — non-negative row frequencies only + # Fold the row phase and conjugate-symmetry weight into ``m`` so the shared base row + # kernel is reused across peaks; then contract rows, apply the column phase, and + # contract columns against the shared base column kernel. + prod = torch.matmul(base_row, mc_half * (row_weight * phase_row)[:, :, None]) # (K, num, N) + up = torch.matmul(prod * phase_col[:, None, :], base_col) # (K, num, num) + + if N % 2 == 0: + # Row-only folding is exact for every column except the Nyquist column ``v = N/2``, + # whose conjugate partner stays in the same column. Correct it with an exact rank-1 + # update ``pr_diff ⊗ col_kern[:, N/2]``, where ``pr_diff = -2i Im(S)`` and ``S`` sums + # only the kept interior rows. + nyq = N // 2 + interior = row_weight - 1.0 # 1 on interior rows, 0 on the DC / Nyquist rows + s = torch.matmul(base_row, (mc_half[..., nyq] * (interior * phase_row)).unsqueeze(-1)) + pr_diff = -2j * s[..., 0].imag # (K, num) + col_nyq = base_col[nyq, :][None, :] * phase_col[:, nyq : nyq + 1] # (K, num) + up = up + pr_diff[:, :, None] * col_nyq[:, None, :] + + image_up = up.real + + K = xy.shape[0] + kidx = torch.arange(K, device=device) + idx = torch.argmax(image_up.reshape(K, -1), dim=1) + sub_r = torch.div(idx, num, rounding_mode="floor") + sub_c = idx % num + + # 3-point parabolic refinement around the upsampled maximum (interior only) + interior = (sub_r > 0) & (sub_r < num - 1) & (sub_c > 0) & (sub_c < num - 1) + rr = sub_r.clamp(1, num - 2) + cc = sub_c.clamp(1, num - 2) + c11 = image_up[kidx, rr, cc] + c21, c01 = image_up[kidx, rr + 1, cc], image_up[kidx, rr - 1, cc] + c12, c10 = image_up[kidx, rr, cc + 1], image_up[kidx, rr, cc - 1] + zero = torch.zeros(K, device=device, dtype=dtype) + denom_x = 4.0 * c11 - 2.0 * c21 - 2.0 * c01 + denom_y = 4.0 * c11 - 2.0 * c12 - 2.0 * c10 + dx = torch.where(interior & (denom_x != 0), (c21 - c01) / denom_x, zero) + dy = torch.where(interior & (denom_y != 0), (c12 - c10) / denom_y, zero) + + sub = torch.stack([sub_r.to(dtype), sub_c.to(dtype)], dim=1) - global_shift + return xy + (sub + torch.stack([dx, dy], dim=1)) / uf + + +def _bilinear(a: torch.Tensor, r: torch.Tensor, c: torch.Tensor) -> torch.Tensor: + """Bilinear interpolation of ``a`` at fractional ``(r, c)`` (vectorized over peaks). + + Parameters + ---------- + a : torch.Tensor + ``(H, W)`` map to sample. + r : torch.Tensor + ``(K,)`` fractional row coordinates. + c : torch.Tensor + ``(K,)`` fractional column coordinates. + + Returns + ------- + torch.Tensor + ``(K,)`` interpolated values. + """ + H, W = a.shape + r0 = torch.floor(r).long() + c0 = torch.floor(c).long() + r1 = (r0 + 1).clamp(max=H - 1) + c1 = (c0 + 1).clamp(max=W - 1) + r0 = r0.clamp(0, H - 1) + c0 = c0.clamp(0, W - 1) + dr = r - r0.to(a.dtype) + dc = c - c0.to(a.dtype) + return ( + (1 - dr) * (1 - dc) * a[r0, c0] + + (1 - dr) * dc * a[r0, c1] + + dr * (1 - dc) * a[r1, c0] + + dr * dc * a[r1, c1] + ) + + +def _bilinear_batched( + corr: torch.Tensor, bidx: torch.Tensor, r: torch.Tensor, c: torch.Tensor +) -> torch.Tensor: + """Bilinear interpolation of a batch ``corr`` ``(B, H, W)`` at per-peak ``(r, c)``. + + Batched form of :func:`_bilinear`. The four corner samples are gathered as + ``corr[bidx, r0, c0]`` etc., matching the single-pattern interpolation + value-for-value. + + Parameters + ---------- + corr : torch.Tensor + ``(B, H, W)`` maps to sample. + bidx : torch.Tensor + ``(T,)`` batch index selecting each peak's map. + r : torch.Tensor + ``(T,)`` fractional row coordinates. + c : torch.Tensor + ``(T,)`` fractional column coordinates. + + Returns + ------- + torch.Tensor + ``(T,)`` interpolated values. + """ + H, W = corr.shape[-2], corr.shape[-1] + dtype = corr.dtype + r0 = torch.floor(r).long() + c0 = torch.floor(c).long() + r1 = (r0 + 1).clamp(max=H - 1) + c1 = (c0 + 1).clamp(max=W - 1) + r0 = r0.clamp(0, H - 1) + c0 = c0.clamp(0, W - 1) + dr = r - r0.to(dtype) + dc = c - c0.to(dtype) + return ( + (1 - dr) * (1 - dc) * corr[bidx, r0, c0] + + (1 - dr) * dc * corr[bidx, r0, c1] + + dr * (1 - dc) * corr[bidx, r1, c0] + + dr * dc * corr[bidx, r1, c1] + ) + + +def _to_numpy(peaks: torch.Tensor) -> np.ndarray: + """Convert a peaks tensor to a contiguous ``(M, 3)`` float64 numpy array. + + Parameters + ---------- + peaks : torch.Tensor + ``(M, 3)`` (or flat) peaks tensor on any device. + + Returns + ------- + np.ndarray + ``(M, 3)`` ``float64`` array of ``[q_row, q_col, intensity]`` rows. + """ + return peaks.detach().cpu().numpy().astype(np.float64).reshape(-1, 3) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index 185154ed..ca49d294 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -22,7 +22,7 @@ from quantem.core.io.serialize import AutoSerialize from quantem.core.utils.imaging_utils import cross_correlation_shift from quantem.diffraction.model_fitting_visualizations import ModelDiffractionVisualizations -from quantem.diffraction.strain_visualization import StrainFitting +from quantem.diffraction.strain import StrainMap def _parse_init(value: float | int | Sequence[float | int | None], *, name: str) -> float: @@ -1047,7 +1047,7 @@ def initialize_strain_class( self, u_ref: np.ndarray | None = None, v_ref: np.ndarray | None = None, - )->StrainFitting: + )->StrainMap: if self.u_array is None or self.v_array is None: self.get_individual_uv_vectors() if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): @@ -1066,7 +1066,7 @@ def initialize_strain_class( else: default_sampling = float(self.dataset.sampling) - return StrainFitting( + return StrainMap( u_array = self.u_array, v_array = self.v_array, ds_shape = self.dataset.shape, diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py new file mode 100644 index 00000000..a12e8cd5 --- /dev/null +++ b/src/quantem/diffraction/strain.py @@ -0,0 +1,762 @@ +from __future__ import annotations + +import warnings + +import numpy as np +from numpy.lib.stride_tricks import sliding_window_view + +from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.io.serialize import AutoSerialize +from quantem.diffraction.strain_visualization import ( + plot_strain_panels, + plot_strain_precision_histogram, +) + + +class StrainMap(AutoSerialize): + """Strain tensor maps fit from per-position lattice vectors. + + Stores the reference-frame strain components ``e_rr`` (row), ``e_cc`` (col), + ``e_rc`` (shear), and ``phi`` (infinitesimal rotation). The reference lattice + is the median of the fitted ``g_u``/``g_v`` over a mask/ROI; the strain tensor + is recomputed by :meth:`update_reference`. + + For nanobeam 4D-STEM the lattice vectors are measured in reciprocal space, so + the shear/rotation signs are flipped relative to real space (``real_space=False``). + + Parameters + ---------- + u_array : np.ndarray + Per-position first lattice vector, shape ``(scan_row, scan_col, 2)``. + v_array : np.ndarray + Per-position second lattice vector, shape ``(scan_row, scan_col, 2)``. + ds_shape : tuple of int + Shape of the parent scan grid, used to size the strain maps. + real_space : bool + ``True`` for real-space lattice vectors; ``False`` for reciprocal-space + (nanobeam) data, which flips the shear/rotation signs. + u_ref : np.ndarray, optional + Fixed reference for ``u``; if omitted the median over the mask/ROI is used. + A value supplied here persists across re-fits. + v_ref : np.ndarray, optional + Fixed reference for ``v``; if omitted the median over the mask/ROI is used. + A value supplied here persists across re-fits. + mask : np.ndarray, optional + ``(scan_row, scan_col)`` weighting/ROI mask; defaults to all ones (the full + scan). Normalized to ``[0, 1]`` on assignment. + ds_sampling : float, optional + Real-space scan sampling (step size); defaults to ``1.0``. + ds_units : str, optional + Units for ``ds_sampling``; defaults to ``"pixels"``. + """ + + mask: np.ndarray | None = None + real_space: bool = False + + e_rr: Dataset2d + e_cc: Dataset2d + e_rc: Dataset2d + phi: Dataset2d + + u_ref: np.ndarray | None = None + v_ref: np.ndarray | None = None + u_array: np.ndarray + v_array: np.ndarray + + ds_sampling: float = 1.0 + ds_units: str = "pixels" + ds_shape: tuple[int, ...] + + def __init__( + self, + u_array: np.ndarray, + v_array: np.ndarray, + ds_shape: tuple[int, ...], + real_space: bool, + u_ref: np.ndarray | None = None, + v_ref: np.ndarray | None = None, + mask: np.ndarray | None = None, + ds_sampling: float | None = None, + ds_units: str | None = None, + ): + super().__init__() + self.u_array = u_array + self.v_array = v_array + + self.ds_shape = ds_shape + self.real_space = real_space + + self.ds_sampling = 1.0 if ds_sampling is None else ds_sampling + self.ds_units = "pixels" if ds_units is None else ds_units + + self.mask = np.ones(ds_shape[:2]) if mask is None else mask + self.mask = (self.mask - np.min(self.mask)) / np.max(self.mask) + + # user-supplied reference vectors persist across re-fits (None = use median) + self._u_ref_fixed = None if u_ref is None else np.asarray(u_ref, dtype=float) + self._v_ref_fixed = None if v_ref is None else np.asarray(v_ref, dtype=float) + self.u_ref = None + self.v_ref = None + + self.update_reference() + + # ---- main methods ---- + + def update_reference( + self, + strain_mask: np.ndarray | None = None, + u_ref: np.ndarray | None = None, + v_ref: np.ndarray | None = None, + plot_strain_roi: bool = False, + **plot_kwargs, + ) -> "StrainMap": + """(Re)compute the reference lattice and strain tensor maps. + + Reference precedence: explicit ``u_ref``/``v_ref`` argument > vectors fixed at + construction > median over ``strain_mask`` (if given) else over ``self.mask`` + else the global median. + + Parameters + ---------- + strain_mask : np.ndarray, optional + ``(scan_row, scan_col)`` ROI selecting the positions used to compute the + median reference lattice. If omitted, ``self.mask`` (else the global + median) is used. + u_ref : np.ndarray, optional + Explicit reference for ``u``; overrides both the construction-time fixed + value and the median. + v_ref : np.ndarray, optional + Explicit reference for ``v``; overrides both the construction-time fixed + value and the median. + plot_strain_roi : bool, default=False + If ``True``, show the recomputed strain via :meth:`plot_strain_roi` + (color-scaled to the ROI) so the chosen reference region can be checked + for flatness. + **plot_kwargs + Forwarded to :meth:`plot_strain_roi` when ``plot_strain_roi=True``. + + Returns + ------- + StrainMap + ``self``, with the reference lattice and strain maps recomputed. + """ + u_med, v_med = _reference_lattice(self.u_array, self.v_array, self.mask, strain_mask) + + if u_ref is not None: + self.u_ref = np.asarray(u_ref, dtype=float) + elif self._u_ref_fixed is not None: + self.u_ref = self._u_ref_fixed + else: + self.u_ref = u_med + + if v_ref is not None: + self.v_ref = np.asarray(v_ref, dtype=float) + elif self._v_ref_fixed is not None: + self.v_ref = self._v_ref_fixed + else: + self.v_ref = v_med + + e_rr, e_cc, e_rc, phi = _strain_tensor( + self.u_array, self.v_array, self.u_ref, self.v_ref, self.real_space + ) + self.e_rr = Dataset2d.from_array(e_rr, name="strain e_rr", signal_units="fractional") + self.e_cc = Dataset2d.from_array(e_cc, name="strain e_cc", signal_units="fractional") + self.e_rc = Dataset2d.from_array(e_rc, name="strain e_rc", signal_units="fractional") + self.phi = Dataset2d.from_array(phi, name="strain rotation", signal_units="radians") + + if plot_strain_roi: + self.plot_strain_roi(strain_mask=strain_mask, **plot_kwargs) + return self + + def rotate_strain( + self, rotation_angle: float = 0.0 + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Tensor-rotate the strain into a frame rotated by ``rotation_angle`` (degrees). + + The rotation field ``phi`` is invariant under frame rotation and is not + transformed. + + Parameters + ---------- + rotation_angle : float, default=0.0 + Frame rotation angle, in degrees. + + Returns + ------- + tuple of np.ndarray + ``(e_uu, e_vv, e_uv)`` strain components in the rotated frame. + """ + return _rotate_strain_tensor( + self.e_rr.array, self.e_cc.array, self.e_rc.array, rotation_angle + ) + + def plot_strain_roi( + self, + strain_mask: np.ndarray | None = None, + plot_rotation: bool = True, + cmap_strain: str = "RdBu_r", + cmap_rotation: str = "PiYG", + layout: str = "horizontal", + figsize: tuple[float, float] | None = None, + **kwargs, + ): + """Plot the strain in the raw row/col reference frame, color-scaled to the ROI. + + The color range is symmetric about zero and set by the largest absolute + strain (and rotation) *inside the reference ROI* — ``strain_mask`` if given, + else ``self.mask`` — so a well-chosen, strain-free reference region reads as + flat (near mid-color) and any residual gradient or tilt stands out. The ROI + itself is drawn in color while everything outside it is shown in greyscale, + so the chosen reference region is obvious at a glance. Unlike + :meth:`plot_strain`, no display rotation is applied: the panels show the raw + ``e_rr``/``e_cc``/``e_rc`` that :meth:`update_reference` just computed. + + Parameters + ---------- + strain_mask : np.ndarray, optional + ROI defining the color range (and the reference region). If omitted, + ``self.mask`` is used. + plot_rotation : bool, default=True + Whether to include the rotation (``phi``) panel. + cmap_strain : str, default="RdBu_r" + Colormap for the strain panels. + cmap_rotation : str, default="PiYG" + Colormap for the rotation panel. + layout : {"horizontal", "vertical"}, default="horizontal" + Panel arrangement. + figsize : tuple of float, optional + Figure size in inches; if omitted it is derived from the layout. + **kwargs + Forwarded to + :func:`~quantem.diffraction.strain_visualization.plot_strain_panels`. + + Returns + ------- + tuple + ``(fig, ax)`` from :func:`plot_strain_panels`. + """ + roi_src = self.mask if strain_mask is None else strain_mask + e_rr, e_cc, e_rc, phi = ( + self.e_rr.array, + self.e_cc.array, + self.e_rc.array, + self.phi.array, + ) + + inside = np.asarray(roi_src) > 0 if roi_src is not None else np.ones(e_rr.shape, bool) + if not inside.any(): + inside = np.ones(e_rr.shape, bool) + + strain_stack = np.stack([e_rr[inside], e_cc[inside], e_rc[inside]]) + smax = float(np.nanmax(np.abs(strain_stack))) * 100.0 + rmax = float(np.rad2deg(np.nanmax(np.abs(phi[inside])))) + smax = smax if smax > 0 else 1e-6 + rmax = rmax if rmax > 0 else 1e-6 + + return plot_strain_panels( + e_rr, + e_cc, + e_rc, + phi, + self.mask, + self.u_ref, + self.v_ref, + self.ds_shape, + ds_sampling=self.ds_sampling, + ds_units=self.ds_units, + strain_range_percent=(-smax, smax), + rotation_range_degrees=(-rmax, rmax), + roi=inside, + plot_rotation=plot_rotation, + cmap_strain=cmap_strain, + cmap_rotation=cmap_rotation, + layout=layout, + figsize=figsize, + panel_titles=( + r"$\epsilon_{rr}$ $\updownarrow$", + r"$\epsilon_{cc}$ $\leftrightarrow$", + r"$\epsilon_{rc}$ $\nwarrow\!\!\!\!\!\!\!\!\!\:\searrow$", + ), + **kwargs, + ) + + def plot_strain( + self, + rotation_angle: float = 20.0, + strain_range_percent: tuple[float, float] = (-3.0, 3.0), + rotation_range_degrees: tuple[float, float] = (-2.0, 2.0), + mask_range: tuple[float, float] = (0.0, 1.0), + plot_rotation: bool = True, + plot_gvecs: bool = False, + plot_scalebar: bool = False, + cmap_strain: str = "RdBu_r", + cmap_rotation: str = "PiYG", + layout: str = "horizontal", + figsize: tuple[float, float] | None = None, + **kwargs, + ): + """Plot the strain (rotated into the display frame) and rotation panels. + + Parameters + ---------- + rotation_angle : float, default=20.0 + Angle (degrees) by which the strain tensor is rotated into the display + frame before plotting. + strain_range_percent : tuple of float, default=(-3.0, 3.0) + Symmetric color range for the strain panels, in percent. + rotation_range_degrees : tuple of float, default=(-2.0, 2.0) + Symmetric color range for the rotation panel, in degrees. + mask_range : tuple of float, default=(0.0, 1.0) + ``(low, high)`` window remapping the mask brightness: positions with + mask ``>= high`` are shown at full color, ``<= low`` are black, and + values between ramp linearly from black to full. The default leaves the + normalized mask unchanged. + plot_rotation : bool, default=True + Whether to include the rotation (``phi``) panel. + plot_gvecs : bool, default=False + Whether to overlay the reference lattice vectors. + plot_scalebar : bool, default=False + Whether to draw a real-space scale bar. + cmap_strain : str, default="RdBu_r" + Colormap for the strain panels. + cmap_rotation : str, default="PiYG" + Colormap for the rotation panel. + layout : {"horizontal", "vertical"}, default="horizontal" + Panel arrangement. + figsize : tuple of float, optional + Figure size in inches; if omitted it is derived from the layout. + **kwargs + Forwarded to + :func:`~quantem.diffraction.strain_visualization.plot_strain_panels`. + + Returns + ------- + tuple + ``(fig, ax)`` from :func:`plot_strain_panels`. + """ + e_uu, e_vv, e_uv = self.rotate_strain(rotation_angle) + return plot_strain_panels( + e_uu, + e_vv, + e_uv, + self.phi.array, + self.mask, + self.u_ref, + self.v_ref, + self.ds_shape, + ds_sampling=self.ds_sampling, + ds_units=self.ds_units, + strain_range_percent=strain_range_percent, + rotation_range_degrees=rotation_range_degrees, + mask_range=mask_range, + plot_rotation=plot_rotation, + plot_gvecs=plot_gvecs, + plot_scalebar=plot_scalebar, + cmap_strain=cmap_strain, + cmap_rotation=cmap_rotation, + layout=layout, + figsize=figsize, + **kwargs, + ) + + def estimate_strain_precision( + self, + mask_range: tuple[float, float] = (0.0, 1.0), + rotation_angle: float = 0.0, + window: int = 5, + mask_threshold: float = 0.5, + min_neighbors: int = 3, + component: str = "combined", + bins: int = 50, + bounds: tuple[float, float] | None = None, + plot: bool = True, + returnfig: bool = False, + ): + """Estimate strain *precision* (random scatter) from local median deviations. + + This measures repeatability, not accuracy. Without a ground truth (e.g. a + simulation) it cannot detect systematic error — only how far each position + scatters from its local neighborhood. For every position the deviation from + the median of its surrounding well-indexed neighbors is + + ``error(r, c) = | strain(r, c) - median( strain over neighbors with + scaled mask > mask_threshold ) |`` + + computed for each tensor component (the center position is excluded from its + own median). The three strain components are reduced to one rotation-invariant + number via the Frobenius norm of the symmetric strain-tensor deviation, + + ``combined = sqrt(d_uu**2 + d_vv**2 + 2*d_uv**2)``, + + (equivalently the root-sum-square of the principal-strain deviations) so a + single strain precision can be quoted and compared between datasets. Rotation + precision is reported separately, not folded into ``combined``. + + Each component's precision is summarized as the mask-weighted **RMS** of its + per-position deviations — a sigma-like scatter — and a weighted histogram of + those deviations is shown. RMS combining is self-consistent under the + Frobenius sum: ``rms(combined) == sqrt(rms_uu**2 + rms_vv**2 + 2*rms_uv**2)``. + + Parameters + ---------- + mask_range : tuple of float, default=(0.0, 1.0) + ``(low, high)`` window remapping :attr:`mask` to ``[0, 1]`` (same + convention as :meth:`plot_strain`); the remapped mask both selects + neighbors (``> mask_threshold``) and weights the histogram and mean. + rotation_angle : float, default=0.0 + Frame rotation (degrees) applied before measuring per-component precision, + matching :meth:`plot_strain`. ``0`` reports the raw row/col frame + (``e_uu == e_rr`` ...). The combined number is rotation-invariant. + window : int, default=5 + Odd edge length (px) of the neighborhood bounding box; the footprint is + the inscribed disk of radius ``window / 2`` (3 -> 8 neighbors, 5 -> 20, + 7 -> 36). A pure linear strain ramp cancels in the (symmetric) median, so + larger windows mostly just steady the median — at the cost of blurring + *curved* strain and biasing the masked edges. ``5`` roughly halves the + noise-floor over-estimate of ``3`` (~9% -> ~4%) while staying local. + mask_threshold : float, default=0.5 + A neighbor contributes to the local median only if its scaled mask + exceeds this value. + min_neighbors : int, default=3 + Minimum number of valid neighbors required; positions with fewer get no + precision estimate (``nan``, dropped from the statistics). + component : {"combined","e_uu","e_vv","e_uv","rotation"}, default="combined" + Which error distribution to histogram. + bins : int, default=50 + Number of histogram bins (or a sequence of bin edges). + bounds : tuple of float, optional + ``(low, high)`` histogram range in display units (percent for strain, + degrees for rotation). Fix it to compare datasets on the same axis. Mass + outside the range (or outside an explicit ``bins`` edge array) is piled + into the edge bins as overflow rather than dropped, so the histogram + always integrates to the same weight as the reported RMS. + plot : bool, default=True + If ``True``, draw the weighted precision histogram. + returnfig : bool, default=False + If ``True``, return ``(fig, ax)`` instead of the results dict. + + Returns + ------- + dict or tuple + A results dict with the mask-weighted ``rms`` precision per component and + the ``combined`` value (strain in percent, rotation in degrees), the + normalized ``counts`` and ``edges`` of the histogrammed ``component`` (and + ``counts_raw``, the weighted bin sums), ``out_of_range_fraction`` (weighted + mass piled into the edge bins as overflow), and the chosen settings; or + ``(fig, ax)`` when ``returnfig=True``. + """ + if window < 3 or window % 2 == 0: + raise ValueError("window must be an odd integer >= 3.") + valid_components = ("combined", "e_uu", "e_vv", "e_uv", "rotation") + if component not in valid_components: + raise ValueError(f"component must be one of {valid_components}.") + + # number of neighbors in the circular footprint (matches _local_masked_median) + p = window // 2 + oy, ox = np.ogrid[-p : p + 1, -p : p + 1] + n_neighbors = int(np.sum((oy ** 2 + ox ** 2) <= (window / 2.0) ** 2) - 1) + + # per-component fields in the (optionally rotated) display frame; phi is + # rotation-invariant and is carried through unchanged + e_uu, e_vv, e_uv = self.rotate_strain(rotation_angle) + fields = {"e_uu": e_uu, "e_vv": e_vv, "e_uv": e_uv, "rotation": self.phi.array} + + # remap the mask exactly as plot_strain does, then use it both to select + # neighbors (> mask_threshold) and to weight the histogram / mean + low, high = float(mask_range[0]), float(mask_range[1]) + m = np.asarray(self.mask, dtype=float) + if high > low: + scaled = np.clip((m - low) / (high - low), 0.0, 1.0) + else: + scaled = (m >= high).astype(float) + valid = scaled > float(mask_threshold) + + # per-component local-median deviation, native units (fractional / radians) + dev = { + name: np.abs(field - _local_masked_median(field, valid, window, min_neighbors)) + for name, field in fields.items() + } + # single rotation-invariant number: Frobenius norm of the symmetric + # strain-tensor deviation (== root-sum-square of the principal-strain + # deviations). Rotation is reported separately, not folded in: in nanobeam + # data it is partly a systematic (tilt/descan) and would mix radians into a + # percent figure. + dev["combined"] = np.sqrt( + dev["e_uu"] ** 2 + dev["e_vv"] ** 2 + 2.0 * dev["e_uv"] ** 2 + ) + + # display-unit scaling: strain -> percent, rotation -> degrees + scale = { + "e_uu": 100.0, + "e_vv": 100.0, + "e_uv": 100.0, + "rotation": float(np.rad2deg(1.0)), + "combined": 100.0, + } + + def _weighted_rms(err_native: np.ndarray, factor: float) -> float: + e = err_native * factor + finite = np.isfinite(e) + w = scaled[finite] + wsum = float(w.sum()) + return float(np.sqrt(np.sum(e[finite] ** 2 * w) / wsum)) if wsum > 0 else float("nan") + + # mask-weighted RMS deviation (a sigma-like scatter). Under the Frobenius + # sum this is self-consistent: rms(combined) == sqrt(rms_uu**2 + rms_vv**2 + # + 2*rms_uv**2), so the combined number agrees with the per-component ones. + rms = {name: _weighted_rms(dev[name], scale[name]) for name in scale} + + # weighted histogram of the chosen component, in display units. The headline + # RMS above is computed over *all* finite positions, so the histogram must be + # too: with an explicit bin-edge array (e.g. bins=np.arange(0, 1, 0.02)) + # np.histogram ignores `range` and silently DROPS everything outside the + # edges, then renormalizing makes the plot look far tighter than the reported + # RMS. Instead pile any out-of-range mass into the edge bins (overflow) so the + # histogram integrates to the same weight the RMS sees, and report the + # fraction that landed there. + e = dev[component] * scale[component] + finite = np.isfinite(e) + e_f = e[finite] + w_f = scaled[finite] + edges = np.histogram_bin_edges(e_f, bins=bins, range=bounds) + lo, hi = float(edges[0]), float(edges[-1]) + wtot = float(w_f.sum()) + frac_below = float(w_f[e_f < lo].sum()) / wtot if wtot > 0 else 0.0 + frac_above = float(w_f[e_f > hi].sum()) / wtot if wtot > 0 else 0.0 + out_of_range_fraction = frac_below + frac_above + counts_raw, edges = np.histogram(np.clip(e_f, lo, hi), bins=edges, weights=w_f) + total = float(counts_raw.sum()) + counts = counts_raw / total if total > 0 else counts_raw + + unit = "°" if component == "rotation" else "%" + result = { + "rms": rms, + "component": component, + "unit": unit, + "counts": counts, + "counts_raw": counts_raw, + "edges": edges, + "out_of_range_fraction": out_of_range_fraction, + "window": int(window), + "n_neighbors": n_neighbors, + "mask_threshold": float(mask_threshold), + "mask_range": (low, high), + "rotation_angle": float(rotation_angle), + } + + print("Strain precision (RMS of local median deviation, weighted by scaled mask)") + print( + f" reference={n_neighbors} neighbors (disk, window={window}) " + f"mask>{mask_threshold:g} min_neighbors={min_neighbors} " + f"rotation_angle={rotation_angle:g} deg" + ) + for name in ("e_uu", "e_vv", "e_uv"): + print(f" {name:<9}: {rms[name]:7.4f} %") + print(f" {'rotation':<9}: {rms['rotation']:7.4f} deg") + print( + f" {'combined':<9}: {rms['combined']:7.4f} % " + "(strain-only Frobenius norm; rotation excluded)" + ) + if out_of_range_fraction > 0: + print( + f" note: {100 * out_of_range_fraction:.1f}% of weighted mass fell " + f"outside the histogram range [{lo:g}, {hi:g}] {unit} and was piled " + "into the edge bins (overflow); widen `bins`/`bounds` to resolve the " + "tail. The RMS above includes it." + ) + + if not (plot or returnfig): + return result + + fig, ax = plot_strain_precision_histogram(edges, counts, rms, component, unit) + if returnfig: + return fig, ax + return result + + +# ---- module-level fitting functions ---- + + +def _local_masked_median( + field: np.ndarray, + valid: np.ndarray, + window: int, + min_neighbors: int, +) -> np.ndarray: + """Median of each position's surrounding neighbors over valid (masked) pixels. + + The center position is excluded ("surrounding" only); a neighbor contributes + only where ``valid`` is True and the field is finite. Neighbors are taken over a + circular (isotropic) footprint of radius ``window / 2`` inscribed in the + ``window`` x ``window`` box — a disk avoids the square's far corners, which + over-weight the diagonals and sample the most strain-different points. Positions + left with fewer than ``min_neighbors`` contributing neighbors return ``nan``. + + Parameters + ---------- + field : np.ndarray + ``(scan_row, scan_col)`` field to take local medians of. + valid : np.ndarray + ``(scan_row, scan_col)`` boolean mask of usable neighbor positions. + window : int + Odd edge length of the bounding box; the footprint is the disk of radius + ``window / 2`` within it (3 -> 8 neighbors, 5 -> 20, 7 -> 36). + min_neighbors : int + Minimum contributing neighbors required, else ``nan``. + + Returns + ------- + np.ndarray + ``(scan_row, scan_col)`` local masked median (``nan`` where undefined). + """ + p = window // 2 + fpad = np.pad(np.asarray(field, dtype=float), p, mode="constant", constant_values=np.nan) + vpad = np.pad(np.asarray(valid, dtype=bool), p, mode="constant", constant_values=False) + + # writable per-position (window, window) neighborhoods + fw = sliding_window_view(fpad, (window, window)).copy() + vw = sliding_window_view(vpad, (window, window)) + fw[~vw] = np.nan + fw[:, :, p, p] = np.nan # exclude the center position from its own median + # restrict the square box to a circular footprint of radius window/2 + oy, ox = np.ogrid[-p : p + 1, -p : p + 1] + outside = (oy ** 2 + ox ** 2) > (window / 2.0) ** 2 + fw[:, :, outside] = np.nan + + flat = fw.reshape(fw.shape[0], fw.shape[1], -1) + count = np.sum(np.isfinite(flat), axis=-1) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + med = np.nanmedian(flat, axis=-1) + med[count < min_neighbors] = np.nan + return med + + +def _reference_lattice( + u_array: np.ndarray, + v_array: np.ndarray, + mask: np.ndarray | None = None, + strain_mask: np.ndarray | None = None, +) -> tuple[np.ndarray, np.ndarray]: + """Median reference lattice vectors over an ROI / mask, else the global median. + + Parameters + ---------- + u_array : np.ndarray + Per-position first lattice vector, shape ``(scan_row, scan_col, 2)``. + v_array : np.ndarray + Per-position second lattice vector, shape ``(scan_row, scan_col, 2)``. + mask : np.ndarray, optional + ``(scan_row, scan_col)`` ROI; positions equal to 1 are included. Used when + ``strain_mask`` is not given. + strain_mask : np.ndarray, optional + ``(scan_row, scan_col)`` ROI taking precedence over ``mask``. + + Returns + ------- + tuple of np.ndarray + ``(u_ref, v_ref)``, each a length-2 reference vector. + """ + if strain_mask is not None: + m = np.asarray(strain_mask == 1, dtype=bool) + elif mask is not None: + m = np.asarray(mask == 1, dtype=bool) + else: + m = None + + # nan-median: positions fit_lattice could not fit are NaN and must be ignored, + # otherwise the reference (and hence the whole strain map) collapses to NaN. + if m is None or not m.any(): + u_ref = np.nanmedian(u_array.reshape(-1, 2), axis=0) + v_ref = np.nanmedian(v_array.reshape(-1, 2), axis=0) + else: + u_ref = np.array((np.nanmedian(u_array[m, 0]), np.nanmedian(u_array[m, 1])), dtype=float) + v_ref = np.array((np.nanmedian(v_array[m, 0]), np.nanmedian(v_array[m, 1])), dtype=float) + return u_ref, v_ref + + +def _strain_tensor( + u_array: np.ndarray, + v_array: np.ndarray, + u_ref: np.ndarray, + v_ref: np.ndarray, + real_space: bool, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Per-position strain tensor from lattice vectors relative to a reference. + + For reciprocal-space (nanobeam) data the shear and rotation are sign-flipped via + ``const = -1``. + + Parameters + ---------- + u_array : np.ndarray + Per-position first lattice vector, shape ``(scan_row, scan_col, 2)``. + v_array : np.ndarray + Per-position second lattice vector, shape ``(scan_row, scan_col, 2)``. + u_ref : np.ndarray + Reference first lattice vector (length 2). + v_ref : np.ndarray + Reference second lattice vector (length 2). + real_space : bool + ``True`` for real-space data; ``False`` (nanobeam, reciprocal space) flips + the shear and rotation signs. + + Returns + ------- + tuple of np.ndarray + ``(e_rr, e_cc, e_rc, phi)``, each of shape ``(scan_row, scan_col)``. + """ + scan_r, scan_c = u_array.shape[0], u_array.shape[1] + Uref = np.stack((u_ref, v_ref), axis=1).astype(float) + strain_trans = np.zeros((scan_r, scan_c, 2, 2)) + for r in range(scan_r): + for c in range(scan_c): + U = np.stack((u_array[r, c, :], v_array[r, c, :]), axis=1) + # Positions fit_lattice could not fit are NaN; a degenerate (collinear) + # fit is singular. Either way there is no meaningful inverse -- leave the + # strain NaN (masked out downstream) rather than feeding NaN into pinv, + # whose SVD does not converge and raises LinAlgError. + if not np.all(np.isfinite(U)) or abs(np.linalg.det(U)) < 1e-12: + strain_trans[r, c, :, :] = np.nan + continue + strain_trans[r, c, :, :] = Uref @ np.linalg.inv(U) + + const = 1 if real_space else -1 + e_rr = strain_trans[:, :, 0, 0] - 1 + e_cc = strain_trans[:, :, 1, 1] - 1 + e_rc = strain_trans[:, :, 1, 0] * 0.5 * const + strain_trans[:, :, 0, 1] * 0.5 * const + phi = strain_trans[:, :, 1, 0] * -0.5 * const + strain_trans[:, :, 0, 1] * 0.5 * const + return e_rr, e_cc, e_rc, phi + + +def _rotate_strain_tensor( + e_rr: np.ndarray, + e_cc: np.ndarray, + e_rc: np.ndarray, + rotation_angle: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Rotate a 2D strain tensor by ``rotation_angle`` (degrees). + + Parameters + ---------- + e_rr : np.ndarray + Row-row (normal) strain component. + e_cc : np.ndarray + Column-column (normal) strain component. + e_rc : np.ndarray + Row-column (shear) strain component. + rotation_angle : float + Frame rotation angle, in degrees. + + Returns + ------- + tuple of np.ndarray + ``(e_uu, e_vv, e_uv)`` in the rotated frame. + """ + angle = np.deg2rad(rotation_angle) + c = np.cos(angle) + s = np.sin(angle) + e_uu = e_rr * (c * c) + 2.0 * e_rc * (c * s) + e_cc * (s * s) + e_vv = e_rr * (s * s) - 2.0 * e_rc * (c * s) + e_cc * (c * c) + e_uv = (e_cc - e_rr) * (c * s) + e_rc * (c * c - s * s) + return e_uu, e_vv, e_uv diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index 79fa4de0..18df58f3 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -17,7 +17,7 @@ from quantem.core.utils.utils import electron_wavelength_angstrom from quantem.core.utils.validators import ensure_valid_array from quantem.core.visualization import ScalebarConfig, show_2d -from quantem.diffraction.strain_visualization import StrainFitting +from quantem.diffraction.strain import StrainMap class StrainMapAutocorrelation(AutoSerialize): @@ -589,16 +589,16 @@ def create_mask( return self - def initilize_strain_class( + def initialize_strain_class( self, u_ref: np.ndarray | None = None, v_ref: np.ndarray | None = None, - )->StrainFitting: + )->StrainMap: if self.u_array is None or self.v_array is None: - raise RuntimeWarning("Need to run fit_lattice_vectors before initilizing strain class") + raise RuntimeWarning("Need to run fit_lattice_vectors before initializing strain class") if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") - + default_units = None default_sampling = None if hasattr(self.dataset, 'units'): @@ -611,30 +611,18 @@ def initilize_strain_class( default_sampling = float(self.dataset.sampling[0]) else: default_sampling = float(self.dataset.sampling) - - if self.mask is not None: - return StrainFitting( - u_array = self.u_array, - v_array = self.v_array, - ds_shape = self.dataset.shape, - real_space = self.real_space, - u_ref = u_ref, - v_ref = v_ref, - mask = self.mask, - ds_sampling=default_sampling, - ds_units = default_units, - ) - else: - return StrainFitting( - u_array = self.u_array, - v_array = self.v_array, - ds_shape = self.dataset.shape, - real_space = self.real_space, - u_ref = u_ref, - v_ref = v_ref, - ds_sampling=default_sampling, - ds_units = default_units, - ) + + return StrainMap( + u_array = self.u_array, + v_array = self.v_array, + ds_shape = self.dataset.shape, + real_space = self.real_space, + u_ref = u_ref, + v_ref = v_ref, + mask = self.mask, + ds_sampling=default_sampling, + ds_units = default_units, + ) def plot_lattice_vectors( self, diff --git a/src/quantem/diffraction/strain_visualization.py b/src/quantem/diffraction/strain_visualization.py index e5b02d50..0326d513 100644 --- a/src/quantem/diffraction/strain_visualization.py +++ b/src/quantem/diffraction/strain_visualization.py @@ -5,381 +5,323 @@ from matplotlib.cm import ScalarMappable from matplotlib.colors import Normalize from matplotlib.patches import FancyArrowPatch +from matplotlib.ticker import FuncFormatter -from quantem.core.datastructures.dataset2d import Dataset2d -from quantem.core.io.serialize import AutoSerialize from quantem.core.visualization.visualization_utils import ScalebarConfig, add_scalebar_to_ax -class StrainFitting(AutoSerialize): - mask: np.ndarray | None = None - real_space: bool = False - - strain_raw_err: Dataset2d - strain_raw_ecc: Dataset2d - strain_raw_erc: Dataset2d - strain_rotation: Dataset2d - - u_ref: np.ndarray | None = None - v_ref: np.ndarray | None = None - u_array: np.ndarray - v_array: np.ndarray - - ds_sampling = 1.0 - ds_units = 'pixels' - ds_shape: tuple[int, ...] - - - def __init__( - self, - u_array: np.ndarray, - v_array: np.ndarray, - ds_shape: tuple[int, ...], - real_space: bool, - - u_ref: np.ndarray | None = None, - v_ref: np.ndarray | None = None, - mask: np.ndarray | None = None, - - ds_sampling: float | None = None, - ds_units: str | None = None, - ): - super().__init__() - self.u_array = u_array - self.v_array = v_array - - self.ds_shape = ds_shape - self.real_space = real_space - - self.ds_sampling = 1.0 if ds_sampling is None else ds_sampling - self.ds_units = 'pixels' if ds_units is None else ds_units - - self.mask = np.ones(ds_shape[:2]) if mask is None else mask - self.mask = (self.mask - np.min(self.mask))/np.max(self.mask) - - self.u_ref = u_ref if u_ref is not None else None - self.v_ref = v_ref if v_ref is not None else None - - - - def fit_strain( - self, - plot_strain = False, - strain_mask: np.ndarray | None = None, - ) -> "StrainFitting": - - if self.u_array is None: - raise RuntimeError("u_array not available. Set u_array before calling fit_strain().") - if self.v_array is None: - raise RuntimeError("v_array not available. Set v_array before calling fit_strain().") - - u_fit = self.u_array - v_fit = self.v_array - - if strain_mask is not None: - m = np.asarray((strain_mask==1), dtype=bool) - u_ref = np.array( - (np.median(u_fit[m, 0]), np.median(u_fit[m, 1])), - dtype=float, - ) - v_ref = np.array( - (np.median(v_fit[m, 0]), np.median(v_fit[m, 1])), - dtype=float, - ) - elif self.mask is None: - u_ref = np.median(u_fit.reshape(-1, 2), axis=0) - v_ref = np.median(v_fit.reshape(-1, 2), axis=0) - else: - m = np.asarray((self.mask==1), dtype=bool) - u_ref = np.array( - (np.median(u_fit[m, 0]), np.median(u_fit[m, 1])), - dtype=float, - ) - v_ref = np.array( - (np.median(v_fit[m, 0]), np.median(v_fit[m, 1])), - dtype=float, - ) - if self.u_ref is not None: - u_ref = self.u_ref - if self.v_ref is not None: - v_ref = self.v_ref - self.u_ref = u_ref - self.v_ref = v_ref - scan_r = self.ds_shape[0] - scan_c = self.ds_shape[1] - - Uref = np.stack((u_ref, v_ref), axis=1).astype(float) - strain_trans = np.zeros((scan_r, scan_c, 2, 2)) - for r in range(scan_r): - for c in range(scan_c): - U = np.stack((u_fit[r, c, :], v_fit[r, c, :]), axis=1) - det = np.linalg.det(U) - if not np.isfinite(det) or abs(det) < 1e-12: - U_inv = np.linalg.pinv(U) - else: - U_inv = np.linalg.inv(U) - strain_trans[r, c, :, :] = Uref @ U_inv - const = 1 - if self.real_space is False: - const = -1 - - self.strain_raw_err = Dataset2d.from_array( - strain_trans[:, :, 0, 0] - 1, - name="strain err", - signal_units="fractional", - ) - self.strain_raw_ecc = Dataset2d.from_array( - strain_trans[:, :, 1, 1] - 1, - name="strain ecc", - signal_units="fractional", +def plot_strain_panels( + e_uu: np.ndarray, + e_vv: np.ndarray, + e_uv: np.ndarray, + rotation: np.ndarray, + mask: np.ndarray | None, + u_ref: np.ndarray | None, + v_ref: np.ndarray | None, + ds_shape: tuple[int, ...], + ds_sampling: float = 1.0, + ds_units: str = "pixels", + strain_range_percent: tuple[float, float] = (-3.0, 3.0), + rotation_range_degrees: tuple[float, float] = (-2.0, 2.0), + mask_range: tuple[float, float] = (0.0, 1.0), + roi: np.ndarray | None = None, + plot_rotation: bool = True, + plot_gvecs: bool = False, + plot_scalebar: bool = False, + cmap_strain: str = "RdBu_r", + cmap_rotation: str = "PiYG", + layout: str = "horizontal", + figsize: tuple[float, float] | None = None, + panel_titles: tuple[str, str, str] | None = None, + **kwargs, +): + """Render strain (e_uu, e_vv, e_uv) and rotation panels. + + Strain arrays are fractional (multiplied by 100 for display); ``rotation`` is + in radians (converted to degrees for display). ``panel_titles`` overrides the + three strain-panel titles (e.g. to label the raw row/col reference frame). + + The mask modulates panel brightness (black where masked out). ``mask_range`` + ``(low, high)`` remaps it linearly before display: mask values ``>= high`` show + full color, ``<= low`` go black, and values between ramp from black to full. + The default ``(0.0, 1.0)`` leaves the already-normalized mask unchanged. + + When ``roi`` (a boolean ``(scan_row, scan_col)`` array) is given, positions + inside it are drawn in color and positions outside it in greyscale (the same + field, desaturated), so a chosen reference region stands out from its context. + """ + if mask is None: + mask = np.ones(ds_shape[:2]) + + # remap the mask brightness onto the [low, high] window: <= low -> black, + # >= high -> full color, linear between. default (0, 1) is a no-op. + low, high = float(mask_range[0]), float(mask_range[1]) + if high > low: + mask = np.clip((np.asarray(mask, dtype=float) - low) / (high - low), 0.0, 1.0) + else: + mask = (np.asarray(mask, dtype=float) >= high).astype(float) + + if cmap_rotation is None: + cmap_rotation = cmap_strain + + if layout not in ["horizontal", "vertical"]: + raise ValueError("layout must be 'horizontal' or 'vertical'") + + ncols = 4 if plot_rotation else 3 + is_horizontal = layout == "horizontal" + + if figsize is None: + figsize = (8, 3) if is_horizontal else (6, 6) + + if is_horizontal: + fig, ax = plt.subplots(1, ncols, figsize=figsize) + else: + fig, ax = plt.subplots(ncols, 1, figsize=figsize) + + cm_strain = plt.get_cmap(cmap_strain).copy() + cm_strain.set_bad(color="black") + cm_rot = plt.get_cmap(cmap_rotation).copy() + cm_rot.set_bad(color="black") + + euu_pct = e_uu * 100 + evv_pct = e_vv * 100 + euv_pct = e_uv * 100 + rot_deg = np.rad2deg(rotation) + + roi_bool = None if roi is None else np.asarray(roi).astype(bool) + gray_cm = plt.get_cmap("gray").copy() + gray_cm.set_bad(color="black") + + def _roi_compose(norm_vals, color_cm): + """Color the field inside the ROI; show it in greyscale outside the ROI.""" + rgb = color_cm(norm_vals)[:, :, :3] + if roi_bool is None: + return rgb + rgb_gray = gray_cm(norm_vals)[:, :, :3] + return np.where(roi_bool[:, :, np.newaxis], rgb, rgb_gray) + + norm_strain = Normalize(vmin=strain_range_percent[0], vmax=strain_range_percent[1]) + euu_disp = _roi_compose(norm_strain(euu_pct), cm_strain) + evv_disp = _roi_compose(norm_strain(evv_pct), cm_strain) + euv_disp = _roi_compose(norm_strain(euv_pct), cm_strain) + + title_fs = 16 + ax[0].imshow(euu_disp * mask[:, :, np.newaxis]) + ax[1].imshow(evv_disp * mask[:, :, np.newaxis]) + ax[2].imshow(euv_disp * mask[:, :, np.newaxis]) + + if panel_titles is None: + panel_titles = ( + r"$\epsilon_{uu}$ $\updownarrow$", + r"$\epsilon_{vv}$ $\leftrightarrow$", + r"$\epsilon_{uv}$ $\nwarrow\!\!\!\!\!\!\!\!\!\:\searrow$", ) - self.strain_raw_erc = Dataset2d.from_array( - strain_trans[:, :, 1, 0] * 0.5 * const + strain_trans[:, :, 0, 1] * 0.5 * const, - name="strain erc", - signal_units="fractional", + ax[0].set_title(panel_titles[0], fontsize=title_fs) + ax[1].set_title(panel_titles[1], fontsize=title_fs) + ax[2].set_title(panel_titles[2], fontsize=title_fs) + + if plot_rotation: + norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) + rot_disp = _roi_compose(norm_rot(rot_deg), cm_rot) + ax[3].imshow(rot_disp * mask[:, :, np.newaxis]) + ax[3].set_title(r"Rotation $\circlearrowleft$", fontsize=title_fs) + + for a in ax: + a.set_xticks([]) + a.set_yticks([]) + a.set_facecolor("black") + a.set_aspect("equal") + + if plot_scalebar: + scalebar_kwargs = {} + for key, value in kwargs.items(): + if key.startswith("scalebar_"): + scalebar_key = key[len("scalebar_"):] + scalebar_kwargs[scalebar_key] = value + + scalebar_defaults = { + "sampling": ds_sampling, + "units": ds_units, + "length": None, + "width_px": 1, + "pad_px": 0.5, + "color": "black", + "loc": "lower left", + "fontsize": 12, + "bold": True, + } + scalebar_defaults.update(scalebar_kwargs) + scalebar_config = ScalebarConfig(**scalebar_defaults) + add_scalebar_to_ax( + ax[0], + array_size=int(ds_shape[0]), + sampling=scalebar_config.sampling, + length_units=scalebar_config.length, + units=scalebar_config.units, + width_px=scalebar_config.width_px, + pad_px=scalebar_config.pad_px, + color=scalebar_config.color, + loc=scalebar_config.loc, + fontsize=scalebar_config.fontsize, + bold=scalebar_config.bold, ) - self.strain_rotation = Dataset2d.from_array( - strain_trans[:, :, 1, 0] * -0.5 * const + strain_trans[:, :, 0, 1] * 0.5 * const, - name="strain rotation", - signal_units="fractional", - ) - if plot_strain: - self.plot_strain() - return self - - - def plot_strain( - self, - rotation_angle=20, - strain_range_percent=(-3.0, 3.0), - rotation_range_degrees=(-2.0, 2.0), - plot_rotation=True, - plot_gvecs=True, - plot_scalebar=False, - cmap_strain="RdBu_r", - cmap_rotation="PiYG", - layout="horizontal", - figsize=None, - **kwargs, - ): - if not hasattr(self, 'strain_raw_err') or self.strain_raw_err is None: - raise RuntimeError("Call fit_strain() first.") - if not hasattr(self, 'strain_raw_ecc') or self.strain_raw_ecc is None: - raise RuntimeError("Call fit_strain() first.") - if not hasattr(self, 'strain_raw_erc') or self.strain_raw_erc is None: - raise RuntimeError("Call fit_strain() first.") - if not hasattr(self, 'strain_rotation') or self.strain_rotation is None: - raise RuntimeError("Call fit_strain() first.") - - if self.mask is None: - self.mask = np.zeros(self.ds_shape[:2]) - - if cmap_rotation is None: - cmap_rotation = cmap_strain - - if layout not in ["horizontal", "vertical"]: - raise ValueError("layout must be 'horizontal' or 'vertical'") - - angle = np.deg2rad(rotation_angle) - c = np.cos(angle) - s = np.sin(angle) - - err = self.strain_raw_err.array - ecc = self.strain_raw_ecc.array - erc = self.strain_raw_erc.array - - euu = err * (c * c) + 2.0 * erc * (c * s) + ecc * (s * s) - evv = err * (s * s) - 2.0 * erc * (c * s) + ecc * (c * c) - euv = (ecc - err) * (c * s) + erc * (c * c - s * s) - - strain_euu = self.strain_raw_err.copy() - strain_evv = self.strain_raw_ecc.copy() - strain_euv = self.strain_raw_erc.copy() - strain_euu.array[...] = euu - strain_evv.array[...] = evv - strain_euv.array[...] = euv - - ncols = 4 if plot_rotation else 3 - is_horizontal = layout == "horizontal" - - if figsize is None: - figsize = (6, 6) if is_horizontal else (6, 6) - - if is_horizontal: - fig, ax = plt.subplots(1, ncols, figsize=figsize) - else: - fig, ax = plt.subplots(ncols, 1, figsize=figsize) - cm_strain = plt.get_cmap(cmap_strain).copy() - cm_strain.set_bad(color="black") - cm_rot = plt.get_cmap(cmap_rotation).copy() - cm_rot.set_bad(color="black") + if is_horizontal: + fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) + if plot_rotation: + pos3 = ax[3].get_position() + ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) - euu_pct = strain_euu.array * 100 - evv_pct = strain_evv.array * 100 - euv_pct = strain_euv.array * 100 - rot_deg = np.rad2deg(self.strain_rotation.array) + cb_orientation = "horizontal" + cb_size = 0.02 + cb_pad = 0.02 - norm_strain = Normalize(vmin=strain_range_percent[0], vmax=strain_range_percent[1]) - euu_rgb = cm_strain(norm_strain(euu_pct))[:, :, :3] - evv_rgb = cm_strain(norm_strain(evv_pct))[:, :, :3] - euv_rgb = cm_strain(norm_strain(euv_pct))[:, :, :3] + b0 = ax[0].get_position() + b2 = ax[2].get_position() + strain_cb_pos = [b0.x0, b0.y0 - cb_pad - cb_size, b2.x1 - b0.x0, cb_size] - title_fs = 16 - ax[0].imshow(euu_rgb * self.mask[:, :, np.newaxis],) - ax[1].imshow(evv_rgb * self.mask[:, :, np.newaxis],) - ax[2].imshow(euv_rgb * self.mask[:, :, np.newaxis],) + if plot_rotation: + b3 = ax[3].get_position() + rot_cb_pos = [b3.x0, b0.y0 - cb_pad - cb_size, b3.x1 - b3.x0, cb_size] + last_pos = b3 + else: + rot_cb_pos = None + last_pos = b2 + + else: + fig.subplots_adjust(left=0.04, right=0.80, top=0.98, bottom=0.04, hspace=0.15) + + cb_orientation = "vertical" + cb_size = 0.02 + cb_pad = 0.02 - ax[0].set_title(r"$\epsilon_{uu}$ $\updownarrow$", fontsize=title_fs) - ax[1].set_title(r"$\epsilon_{vv}$ $\leftrightarrow$", fontsize=title_fs) - ax[2].set_title(r"$\epsilon_{uv}$ $\nearrow\!\!\!\!\!\!\!\!\!\:\swarrow$", fontsize=title_fs) + b0 = ax[0].get_position() + b2 = ax[2].get_position() + strain_cb_pos = [b0.x1 + cb_pad, b2.y0, cb_size, b0.y1 - b2.y0] if plot_rotation: - norm_rot = Normalize(vmin=rotation_range_degrees[0], vmax=rotation_range_degrees[1]) - rot_rgb = cm_rot(norm_rot(rot_deg))[:, :, :3] - ax[3].imshow(rot_rgb * self.mask[:, :, np.newaxis],) - ax[3].set_title(r"Rotation $\circlearrowleft$", fontsize=title_fs) - - for a in ax: - a.set_xticks([]) - a.set_yticks([]) - a.set_facecolor("black") - a.set_aspect("equal") - - if plot_scalebar: - scalebar_kwargs = {} - for key, value in kwargs.items(): - if key.startswith('scalebar_'): - scalebar_key = key[len('scalebar_'):] - scalebar_kwargs[scalebar_key] = value - - scalebar_defaults = { - 'sampling': self.ds_sampling, - 'units': self.ds_units, - 'length': None, - 'width_px': 1, - 'pad_px': 0.5, - 'color': 'black', - 'loc': 'lower left', - 'fontsize': 12, - 'bold': True, - } - scalebar_defaults.update(scalebar_kwargs) - scalebar_config = ScalebarConfig(**scalebar_defaults) - add_scalebar_to_ax( - ax[0], - array_size=int(self.ds_shape[0]), - sampling=scalebar_config.sampling, - length_units=scalebar_config.length, - units=scalebar_config.units, - width_px=scalebar_config.width_px, - pad_px=scalebar_config.pad_px, - color=scalebar_config.color, - loc=scalebar_config.loc, - fontsize=scalebar_config.fontsize, - bold=scalebar_config.bold, - ) + b3 = ax[3].get_position() + rot_cb_pos = [b0.x1 + cb_pad, b3.y0, cb_size, b3.y1 - b3.y0] + last_pos = b3 + else: + rot_cb_pos = None + last_pos = b2 + + cax1 = fig.add_axes(strain_cb_pos) + sm_strain = ScalarMappable(norm=norm_strain, cmap=cm_strain) + cbar1 = fig.colorbar(sm_strain, cax=cax1, orientation=cb_orientation) + cbar1.set_label("Strain", fontsize=title_fs) + cbar1.formatter = FuncFormatter(lambda v, _pos: f"{v:g}%") + cbar1.update_ticks() + cbar1.ax.tick_params(labelsize=12) + + if plot_rotation and rot_cb_pos is not None: + cax2 = fig.add_axes(rot_cb_pos) + sm_rot = ScalarMappable(norm=norm_rot, cmap=cm_rot) + cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation=cb_orientation) + cbar2.set_label("Rotation", fontsize=title_fs) + cbar2.formatter = FuncFormatter(lambda v, _pos: f"{v:g}°") + cbar2.update_ticks() + cbar2.ax.tick_params(labelsize=12) + + if plot_gvecs: + if u_ref is None or v_ref is None: + print("Warning: u_ref and v_ref not found. Call fit_strain() first.") + return fig, ax if is_horizontal: - fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) - if plot_rotation: - pos3 = ax[3].get_position() - ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) - - cb_orientation = "horizontal" - cb_size = 0.02 - cb_pad = 0.02 - - b0 = ax[0].get_position() - b2 = ax[2].get_position() - strain_cb_pos = [b0.x0, b0.y0 - cb_pad - cb_size, b2.x1 - b0.x0, cb_size] - - if plot_rotation: - b3 = ax[3].get_position() - rot_cb_pos = [b3.x0, b0.y0 - cb_pad - cb_size, b3.x1 - b3.x0, cb_size] - last_pos = b3 - else: - rot_cb_pos = None - last_pos = b2 - + ref_width = last_pos.width * 0.8 + ref_left = last_pos.x1 - 0.035 + ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) else: - fig.subplots_adjust(left=0.04, right=0.80, top=0.98, bottom=0.04, hspace=0.15) - - cb_orientation = "vertical" - cb_size = 0.02 - cb_pad = 0.02 - - b0 = ax[0].get_position() - b2 = ax[2].get_position() - strain_cb_pos = [b0.x1 + cb_pad, b2.y0, cb_size, b0.y1 - b2.y0] - - if plot_rotation: - b3 = ax[3].get_position() - rot_cb_pos = [b0.x1 + cb_pad, b3.y0, cb_size, b3.y1 - b3.y0] - last_pos = b3 - else: - rot_cb_pos = None - last_pos = b2 - - cax1 = fig.add_axes(strain_cb_pos) - sm_strain = ScalarMappable(norm=norm_strain, cmap=cm_strain) - cbar1 = fig.colorbar(sm_strain, cax=cax1, orientation=cb_orientation) - cbar1.set_label("Strain (%)", fontsize=title_fs) - cbar1.ax.tick_params(labelsize=12) - - if plot_rotation and rot_cb_pos is not None: - cax2 = fig.add_axes(rot_cb_pos) - sm_rot = ScalarMappable(norm=norm_rot, cmap=cm_rot) - cbar2 = fig.colorbar(sm_rot, cax=cax2, orientation=cb_orientation) - cbar2.set_label("Rotation (deg)", fontsize=title_fs) - cbar2.ax.tick_params(labelsize=12) - - if plot_gvecs: - if self.u_ref is None or self.v_ref is None: - print("Warning: u_ref and v_ref not found. Call fit_strain() first.") - return fig, ax - - if is_horizontal: - ref_width = last_pos.width * 0.8 - ref_left = last_pos.x1 - 0.035 - ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) - else: - ref_height = last_pos.height * 0.5 - ref_bottom = last_pos.y0 - ref_height - 0.05 - ref_width = last_pos.width * 0.8 - ref_left = last_pos.x0 + (last_pos.width - ref_width) / 2 # Center it - ref_ax = fig.add_axes([ref_left, ref_bottom, ref_width, ref_height]) - - ref_ax.set_xlim(-1.5, 1.5) - ref_ax.set_ylim(-1.5, 1.5) - ref_ax.set_aspect('equal') - ref_ax.axis('off') - u_norm = self.u_ref / np.linalg.norm(self.u_ref) - v_norm = self.v_ref / np.linalg.norm(self.v_ref) - - u_row, u_col = u_norm - v_row, v_col = v_norm - arrow_props_ref = dict(arrowstyle='->', lw=3, mutation_scale=25) - - u_arrow = FancyArrowPatch( - (0, 0), (u_col, -u_row), - color='darkred', **arrow_props_ref - ) - ref_ax.add_patch(u_arrow) - - v_arrow = FancyArrowPatch( - (0, 0), (v_col, -v_row), - color='darkblue', **arrow_props_ref - ) - ref_ax.add_patch(v_arrow) - ref_ax.text(u_col * 1.3, -u_row * 1.3, r'$\mathbf{g}_{1}$', - fontsize=14, fontweight='bold', color='darkred', - ha='center', va='center') - - ref_ax.text(v_col * 1.3, -v_row * 1.3, r'$\mathbf{g}_{2}$', - fontsize=14, fontweight='bold', color='darkblue', - ha='center', va='center') - - - return fig, ax + ref_height = last_pos.height * 0.5 + ref_bottom = last_pos.y0 - ref_height - 0.05 + ref_width = last_pos.width * 0.8 + ref_left = last_pos.x0 + (last_pos.width - ref_width) / 2 + ref_ax = fig.add_axes([ref_left, ref_bottom, ref_width, ref_height]) + + ref_ax.set_xlim(-1.5, 1.5) + ref_ax.set_ylim(-1.5, 1.5) + ref_ax.set_aspect("equal") + ref_ax.axis("off") + u_norm = u_ref / np.linalg.norm(u_ref) + v_norm = v_ref / np.linalg.norm(v_ref) + + u_row, u_col = u_norm + v_row, v_col = v_norm + arrow_props_ref = dict(arrowstyle="->", lw=3, mutation_scale=25) + + u_arrow = FancyArrowPatch( + (0, 0), (u_col, -u_row), + color="darkred", **arrow_props_ref + ) + ref_ax.add_patch(u_arrow) + v_arrow = FancyArrowPatch( + (0, 0), (v_col, -v_row), + color="darkblue", **arrow_props_ref + ) + ref_ax.add_patch(v_arrow) + ref_ax.text(u_col * 1.3, -u_row * 1.3, r"$\mathbf{g}_{1}$", + fontsize=14, fontweight="bold", color="darkred", + ha="center", va="center") + + ref_ax.text(v_col * 1.3, -v_row * 1.3, r"$\mathbf{g}_{2}$", + fontsize=14, fontweight="bold", color="darkblue", + ha="center", va="center") + + return fig, ax + + +def plot_strain_precision_histogram( + edges: np.ndarray, + counts: np.ndarray, + precision: dict[str, float], + component: str, + unit: str, + *, + figsize: tuple[float, float] = (6.0, 4.0), +): + """Weighted histogram of the local-deviation strain precision. + + ``edges``/``counts`` describe the (mask-weighted, normalized) distribution of + the chosen ``component`` deviation in display units (``unit``). ``precision`` + carries the mask-weighted RMS precision of every component for the annotation + box; the RMS of the plotted component is marked with a dashed line. + """ + fig, ax = plt.subplots(figsize=figsize) + edges = np.asarray(edges, dtype=float) + counts = np.asarray(counts, dtype=float) + centers = 0.5 * (edges[:-1] + edges[1:]) + widths = np.diff(edges) + + ax.bar(centers, counts, width=widths, align="center", + color="#4C72B0", edgecolor="white", linewidth=0.3) + + rms_value = precision[component] + if np.isfinite(rms_value): + ax.axvline(rms_value, color="crimson", ls="--", lw=2, + label=f"RMS = {rms_value:.3g} {unit}") + ax.legend(loc="upper left", fontsize=9) + + label = "combined" if component == "combined" else component + ax.set_xlabel(f"{label} precision ({unit})", fontsize=12) + ax.set_ylabel("weighted fraction", fontsize=12) + ax.set_title("Strain precision (RMS of local median deviation)", fontsize=13) + ax.tick_params(labelsize=10) + + annotation = "\n".join( + [ + rf"$\epsilon_{{uu}}$: {precision['e_uu']:.3g} %", + rf"$\epsilon_{{vv}}$: {precision['e_vv']:.3g} %", + rf"$\epsilon_{{uv}}$: {precision['e_uv']:.3g} %", + f"rotation: {precision['rotation']:.3g} °", + f"combined: {precision['combined']:.3g} %", + ] + ) + ax.text(0.97, 0.97, annotation, transform=ax.transAxes, ha="right", va="top", + fontsize=9, family="monospace", + bbox=dict(boxstyle="round", fc="white", ec="0.7", alpha=0.9)) + + fig.tight_layout() + return fig, ax From c9faf13cf32720a5a24e351ffcb848c4ec144a78 Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 1 Jun 2026 10:09:13 +0200 Subject: [PATCH 81/88] minor fixes --- src/quantem/diffraction/strain.py | 133 ++++++++++-------- .../diffraction/strain_visualization.py | 42 +++--- 2 files changed, 103 insertions(+), 72 deletions(-) diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py index a12e8cd5..5f5046ee 100644 --- a/src/quantem/diffraction/strain.py +++ b/src/quantem/diffraction/strain.py @@ -392,17 +392,22 @@ def estimate_strain_precision( single strain precision can be quoted and compared between datasets. Rotation precision is reported separately, not folded into ``combined``. - Each component's precision is summarized as the mask-weighted **RMS** of its - per-position deviations — a sigma-like scatter — and a weighted histogram of - those deviations is shown. RMS combining is self-consistent under the - Frobenius sum: ``rms(combined) == sqrt(rms_uu**2 + rms_vv**2 + 2*rms_uv**2)``. + Each component's precision is summarized by the mask-weighted **median** of its + per-position deviations — the center of the histogram bulk. The median is used + (not the mean or RMS) because a handful of bad-fit pixels form a heavy tail that + would drag a second moment far to the right of where the distribution actually + sits, leaving the reported number disconnected from the histogram; the median + ignores that tail. A weighted histogram of the chosen component is shown, marked + with its median. Parameters ---------- mask_range : tuple of float, default=(0.0, 1.0) ``(low, high)`` window remapping :attr:`mask` to ``[0, 1]`` (same - convention as :meth:`plot_strain`); the remapped mask both selects - neighbors (``> mask_threshold``) and weights the histogram and mean. + convention as :meth:`plot_strain`); the remapped mask both selects which + positions are trusted (``> mask_threshold`` -- used as neighbors *and* as + the set the precision is computed over) and weights the histogram and the + median. rotation_angle : float, default=0.0 Frame rotation (degrees) applied before measuring per-component precision, matching :meth:`plot_strain`. ``0`` reports the raw row/col frame @@ -415,8 +420,11 @@ def estimate_strain_precision( *curved* strain and biasing the masked edges. ``5`` roughly halves the noise-floor over-estimate of ``3`` (~9% -> ~4%) while staying local. mask_threshold : float, default=0.5 - A neighbor contributes to the local median only if its scaled mask - exceeds this value. + A position is trusted only if its scaled mask exceeds this value. Trusted + positions are the ones used as local-median neighbors *and* the ones whose + deviations enter the reported median and histogram; sub-threshold positions + are excluded from both (not merely down-weighted), so a poorly-indexed + pixel cannot leak its scatter into the precision. min_neighbors : int, default=3 Minimum number of valid neighbors required; positions with fewer get no precision estimate (``nan``, dropped from the statistics). @@ -426,10 +434,10 @@ def estimate_strain_precision( Number of histogram bins (or a sequence of bin edges). bounds : tuple of float, optional ``(low, high)`` histogram range in display units (percent for strain, - degrees for rotation). Fix it to compare datasets on the same axis. Mass - outside the range (or outside an explicit ``bins`` edge array) is piled - into the edge bins as overflow rather than dropped, so the histogram - always integrates to the same weight as the reported RMS. + degrees for rotation). Fix it to compare datasets on the same axis. Values + outside the range are left out of the bars (no overflow spike); the median + is computed from all trusted positions regardless, and + ``out_of_range_fraction`` records how much was off-range. plot : bool, default=True If ``True``, draw the weighted precision histogram. returnfig : bool, default=False @@ -438,12 +446,13 @@ def estimate_strain_precision( Returns ------- dict or tuple - A results dict with the mask-weighted ``rms`` precision per component and - the ``combined`` value (strain in percent, rotation in degrees), the - normalized ``counts`` and ``edges`` of the histogrammed ``component`` (and - ``counts_raw``, the weighted bin sums), ``out_of_range_fraction`` (weighted - mass piled into the edge bins as overflow), and the chosen settings; or - ``(fig, ax)`` when ``returnfig=True``. + A results dict with the ``precision`` (mask-weighted median local + deviation) per component and ``combined`` (strain in percent, rotation in + degrees), the normalized ``counts`` and ``edges`` of the histogrammed + ``component`` (and ``counts_raw``, the weighted bin sums), + ``out_of_range_fraction`` (weighted mass outside the histogram range, + excluded from the bars), and the chosen settings; or ``(fig, ax)`` when + ``returnfig=True``. """ if window < 3 or window % 2 == 0: raise ValueError("window must be an odd integer >= 3.") @@ -494,43 +503,43 @@ def estimate_strain_precision( "combined": 100.0, } - def _weighted_rms(err_native: np.ndarray, factor: float) -> float: + # Precision = the weighted MEDIAN of each per-position deviation distribution, + # in display units. Restricted to trusted positions (valid == scaled > + # mask_threshold, the SAME set used to pick neighbors) and mask-weighted within + # it -- otherwise sub-threshold junk pixels, already excluded as neighbors, + # would leak in. The median sits at the center of the histogram bulk and is + # immune to the heavy outlier tail that a mean / RMS would chase out to the + # right (a few bad-fit pixels dominate a second moment but not the median). + def _weighted_median(err_native: np.ndarray, factor: float) -> float: e = err_native * factor - finite = np.isfinite(e) - w = scaled[finite] - wsum = float(w.sum()) - return float(np.sqrt(np.sum(e[finite] ** 2 * w) / wsum)) if wsum > 0 else float("nan") - - # mask-weighted RMS deviation (a sigma-like scatter). Under the Frobenius - # sum this is self-consistent: rms(combined) == sqrt(rms_uu**2 + rms_vv**2 - # + 2*rms_uv**2), so the combined number agrees with the per-component ones. - rms = {name: _weighted_rms(dev[name], scale[name]) for name in scale} - - # weighted histogram of the chosen component, in display units. The headline - # RMS above is computed over *all* finite positions, so the histogram must be - # too: with an explicit bin-edge array (e.g. bins=np.arange(0, 1, 0.02)) - # np.histogram ignores `range` and silently DROPS everything outside the - # edges, then renormalizing makes the plot look far tighter than the reported - # RMS. Instead pile any out-of-range mass into the edge bins (overflow) so the - # histogram integrates to the same weight the RMS sees, and report the - # fraction that landed there. + use = np.isfinite(e) & valid + return _weighted_quantile(e[use], scaled[use], 0.5) + + precision = {name: _weighted_median(dev[name], scale[name]) for name in scale} + + # weighted histogram of the chosen component, over the same trusted positions + # as the median above. This is purely a picture of the common error values, so + # anything beyond the bin range is left OUT of the bars -- no overflow spike at + # the edge to crush the bulk. Nothing is lost: the median is computed from all + # trusted positions regardless. Bars are normalized by the total trusted + # weight, so each bar is the true fraction of all trusted positions and the + # off-range mass simply isn't drawn (the bars sum to 1 - out_of_range_fraction). e = dev[component] * scale[component] - finite = np.isfinite(e) - e_f = e[finite] - w_f = scaled[finite] + use = np.isfinite(e) & valid # trusted positions only, consistent with median + e_f = e[use] + w_f = scaled[use] edges = np.histogram_bin_edges(e_f, bins=bins, range=bounds) lo, hi = float(edges[0]), float(edges[-1]) wtot = float(w_f.sum()) frac_below = float(w_f[e_f < lo].sum()) / wtot if wtot > 0 else 0.0 frac_above = float(w_f[e_f > hi].sum()) / wtot if wtot > 0 else 0.0 out_of_range_fraction = frac_below + frac_above - counts_raw, edges = np.histogram(np.clip(e_f, lo, hi), bins=edges, weights=w_f) - total = float(counts_raw.sum()) - counts = counts_raw / total if total > 0 else counts_raw + counts_raw, edges = np.histogram(e_f, bins=edges, weights=w_f) + counts = counts_raw / wtot if wtot > 0 else counts_raw unit = "°" if component == "rotation" else "%" result = { - "rms": rms, + "precision": precision, "component": component, "unit": unit, "counts": counts, @@ -544,31 +553,24 @@ def _weighted_rms(err_native: np.ndarray, factor: float) -> float: "rotation_angle": float(rotation_angle), } - print("Strain precision (RMS of local median deviation, weighted by scaled mask)") + print("Strain precision (median local deviation, mask-weighted)") print( f" reference={n_neighbors} neighbors (disk, window={window}) " f"mask>{mask_threshold:g} min_neighbors={min_neighbors} " f"rotation_angle={rotation_angle:g} deg" ) for name in ("e_uu", "e_vv", "e_uv"): - print(f" {name:<9}: {rms[name]:7.4f} %") - print(f" {'rotation':<9}: {rms['rotation']:7.4f} deg") + print(f" {name:<9}: {precision[name]:7.4f} %") + print(f" {'rotation':<9}: {precision['rotation']:7.4f} deg") print( - f" {'combined':<9}: {rms['combined']:7.4f} % " + f" {'combined':<9}: {precision['combined']:7.4f} % " "(strain-only Frobenius norm; rotation excluded)" ) - if out_of_range_fraction > 0: - print( - f" note: {100 * out_of_range_fraction:.1f}% of weighted mass fell " - f"outside the histogram range [{lo:g}, {hi:g}] {unit} and was piled " - "into the edge bins (overflow); widen `bins`/`bounds` to resolve the " - "tail. The RMS above includes it." - ) if not (plot or returnfig): return result - fig, ax = plot_strain_precision_histogram(edges, counts, rms, component, unit) + fig, ax = plot_strain_precision_histogram(edges, counts, precision, component, unit) if returnfig: return fig, ax return result @@ -577,6 +579,25 @@ def _weighted_rms(err_native: np.ndarray, factor: float) -> float: # ---- module-level fitting functions ---- +def _weighted_quantile(values: np.ndarray, weights: np.ndarray, q: float) -> float: + """Weighted ``q``-quantile of ``values`` (``q`` in ``[0, 1]``); ``nan`` if no weight. + + Uses cumulative-weight interpolation with weights centered on each sorted sample, + so with uniform weights it tracks ``np.quantile``'s linear interpolation and is + robust to a heavy upper tail (the median ignores how far the outliers reach). + """ + values = np.asarray(values, dtype=float) + weights = np.asarray(weights, dtype=float) + total = float(weights.sum()) + if values.size == 0 or total <= 0: + return float("nan") + order = np.argsort(values) + v = values[order] + w = weights[order] + cw = np.cumsum(w) - 0.5 * w + return float(np.interp(q * total, cw, v)) + + def _local_masked_median( field: np.ndarray, valid: np.ndarray, diff --git a/src/quantem/diffraction/strain_visualization.py b/src/quantem/diffraction/strain_visualization.py index 0326d513..609ef6d4 100644 --- a/src/quantem/diffraction/strain_visualization.py +++ b/src/quantem/diffraction/strain_visualization.py @@ -284,10 +284,10 @@ def plot_strain_precision_histogram( ): """Weighted histogram of the local-deviation strain precision. - ``edges``/``counts`` describe the (mask-weighted, normalized) distribution of - the chosen ``component`` deviation in display units (``unit``). ``precision`` - carries the mask-weighted RMS precision of every component for the annotation - box; the RMS of the plotted component is marked with a dashed line. + ``edges``/``counts`` describe the (mask-weighted, normalized) distribution of the + chosen ``component`` deviation in display units (``unit``). ``precision`` is the + weighted-median local deviation per component (used for the annotation box); the + plotted component's median is marked with a solid line. """ fig, ax = plt.subplots(figsize=figsize) edges = np.asarray(edges, dtype=float) @@ -298,25 +298,35 @@ def plot_strain_precision_histogram( ax.bar(centers, counts, width=widths, align="center", color="#4C72B0", edgecolor="white", linewidth=0.3) - rms_value = precision[component] - if np.isfinite(rms_value): - ax.axvline(rms_value, color="crimson", ls="--", lw=2, - label=f"RMS = {rms_value:.3g} {unit}") - ax.legend(loc="upper left", fontsize=9) + median_value = precision[component] + if np.isfinite(median_value): + ax.axvline(median_value, color="crimson", ls="-", lw=2) + # label the line inline -- a legend box here would sit on top of the info box. + # Put the text on whichever side of the line keeps it clear of the right box. + span = float(edges[-1] - edges[0]) + on_right = span > 0 and (median_value - edges[0]) / span > 0.5 + ax.annotate( + f"median = {median_value:.3g} {unit}", + xy=(median_value, 0.96), xycoords=("data", "axes fraction"), + xytext=(-6 if on_right else 6, 0), textcoords="offset points", + ha="right" if on_right else "left", va="top", + color="crimson", fontsize=9, + ) label = "combined" if component == "combined" else component - ax.set_xlabel(f"{label} precision ({unit})", fontsize=12) + ax.set_xlabel(f"{label} deviation ({unit})", fontsize=12) ax.set_ylabel("weighted fraction", fontsize=12) - ax.set_title("Strain precision (RMS of local median deviation)", fontsize=13) + ax.set_title("Strain precision (median local deviation)", fontsize=13) ax.tick_params(labelsize=10) annotation = "\n".join( [ - rf"$\epsilon_{{uu}}$: {precision['e_uu']:.3g} %", - rf"$\epsilon_{{vv}}$: {precision['e_vv']:.3g} %", - rf"$\epsilon_{{uv}}$: {precision['e_uv']:.3g} %", - f"rotation: {precision['rotation']:.3g} °", - f"combined: {precision['combined']:.3g} %", + r"median:", + rf" $\epsilon_{{uu}}$: {precision['e_uu']:.3g} %", + rf" $\epsilon_{{vv}}$: {precision['e_vv']:.3g} %", + rf" $\epsilon_{{uv}}$: {precision['e_uv']:.3g} %", + rf" rotation: {precision['rotation']:.3g} °", + rf" combined: {precision['combined']:.3g} %", ] ) ax.text(0.97, 0.97, annotation, transform=ax.transAxes, ha="right", va="top", From 2b81d107018cacdb1fa7b25bf2d350ebf97d4dcf Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 1 Jun 2026 11:28:18 +0200 Subject: [PATCH 82/88] cepstral updates --- src/quantem/diffraction/bragg_vectors.py | 67 +- src/quantem/diffraction/strain.py | 56 +- .../diffraction/strain_autocorrelation.py | 603 ++++++++++++++++-- .../diffraction/strain_visualization.py | 52 +- 4 files changed, 707 insertions(+), 71 deletions(-) diff --git a/src/quantem/diffraction/bragg_vectors.py b/src/quantem/diffraction/bragg_vectors.py index ed36ede4..aceb1910 100644 --- a/src/quantem/diffraction/bragg_vectors.py +++ b/src/quantem/diffraction/bragg_vectors.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any +from pathlib import Path +from typing import Any, Literal, Sequence, Union import numpy as np import torch @@ -86,7 +87,7 @@ def __init__( ): if _token is not self._token: raise RuntimeError("Use BraggVectors.from_dataset() to instantiate this class.") - super().__init__() + super(BraggVectors, self).__init__() self.dataset = dataset self.device = device @@ -141,6 +142,68 @@ def from_dataset( dataset.name = name return cls(dataset=dataset, device=device, _token=cls._token) + def save( + self, + path: str | Path, + mode: Literal["w", "o"] = "w", + store: Literal["auto", "zip", "dir"] = "auto", + skip: Union[str, type, Sequence[Union[str, type]]] = (), + compression_level: int | None = 4, + *, + include_dataset: bool = False, + ) -> None: + """Save the workflow to disk, excluding the raw 4D-STEM dataset by default. + + Overrides :meth:`~quantem.core.io.serialize.AutoSerialize.save` to drop + :attr:`dataset` — the raw 4D-STEM cube, which dominates the file size — from + serialization by default. The detected :attr:`peaks`, lattice fit + (:attr:`u_array`/:attr:`v_array`), Bragg vector map and all diagnostics are + kept, so the file holds the *results* of the workflow (orders of magnitude + smaller than the data) rather than the data itself. + + ``"dataset"`` is recorded in the file's skip metadata, so a reloaded workflow + simply has no ``dataset`` attribute. Re-attach one (``bv.dataset = ds``) before + calling methods that read the raw cube — :meth:`detect_disks`, + :meth:`correlation_map`, :meth:`make_template_from_data`, + :meth:`calculate_strain_map`, etc. Pass ``include_dataset=True`` to keep the + dataset in the file instead. + + Parameters + ---------- + path : str or Path + Target file path. Use a ``.zip`` extension for zip format, otherwise a + directory is written. + mode : {'w', 'o'}, default='w' + ``'w'`` writes only if the path does not exist; ``'o'`` overwrites. + store : {'auto', 'zip', 'dir'}, default='auto' + Storage format; ``'auto'`` infers from the file extension. + skip : str, type, or sequence of (str or type), default=() + Additional attribute names/types to skip during serialization, merged with + the default ``dataset`` exclusion. + compression_level : int or None, default=4 + Zstandard/Blosc compression level (0–9); ``0`` disables compression. + include_dataset : bool, default=False + If ``True``, keep the raw 4D-STEM :attr:`dataset` in the file (large). The + default ``False`` excludes it. + """ + if isinstance(skip, (str, type)): + skip = [skip] + else: + skip = list(skip) + if not include_dataset and "dataset" not in skip: + skip.append("dataset") + # Explicit (two-arg) super() rather than the bare super(): the zero-arg form + # needs a compiler-created __class__ closure cell that is absent when this + # method's source is re-exec'd from a string (Jupyter autoreload), which + # raises "super(): __class__ cell not found". The explicit form is immune. + super(BraggVectors, self).save( + path, + mode=mode, + store=store, + skip=skip, + compression_level=compression_level, + ) + # ---- main methods ---- def make_template_synthetic( diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py index 5f5046ee..649746c1 100644 --- a/src/quantem/diffraction/strain.py +++ b/src/quantem/diffraction/strain.py @@ -21,8 +21,10 @@ class StrainMap(AutoSerialize): is the median of the fitted ``g_u``/``g_v`` over a mask/ROI; the strain tensor is recomputed by :meth:`update_reference`. - For nanobeam 4D-STEM the lattice vectors are measured in reciprocal space, so - the shear/rotation signs are flipped relative to real space (``real_space=False``). + Two measurement modalities are supported and give identical strain for the same + deformation, so correlation and cepstral maps can be compared directly: + reciprocal-space Bragg vectors (``real_space=False``, nanobeam correlation) and + real-space cepstral/autocorrelation vectors (``real_space=True``). Parameters ---------- @@ -33,8 +35,9 @@ class StrainMap(AutoSerialize): ds_shape : tuple of int Shape of the parent scan grid, used to size the strain maps. real_space : bool - ``True`` for real-space lattice vectors; ``False`` for reciprocal-space - (nanobeam) data, which flips the shear/rotation signs. + ``False`` for reciprocal-space (Bragg/correlation) lattice vectors; ``True`` + for real-space (cepstral autocorrelation / DPC) vectors. Both modalities are + arranged to yield matching strain (see :func:`_strain_tensor`). u_ref : np.ndarray, optional Fixed reference for ``u``; if omitted the median over the mask/ROI is used. A value supplied here persists across re-fits. @@ -705,8 +708,21 @@ def _strain_tensor( ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Per-position strain tensor from lattice vectors relative to a reference. - For reciprocal-space (nanobeam) data the shear and rotation are sign-flipped via - ``const = -1``. + Two measurement modalities are supported and are arranged to give *identical* + strain for the same physical deformation, so correlation (Bragg) and cepstral + (autocorrelation) maps can be compared directly: + + * ``real_space=False`` -- reciprocal-space lattice vectors (nanobeam Bragg + disks), which contract under tension. The per-position transform is + ``strain_trans = U_ref @ inv(U)``. + * ``real_space=True`` -- real-space lattice vectors (cepstral / Patterson + autocorrelation peaks, or DPC), which expand under tension. The transform is + ``strain_trans = (U @ inv(U_ref)).T``. + + Both expressions evaluate to ``F.T`` (the transpose of the real-space deformation + gradient), so the normal strains, shear, and rotation come out the same + regardless of modality, and the reciprocal-space sign convention (``const = -1``, + which sets the shear/rotation handedness) is shared. Parameters ---------- @@ -719,8 +735,9 @@ def _strain_tensor( v_ref : np.ndarray Reference second lattice vector (length 2). real_space : bool - ``True`` for real-space data; ``False`` (nanobeam, reciprocal space) flips - the shear and rotation signs. + ``False`` for reciprocal-space (Bragg/correlation) vectors; ``True`` for + real-space (cepstral autocorrelation / DPC) vectors. Selects the per-position + transform above; both yield matching strain. Returns ------- @@ -730,6 +747,13 @@ def _strain_tensor( scan_r, scan_c = u_array.shape[0], u_array.shape[1] Uref = np.stack((u_ref, v_ref), axis=1).astype(float) strain_trans = np.zeros((scan_r, scan_c, 2, 2)) + + # For real-space vectors the reference is inverted once (it is shared by every + # position); a non-finite or singular reference leaves the whole map undefined. + Uref_inv = None + if real_space and np.all(np.isfinite(Uref)) and abs(np.linalg.det(Uref)) >= 1e-12: + Uref_inv = np.linalg.inv(Uref) + for r in range(scan_r): for c in range(scan_c): U = np.stack((u_array[r, c, :], v_array[r, c, :]), axis=1) @@ -740,9 +764,19 @@ def _strain_tensor( if not np.all(np.isfinite(U)) or abs(np.linalg.det(U)) < 1e-12: strain_trans[r, c, :, :] = np.nan continue - strain_trans[r, c, :, :] = Uref @ np.linalg.inv(U) - - const = 1 if real_space else -1 + if real_space: + # real-space vectors expand under tension: (U @ U_ref^-1).T == F.T + if Uref_inv is None: + strain_trans[r, c, :, :] = np.nan + else: + strain_trans[r, c, :, :] = (U @ Uref_inv).T + else: + # reciprocal-space vectors contract under tension: U_ref @ U^-1 == F.T + strain_trans[r, c, :, :] = Uref @ np.linalg.inv(U) + + # const = -1 is the reciprocal-space (nanobeam) shear/rotation convention. Both + # modalities reduce strain_trans to F.T above, so the convention is shared. + const = -1 e_rr = strain_trans[:, :, 0, 0] - 1 e_cc = strain_trans[:, :, 1, 1] - 1 e_rc = strain_trans[:, :, 1, 0] * 0.5 * const + strain_trans[:, :, 0, 1] * 0.5 * const diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index 18df58f3..61c0c1da 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -21,19 +21,88 @@ class StrainMapAutocorrelation(AutoSerialize): + """Cepstral / autocorrelation lattice fitting and strain mapping for 4D-STEM. + + An alternative to correlation-based :class:`~quantem.diffraction.bragg_vectors.BraggVectors` + that needs no disk template: every diffraction pattern is transformed into the + autocorrelation (real-space) domain, where the crystal periodicity shows up as a + lattice of sharp peaks, and those peaks are fit per scan position to recover the + local lattice vectors. Because the transform lives in real space, the peaks track + real-space lattice spacings (they *expand* under tension, opposite to Bragg disks), + so ``real_space=True`` -- :func:`~quantem.diffraction.strain._strain_tensor` then + reduces both this and the reciprocal-space Bragg path to the same deformation + gradient, and the two strain maps can be compared directly. + + Workflow (each step writes state consumed by the next): + + 1. :meth:`diffraction_mask` -- build a soft mask over the detector that suppresses + the bright central beam / vacuum so the transform is dominated by the lattice. + 2. :meth:`preprocess` -- transform every pattern and average the magnitudes into a + mean transform image. Three ``mode`` choices set the intensity scaling applied + before the FFT: ``"linear"`` (Patterson / autocorrelation), ``"log"`` + (cepstrum), or ``"gamma"`` (power law). The detector->scan rotation + (``q_to_r_rotation_ccw_deg`` + ``q_transpose``) is read from the parent dataset + metadata, the single source of truth shared with the DPC/CoM and Bragg + workflows. + 3. :meth:`choose_lattice_vector` -- refine a hand-picked initial ``(u, v)`` basis + against the mean transform, optionally auto-detecting and re-fitting all peaks. + 4. :meth:`fit_lattice_vectors` -- the heavy step: transform and fit the lattice + vectors at every scan position into ``u_array``/``v_array`` of shape + ``(scan_row, scan_col, 2)``. + 5. :meth:`create_mask` -- compute the per-position weight :attr:`mask_weight` + (lattice signal strength) used to weight the reference lattice. + 6. :meth:`calculate_strain_map` -- hand the lattice vectors (and + :attr:`mask_weight`) to a :class:`~quantem.diffraction.strain.StrainMap`. + + Use :meth:`from_dataset` or :meth:`from_array` to construct an instance. + + Parameters + ---------- + dataset : Dataset4dstem + The 4D-STEM dataset to analyze. + input_data : Any, optional + The original object passed to the constructor (a dataset or array), retained + for provenance. + """ + _token = object() + # Cepstral / Patterson lattice vectors are measured in the autocorrelation + # (real-space) domain -- the peaks track real-space lattice spacings and expand + # under tension -- so real_space=True. _strain_tensor reduces both this and the + # reciprocal-space Bragg path to F.T, so the two strain maps agree. + real_space: bool = True + def __init__( self, dataset: Dataset4dstem, input_data: Any | None = None, _token: object | None = None, ): + """Private constructor; use :meth:`from_dataset` or :meth:`from_array`. + + Direct instantiation is blocked by the ``_token`` guard. The factory + classmethods are the supported entry points and document their arguments. + + Parameters + ---------- + dataset : Dataset4dstem + The 4D-STEM dataset to analyze. + input_data : Any, optional + The original object passed to a factory (kept for provenance). + _token : object, optional + Internal sentinel; must match the class token or a ``RuntimeError`` is + raised. + """ if _token is not self._token: raise RuntimeError( "Use StrainMapAutocorrelation.from_dataset() or StrainMapAutocorrelation.from_array() to instantiate this class." ) - super().__init__() + # Explicit (two-arg) super() rather than the bare super(): the zero-arg form + # needs a compiler-created __class__ closure cell that is absent when this + # method's source is re-exec'd from a string (Jupyter autoreload), which would + # raise "super(): __class__ cell not found". + super(StrainMapAutocorrelation, self).__init__() self.dataset = dataset self.input_data = input_data self.strain = None @@ -47,16 +116,34 @@ def __init__( self.mask_diffraction = np.ones(self.dataset.array.shape[2:]) self.mask_diffraction_inv = np.zeros(self.dataset.array.shape[2:]) + # initial basis from choose_lattice_vector(); per-position fits from + # fit_lattice_vectors(); per-position weight from create_mask(). + self.u: np.ndarray | None = None + self.v: np.ndarray | None = None + self.u_peak_fit: Dataset3d | None = None + self.v_peak_fit: Dataset3d | None = None self.u_ref: np.ndarray | None = None self.v_ref: np.ndarray | None = None self.u_array: np.ndarray | None = None self.v_array: np.ndarray | None = None - self.mask: np.ndarray | None = None - - self.real_space = True + self.mask_weight: np.ndarray | None = None @classmethod def from_dataset(cls, dataset: Dataset4dstem, *, name: str | None = None) -> "StrainMapAutocorrelation": + """Create a cepstral strain workflow bound to a 4D-STEM dataset. + + Parameters + ---------- + dataset : Dataset4dstem + The 4D-STEM dataset to analyze. + name : str, optional + If given, sets ``dataset.name``. + + Returns + ------- + StrainMapAutocorrelation + A new workflow instance bound to ``dataset``. + """ if not isinstance(dataset, Dataset4dstem): raise TypeError("StrainMapAutocorrelation.from_dataset expects a Dataset4dstem instance.") if name is not None: @@ -65,6 +152,20 @@ def from_dataset(cls, dataset: Dataset4dstem, *, name: str | None = None) -> "St @classmethod def from_array(cls, array: NDArray, *, name: str = "strain_map_autocorrelation") -> "StrainMapAutocorrelation": + """Create a cepstral strain workflow from a raw 4D array. + + Parameters + ---------- + array : np.ndarray + 4D-STEM data with shape ``(scan_row, scan_col, dp_row, dp_col)``. + name : str, default="strain_map_autocorrelation" + Name for the wrapped :class:`Dataset4dstem`. + + Returns + ------- + StrainMapAutocorrelation + A new workflow instance wrapping the data in a :class:`Dataset4dstem`. + """ arr = ensure_valid_array(array) if arr.ndim != 4: raise ValueError( @@ -80,6 +181,37 @@ def diffraction_mask( plot_mask=True, figsize=(8, 4), ): + """Build a soft detector mask suppressing the central beam and vacuum. + + Pixels of the mean diffraction pattern below ``threshold`` (plus the detector + border) are treated as "outside" the useful signal. The kept region is feathered + with a raised-cosine taper of width ``edge_blend`` (via a Euclidean distance + transform) into :attr:`mask_diffraction` (multiplicative, in ``[0, 1]``), and a + complementary fill :attr:`mask_diffraction_inv` replaces the masked region with a + flat edge intensity. Both are applied to every pattern in :meth:`preprocess` and + :meth:`fit_lattice_vectors` so the transform is dominated by the crystalline + signal rather than the bright unscattered beam. + + Parameters + ---------- + threshold : float, optional + Mean-intensity level below which detector pixels are masked out. Required; + choose it from the mean diffraction pattern (e.g. just above the vacuum + level). + edge_blend : float, default=64.0 + Feather width in pixels of the raised-cosine taper between kept and masked + regions; larger values give a softer transition. + plot_mask : bool, default=True + If ``True``, show the raw mean pattern beside the masked/filled pattern (log + scaled) so the mask can be checked. + figsize : tuple of float, default=(8, 4) + Figure size in inches for the diagnostic plot. + + Returns + ------- + StrainMapAutocorrelation + ``self``, with :attr:`mask_diffraction` and :attr:`mask_diffraction_inv` set. + """ dp_mean = np.mean(self.dataset.array, axis=(0, 1)) mask_init = dp_mean < threshold mask_init[:, 0] = True @@ -125,6 +257,54 @@ def preprocess( gamma: float = 0.5, **plot_kwargs: Any, ) -> "StrainMapAutocorrelation": + """Transform every pattern into the autocorrelation domain and average it. + + For each diffraction pattern the masked/filled pattern (from + :meth:`diffraction_mask`) is intensity-scaled per ``mode``, Fourier transformed, + and its magnitude accumulated; the mean over all scan positions is stored + (fft-shifted, origin centered) as :attr:`transform`. A display copy rotated by + the detector->scan rotation is stored as :attr:`transform_rotated`. The crystal + periodicity appears as a lattice of peaks about the center, which later steps fit. + + The detector->scan rotation (``q_to_r_rotation_ccw_deg``) and transpose + (``q_transpose``) default to the parent dataset metadata -- the same source used + by the DPC/CoM and :class:`BraggVectors` workflows -- so the strain frame is + consistent across methods; pass them explicitly to override. + + Parameters + ---------- + mode : {"linear", "log", "gamma"}, default="linear" + Intensity scaling applied before the FFT. ``"linear"`` is the Patterson / + autocorrelation (aliases ``"patterson"``, ``"acf"``, ``"autocorrelation"``); + ``"log"`` is the cepstrum, ``log1p(I)`` (aliases ``"cepstrum"``, + ``"cepstral"``); ``"gamma"`` raises intensity to the power ``gamma`` + (aliases ``"power"``, ``"sqrt"``). + q_to_r_rotation_ccw_deg : float, optional + Counter-clockwise detector->scan rotation in degrees for the rotated display + transform. Defaults to ``dataset.metadata["q_to_r_rotation_ccw_deg"]`` if + present, else ``0`` (with a warning). + q_transpose : bool, optional + Whether to transpose the detector axes before rotating the display transform. + Defaults to ``dataset.metadata["q_transpose"]`` if present, else ``False``. + skip : int, optional + If given, subsample the scan by this stride (``array[::skip, ::skip]``) when + building the mean transform -- a fast preview over fewer patterns. + plot_transform : bool, default=True + If ``True``, show the original and rotated mean transforms via + :meth:`plot_transform`. + cropping_factor : float, default=0.25 + Fraction of the transform width/height shown when ``plot_transform`` is + ``True`` (the lattice peaks sit near the center). + gamma : float, default=0.5 + Exponent for ``mode="gamma"`` (ignored otherwise). + **plot_kwargs + Forwarded to :meth:`plot_transform`. + + Returns + ------- + StrainMapAutocorrelation + ``self``, with :attr:`transform` and :attr:`transform_rotated` set. + """ mode_in = mode.strip().lower() if mode_in in {"linear", "patterson", "paterson", "acf", "autocorrelation"}: mode_norm = "linear" @@ -202,7 +382,7 @@ def preprocess( q_to_r_rotation_ccw_deg = 0.0 if q_to_r_rotation_ccw_deg is None else q_to_r_rotation_ccw_deg q_transpose = False if q_transpose is None else q_transpose warnings.warn( - "StrainMapPatterson.preprocess: setting q_to_r_rotation_ccw_deg=0.0 and q_transpose=False.", + "StrainMapAutocorrelation.preprocess: setting q_to_r_rotation_ccw_deg=0.0 and q_transpose=False.", UserWarning, ) @@ -259,6 +439,29 @@ def plot_transform( scalebar_fraction: float = 0.25, **plot_kwargs: Any, ): + """Show the original and rotated mean transform images side by side. + + The color range is set from the brightest lattice peak (the global max of the + radially weighted transform) so the central DC peak does not wash out the panel, + and both panels are cropped to the central ``cropping_factor`` window where the + lattice peaks lie. A scale bar is drawn in real-space units. + + Parameters + ---------- + cropping_factor : float, default=0.25 + Fraction of the full transform width/height shown about the center. + scalebar_fraction : float, default=0.25 + Target scale-bar length as a fraction of the cropped view width (snapped to + a "nice" round value). + **plot_kwargs + Forwarded to :func:`~quantem.core.visualization.show_2d` (overriding the + defaults computed here). + + Returns + ------- + tuple + ``(fig, ax)`` from :func:`~quantem.core.visualization.show_2d`. + """ if self.transform is None or self.transform_rotated is None: raise ValueError("Run preprocess() first to compute transform images.") @@ -306,6 +509,31 @@ def plot_single_transform( scalebar_fraction: float = 0.25, **plot_kwargs: Any, ): + """Show the transform of a single scan position with its fitted lattice vectors. + + Recomputes the transform for the pattern at ``(row, col)`` using the current + ``mode``, and overlays the per-position fitted ``u``/``v`` vectors (from + :meth:`fit_lattice_vectors`) plus any detected peaks. Useful for inspecting the + fit quality at a specific position. + + Parameters + ---------- + row : int, default=0 + Scan row index of the pattern to transform. + col : int, default=0 + Scan column index of the pattern to transform. + cropping_factor : float, default=0.25 + Fraction of the full transform width/height shown about the center. + scalebar_fraction : float, default=0.25 + Target scale-bar length as a fraction of the cropped view width. + **plot_kwargs + Forwarded to :func:`~quantem.core.visualization.show_2d`. + + Returns + ------- + None + Draws the figure; nothing is returned. + """ if self.transform is None or self.transform_rotated is None: raise ValueError("Run preprocess() first to compute transform images.") if self.u_peak_fit is None or self.v_peak_fit is None: @@ -393,6 +621,57 @@ def choose_lattice_vector( cropping_factor: float = 0.25, **plot_kwargs: Any, ) -> "StrainMapAutocorrelation": + """Refine a hand-picked initial lattice basis against the mean transform. + + Takes an approximate basis ``(u, v)`` -- read off the transform plot by eye -- + and refines each vector to the nearest transform peak, storing the result in + :attr:`u` and :attr:`v`. These seed the per-position fit in + :meth:`fit_lattice_vectors`. Vectors are ``(row, col)`` offsets from the + transform center, in transform pixels. + + Parameters + ---------- + u : tuple of float or np.ndarray + Initial first lattice vector ``(d_row, d_col)`` relative to the center. + v : tuple of float or np.ndarray + Initial second lattice vector ``(d_row, d_col)`` relative to the center. + define_in_rotated : bool, default=False + If ``True``, ``u``/``v`` are given in the rotated (display) frame and are + converted back to the raw detector frame before fitting. + refine_gaussian : bool, default=True + If ``True``, refine each peak by a 2D isotropic Gaussian fit; otherwise use + the parabolic sub-pixel estimate only. + refine_dft : bool, default=False + If ``True``, additionally refine by DFT upsampling (uses ``upsample``). + refine_all_peaks : bool, default=False + If ``True``, auto-detect all peaks above ``threshold_percentile`` and fit + the basis to the full set by weighted least squares (storing the detected + peaks/weights for reuse), rather than refining only ``u`` and ``v``. + refine_radius_px : float, default=2.0 + Half-width in pixels of the window used for sub-pixel/Gaussian refinement. + upsample : int, default=16 + DFT upsampling factor used when ``refine_dft=True``. + gaussian_maxfev : int, default=100 + Maximum function evaluations for the Gaussian fit. + threshold_percentile : float, default=0.9975 + Intensity percentile (0--1) above which local maxima are kept as peaks when + ``refine_all_peaks=True``. + min_peak_spacing : float, default=0 + Minimum spacing in pixels between accepted peaks when + ``refine_all_peaks=True`` (0 disables the spacing filter). + plot : bool, default=True + If ``True``, show the transform with the refined ``u``/``v`` overlaid. + cropping_factor : float, default=0.25 + Fraction of the transform shown about the center when ``plot=True``. + **plot_kwargs + Forwarded to :meth:`plot_transform`. + + Returns + ------- + StrainMapAutocorrelation + ``self``, with :attr:`u` and :attr:`v` set (and the detected peaks/weights + when ``refine_all_peaks=True``). + """ if self.transform is None or self.transform_rotated is None: raise ValueError("Run preprocess() first to compute transform images.") @@ -462,6 +741,42 @@ def fit_lattice_vectors( gaussian_maxfev: int = 100, progressbar: bool = True, ) -> "StrainMapAutocorrelation": + """Fit the lattice vectors at every scan position (the heavy step). + + For each scan position the masked/filled pattern is transformed (same ``mode`` + as :meth:`preprocess`) and the lattice basis is refined from the + :meth:`choose_lattice_vector` seed ``(self.u, self.v)``. The fitted vectors are + written to :attr:`u_array`/:attr:`v_array` (shape ``(scan_row, scan_col, 2)``, + row/col components) for :meth:`calculate_strain_map`; the full fit records + (position, amplitude, width, background) are kept in :attr:`u_peak_fit`/ + :attr:`v_peak_fit` (shape ``(scan_row, scan_col, 5)``). + + Parameters + ---------- + refine_gaussian : bool, default=True + If ``True``, refine each peak with a 2D isotropic Gaussian fit; otherwise + use the parabolic sub-pixel estimate only. + refine_dft : bool, default=False + If ``True``, additionally refine by DFT upsampling (uses ``upsample``). + refine_all_peaks : bool, default=False + If ``True``, fit the basis to all peaks detected in + :meth:`choose_lattice_vector` (which must have been called with + ``refine_all_peaks=True``) rather than just ``u`` and ``v``. + refine_radius_px : float, default=2.0 + Half-width in pixels of the window used for sub-pixel/Gaussian refinement. + upsample : int, default=16 + DFT upsampling factor used when ``refine_dft=True``. + gaussian_maxfev : int, default=100 + Maximum function evaluations for each Gaussian fit. + progressbar : bool, default=True + If ``True``, show a tqdm progress bar over the scan positions. + + Returns + ------- + StrainMapAutocorrelation + ``self``, with :attr:`u_array`, :attr:`v_array`, :attr:`u_peak_fit`, and + :attr:`v_peak_fit` set. + """ if self.u is None or self.v is None: raise ValueError("Run choose_lattice_vector() first to set initial lattice vectors (self.u, self.v).") if refine_all_peaks: @@ -552,25 +867,62 @@ def create_mask( max_threshold: float = 0.6, exclusion_radius_fraction: float = 0.1, smooth: bool = True, - ): + ): + """Compute the per-position weight :attr:`mask_weight` from lattice signal. + + Builds a ``(scan_row, scan_col)`` weight in ``[0, 1]`` measuring how much + crystalline signal each position carries, the analogue of + :attr:`BraggVectors.mask_weight`. It is the default reference weighting handed to + :meth:`calculate_strain_map`, so strong, well-fit positions dominate the + reference lattice and weak/vacuum positions are down-weighted. Two estimators are + available, both rescaled by the ``(min_threshold, max_threshold)`` window (values + below ``min`` -> 0, above ``max`` -> 1) and optionally smoothed. + + Parameters + ---------- + use_radial_method : bool, default=False + If ``True``, weight by the total diffracted intensity *outside* a central + disk (radius ``exclusion_radius_fraction`` of the detector width), excluding + the bright unscattered beam. If ``False`` (default), weight by the mean + fitted peak amplitude from :meth:`fit_lattice_vectors` (requires it to have + been run). + min_threshold : float, default=0.4 + Lower edge of the rescaling window (as a fraction of the max signal); weights + at or below this map to 0. + max_threshold : float, default=0.6 + Upper edge of the rescaling window; weights at or above this map to 1. + exclusion_radius_fraction : float, default=0.1 + Central-disk radius (fraction of detector width) excluded by the radial + method. + smooth : bool, default=True + If ``True``, apply a ``sin^2`` easing to the rescaled weight for a smoother + ramp. + + Returns + ------- + StrainMapAutocorrelation + ``self``, with :attr:`mask_weight` set. + """ if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") - + scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] - self.mask = np.ones(self.dataset.shape[:2]) - + self.mask_weight = np.ones(self.dataset.shape[:2]) + i0_sum_array = np.empty(shape=(scan_r, scan_c)) if use_radial_method: - center_y, center_x = np.array(self.dataset.shape[:-2]) / 2 + # center / radius are in DETECTOR coordinates (the last two axes) + center_y = self.dataset.shape[-2] / 2.0 + center_x = self.dataset.shape[-1] / 2.0 y, x = np.ogrid[:self.dataset.shape[-2], :self.dataset.shape[-1]] radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] + outside_mask = radius_map > exclusion_radius for r in range(scan_r): for c in range(scan_c): - dp = self.dataset.array[r, c] - outside_mask = radius_map > exclusion_radius + dp = self.dataset.array[r, c] i0_sum_array[r, c] = np.sum(dp[outside_mask]) else: if self.u_peak_fit is None or self.v_peak_fit is None: @@ -578,50 +930,75 @@ def create_mask( u_amplitudes = self.u_peak_fit.array[:, :, 2] v_amplitudes = self.v_peak_fit.array[:, :, 2] i0_sum_array = (u_amplitudes + v_amplitudes) / 2.0 - + max_intensity = np.max(i0_sum_array) if max_intensity == 0: - return np.ones_like(i0_sum_array) - self.mask = i0_sum_array / max_intensity - self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) + self.mask_weight = np.ones_like(i0_sum_array) + return self + self.mask_weight = i0_sum_array / max_intensity + self.mask_weight = np.clip( + (self.mask_weight - min_threshold) / (max_threshold - min_threshold), 0, 1 + ) if smooth: - self.mask = np.sin(np.pi / 2 * self.mask) ** 2 + self.mask_weight = np.sin(np.pi / 2 * self.mask_weight) ** 2 return self - def initialize_strain_class( + def calculate_strain_map( self, u_ref: np.ndarray | None = None, v_ref: np.ndarray | None = None, - )->StrainMap: + mask: np.ndarray | None = None, + ) -> StrainMap: + """Build a :class:`StrainMap` from the fitted per-position lattice vectors. + + Mirrors :meth:`BraggVectors.calculate_strain_map` so the downstream strain cells + (``plot_strain``, ``update_reference``, ``estimate_strain_precision``) are + identical for the correlation and cepstral workflows. Because the cepstral + vectors are real-space, ``real_space=True`` is passed and + :func:`~quantem.diffraction.strain._strain_tensor` yields strain matching the + correlation result on the same data. + + Parameters + ---------- + u_ref : np.ndarray, optional + ``(2,)`` reference for the first lattice vector. Defaults to the median over + the scan inside :class:`StrainMap`. + v_ref : np.ndarray, optional + ``(2,)`` reference for the second lattice vector. Defaults to the median over + the scan inside :class:`StrainMap`. + mask : np.ndarray, optional + ``(scan_row, scan_col)`` per-position weighting used when computing the + reference lattice. Defaults to :attr:`mask_weight` from :meth:`create_mask` + (the lattice signal strength), so strong, well-fit positions dominate the + reference. + + Returns + ------- + StrainMap + A strain map initialized from the fitted lattice vectors. + """ if self.u_array is None or self.v_array is None: - raise RuntimeWarning("Need to run fit_lattice_vectors before initializing strain class") + raise ValueError("Run fit_lattice_vectors() before calculate_strain_map().") if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") - default_units = None - default_sampling = None - if hasattr(self.dataset, 'units'): - if isinstance(self.dataset.units, (tuple, list)): - default_units = str(self.dataset.units[0]) - else: - default_units = str(self.dataset.units) - if hasattr(self.dataset, 'sampling'): - if isinstance(self.dataset.sampling, (tuple, list, np.ndarray)): - default_sampling = float(self.dataset.sampling[0]) - else: - default_sampling = float(self.dataset.sampling) + if mask is None: + mask = self.mask_weight + + ds_sampling = float(self.dataset.sampling[0]) + ds_units = str(self.dataset.units[0]) return StrainMap( - u_array = self.u_array, - v_array = self.v_array, - ds_shape = self.dataset.shape, - real_space = self.real_space, - u_ref = u_ref, - v_ref = v_ref, - mask = self.mask, - ds_sampling=default_sampling, - ds_units = default_units, + u_array=self.u_array, + v_array=self.v_array, + ds_shape=tuple(self.dataset.shape), + real_space=self.real_space, + u_ref=u_ref, + v_ref=v_ref, + mask=mask, + ds_sampling=ds_sampling, + ds_units=ds_units, ) def plot_lattice_vectors( @@ -633,6 +1010,37 @@ def plot_lattice_vectors( figsize: tuple[float, float] | None = None, **imshow_kwargs: Any, ): + """Plot the four per-position lattice-vector component maps. + + Shows ``u_row``, ``u_col``, ``v_row``, ``v_col`` from :attr:`u_array`/ + :attr:`v_array` as four panels (optionally with the mean subtracted), on a + shared symmetric color range. Positions whose vectors deviate from the + :meth:`choose_lattice_vector` seed by more than ``max_shift`` are masked out, so + bad fits do not blow up the color scale. A quick diagnostic of fit smoothness + before computing strain. + + Parameters + ---------- + subtract_mean : bool, default=True + If ``True``, subtract the (in-range) mean of each component so deviations + from the average lattice are shown. + max_shift : float, default=1.0 + Maximum allowed deviation (pixels) of a vector from the seed before its + position is masked out of the plot and the color-range statistics. + cmap : str, default="PiYG_r" + Diverging colormap; masked positions are drawn black. + axsize : tuple of float, optional + Per-panel size in inches; defaults to ``(4, 4)`` when ``figsize`` is unset. + figsize : tuple of float, optional + Overall figure size in inches; defaults to ``(4 * axsize[0], axsize[1])``. + **imshow_kwargs + Forwarded to :meth:`matplotlib.axes.Axes.imshow`. + + Returns + ------- + tuple + ``(fig, ax)`` with the four-panel figure. + """ if self.u_array is None or self.v_array is None: raise ValueError("Run fit_lattice_vectors() first to compute u_array and v_array.") if self.u is None or self.v is None: @@ -716,6 +1124,7 @@ def plot_lattice_vectors( def _nice_length_units(target: float) -> float: + """Round ``target`` to the nearest "nice" scale-bar length (1/2/5 x 10^n).""" if not np.isfinite(target) or target <= 0: return 0.0 exp = np.floor(np.log10(target)) @@ -732,6 +1141,10 @@ def _nice_length_units(target: float) -> float: def _apply_center_crop_limits(ax: Any, shape: tuple[int, int], cropping_factor: float) -> None: + """Zoom ``ax`` to the central ``cropping_factor`` fraction of a ``shape`` image. + + Preserves the existing y-axis direction (inverted for image coordinates). + """ if cropping_factor >= 1.0: return if not (0.0 < cropping_factor <= 1.0): @@ -753,6 +1166,7 @@ def _apply_center_crop_limits(ax: Any, shape: tuple[int, int], cropping_factor: def _flatten_axes(ax: Any) -> list[Any]: + """Flatten a matplotlib axes container (array/list/tuple) to a flat list of axes.""" if isinstance(ax, np.ndarray): return list(ax.ravel()) if isinstance(ax, (list, tuple)): @@ -764,6 +1178,11 @@ def _flatten_axes(ax: Any) -> list[Any]: def _raw_vec_to_display(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: bool) -> NDArray: + """Map a raw-detector ``(row, col)`` vector into the rotated display frame. + + Applies the optional axis transpose, then a counter-clockwise rotation of + ``rotation_ccw_deg``. Inverse of :func:`_display_vec_to_raw`. + """ v = np.asarray(vec_rc, dtype=float).reshape(2) dr, dc = v[0], v[1] @@ -780,6 +1199,10 @@ def _raw_vec_to_display(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: def _display_vec_to_raw(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: bool) -> NDArray: + """Map a rotated-display ``(row, col)`` vector back to the raw detector frame. + + Inverse of :func:`_raw_vec_to_display`: undo the rotation, then the transpose. + """ v = np.asarray(vec_rc, dtype=float).reshape(2) dr, dc = v[0], v[1] @@ -797,6 +1220,7 @@ def _display_vec_to_raw(vec_rc: NDArray, *, rotation_ccw_deg: float, transpose: def _plot_lattice_vectors(ax: Any, center_rc: tuple[float, float], u_rc: NDArray, v_rc: NDArray) -> None: + """Draw the ``u`` (red) and ``v`` (cyan) lattice vectors from ``center_rc`` on ``ax``.""" r0, c0 = center_rc def _draw(vec: NDArray, label: str, color: tuple[float, float, float]) -> None: @@ -809,6 +1233,7 @@ def _draw(vec: NDArray, label: str, color: tuple[float, float, float]) -> None: _draw(np.asarray(v_rc, dtype=float).reshape(2), "v", (0.0, 0.7, 1.0)) def _plot_peaks(ax: Any, center_rc: tuple[float, float], peaks_plot: NDArray) -> None: + """Mark each detected peak (green dot), offset from ``center_rc``, on ``ax``.""" r0, c0 = center_rc def _draw(vec: NDArray, color: tuple[float, float, float]) -> None: @@ -828,6 +1253,11 @@ def _overlay_lattice_vectors( q_transpose: bool, peaks_plot: NDArray | None = None, ) -> None: + """Overlay lattice vectors on the original (and, if present, rotated) transform axes. + + Draws ``u``/``v`` (and any ``peaks_plot``) on the first axis in raw coordinates, and + on the second axis (if any) in the rotated display frame. + """ axs = _flatten_axes(ax) if not axs: return @@ -846,6 +1276,11 @@ def _overlay_lattice_vectors( def _parabolic_vertex_delta(v_m1: float, v_0: float, v_p1: float) -> float: + """Sub-pixel vertex offset (in ``[-1, 1]``) of a parabola through three samples. + + Given values at offsets ``-1, 0, +1``, returns the offset of the parabola's extremum + from the center sample; ``0`` for a degenerate (flat/non-finite) fit. + """ denom = v_m1 - 2.0 * v_0 + v_p1 if denom == 0 or not np.isfinite(denom): return 0.0 @@ -862,6 +1297,11 @@ def _refine_peak_subpixel( c_guess: float, radius_px: float = 2.0, ) -> tuple[float, float]: + """Refine a peak near ``(r_guess, c_guess)`` to sub-pixel ``(row, col)``. + + Finds the brightest pixel in a ``radius_px`` window, then applies independent + parabolic vertex offsets along each axis. + """ im = np.asarray(im, dtype=float) H, W = im.shape @@ -904,6 +1344,31 @@ def _refine_peak_subpixel_dft( c0: float, upsample: int, ) -> tuple[float, float]: + """Refine a peak location to subpixel precision via local DFT upsampling. + + Uses a matrix-multiply DFT (the Guizar-Sicairos upsampled cross-correlation + trick) to evaluate the image's Fourier interpolant on a fine grid in a small + neighborhood around the initial estimate, then locates the maximum of that + upsampled patch with a 3-point parabolic vertex refinement. This avoids + interpolating the whole image and is accurate to roughly ``1 / upsample`` of a + pixel. + + Parameters + ---------- + im : NDArray + 2D real image (a transform panel) whose peak is being refined. + r0, c0 : float + Initial peak estimate in pixel coordinates (row, column). If ``r0`` or + ``c0`` is a torch tensor it is converted to a Python float. + upsample : int + DFT upsampling factor. Values ``<= 1`` skip refinement and return the + input estimate unchanged; larger values give finer subpixel resolution. + + Returns + ------- + tuple[float, float] + The refined ``(row, column)`` peak location in pixel coordinates. + """ if upsample <= 1: return r0, c0 @@ -955,6 +1420,62 @@ def _refine_lattice_vectors( threshold_percentile: float = 0.9975, min_peak_spacing: float = 0, ) -> tuple[NDArray, NDArray, NDArray, NDArray]: + """Refine the two lattice vectors of a transform panel to subpixel precision. + + Starting from integer-pixel guesses for the ``u`` and ``v`` lattice vectors + (expressed as row/column offsets from the panel center), this locates the + corresponding autocorrelation/cepstral peaks and refines them with up to + three successively finer stages: a 3-point parabolic vertex estimate, an + isotropic 2D Gaussian least-squares fit, and DFT upsampling. When + ``refine_all_peaks`` is set, all bright peaks are detected and the lattice + basis is recovered by an intensity-weighted least-squares fit to the full + peak lattice instead of refining only the two seed vectors. + + Parameters + ---------- + im : NDArray + 2D real transform panel (Patterson/cepstral image) to fit. + u_rc, v_rc : NDArray + Length-2 initial lattice vectors as ``(row, column)`` offsets relative to + the panel center. + radius_px : float, optional + Half-width (in pixels) of the fitting window used for the Gaussian fit. + Default 2.0. + refine_gaussian : bool, optional + If True (default), refine each peak with an isotropic 2D Gaussian fit. + refine_dft : bool, optional + If True, follow the Gaussian fit with DFT-upsampled refinement + (requires ``upsample > 1``). Default False. + refine_all_peaks : bool, optional + If True, detect every bright peak and solve a weighted least-squares fit + for the lattice basis rather than refining only ``u_rc`` and ``v_rc``. + Default False. + peaks : NDArray or None, optional + Precomputed peak positions (row/col offsets from center) to use when + ``refine_all_peaks`` is set; if None they are detected automatically. + weights : NDArray or None, optional + Precomputed peak weights paired with ``peaks``; if None they are derived + from peak amplitudes. + upsample : int, optional + DFT upsampling factor for ``refine_dft``. Default 16. + maxfev : int, optional + Maximum function evaluations for the Gaussian ``curve_fit``. Default 100. + threshold_percentile : float, optional + Fractional intensity percentile (0-1) used as the peak-detection + threshold when auto-detecting peaks. Default 0.9975. + min_peak_spacing : float, optional + Minimum allowed spacing (in pixels) between detected peaks; ``0`` (default) + disables the spacing filter. + + Returns + ------- + tuple[NDArray, NDArray, NDArray, NDArray] + ``(u_result, v_result, pts, weights)``. ``u_result`` and ``v_result`` are + length-5 arrays ``(row_offset, col_offset, amplitude, sigma, background)`` + giving the refined lattice vectors relative to the panel center. ``pts`` + and ``weights`` are the detected peak positions and their normalized + weights when ``refine_all_peaks`` is True, otherwise both are ``None``. + """ from scipy.optimize import curve_fit im = np.asarray(im, dtype=float) diff --git a/src/quantem/diffraction/strain_visualization.py b/src/quantem/diffraction/strain_visualization.py index 609ef6d4..310084af 100644 --- a/src/quantem/diffraction/strain_visualization.py +++ b/src/quantem/diffraction/strain_visualization.py @@ -166,35 +166,53 @@ def _roi_compose(norm_vals, color_cm): bold=scalebar_config.bold, ) + cb_size = 0.02 + cb_pad = 0.02 + + def _finalize_layout(): + # set_aspect("equal") only resizes/recenters each panel at draw time, so + # get_position() before a draw returns stale boxes -- placing the colorbars + # and g-vector compass off those boxes then spills them off the figure. + # Settle the layout cheaply first so every box read below is the real one. + try: + fig.draw_without_rendering() + except AttributeError: # matplotlib < 3.5 + fig.canvas.draw() + if is_horizontal: - fig.subplots_adjust(left=0.04, right=0.98, top=0.90, bottom=0.16, wspace=0.05) + # Reserve a bottom band wide enough for the colorbar + its tick labels and + # title (fontsize 16) and a right band for the rotation-panel gap; widen the + # right band when the g-vector compass is drawn in it. These keep the figure + # usable when saved "as is" (no bbox_inches='tight'). + right = 0.78 if plot_gvecs else 0.93 + fig.subplots_adjust(left=0.04, right=right, top=0.88, bottom=0.24, wspace=0.05) if plot_rotation: + # nudge the rotation panel right for a visual gap from the strain panels; + # 0.03 stays inside the reserved right band so nothing is clipped. pos3 = ax[3].get_position() ax[3].set_position([pos3.x0 + 0.03, pos3.y0, pos3.width, pos3.height]) + _finalize_layout() cb_orientation = "horizontal" - cb_size = 0.02 - cb_pad = 0.02 - b0 = ax[0].get_position() b2 = ax[2].get_position() - strain_cb_pos = [b0.x0, b0.y0 - cb_pad - cb_size, b2.x1 - b0.x0, cb_size] + cb_y = b2.y0 - cb_pad - cb_size + strain_cb_pos = [b0.x0, cb_y, b2.x1 - b0.x0, cb_size] if plot_rotation: b3 = ax[3].get_position() - rot_cb_pos = [b3.x0, b0.y0 - cb_pad - cb_size, b3.x1 - b3.x0, cb_size] + rot_cb_pos = [b3.x0, cb_y, b3.x1 - b3.x0, cb_size] last_pos = b3 else: rot_cb_pos = None last_pos = b2 else: - fig.subplots_adjust(left=0.04, right=0.80, top=0.98, bottom=0.04, hspace=0.15) + # Top band for the panel titles, right band for the vertical colorbars + labels. + fig.subplots_adjust(left=0.04, right=0.80, top=0.92, bottom=0.06, hspace=0.15) + _finalize_layout() cb_orientation = "vertical" - cb_size = 0.02 - cb_pad = 0.02 - b0 = ax[0].get_position() b2 = ax[2].get_position() strain_cb_pos = [b0.x1 + cb_pad, b2.y0, cb_size, b0.y1 - b2.y0] @@ -229,16 +247,16 @@ def _roi_compose(norm_vals, color_cm): print("Warning: u_ref and v_ref not found. Call fit_strain() first.") return fig, ax + # The compass goes in the reserved margin beside the last panel; clamp its + # right edge to 0.99 so it never spills off the figure when saved "as is". if is_horizontal: - ref_width = last_pos.width * 0.8 - ref_left = last_pos.x1 - 0.035 + ref_left = last_pos.x1 + 0.005 + ref_width = min(last_pos.width, 0.99 - ref_left) ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) else: - ref_height = last_pos.height * 0.5 - ref_bottom = last_pos.y0 - ref_height - 0.05 - ref_width = last_pos.width * 0.8 - ref_left = last_pos.x0 + (last_pos.width - ref_width) / 2 - ref_ax = fig.add_axes([ref_left, ref_bottom, ref_width, ref_height]) + ref_left = min(last_pos.x1 + 0.18, 0.74) + ref_width = min(last_pos.width, 0.99 - ref_left) + ref_ax = fig.add_axes([ref_left, last_pos.y0, ref_width, last_pos.height]) ref_ax.set_xlim(-1.5, 1.5) ref_ax.set_ylim(-1.5, 1.5) From d21b50ad69d7039a6239fb3669d014d68aed607c Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 1 Jun 2026 15:01:54 +0200 Subject: [PATCH 83/88] updates to cepstral pipeline --- src/quantem/diffraction/bragg_vectors.py | 25 +- .../bragg_vectors_visualization.py | 22 +- src/quantem/diffraction/strain.py | 94 ++- .../diffraction/strain_autocorrelation.py | 624 ++++++++++++++---- 4 files changed, 602 insertions(+), 163 deletions(-) diff --git a/src/quantem/diffraction/bragg_vectors.py b/src/quantem/diffraction/bragg_vectors.py index aceb1910..fccbdaf8 100644 --- a/src/quantem/diffraction/bragg_vectors.py +++ b/src/quantem/diffraction/bragg_vectors.py @@ -959,13 +959,14 @@ def show_template( position : tuple of int, default=(0, 0) ``(row, col)`` scan position whose correlation map is shown. crop_factor : float, optional - If given, zoom all three panels to a square window of half-width - ``crop_factor * radius`` about the pattern center, where ``radius`` is - the central-beam radius (the synthetic template radius if known, else - estimated from the mean diffraction pattern). For example, - ``crop_factor=2.0`` shows two beam radii either side of the center. The - window is clamped to the detector, so a large factor shows the full - image. ``None`` (default) shows the full panels. + If given, zoom to a square window of half-width ``crop_factor * radius`` + about the central-beam center, where ``radius`` is the central-beam + radius (the synthetic template radius if known, else estimated from the + mean diffraction pattern). The mean-diffraction and correlation panels are + centered on the beam; the template panel on its own (fftshifted) center. + For example, ``crop_factor=2.0`` shows two beam radii either side of the + center. The window is clamped to the detector, so a large factor shows the + full image. ``None`` (default) shows the full panels. returnfig : bool, default=False If ``True``, return the ``(fig, ax)`` for further customization. **kwargs @@ -984,11 +985,15 @@ def show_template( crop = None if crop_factor is not None: - H, W = int(self.dataset.shape[-2]), int(self.dataset.shape[-1]) + # Center the mean-diffraction and correlation panels on the actual + # central-beam position rather than the geometric center (H//2, W//2): + # the unscattered beam -- and the correlation peak that matches it -- are + # generally offset by a few pixels from the detector center. + center, radius_est = estimate_central_beam(dp_mean) radius = self.metadata.get("template", {}).get("radius") if radius is None: - _, radius = estimate_central_beam(dp_mean) - crop = (H // 2, W // 2, float(crop_factor) * float(radius)) + radius = radius_est + crop = (float(center[0]), float(center[1]), float(crop_factor) * float(radius)) fig, ax = plot_template( dp_mean, diff --git a/src/quantem/diffraction/bragg_vectors_visualization.py b/src/quantem/diffraction/bragg_vectors_visualization.py index 891f2e72..047927bc 100644 --- a/src/quantem/diffraction/bragg_vectors_visualization.py +++ b/src/quantem/diffraction/bragg_vectors_visualization.py @@ -26,10 +26,13 @@ def plot_template( position : tuple of int ``(row, col)`` scan position the correlation map was computed at. crop : tuple of float, optional - ``(center_row, center_col, half_width)`` zoom window (in pixels) applied to - all three panels. The view spans ``half_width`` either side of the center, - clamped to each image's bounds, so an over-large ``half_width`` just shows - the full image. ``None`` (default) shows the full panels. + ``(center_row, center_col, half_width)`` zoom window (in pixels). The mean + diffraction and correlation panels are centered on ``(center_row, + center_col)`` -- the central-beam position -- while the template panel is + centered on its own array center (it is displayed fftshifted to there). The + view spans ``half_width`` either side of the center, clamped to each image's + bounds, so an over-large ``half_width`` just shows the full image. ``None`` + (default) shows the full panels. figsize : tuple of float, default=(13, 4) Figure size in inches. @@ -50,10 +53,15 @@ def plot_template( a.set_yticks([]) if crop is not None: cr, cc, hw = float(crop[0]), float(crop[1]), float(crop[2]) - for a, img in zip(ax, (dp_mean, template, corr_map)): + th, tw = template.shape[:2] + # The mean-diffraction beam and the correlation peak sit at the beam center + # (cr, cc); the displayed template is fftshifted to its own array center, so + # zoom that panel about its center instead. + panel_centers = ((cr, cc), (th / 2.0, tw / 2.0), (cr, cc)) + for a, img, (ecr, ecc) in zip(ax, (dp_mean, template, corr_map), panel_centers): h, w = img.shape[:2] - a.set_xlim(max(cc - hw, -0.5), min(cc + hw, w - 0.5)) - a.set_ylim(min(cr + hw, h - 0.5), max(cr - hw, -0.5)) + a.set_xlim(max(ecc - hw, -0.5), min(ecc + hw, w - 0.5)) + a.set_ylim(min(ecr + hw, h - 0.5), max(ecr - hw, -0.5)) fig.tight_layout() return fig, ax diff --git a/src/quantem/diffraction/strain.py b/src/quantem/diffraction/strain.py index 649746c1..637fb4d8 100644 --- a/src/quantem/diffraction/strain.py +++ b/src/quantem/diffraction/strain.py @@ -92,8 +92,21 @@ def __init__( self.ds_sampling = 1.0 if ds_sampling is None else ds_sampling self.ds_units = "pixels" if ds_units is None else ds_units - self.mask = np.ones(ds_shape[:2]) if mask is None else mask - self.mask = (self.mask - np.min(self.mask)) / np.max(self.mask) + # Per-position weighting / ROI in [0, 1]. The mask producers + # (BraggVectors.fit_lattice, StrainMapAutocorrelation.create_mask) already emit a + # [0, 1] weight, so a well-formed mask is taken as-is: re-normalizing it here + # would collide with that scaling -- a near-constant mask (e.g. the radial + # cepstral weight) would be squashed to ~0 and blank the strain display. Only a + # mask that falls outside [0, 1] (e.g. a raw-intensity ROI) is rescaled, and a + # constant / empty / all-NaN mask falls back to uniform full weight. + m = np.ones(ds_shape[:2], dtype=float) if mask is None else np.asarray(mask, dtype=float) + m_lo = np.nanmin(m) + m_hi = np.nanmax(m) + if not (np.isfinite(m_lo) and np.isfinite(m_hi)) or m_hi <= m_lo: + m = np.ones_like(m) + elif m_lo < 0.0 or m_hi > 1.0: + m = (m - m_lo) / (m_hi - m_lo) + self.mask = m # user-supplied reference vectors persist across re-fits (None = use median) self._u_ref_fixed = None if u_ref is None else np.asarray(u_ref, dtype=float) @@ -434,13 +447,17 @@ def estimate_strain_precision( component : {"combined","e_uu","e_vv","e_uv","rotation"}, default="combined" Which error distribution to histogram. bins : int, default=50 - Number of histogram bins (or a sequence of bin edges). + Number of histogram bins, or a sequence of explicit bin edges. With a + bin *count* and no ``bounds``, the range defaults to ``[0, weighted 99th + percentile]`` of the trusted deviations -- robust to the heavy outlier + tail, which otherwise sets the range to its max and crushes the bulk into + the first bin. Passing explicit edges (or ``bounds``) overrides this. bounds : tuple of float, optional ``(low, high)`` histogram range in display units (percent for strain, - degrees for rotation). Fix it to compare datasets on the same axis. Values - outside the range are left out of the bars (no overflow spike); the median - is computed from all trusted positions regardless, and - ``out_of_range_fraction`` records how much was off-range. + degrees for rotation). Fix it to compare datasets on the same axis, or to + see the full tail. Values outside the range are left out of the bars (no + overflow spike); the median is computed from all trusted positions + regardless, and ``out_of_range_fraction`` records how much was off-range. plot : bool, default=True If ``True``, draw the weighted precision histogram. returnfig : bool, default=False @@ -531,6 +548,17 @@ def _weighted_median(err_native: np.ndarray, factor: float) -> float: use = np.isfinite(e) & valid # trusted positions only, consistent with median e_f = e[use] w_f = scaled[use] + # Default histogram range: a robust weighted upper percentile, NOT the raw + # max. A handful of bad-fit positions can reach tens of percent; used as the + # range they crush the entire bulk into the first bin and leave the rest of + # the axis empty (a spurious "spike at 0" plus a far outlier spike). Capping + # at the weighted 99th percentile keeps the common error values readable; the + # few positions past it spill into out_of_range_fraction (reported, not + # drawn). An explicit `bounds`, or passing bin EDGES as `bins`, overrides it. + if bounds is None and np.ndim(bins) == 0 and e_f.size and float(w_f.sum()) > 0: + hi_default = _weighted_quantile(e_f, w_f, 0.99) + if np.isfinite(hi_default) and hi_default > 0: + bounds = (0.0, hi_default) edges = np.histogram_bin_edges(e_f, bins=bins, range=bounds) lo, hi = float(edges[0]), float(edges[-1]) wtot = float(w_f.sum()) @@ -662,7 +690,18 @@ def _reference_lattice( mask: np.ndarray | None = None, strain_mask: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray]: - """Median reference lattice vectors over an ROI / mask, else the global median. + """Weighted-median reference lattice vectors, else the global median. + + The reference is the per-component **weighted median** of the lattice vectors. + Weights come from ``strain_mask`` if given, else the continuous ``mask`` + (the ``[0, 1]`` per-position weight from :meth:`create_mask` / ``fit_lattice``): + strong, well-indexed positions dominate the reference and weak / vacuum / bad-fit + positions are down-weighted. A boolean ROI (weights in ``{0, 1}``) reduces to the + plain median over the selected positions, so an explicit ``strain_mask`` behaves + as before. The weighted median (not ``mask == 1``) is used because a continuous + weight rarely hits *exactly* 1 -- the old exact-equality test collapsed a min-max + normalized mask to its single global-max position and made the reference one + arbitrary pixel. Parameters ---------- @@ -671,10 +710,10 @@ def _reference_lattice( v_array : np.ndarray Per-position second lattice vector, shape ``(scan_row, scan_col, 2)``. mask : np.ndarray, optional - ``(scan_row, scan_col)`` ROI; positions equal to 1 are included. Used when - ``strain_mask`` is not given. + ``(scan_row, scan_col)`` per-position weight in ``[0, 1]``. Used as the median + weights when ``strain_mask`` is not given. strain_mask : np.ndarray, optional - ``(scan_row, scan_col)`` ROI taking precedence over ``mask``. + ``(scan_row, scan_col)`` ROI / weight taking precedence over ``mask``. Returns ------- @@ -682,20 +721,29 @@ def _reference_lattice( ``(u_ref, v_ref)``, each a length-2 reference vector. """ if strain_mask is not None: - m = np.asarray(strain_mask == 1, dtype=bool) + w = np.asarray(strain_mask, dtype=float).reshape(-1) elif mask is not None: - m = np.asarray(mask == 1, dtype=bool) + w = np.asarray(mask, dtype=float).reshape(-1) else: - m = None - - # nan-median: positions fit_lattice could not fit are NaN and must be ignored, - # otherwise the reference (and hence the whole strain map) collapses to NaN. - if m is None or not m.any(): - u_ref = np.nanmedian(u_array.reshape(-1, 2), axis=0) - v_ref = np.nanmedian(v_array.reshape(-1, 2), axis=0) - else: - u_ref = np.array((np.nanmedian(u_array[m, 0]), np.nanmedian(u_array[m, 1])), dtype=float) - v_ref = np.array((np.nanmedian(v_array[m, 0]), np.nanmedian(v_array[m, 1])), dtype=float) + w = None + + u_flat = u_array.reshape(-1, 2) + v_flat = v_array.reshape(-1, 2) + + def _wmed(vals: np.ndarray) -> float: + # weighted median over finite, positively-weighted positions; positions + # fit_lattice could not fit are NaN and must be dropped, else the reference + # (and the whole strain map) collapses to NaN. Falls back to the unweighted + # nan-median when no weight is given or none survives. + finite = np.isfinite(vals) + ww = np.ones_like(vals) if w is None else w + use = finite & (ww > 0) + if not use.any(): + return float(np.nanmedian(vals)) if finite.any() else float("nan") + return _weighted_quantile(vals[use], ww[use], 0.5) + + u_ref = np.array((_wmed(u_flat[:, 0]), _wmed(u_flat[:, 1])), dtype=float) + v_ref = np.array((_wmed(v_flat[:, 0]), _wmed(v_flat[:, 1])), dtype=float) return u_ref, v_ref diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index 61c0c1da..c65e08c4 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -177,6 +177,7 @@ def from_array(cls, array: NDArray, *, name: str = "strain_map_autocorrelation") def diffraction_mask( self, threshold=None, + threshold_percentile=50.0, edge_blend=64.0, plot_mask=True, figsize=(8, 4), @@ -195,9 +196,14 @@ def diffraction_mask( Parameters ---------- threshold : float, optional - Mean-intensity level below which detector pixels are masked out. Required; - choose it from the mean diffraction pattern (e.g. just above the vacuum - level). + Absolute mean-intensity level below which detector pixels are masked out. If + ``None`` (default), the level is taken from ``threshold_percentile`` of the + mean diffraction pattern, so no manual intensity value is needed. + threshold_percentile : float, default=50.0 + Percentile (``0``-``100``) of the mean diffraction pattern used to set the + masking level when ``threshold`` is ``None``. The default keeps the brighter + half of the detector (disks and central beam) and masks the dimmer vacuum; + raise it to mask more aggressively. Ignored when ``threshold`` is given. edge_blend : float, default=64.0 Feather width in pixels of the raised-cosine taper between kept and masked regions; larger values give a softer transition. @@ -213,6 +219,8 @@ def diffraction_mask( ``self``, with :attr:`mask_diffraction` and :attr:`mask_diffraction_inv` set. """ dp_mean = np.mean(self.dataset.array, axis=(0, 1)) + if threshold is None: + threshold = np.percentile(dp_mean, threshold_percentile) mask_init = dp_mean < threshold mask_init[:, 0] = True mask_init[0, :] = True @@ -740,6 +748,7 @@ def fit_lattice_vectors( upsample: int = 16, gaussian_maxfev: int = 100, progressbar: bool = True, + device: str = "cpu", ) -> "StrainMapAutocorrelation": """Fit the lattice vectors at every scan position (the heavy step). @@ -751,6 +760,19 @@ def fit_lattice_vectors( (position, amplitude, width, background) are kept in :attr:`u_peak_fit`/ :attr:`v_peak_fit` (shape ``(scan_row, scan_col, 5)``). + Whenever ``refine_dft=False`` (including ``refine_all_peaks=True``) the transforms + and the isotropic-Gaussian peak fits are batched across scan positions on + ``device`` (the analogue of the correlation pipeline's + :func:`~quantem.diffraction.disk_detection.detect_disks_batch`): the Gaussian fit + is a vectorized Levenberg-Marquardt solve rather than a per-position + ``scipy.optimize.curve_fit``. For ``refine_all_peaks=True`` every detected peak is + batch-refined across the stack and the basis is solved per position by weighted + least squares -- avoiding the ``n_positions x n_peaks`` ``curve_fit`` calls of the + old loop. This removes the dominant per-call overhead and reproduces the + per-position result to ~1e-6 px, so the fitted vectors are unchanged while the + step runs far faster (and faster still on a GPU). Only ``refine_dft=True`` falls + back to the per-position path. + Parameters ---------- refine_gaussian : bool, default=True @@ -800,56 +822,73 @@ def fit_lattice_vectors( self.u_array = np.zeros((scan_r, scan_c, 2)) self.v_array = np.zeros((scan_r, scan_c, 2)) - mode = self.metadata.get("mode", "linear").lower() - if mode == "gamma": - g = self.metadata["gamma"] - - it = np.ndindex(scan_r, scan_c) - if progressbar: - try: - from tqdm.auto import tqdm # type: ignore - - it = tqdm(it, total=scan_r * scan_c, desc="fit_lattice_vectors", leave=True) - except Exception: - pass - u0 = np.asarray(self.u, dtype=float).reshape(2) v0 = np.asarray(self.v, dtype=float).reshape(2) - - for r, c in it: - dp = self.dataset.array[r, c] * self.mask_diffraction + self.mask_diffraction_inv - - if mode == "linear": - im = np.fft.fftshift(np.abs(np.fft.fft2(dp))) - elif mode == "log": - im = np.fft.fftshift(np.abs(np.fft.fft2(np.log1p(dp)))) - elif mode == "gamma": - im = np.fft.fftshift(np.abs(np.fft.fft2(np.power(np.clip(dp, 0.0, None), g)))) - else: - raise ValueError("metadata['mode'] must be 'linear', 'log', or 'gamma'") - - u_fit_abs, v_fit_abs, _, _ = _refine_lattice_vectors( - im, - u_rc=u0, - v_rc=v0, - radius_px=refine_radius_px, + if not refine_dft: + # Fast path: batch the transforms and isotropic-Gaussian fits across scan + # positions on `device` (per-position scipy.optimize.curve_fit -> vectorized + # LM). Covers both the 2-vector fit and the all-peaks basis fit; reproduces + # the per-position result to ~1e-6 px. Only DFT upsampling still falls back. + self._fit_lattice_vectors_batched( + u0=u0, + v0=v0, refine_gaussian=refine_gaussian, - refine_dft=refine_dft, + refine_radius_px=refine_radius_px, + device=device, + progressbar=progressbar, refine_all_peaks=refine_all_peaks, - peaks=self.mean_img_peaks, - weights=self.mean_img_weights, - upsample=upsample, - maxfev=gaussian_maxfev, + peaks=self.mean_img_peaks if refine_all_peaks else None, + weights=self.mean_img_weights if refine_all_peaks else None, ) - - self.u_peak_fit.array[r, c, :] = u_fit_abs - self.v_peak_fit.array[r, c, :] = v_fit_abs - - self.u_array[r, c, 0] = u_fit_abs[0] - self.u_array[r, c, 1] = u_fit_abs[1] - self.v_array[r, c, 0] = v_fit_abs[0] - self.v_array[r, c, 1] = v_fit_abs[1] + else: + # Per-position fallback for DFT upsampling (refine_dft=True). + mode = self.metadata.get("mode", "linear").lower() + if mode == "gamma": + g = self.metadata["gamma"] + + it = np.ndindex(scan_r, scan_c) + if progressbar: + try: + from tqdm.auto import tqdm # type: ignore + + it = tqdm(it, total=scan_r * scan_c, desc="fit_lattice_vectors", leave=True) + except Exception: + pass + + for r, c in it: + dp = self.dataset.array[r, c] * self.mask_diffraction + self.mask_diffraction_inv + + if mode == "linear": + im = np.fft.fftshift(np.abs(np.fft.fft2(dp))) + elif mode == "log": + im = np.fft.fftshift(np.abs(np.fft.fft2(np.log1p(dp)))) + elif mode == "gamma": + im = np.fft.fftshift(np.abs(np.fft.fft2(np.power(np.clip(dp, 0.0, None), g)))) + else: + raise ValueError("metadata['mode'] must be 'linear', 'log', or 'gamma'") + + u_fit_abs, v_fit_abs, _, _ = _refine_lattice_vectors( + im, + u_rc=u0, + v_rc=v0, + radius_px=refine_radius_px, + refine_gaussian=refine_gaussian, + refine_dft=refine_dft, + refine_all_peaks=refine_all_peaks, + peaks=self.mean_img_peaks, + weights=self.mean_img_weights, + upsample=upsample, + maxfev=gaussian_maxfev, + ) + + self.u_peak_fit.array[r, c, :] = u_fit_abs + self.v_peak_fit.array[r, c, :] = v_fit_abs + + self.u_array[r, c, 0] = u_fit_abs[0] + self.u_array[r, c, 1] = u_fit_abs[1] + self.v_array[r, c, 0] = v_fit_abs[0] + self.v_array[r, c, 1] = v_fit_abs[1] self.metadata["fit_refine_gaussian"] = refine_gaussian self.metadata["fit_refine_dft"] = refine_dft @@ -858,45 +897,225 @@ def fit_lattice_vectors( self.metadata["fit_upsample"] = upsample self.metadata["fit_gaussian_maxfev"] = gaussian_maxfev + # Populate the per-position weight directly from the fitted peak amplitudes, + # mirroring the correlation pipeline where BraggVectors.fit_lattice emits + # mask_weight. create_mask() is therefore optional -- call it only to plot the + # weight map, recompute, or switch to the radial estimator. + self.mask_weight = self._amplitude_mask_weight() + return self + def _fit_lattice_vectors_batched( + self, + *, + u0: NDArray, + v0: NDArray, + refine_gaussian: bool, + refine_radius_px: float, + device: str, + progressbar: bool, + refine_all_peaks: bool = False, + peaks: NDArray | None = None, + weights: NDArray | None = None, + ) -> None: + """Batched torch implementation of the non-DFT :meth:`fit_lattice_vectors` path. + + The transform (mask/fill -> ``mode`` -> ``|FFT|`` -> fftshift) and the + parabolic/isotropic-Gaussian peak refinement are evaluated for a stack of scan + positions at once on ``device``, the analogue of the correlation pipeline's + :func:`~quantem.diffraction.disk_detection.detect_disks_batch`. This reproduces + :func:`_refine_lattice_vectors` (with ``refine_dft=False``) to ~1e-6 px while + removing the per-position ``scipy.optimize.curve_fit`` overhead. Results are + written into :attr:`u_array`/:attr:`v_array` and :attr:`u_peak_fit`/ + :attr:`v_peak_fit`. + + With ``refine_all_peaks=False`` only the two seed vectors ``u0``/``v0`` are + refined per position. With ``refine_all_peaks=True`` every peak in ``peaks`` (the + basis detected by :meth:`choose_lattice_vector`, weighted by ``weights``) is + batch-refined across the stack and the basis is recovered per position by the + same intensity-weighted least-squares fit as the per-position all-peaks branch -- + so that path is batched too rather than looping ``scipy.optimize.curve_fit`` over + ``n_positions x n_peaks``. + """ + scan_r, scan_c, H, W = self.dataset.array.shape + n_pos = scan_r * scan_c + rcent, ccent = H // 2, W // 2 + + mode = self.metadata.get("mode", "linear").lower() + if mode not in ("linear", "log", "gamma"): + raise ValueError("metadata['mode'] must be 'linear', 'log', or 'gamma'") + gamma = float(self.metadata["gamma"]) if mode == "gamma" else None + + dev = torch.device(device) + mask = torch.as_tensor(self.mask_diffraction, dtype=torch.float64, device=dev) + mask_inv = torch.as_tensor(self.mask_diffraction_inv, dtype=torch.float64, device=dev) + + if refine_all_peaks: + # Precompute the fixed pieces of the per-position all-peaks fit (these do not + # change across scan positions): the detected peak seeds, their normalized + # weights, and the seed basis used to assign integer (h, k) indices. + peaks_arr = np.asarray(peaks, dtype=float).reshape(-1, 2) + w = np.asarray(weights, dtype=float).reshape(-1) + wsum = float(w.sum()) + w_norm = w / wsum if wsum != 0 else np.full(w.shape, 1.0 / max(1, w.size)) + sqrt_w = np.sqrt(w_norm)[:, None] + A_seed = np.column_stack((u0, v0)) # (2, 2) + n_pk = peaks_arr.shape[0] + + # Match the correlation chunking heuristic (see BraggVectors._detect_positions). + batch_size = int(min(1024, max(1, 16_000_000 // (H * W)))) + + starts = range(0, n_pos, batch_size) + if progressbar: + try: + from tqdm.auto import tqdm # type: ignore + + bar = tqdm(total=n_pos, desc="fit_lattice_vectors", leave=True) + except Exception: + bar = None + else: + bar = None + + for start in starts: + stop = min(start + batch_size, n_pos) + idxs = [(idx // scan_c, idx % scan_c) for idx in range(start, stop)] + + dps = torch.stack( + [ + torch.as_tensor( + np.asarray(self.dataset.array[r, c]), dtype=torch.float64, device=dev + ) + for r, c in idxs + ], + dim=0, + ) + dpm = dps * mask + mask_inv + if mode == "linear": + tr = dpm + elif mode == "log": + tr = torch.log1p(dpm) + else: # gamma + tr = dpm.clamp(min=0.0).pow(gamma) + ims = torch.fft.fftshift(torch.fft.fft2(tr).abs(), dim=(-2, -1)) + + if refine_all_peaks: + # Batch-refine each detected peak across the stack (one vectorized LM + # solve per peak), then recover the basis per position with the same + # weighted least squares as _refine_lattice_vectors' all-peaks branch. + pts_all = np.empty((len(idxs), n_pk, 2), dtype=float) + for j in range(n_pk): + rj = _refine_peaks_batched( + ims, peaks_arr[j], radius_px=refine_radius_px, + refine_gaussian=refine_gaussian, + ).cpu().numpy() + pts_all[:, j, :] = rj[:, :2] + ims_np = ims.cpu().numpy() + for k, (r, c) in enumerate(idxs): + pts = pts_all[k] # (n_pk, 2) row/col offsets from center + ab = np.round(np.linalg.lstsq(A_seed, pts.T, rcond=None)[0]).T + M = np.ones((n_pk, 3)) + M[:, :2] = ab # integer (h, k) indices + constant (center) column + uvc = np.linalg.lstsq(M * sqrt_w, pts * sqrt_w, rcond=None)[0] # (3, 2) + u_ref, v_ref = uvc[0], uvc[1] + amp_u = _parabolic_peak_rc_amp(ims_np[k], rcent + u_ref[0], ccent + u_ref[1])[2] + amp_v = _parabolic_peak_rc_amp(ims_np[k], rcent + v_ref[0], ccent + v_ref[1])[2] + self.u_peak_fit.array[r, c, :] = (u_ref[0], u_ref[1], amp_u, 0.0, 0.0) + self.v_peak_fit.array[r, c, :] = (v_ref[0], v_ref[1], amp_v, 0.0, 0.0) + self.u_array[r, c, 0] = u_ref[0] + self.u_array[r, c, 1] = u_ref[1] + self.v_array[r, c, 0] = v_ref[0] + self.v_array[r, c, 1] = v_ref[1] + else: + u_np = _refine_peaks_batched( + ims, u0, radius_px=refine_radius_px, refine_gaussian=refine_gaussian + ).cpu().numpy() + v_np = _refine_peaks_batched( + ims, v0, radius_px=refine_radius_px, refine_gaussian=refine_gaussian + ).cpu().numpy() + for k, (r, c) in enumerate(idxs): + self.u_peak_fit.array[r, c, :] = u_np[k] + self.v_peak_fit.array[r, c, :] = v_np[k] + self.u_array[r, c, 0] = u_np[k, 0] + self.u_array[r, c, 1] = u_np[k, 1] + self.v_array[r, c, 0] = v_np[k, 0] + self.v_array[r, c, 1] = v_np[k, 1] + + if bar is not None: + bar.update(len(idxs)) + + if bar is not None: + bar.close() + + def _amplitude_mask_weight(self) -> np.ndarray: + """Per-position weight from the fitted u/v peak amplitudes, min-max to ``[0, 1]``. + + The mean of the two fitted lattice-peak amplitudes is the cepstral order + parameter (how much crystalline signal a position carries). It is min-max + normalized to ``[0, 1]`` with no contrast windowing -- the honest weight; set + display contrast later via :meth:`StrainMap.plot_strain`'s ``mask_range``. + Degenerate input (constant / non-finite / amplitudes unavailable) falls back to + uniform full weight. + """ + scan_r = self.dataset.shape[0] + scan_c = self.dataset.shape[1] + if self.u_peak_fit is None or self.v_peak_fit is None: + return np.ones((scan_r, scan_c)) + signal = (self.u_peak_fit.array[:, :, 2] + self.v_peak_fit.array[:, :, 2]) / 2.0 + lo = np.nanmin(signal) + hi = np.nanmax(signal) + if np.isfinite(lo) and np.isfinite(hi) and hi > lo: + return (signal - lo) / (hi - lo) + return np.ones((scan_r, scan_c)) + def create_mask( self, use_radial_method: bool = False, - min_threshold: float = 0.4, - max_threshold: float = 0.6, exclusion_radius_fraction: float = 0.1, - smooth: bool = True, + plot: bool = True, + figsize: tuple[float, float] = (5, 4), ): - """Compute the per-position weight :attr:`mask_weight` from lattice signal. + """(Re)compute the per-position weight :attr:`mask_weight` from lattice signal. + + Usually **optional**: :meth:`fit_lattice_vectors` already populates + :attr:`mask_weight` from the fitted peak amplitudes (mirroring the correlation + pipeline, where ``BraggVectors.fit_lattice`` emits ``mask_weight``). Call this + only to plot the weight map, to recompute it, or to switch to the radial + estimator. Builds a ``(scan_row, scan_col)`` weight in ``[0, 1]`` measuring how much crystalline signal each position carries, the analogue of :attr:`BraggVectors.mask_weight`. It is the default reference weighting handed to :meth:`calculate_strain_map`, so strong, well-fit positions dominate the - reference lattice and weak/vacuum positions are down-weighted. Two estimators are - available, both rescaled by the ``(min_threshold, max_threshold)`` window (values - below ``min`` -> 0, above ``max`` -> 1) and optionally smoothed. + reference lattice and weak/vacuum positions are down-weighted. + + The raw signal is min-max normalized to ``[0, 1]`` with **no** contrast windowing + or smoothing: this is the honest per-position order parameter. Set the display + contrast later, in one place, via :meth:`StrainMap.plot_strain`'s ``mask_range`` + argument (e.g. ``mask_range=(0.6, 0.8)``) -- weights at/below ``low`` render + black, at/above ``high`` render full color. (Min-max is sensitive to a few very + bright positions, which compress the bulk toward 0; pick ``mask_range`` to match + where the bulk actually sits -- the weight-map plot here shows it.) Parameters ---------- use_radial_method : bool, default=False If ``True``, weight by the total diffracted intensity *outside* a central disk (radius ``exclusion_radius_fraction`` of the detector width), excluding - the bright unscattered beam. If ``False`` (default), weight by the mean - fitted peak amplitude from :meth:`fit_lattice_vectors` (requires it to have - been run). - min_threshold : float, default=0.4 - Lower edge of the rescaling window (as a fraction of the max signal); weights - at or below this map to 0. - max_threshold : float, default=0.6 - Upper edge of the rescaling window; weights at or above this map to 1. + the bright unscattered beam. Use a **small** exclusion (~0.1) so the Bragg + disks are kept: a large exclusion keeps only the far-corner diffuse scatter, + which is *anti*-correlated with crystallinity and inverts the contrast. If + ``False`` (default, recommended), weight by the mean fitted peak amplitude + from :meth:`fit_lattice_vectors` (requires it to have been run) -- the direct + cepstral order parameter (identical to the weight ``fit_lattice_vectors`` + stores automatically). exclusion_radius_fraction : float, default=0.1 Central-disk radius (fraction of detector width) excluded by the radial method. - smooth : bool, default=True - If ``True``, apply a ``sin^2`` easing to the rescaled weight for a smoother - ramp. + plot : bool, default=True + If ``True``, show the resulting per-position weight map (the analogue of the + correlation pipeline's :meth:`BraggVectors.fit_lattice` plot). + figsize : tuple of float, default=(5, 4) + Figure size in inches for the weight-map plot. Returns ------- @@ -908,9 +1127,6 @@ def create_mask( scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] - self.mask_weight = np.ones(self.dataset.shape[:2]) - - i0_sum_array = np.empty(shape=(scan_r, scan_c)) if use_radial_method: # center / radius are in DETECTOR coordinates (the last two axes) @@ -920,27 +1136,32 @@ def create_mask( radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] outside_mask = radius_map > exclusion_radius + signal = np.empty(shape=(scan_r, scan_c)) for r in range(scan_r): for c in range(scan_c): dp = self.dataset.array[r, c] - i0_sum_array[r, c] = np.sum(dp[outside_mask]) + signal[r, c] = np.sum(dp[outside_mask]) + # honest min-max normalization to [0, 1]; constant/degenerate -> ones + lo = np.nanmin(signal) + hi = np.nanmax(signal) + if np.isfinite(lo) and np.isfinite(hi) and hi > lo: + self.mask_weight = (signal - lo) / (hi - lo) + else: + self.mask_weight = np.ones((scan_r, scan_c)) else: if self.u_peak_fit is None or self.v_peak_fit is None: raise RuntimeError("For intensity-based masking, run fit_lattice_vectors() first.") - u_amplitudes = self.u_peak_fit.array[:, :, 2] - v_amplitudes = self.v_peak_fit.array[:, :, 2] - i0_sum_array = (u_amplitudes + v_amplitudes) / 2.0 + self.mask_weight = self._amplitude_mask_weight() + + if plot: + fig, ax = plt.subplots(1, 1, figsize=figsize) + handle = ax.imshow(self.mask_weight, cmap="gray", vmin=0.0, vmax=1.0) + ax.set_title("Per-position lattice weight (mask_weight)") + ax.set_xlabel("scan column") + ax.set_ylabel("scan row") + fig.colorbar(handle, ax=ax, fraction=0.046, pad=0.04) + fig.tight_layout() - max_intensity = np.max(i0_sum_array) - if max_intensity == 0: - self.mask_weight = np.ones_like(i0_sum_array) - return self - self.mask_weight = i0_sum_array / max_intensity - self.mask_weight = np.clip( - (self.mask_weight - min_threshold) / (max_threshold - min_threshold), 0, 1 - ) - if smooth: - self.mask_weight = np.sin(np.pi / 2 * self.mask_weight) ** 2 return self @@ -1290,6 +1511,46 @@ def _parabolic_vertex_delta(v_m1: float, v_0: float, v_p1: float) -> float: return np.clip(delta, -1.0, 1.0) +def _parabolic_peak_rc_amp(im: NDArray, r_guess: float, c_guess: float) -> tuple[float, float, float]: + """3-point parabolic sub-pixel peak near ``(r_guess, c_guess)`` and its amplitude. + + Snaps to the brightest pixel in the 3x3 window around the (absolute-coordinate) + guess, refines row and column independently by a parabolic vertex, and returns + ``(r_sub, c_sub, amp)`` -- the sub-pixel peak position and the image value at the + rounded vertex. Shared by the per-position :func:`_refine_lattice_vectors` and the + batched :meth:`StrainMapAutocorrelation._fit_lattice_vectors_batched` so they agree + exactly. + """ + H, W = im.shape + r0 = int(np.clip(int(np.round(r_guess)), 0, H - 1)) + c0 = int(np.clip(int(np.round(c_guess)), 0, W - 1)) + win = im[max(0, r0 - 1) : min(H, r0 + 2), max(0, c0 - 1) : min(W, c0 + 2)] + if win.size == 0: + return r_guess, c_guess, 0.0 + + ir, ic = np.unravel_index(np.argmax(win), win.shape) + r_peak = max(0, r0 - 1) + ir + c_peak = max(0, c0 - 1) + ic + + if 0 < r_peak < H - 1: + col = im[r_peak - 1 : r_peak + 2, c_peak] + dr = _parabolic_vertex_delta(col[0], col[1], col[2]) + else: + dr = 0.0 + + if 0 < c_peak < W - 1: + row = im[r_peak, c_peak - 1 : c_peak + 2] + dc = _parabolic_vertex_delta(row[0], row[1], row[2]) + else: + dc = 0.0 + + r_sub = r_peak + dr + c_sub = c_peak + dc + r_int = int(np.clip(int(np.round(r_sub)), 0, H - 1)) + c_int = int(np.clip(int(np.round(c_sub)), 0, W - 1)) + return r_sub, c_sub, float(im[r_int, c_int]) + + def _refine_peak_subpixel( im: NDArray, *, @@ -1381,7 +1642,7 @@ def _refine_peak_subpixel_dft( up = upsample du = int(np.fix(np.ceil(1.5 * up))) - patch = np.abs(dft_upsample(F, up=up, shift=(r0, c0), device="cpu")) + patch = np.abs(dft_upsample(F, up=up, shift=(r0, c0))) patch = np.asarray(patch, dtype=float) i0, j0 = np.unravel_index(np.argmax(patch), patch.shape) @@ -1486,43 +1747,6 @@ def _refine_lattice_vectors( r_center = H // 2 c_center = W // 2 - def _parabolic_peak_rc_amp(*, r_guess: float, c_guess: float) -> tuple[float, float, float]: - r0 = int(np.clip(int(np.round(r_guess)), 0, H - 1)) - c0 = int(np.clip(int(np.round(c_guess)), 0, W - 1)) - win = im[ - max(0, r0 - 1) : min(H, r0 + 2), - max(0, c0 - 1) : min(W, c0 + 2), - ] - if win.size == 0: - return r_guess, c_guess, 0.0 - - ir, ic = np.unravel_index(np.argmax(win), win.shape) - r_peak = max(0, r0 - 1) + ir - c_peak = max(0, c0 - 1) + ic - - r_ref = r_peak - c_ref = c_peak - - if 0 < r_peak < H - 1: - col = im[r_peak - 1 : r_peak + 2, c_peak] - dr = _parabolic_vertex_delta(col[0], col[1], col[2]) - else: - dr = 0.0 - - if 0 < c_peak < W - 1: - row = im[r_peak, c_peak - 1 : c_peak + 2] - dc = _parabolic_vertex_delta(row[0], row[1], row[2]) - else: - dc = 0.0 - - r_sub = r_ref + dr - c_sub = c_ref + dc - r_int = int(np.clip(int(np.round(r_sub)), 0, H - 1)) - c_int = int(np.clip(int(np.round(c_sub)), 0, W - 1)) - amp = im[r_int, c_int] - - return r_sub, c_sub, amp - def _fit_gaussian_isotropic( *, r0: float, @@ -1599,7 +1823,7 @@ def _refine_one(vec: NDArray) -> NDArray: r_guess = r_center + vec[0] c_guess = c_center + vec[1] - r_par, c_par, amp_par = _parabolic_peak_rc_amp(r_guess=r_guess, c_guess=c_guess) + r_par, c_par, amp_par = _parabolic_peak_rc_amp(im, r_guess, c_guess) if refine_gaussian: r_fit, c_fit, amp, sig, bg = _fit_gaussian_isotropic( @@ -1684,10 +1908,164 @@ def _find_initial_peaks_weights(initial_peaks: NDArray) -> tuple[NDArray, NDArra uvr0 = np.linalg.lstsq(A_weighted, pts_weighted, rcond=None)[0] u_refined = uvr0[0,:] v_refined = uvr0[1,:] - _,_, amp_par_u = _parabolic_peak_rc_amp(r_guess=u_refined[0], c_guess=u_refined[1]) - _,_, amp_par_v = _parabolic_peak_rc_amp(r_guess=v_refined[0], c_guess=v_refined[1]) + _, _, amp_par_u = _parabolic_peak_rc_amp(im, r_center + u_refined[0], c_center + u_refined[1]) + _, _, amp_par_v = _parabolic_peak_rc_amp(im, r_center + v_refined[0], c_center + v_refined[1]) return np.array((u_refined[0], u_refined[1], amp_par_u, 0, 0), dtype=float), np.array((v_refined[0], v_refined[1], amp_par_v, 0, 0), dtype=float), pts, weights return _refine_one(u_rc), _refine_one(v_rc), None, None + +def _refine_peaks_batched( + ims: torch.Tensor, + vec: NDArray, + *, + radius_px: float, + refine_gaussian: bool, + lm_iters: int = 50, +) -> torch.Tensor: + """Batched, vectorized version of the single-image ``_refine_one`` peak refinement. + + Refines one lattice peak (located near ``center + vec``) for every transform in the + stack ``ims`` of shape ``(B, H, W)`` at once. The parabolic sub-pixel step matches + :func:`_refine_lattice_vectors`' ``_parabolic_peak_rc_amp`` exactly; when + ``refine_gaussian`` is set, the per-position ``scipy.optimize.curve_fit`` of an + isotropic 2D Gaussian is replaced by a batched Levenberg-Marquardt solve of the + identical objective and bounds. The two agree to ~1e-6 px (machine precision) but the + batched solve removes the dominant per-call overhead, so it runs far faster (and + scales onto a GPU via the tensor ``device``). + + Parameters + ---------- + ims : torch.Tensor + Stack of transformed (fftshifted ``|FFT|``) images, shape ``(B, H, W)``, float. + vec : NDArray + Lattice vector ``(row, col)`` relative to the panel center; the peak is sought + near ``(H // 2 + row, W // 2 + col)``. + radius_px : float + Half-width in pixels of the Gaussian-fit window. + refine_gaussian : bool + If ``True``, refine with the batched isotropic-Gaussian LM fit; otherwise return + the parabolic estimate only (``sigma`` and ``background`` set to 0). + lm_iters : int, default=50 + Number of Levenberg-Marquardt iterations. + + Returns + ------- + torch.Tensor + Shape ``(B, 5)``: ``(row_offset, col_offset, amplitude, sigma, background)`` with + ``row_offset``/``col_offset`` measured relative to the panel center (matching + :func:`_refine_lattice_vectors`). + """ + Bn, Hn, Wn = ims.shape + dev, dt = ims.device, ims.dtype + rcent, ccent = Hn // 2, Wn // 2 + bidx = torch.arange(Bn, device=dev) + + # Clamp the seed into the interior so the 3x3 window below is always in-bounds + # (mirrors the clamping in _parabolic_peak_rc_amp; matters only for edge peaks, + # which are interior in normal use but must not crash the batched solve). + r0 = int(np.clip(round(rcent + float(vec[0])), 1, Hn - 2)) + c0 = int(np.clip(round(ccent + float(vec[1])), 1, Wn - 2)) + + # --- 3x3 argmax around the seed (matches _parabolic_peak_rc_amp) --- + win3 = ims[:, r0 - 1 : r0 + 2, c0 - 1 : c0 + 2].reshape(Bn, -1) + am = win3.argmax(1) + r_peak = r0 - 1 + torch.div(am, 3, rounding_mode="floor") + c_peak = c0 - 1 + (am % 3) + + def _parab(vm1: torch.Tensor, v0_: torch.Tensor, vp1: torch.Tensor) -> torch.Tensor: + denom = vm1 - 2.0 * v0_ + vp1 + delta = 0.5 * (vm1 - vp1) / denom + delta = torch.where(torch.isfinite(delta), delta, torch.zeros_like(delta)) + delta = torch.where(denom == 0, torch.zeros_like(delta), delta) + return delta.clamp(-1.0, 1.0) + + v0_ = ims[bidx, r_peak, c_peak] + dr = _parab(ims[bidx, (r_peak - 1).clamp(0, Hn - 1), c_peak], v0_, ims[bidx, (r_peak + 1).clamp(0, Hn - 1), c_peak]) + dr = torch.where((r_peak > 0) & (r_peak < Hn - 1), dr, torch.zeros_like(dr)) + dc = _parab(ims[bidx, r_peak, (c_peak - 1).clamp(0, Wn - 1)], v0_, ims[bidx, r_peak, (c_peak + 1).clamp(0, Wn - 1)]) + dc = torch.where((c_peak > 0) & (c_peak < Wn - 1), dc, torch.zeros_like(dc)) + r_sub = r_peak.to(dt) + dr + c_sub = c_peak.to(dt) + dc + + if not refine_gaussian: + ri = r_sub.round().long().clamp(0, Hn - 1) + ci = c_sub.round().long().clamp(0, Wn - 1) + amp = ims[bidx, ri, ci] + zeros = torch.zeros(Bn, device=dev, dtype=dt) + return torch.stack([r_sub - rcent, c_sub - ccent, amp, zeros, zeros], 1) + + # --- isotropic Gaussian fit over a (2*rad+1)^2 window, batched LM --- + rad = int(max(1, np.ceil(radius_px))) + P = 2 * rad + 1 + r0i = r_sub.round().long() + c0i = c_sub.round().long() + off = torch.arange(-rad, rad + 1, device=dev) + rows_idx = (r0i[:, None, None] + off[None, :, None]).expand(Bn, P, P) + cols_idx = (c0i[:, None, None] + off[None, None, :]).expand(Bn, P, P) + bexp = bidx[:, None, None].expand(Bn, P, P) + win = ims[bexp, rows_idx.clamp(0, Hn - 1), cols_idx.clamp(0, Wn - 1)] + RR = rows_idx.to(dt).reshape(Bn, -1) + CC = cols_idx.to(dt).reshape(Bn, -1) + y = win.reshape(Bn, -1) + + am2 = y.argmax(1) + r_pk2 = (r0i - rad + torch.div(am2, P, rounding_mode="floor")).to(dt) + c_pk2 = (c0i - rad + (am2 % P)).to(dt) + bg0 = y.median(1).values + amp0 = (y.max(1).values - bg0).clamp(min=0.0) + sig0 = torch.full((Bn,), max(0.75, radius_px / 2.0), device=dev, dtype=dt) + p = torch.stack([r_pk2, c_pk2, amp0, sig0, bg0], 1) + + rlo = (r0i - rad).to(dt) - 0.5 + rhi = (r0i + rad).to(dt) + 0.5 + clo = (c0i - rad).to(dt) - 0.5 + chi = (c0i + rad).to(dt) + 0.5 + sig_hi = radius_px * 4.0 + + lam = torch.full((Bn,), 1e-2, device=dev, dtype=dt) + eye5 = torch.eye(5, device=dev, dtype=dt)[None] + + def _sse(p_: torch.Tensor) -> torch.Tensor: + row, col, amp, sig, bg = [p_[:, i : i + 1] for i in range(5)] + sig = sig.clamp(min=1e-9) + E = torch.exp(-((RR - row) ** 2 + (CC - col) ** 2) / (2.0 * sig * sig)) + return ((bg + amp * E - y) ** 2).sum(1) + + for _ in range(lm_iters): + row, col, amp, sig, bg = [p[:, i : i + 1] for i in range(5)] + sig = sig.clamp(min=1e-9) + d_row = RR - row + d_col = CC - col + E = torch.exp(-(d_row * d_row + d_col * d_col) / (2.0 * sig * sig)) + res = (bg + amp * E) - y + g_row = amp * E * d_row / (sig * sig) + g_col = amp * E * d_col / (sig * sig) + g_amp = E + g_sig = amp * E * ((d_row * d_row + d_col * d_col) / (sig**3)) + g_bg = torch.ones_like(E) + J = torch.stack([g_row, g_col, g_amp, g_sig, g_bg], 2) # (B, N, 5) + JT = J.transpose(1, 2) + JTJ = JT @ J + JTr = JT @ res[..., None] + diag = torch.diagonal(JTJ, dim1=1, dim2=2).clamp(min=1e-12) + A = JTJ + lam[:, None, None] * eye5 * diag[:, None, :] + delta = torch.linalg.solve(A, -JTr)[..., 0] + pn = p + delta + pn[:, 0] = pn[:, 0].clamp(rlo, rhi) + pn[:, 1] = pn[:, 1].clamp(clo, chi) + pn[:, 2] = pn[:, 2].clamp(min=0.0) + pn[:, 3] = pn[:, 3].clamp(0.25, sig_hi) + s_new = _sse(pn) + s_old = res.pow(2).sum(1) + better = s_new < s_old + p = torch.where(better[:, None], pn, p) + lam = torch.where(better, (lam * 0.5).clamp(min=1e-9), (lam * 3.0).clamp(max=1e6)) + + row, col, amp, sig, bg = [p[:, i] for i in range(5)] + ok = torch.isfinite(p).all(1) + row = torch.where(ok, row, r_sub) + col = torch.where(ok, col, c_sub) + return torch.stack([row - rcent, col - ccent, amp, sig, bg], 1) + From 836887b34dce0d14156b6b0723d442d7904f0097 Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 1 Jun 2026 15:21:40 +0200 Subject: [PATCH 84/88] fixes and speed up --- .../diffraction/strain_autocorrelation.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index c65e08c4..34d23d04 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -768,10 +768,13 @@ def fit_lattice_vectors( ``scipy.optimize.curve_fit``. For ``refine_all_peaks=True`` every detected peak is batch-refined across the stack and the basis is solved per position by weighted least squares -- avoiding the ``n_positions x n_peaks`` ``curve_fit`` calls of the - old loop. This removes the dominant per-call overhead and reproduces the - per-position result to ~1e-6 px, so the fitted vectors are unchanged while the - step runs far faster (and faster still on a GPU). Only ``refine_dft=True`` falls - back to the per-position path. + old loop. This removes the dominant per-call overhead and runs far faster (and + faster still on a GPU). The batched Levenberg-Marquardt fit reproduces the + per-position ``scipy.optimize.curve_fit`` to ~1e-6 px on clean, well-isolated + peaks; on noisy, non-Gaussian cepstral peaks the bounded-trf and LM optima can + land a few hundredths of a pixel apart (amplitude-preserving and well below + strain-relevant precision). Only ``refine_dft=True`` falls back to the + per-position path. Parameters ---------- @@ -828,8 +831,9 @@ def fit_lattice_vectors( if not refine_dft: # Fast path: batch the transforms and isotropic-Gaussian fits across scan # positions on `device` (per-position scipy.optimize.curve_fit -> vectorized - # LM). Covers both the 2-vector fit and the all-peaks basis fit; reproduces - # the per-position result to ~1e-6 px. Only DFT upsampling still falls back. + # LM). Covers both the 2-vector fit and the all-peaks basis fit; matches the + # per-position result to ~1e-6 px on clean peaks (a few 0.01 px on noisy, + # non-Gaussian peaks -- optimizer variance). Only DFT upsampling still falls back. self._fit_lattice_vectors_batched( u0=u0, v0=v0, @@ -924,8 +928,10 @@ def _fit_lattice_vectors_batched( parabolic/isotropic-Gaussian peak refinement are evaluated for a stack of scan positions at once on ``device``, the analogue of the correlation pipeline's :func:`~quantem.diffraction.disk_detection.detect_disks_batch`. This reproduces - :func:`_refine_lattice_vectors` (with ``refine_dft=False``) to ~1e-6 px while - removing the per-position ``scipy.optimize.curve_fit`` overhead. Results are + :func:`_refine_lattice_vectors` (with ``refine_dft=False``) -- to ~1e-6 px on + clean peaks, within a few 0.01 px on noisy non-Gaussian peaks (bounded-trf vs LM + optimizer variance) -- while removing the per-position + ``scipy.optimize.curve_fit`` overhead. Results are written into :attr:`u_array`/:attr:`v_array` and :attr:`u_peak_fit`/ :attr:`v_peak_fit`. @@ -1931,9 +1937,12 @@ def _refine_peaks_batched( :func:`_refine_lattice_vectors`' ``_parabolic_peak_rc_amp`` exactly; when ``refine_gaussian`` is set, the per-position ``scipy.optimize.curve_fit`` of an isotropic 2D Gaussian is replaced by a batched Levenberg-Marquardt solve of the - identical objective and bounds. The two agree to ~1e-6 px (machine precision) but the - batched solve removes the dominant per-call overhead, so it runs far faster (and - scales onto a GPU via the tensor ``device``). + identical objective and bounds. On clean, well-isolated peaks the two agree to + ~1e-6 px; on noisy, non-Gaussian peaks (e.g. a sinc-like cepstral peak on the sloped + tail of a bright neighbor) the bounded-trf and LM optima can differ by a few 0.01 px + -- amplitude-preserving and well below strain-relevant precision. The batched solve + removes the dominant per-call overhead, so it runs far faster (and scales onto a GPU + via the tensor ``device``). Parameters ---------- From 00edf7c6bb72688e1a88a1e507c6ded7bc003cc7 Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 1 Jun 2026 17:04:20 +0200 Subject: [PATCH 85/88] strain model fitting updates --- src/quantem/diffraction/model_fitting.py | 101 +++++++++++++----- .../diffraction/strain_autocorrelation.py | 49 +++++++-- 2 files changed, 111 insertions(+), 39 deletions(-) diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index ca49d294..f4b5390e 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -986,61 +986,104 @@ def render_individual_pattern(self, row, col): def create_mask( self, use_radial_method: bool = False, - min_threshold: float = 0.4, - max_threshold: float = 0.6, exclusion_radius_fraction: float = 0.1, - smooth: bool = True, + plot: bool = True, + figsize: tuple[float, float] = (5, 4), ): + """Compute the per-position weight :attr:`mask` from the fitted lattice signal. + + Builds a ``(scan_row, scan_col)`` weight in ``[0, 1]`` measuring how much + crystalline signal each position carries -- the model-fitting analogue of + :attr:`BraggVectors.mask_weight` and the cepstral ``mask_weight``. It is handed to + :meth:`initialize_strain_class`, so strong, well-fit positions dominate the + reference lattice and weak/vacuum positions are down-weighted. + + The raw signal (summed intensity of the non-central fitted disks, or the + diffracted intensity outside a central disk for the radial method) is normalized + to ``[0, 1]`` with **no** contrast windowing or smoothing: this is the honest + per-position order parameter. Set the display contrast later, in one place, via + the ``mask_range`` argument of :meth:`StrainMap.plot_strain`, + :meth:`StrainMap.update_reference`, and :meth:`StrainMap.estimate_strain_precision` + (e.g. ``mask_range=(0.37, 0.5)``) -- weights at/below ``low`` render black, at/above + ``high`` render full color. The weight-map plot here shows where the bulk sits so + an appropriate ``mask_range`` can be chosen. + + Parameters + ---------- + use_radial_method : bool, default=False + If ``True``, weight by the total diffracted intensity *outside* a central + disk (radius ``exclusion_radius_fraction`` of the detector width). If + ``False`` (default, recommended), weight by the summed intensity of the + fitted non-central lattice disks -- requires + :meth:`fit_individual_diffraction_pattern` to have been run. + exclusion_radius_fraction : float, default=0.1 + Central-disk radius (fraction of detector width) excluded by the radial + method. + plot : bool, default=True + If ``True``, show the resulting per-position weight map (so a separate + ``plt.imshow(mask)`` cell is unnecessary). + figsize : tuple of float, default=(5, 4) + Figure size in inches for the weight-map plot. + + Returns + ------- + ModelDiffraction + ``self``, with :attr:`mask` set. + """ + if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): + raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] - self.mask = np.zeros(self.dataset.shape[:2]) - - self.i0_sum_array = np.empty(shape=(scan_r, scan_c)) - - if self.state_individual_refined is None: - raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") - if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): - raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") + self.i0_sum_array = np.zeros(shape=(scan_r, scan_c)) if use_radial_method: center_y, center_x = np.array(self.dataset.shape[:-2]) / 2 - y, x = np.ogrid[:self.dataset.shape[-2], :self.dataset.shape[-1]] - radius_map = np.sqrt((x - center_x)**2 + (y - center_y)**2) + y, x = np.ogrid[: self.dataset.shape[-2], : self.dataset.shape[-1]] + radius_map = np.sqrt((x - center_x) ** 2 + (y - center_y) ** 2) exclusion_radius = exclusion_radius_fraction * self.dataset.shape[-1] + outside_mask = radius_map > exclusion_radius for r in range(scan_r): for c in range(scan_c): - dp = self.dataset.array[r, c] - outside_mask = radius_map > exclusion_radius - self.i0_sum_array[r, c] = np.sum(dp[outside_mask]) + self.i0_sum_array[r, c] = np.sum(self.dataset.array[r, c][outside_mask]) else: + if self.state_individual_refined is None: + raise RuntimeError("Call .fit_individual_diffraction_pattern(...) first.") for r in range(scan_r): for c in range(scan_c): pos_state = self.state_individual_refined[r, c] if pos_state is None: - self.i0_sum_array[r, c] = 0.0 continue - i0_raw = None uv_indices = None for key in pos_state.keys(): - if key.endswith('i0_raw'): + if key.endswith("i0_raw"): i0_raw = pos_state[key].cpu().numpy() - if key.endswith('uv_indices'): + if key.endswith("uv_indices"): uv_indices = pos_state[key].cpu().numpy() - if i0_raw is None or uv_indices is None: - self.i0_sum_array[r, c] = 0.0 continue - is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) + + # honest [0, 1] normalization, no contrast windowing (set mask_range at display) max_intensity = np.max(self.i0_sum_array) - if max_intensity == 0: - return np.zeros_like(self.i0_sum_array) - self.mask = self.i0_sum_array / max_intensity - self.mask = np.clip((self.mask - min_threshold) / (max_threshold - min_threshold), 0, 1) - if smooth: - self.mask = np.sin(np.pi / 2 * self.mask) ** 2 + if max_intensity > 0: + self.mask = self.i0_sum_array / max_intensity + else: + self.mask = np.zeros_like(self.i0_sum_array) + + if plot: + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(1, 1, figsize=figsize) + handle = ax.imshow(self.mask, cmap="gray", vmin=0.0, vmax=1.0) + ax.set_title("Per-position lattice weight (mask)") + ax.set_xlabel("scan column") + ax.set_ylabel("scan row") + fig.colorbar(handle, ax=ax, fraction=0.046, pad=0.04) + fig.tight_layout() + return self def initialize_strain_class( diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index 34d23d04..502c89bc 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -941,7 +941,13 @@ def _fit_lattice_vectors_batched( batch-refined across the stack and the basis is recovered per position by the same intensity-weighted least-squares fit as the per-position all-peaks branch -- so that path is batched too rather than looping ``scipy.optimize.curve_fit`` over - ``n_positions x n_peaks``. + ``n_positions x n_peaks``. In both cases the amplitude/width/background stored in + :attr:`u_peak_fit`/:attr:`v_peak_fit` (columns 2-4, the source of the mask weight) + come from the single-peak refinement at ``u0``/``v0`` -- the background-subtracted + Gaussian height, the crystalline order parameter. The all-peaks least squares + only improves the u/v *positions* (columns 0-1); the raw cepstral value at those + positions rides the central-autocorrelation pedestal and would invert the mask + (bright in vacuum), so it is deliberately not used. """ scan_r, scan_c, H, W = self.dataset.array.shape n_pos = scan_r * scan_c @@ -1015,7 +1021,18 @@ def _fit_lattice_vectors_batched( refine_gaussian=refine_gaussian, ).cpu().numpy() pts_all[:, j, :] = rj[:, :2] - ims_np = ims.cpu().numpy() + # Amplitude/width/background for the mask weight come from the SAME + # single-peak refinement at the u/v seeds as the single-peak branch + # below -- i.e. the background-subtracted Gaussian height, the true + # crystalline order parameter. (The all-peaks lstsq only improves the + # u/v *positions*; the raw cepstral value at those positions rides the + # central-autocorrelation pedestal and would invert the mask in vacuum.) + u_fit = _refine_peaks_batched( + ims, u0, radius_px=refine_radius_px, refine_gaussian=refine_gaussian + ).cpu().numpy() + v_fit = _refine_peaks_batched( + ims, v0, radius_px=refine_radius_px, refine_gaussian=refine_gaussian + ).cpu().numpy() for k, (r, c) in enumerate(idxs): pts = pts_all[k] # (n_pk, 2) row/col offsets from center ab = np.round(np.linalg.lstsq(A_seed, pts.T, rcond=None)[0]).T @@ -1023,10 +1040,12 @@ def _fit_lattice_vectors_batched( M[:, :2] = ab # integer (h, k) indices + constant (center) column uvc = np.linalg.lstsq(M * sqrt_w, pts * sqrt_w, rcond=None)[0] # (3, 2) u_ref, v_ref = uvc[0], uvc[1] - amp_u = _parabolic_peak_rc_amp(ims_np[k], rcent + u_ref[0], ccent + u_ref[1])[2] - amp_v = _parabolic_peak_rc_amp(ims_np[k], rcent + v_ref[0], ccent + v_ref[1])[2] - self.u_peak_fit.array[r, c, :] = (u_ref[0], u_ref[1], amp_u, 0.0, 0.0) - self.v_peak_fit.array[r, c, :] = (v_ref[0], v_ref[1], amp_v, 0.0, 0.0) + self.u_peak_fit.array[r, c, :] = ( + u_ref[0], u_ref[1], u_fit[k, 2], u_fit[k, 3], u_fit[k, 4] + ) + self.v_peak_fit.array[r, c, :] = ( + v_ref[0], v_ref[1], v_fit[k, 2], v_fit[k, 3], v_fit[k, 4] + ) self.u_array[r, c, 0] = u_ref[0] self.u_array[r, c, 1] = u_ref[1] self.v_array[r, c, 0] = v_ref[0] @@ -1742,6 +1761,11 @@ def _refine_lattice_vectors( giving the refined lattice vectors relative to the panel center. ``pts`` and ``weights`` are the detected peak positions and their normalized weights when ``refine_all_peaks`` is True, otherwise both are ``None``. + When ``refine_all_peaks`` is True the position comes from the + intensity-weighted all-peaks least squares, but the amplitude/sigma/background + come from the single-peak refinement at the u/v seed -- the + background-subtracted Gaussian height (the mask order parameter), not the raw + cepstral value, which rides the central pedestal and inverts the mask in vacuum. """ from scipy.optimize import curve_fit @@ -1914,10 +1938,15 @@ def _find_initial_peaks_weights(initial_peaks: NDArray) -> tuple[NDArray, NDArra uvr0 = np.linalg.lstsq(A_weighted, pts_weighted, rcond=None)[0] u_refined = uvr0[0,:] v_refined = uvr0[1,:] - _, _, amp_par_u = _parabolic_peak_rc_amp(im, r_center + u_refined[0], c_center + u_refined[1]) - _, _, amp_par_v = _parabolic_peak_rc_amp(im, r_center + v_refined[0], c_center + v_refined[1]) - - return np.array((u_refined[0], u_refined[1], amp_par_u, 0, 0), dtype=float), np.array((v_refined[0], v_refined[1], amp_par_v, 0, 0), dtype=float), pts, weights + # Position from the weighted all-peaks lstsq; amplitude/width/background from + # the SAME single-peak refinement at the u/v seeds as the single-peak return + # below -- the background-subtracted Gaussian height (the crystalline order + # parameter), not the raw cepstral value, which rides the central pedestal and + # would invert the mask in vacuum. + u_amp_fit = _refine_one(u_rc) + v_amp_fit = _refine_one(v_rc) + + return np.array((u_refined[0], u_refined[1], u_amp_fit[2], u_amp_fit[3], u_amp_fit[4]), dtype=float), np.array((v_refined[0], v_refined[1], v_amp_fit[2], v_amp_fit[3], v_amp_fit[4]), dtype=float), pts, weights return _refine_one(u_rc), _refine_one(v_rc), None, None From 071355a352459194a65a605ccf84f09db1854a77 Mon Sep 17 00:00:00 2001 From: cophus Date: Mon, 1 Jun 2026 21:17:32 +0200 Subject: [PATCH 86/88] few more fixes --- src/quantem/diffraction/bragg_vectors.py | 2 +- src/quantem/diffraction/model_fitting.py | 79 +++++++++++++------ .../diffraction/strain_autocorrelation.py | 60 +++++++------- 3 files changed, 87 insertions(+), 54 deletions(-) diff --git a/src/quantem/diffraction/bragg_vectors.py b/src/quantem/diffraction/bragg_vectors.py index fccbdaf8..a2da2eba 100644 --- a/src/quantem/diffraction/bragg_vectors.py +++ b/src/quantem/diffraction/bragg_vectors.py @@ -659,7 +659,7 @@ def index_peaks( brightest is kept. The result is a compact reference lattice stored on :attr:`reference_ab` / :attr:`reference_qpos` / :attr:`reference_intensity` which :meth:`fit_lattice` matches against at every scan position. This step - is deliberately quick and lightweight. + is quick and lightweight. With ``plot=True`` (default) the reference lattice is drawn over the Bragg vector map, each site ringed and labelled with its ``(a, b)`` index. The ring diff --git a/src/quantem/diffraction/model_fitting.py b/src/quantem/diffraction/model_fitting.py index f4b5390e..b148fa91 100644 --- a/src/quantem/diffraction/model_fitting.py +++ b/src/quantem/diffraction/model_fitting.py @@ -992,21 +992,21 @@ def create_mask( ): """Compute the per-position weight :attr:`mask` from the fitted lattice signal. - Builds a ``(scan_row, scan_col)`` weight in ``[0, 1]`` measuring how much - crystalline signal each position carries -- the model-fitting analogue of - :attr:`BraggVectors.mask_weight` and the cepstral ``mask_weight``. It is handed to - :meth:`initialize_strain_class`, so strong, well-fit positions dominate the + Builds a ``(scan_row, scan_col)`` weight in ``[0, 1]`` measuring the lattice + signal at each position -- the model-fitting analogue of + :attr:`BraggVectors.mask_weight` and the cepstral ``mask_weight``. It is passed to + :meth:`calculate_strain_map`, where higher-weight positions contribute more to the reference lattice and weak/vacuum positions are down-weighted. The raw signal (summed intensity of the non-central fitted disks, or the - diffracted intensity outside a central disk for the radial method) is normalized - to ``[0, 1]`` with **no** contrast windowing or smoothing: this is the honest - per-position order parameter. Set the display contrast later, in one place, via - the ``mask_range`` argument of :meth:`StrainMap.plot_strain`, - :meth:`StrainMap.update_reference`, and :meth:`StrainMap.estimate_strain_precision` - (e.g. ``mask_range=(0.37, 0.5)``) -- weights at/below ``low`` render black, at/above - ``high`` render full color. The weight-map plot here shows where the bulk sits so - an appropriate ``mask_range`` can be chosen. + diffracted intensity outside a central disk for the radial method) is min-max + normalized to ``[0, 1]``; no contrast windowing or smoothing is applied. Display + contrast is applied via the ``mask_range`` argument of + :meth:`StrainMap.plot_strain`, :meth:`StrainMap.update_reference`, and + :meth:`StrainMap.estimate_strain_precision` (e.g. ``mask_range=(0.37, 0.5)``): + weights at/below ``low`` render black, at/above ``high`` render full color. The + weight-map plot shows the distribution so an appropriate ``mask_range`` can be + chosen. Parameters ---------- @@ -1066,12 +1066,17 @@ def create_mask( is_not_center = ~((uv_indices[:, 0] == 0) & (uv_indices[:, 1] == 0)) self.i0_sum_array[r, c] = np.sum(i0_raw[is_not_center]) - # honest [0, 1] normalization, no contrast windowing (set mask_range at display) - max_intensity = np.max(self.i0_sum_array) - if max_intensity > 0: - self.mask = self.i0_sum_array / max_intensity + # Min-max normalization to [0, 1], no contrast windowing (set mask_range at + # display). Subtracting the floor -- rather than only dividing by the max -- keeps + # the weight from saturating near 1.0 when every position carries a baseline + # lattice intensity, and matches the cepstral _amplitude_mask_weight. Degenerate + # (constant / non-finite) input falls back to uniform full weight. + lo = np.nanmin(self.i0_sum_array) + hi = np.nanmax(self.i0_sum_array) + if np.isfinite(lo) and np.isfinite(hi) and hi > lo: + self.mask = (self.i0_sum_array - lo) / (hi - lo) else: - self.mask = np.zeros_like(self.i0_sum_array) + self.mask = np.ones_like(self.i0_sum_array) if plot: import matplotlib.pyplot as plt @@ -1086,16 +1091,46 @@ def create_mask( return self - def initialize_strain_class( + def calculate_strain_map( self, u_ref: np.ndarray | None = None, v_ref: np.ndarray | None = None, - )->StrainMap: + mask: np.ndarray | None = None, + ) -> StrainMap: + """Build a :class:`StrainMap` from the fitted per-position lattice vectors. + + Mirrors :meth:`BraggVectors.calculate_strain_map` and + :meth:`StrainMapAutocorrelation.calculate_strain_map` so the downstream strain + cells (``plot_strain``, ``update_reference``, ``estimate_strain_precision``) are + identical across the correlation, cepstral, and model-fitting workflows. + + Parameters + ---------- + u_ref : np.ndarray, optional + ``(2,)`` reference for the first lattice vector. Defaults to the median over + the scan inside :class:`StrainMap`. + v_ref : np.ndarray, optional + ``(2,)`` reference for the second lattice vector. Defaults to the median over + the scan inside :class:`StrainMap`. + mask : np.ndarray, optional + ``(scan_row, scan_col)`` per-position weighting used when computing the + reference lattice. Defaults to :attr:`mask` from :meth:`create_mask` (the + lattice signal strength), so strong, well-fit positions dominate the + reference. + + Returns + ------- + StrainMap + A strain map initialized from the fitted lattice vectors. + """ if self.u_array is None or self.v_array is None: self.get_individual_uv_vectors() if not isinstance(self.dataset, (Dataset4d, Dataset4dstem)): raise ValueError("Dataset must be Dataset4d or Dataset4dstem.") - + + if mask is None: + mask = self.mask + default_units = None default_sampling = None if hasattr(self.dataset, 'units'): @@ -1108,7 +1143,7 @@ def initialize_strain_class( default_sampling = float(self.dataset.sampling[0]) else: default_sampling = float(self.dataset.sampling) - + return StrainMap( u_array = self.u_array, v_array = self.v_array, @@ -1116,7 +1151,7 @@ def initialize_strain_class( real_space = self.real_space, u_ref = u_ref, v_ref = v_ref, - mask = self.mask, + mask = mask, ds_sampling=default_sampling, ds_units = default_units, ) diff --git a/src/quantem/diffraction/strain_autocorrelation.py b/src/quantem/diffraction/strain_autocorrelation.py index 502c89bc..c9d4282a 100644 --- a/src/quantem/diffraction/strain_autocorrelation.py +++ b/src/quantem/diffraction/strain_autocorrelation.py @@ -944,10 +944,10 @@ def _fit_lattice_vectors_batched( ``n_positions x n_peaks``. In both cases the amplitude/width/background stored in :attr:`u_peak_fit`/:attr:`v_peak_fit` (columns 2-4, the source of the mask weight) come from the single-peak refinement at ``u0``/``v0`` -- the background-subtracted - Gaussian height, the crystalline order parameter. The all-peaks least squares - only improves the u/v *positions* (columns 0-1); the raw cepstral value at those - positions rides the central-autocorrelation pedestal and would invert the mask - (bright in vacuum), so it is deliberately not used. + Gaussian height. The all-peaks least squares only improves the u/v *positions* + (columns 0-1); the raw cepstral value at those positions rides the + central-autocorrelation pedestal and would invert the mask (bright in vacuum), so + it is not used for the weight. """ scan_r, scan_c, H, W = self.dataset.array.shape n_pos = scan_r * scan_c @@ -1023,10 +1023,10 @@ def _fit_lattice_vectors_batched( pts_all[:, j, :] = rj[:, :2] # Amplitude/width/background for the mask weight come from the SAME # single-peak refinement at the u/v seeds as the single-peak branch - # below -- i.e. the background-subtracted Gaussian height, the true - # crystalline order parameter. (The all-peaks lstsq only improves the - # u/v *positions*; the raw cepstral value at those positions rides the - # central-autocorrelation pedestal and would invert the mask in vacuum.) + # below -- i.e. the background-subtracted Gaussian height. (The all-peaks + # lstsq only improves the u/v *positions*; the raw cepstral value at those + # positions rides the central-autocorrelation pedestal and would invert + # the mask in vacuum.) u_fit = _refine_peaks_batched( ims, u0, radius_px=refine_radius_px, refine_gaussian=refine_gaussian ).cpu().numpy() @@ -1074,12 +1074,11 @@ def _fit_lattice_vectors_batched( def _amplitude_mask_weight(self) -> np.ndarray: """Per-position weight from the fitted u/v peak amplitudes, min-max to ``[0, 1]``. - The mean of the two fitted lattice-peak amplitudes is the cepstral order - parameter (how much crystalline signal a position carries). It is min-max - normalized to ``[0, 1]`` with no contrast windowing -- the honest weight; set - display contrast later via :meth:`StrainMap.plot_strain`'s ``mask_range``. - Degenerate input (constant / non-finite / amplitudes unavailable) falls back to - uniform full weight. + The mean of the two fitted lattice-peak amplitudes measures the lattice signal at + each position. It is min-max normalized to ``[0, 1]`` with no contrast windowing; + display contrast is applied later via :meth:`StrainMap.plot_strain`'s + ``mask_range``. Degenerate input (constant / non-finite / amplitudes unavailable) + falls back to uniform full weight. """ scan_r = self.dataset.shape[0] scan_c = self.dataset.shape[1] @@ -1107,19 +1106,19 @@ def create_mask( only to plot the weight map, to recompute it, or to switch to the radial estimator. - Builds a ``(scan_row, scan_col)`` weight in ``[0, 1]`` measuring how much - crystalline signal each position carries, the analogue of - :attr:`BraggVectors.mask_weight`. It is the default reference weighting handed to - :meth:`calculate_strain_map`, so strong, well-fit positions dominate the - reference lattice and weak/vacuum positions are down-weighted. + Builds a ``(scan_row, scan_col)`` weight in ``[0, 1]`` measuring the lattice + signal at each position, the analogue of :attr:`BraggVectors.mask_weight`. It is + the default reference weighting handed to :meth:`calculate_strain_map`, so strong, + well-fit positions dominate the reference lattice and weak/vacuum positions are + down-weighted. The raw signal is min-max normalized to ``[0, 1]`` with **no** contrast windowing - or smoothing: this is the honest per-position order parameter. Set the display - contrast later, in one place, via :meth:`StrainMap.plot_strain`'s ``mask_range`` - argument (e.g. ``mask_range=(0.6, 0.8)``) -- weights at/below ``low`` render - black, at/above ``high`` render full color. (Min-max is sensitive to a few very - bright positions, which compress the bulk toward 0; pick ``mask_range`` to match - where the bulk actually sits -- the weight-map plot here shows it.) + or smoothing. Display contrast is applied via :meth:`StrainMap.plot_strain`'s + ``mask_range`` argument (e.g. ``mask_range=(0.6, 0.8)``) -- weights at/below + ``low`` render black, at/above ``high`` render full color. (Min-max is sensitive + to a few very bright positions, which compress the bulk toward 0; pick + ``mask_range`` to match where the bulk actually sits -- the weight-map plot here + shows it.) Parameters ---------- @@ -1130,9 +1129,8 @@ def create_mask( disks are kept: a large exclusion keeps only the far-corner diffuse scatter, which is *anti*-correlated with crystallinity and inverts the contrast. If ``False`` (default, recommended), weight by the mean fitted peak amplitude - from :meth:`fit_lattice_vectors` (requires it to have been run) -- the direct - cepstral order parameter (identical to the weight ``fit_lattice_vectors`` - stores automatically). + from :meth:`fit_lattice_vectors` (requires it to have been run), identical to + the weight ``fit_lattice_vectors`` stores automatically. exclusion_radius_fraction : float, default=0.1 Central-disk radius (fraction of detector width) excluded by the radial method. @@ -1166,7 +1164,7 @@ def create_mask( for c in range(scan_c): dp = self.dataset.array[r, c] signal[r, c] = np.sum(dp[outside_mask]) - # honest min-max normalization to [0, 1]; constant/degenerate -> ones + # min-max normalization to [0, 1]; constant/degenerate -> ones lo = np.nanmin(signal) hi = np.nanmax(signal) if np.isfinite(lo) and np.isfinite(hi) and hi > lo: @@ -1764,8 +1762,8 @@ def _refine_lattice_vectors( When ``refine_all_peaks`` is True the position comes from the intensity-weighted all-peaks least squares, but the amplitude/sigma/background come from the single-peak refinement at the u/v seed -- the - background-subtracted Gaussian height (the mask order parameter), not the raw - cepstral value, which rides the central pedestal and inverts the mask in vacuum. + background-subtracted Gaussian height, not the raw cepstral value, which rides + the central pedestal and inverts the mask in vacuum. """ from scipy.optimize import curve_fit From 990e25dae4b38d4dbd4d5ab5763c0f46d8824c83 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Mon, 1 Jun 2026 22:42:49 -0700 Subject: [PATCH 87/88] updating imports --- src/quantem/core/fitting/base.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 8a64cb3b..9b075c6e 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -11,9 +11,6 @@ from quantem.core.ml.optimizer_mixin import ( OptimizerMixin, - OptimizerParams, - OptimizerType, - SchedulerType, ) From 947d3af56bc83409238b5c615fd5bf9d52d16548 Mon Sep 17 00:00:00 2001 From: Miti Shah Date: Tue, 2 Jun 2026 13:52:08 -0700 Subject: [PATCH 88/88] fixing code to match new dev updates --- src/quantem/core/fitting/base.py | 11 +++++++---- src/quantem/core/fitting/diffraction.py | 12 ++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/quantem/core/fitting/base.py b/src/quantem/core/fitting/base.py index 9b075c6e..21f7a7f6 100644 --- a/src/quantem/core/fitting/base.py +++ b/src/quantem/core/fitting/base.py @@ -295,15 +295,18 @@ def constraint_loss( ) -> torch.Tensor: return torch.zeros((), device=ctx.device, dtype=ctx.dtype) - def get_optimization_parameters(self) -> Any: - return [p for p in self.parameters() if p.requires_grad] + def get_optimization_parameters(self) -> dict[str, list]: + params = [p for p in self.parameters() if p.requires_grad] + if not params: + return {} + return {'default': params} def initialize_optimizer(self, optimizer_params: dict[str, Any] | None = None, scheduler_params: dict[str, Any] | None = None, num_iter: int | None = None, ) -> None: - trainable_params = list(self.get_optimization_parameters()) + trainable_params = (self.get_optimization_parameters()) if not trainable_params: self._optimizer = None self._scheduler = None @@ -359,7 +362,7 @@ def _infer_scheduler_rebuild_params(self) -> Any: } def _rebuild_optimizer_after_trainability_change(self) -> None: - trainable_params = list(self.get_optimization_parameters()) + trainable_params = (self.get_optimization_parameters()) if not trainable_params: self._optimizer = None self._scheduler = None diff --git a/src/quantem/core/fitting/diffraction.py b/src/quantem/core/fitting/diffraction.py index 2da095d7..a62c01f3 100644 --- a/src/quantem/core/fitting/diffraction.py +++ b/src/quantem/core/fitting/diffraction.py @@ -489,12 +489,14 @@ def constraint_loss_batched( return tv_loss + cutoff_loss + circular_loss - def get_optimization_parameters(self) -> Any: + def get_optimization_parameters(self) -> dict[str, list[torch.nn.Parameter]]: params = [] for name, param in self.named_parameters(recurse=True): if not name.startswith('origin.') and param.requires_grad: params.append(param) - return params + if not params: + return {} + return {'default': params} @@ -916,9 +918,11 @@ def forward_batched( device=ctx.device, dtype=ctx.dtype, ) - def get_optimization_parameters(self) -> Any: + def get_optimization_parameters(self) -> dict[str, list[torch.nn.Parameter]]: params = [] for name, param in self.named_parameters(recurse=True): if not name.startswith('disk.') and param.requires_grad: params.append(param) - return params + if not params: + return {} + return {'default': params}