ossify 0.2.0__tar.gz → 0.2.2__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 (35) hide show
  1. {ossify-0.2.0 → ossify-0.2.2}/PKG-INFO +2 -2
  2. {ossify-0.2.0 → ossify-0.2.2}/pyproject.toml +19 -5
  3. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/__init__.py +1 -1
  4. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/base.py +24 -1
  5. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/data_layers.py +76 -13
  6. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/translate.py +42 -25
  7. {ossify-0.2.0 → ossify-0.2.2}/.github/workflows/mkdocs_publish.yml +0 -0
  8. {ossify-0.2.0 → ossify-0.2.2}/.github/workflows/python-package.yml +0 -0
  9. {ossify-0.2.0 → ossify-0.2.2}/.gitignore +0 -0
  10. {ossify-0.2.0 → ossify-0.2.2}/.pre-commit-config.yaml +0 -0
  11. {ossify-0.2.0 → ossify-0.2.2}/LICENSE +0 -0
  12. {ossify-0.2.0 → ossify-0.2.2}/README.md +0 -0
  13. {ossify-0.2.0 → ossify-0.2.2}/mkdocs.yml +0 -0
  14. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/_sync/__init__.py +0 -0
  15. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/_sync/base.py +0 -0
  16. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/_sync/graph.py +0 -0
  17. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/_sync/mapping.py +0 -0
  18. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/_sync/mesh.py +0 -0
  19. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/_sync/morph.py +0 -0
  20. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/_sync/points.py +0 -0
  21. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/_sync/table.py +0 -0
  22. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/algorithms.py +0 -0
  23. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/compartment_models/minnie65_ds15_us0_bd0.json +0 -0
  24. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/compartment_models/v1dd_ds15_us0_bd0.json +0 -0
  25. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/compartment_models/xgb/minnie65_ds15_us0_bd0.ubj +0 -0
  26. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/compartment_models/xgb/v1dd_ds15_us0_bd0.ubj +0 -0
  27. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/compartments.py +0 -0
  28. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/file_io.py +0 -0
  29. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/graph_functions.py +0 -0
  30. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/plot.py +0 -0
  31. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/plot3d.py +0 -0
  32. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/plot_utils.py +0 -0
  33. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/structured_prediction.py +0 -0
  34. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/sync_classes.py +0 -0
  35. {ossify-0.2.0 → ossify-0.2.2}/src/ossify/utils.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ossify
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Mesh and skeleton analysis
5
5
  Author-email: Casey Schneider-Mizell <caseysm@gmail.com>
6
6
  License-File: LICENSE
7
7
  Classifier: License :: OSI Approved :: MIT License
8
8
  Requires-Python: >=3.11
9
- Requires-Dist: caveclient>=7.6.0
9
+ Requires-Dist: caveclient>=8.2.1
10
10
  Requires-Dist: cloud-files>=5.8.0
11
11
  Requires-Dist: fastremap>=1.15.1
12
12
  Requires-Dist: matplotlib>=3.10.6
@@ -6,12 +6,12 @@ build-backend = "hatchling.build"
6
6
  allow-direct-references = true
7
7
  [project]
8
8
  name = "ossify"
9
- version = "0.2.0"
9
+ version = "0.2.2"
10
10
  description = "Mesh and skeleton analysis"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.11"
13
13
  dependencies = [
14
- "caveclient>=7.6.0",
14
+ "caveclient>=8.2.1",
15
15
  "numpy>=2.0.2",
16
16
  "scipy>=1.15",
17
17
  "trimesh>=4.6.2",
@@ -95,7 +95,7 @@ default-groups = ["dev", "docs", "lint", "profile", "viz"]
95
95
 
96
96
 
97
97
  [tool.bumpversion]
98
- current_version = "0.2.0"
98
+ current_version = "0.2.2"
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.0"
8
+ __version__ = "0.2.2"
9
9
 
10
10
 
11
11
  def __getattr__(name):
@@ -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."""
@@ -200,9 +200,11 @@ class EdgeMixin(ABC):
200
200
  )
201
201
  if limit is None:
202
202
  limit = np.inf
203
+ # source_target_distances returns a (len(sources), len(targets)) matrix
204
+ # and indexes by these arrays, so scalar inputs must be promoted to 1-d.
203
205
  return gf.source_target_distances(
204
- sources=sources,
205
- targets=targets,
206
+ sources=np.atleast_1d(sources),
207
+ targets=np.atleast_1d(targets),
206
208
  csgraph=self.csgraph_undirected,
207
209
  limit=limit,
208
210
  )
@@ -305,6 +307,16 @@ class FaceMixin(ABC):
305
307
  )
306
308
  return self._csgraph
307
309
 
310
+ def _reset_derived_properties(self) -> None:
311
+ """Reset cached properties derived from vertex positions.
312
+
313
+ The distance-weighted ``csgraph`` and the ``trimesh`` object both embed
314
+ vertex coordinates, so they must be dropped when the geometry changes
315
+ (e.g. after a spatial transform).
316
+ """
317
+ self._csgraph = None
318
+ self._trimesh = None
319
+
308
320
  def _map_faces_to_index(
309
321
  self, faces: np.ndarray, vertex_indices: np.ndarray
310
322
  ) -> np.ndarray:
@@ -472,7 +484,12 @@ class PointMixin(ABC):
472
484
  vertex_index_map = self.vertex_index_map
473
485
  else:
474
486
  vertex_index_map = {v: i for i, v in enumerate(vertex_index)}
475
- vertices = fastremap.remap(vertices, vertex_index_map)
487
+ # fastremap.remap rejects 0-d arrays, so a scalar vertex would crash
488
+ # here. Remap on a 1-d view and restore the caller's shape, keeping
489
+ # scalar-in -> scalar-out (as the positional path already does).
490
+ vertices = fastremap.remap(
491
+ np.atleast_1d(vertices), vertex_index_map
492
+ ).reshape(vertices.shape)
476
493
  vertices = np.array(vertices)
477
494
  return vertices, as_positional
478
495
 
@@ -1090,9 +1107,10 @@ class PointMixin(ABC):
1090
1107
  new_morphsync.layers.pop(l)
1091
1108
  new_morphsync.links = {}
1092
1109
 
1093
- return self.__class__._from_existing(
1094
- new_morphsync=new_morphsync, old_obj=self._cell
1095
- )
1110
+ # Copy this layer alone, detached from any Cell: build it from this
1111
+ # layer's own metadata (``_from_existing`` reads ``.nodes``/``.edges``/
1112
+ # etc.), not from ``self._cell`` (a Cell has no such layer attributes).
1113
+ return self.__class__._from_existing(new_morphsync=new_morphsync, old_obj=self)
1096
1114
 
1097
1115
  def transform(
1098
1116
  self, transform: Union[np.ndarray, Callable], inplace: bool = False
@@ -1111,8 +1129,22 @@ class PointMixin(ABC):
1111
1129
  target.layer.nodes[target.spatial_columns] = transform(
1112
1130
  target.layer.vertices
1113
1131
  )
1132
+ # Moving vertices changes Euclidean distances, so any geometry-derived
1133
+ # caches (distance-weighted csgraph, trimesh, base graphs) are now stale.
1134
+ target._invalidate_spatial_caches()
1114
1135
  return target
1115
1136
 
1137
+ def _invalidate_spatial_caches(self) -> None:
1138
+ """Invalidate caches derived from vertex positions.
1139
+
1140
+ Called after a spatial transform. Distance-weighted graphs and mesh
1141
+ objects depend on coordinates and must be rebuilt. Layers without such
1142
+ caches (e.g. plain point clouds) do nothing.
1143
+ """
1144
+ reset = getattr(self, "_reset_derived_properties", None)
1145
+ if reset is not None:
1146
+ reset()
1147
+
1116
1148
  @contextlib.contextmanager
1117
1149
  def mask_context(self, mask: np.ndarray) -> Generator[Self, None, None]:
1118
1150
  """Context manager to temporarily apply a mask via the current layer.
@@ -1136,7 +1168,7 @@ class PointMixin(ABC):
1136
1168
  try:
1137
1169
  yield new_self
1138
1170
  finally:
1139
- pass
1171
+ new_self._close()
1140
1172
 
1141
1173
  def _register_cell(self, mws: "Cell") -> None:
1142
1174
  """Register a Cell object with this layer.
@@ -1148,6 +1180,18 @@ class PointMixin(ABC):
1148
1180
  """
1149
1181
  self._cell = mws
1150
1182
 
1183
+ def _close(self) -> None:
1184
+ """Release this layer's data and detach it from any Cell.
1185
+
1186
+ Used by ``mask_context`` (when it yields a bare layer rather than a
1187
+ Cell) so the temporary masked copy is torn down when the block exits.
1188
+ The layer must not be used after it is closed. See ``Cell._close``.
1189
+ """
1190
+ if self._morphsync is None:
1191
+ return
1192
+ self._cell = None
1193
+ self._morphsync = None
1194
+
1151
1195
  def get_unmapped_vertices(
1152
1196
  self,
1153
1197
  target_layers: Optional[Union[str, List[str]]] = None,
@@ -2117,6 +2161,17 @@ class SkeletonLayer(GraphLayer):
2117
2161
  super()._reset_derived_properties()
2118
2162
  self._dag_cache = gf.DAGCache()
2119
2163
 
2164
+ def _invalidate_spatial_caches(self) -> None:
2165
+ # Drops the lazy csgraph/dag caches, then refresh the eagerly-stored
2166
+ # distance-weighted base graph from the transformed coordinates. The
2167
+ # base graph is a snapshot of the original geometry, so this is only
2168
+ # possible when unmasked -- a masked skeleton's base graph spans
2169
+ # vertices that are absent here and cannot be rebuilt. (The binary base
2170
+ # graph is topology-only and unaffected by a spatial transform.)
2171
+ super()._invalidate_spatial_caches()
2172
+ if np.array_equal(self.vertex_index, self.base_vertex_index):
2173
+ self._base_properties["base_csgraph"] = self.csgraph
2174
+
2120
2175
  def _infer_root(self, root: Optional[int]) -> int:
2121
2176
  """Infer the root node from the graph structure or validate provided root.
2122
2177
 
@@ -2227,18 +2282,26 @@ class SkeletonLayer(GraphLayer):
2227
2282
  Self
2228
2283
  """
2229
2284
  self._reset_derived_properties()
2230
- if not as_positional:
2231
- new_root = np.flatnonzero(self.vertex_index == new_root)[0]
2285
+ # ``_root``/``base_root`` and ``_apply_root_to_edges`` all work in vertex
2286
+ # index space, so normalize a positional argument up front rather than
2287
+ # converting to positional (which the rest of the class does not expect).
2288
+ if as_positional:
2289
+ new_root = int(self.vertex_index[new_root])
2232
2290
  self._root = new_root
2233
- self._dag_cache.root = self._root
2234
- self._apply_root_to_edges(new_root)
2291
+ self._dag_cache.root = self.root_positional
2292
+ self._dag_cache.parent_node_array = self._apply_root_to_edges(new_root)
2293
+ # Rebuild the full base-property snapshot from the rerooted skeleton.
2294
+ # This must include every key ``_set_base_properties`` normally writes --
2295
+ # notably ``base_csgraph_binary`` (used by ``hops_to_root``) -- because a
2296
+ # provided dict replaces ``_base_properties`` wholesale.
2235
2297
  self._set_base_properties(
2236
2298
  base_properties={
2237
- "base_root": new_root,
2299
+ "base_root": self.root,
2300
+ "base_root_location": self.root_location,
2238
2301
  "base_vertex_index": self.vertex_index,
2239
2302
  "base_parent_array": self.parent_node_array,
2240
2303
  "base_csgraph": self.csgraph,
2241
- "base_root_location": self.root_location,
2304
+ "base_csgraph_binary": self.csgraph_binary,
2242
2305
  }
2243
2306
  )
2244
2307
  return self
@@ -23,10 +23,10 @@ if TYPE_CHECKING:
23
23
  from caveclient import CAVEclientFull as CAVEclient
24
24
 
25
25
 
26
- # Synchronous bulk-skeleton download cap in the CAVE skeleton service
27
- # (caveclient.skeletonservice.MAX_BULK_SYNCHRONOUS_SKELETONS); larger lists are silently
28
- # truncated by the endpoint, so callers must chunk at this size.
29
- _BULK_SKELETON_CHUNK = 10
26
+ # Per-call cap for cached bulk-skeleton downloads via ``client.skeleton.fetch_skeletons``
27
+ # (caveclient.skeletonservice.MAX_BULK_CACHED_SKELETONS); longer lists are silently truncated
28
+ # by the endpoint, so callers must chunk at this size.
29
+ _BULK_SKELETON_CHUNK = 500
30
30
 
31
31
 
32
32
  class SWCCompartment(IntEnum):
@@ -563,23 +563,32 @@ def _get_bulk_skeletons(
563
563
  client: "CAVEclient",
564
564
  root_ids: list[int],
565
565
  skeleton_version: int,
566
- generate_missing: bool = False,
566
+ method: Literal["gcs", "server"] = "gcs",
567
567
  ) -> dict[int, dict]:
568
- """Download skeletons in bulk, chunked at the synchronous bulk cap.
569
-
570
- Returns ``{int root_id: skeleton_dict}``. Roots whose skeleton is not yet available
571
- (async/not-generated) are omitted with ``generate_missing=False`` (the worker default,
572
- assuming a prior ``generate_bulk_skeletons_async`` pre-pass) these simply don't appear.
568
+ """Download cached skeletons in bulk, chunked at the cached-bulk cap.
569
+
570
+ Uses ``client.skeleton.fetch_skeletons``, which retrieves only already-cached skeletons and
571
+ skips the per-root chunkedgraph validation of the older ``get_bulk_skeletons`` path. With
572
+ ``method="gcs"`` (the default) skeleton H5 files are downloaded directly from the storage
573
+ bucket via a short-lived downscoped token, bypassing the service for data transfer — this
574
+ avoids the request rate limits that throttle bulk loads through the server. ``method="server"``
575
+ routes the download through the skeleton service instead.
576
+
577
+ Skeletons are never generated inline (``generate_missing_skeletons=False`` always). Returns
578
+ ``{int root_id: skeleton_dict}``; roots whose skeleton is not yet cached (async/not-generated)
579
+ are omitted — assuming a prior ``generate_bulk_skeletons_async`` pre-pass, these simply don't
580
+ appear.
573
581
  """
574
582
  out: dict[int, dict] = {}
575
583
  for i in range(0, len(root_ids), _BULK_SKELETON_CHUNK):
576
584
  chunk = root_ids[i : i + _BULK_SKELETON_CHUNK]
577
585
  with suppress_output():
578
- res = client.skeleton.get_bulk_skeletons(
586
+ res = client.skeleton.fetch_skeletons(
579
587
  chunk,
580
588
  skeleton_version=skeleton_version,
581
589
  output_format="dict",
582
- generate_missing_skeletons=generate_missing,
590
+ method=method,
591
+ generate_missing_skeletons=False,
583
592
  )
584
593
  for k, v in res.items():
585
594
  out[int(k)] = v
@@ -601,7 +610,7 @@ def load_cell_batch_from_client(
601
610
  omit_self_synapses: bool = True,
602
611
  skeleton_version: int = 4,
603
612
  skip_invalid: bool = False,
604
- generate_missing_skeletons: bool = False,
613
+ skeleton_download_method: Literal["gcs", "server"] = "gcs",
605
614
  row_limit: int = 500_000,
606
615
  ) -> dict[int, Cell]:
607
616
  """Load many cells with the poolable fetches batched into a few queries.
@@ -611,6 +620,9 @@ def load_cell_batch_from_client(
611
620
  each cell is then assembled network-free — no skeleton is fetched twice, and the batch is
612
621
  validated with a single round trip. All roots share one ``timestamp``.
613
622
 
623
+ All parameters not listed below match :func:`load_cell_from_client` and are applied
624
+ identically to every cell in the batch.
625
+
614
626
  Parameters
615
627
  ----------
616
628
  root_ids: list[int]
@@ -622,11 +634,14 @@ def load_cell_batch_from_client(
622
634
  skip_invalid: bool
623
635
  If True, roots that are not valid at ``timestamp`` — or whose skeleton is not available
624
636
  — are dropped from the result instead of raising.
625
- generate_missing_skeletons: bool
626
- Passed to ``get_bulk_skeletons``. Default False assumes skeletons were already produced
627
- by a ``generate_bulk_skeletons_async`` pre-pass; a worker then just downloads them (fast,
628
- and degrades gracefully on a cache miss). Set True to block on server-side generation.
629
- (all other parameters match :func:`load_cell_from_client`.)
637
+ skeleton_download_method: "gcs" or "server"
638
+ How ``fetch_skeletons`` retrieves cached skeletons. ``"gcs"`` (default) downloads H5 files
639
+ directly from the storage bucket via a downscoped token, bypassing the service for data
640
+ transfer and avoiding its request rate limits preferred for bulk loads. ``"server"``
641
+ routes the download through the skeleton service instead.
642
+ row_limit: int
643
+ Passed to :func:`fetch_frames_batch`; guards against a silently-truncated pooled synapse
644
+ query at/above the server row limit (default 500,000). Set to 0 to disable.
630
645
 
631
646
  Returns
632
647
  -------
@@ -649,18 +664,20 @@ def load_cell_batch_from_client(
649
664
  + (" ..." if len(invalid_ids) > 5 else "")
650
665
  )
651
666
 
652
- # 2. Skeletons: one bulk download (chunked at the sync cap) to obtain lvl2_ids, then
653
- # injected into assembly. Assumes a prior generate_bulk_skeletons_async pre-pass.
667
+ # 2. Skeletons: one cached bulk download (chunked at the cached-bulk cap) to obtain lvl2_ids,
668
+ # then injected into assembly. Assumes a prior generate_bulk_skeletons_async pre-pass.
654
669
  sk_by_root = _get_bulk_skeletons(
655
- client, valid_ids, skeleton_version, generate_missing=generate_missing_skeletons
670
+ client,
671
+ valid_ids,
672
+ skeleton_version,
673
+ method=skeleton_download_method,
656
674
  )
657
675
  no_skeleton = [r for r in valid_ids if r not in sk_by_root]
658
676
  if no_skeleton and not skip_invalid:
659
677
  raise ValueError(
660
- f"{len(no_skeleton)} root id(s) have no available skeleton "
661
- f"(run generate_bulk_skeletons_async first, or pass generate_missing_skeletons=True "
662
- f"or skip_invalid=True): {no_skeleton[:5]}"
663
- + (" ..." if len(no_skeleton) > 5 else "")
678
+ f"{len(no_skeleton)} root id(s) have no cached skeleton "
679
+ f"(run generate_bulk_skeletons_async first, or pass skip_invalid=True): "
680
+ f"{no_skeleton[:5]}" + (" ..." if len(no_skeleton) > 5 else "")
664
681
  )
665
682
  valid_ids = [r for r in valid_ids if r in sk_by_root]
666
683
  lvl2_ids_by_root = {r: sk_by_root[r]["lvl2_ids"] for r in valid_ids}
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
File without changes