ossify 0.2.2__tar.gz → 0.2.4__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 (36) hide show
  1. {ossify-0.2.2 → ossify-0.2.4}/PKG-INFO +1 -1
  2. {ossify-0.2.2 → ossify-0.2.4}/pyproject.toml +2 -2
  3. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/__init__.py +1 -1
  4. ossify-0.2.4/src/ossify/_sync/base.py +534 -0
  5. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/morph.py +71 -9
  6. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/data_layers.py +7 -0
  7. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/file_io.py +107 -9
  8. ossify-0.2.2/src/ossify/_sync/base.py +0 -280
  9. {ossify-0.2.2 → ossify-0.2.4}/.github/workflows/mkdocs_publish.yml +0 -0
  10. {ossify-0.2.2 → ossify-0.2.4}/.github/workflows/python-package.yml +0 -0
  11. {ossify-0.2.2 → ossify-0.2.4}/.gitignore +0 -0
  12. {ossify-0.2.2 → ossify-0.2.4}/.pre-commit-config.yaml +0 -0
  13. {ossify-0.2.2 → ossify-0.2.4}/LICENSE +0 -0
  14. {ossify-0.2.2 → ossify-0.2.4}/README.md +0 -0
  15. {ossify-0.2.2 → ossify-0.2.4}/mkdocs.yml +0 -0
  16. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/__init__.py +0 -0
  17. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/graph.py +0 -0
  18. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/mapping.py +0 -0
  19. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/mesh.py +0 -0
  20. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/points.py +0 -0
  21. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/table.py +0 -0
  22. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/algorithms.py +0 -0
  23. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/base.py +0 -0
  24. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartment_models/minnie65_ds15_us0_bd0.json +0 -0
  25. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartment_models/v1dd_ds15_us0_bd0.json +0 -0
  26. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartment_models/xgb/minnie65_ds15_us0_bd0.ubj +0 -0
  27. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartment_models/xgb/v1dd_ds15_us0_bd0.ubj +0 -0
  28. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartments.py +0 -0
  29. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/graph_functions.py +0 -0
  30. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/plot.py +0 -0
  31. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/plot3d.py +0 -0
  32. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/plot_utils.py +0 -0
  33. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/structured_prediction.py +0 -0
  34. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/sync_classes.py +0 -0
  35. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/translate.py +0 -0
  36. {ossify-0.2.2 → ossify-0.2.4}/src/ossify/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ossify
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Mesh and skeleton analysis
5
5
  Author-email: Casey Schneider-Mizell <caseysm@gmail.com>
6
6
  License-File: LICENSE
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
  allow-direct-references = true
7
7
  [project]
8
8
  name = "ossify"
9
- version = "0.2.2"
9
+ version = "0.2.4"
10
10
  description = "Mesh and skeleton analysis"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.11"
@@ -95,7 +95,7 @@ default-groups = ["dev", "docs", "lint", "profile", "viz"]
95
95
 
96
96
 
97
97
  [tool.bumpversion]
98
- current_version = "0.2.2"
98
+ current_version = "0.2.4"
99
99
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
100
100
  serialize = ["{major}.{minor}.{patch}"]
101
101
  regex = false
@@ -5,7 +5,7 @@ from .base import *
5
5
  from .file_io import *
6
6
  from .translate import *
7
7
 
8
- __version__ = "0.2.2"
8
+ __version__ = "0.2.4"
9
9
 
10
10
 
11
11
  def __getattr__(name):
@@ -0,0 +1,534 @@
1
+ from typing import Optional, Union
2
+
3
+ import fastremap
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ DEFAULT_SPATIAL_COLUMNS = ["x", "y", "z"]
8
+
9
+ # Ossify's canonical identifier space. Identifiers are nominally drawn from the
10
+ # uint64 segmentation ID space, but every ID we actually support fits within the
11
+ # nonnegative range of a signed 64-bit integer. We therefore own a single
12
+ # internal representation -- ``int64`` -- so that a value never depends on
13
+ # whether an upstream source happened to hand us signed or unsigned integers.
14
+ _INT64_MAX = int(np.iinfo(np.int64).max) # 9223372036854775807
15
+ _UINT64_INT64_MAX = np.uint64(_INT64_MAX)
16
+
17
+
18
+ def _range_error(name: str, kind: str) -> ValueError:
19
+ if kind == "negative":
20
+ return ValueError(
21
+ f"{name}: identifiers must be nonnegative, but negative values were found."
22
+ )
23
+ return ValueError(
24
+ f"{name}: identifiers must be <= {_INT64_MAX} (int64 max), "
25
+ f"but larger values were found."
26
+ )
27
+
28
+
29
+ def _validate_and_cast_integer_ndarray(
30
+ arr: np.ndarray, null_mask: Optional[np.ndarray], name: str
31
+ ) -> np.ndarray:
32
+ """Validate a signed/unsigned integer ndarray and cast to ``int64``.
33
+
34
+ Validation uses integer-safe comparisons only -- no value ever passes
35
+ through a floating-point representation, which would silently lose
36
+ precision for IDs above ``2**53``.
37
+ """
38
+ kind = arr.dtype.kind
39
+ if kind == "i":
40
+ # Signed integers already fit within int64 (int8..int64); only the
41
+ # lower bound can be violated. Null positions carry a sentinel and are
42
+ # excluded from the check.
43
+ negative = arr < 0
44
+ if null_mask is not None:
45
+ negative = negative & ~null_mask
46
+ if negative.any():
47
+ raise _range_error(name, "negative")
48
+ return arr.astype(np.int64, copy=False)
49
+ if kind == "u":
50
+ # Unsigned integers cannot be negative; only uint64 can exceed int64
51
+ # max. Compare against a uint64 scalar so numpy stays in integer land
52
+ # (mixing a uint64 array with a Python int upcasts to float64).
53
+ too_large = arr > _UINT64_INT64_MAX
54
+ if null_mask is not None:
55
+ too_large = too_large & ~null_mask
56
+ if too_large.any():
57
+ raise _range_error(name, "too_large")
58
+ # Every remaining value is <= int64 max, so this cast is lossless.
59
+ return arr.astype(np.int64)
60
+ raise TypeError(
61
+ f"{name}: expected integer identifiers, got array of dtype {arr.dtype}."
62
+ )
63
+
64
+
65
+ def _canonicalize_object_array(
66
+ arr: np.ndarray, allow_null: bool, name: str
67
+ ) -> tuple[np.ndarray, np.ndarray]:
68
+ """Canonicalize an object-dtype array element by element.
69
+
70
+ Object arrays are the only place mixed Python ints, ``None``/``pd.NA`` and
71
+ stray floats can coexist, so each element is inspected individually.
72
+ """
73
+ n = len(arr)
74
+ out = np.zeros(n, dtype=np.int64)
75
+ null_mask = np.zeros(n, dtype=bool)
76
+ for i, v in enumerate(arr):
77
+ if v is None or v is pd.NA:
78
+ null_mask[i] = True
79
+ continue
80
+ if isinstance(v, (bool, np.bool_)):
81
+ raise TypeError(
82
+ f"{name}: boolean values are not valid identifiers (got {v!r})."
83
+ )
84
+ if isinstance(v, (float, np.floating)):
85
+ # A float may already have lost precision before Ossify saw it, so
86
+ # even an integral-looking float is rejected. A NaN is treated as a
87
+ # null when nulls are permitted.
88
+ if v != v: # NaN
89
+ null_mask[i] = True
90
+ continue
91
+ raise TypeError(
92
+ f"{name}: float identifiers are not accepted (got {v!r}); "
93
+ f"a float may already have lost precision above 2**53."
94
+ )
95
+ if isinstance(v, (int, np.integer)):
96
+ iv = int(v)
97
+ if iv < 0:
98
+ raise _range_error(name, "negative")
99
+ if iv > _INT64_MAX:
100
+ raise _range_error(name, "too_large")
101
+ out[i] = iv
102
+ continue
103
+ raise TypeError(
104
+ f"{name}: non-integer identifier {v!r} of type {type(v).__name__}."
105
+ )
106
+ if null_mask.any() and not allow_null:
107
+ raise ValueError(f"{name}: null identifier values are not allowed here.")
108
+ return out, null_mask
109
+
110
+
111
+ def _extract_int64(
112
+ values, allow_null: bool, name: str
113
+ ) -> tuple[np.ndarray, np.ndarray]:
114
+ """Core: return ``(int64_ndarray, null_mask)`` for any array-like input."""
115
+ dtype = getattr(values, "dtype", None)
116
+ if dtype is not None and pd.api.types.is_extension_array_dtype(dtype):
117
+ # pandas nullable integer container (Int64/UInt64/Int32/...).
118
+ ea = values.array if isinstance(values, (pd.Series, pd.Index)) else values
119
+ if not pd.api.types.is_integer_dtype(ea.dtype):
120
+ raise TypeError(
121
+ f"{name}: expected integer identifiers, got extension dtype {ea.dtype}."
122
+ )
123
+ null_mask = np.asarray(ea.isna())
124
+ if null_mask.any() and not allow_null:
125
+ raise ValueError(f"{name}: null identifier values are not allowed here.")
126
+ # Materialize the underlying signed/unsigned native array (nulls filled
127
+ # with a sentinel 0) then validate exactly like a plain ndarray.
128
+ native = ea.to_numpy(dtype=ea.dtype.numpy_dtype, na_value=0)
129
+ int64 = _validate_and_cast_integer_ndarray(native, null_mask, name)
130
+ return int64, null_mask
131
+
132
+ arr = values if isinstance(values, np.ndarray) else np.asarray(values)
133
+ if arr.size == 0:
134
+ # np.asarray([]) is float64; an empty ID container is still valid.
135
+ return arr.astype(np.int64), np.zeros(0, dtype=bool)
136
+ kind = arr.dtype.kind
137
+ if kind in ("i", "u"):
138
+ int64 = _validate_and_cast_integer_ndarray(arr, None, name)
139
+ return int64, np.zeros(len(int64), dtype=bool)
140
+ if kind == "f":
141
+ raise TypeError(
142
+ f"{name}: float identifiers are not accepted; a float may already "
143
+ f"have lost precision above 2**53."
144
+ )
145
+ if kind == "b":
146
+ raise TypeError(f"{name}: boolean values are not valid identifiers.")
147
+ if kind == "O":
148
+ return _canonicalize_object_array(arr, allow_null, name)
149
+ raise TypeError(f"{name}: unsupported identifier dtype {arr.dtype}.")
150
+
151
+
152
+ def _wrap_null(int64: np.ndarray, null_mask: np.ndarray, allow_null: bool):
153
+ """Return an ``int64`` ndarray, or a nullable ``Int64`` array if NA present."""
154
+ if allow_null and null_mask.any():
155
+ return pd.arrays.IntegerArray(int64, null_mask.copy())
156
+ return int64
157
+
158
+
159
+ def canonicalize_ids(
160
+ values,
161
+ *,
162
+ allow_null: bool = False,
163
+ name: str = "id",
164
+ ):
165
+ """Convert integer identifiers to Ossify's canonical ``int64`` representation.
166
+
167
+ Ossify treats identifiers as living in the nonnegative ``int64`` range even
168
+ though the nominal ID space is ``uint64``. Upstream sources may hand us the
169
+ same ID as a Python ``int``, a NumPy signed/unsigned integer, or inside a
170
+ pandas container, and joining two differently-typed key spaces can make
171
+ pandas coerce values through ``float64`` -- silently collapsing distinct IDs
172
+ above ``2**53``. Canonicalizing every ID to ``int64`` before it participates
173
+ in a lookup removes that failure mode.
174
+
175
+ Parameters
176
+ ----------
177
+ values :
178
+ The identifier(s). Accepted forms: a Python ``int``; a NumPy signed or
179
+ unsigned integer scalar; a NumPy integer array; a list/tuple of the
180
+ above; a pandas ``Series`` or ``Index``; or a pandas nullable integer
181
+ (``Int64``/``UInt64``/...) container.
182
+ allow_null :
183
+ Whether nulls (``None``/``pd.NA``/``NaN``) are permitted. Only pass
184
+ ``True`` where the calling mapping operation legitimately supports
185
+ missing values; otherwise a null raises.
186
+ name :
187
+ Label used in error messages (typically the column or layer name).
188
+
189
+ Returns
190
+ -------
191
+ :
192
+ The canonical form, matching the input's container shape:
193
+
194
+ - scalar in -> ``np.int64`` scalar;
195
+ - ``Series`` in -> ``Series`` (index and name preserved);
196
+ - ``Index`` in -> ``Index`` (name preserved);
197
+ - array-like in -> ``np.ndarray`` of ``int64``.
198
+
199
+ Where nulls are present (and permitted) the result uses pandas nullable
200
+ ``Int64`` so missing values survive without a float cast; otherwise a
201
+ plain ``int64`` container is returned.
202
+
203
+ Raises
204
+ ------
205
+ TypeError
206
+ If a value is a float (including integral-looking floats), a boolean, or
207
+ any non-integer object.
208
+ ValueError
209
+ If a value is negative, exceeds ``int64`` max, or is null where nulls are
210
+ not allowed.
211
+ """
212
+ # Scalars. Order matters: bool is a subclass of int, and np.bool_ must be
213
+ # rejected before the integer check.
214
+ if isinstance(values, (bool, np.bool_)):
215
+ raise TypeError(f"{name}: boolean values are not valid identifiers.")
216
+ if isinstance(values, (int, np.integer)):
217
+ iv = int(values)
218
+ if iv < 0:
219
+ raise _range_error(name, "negative")
220
+ if iv > _INT64_MAX:
221
+ raise _range_error(name, "too_large")
222
+ return np.int64(iv)
223
+ if isinstance(values, (float, np.floating)):
224
+ raise TypeError(
225
+ f"{name}: float identifiers are not accepted; a float may already "
226
+ f"have lost precision above 2**53."
227
+ )
228
+
229
+ if isinstance(values, pd.Index):
230
+ int64, null_mask = _extract_int64(values, allow_null, name)
231
+ return pd.Index(_wrap_null(int64, null_mask, allow_null), name=values.name)
232
+ if isinstance(values, pd.Series):
233
+ int64, null_mask = _extract_int64(values, allow_null, name)
234
+ return pd.Series(
235
+ _wrap_null(int64, null_mask, allow_null),
236
+ index=values.index,
237
+ name=values.name,
238
+ )
239
+
240
+ int64, null_mask = _extract_int64(values, allow_null, name)
241
+ return _wrap_null(int64, null_mask, allow_null)
242
+
243
+
244
+ def _canonicalize_index(index: pd.Index) -> pd.Index:
245
+ """Return ``index`` in canonical int64 form if it holds integer identifiers.
246
+
247
+ Non-integer indexes (e.g. a string or float index) are returned untouched
248
+ so canonicalization never changes behavior for data that is not an ID.
249
+ """
250
+ dtype = index.dtype
251
+ if dtype.kind in ("i", "u") or (
252
+ pd.api.types.is_extension_array_dtype(dtype)
253
+ and pd.api.types.is_integer_dtype(dtype)
254
+ ):
255
+ return canonicalize_ids(index, name="node index")
256
+ return index
257
+
258
+
259
+ def mask_and_remap(
260
+ arr: np.ndarray,
261
+ mask: Union[np.ndarray, list],
262
+ ) -> np.ndarray:
263
+ """Given an array in unmasked indexing and a mask,
264
+ return the array in remapped indexing and omit rows with masked values.
265
+
266
+ Parameters
267
+ ----------
268
+ arr :
269
+ NxM array of indices
270
+ mask :
271
+ 1D array of indices to mask, either as a boolean mask or as a list of indices
272
+ """
273
+ if np.array(mask).dtype == bool:
274
+ mask = np.where(mask)[0]
275
+ return _mask_and_remap(np.array(arr, dtype=int), mask)
276
+
277
+
278
+ def _mask_and_remap(
279
+ arr: np.ndarray,
280
+ mask: np.ndarray,
281
+ ) -> np.ndarray:
282
+ mask_dict = {k: v for k, v in zip(mask, range(len(mask)))}
283
+ mask_dict[-1] = -1
284
+
285
+ arr_offset = arr + 1
286
+ arr_mask_full = fastremap.remap(
287
+ fastremap.mask_except(arr_offset, list(mask + 1)) - 1,
288
+ mask_dict,
289
+ )
290
+ if len(arr_mask_full.shape) == 1:
291
+ return arr_mask_full[~np.any(arr_mask_full == -1)]
292
+ else:
293
+ return arr_mask_full[~np.any(arr_mask_full == -1, axis=1)]
294
+
295
+
296
+ class Layer:
297
+ def __init__(
298
+ self,
299
+ nodes: Union[pd.DataFrame, np.ndarray],
300
+ facets: Union[pd.DataFrame, np.ndarray],
301
+ spatial_columns: Optional[list] = None,
302
+ relation_columns: Optional[list] = None,
303
+ copy: bool = True,
304
+ **kwargs,
305
+ ):
306
+ """A class for representing a set of nodes and their relationships (facets).
307
+
308
+ Parameters
309
+ ----------
310
+ nodes :
311
+ A DataFrame or nx3 array of the nodes/vertices/points in the layer.
312
+ If an array is provided, it must be nx3 and the columns will be named
313
+ "x", "y", and "z" unless `spatial_columns` is provided.
314
+ facets :
315
+ A DataFrame or m x k array of the facets/edges/faces in the layer.
316
+ Some columns must correspond to indices in `nodes`, these are specified
317
+ by `relation_columns`.
318
+ spatial_columns :
319
+ A list of column names in `nodes` that correspond to spatial coordinates.
320
+ If not provided and `nodes` is a DataFrame, all columns will be used. If
321
+ `nodes` is an array, defaults to ["x", "y", "z"].
322
+ relation_columns :
323
+ A list of column names in `facets` that correspond to indices in `nodes`.
324
+ If not provided and `facets` is a DataFrame, all columns will be used.
325
+ copy :
326
+ Whether to copy the input DataFrames. If False, the input DataFrames
327
+ may be modified in place.
328
+ """
329
+ if not isinstance(nodes, pd.DataFrame):
330
+ if isinstance(nodes, np.ndarray):
331
+ if nodes.shape[1] == 3:
332
+ nodes = pd.DataFrame(nodes, columns=DEFAULT_SPATIAL_COLUMNS)
333
+ if spatial_columns is None:
334
+ spatial_columns = DEFAULT_SPATIAL_COLUMNS
335
+ else:
336
+ raise ValueError("Nodes must be an nx3 array")
337
+ else:
338
+ raise ValueError("Nodes must be a DataFrame or an nx3 array")
339
+
340
+ if copy:
341
+ nodes = nodes.copy()
342
+ self.nodes: pd.DataFrame = nodes
343
+ # Node indexes hold identifiers, so pin them to Ossify's canonical int64
344
+ # representation. This keeps every downstream lookup (masking joins,
345
+ # link joins) in a single signed key space -- mixing int64 and uint64
346
+ # keys is what lets pandas collapse distinct IDs above 2**53 via float.
347
+ self.nodes.index = _canonicalize_index(self.nodes.index)
348
+
349
+ if spatial_columns is None:
350
+ spatial_columns = []
351
+ self.spatial_columns = spatial_columns
352
+
353
+ if facets is None:
354
+ facets = pd.DataFrame()
355
+ if not isinstance(facets, pd.DataFrame):
356
+ facets = pd.DataFrame(facets)
357
+ if relation_columns is None:
358
+ relation_columns = facets.columns.tolist()
359
+ if copy:
360
+ facets = facets.copy()
361
+ self.facets: pd.DataFrame = facets
362
+
363
+ if relation_columns is None:
364
+ relation_columns = []
365
+ self.relation_columns = relation_columns
366
+
367
+ @property
368
+ def vertices(self) -> np.ndarray:
369
+ """Array of the spatial coordinates of the vertices"""
370
+ return self.vertices_df.to_numpy(dtype=float)
371
+
372
+ @property
373
+ def vertices_df(self) -> pd.DataFrame:
374
+ """DataFrame of the spatial coordinates of the vertices"""
375
+ return (
376
+ self.nodes[self.spatial_columns]
377
+ if self.spatial_columns is not None
378
+ else self.nodes
379
+ )
380
+
381
+ @property
382
+ def points(self) -> np.ndarray:
383
+ """Alias for vertices"""
384
+ return self.vertices
385
+
386
+ @property
387
+ def n_nodes(self) -> int:
388
+ """Number of nodes in the layer."""
389
+ return len(self.nodes)
390
+
391
+ @property
392
+ def n_vertices(self) -> int:
393
+ """Number of vertices in the layer. Alias for n_nodes."""
394
+ return self.n_nodes
395
+
396
+ @property
397
+ def n_points(self) -> int:
398
+ """Number of points in the layer. Alias for n_nodes."""
399
+ return self.n_nodes
400
+
401
+ @property
402
+ def n_facets(self) -> int:
403
+ """Number of facets (edges/faces) in the layer."""
404
+ return len(self.facets)
405
+
406
+ @property
407
+ def nodes_index(self) -> pd.Index:
408
+ """Index of the nodes DataFrame."""
409
+ return self.nodes.index
410
+
411
+ @property
412
+ def vertices_index(self) -> pd.Index:
413
+ """Index of the vertices. Alias for nodes_index."""
414
+ return self.nodes_index
415
+
416
+ @property
417
+ def points_index(self) -> pd.Index:
418
+ """Index of the points. Alias for nodes_index."""
419
+ return self.nodes_index
420
+
421
+ @property
422
+ def facets_index(self) -> pd.Index:
423
+ """Index of the facets DataFrame."""
424
+ return self.facets.index
425
+
426
+ @property
427
+ def edge_index(self) -> pd.Index:
428
+ """Index of the edges. Alias for facets_index."""
429
+ return self.facets_index
430
+
431
+ @property
432
+ def facets_positional(self) -> np.ndarray:
433
+ """Array of the facets in positional indexing, such that 0 corresponds to the
434
+ first node in its current node index ordering"""
435
+ return mask_and_remap(self.facets[self.relation_columns], self.nodes.index)
436
+
437
+ def query_nodes(self, query_str: str):
438
+ """Query the nodes DataFrame and return a new layer with the
439
+ corresponding nodes and facets.
440
+
441
+ Parameters
442
+ ----------
443
+ query_str :
444
+ A query string to pass to `pd.DataFrame.query` on the nodes DataFrame.
445
+
446
+ Returns
447
+ -------
448
+ :
449
+ A new layer with the queried nodes and corresponding facets.
450
+
451
+ Notes
452
+ -----
453
+ When masking by nodes, only relationships that reference exclusively the
454
+ remaining nodes are kept.
455
+ """
456
+ new_nodes = self.nodes.query(query_str)
457
+ new_index = new_nodes.index
458
+ return self.mask_by_node_index(new_index, new_nodes=new_nodes)
459
+
460
+ def mask_nodes(self, mask: np.ndarray):
461
+ """Mask the nodes DataFrame and return a new layer with the
462
+ corresponding nodes and facets.
463
+
464
+ Parameters
465
+ ----------
466
+ mask :
467
+ A boolean mask array to filter the nodes DataFrame. This masking is applied
468
+ in positional indexing (i.e. order, not key matters).
469
+
470
+ Returns
471
+ -------
472
+ :
473
+ A new layer with the masked nodes and corresponding facets.
474
+
475
+ Notes
476
+ -----
477
+ When masking by nodes, only relationships that reference exclusively the
478
+ remaining nodes are kept.
479
+ """
480
+ new_nodes = self.nodes.iloc[mask]
481
+ new_index = new_nodes.index
482
+ return self.mask_by_node_index(new_index, new_nodes=new_nodes)
483
+
484
+ def mask_by_node_index(
485
+ self,
486
+ new_index: Union[np.ndarray, pd.Index, pd.Series],
487
+ new_nodes: Optional[pd.DataFrame] = None,
488
+ ):
489
+ """Create a new layer containing only the specified nodes and their facets.
490
+
491
+ Parameters
492
+ ----------
493
+ new_index :
494
+ Index of nodes to keep in the new layer.
495
+ new_nodes :
496
+ Pre-filtered nodes DataFrame. If None, nodes will be filtered automatically
497
+ based on new_index.
498
+
499
+ Returns
500
+ -------
501
+ :
502
+ A new layer instance containing only the specified nodes and facets that
503
+ reference those nodes.
504
+
505
+ Notes
506
+ -----
507
+ Only facets that reference exclusively the nodes in new_index are kept.
508
+ """
509
+ if new_nodes is None:
510
+ new_nodes = self.nodes.loc[self.nodes.index.intersection(new_index)]
511
+
512
+ new_facets = self.facets[
513
+ self.facets[self.relation_columns].isin(new_index).all(axis=1)
514
+ ]
515
+ out = self.__class__((new_nodes, new_facets), **self.get_params())
516
+ return out
517
+
518
+ @property
519
+ def layer_type(self) -> str:
520
+ """String identifier of the layer type (e.g., 'mesh', 'points', 'graph')."""
521
+ return str(self.__class__).strip(">'").split(".")[-1].lower()
522
+
523
+ def get_params(self) -> dict:
524
+ """Get the parameters used to initialize this layer.
525
+
526
+ Returns
527
+ -------
528
+ :
529
+ Dictionary containing layer initialization parameters.
530
+ """
531
+ return {
532
+ "spatial_columns": self.spatial_columns,
533
+ "relation_columns": self.relation_columns,
534
+ }
@@ -5,7 +5,7 @@ from typing import Any, Optional, Union
5
5
  import numpy as np
6
6
  import pandas as pd
7
7
 
8
- from .base import Layer
8
+ from .base import Layer, canonicalize_ids
9
9
  from .graph import Graph
10
10
  from .mapping import project_points_to_nearest
11
11
  from .mesh import Mesh
@@ -349,6 +349,12 @@ class MorphSync:
349
349
  mapping_df.name = target
350
350
  mapping_df = mapping_df.to_frame().reset_index()
351
351
 
352
+ # Canonicalize both endpoint columns to int64 so a link never stores
353
+ # one side as uint64 and the other as int64. A mixed-dtype link is what
354
+ # lets a later join coerce keys through float and mismatch IDs > 2**53.
355
+ mapping_df[source] = canonicalize_ids(mapping_df[source], name=source)
356
+ mapping_df[target] = canonicalize_ids(mapping_df[target], name=target)
357
+
352
358
  self.links[(source, target)] = mapping_df
353
359
  self.links[(target, source)] = mapping_df
354
360
 
@@ -443,9 +449,11 @@ class MorphSync:
443
449
  """
444
450
  if source_index is None:
445
451
  source_index = self.layers[source].nodes_index
446
- else:
447
- if not isinstance(source_index, pd.Index):
448
- source_index = pd.Index(source_index)
452
+ # Canonicalize the source key space to int64 up front so the first
453
+ # join key matches the (also int64) link index exactly.
454
+ source_index = canonicalize_ids(source_index, name=source)
455
+ if not isinstance(source_index, pd.Index):
456
+ source_index = pd.Index(source_index)
449
457
 
450
458
  joined_mapping = pd.DataFrame(index=source_index)
451
459
  joined_mapping[source] = source_index
@@ -453,10 +461,34 @@ class MorphSync:
453
461
  path = self.get_link_path(source, target)
454
462
  for i in range(len(path) - 1):
455
463
  current_source, current_target = path[i], path[i + 1]
456
- mapping_series = (
457
- self.links[(current_source, current_target)]
458
- .set_index(current_source)[current_target]
459
- .astype("Int64")
464
+ link_df = self.links[(current_source, current_target)]
465
+
466
+ # Both key spaces participating in the join must be canonicalized
467
+ # to int64 *before pandas sees them*. A stored link may still carry
468
+ # legacy mixed int64/uint64 dtypes (e.g. from an older .osy file);
469
+ # joining a uint64 index against an int64/Int64 key column makes
470
+ # pandas coerce to float64 and silently collapse IDs above 2**53.
471
+ key_index = canonicalize_ids(
472
+ pd.Index(link_df[current_source]), name=current_source
473
+ )
474
+ # A stored link endpoint is always a real, non-null identifier, so
475
+ # the target values are canonicalized strictly (a null here is
476
+ # malformed and raises). They are then held as nullable Int64 so
477
+ # that the <NA>s the left join *introduces* for unmatched rows do
478
+ # not force a float cast -- a float target ID would lose precision
479
+ # before it becomes the next hop's join key.
480
+ values = canonicalize_ids(link_df[current_target], name=current_target)
481
+ mapping_series = pd.Series(
482
+ pd.array(values, dtype="Int64"),
483
+ index=key_index,
484
+ name=current_target,
485
+ )
486
+
487
+ # The join key column of joined_mapping may itself carry <NA> from a
488
+ # previous hop; canonicalize it (nulls allowed) so it stays in the
489
+ # int64 key space alongside key_index.
490
+ joined_mapping[current_source] = canonicalize_ids(
491
+ joined_mapping[current_source], allow_null=True, name=current_source
460
492
  )
461
493
 
462
494
  try:
@@ -693,10 +725,40 @@ class MorphSync:
693
725
  new_morphology._add_layer(
694
726
  other_layer_name, other_layer.mask_by_node_index(other_indices)
695
727
  )
696
- new_morphology.links = self.links
728
+
729
+ # Rebuild links restricted to the retained nodes of each layer rather
730
+ # than copying the original (unpruned) tables. Carrying whole link
731
+ # tables over would leave endpoints that reference nodes no longer in
732
+ # their layer; pruning here makes "retained links reference retained
733
+ # objects" true by construction, not by a later runtime check.
734
+ new_morphology.links = self._prune_links(new_morphology)
697
735
 
698
736
  return new_morphology
699
737
 
738
+ def _prune_links(self, new_morphology: "MorphSync") -> dict:
739
+ """Restrict every link table to endpoints present in the masked layers.
740
+
741
+ Both endpoint columns and both layer indexes are canonicalized to int64
742
+ before the membership test so the ``isin`` lookup cannot mismatch a
743
+ uint64 key against an int64 index (the same float-collision hazard the
744
+ mapping joins avoid).
745
+ """
746
+ pruned: dict = {}
747
+ for (src, tgt), link_df in self.links.items():
748
+ if src not in new_morphology.layers or tgt not in new_morphology.layers:
749
+ continue
750
+ src_keys = pd.Index(canonicalize_ids(link_df[src], name=src))
751
+ tgt_keys = pd.Index(canonicalize_ids(link_df[tgt], name=tgt))
752
+ src_retained = canonicalize_ids(
753
+ new_morphology.layers[src].nodes_index, name=src
754
+ )
755
+ tgt_retained = canonicalize_ids(
756
+ new_morphology.layers[tgt].nodes_index, name=tgt
757
+ )
758
+ keep = src_keys.isin(src_retained) & tgt_keys.isin(tgt_retained)
759
+ pruned[(src, tgt)] = link_df[np.asarray(keep)]
760
+ return pruned
761
+
700
762
  def get_link_as_layer(self, source: str, target: str) -> Graph:
701
763
  """Create a Graph layer representing the mapping between two layers.
702
764
 
@@ -24,6 +24,7 @@ from scipy import sparse, spatial
24
24
 
25
25
  from . import graph_functions as gf
26
26
  from . import utils
27
+ from ._sync.base import canonicalize_ids
27
28
  from .sync_classes import *
28
29
 
29
30
  if TYPE_CHECKING:
@@ -1025,6 +1026,12 @@ class PointMixin(ABC):
1025
1026
  if len(mask) == self.n_vertices and np.issubdtype(mask.dtype, np.bool_):
1026
1027
  mask = mask.astype(bool)
1027
1028
  else:
1029
+ # `mask` is a set of vertex identifiers. Canonicalize it to int64
1030
+ # so the membership test shares the (also int64) vertex_index key
1031
+ # space -- np.isin over mixed int64/uint64 arrays otherwise picks
1032
+ # float64 as the common type and collides IDs above 2**53.
1033
+ if mask.dtype.kind in ("i", "u"):
1034
+ mask = canonicalize_ids(mask, name=self.layer_name)
1028
1035
  mask = np.isin(self.vertex_index, mask)
1029
1036
  return self._morphsync.apply_mask(
1030
1037
  layer_name=self.layer_name,
@@ -16,6 +16,7 @@ from scipy.sparse import load_npz, save_npz
16
16
 
17
17
  from ossify import utils
18
18
 
19
+ from ._sync.base import canonicalize_ids
19
20
  from .base import Cell
20
21
  from .data_layers import GraphLayer, Link, MeshLayer, PointCloudLayer, SkeletonLayer
21
22
 
@@ -912,6 +913,68 @@ def build_point_cloud(
912
913
  return cell
913
914
 
914
915
 
916
+ def _exact_integer_id_coverage(link_ids: pd.Index, source_ids: pd.Index) -> bool:
917
+ """Test whether two integer-ID domains are exactly the same set.
918
+
919
+ Both arguments must already be canonicalized ``int64`` pandas ``Index``
920
+ objects. The comparison is order-independent and stays entirely in the
921
+ integer domain -- it never coerces identifiers through ``float64`` -- so it
922
+ is safe for IDs above ``2**53``.
923
+
924
+ Parameters
925
+ ----------
926
+ link_ids :
927
+ The linkage column for a candidate source layer.
928
+ source_ids :
929
+ That layer's vertex index.
930
+
931
+ Returns
932
+ -------
933
+ :
934
+ ``True`` iff the two contain exactly the same set of IDs.
935
+ """
936
+ if len(link_ids) != len(source_ids):
937
+ return False
938
+ # ``symmetric_difference`` is empty iff neither side has an ID the other
939
+ # lacks. Using pandas ``Index`` set ops keeps the comparison in int64 and
940
+ # avoids materializing Python ``set`` objects over large ID arrays.
941
+ return len(link_ids.symmetric_difference(source_ids)) == 0
942
+
943
+
944
+ def _linkage_source_error(linkage_pair, link_df, cell, sample_size: int = 10) -> str:
945
+ """Build a concise diagnostic message for an unresolvable linkage source.
946
+
947
+ Reports, for each endpoint, the layer vertex count, the linkage row count,
948
+ the number of unique linkage IDs, and the missing/extra ID counts, plus a
949
+ small bounded sample of the offending IDs. This replaces the enormous
950
+ pandas ``KeyError`` that a bad ``.loc`` reindex would otherwise raise.
951
+ """
952
+ lines = [
953
+ f"Could not infer a valid source layer for linkage "
954
+ f"({linkage_pair[0]!r}, {linkage_pair[1]!r}) with {len(link_df)} rows.",
955
+ "A source endpoint must map exactly one linkage row to each of its "
956
+ "vertices (unique, complete coverage). Neither endpoint qualifies:",
957
+ ]
958
+ for source in linkage_pair:
959
+ source_ids = canonicalize_ids(
960
+ pd.Index(cell._all_objects[source].vertex_index), name=source
961
+ )
962
+ link_ids = canonicalize_ids(pd.Index(link_df[source]), name=source)
963
+ unique_link_ids = link_ids.unique()
964
+ missing = source_ids.difference(link_ids)
965
+ extra = link_ids.difference(source_ids)
966
+ lines.append(
967
+ f" - {source!r}: vertices={len(source_ids)}, "
968
+ f"link_rows={len(link_ids)}, unique_link_ids={len(unique_link_ids)}, "
969
+ f"missing_source_ids={len(missing)}, extra_link_ids={len(extra)}"
970
+ )
971
+ if len(missing):
972
+ lines.append(f" missing sample: {missing[:sample_size].tolist()}")
973
+ if len(extra):
974
+ lines.append(f" extra sample: {extra[:sample_size].tolist()}")
975
+ return "\n".join(lines)
976
+
977
+
915
978
  def build_linkage(
916
979
  linkage_pair,
917
980
  tf,
@@ -919,17 +982,52 @@ def build_linkage(
919
982
  ) -> Cell:
920
983
  prefix = f"linkage/{linkage_pair[0]}/{linkage_pair[1]}"
921
984
  link_df = load_dataframe(f"{prefix}/linkage.feather", tf)
922
- # Determine source based on the length of the vertices in the mapping and in the skeleton layer
923
- if len(link_df) == len(cell._all_objects[linkage_pair[0]].nodes):
924
- source_layer = linkage_pair[0]
925
- target_layer = linkage_pair[1]
926
- elif len(link_df) == len(cell._all_objects[linkage_pair[1]].nodes):
927
- source_layer = linkage_pair[1]
928
- target_layer = linkage_pair[0]
929
- else:
930
- raise ValueError("Linkage DataFrame does not match any layer.")
985
+ # Legacy .osy files may store link columns with mixed int64/uint64 dtypes
986
+ # (dtype optimization only downcast signed ints, leaving uint64 untouched).
987
+ # Canonicalize both columns to int64 before any label-based reindex below so
988
+ # that lookup cannot collide two IDs above 2**53 through a float coercion of
989
+ # mismatched signed/unsigned keys.
990
+ for col in linkage_pair:
991
+ if col in link_df.columns:
992
+ link_df[col] = canonicalize_ids(link_df[col], name=col)
993
+
994
+ # The archive sorts the pair names, so serialized column order does not
995
+ # preserve the original source direction. Infer the source by exact
996
+ # source-domain validation rather than row count alone: a layer qualifies
997
+ # as source only when its linkage column has no missing IDs, is unique, has
998
+ # one row per vertex, and covers exactly that layer's vertex index. Row
999
+ # count alone is ambiguous whenever the two layers have equal cardinality
1000
+ # (e.g. a many-to-one graph <-> annotation link where the counts coincide).
1001
+ candidates = []
1002
+ for source, target in (
1003
+ (linkage_pair[0], linkage_pair[1]),
1004
+ (linkage_pair[1], linkage_pair[0]),
1005
+ ):
1006
+ source_ids = canonicalize_ids(
1007
+ pd.Index(cell._all_objects[source].vertex_index), name=source
1008
+ )
1009
+ link_ids = canonicalize_ids(pd.Index(link_df[source]), name=source)
1010
+ if (
1011
+ len(link_ids) == len(source_ids)
1012
+ and link_ids.is_unique
1013
+ and _exact_integer_id_coverage(link_ids, source_ids)
1014
+ ):
1015
+ candidates.append((source, target))
1016
+
1017
+ if not candidates:
1018
+ raise ValueError(_linkage_source_error(linkage_pair, link_df, cell))
1019
+
1020
+ # Zero candidates raises above. One candidate is the unambiguous source.
1021
+ # Two candidates means a true bijection: both columns are unique and cover
1022
+ # their layers exactly, so the mapping is one-to-one. MorphSync stores every
1023
+ # link in both directions (see ``add_link``), so either choice yields the
1024
+ # same bidirectional link; pick the first deterministically.
1025
+ source_layer, target_layer = candidates[0]
931
1026
 
932
1027
  layer = cell._all_objects[source_layer]
1028
+ # Validation above guarantees the source column is unique and covers exactly
1029
+ # the source vertex index, so this reindex is a total, collision-free
1030
+ # reordering -- it can no longer raise a large KeyError.
933
1031
  cell._all_objects[source_layer]._process_linkage(
934
1032
  Link(
935
1033
  link_df.set_index(source_layer).loc[layer.vertex_index][target_layer],
@@ -1,280 +0,0 @@
1
- from typing import Optional, Union
2
-
3
- import fastremap
4
- import numpy as np
5
- import pandas as pd
6
-
7
- DEFAULT_SPATIAL_COLUMNS = ["x", "y", "z"]
8
-
9
-
10
- def mask_and_remap(
11
- arr: np.ndarray,
12
- mask: Union[np.ndarray, list],
13
- ) -> np.ndarray:
14
- """Given an array in unmasked indexing and a mask,
15
- return the array in remapped indexing and omit rows with masked values.
16
-
17
- Parameters
18
- ----------
19
- arr :
20
- NxM array of indices
21
- mask :
22
- 1D array of indices to mask, either as a boolean mask or as a list of indices
23
- """
24
- if np.array(mask).dtype == bool:
25
- mask = np.where(mask)[0]
26
- return _mask_and_remap(np.array(arr, dtype=int), mask)
27
-
28
-
29
- def _mask_and_remap(
30
- arr: np.ndarray,
31
- mask: np.ndarray,
32
- ) -> np.ndarray:
33
- mask_dict = {k: v for k, v in zip(mask, range(len(mask)))}
34
- mask_dict[-1] = -1
35
-
36
- arr_offset = arr + 1
37
- arr_mask_full = fastremap.remap(
38
- fastremap.mask_except(arr_offset, list(mask + 1)) - 1,
39
- mask_dict,
40
- )
41
- if len(arr_mask_full.shape) == 1:
42
- return arr_mask_full[~np.any(arr_mask_full == -1)]
43
- else:
44
- return arr_mask_full[~np.any(arr_mask_full == -1, axis=1)]
45
-
46
-
47
- class Layer:
48
- def __init__(
49
- self,
50
- nodes: Union[pd.DataFrame, np.ndarray],
51
- facets: Union[pd.DataFrame, np.ndarray],
52
- spatial_columns: Optional[list] = None,
53
- relation_columns: Optional[list] = None,
54
- copy: bool = True,
55
- **kwargs,
56
- ):
57
- """A class for representing a set of nodes and their relationships (facets).
58
-
59
- Parameters
60
- ----------
61
- nodes :
62
- A DataFrame or nx3 array of the nodes/vertices/points in the layer.
63
- If an array is provided, it must be nx3 and the columns will be named
64
- "x", "y", and "z" unless `spatial_columns` is provided.
65
- facets :
66
- A DataFrame or m x k array of the facets/edges/faces in the layer.
67
- Some columns must correspond to indices in `nodes`, these are specified
68
- by `relation_columns`.
69
- spatial_columns :
70
- A list of column names in `nodes` that correspond to spatial coordinates.
71
- If not provided and `nodes` is a DataFrame, all columns will be used. If
72
- `nodes` is an array, defaults to ["x", "y", "z"].
73
- relation_columns :
74
- A list of column names in `facets` that correspond to indices in `nodes`.
75
- If not provided and `facets` is a DataFrame, all columns will be used.
76
- copy :
77
- Whether to copy the input DataFrames. If False, the input DataFrames
78
- may be modified in place.
79
- """
80
- if not isinstance(nodes, pd.DataFrame):
81
- if isinstance(nodes, np.ndarray):
82
- if nodes.shape[1] == 3:
83
- nodes = pd.DataFrame(nodes, columns=DEFAULT_SPATIAL_COLUMNS)
84
- if spatial_columns is None:
85
- spatial_columns = DEFAULT_SPATIAL_COLUMNS
86
- else:
87
- raise ValueError("Nodes must be an nx3 array")
88
- else:
89
- raise ValueError("Nodes must be a DataFrame or an nx3 array")
90
-
91
- if copy:
92
- nodes = nodes.copy()
93
- self.nodes: pd.DataFrame = nodes
94
-
95
- if spatial_columns is None:
96
- spatial_columns = []
97
- self.spatial_columns = spatial_columns
98
-
99
- if facets is None:
100
- facets = pd.DataFrame()
101
- if not isinstance(facets, pd.DataFrame):
102
- facets = pd.DataFrame(facets)
103
- if relation_columns is None:
104
- relation_columns = facets.columns.tolist()
105
- if copy:
106
- facets = facets.copy()
107
- self.facets: pd.DataFrame = facets
108
-
109
- if relation_columns is None:
110
- relation_columns = []
111
- self.relation_columns = relation_columns
112
-
113
- @property
114
- def vertices(self) -> np.ndarray:
115
- """Array of the spatial coordinates of the vertices"""
116
- return self.vertices_df.to_numpy(dtype=float)
117
-
118
- @property
119
- def vertices_df(self) -> pd.DataFrame:
120
- """DataFrame of the spatial coordinates of the vertices"""
121
- return (
122
- self.nodes[self.spatial_columns]
123
- if self.spatial_columns is not None
124
- else self.nodes
125
- )
126
-
127
- @property
128
- def points(self) -> np.ndarray:
129
- """Alias for vertices"""
130
- return self.vertices
131
-
132
- @property
133
- def n_nodes(self) -> int:
134
- """Number of nodes in the layer."""
135
- return len(self.nodes)
136
-
137
- @property
138
- def n_vertices(self) -> int:
139
- """Number of vertices in the layer. Alias for n_nodes."""
140
- return self.n_nodes
141
-
142
- @property
143
- def n_points(self) -> int:
144
- """Number of points in the layer. Alias for n_nodes."""
145
- return self.n_nodes
146
-
147
- @property
148
- def n_facets(self) -> int:
149
- """Number of facets (edges/faces) in the layer."""
150
- return len(self.facets)
151
-
152
- @property
153
- def nodes_index(self) -> pd.Index:
154
- """Index of the nodes DataFrame."""
155
- return self.nodes.index
156
-
157
- @property
158
- def vertices_index(self) -> pd.Index:
159
- """Index of the vertices. Alias for nodes_index."""
160
- return self.nodes_index
161
-
162
- @property
163
- def points_index(self) -> pd.Index:
164
- """Index of the points. Alias for nodes_index."""
165
- return self.nodes_index
166
-
167
- @property
168
- def facets_index(self) -> pd.Index:
169
- """Index of the facets DataFrame."""
170
- return self.facets.index
171
-
172
- @property
173
- def edge_index(self) -> pd.Index:
174
- """Index of the edges. Alias for facets_index."""
175
- return self.facets_index
176
-
177
- @property
178
- def facets_positional(self) -> np.ndarray:
179
- """Array of the facets in positional indexing, such that 0 corresponds to the
180
- first node in its current node index ordering"""
181
- return mask_and_remap(self.facets[self.relation_columns], self.nodes.index)
182
-
183
- def query_nodes(self, query_str: str):
184
- """Query the nodes DataFrame and return a new layer with the
185
- corresponding nodes and facets.
186
-
187
- Parameters
188
- ----------
189
- query_str :
190
- A query string to pass to `pd.DataFrame.query` on the nodes DataFrame.
191
-
192
- Returns
193
- -------
194
- :
195
- A new layer with the queried nodes and corresponding facets.
196
-
197
- Notes
198
- -----
199
- When masking by nodes, only relationships that reference exclusively the
200
- remaining nodes are kept.
201
- """
202
- new_nodes = self.nodes.query(query_str)
203
- new_index = new_nodes.index
204
- return self.mask_by_node_index(new_index, new_nodes=new_nodes)
205
-
206
- def mask_nodes(self, mask: np.ndarray):
207
- """Mask the nodes DataFrame and return a new layer with the
208
- corresponding nodes and facets.
209
-
210
- Parameters
211
- ----------
212
- mask :
213
- A boolean mask array to filter the nodes DataFrame. This masking is applied
214
- in positional indexing (i.e. order, not key matters).
215
-
216
- Returns
217
- -------
218
- :
219
- A new layer with the masked nodes and corresponding facets.
220
-
221
- Notes
222
- -----
223
- When masking by nodes, only relationships that reference exclusively the
224
- remaining nodes are kept.
225
- """
226
- new_nodes = self.nodes.iloc[mask]
227
- new_index = new_nodes.index
228
- return self.mask_by_node_index(new_index, new_nodes=new_nodes)
229
-
230
- def mask_by_node_index(
231
- self,
232
- new_index: Union[np.ndarray, pd.Index, pd.Series],
233
- new_nodes: Optional[pd.DataFrame] = None,
234
- ):
235
- """Create a new layer containing only the specified nodes and their facets.
236
-
237
- Parameters
238
- ----------
239
- new_index :
240
- Index of nodes to keep in the new layer.
241
- new_nodes :
242
- Pre-filtered nodes DataFrame. If None, nodes will be filtered automatically
243
- based on new_index.
244
-
245
- Returns
246
- -------
247
- :
248
- A new layer instance containing only the specified nodes and facets that
249
- reference those nodes.
250
-
251
- Notes
252
- -----
253
- Only facets that reference exclusively the nodes in new_index are kept.
254
- """
255
- if new_nodes is None:
256
- new_nodes = self.nodes.loc[self.nodes.index.intersection(new_index)]
257
-
258
- new_facets = self.facets[
259
- self.facets[self.relation_columns].isin(new_index).all(axis=1)
260
- ]
261
- out = self.__class__((new_nodes, new_facets), **self.get_params())
262
- return out
263
-
264
- @property
265
- def layer_type(self) -> str:
266
- """String identifier of the layer type (e.g., 'mesh', 'points', 'graph')."""
267
- return str(self.__class__).strip(">'").split(".")[-1].lower()
268
-
269
- def get_params(self) -> dict:
270
- """Get the parameters used to initialize this layer.
271
-
272
- Returns
273
- -------
274
- :
275
- Dictionary containing layer initialization parameters.
276
- """
277
- return {
278
- "spatial_columns": self.spatial_columns,
279
- "relation_columns": self.relation_columns,
280
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes