ossify 0.2.1__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.1 → ossify-0.2.2}/PKG-INFO +1 -1
  2. {ossify-0.2.1 → ossify-0.2.2}/pyproject.toml +18 -4
  3. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/__init__.py +1 -1
  4. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/base.py +24 -1
  5. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/data_layers.py +76 -13
  6. {ossify-0.2.1 → ossify-0.2.2}/.github/workflows/mkdocs_publish.yml +0 -0
  7. {ossify-0.2.1 → ossify-0.2.2}/.github/workflows/python-package.yml +0 -0
  8. {ossify-0.2.1 → ossify-0.2.2}/.gitignore +0 -0
  9. {ossify-0.2.1 → ossify-0.2.2}/.pre-commit-config.yaml +0 -0
  10. {ossify-0.2.1 → ossify-0.2.2}/LICENSE +0 -0
  11. {ossify-0.2.1 → ossify-0.2.2}/README.md +0 -0
  12. {ossify-0.2.1 → ossify-0.2.2}/mkdocs.yml +0 -0
  13. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/_sync/__init__.py +0 -0
  14. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/_sync/base.py +0 -0
  15. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/_sync/graph.py +0 -0
  16. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/_sync/mapping.py +0 -0
  17. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/_sync/mesh.py +0 -0
  18. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/_sync/morph.py +0 -0
  19. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/_sync/points.py +0 -0
  20. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/_sync/table.py +0 -0
  21. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/algorithms.py +0 -0
  22. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/compartment_models/minnie65_ds15_us0_bd0.json +0 -0
  23. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/compartment_models/v1dd_ds15_us0_bd0.json +0 -0
  24. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/compartment_models/xgb/minnie65_ds15_us0_bd0.ubj +0 -0
  25. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/compartment_models/xgb/v1dd_ds15_us0_bd0.ubj +0 -0
  26. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/compartments.py +0 -0
  27. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/file_io.py +0 -0
  28. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/graph_functions.py +0 -0
  29. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/plot.py +0 -0
  30. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/plot3d.py +0 -0
  31. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/plot_utils.py +0 -0
  32. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/structured_prediction.py +0 -0
  33. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/sync_classes.py +0 -0
  34. {ossify-0.2.1 → ossify-0.2.2}/src/ossify/translate.py +0 -0
  35. {ossify-0.2.1 → ossify-0.2.2}/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.2
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.2"
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.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.1"
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
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
File without changes