ossify 0.2.1__tar.gz → 0.2.3__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.1 → ossify-0.2.3}/PKG-INFO +1 -1
  2. {ossify-0.2.1 → ossify-0.2.3}/pyproject.toml +18 -4
  3. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/__init__.py +1 -1
  4. ossify-0.2.3/src/ossify/_sync/base.py +534 -0
  5. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/_sync/morph.py +71 -9
  6. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/base.py +24 -1
  7. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/data_layers.py +83 -13
  8. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/file_io.py +9 -0
  9. ossify-0.2.1/src/ossify/_sync/base.py +0 -280
  10. {ossify-0.2.1 → ossify-0.2.3}/.github/workflows/mkdocs_publish.yml +0 -0
  11. {ossify-0.2.1 → ossify-0.2.3}/.github/workflows/python-package.yml +0 -0
  12. {ossify-0.2.1 → ossify-0.2.3}/.gitignore +0 -0
  13. {ossify-0.2.1 → ossify-0.2.3}/.pre-commit-config.yaml +0 -0
  14. {ossify-0.2.1 → ossify-0.2.3}/LICENSE +0 -0
  15. {ossify-0.2.1 → ossify-0.2.3}/README.md +0 -0
  16. {ossify-0.2.1 → ossify-0.2.3}/mkdocs.yml +0 -0
  17. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/_sync/__init__.py +0 -0
  18. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/_sync/graph.py +0 -0
  19. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/_sync/mapping.py +0 -0
  20. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/_sync/mesh.py +0 -0
  21. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/_sync/points.py +0 -0
  22. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/_sync/table.py +0 -0
  23. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/algorithms.py +0 -0
  24. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/compartment_models/minnie65_ds15_us0_bd0.json +0 -0
  25. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/compartment_models/v1dd_ds15_us0_bd0.json +0 -0
  26. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/compartment_models/xgb/minnie65_ds15_us0_bd0.ubj +0 -0
  27. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/compartment_models/xgb/v1dd_ds15_us0_bd0.ubj +0 -0
  28. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/compartments.py +0 -0
  29. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/graph_functions.py +0 -0
  30. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/plot.py +0 -0
  31. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/plot3d.py +0 -0
  32. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/plot_utils.py +0 -0
  33. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/structured_prediction.py +0 -0
  34. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/sync_classes.py +0 -0
  35. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/translate.py +0 -0
  36. {ossify-0.2.1 → ossify-0.2.3}/src/ossify/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ossify
3
- Version: 0.2.1
3
+ Version: 0.2.3
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.1"
9
+ version = "0.2.3"
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.1"
98
+ current_version = "0.2.3"
99
99
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
100
100
  serialize = ["{major}.{minor}.{patch}"]
101
101
  regex = false
@@ -136,8 +136,22 @@ cmd = "uv run bump-my-version bump"
136
136
  help = "Bump the version number in the project. Use with 'patch', 'minor', or 'major' to specify the version change."
137
137
 
138
138
  [tool.poe.tasks.test]
139
- cmd = "uv run pytest --cov=ossify tests"
140
- help = "Run pytest with code coverage."
139
+ cmd = "uv run pytest --cov=ossify --cov-branch --cov-report=term-missing tests"
140
+ help = "Run pytest with branch coverage and a missing-lines report."
141
+
142
+ [tool.pytest.ini_options]
143
+ # src-layout: put the package on the path so tests import ossify from src/
144
+ # during collection without relying on the editable install being present.
145
+ pythonpath = ["src"]
146
+
147
+ [tool.coverage.run]
148
+ branch = true
149
+ source = ["ossify"]
150
+
151
+ [tool.coverage.report]
152
+ # Surface partially-covered branches, not just uncovered lines.
153
+ show_missing = true
154
+ skip_covered = false
141
155
 
142
156
  [tool.poe.tasks.doc-preview]
143
157
  cmd = "uv run mkdocs serve"
@@ -5,7 +5,7 @@ from .base import *
5
5
  from .file_io import *
6
6
  from .translate import *
7
7
 
8
- __version__ = "0.2.1"
8
+ __version__ = "0.2.3"
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
 
@@ -848,6 +848,25 @@ class Cell:
848
848
  )
849
849
  return pd.concat(remap_features, axis=1)
850
850
 
851
+ def _close(self) -> None:
852
+ """Release this Cell's data and break the layer<->cell reference cycle.
853
+
854
+ Layers hold a back-reference to their Cell (and vice versa), so a Cell
855
+ is only reclaimed by the cyclic garbage collector, not by refcounting.
856
+ ``mask_context`` calls this on exit so the temporary masked copy is torn
857
+ down immediately -- freeing its (potentially large) data as soon as the
858
+ block ends rather than lingering until the next gc pass. The Cell must
859
+ not be used after it is closed.
860
+ """
861
+ if self._morphsync is None:
862
+ return
863
+ for obj in list(self._all_objects.values()):
864
+ obj._cell = None
865
+ obj._morphsync = None
866
+ self._managed_layers.clear()
867
+ self._annotations._layers.clear()
868
+ self._morphsync = None
869
+
851
870
  @contextlib.contextmanager
852
871
  def mask_context(
853
872
  self,
@@ -856,6 +875,10 @@ class Cell:
856
875
  ) -> Generator[Self, None, None]:
857
876
  """Create a masked version of the MeshWork object in a context state.
858
877
 
878
+ The masked Cell is a temporary scoped to the ``with`` block: it is torn
879
+ down when the block exits (including on error), so do not retain it or
880
+ any of its layers past the context.
881
+
859
882
  Parameters
860
883
  ----------
861
884
  layer: str
@@ -872,7 +895,7 @@ class Cell:
872
895
  try:
873
896
  yield nrn_out
874
897
  finally:
875
- pass
898
+ nrn_out._close()
876
899
 
877
900
  def _cleanup_links(self, layer_name: str) -> None:
878
901
  """Remove all links involving the specified layer from MorphSync."""
@@ -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:
@@ -200,9 +201,11 @@ class EdgeMixin(ABC):
200
201
  )
201
202
  if limit is None:
202
203
  limit = np.inf
204
+ # source_target_distances returns a (len(sources), len(targets)) matrix
205
+ # and indexes by these arrays, so scalar inputs must be promoted to 1-d.
203
206
  return gf.source_target_distances(
204
- sources=sources,
205
- targets=targets,
207
+ sources=np.atleast_1d(sources),
208
+ targets=np.atleast_1d(targets),
206
209
  csgraph=self.csgraph_undirected,
207
210
  limit=limit,
208
211
  )
@@ -305,6 +308,16 @@ class FaceMixin(ABC):
305
308
  )
306
309
  return self._csgraph
307
310
 
311
+ def _reset_derived_properties(self) -> None:
312
+ """Reset cached properties derived from vertex positions.
313
+
314
+ The distance-weighted ``csgraph`` and the ``trimesh`` object both embed
315
+ vertex coordinates, so they must be dropped when the geometry changes
316
+ (e.g. after a spatial transform).
317
+ """
318
+ self._csgraph = None
319
+ self._trimesh = None
320
+
308
321
  def _map_faces_to_index(
309
322
  self, faces: np.ndarray, vertex_indices: np.ndarray
310
323
  ) -> np.ndarray:
@@ -472,7 +485,12 @@ class PointMixin(ABC):
472
485
  vertex_index_map = self.vertex_index_map
473
486
  else:
474
487
  vertex_index_map = {v: i for i, v in enumerate(vertex_index)}
475
- vertices = fastremap.remap(vertices, vertex_index_map)
488
+ # fastremap.remap rejects 0-d arrays, so a scalar vertex would crash
489
+ # here. Remap on a 1-d view and restore the caller's shape, keeping
490
+ # scalar-in -> scalar-out (as the positional path already does).
491
+ vertices = fastremap.remap(
492
+ np.atleast_1d(vertices), vertex_index_map
493
+ ).reshape(vertices.shape)
476
494
  vertices = np.array(vertices)
477
495
  return vertices, as_positional
478
496
 
@@ -1008,6 +1026,12 @@ class PointMixin(ABC):
1008
1026
  if len(mask) == self.n_vertices and np.issubdtype(mask.dtype, np.bool_):
1009
1027
  mask = mask.astype(bool)
1010
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)
1011
1035
  mask = np.isin(self.vertex_index, mask)
1012
1036
  return self._morphsync.apply_mask(
1013
1037
  layer_name=self.layer_name,
@@ -1090,9 +1114,10 @@ class PointMixin(ABC):
1090
1114
  new_morphsync.layers.pop(l)
1091
1115
  new_morphsync.links = {}
1092
1116
 
1093
- return self.__class__._from_existing(
1094
- new_morphsync=new_morphsync, old_obj=self._cell
1095
- )
1117
+ # Copy this layer alone, detached from any Cell: build it from this
1118
+ # layer's own metadata (``_from_existing`` reads ``.nodes``/``.edges``/
1119
+ # etc.), not from ``self._cell`` (a Cell has no such layer attributes).
1120
+ return self.__class__._from_existing(new_morphsync=new_morphsync, old_obj=self)
1096
1121
 
1097
1122
  def transform(
1098
1123
  self, transform: Union[np.ndarray, Callable], inplace: bool = False
@@ -1111,8 +1136,22 @@ class PointMixin(ABC):
1111
1136
  target.layer.nodes[target.spatial_columns] = transform(
1112
1137
  target.layer.vertices
1113
1138
  )
1139
+ # Moving vertices changes Euclidean distances, so any geometry-derived
1140
+ # caches (distance-weighted csgraph, trimesh, base graphs) are now stale.
1141
+ target._invalidate_spatial_caches()
1114
1142
  return target
1115
1143
 
1144
+ def _invalidate_spatial_caches(self) -> None:
1145
+ """Invalidate caches derived from vertex positions.
1146
+
1147
+ Called after a spatial transform. Distance-weighted graphs and mesh
1148
+ objects depend on coordinates and must be rebuilt. Layers without such
1149
+ caches (e.g. plain point clouds) do nothing.
1150
+ """
1151
+ reset = getattr(self, "_reset_derived_properties", None)
1152
+ if reset is not None:
1153
+ reset()
1154
+
1116
1155
  @contextlib.contextmanager
1117
1156
  def mask_context(self, mask: np.ndarray) -> Generator[Self, None, None]:
1118
1157
  """Context manager to temporarily apply a mask via the current layer.
@@ -1136,7 +1175,7 @@ class PointMixin(ABC):
1136
1175
  try:
1137
1176
  yield new_self
1138
1177
  finally:
1139
- pass
1178
+ new_self._close()
1140
1179
 
1141
1180
  def _register_cell(self, mws: "Cell") -> None:
1142
1181
  """Register a Cell object with this layer.
@@ -1148,6 +1187,18 @@ class PointMixin(ABC):
1148
1187
  """
1149
1188
  self._cell = mws
1150
1189
 
1190
+ def _close(self) -> None:
1191
+ """Release this layer's data and detach it from any Cell.
1192
+
1193
+ Used by ``mask_context`` (when it yields a bare layer rather than a
1194
+ Cell) so the temporary masked copy is torn down when the block exits.
1195
+ The layer must not be used after it is closed. See ``Cell._close``.
1196
+ """
1197
+ if self._morphsync is None:
1198
+ return
1199
+ self._cell = None
1200
+ self._morphsync = None
1201
+
1151
1202
  def get_unmapped_vertices(
1152
1203
  self,
1153
1204
  target_layers: Optional[Union[str, List[str]]] = None,
@@ -2117,6 +2168,17 @@ class SkeletonLayer(GraphLayer):
2117
2168
  super()._reset_derived_properties()
2118
2169
  self._dag_cache = gf.DAGCache()
2119
2170
 
2171
+ def _invalidate_spatial_caches(self) -> None:
2172
+ # Drops the lazy csgraph/dag caches, then refresh the eagerly-stored
2173
+ # distance-weighted base graph from the transformed coordinates. The
2174
+ # base graph is a snapshot of the original geometry, so this is only
2175
+ # possible when unmasked -- a masked skeleton's base graph spans
2176
+ # vertices that are absent here and cannot be rebuilt. (The binary base
2177
+ # graph is topology-only and unaffected by a spatial transform.)
2178
+ super()._invalidate_spatial_caches()
2179
+ if np.array_equal(self.vertex_index, self.base_vertex_index):
2180
+ self._base_properties["base_csgraph"] = self.csgraph
2181
+
2120
2182
  def _infer_root(self, root: Optional[int]) -> int:
2121
2183
  """Infer the root node from the graph structure or validate provided root.
2122
2184
 
@@ -2227,18 +2289,26 @@ class SkeletonLayer(GraphLayer):
2227
2289
  Self
2228
2290
  """
2229
2291
  self._reset_derived_properties()
2230
- if not as_positional:
2231
- new_root = np.flatnonzero(self.vertex_index == new_root)[0]
2292
+ # ``_root``/``base_root`` and ``_apply_root_to_edges`` all work in vertex
2293
+ # index space, so normalize a positional argument up front rather than
2294
+ # converting to positional (which the rest of the class does not expect).
2295
+ if as_positional:
2296
+ new_root = int(self.vertex_index[new_root])
2232
2297
  self._root = new_root
2233
- self._dag_cache.root = self._root
2234
- self._apply_root_to_edges(new_root)
2298
+ self._dag_cache.root = self.root_positional
2299
+ self._dag_cache.parent_node_array = self._apply_root_to_edges(new_root)
2300
+ # Rebuild the full base-property snapshot from the rerooted skeleton.
2301
+ # This must include every key ``_set_base_properties`` normally writes --
2302
+ # notably ``base_csgraph_binary`` (used by ``hops_to_root``) -- because a
2303
+ # provided dict replaces ``_base_properties`` wholesale.
2235
2304
  self._set_base_properties(
2236
2305
  base_properties={
2237
- "base_root": new_root,
2306
+ "base_root": self.root,
2307
+ "base_root_location": self.root_location,
2238
2308
  "base_vertex_index": self.vertex_index,
2239
2309
  "base_parent_array": self.parent_node_array,
2240
2310
  "base_csgraph": self.csgraph,
2241
- "base_root_location": self.root_location,
2311
+ "base_csgraph_binary": self.csgraph_binary,
2242
2312
  }
2243
2313
  )
2244
2314
  return self
@@ -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
 
@@ -919,6 +920,14 @@ def build_linkage(
919
920
  ) -> Cell:
920
921
  prefix = f"linkage/{linkage_pair[0]}/{linkage_pair[1]}"
921
922
  link_df = load_dataframe(f"{prefix}/linkage.feather", tf)
923
+ # Legacy .osy files may store link columns with mixed int64/uint64 dtypes
924
+ # (dtype optimization only downcast signed ints, leaving uint64 untouched).
925
+ # Canonicalize both columns to int64 before the label-based ``.loc`` reindex
926
+ # below so that lookup cannot collide two IDs above 2**53 through a float
927
+ # coercion of mismatched signed/unsigned keys.
928
+ for col in linkage_pair:
929
+ if col in link_df.columns:
930
+ link_df[col] = canonicalize_ids(link_df[col], name=col)
922
931
  # Determine source based on the length of the vertices in the mapping and in the skeleton layer
923
932
  if len(link_df) == len(cell._all_objects[linkage_pair[0]].nodes):
924
933
  source_layer = linkage_pair[0]
@@ -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