cyclebane 24.5.0__py3-none-any.whl
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.
- cyclebane/__init__.py +16 -0
- cyclebane/graph.py +461 -0
- cyclebane/node_values.py +400 -0
- cyclebane/py.typed +0 -0
- cyclebane-24.5.0.dist-info/LICENSE +29 -0
- cyclebane-24.5.0.dist-info/METADATA +94 -0
- cyclebane-24.5.0.dist-info/RECORD +9 -0
- cyclebane-24.5.0.dist-info/WHEEL +5 -0
- cyclebane-24.5.0.dist-info/top_level.txt +1 -0
cyclebane/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
|
|
3
|
+
# ruff: noqa: E402, F401
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = importlib.metadata.version(__package__ or __name__)
|
|
9
|
+
except importlib.metadata.PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
del importlib
|
|
13
|
+
|
|
14
|
+
from .graph import Graph
|
|
15
|
+
|
|
16
|
+
__all__ = ["Graph"]
|
cyclebane/graph.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Generator, Hashable, Iterable
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import networkx as nx
|
|
10
|
+
|
|
11
|
+
from .node_values import IndexName, IndexValue, NodeValues
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_unique_sink(graph: nx.DiGraph) -> Hashable:
|
|
15
|
+
sink_nodes = [node for node in graph.nodes if graph.out_degree(node) == 0]
|
|
16
|
+
if len(sink_nodes) != 1:
|
|
17
|
+
raise ValueError('Graph must have exactly one sink node')
|
|
18
|
+
return sink_nodes[0]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_new_node_name(graph: nx.DiGraph) -> str:
|
|
22
|
+
while True:
|
|
23
|
+
name = str(uuid4())
|
|
24
|
+
if name not in graph:
|
|
25
|
+
return name
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _remove_ancestors(graph: nx.DiGraph, node: Hashable) -> nx.DiGraph:
|
|
29
|
+
graph = graph.copy()
|
|
30
|
+
ancestors = nx.ancestors(graph, node)
|
|
31
|
+
ancestors_successors = {
|
|
32
|
+
ancestor: graph.successors(ancestor) for ancestor in ancestors
|
|
33
|
+
}
|
|
34
|
+
to_remove = []
|
|
35
|
+
for ancestor, successors in ancestors_successors.items():
|
|
36
|
+
# If any successor does not have node as descendant we must keep the node
|
|
37
|
+
if all(nx.has_path(graph, successor, node) for successor in successors):
|
|
38
|
+
to_remove.append(ancestor)
|
|
39
|
+
graph.remove_nodes_from(to_remove)
|
|
40
|
+
graph.remove_edges_from(list(graph.in_edges(node)))
|
|
41
|
+
return graph
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class IndexValues:
|
|
46
|
+
"""
|
|
47
|
+
Index values used as part of :py:class:`NodeName`.
|
|
48
|
+
|
|
49
|
+
Conceptually, this is a mapping from index names to index values.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
axes: tuple[IndexName, ...]
|
|
53
|
+
values: tuple[IndexValue, ...]
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def from_tuple(t: tuple[tuple[IndexName, IndexValue], ...]) -> IndexValues:
|
|
57
|
+
names = tuple(name for name, _ in t)
|
|
58
|
+
values = tuple(value for _, value in t)
|
|
59
|
+
return IndexValues(axes=names, values=values)
|
|
60
|
+
|
|
61
|
+
def to_tuple(self) -> tuple[tuple[IndexName, IndexValue], ...]:
|
|
62
|
+
return tuple(zip(self.axes, self.values))
|
|
63
|
+
|
|
64
|
+
def merge_index(self, other: IndexValues) -> IndexValues:
|
|
65
|
+
return IndexValues(
|
|
66
|
+
axes=other.axes + self.axes, values=other.values + self.values
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
return ', '.join(
|
|
71
|
+
f'{name}={value}' for name, value in zip(self.axes, self.values)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def __len__(self) -> int:
|
|
75
|
+
return len(self.axes)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class NodeName:
|
|
80
|
+
"""Node name with indices used for mapped nodes when converting to NetworkX."""
|
|
81
|
+
|
|
82
|
+
name: Hashable
|
|
83
|
+
index: IndexValues
|
|
84
|
+
|
|
85
|
+
def merge_index(self, other: IndexValues) -> NodeName:
|
|
86
|
+
return NodeName(name=self.name, index=self.index.merge_index(other))
|
|
87
|
+
|
|
88
|
+
def __str__(self) -> str:
|
|
89
|
+
return f'{self.name}({self.index})'
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class MappedNode:
|
|
94
|
+
"""
|
|
95
|
+
Key for a node in :py:class:`Graph` representing a collection of "mapped" nodes.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
name: Hashable
|
|
99
|
+
indices: tuple[IndexName, ...]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _node_with_indices(node: Hashable, indices: tuple[IndexName, ...]) -> MappedNode:
|
|
103
|
+
if isinstance(node, MappedNode):
|
|
104
|
+
return MappedNode(name=node.name, indices=indices + node.indices)
|
|
105
|
+
return MappedNode(name=node, indices=indices)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _node_indices(node: Hashable) -> tuple[IndexName, ...]:
|
|
109
|
+
if isinstance(node, MappedNode):
|
|
110
|
+
return node.indices
|
|
111
|
+
return ()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _find_successors(
|
|
115
|
+
graph: nx.DiGraph, *, root_nodes: tuple[Hashable]
|
|
116
|
+
) -> set[Hashable]:
|
|
117
|
+
successors = set()
|
|
118
|
+
for root in root_nodes:
|
|
119
|
+
if graph.in_degree(root) > 0:
|
|
120
|
+
raise ValueError(f"Mapped node '{root}' is not a source node")
|
|
121
|
+
successors.update(nx.descendants(graph, source=root) | {root})
|
|
122
|
+
return successors
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _rename_successors(
|
|
126
|
+
graph: nx.DiGraph, *, successors: Iterable[Hashable], index: IndexValues
|
|
127
|
+
) -> nx.DiGraph:
|
|
128
|
+
"""Replace 'node' and all its successors with (node, suffix), and update all edges
|
|
129
|
+
accordingly."""
|
|
130
|
+
renamed_nodes = {
|
|
131
|
+
node: (
|
|
132
|
+
node.merge_index(index)
|
|
133
|
+
if isinstance(node, NodeName)
|
|
134
|
+
else NodeName(name=node, index=index)
|
|
135
|
+
)
|
|
136
|
+
for node in successors
|
|
137
|
+
}
|
|
138
|
+
return nx.relabel_nodes(graph, renamed_nodes, copy=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _yield_index(
|
|
142
|
+
indices: list[tuple[IndexName, Iterable[IndexValue]]]
|
|
143
|
+
) -> Generator[tuple[tuple[IndexName, IndexValue], ...], None, None]:
|
|
144
|
+
"""Given a multi-dimensional index, yield all possible combinations."""
|
|
145
|
+
name, index = indices[0]
|
|
146
|
+
for index_value in index:
|
|
147
|
+
if len(indices) == 1:
|
|
148
|
+
yield ((name, index_value),)
|
|
149
|
+
else:
|
|
150
|
+
for rest in _yield_index(indices[1:]):
|
|
151
|
+
yield ((name, index_value),) + rest
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class PositionalIndexer:
|
|
155
|
+
"""
|
|
156
|
+
Helper class to allow slicing a named dim of a graph using positional indexing.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, graph: Graph, index_name: IndexName):
|
|
160
|
+
self.graph = graph
|
|
161
|
+
self.index_name = index_name
|
|
162
|
+
|
|
163
|
+
def __getitem__(self, key: int | slice) -> Graph:
|
|
164
|
+
# Supporting single indices may be conceptually ill-defined if the index
|
|
165
|
+
# `reduce` was applied to the graph, so we might never support this.
|
|
166
|
+
if isinstance(key, int):
|
|
167
|
+
raise NotImplementedError('Only slices are supported')
|
|
168
|
+
node_values = NodeValues(
|
|
169
|
+
{
|
|
170
|
+
name: (
|
|
171
|
+
col.loc({self.index_name: key})
|
|
172
|
+
if self.index_name in col.index_names
|
|
173
|
+
else col
|
|
174
|
+
)
|
|
175
|
+
for name, col in self.graph._node_values.items()
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
return Graph(self.graph.graph, node_values=node_values)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
MappingToArrayLike = Any # dict[str, Numpy|DataArray], DataFrame, etc.
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Graph:
|
|
185
|
+
"""
|
|
186
|
+
A Cyclebane graph is a directed acyclic graph with additional array-like structure.
|
|
187
|
+
|
|
188
|
+
The array-like structure selectively affects nodes in the graph by associating
|
|
189
|
+
source nodes with an array-like object. These source node and all their descendants
|
|
190
|
+
thus gain an additional index (or "dimension").
|
|
191
|
+
|
|
192
|
+
Nomenclature:
|
|
193
|
+
|
|
194
|
+
- Index: As in Pandas, and index is a sequence of values that label an axis.
|
|
195
|
+
- Index-value: A single value in an index.
|
|
196
|
+
- Index-name: The name of an index.
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
Notes
|
|
200
|
+
-----
|
|
201
|
+
The current implementation is not complete, there is a number of things to
|
|
202
|
+
improve:
|
|
203
|
+
- Overall, I would like to reduce the array-handling code and transparently forward
|
|
204
|
+
to the slicing code of the underlying array-like object (Pandas, NumPy, Xarray,
|
|
205
|
+
Scipp). Basically, we would like to use the slicing methods of the underlying
|
|
206
|
+
object. This may not be trivial, since we might mix different types of array-like
|
|
207
|
+
objects at nodes with multiple predecessors.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self, graph: nx.DiGraph, *, node_values: NodeValues | None = None):
|
|
211
|
+
"""
|
|
212
|
+
Initialize a graph from a directed NetworkX graph.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
graph:
|
|
217
|
+
The directed graph representing the data flow.
|
|
218
|
+
node_values:
|
|
219
|
+
A mapping from source node names to array-like objects. The implementation
|
|
220
|
+
assumes that the graph has been setup correctly. Do not use this argument
|
|
221
|
+
unless you know what you are doing.
|
|
222
|
+
"""
|
|
223
|
+
self.graph = graph
|
|
224
|
+
self._node_values = node_values or NodeValues({})
|
|
225
|
+
|
|
226
|
+
def copy(self) -> Graph:
|
|
227
|
+
return Graph(self.graph.copy(), node_values=self._node_values)
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def index_names(self) -> tuple[IndexName, ...]:
|
|
231
|
+
"""Names of the indices (dimensions) of the graph."""
|
|
232
|
+
return tuple(self.indices)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
|
|
236
|
+
"""Names and values of the indices of the graph."""
|
|
237
|
+
return self._node_values.indices
|
|
238
|
+
|
|
239
|
+
def map(self, node_values: MappingToArrayLike) -> Graph:
|
|
240
|
+
"""
|
|
241
|
+
Map the graph over the given values by associating source nodes with values.
|
|
242
|
+
|
|
243
|
+
All successors of the mapped source nodes are replaced with new nodes, one for
|
|
244
|
+
each index value. The value is set as an attribute on the new source nodes
|
|
245
|
+
(but not their successors).
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
node_values:
|
|
250
|
+
A mapping from source node names to array-like objects. The source nodes
|
|
251
|
+
are the roots of the branches to be mapped. The array-like objects must
|
|
252
|
+
support slicing, e.g., NumPy arrays, Xarray DataArrays, Pandas DataFrames,
|
|
253
|
+
etc.
|
|
254
|
+
"""
|
|
255
|
+
new_values = NodeValues.from_mapping(
|
|
256
|
+
node_values, axis_zero=len(self.index_names)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Make sure root nodes exist in graph, add them if not. This choice allows for
|
|
260
|
+
# mapping, e.g., with multiple columns from a DataFrame, representing labels
|
|
261
|
+
# used later for groupby operations.
|
|
262
|
+
graph = self.graph.copy()
|
|
263
|
+
graph.add_nodes_from(new_values)
|
|
264
|
+
|
|
265
|
+
successors = _find_successors(graph, root_nodes=new_values)
|
|
266
|
+
name_mapping: dict[Hashable, MappedNode] = {}
|
|
267
|
+
for node in successors:
|
|
268
|
+
name_mapping[node] = _node_with_indices(node, tuple(new_values.indices))
|
|
269
|
+
|
|
270
|
+
return Graph(
|
|
271
|
+
nx.relabel_nodes(graph, name_mapping),
|
|
272
|
+
node_values=self._node_values.merge(new_values),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def reduce(
|
|
276
|
+
self,
|
|
277
|
+
key: None | Hashable = None,
|
|
278
|
+
*,
|
|
279
|
+
index: None | Hashable = None,
|
|
280
|
+
axis: None | int = None,
|
|
281
|
+
name: None | Hashable = None,
|
|
282
|
+
attrs: None | dict[str, Any] = None,
|
|
283
|
+
) -> Graph:
|
|
284
|
+
"""
|
|
285
|
+
Reduce over the given index or axis previously created with :py:meth:`map`.
|
|
286
|
+
|
|
287
|
+
If neither index nor axis is given, all axes are reduced.
|
|
288
|
+
|
|
289
|
+
Parameters
|
|
290
|
+
----------
|
|
291
|
+
key:
|
|
292
|
+
The name of the source node to reduce. This is the original name prior to
|
|
293
|
+
mapping. If not given, tries to find a unique sink node.
|
|
294
|
+
index:
|
|
295
|
+
The name of the index to reduce over. Only one of index and axis can be
|
|
296
|
+
given.
|
|
297
|
+
axis:
|
|
298
|
+
Integer axis index to reduce over. Only one of index and axis can be given.
|
|
299
|
+
name:
|
|
300
|
+
The name of the new node. If not given, a unique name is generated.
|
|
301
|
+
attrs:
|
|
302
|
+
Attributes to set on the new node(s).
|
|
303
|
+
"""
|
|
304
|
+
key = key or _get_unique_sink(self.graph)
|
|
305
|
+
name = name or _get_new_node_name(self.graph)
|
|
306
|
+
|
|
307
|
+
attrs = attrs or {}
|
|
308
|
+
if index is not None and axis is not None:
|
|
309
|
+
raise ValueError('Only one of index and axis can be given')
|
|
310
|
+
key = self._from_orig_key(key)
|
|
311
|
+
indices = _node_indices(key)
|
|
312
|
+
if index is not None and index not in indices:
|
|
313
|
+
raise ValueError(f"Node '{key}' does not have index '{index}'.")
|
|
314
|
+
# TODO We can support indexing from the back in the future.
|
|
315
|
+
if axis is not None and (axis < 0 or axis >= len(indices)):
|
|
316
|
+
raise ValueError(f"Node '{key}' does not have axis '{axis}'.")
|
|
317
|
+
if index is not None:
|
|
318
|
+
new_index = tuple(value for value in indices if value != index)
|
|
319
|
+
elif axis is not None:
|
|
320
|
+
# TODO Should axis refer to axes of graph, or the node?
|
|
321
|
+
new_index = tuple(value for i, value in enumerate(indices) if i != axis)
|
|
322
|
+
else:
|
|
323
|
+
new_index = None
|
|
324
|
+
if name in self.graph:
|
|
325
|
+
raise ValueError(f"Node '{name}' already exists in the graph.")
|
|
326
|
+
|
|
327
|
+
graph = self.graph.copy()
|
|
328
|
+
name = MappedNode(name=name, indices=new_index) if new_index else name
|
|
329
|
+
graph.add_node(name, **attrs)
|
|
330
|
+
graph.add_edge(key, name)
|
|
331
|
+
|
|
332
|
+
return Graph(graph, node_values=self._node_values)
|
|
333
|
+
|
|
334
|
+
def _from_orig_key(self, key: Hashable) -> Hashable:
|
|
335
|
+
# Graph.map relabels nodes to include index names, which can be inconvenient
|
|
336
|
+
# for the user. Is this convenience of finding the node by its original name
|
|
337
|
+
# worth the complexity and a good idea?
|
|
338
|
+
if key not in self.graph:
|
|
339
|
+
matches = [
|
|
340
|
+
node
|
|
341
|
+
for node in self.graph.nodes
|
|
342
|
+
if isinstance(node, MappedNode) and node.name == key
|
|
343
|
+
]
|
|
344
|
+
if len(matches) == 0:
|
|
345
|
+
raise KeyError(f"Node '{key}' does not exist in the graph.")
|
|
346
|
+
if len(matches) > 1:
|
|
347
|
+
raise KeyError(f"Node '{key}' is ambiguous. Found {matches}.")
|
|
348
|
+
return matches[0]
|
|
349
|
+
return key
|
|
350
|
+
|
|
351
|
+
def by_position(self, index_name: IndexName) -> PositionalIndexer:
|
|
352
|
+
return PositionalIndexer(self, index_name)
|
|
353
|
+
|
|
354
|
+
def to_networkx(self, value_attr: str = 'value') -> nx.DiGraph:
|
|
355
|
+
"""
|
|
356
|
+
Convert to a NetworkX graph, spelling out the internal array structures as
|
|
357
|
+
explicit nodes.
|
|
358
|
+
|
|
359
|
+
Parameters
|
|
360
|
+
----------
|
|
361
|
+
value_attr:
|
|
362
|
+
The name of the attribute on nodes that holds the array-like object.
|
|
363
|
+
"""
|
|
364
|
+
graph = self.graph
|
|
365
|
+
for index_name, index in reversed(self.indices.items()):
|
|
366
|
+
# Find all nodes with this index
|
|
367
|
+
nodes = []
|
|
368
|
+
for node in graph.nodes():
|
|
369
|
+
if index_name in _node_indices(
|
|
370
|
+
node.name if isinstance(node, NodeName) else node
|
|
371
|
+
):
|
|
372
|
+
nodes.append(node)
|
|
373
|
+
# Make a copy for each index value
|
|
374
|
+
graphs = [
|
|
375
|
+
_rename_successors(
|
|
376
|
+
graph, successors=nodes, index=IndexValues.from_tuple(i)
|
|
377
|
+
)
|
|
378
|
+
for i in _yield_index([(index_name, index)])
|
|
379
|
+
]
|
|
380
|
+
graph = nx.compose_all(graphs)
|
|
381
|
+
# Replace all MappingNodes with their name
|
|
382
|
+
new_names = {
|
|
383
|
+
node: NodeName(node.name.name, node.index)
|
|
384
|
+
for node in graph
|
|
385
|
+
if isinstance(node, NodeName) and isinstance(node.name, MappedNode)
|
|
386
|
+
}
|
|
387
|
+
graph = nx.relabel_nodes(graph, new_names)
|
|
388
|
+
|
|
389
|
+
# Get values using previously stored index values
|
|
390
|
+
for node in graph.nodes:
|
|
391
|
+
if (
|
|
392
|
+
isinstance(node, NodeName)
|
|
393
|
+
and (node_values := self._node_values.get(node.name)) is not None
|
|
394
|
+
):
|
|
395
|
+
graph.nodes[node][value_attr] = node_values.sel(node.index.to_tuple())
|
|
396
|
+
|
|
397
|
+
return graph
|
|
398
|
+
|
|
399
|
+
def __getitem__(self, key: Hashable | slice) -> Graph:
|
|
400
|
+
"""
|
|
401
|
+
Get the branch of the graph rooted at the given node.
|
|
402
|
+
|
|
403
|
+
The branch is a subgraph containing the given node and all its ancestors.
|
|
404
|
+
Think of this like a Git branch, where the given node is the head of the branch.
|
|
405
|
+
"""
|
|
406
|
+
if isinstance(key, slice):
|
|
407
|
+
raise NotImplementedError('Only single nodes are supported ')
|
|
408
|
+
key = self._from_orig_key(key)
|
|
409
|
+
ancestors = nx.ancestors(self.graph, key)
|
|
410
|
+
ancestors.add(key)
|
|
411
|
+
# Drop all node values that are not in the branch
|
|
412
|
+
mapped = set(a.name for a in ancestors if isinstance(a, MappedNode))
|
|
413
|
+
keep_values = [key for key in self._node_values.keys() if key in mapped]
|
|
414
|
+
return Graph(
|
|
415
|
+
self.graph.subgraph(ancestors),
|
|
416
|
+
node_values=self._node_values.get_columns(keep_values),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def __setitem__(self, branch: Hashable | slice, other: Graph) -> None:
|
|
420
|
+
"""
|
|
421
|
+
Set a new branch in place of the given branch.
|
|
422
|
+
|
|
423
|
+
The new branch must have a unique sink node. The branch at `branch` is replaced
|
|
424
|
+
with the new branch. The indices and node values are updated accordingly. The
|
|
425
|
+
edges to successors of the old branch are connected to the sink of the new
|
|
426
|
+
branch.
|
|
427
|
+
"""
|
|
428
|
+
if isinstance(branch, slice):
|
|
429
|
+
raise NotImplementedError('Setting slice not supported yet.')
|
|
430
|
+
if not isinstance(other, Graph):
|
|
431
|
+
raise TypeError(f'Expected {Graph}, got {type(other)}')
|
|
432
|
+
new_branch = other.graph
|
|
433
|
+
sink = _get_unique_sink(new_branch)
|
|
434
|
+
# In the future, we could support this if BOTH sink and branch are MappedNodes
|
|
435
|
+
# with identical indices.
|
|
436
|
+
if isinstance(sink, MappedNode) or isinstance(branch, MappedNode):
|
|
437
|
+
raise NotImplementedError('Mapped nodes not supported yet in __setitem__')
|
|
438
|
+
new_branch = nx.relabel_nodes(new_branch, {sink: branch})
|
|
439
|
+
if branch in self.graph:
|
|
440
|
+
graph = _remove_ancestors(self.graph, branch)
|
|
441
|
+
graph.nodes[branch].clear()
|
|
442
|
+
else:
|
|
443
|
+
graph = self.graph
|
|
444
|
+
|
|
445
|
+
intersection_nodes = set(graph.nodes) & set(new_branch.nodes) - {branch}
|
|
446
|
+
|
|
447
|
+
for node in intersection_nodes:
|
|
448
|
+
if graph.pred[node] != new_branch.pred[node]:
|
|
449
|
+
raise ValueError(
|
|
450
|
+
f"Node inputs differ for node '{node}':\n"
|
|
451
|
+
f" {graph.pred[node]}\n"
|
|
452
|
+
f" {new_branch.pred[node]}\n"
|
|
453
|
+
)
|
|
454
|
+
if graph.nodes[node] != new_branch.nodes[node]:
|
|
455
|
+
raise ValueError(f"Node data differs for node '{node}'")
|
|
456
|
+
|
|
457
|
+
graph = nx.compose(graph, new_branch)
|
|
458
|
+
|
|
459
|
+
# Delay setting graph until we know no step fails
|
|
460
|
+
self._node_values = self._node_values.merge(other._node_values)
|
|
461
|
+
self.graph = graph
|
cyclebane/node_values.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections import abc
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Hashable, Iterable, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import numpy
|
|
11
|
+
import pandas
|
|
12
|
+
import scipp
|
|
13
|
+
import xarray
|
|
14
|
+
|
|
15
|
+
IndexName = Hashable
|
|
16
|
+
IndexValue = Hashable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValueArray(ABC):
|
|
20
|
+
"""
|
|
21
|
+
Abstract base class for a series of values with an index that can be sliced.
|
|
22
|
+
|
|
23
|
+
Used by :py:class:`NodeValues` to store the values of a given node in a graph. The
|
|
24
|
+
abstraction allows for the use of different data structures to store the values of
|
|
25
|
+
nodes in a graph, such as pandas.DataFrame, xarray.DataArray, numpy.ndarray, or
|
|
26
|
+
simple Python iterables.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_registry = []
|
|
30
|
+
|
|
31
|
+
def __init_subclass__(cls) -> None:
|
|
32
|
+
super().__init_subclass__()
|
|
33
|
+
ValueArray._registry.append(cls)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def from_array_like(values: Any, *, axis_zero: int = 0) -> ValueArray:
|
|
37
|
+
# Reversed to ensure SequenceAdapter is tried last, as it is the most general
|
|
38
|
+
# SequenceAdapter is defined right after this class so it is registered first
|
|
39
|
+
for subclass in reversed(ValueArray._registry):
|
|
40
|
+
if (a := subclass.try_from(values, axis_zero=axis_zero)) is not None:
|
|
41
|
+
return a
|
|
42
|
+
raise ValueError(f'Cannot create ValueArray from {values}')
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def try_from(obj: Any, *, axis_zero: int = 0) -> ValueArray | None:
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
|
|
51
|
+
"""Return data by selecting from index with given name and index value."""
|
|
52
|
+
|
|
53
|
+
def loc(self, key: dict[IndexName, slice]) -> ValueArray:
|
|
54
|
+
if not all(isinstance(i, slice) for i in key.values()):
|
|
55
|
+
raise ValueError('ValueArray.loc only accepts slices, not integers')
|
|
56
|
+
if not set(key).issubset(set(self.index_names)):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f'ValueArray.loc got {key.keys()}, not a subset of {self.index_names}'
|
|
59
|
+
)
|
|
60
|
+
return self[key]
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def __getitem__(self, key: dict[IndexName, slice]) -> ValueArray:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def shape(self) -> tuple[int, ...]:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def index_names(self) -> tuple[IndexName, ...]:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SequenceAdapter(ValueArray):
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
values: Sequence[Any],
|
|
86
|
+
*,
|
|
87
|
+
index: Iterable[IndexValue] | None = None,
|
|
88
|
+
axis_zero: int = 0,
|
|
89
|
+
):
|
|
90
|
+
self._values = values
|
|
91
|
+
self._index = index or range(len(values))
|
|
92
|
+
self._axis_zero = axis_zero
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def try_from(obj: Any, *, axis_zero: int = 0) -> SequenceAdapter | None:
|
|
96
|
+
return SequenceAdapter(obj, axis_zero=axis_zero)
|
|
97
|
+
|
|
98
|
+
def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
|
|
99
|
+
if len(key) != 1:
|
|
100
|
+
raise ValueError('SequenceAdapter only supports single index')
|
|
101
|
+
_, i = key[0]
|
|
102
|
+
return self._values[self._index.index(i)]
|
|
103
|
+
|
|
104
|
+
def __getitem__(self, key: dict[IndexName, slice]) -> SequenceAdapter:
|
|
105
|
+
_, i = next(iter(key.items()))
|
|
106
|
+
return SequenceAdapter(
|
|
107
|
+
self._values[i], index=self._index[i], axis_zero=self._axis_zero
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def shape(self) -> tuple[int, ...]:
|
|
112
|
+
return (len(self._values),)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def index_names(self) -> tuple[IndexName, ...]:
|
|
116
|
+
return (f'dim_{self._axis_zero}',)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
|
|
120
|
+
return {f'dim_{self._axis_zero}': self._index}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class PandasSeriesAdapter(ValueArray):
|
|
124
|
+
def __init__(self, series: 'pandas.Series', *, axis_zero: int = 0):
|
|
125
|
+
self._series = series
|
|
126
|
+
self._axis_zero = axis_zero
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def try_from(obj: Any, *, axis_zero: int = 0) -> PandasSeriesAdapter | None:
|
|
130
|
+
try:
|
|
131
|
+
import pandas
|
|
132
|
+
except ModuleNotFoundError:
|
|
133
|
+
return None
|
|
134
|
+
if isinstance(obj, pandas.Series):
|
|
135
|
+
return PandasSeriesAdapter(obj, axis_zero=axis_zero)
|
|
136
|
+
|
|
137
|
+
def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
|
|
138
|
+
if len(key) != 1:
|
|
139
|
+
raise ValueError('PandasSeriesAdapter only supports single index')
|
|
140
|
+
index_name, i = key[0]
|
|
141
|
+
if index_name != self.index_names[0]:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f'Unexpected index name {index_name} for PandasSeriesAdapter with '
|
|
144
|
+
f'index names {self.index_names}'
|
|
145
|
+
)
|
|
146
|
+
return self._series.loc[i]
|
|
147
|
+
|
|
148
|
+
def __getitem__(self, key: dict[IndexName, slice]) -> PandasSeriesAdapter:
|
|
149
|
+
_, i = next(iter(key.items()))
|
|
150
|
+
return PandasSeriesAdapter(self._series[i], axis_zero=self._axis_zero)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def shape(self) -> tuple[int, ...]:
|
|
154
|
+
return (len(self._series),)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def index_names(self) -> tuple[IndexName, ...]:
|
|
158
|
+
index_name = (
|
|
159
|
+
self._series.index.name
|
|
160
|
+
if self._series.index.name is not None
|
|
161
|
+
else f'dim_{self._axis_zero}'
|
|
162
|
+
)
|
|
163
|
+
return (index_name,)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
|
|
167
|
+
return {self.index_names[0]: self._series.index}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class XarrayDataArrayAdapter(ValueArray):
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
data_array: 'xarray.DataArray',
|
|
174
|
+
):
|
|
175
|
+
default_indices = {
|
|
176
|
+
dim: range(size)
|
|
177
|
+
for dim, size in data_array.sizes.items()
|
|
178
|
+
if dim not in data_array.coords
|
|
179
|
+
}
|
|
180
|
+
self._data_array = data_array.assign_coords(default_indices)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def try_from(obj: Any, *, axis_zero: int = 0) -> XarrayDataArrayAdapter | None:
|
|
184
|
+
try:
|
|
185
|
+
import xarray
|
|
186
|
+
|
|
187
|
+
if isinstance(obj, xarray.DataArray):
|
|
188
|
+
return XarrayDataArrayAdapter(obj)
|
|
189
|
+
except ModuleNotFoundError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
|
|
193
|
+
return self._data_array.sel(dict(key))
|
|
194
|
+
|
|
195
|
+
def __getitem__(self, key: dict[IndexName, slice]) -> XarrayDataArrayAdapter:
|
|
196
|
+
return XarrayDataArrayAdapter(self._data_array.isel(key))
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def shape(self) -> tuple[int, ...]:
|
|
200
|
+
return self._data_array.shape
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def index_names(self) -> tuple[IndexName, ...]:
|
|
204
|
+
return tuple(self._data_array.dims)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
|
|
208
|
+
return {
|
|
209
|
+
dim: self._data_array.coords[dim].values for dim in self._data_array.dims
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ScippDataArrayAdapter(ValueArray):
|
|
214
|
+
def __init__(self, data_array: 'scipp.DataArray'):
|
|
215
|
+
import scipp
|
|
216
|
+
|
|
217
|
+
default_indices = {
|
|
218
|
+
dim: scipp.arange(dim, size, unit=None)
|
|
219
|
+
for dim, size in data_array.sizes.items()
|
|
220
|
+
if dim not in data_array.coords
|
|
221
|
+
}
|
|
222
|
+
self._data_array = data_array.assign_coords(default_indices)
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def try_from(obj: Any, *, axis_zero: int = 0) -> ScippDataArrayAdapter | None:
|
|
226
|
+
try:
|
|
227
|
+
import scipp
|
|
228
|
+
|
|
229
|
+
if isinstance(obj, scipp.Variable):
|
|
230
|
+
return ScippDataArrayAdapter(scipp.DataArray(obj))
|
|
231
|
+
if isinstance(obj, scipp.DataArray):
|
|
232
|
+
return ScippDataArrayAdapter(obj)
|
|
233
|
+
except ModuleNotFoundError:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
|
|
237
|
+
import scipp
|
|
238
|
+
|
|
239
|
+
values = self._data_array
|
|
240
|
+
for dim, value in key:
|
|
241
|
+
# Reconstruct label, to use label-based indexing instead of positional
|
|
242
|
+
if isinstance(value, tuple):
|
|
243
|
+
value, unit = value
|
|
244
|
+
else:
|
|
245
|
+
unit = None
|
|
246
|
+
label = scipp.scalar(value, unit=unit)
|
|
247
|
+
# Scipp indexing uses a comma to separate dimension label from the index,
|
|
248
|
+
# unlike Numpy and other libraries where it separates the indices for
|
|
249
|
+
# different axes.
|
|
250
|
+
values = values[dim, label]
|
|
251
|
+
return values
|
|
252
|
+
|
|
253
|
+
def __getitem__(self, key: dict[IndexName, slice]) -> ScippDataArrayAdapter:
|
|
254
|
+
values = self._data_array
|
|
255
|
+
for dim, i in key:
|
|
256
|
+
values = values[dim, i]
|
|
257
|
+
return ScippDataArrayAdapter(values)
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def shape(self) -> tuple[int, ...]:
|
|
261
|
+
return self._data_array.shape
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def index_names(self) -> tuple[IndexName, ...]:
|
|
265
|
+
return tuple(self._data_array.dims)
|
|
266
|
+
|
|
267
|
+
def _index_for_dim(self, dim: str) -> list[tuple[Any, 'scipp.Unit']]:
|
|
268
|
+
# Work around some NetworkX errors. Probably scipp.Variable lacks functionality.
|
|
269
|
+
# For now we return a list of tuples, where the first element is the value and
|
|
270
|
+
# the second is the unit.
|
|
271
|
+
coord = self._data_array.coords[dim]
|
|
272
|
+
unit = coord.unit
|
|
273
|
+
if unit is None:
|
|
274
|
+
return coord.values
|
|
275
|
+
unit = str(unit)
|
|
276
|
+
return [(value, unit) for value in coord.values]
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
|
|
280
|
+
return {dim: self._index_for_dim(dim) for dim in self._data_array.dims}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class NumpyArrayAdapter(ValueArray):
|
|
284
|
+
def __init__(
|
|
285
|
+
self,
|
|
286
|
+
array: 'numpy.ndarray',
|
|
287
|
+
*,
|
|
288
|
+
indices: dict[IndexName, Iterable[IndexValue]] | None = None,
|
|
289
|
+
axis_zero: int = 0,
|
|
290
|
+
):
|
|
291
|
+
import numpy as np
|
|
292
|
+
|
|
293
|
+
self._array = np.asarray(array)
|
|
294
|
+
if indices is None:
|
|
295
|
+
indices = {
|
|
296
|
+
f'dim_{i+axis_zero}': range(size)
|
|
297
|
+
for i, size in enumerate(self._array.shape)
|
|
298
|
+
}
|
|
299
|
+
self._indices = indices
|
|
300
|
+
self._axis_zero = axis_zero
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def try_from(obj: Any, *, axis_zero: int = 0) -> NumpyArrayAdapter | None:
|
|
304
|
+
try:
|
|
305
|
+
import numpy
|
|
306
|
+
except ModuleNotFoundError:
|
|
307
|
+
return None
|
|
308
|
+
if isinstance(obj, numpy.ndarray):
|
|
309
|
+
return NumpyArrayAdapter(obj, axis_zero=axis_zero)
|
|
310
|
+
|
|
311
|
+
def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
|
|
312
|
+
index_tuple = tuple(self._indices[k].index(i) for k, i in key)
|
|
313
|
+
return self._array[index_tuple]
|
|
314
|
+
|
|
315
|
+
def __getitem__(self, key: dict[IndexName, slice]) -> NumpyArrayAdapter:
|
|
316
|
+
return NumpyArrayAdapter(
|
|
317
|
+
self._array[tuple(key.get(k, slice(None)) for k in self._indices)],
|
|
318
|
+
indices={
|
|
319
|
+
index_name: (index_values[key.get(index_name, slice(None))])
|
|
320
|
+
for index_name, index_values in self._indices.items()
|
|
321
|
+
},
|
|
322
|
+
axis_zero=self._axis_zero,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def shape(self) -> tuple[int, ...]:
|
|
327
|
+
return self._array.shape
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def index_names(self) -> tuple[IndexName, ...]:
|
|
331
|
+
return tuple(self._indices)
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
|
|
335
|
+
return self._indices
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class NodeValues(abc.Mapping[Hashable, ValueArray]):
|
|
339
|
+
"""
|
|
340
|
+
A collection of pandas.DataFrame-like objects with distinct indices.
|
|
341
|
+
|
|
342
|
+
This is used by :py:class:`Graph` to store the values of nodes in a graph.
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
def __init__(self, values: Mapping[Hashable, ValueArray]):
|
|
346
|
+
self._values = values
|
|
347
|
+
|
|
348
|
+
def __len__(self) -> int:
|
|
349
|
+
"""Return the number of columns."""
|
|
350
|
+
return len(self._values)
|
|
351
|
+
|
|
352
|
+
def __iter__(self) -> Iterable[Hashable]:
|
|
353
|
+
"""Iterate over the column names."""
|
|
354
|
+
return iter(self._values)
|
|
355
|
+
|
|
356
|
+
def __getitem__(self, key: Hashable) -> ValueArray:
|
|
357
|
+
"""Return the column with the given name."""
|
|
358
|
+
return self._values[key]
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def from_mapping(
|
|
362
|
+
values: Mapping[Hashable, Sequence[Any]], axis_zero: int
|
|
363
|
+
) -> NodeValues:
|
|
364
|
+
"""Construct from a mapping of node names to value sequences."""
|
|
365
|
+
value_arrays = {
|
|
366
|
+
key: ValueArray.from_array_like(value, axis_zero=axis_zero)
|
|
367
|
+
for key, value in values.items()
|
|
368
|
+
}
|
|
369
|
+
shapes = {array.shape for array in value_arrays.values()}
|
|
370
|
+
if len(shapes) > 1:
|
|
371
|
+
raise ValueError(
|
|
372
|
+
'All value sequences in a map operation must have the same shape. '
|
|
373
|
+
'Use multiple map operations if necessary.'
|
|
374
|
+
)
|
|
375
|
+
return NodeValues(value_arrays)
|
|
376
|
+
|
|
377
|
+
def merge(self, value_arrays: Mapping[Hashable, ValueArray]) -> NodeValues:
|
|
378
|
+
if value_arrays:
|
|
379
|
+
named = next(iter(value_arrays.values())).index_names
|
|
380
|
+
if any([name in self.indices for name in named]):
|
|
381
|
+
raise ValueError(
|
|
382
|
+
f'Conflicting new index names {named} with existing '
|
|
383
|
+
f'{tuple(self.indices)}'
|
|
384
|
+
)
|
|
385
|
+
for node in value_arrays:
|
|
386
|
+
if node in self:
|
|
387
|
+
raise ValueError(f"Node '{node}' has already been mapped")
|
|
388
|
+
return NodeValues({**self._values, **value_arrays})
|
|
389
|
+
|
|
390
|
+
def get_columns(self, keys: list[Hashable]) -> NodeValues:
|
|
391
|
+
"""Select a subset of columns."""
|
|
392
|
+
return NodeValues({key: self._values[key] for key in keys})
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
|
|
396
|
+
"""Return the indices of the NodeValues object."""
|
|
397
|
+
value_indices = [value.indices for value in self._values.values()]
|
|
398
|
+
return {
|
|
399
|
+
name: index for indices in value_indices for name, index in indices.items()
|
|
400
|
+
}
|
cyclebane/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024, Scipp contributors (https://github.com/scipp)
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: cyclebane
|
|
3
|
+
Version: 24.5.0
|
|
4
|
+
Summary: Transform directed acyclic graphs using map-reduce and groupby operations
|
|
5
|
+
Author: Scipp contributors
|
|
6
|
+
License: BSD 3-Clause License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024, Scipp contributors (https://github.com/scipp)
|
|
9
|
+
All rights reserved.
|
|
10
|
+
|
|
11
|
+
Redistribution and use in source and binary forms, with or without
|
|
12
|
+
modification, are permitted provided that the following conditions are met:
|
|
13
|
+
|
|
14
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
15
|
+
list of conditions and the following disclaimer.
|
|
16
|
+
|
|
17
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
18
|
+
this list of conditions and the following disclaimer in the documentation
|
|
19
|
+
and/or other materials provided with the distribution.
|
|
20
|
+
|
|
21
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
22
|
+
contributors may be used to endorse or promote products derived from
|
|
23
|
+
this software without specific prior written permission.
|
|
24
|
+
|
|
25
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
26
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
27
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
28
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
29
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
30
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
31
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
32
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
33
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
34
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
35
|
+
|
|
36
|
+
Project-URL: Bug Tracker, https://github.com/scipp/cyclebane/issues
|
|
37
|
+
Project-URL: Documentation, https://scipp.github.io/cyclebane
|
|
38
|
+
Project-URL: Source, https://github.com/scipp/cyclebane
|
|
39
|
+
Classifier: Intended Audience :: Science/Research
|
|
40
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
41
|
+
Classifier: Natural Language :: English
|
|
42
|
+
Classifier: Operating System :: OS Independent
|
|
43
|
+
Classifier: Programming Language :: Python :: 3
|
|
44
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
48
|
+
Classifier: Topic :: Scientific/Engineering
|
|
49
|
+
Classifier: Typing :: Typed
|
|
50
|
+
Requires-Python: >=3.10
|
|
51
|
+
Description-Content-Type: text/markdown
|
|
52
|
+
License-File: LICENSE
|
|
53
|
+
Requires-Dist: networkx
|
|
54
|
+
|
|
55
|
+
[](CODE_OF_CONDUCT.md)
|
|
56
|
+
[](https://pypi.python.org/pypi/cyclebane)
|
|
57
|
+
[](https://anaconda.org/scipp/cyclebane)
|
|
58
|
+
[](LICENSE)
|
|
59
|
+
|
|
60
|
+
# Cyclebane
|
|
61
|
+
|
|
62
|
+
## About
|
|
63
|
+
|
|
64
|
+
Transform directed acyclic graphs using map-reduce and groupby operations
|
|
65
|
+
|
|
66
|
+
This library is an attempt to merge the concepts of directed acyclic graphs (DAG) with array-like objects such as NumPy arrays, Pandas DataFrames, or Xarray/Scipp DataArrays.
|
|
67
|
+
This could be useful for describing tasks graphs, e.g., when a series of tasks is applied to chunks of an array.
|
|
68
|
+
These tasks also have an array structure.
|
|
69
|
+
After an reduction operation of chunks, the graph loses this structure, i.e., only a subset of the graph's nodes has array structure.
|
|
70
|
+
What if we could work with this structure, even though only parts of the graph follows it?
|
|
71
|
+
And what if we could use the power of array slicing with named dimensions, or select by label?
|
|
72
|
+
This is what Cyclebane tries to do.
|
|
73
|
+
|
|
74
|
+
Our initial goal is to support:
|
|
75
|
+
|
|
76
|
+
- `map` operations of a DAG's source nodes over an array-like (https://docs.dask.org/en/latest/high-level-graphs.html).
|
|
77
|
+
Cyclebane will effectively copy all descendants of those nodes, once for each array element.
|
|
78
|
+
Cyclebane will support joint mappings of multiple source nodes by mapping over, e.g., a DataFrame with multiple columns, as well as chaining independent map operations at different source nodes.
|
|
79
|
+
In the latter case this will effectively broadcast at descendant nodes that depend on multiple such source nodes.
|
|
80
|
+
- `reduce` operations at descendants of mapped nodes.
|
|
81
|
+
This will add a new node with edges to all copies of the mapped node being reduced.
|
|
82
|
+
Cyclebane will support reducing only individual axes or all axes, similar to Numpy.
|
|
83
|
+
- `groupby` operations similar to Pandas and Xarray (albeit more limited).
|
|
84
|
+
- Positional and label-based indexing.
|
|
85
|
+
Cyclebane will support selecting branches that were creating during `map` (or `groupby`) operations based on their indices.
|
|
86
|
+
The graph structure will be left untouched, i.e., nodes after a `reduce` operation will be preserved, but fewer edges will lead to the reduce node.
|
|
87
|
+
|
|
88
|
+
See also Dask's [High Level Graphs](https://docs.dask.org/en/latest/high-level-graphs.html) for a related concept (without the direct support for any such operations).
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
python -m pip install cyclebane
|
|
94
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
cyclebane/__init__.py,sha256=YGeabZhJkCjRwK-c5kfL4G7viTRXljZRK4_a78fJMoY,371
|
|
2
|
+
cyclebane/graph.py,sha256=M3bX2BTgKhfNM9QnTa1RZiF0KiXvRhFFU_4w5_JCiQU,17165
|
|
3
|
+
cyclebane/node_values.py,sha256=ju6sfSZsLuIdL9Pxcc9cXsNHNJPzx0YCkDsKlfk-3XY,13588
|
|
4
|
+
cyclebane/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
cyclebane-24.5.0.dist-info/LICENSE,sha256=nVEiume4Qj6jMYfSRjHTM2jtJ4FGu0g-5Sdh7osfEYw,1553
|
|
6
|
+
cyclebane-24.5.0.dist-info/METADATA,sha256=0nEYg9Ssl5SpZUxf8_mLBy2CdMbicvabPpGCMywA-l0,5400
|
|
7
|
+
cyclebane-24.5.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
8
|
+
cyclebane-24.5.0.dist-info/top_level.txt,sha256=KVv2uZtZFY3P-omQCsFyxjazWD32M_ym0Kpt-rUxn8Q,10
|
|
9
|
+
cyclebane-24.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cyclebane
|