thyra 2.3.0__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {thyra-2.3.0 → thyra-3.0.0}/PKG-INFO +1 -1
  2. {thyra-2.3.0 → thyra-3.0.0}/pyproject.toml +1 -1
  3. {thyra-2.3.0 → thyra-3.0.0}/thyra/__init__.py +1 -1
  4. {thyra-2.3.0 → thyra-3.0.0}/thyra/convert.py +33 -9
  5. {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +145 -22
  6. {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +1 -10
  7. {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/streaming_converter.py +194 -56
  8. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/extractors/imzml_extractor.py +123 -31
  9. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/extractors/waters_extractor.py +11 -2
  10. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/imzml/imzml_reader.py +538 -12
  11. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/constants.py +6 -0
  12. {thyra-2.3.0 → thyra-3.0.0}/LICENSE +0 -0
  13. {thyra-2.3.0 → thyra-3.0.0}/README.md +0 -0
  14. {thyra-2.3.0 → thyra-3.0.0}/thyra/__main__.py +0 -0
  15. {thyra-2.3.0 → thyra-3.0.0}/thyra/alignment/__init__.py +0 -0
  16. {thyra-2.3.0 → thyra-3.0.0}/thyra/alignment/affine.py +0 -0
  17. {thyra-2.3.0 → thyra-3.0.0}/thyra/alignment/teaching_points.py +0 -0
  18. {thyra-2.3.0 → thyra-3.0.0}/thyra/config.py +0 -0
  19. {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/__init__.py +0 -0
  20. {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/__init__.py +0 -0
  21. {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/_chunking.py +0 -0
  22. {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/converter.py +0 -0
  23. {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
  24. {thyra-2.3.0 → thyra-3.0.0}/thyra/core/__init__.py +0 -0
  25. {thyra-2.3.0 → thyra-3.0.0}/thyra/core/base_converter.py +0 -0
  26. {thyra-2.3.0 → thyra-3.0.0}/thyra/core/base_extractor.py +0 -0
  27. {thyra-2.3.0 → thyra-3.0.0}/thyra/core/base_reader.py +0 -0
  28. {thyra-2.3.0 → thyra-3.0.0}/thyra/core/registry.py +0 -0
  29. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/__init__.py +0 -0
  30. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/extractors/__init__.py +0 -0
  31. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  32. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/__init__.py +0 -0
  33. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/_ims.py +0 -0
  34. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/_ms.py +0 -0
  35. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/_uo.py +0 -0
  36. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/cache.py +0 -0
  37. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/types.py +0 -0
  38. {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/validator.py +0 -0
  39. {thyra-2.3.0 → thyra-3.0.0}/thyra/preview.py +0 -0
  40. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/__init__.py +0 -0
  41. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/__init__.py +0 -0
  42. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  43. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/folder_structure.py +0 -0
  44. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/mis_parser.py +0 -0
  45. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  46. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  47. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
  48. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  49. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  50. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  51. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  52. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  53. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  54. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  55. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  56. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  57. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  58. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  59. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  60. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  61. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  62. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/imzml/__init__.py +0 -0
  63. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/__init__.py +0 -0
  64. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/imaging_grid.py +0 -0
  65. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
  66. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  67. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
  68. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  69. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/masslynx_lib.py +0 -0
  70. {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/waters_reader.py +0 -0
  71. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/__init__.py +0 -0
  72. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/common_axis.py +0 -0
  73. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/data_characteristics.py +0 -0
  74. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/decision_tree.py +0 -0
  75. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/gaps.py +0 -0
  76. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/instrument_detectors.py +0 -0
  77. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/__init__.py +0 -0
  78. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
  79. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  80. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  81. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  82. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  83. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  84. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/strategies/__init__.py +0 -0
  85. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/strategies/base.py +0 -0
  86. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  87. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
  88. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/tic.py +0 -0
  89. {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/types.py +0 -0
  90. {thyra-2.3.0 → thyra-3.0.0}/thyra/tools/__init__.py +0 -0
  91. {thyra-2.3.0 → thyra-3.0.0}/thyra/tools/check_ontology.py +0 -0
  92. {thyra-2.3.0 → thyra-3.0.0}/thyra/tools/make_example_data.py +0 -0
  93. {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/__init__.py +0 -0
  94. {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/bruker_exceptions.py +0 -0
  95. {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/logging_config.py +0 -0
  96. {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/windows_paths.py +0 -0
  97. {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/zarr_atomic_write.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thyra
3
- Version: 2.3.0
3
+ Version: 3.0.0
4
4
  Summary: A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "thyra"
7
- version = "2.3.0"
7
+ version = "3.0.0"
8
8
  description = "A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics"
9
9
  authors = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
10
10
  maintainers = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
@@ -36,7 +36,7 @@ warnings.filterwarnings(
36
36
  category=FutureWarning,
37
37
  )
38
38
 
39
- __version__ = "2.3.0"
39
+ __version__ = "3.0.0"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -147,27 +147,51 @@ def _determine_pixel_size(
147
147
 
148
148
 
149
149
  def _should_use_streaming(streaming: Union[bool, Literal["auto"]], reader: Any) -> bool:
150
- """Determine if streaming converter should be used."""
150
+ """Determine if streaming converter should be used.
151
+
152
+ Args:
153
+ streaming: True to force streaming, False to force the standard
154
+ converter, ``"auto"`` to pick on estimated size.
155
+ reader: The reader for the input.
156
+
157
+ Returns:
158
+ True if the streaming converter should be used.
159
+
160
+ Raises:
161
+ Exception: Whatever ``reader.get_essential_metadata()`` raises. A
162
+ reader that refuses its input must not be silenced here.
163
+ """
151
164
  if streaming is True:
152
165
  return True
153
166
  if streaming != "auto":
154
167
  return False
155
168
 
156
- # Auto-detect based on estimated dataset size (>10GB)
169
+ # Deliberately outside the try below. This is often the first call that
170
+ # builds the reader's parser, so it is the call a refused file fails on --
171
+ # and swallowing it here logged the reason at DEBUG, returned False, and
172
+ # let the converter parse the whole file a second time before failing the
173
+ # same way. On a 2.1 GB imzML that is about two minutes of apparent
174
+ # progress with the real reason invisible.
175
+ essential_meta = reader.get_essential_metadata()
176
+
177
+ # Auto-detect based on estimated dataset size (>10GB). Only the estimate
178
+ # itself is best-effort: a reader whose dimensions are missing or oddly
179
+ # shaped simply does not get the automatic upgrade.
157
180
  try:
158
- essential_meta = reader.get_essential_metadata()
159
181
  dims = essential_meta.dimensions
160
182
  n_pixels = dims[0] * dims[1] * dims[2]
161
183
  # Rough estimate: assume average 10k peaks per spectrum, 8 bytes each
162
184
  estimated_gb = (n_pixels * 10000 * 8) / (1024**3)
163
- if estimated_gb > 10:
164
- logger.info(
165
- f"Auto-detected large dataset (~{estimated_gb:.1f} GB), "
166
- "using streaming converter"
167
- )
168
- return True
169
185
  except Exception as e:
170
186
  logger.debug(f"Could not estimate dataset size for auto-streaming: {e}")
187
+ return False
188
+
189
+ if estimated_gb > 10:
190
+ logger.info(
191
+ f"Auto-detected large dataset (~{estimated_gb:.1f} GB), "
192
+ "using streaming converter"
193
+ )
194
+ return True
171
195
  return False
172
196
 
173
197
 
@@ -306,6 +306,11 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
306
306
  )
307
307
 
308
308
  self._non_empty_pixel_count: int = 0
309
+ # Peaks discarded for falling outside the target mass axis, and
310
+ # whether the one-line summary has been emitted yet. See
311
+ # _count_out_of_range().
312
+ self._out_of_range_peaks: int = 0
313
+ self._out_of_range_warned: bool = False
309
314
  self._pixel_size_detection_info = pixel_size_detection_info
310
315
  self._resampling_config = (
311
316
  _normalize_resampling_config(resampling_config)
@@ -1071,6 +1076,43 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1071
1076
  # This method is only called for TIC-preserving resampling (dense path)
1072
1077
  return self._tic_preserving_resample(mzs, intensities)
1073
1078
 
1079
+ def _count_out_of_range(self, n_dropped: int, n_total: int) -> None:
1080
+ """Record peaks discarded for lying outside the target mass axis.
1081
+
1082
+ Narrowing the mass range is deliberate, so dropping the peaks
1083
+ outside it is the correct answer and not an error -- but it is not
1084
+ something a user should have to infer either, since the previous
1085
+ behaviour conserved the total exactly and so left no trace a TIC
1086
+ check could find.
1087
+
1088
+ Warns once per conversion rather than once per spectrum: a
1089
+ narrowed range typically excludes peaks in every spectrum, and on
1090
+ xenium that is 918,855 identical lines. ``_out_of_range_peaks``
1091
+ keeps the running total; note it counts resample *calls*, and the
1092
+ PCS route resamples every spectrum twice, so it is a lower bound
1093
+ on nothing and an upper bound on nothing -- read the warning, not
1094
+ the counter, if you want a per-spectrum figure.
1095
+
1096
+ Args:
1097
+ n_dropped: Peaks outside the axis in this spectrum.
1098
+ n_total: Peaks in this spectrum before filtering.
1099
+ """
1100
+ self._out_of_range_peaks += n_dropped
1101
+
1102
+ if self._out_of_range_warned or self._common_mass_axis is None:
1103
+ return
1104
+ self._out_of_range_warned = True
1105
+ logger.warning(
1106
+ "Dropping peaks that fall outside the target mass axis "
1107
+ "[%.4f, %.4f] m/z -- %d of %d in the first spectrum affected. "
1108
+ "They are discarded, not folded into the edge bins. Widen the "
1109
+ "resampling range to keep them.",
1110
+ float(self._common_mass_axis[0]),
1111
+ float(self._common_mass_axis[-1]),
1112
+ n_dropped,
1113
+ n_total,
1114
+ )
1115
+
1074
1116
  def _nearest_neighbor_resample(
1075
1117
  self, mzs: NDArray[np.float64], intensities: NDArray[np.float64]
1076
1118
  ) -> Tuple[NDArray[np.int_], NDArray[np.float64]]:
@@ -1079,6 +1121,23 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1079
1121
  Maps each m/z value to its nearest bin in the common mass axis and
1080
1122
  accumulates intensities. Returns only non-zero bins for efficiency.
1081
1123
 
1124
+ Peaks outside the axis are **dropped**, not folded into the edge
1125
+ bins. They used to be clipped to bin 0 or the last bin and then
1126
+ accumulated there, so narrowing the mass range -- the most ordinary
1127
+ thing ``--resample-min-mz`` / ``--resample-max-mz`` are for --
1128
+ piled everything below the floor onto the first bin and everything
1129
+ above the ceiling onto the last. On real ``pea.imzML`` resampled to
1130
+ 400-800 m/z, bin 0 held 654,158 counts where a real peak there is
1131
+ around 80. The total was conserved exactly, so a TIC check could
1132
+ not see it; the peak was simply in the wrong place, 1,634x the
1133
+ median interior bin.
1134
+
1135
+ "In range" is the strict axis span, ``[axis[0], axis[-1]]``: a peak
1136
+ is kept when the axis covers it, not when it is within half a bin of
1137
+ the end. That is the same rule ``_tic_preserving_resample`` already
1138
+ follows -- ``np.interp(..., left=0, right=0)`` and
1139
+ ``thyra.resampling.tic.preserved_tic`` both cut at the endpoints.
1140
+
1082
1141
  Args:
1083
1142
  mzs: Original m/z values from spectrum
1084
1143
  intensities: Corresponding intensity values
@@ -1092,20 +1151,32 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1092
1151
  # Ensure common mass axis is initialized
1093
1152
  if self._common_mass_axis is None:
1094
1153
  raise ValueError("Common mass axis is not initialized")
1154
+
1155
+ axis = self._common_mass_axis
1156
+ in_range = (mzs >= axis[0]) & (mzs <= axis[-1])
1157
+ if not in_range.all():
1158
+ self._count_out_of_range(int(mzs.size - in_range.sum()), int(mzs.size))
1159
+ mzs = mzs[in_range]
1160
+ intensities = intensities[in_range]
1161
+ if mzs.size == 0:
1162
+ return np.array([], dtype=np.int_), np.array([], dtype=np.float64)
1163
+
1095
1164
  # Find insertion points using vectorized binary search
1096
- indices = np.searchsorted(self._common_mass_axis, mzs)
1165
+ indices = np.searchsorted(axis, mzs)
1097
1166
 
1098
1167
  # OPTIMIZED: Handle boundary and nearest neighbor in one pass
1099
- # Clip to valid range
1100
- indices_clipped = np.clip(indices, 0, len(self._common_mass_axis) - 1)
1168
+ # Clip to valid range. Everything reaching here is inside the axis,
1169
+ # so this only pins searchsorted's one-past-the-end result for a
1170
+ # value equal to axis[-1]; it can no longer pull an outside peak in.
1171
+ indices_clipped = np.clip(indices, 0, len(axis) - 1)
1101
1172
 
1102
1173
  # For non-boundary points, check if left is closer
1103
1174
  # Only check where we're not at the left edge
1104
1175
  check_left = indices > 0
1105
1176
  if np.any(check_left):
1106
1177
  # Get distances only for points that need checking
1107
- mz_values = self._common_mass_axis[indices_clipped[check_left]]
1108
- mz_values_left = self._common_mass_axis[indices_clipped[check_left] - 1]
1178
+ mz_values = axis[indices_clipped[check_left]]
1179
+ mz_values_left = axis[indices_clipped[check_left] - 1]
1109
1180
  mz_query = mzs[check_left]
1110
1181
 
1111
1182
  # Use left if it's closer
@@ -1299,18 +1370,42 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1299
1370
  )
1300
1371
  coords_df.set_index("instance_id", inplace=True)
1301
1372
 
1302
- region_map = getattr(self, "_region_map", None)
1303
- if region_map is not None:
1304
- keys = list(zip(x_idx.tolist(), y_idx.tolist()))
1305
- region_numbers = np.array(
1306
- [region_map.get(k, -1) for k in keys], dtype=np.int32
1307
- )
1308
- coords_df["region_number"] = region_numbers
1309
- else:
1310
- coords_df["region_number"] = np.ones(n_pixels, dtype=np.int32)
1373
+ coords_df["region_number"] = self.build_region_numbers(x_idx, y_idx)
1311
1374
 
1312
1375
  return coords_df
1313
1376
 
1377
+ def build_region_numbers(self, x_values, y_values) -> NDArray[np.int32]:
1378
+ """``obs["region_number"]`` for the given pixel positions, in row order.
1379
+
1380
+ Written on every dataset for a consistent schema, so a consumer
1381
+ does not have to branch on whether the acquisition had regions:
1382
+ without a region map every pixel is region 1, which is also what
1383
+ ``uns["regions"]`` reports for that case. Only the Bruker timsTOF
1384
+ reader produces a map today; a position missing from it gets -1.
1385
+
1386
+ Shared because there are four places that build an obs table --
1387
+ the in-memory 2D and 3D converters, the streaming-COO slice
1388
+ builder and the hand-written PCS layout -- and each had its own
1389
+ copy of this rule. The PCS one had no copy at all and simply
1390
+ omitted the column.
1391
+
1392
+ Args:
1393
+ x_values: Pixel x index per obs row.
1394
+ y_values: Pixel y index per obs row.
1395
+
1396
+ Returns:
1397
+ Region number per obs row.
1398
+ """
1399
+ n = len(x_values)
1400
+ region_map = getattr(self, "_region_map", None)
1401
+ if region_map is None:
1402
+ return np.ones(n, dtype=np.int32)
1403
+
1404
+ keys = zip(x_values.tolist(), y_values.tolist())
1405
+ return np.fromiter(
1406
+ (region_map.get(key, -1) for key in keys), dtype=np.int32, count=n
1407
+ )
1408
+
1314
1409
  def _create_mass_dataframe(self) -> pd.DataFrame:
1315
1410
  """Create m/z dataframe for variable metadata.
1316
1411
 
@@ -1987,6 +2082,41 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1987
2082
  # Add comprehensive dataset metadata if supported
1988
2083
  self._add_comprehensive_metadata(metadata)
1989
2084
 
2085
+ def build_root_attrs(
2086
+ self, comprehensive_metadata_obj: Any = None
2087
+ ) -> Dict[str, Any]:
2088
+ """The root-level attributes every write path must persist, identically.
2089
+
2090
+ The store's own attrs, as opposed to the table's ``uns`` block
2091
+ :meth:`build_uns_metadata` owns. Sibling of that method and here for
2092
+ the same reason: the streaming-PCS path hand-writes its Zarr layout
2093
+ and composed its own, shorter set -- 7 attributes against 10 on real
2094
+ ``pea.imzML``, missing ``coordinate_systems``,
2095
+ ``format_specific_metadata`` and ``msi_dataset_info``.
2096
+
2097
+ ``coordinate_systems`` is the one that matters most in practice: it
2098
+ is the structured contract saying what unit ``"global"`` is in, and
2099
+ Ousia and the registration tooling read it rather than guessing.
2100
+ A PCS store simply did not have it, and the route is chosen by size,
2101
+ so the datasets that lost it are the largest ones.
2102
+
2103
+ Sections the reader has nothing for are omitted rather than written
2104
+ empty, matching :meth:`build_uns_metadata`.
2105
+
2106
+ Args:
2107
+ comprehensive_metadata_obj: Already-read comprehensive metadata,
2108
+ when the caller has it. ``None`` reads it from the reader.
2109
+
2110
+ Returns:
2111
+ Mapping of root attribute name to value.
2112
+ """
2113
+ if comprehensive_metadata_obj is None:
2114
+ comprehensive_metadata_obj = self.reader.get_comprehensive_metadata()
2115
+
2116
+ attrs = self._create_pixel_size_attrs()
2117
+ self._add_comprehensive_sections(attrs, comprehensive_metadata_obj)
2118
+ return attrs
2119
+
1990
2120
  def _setup_spatialdata_attrs(
1991
2121
  self, metadata: "SpatialData", comprehensive_metadata_obj
1992
2122
  ) -> None:
@@ -1996,14 +2126,7 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1996
2126
 
1997
2127
  logger.info("Adding comprehensive metadata to SpatialData.attrs")
1998
2128
 
1999
- # Create pixel size attributes
2000
- pixel_size_attrs = self._create_pixel_size_attrs()
2001
-
2002
- # Add comprehensive metadata sections
2003
- self._add_comprehensive_sections(pixel_size_attrs, comprehensive_metadata_obj)
2004
-
2005
- # Update SpatialData attributes
2006
- metadata.attrs.update(pixel_size_attrs)
2129
+ metadata.attrs.update(self.build_root_attrs(comprehensive_metadata_obj))
2007
2130
 
2008
2131
  def _create_pixel_size_attrs(self) -> Dict[str, Any]:
2009
2132
  """Create pixel size and conversion metadata attributes."""
@@ -158,16 +158,7 @@ class SpatialData2DConverter(BaseSpatialDataConverter):
158
158
  # Always add per-pixel region numbers for a consistent schema.
159
159
  # Multi-region datasets use the reader's region map; single-region
160
160
  # or unknown-region datasets default to region 1.
161
- region_map = getattr(self, "_region_map", None)
162
- if region_map is not None:
163
- region_numbers = np.full(pixel_count, -1, dtype=np.int32)
164
- for i in range(pixel_count):
165
- key = (int(x_values[i]), int(y_values[i]))
166
- if key in region_map:
167
- region_numbers[i] = region_map[key]
168
- coords_df["region_number"] = region_numbers
169
- else:
170
- coords_df["region_number"] = np.ones(pixel_count, dtype=np.int32)
161
+ coords_df["region_number"] = self.build_region_numbers(x_values, y_values)
171
162
 
172
163
  return coords_df
173
164