From d0f7704660910a50dd737eec493e2a749a6d4ea4 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 11:22:50 -0800 Subject: [PATCH 01/41] Update channel docs and test to them Including introducing new testing classes to directly test for promises made on the base class. In general, I want to head this direction in this PR: be as explicit as possible about the promises the classes make, and then test each of the promises as directly as possible. --- pyiron_workflow/channels.py | 144 ++++++++-------- tests/unit/test_channels.py | 320 +++++++++++++++++++++--------------- 2 files changed, 264 insertions(+), 200 deletions(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index f2901ad6..32745420 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -1,22 +1,9 @@ """ Channels are access points for information to flow into and out of nodes. +They accomplish this by forming connections between each other, and it should be as +easy as possible to form sensible and reliable connections. -Data channels carry, unsurprisingly, data. -Connections are only permissible between opposite sub-types, i.e. input-output. -When input channels `fetch()` data in, they set their `value` to the first available -data value among their connections -- i.e. the `value` of the first output channel in -their connections who has something other than `NotData`. -Input data channels will raise an error if a `fetch()` is attempted while their parent - node is running. - -Signal channels are tools for procedurally exposing functionality on nodes. -Input signal channels are connected to a callback function which gets invoked when the -channel is called. -Output signal channels call all the input channels they are connected to when they get - called themselves. -In this way, signal channels can force behaviour (node method calls) to propagate -forwards through a graph. -They do not hold any data and have no `value` attribute, but rather fire for an effect. +Nodes get the attention, but channels are the real heroes. """ from __future__ import annotations @@ -44,16 +31,31 @@ class Channel(HasChannel, HasToDict, ABC): """ Channels facilitate the flow of information (data or control signals) into and out of nodes. - They must have a label and belong to a node. - Input/output channels can be (dis)connected from other output/input channels of the - same generic type (i.e. data or signal), and store all of their current connections - in a list. + They must have an identifier (`label: str`) and belong to a parent node + (`node: pyiron_workflow.node.Node`). + + Non-abstract channel classes should come in input/output pairs with a shared + ancestor (`generic_type: type[Channel]`). + + Channels may form (`connect`/`disconnect`) and store (`connections: list[Channel]`) + connections with other channels. + This connection information is reflexive, and is duplicated to be stored on _both_ channels in the form of a reference to their counterpart in the connection. - Child classes must define a string representation, `__str__`, and their - `generic_type` which is a parent of both themselves and their output/input partner. + By using the provided methods to modify connections, the reflexive nature of + these (dis)connections is guaranteed to be handled, and new connections are + subjected to a validity test. + + In this abstract class the only requirement is that the connecting channels form a + "conjugate pair" of classes, i.e. they are different classes but have the same + parent class (`generic_type: type[Channel]`) -- input/output connects to + output/input. + + Iterating over channels yields their connections. + + The length of a channel is the length of its connections. Attributes: label (str): The name of the channel. @@ -72,8 +74,7 @@ def __init__( Args: label (str): A name for the channel. - node (pyiron_workflow.node.Node): The node to which the - channel belongs. + node (pyiron_workflow.node.Node): The node to which the channel belongs. """ self.label: str = label self.node: Node = node @@ -105,7 +106,6 @@ def connect(self, *others: Channel) -> None: Connections are reflexive, and must occur between input and output channels of the same `generic_type` (i.e. data or signal). - Args: *others (Channel): The other channel objects to attempt to connect with. @@ -145,8 +145,8 @@ def disconnect(self, *others: Channel) -> list[tuple[Channel, Channel]]: *others (Channel): The other channels to disconnect from. Returns: - [list[tuple[Channel, Channel]]]: A list of the pairs of channels that no - longer participate in a connection. + [list[tuple[Channel, Channel]]]: A list of the (input, output) conjugate + pairs of channels that no longer participate in a connection. """ destroyed_connections = [] for other in others: @@ -227,25 +227,42 @@ def __repr__(cls): class DataChannel(Channel, ABC): """ Data channels control the flow of data on the graph. - They store this data in a `value` attribute. - They may optionally have a type hint. - They have a `ready` attribute which tells whether their value matches their type - hint (if one is provided, else `True`). - (In the future they may optionally have a storage priority.) - (In the future they may optionally have a storage history limit.) - (In the future they may optionally have an ontological type.) - - Note that type checking is performed on value updates. This is typically not super - expensive, but once you have a workflow you're happy with, you may wish to - deactivate `strict_hints` throughout the workflow for the sake of computational - efficiency during production runs. - - When type checking channel connections, we insist that the output type hint be - _as or more specific_ than the input type hint, to ensure that the input always - receives output of a type it expects. This behaviour can be disabled and all - connections allowed by setting `strict_hints = False` on the relevant input - channel. + They store data persistently (`value`). + + This value may have a default (`default`) and the default-default is to be + `NotData`. + + They may optionally have a type hint (`type_hint`). + + New data and new connections are tested against type hints (if any). + + In addition to the requirement of being a "conjugate pair", if both connecting + channels have type hints, the output channel must have a type hint that is as or + more specific than the input channel. + + In addition to connections, these channels can have a single partner + (`value_receiver: DataChannel`) that is of the _same_ class and obeys type hints as + though it were the "downstream" (input) partner in a connection. + Channels with such partners pass any data updates they receive directly to this + partner (via the `value` setter). + (This is helpful for passing data between scopes, where we want input at one scope + to be passed to the input of nodes at a deeper scope, i.e. macro input passing to + child node input, or vice versa for output.) + + All these type hint tests can be disabled on the input/receiving channel + (`strict_hints: bool`), and this is recommended for the optimal performance in + production runs. + + Channels can indicate whether they hold data they are happy with (`ready: bool`), + which is to say it is data (not `NotData`) and that it conforms to the type hint + (if one is provided and checking is active). + + TODO: + - Storage (including priority and history) + - Ontological hinting + + Some comments on type hinting: For simple type hints like `int` or `str`, type hint comparison is trivial. However, some hints take arguments, e.g. `dict[str, int]` to specify key and value types; `tuple[int, int, str]` to specify a tuple with certain values; @@ -261,10 +278,6 @@ class DataChannel(Channel, ABC): E.g. `Literal[1, 2]` is as or more specific that both `Literal[1, 2]` and `Literal[1, 2, "three"]`. - The data `value` will initialize to an instance of `NotData` by default. - The channel will identify as `ready` when the value is _not_ an instance of - `NotData`, and when the value conforms to type hints (if any). - Warning: Type hinting in python is quite complex, and determining when a hint is "more specific" can be tricky. For instance, in python 3.11 you can now type @@ -372,25 +385,25 @@ def generic_type(self) -> type[Channel]: @property def ready(self) -> bool: """ - Check if the currently stored value satisfies the channel's type hint. + Check if the currently stored value is data and satisfies the channel's type + hint (if hint checking is activated). Returns: - (bool): Whether the value matches the type hint. + (bool): Whether the value is data and matches the type hint. """ - if self.type_hint is not None: - return self._value_is_data and valid_value(self.value, self.type_hint) - else: - return self._value_is_data + return self._value_is_data and ( + valid_value(self.value, self.type_hint) if self._has_hint else True + ) @property - def _value_is_data(self): + def _value_is_data(self) -> bool: return self.value is not NotData @property - def _has_hint(self): + def _has_hint(self) -> bool: return self.type_hint is not None - def _valid_connection(self, other) -> bool: + def _valid_connection(self, other: DataChannel) -> bool: if super()._valid_connection(other): if self._both_typed(other): out, inp = self._figure_out_who_is_who(other) @@ -436,6 +449,8 @@ def fetch(self) -> None: `NotData`; if no such value exists (e.g. because there are no connections or because all the connected output channels have `NotData` as their value), `value` remains unchanged. + I.e., the connection with the highest priority for updating input data is the + 0th connection; build graphs accordingly. Raises: RuntimeError: If the parent node is `running`. @@ -458,10 +473,9 @@ class OutputData(DataChannel): class SignalChannel(Channel, ABC): """ Signal channels give the option control execution flow by triggering callback - functions. + functions when the channel is called. - Output channels can be called to trigger the callback functions of all input - channels to which they are connected. + Inputs hold a callback function to call, and outputs call each of their connections. Signal channels support `>` as syntactic sugar for their connections, i.e. `some_output > some_input` is equivalent to `some_input.connect(some_output)`. @@ -481,10 +495,6 @@ def connect_output_signal(self, signal: OutputSignal): class InputSignal(SignalChannel): - """ - Invokes a callback when called. - """ - def __init__( self, label: str, @@ -517,10 +527,6 @@ def to_dict(self) -> dict: class OutputSignal(SignalChannel): - """ - Calls all the input signal objects in its connections list when called. - """ - def __call__(self) -> None: for c in self.connections: c() diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py index c6861954..a2c4369e 100644 --- a/tests/unit/test_channels.py +++ b/tests/unit/test_channels.py @@ -2,7 +2,8 @@ from sys import version_info from pyiron_workflow.channels import ( - InputData, OutputData, InputSignal, OutputSignal, NotData, ChannelConnectionError + Channel, InputData, OutputData, InputSignal, OutputSignal, NotData, + ChannelConnectionError ) @@ -16,117 +17,191 @@ def update(self): self.foo.append(self.foo[-1] + 1) +@skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") +class TestChannel(TestCase): + + class InputChannel(Channel): + """Just to de-abstract the base class""" + def __str__(self): + return "non-abstract input" + + @property + def generic_type(self) -> type[Channel]: + return Channel + + class OutputChannel(Channel): + """Just to de-abstract the base class""" + def __str__(self): + return "non-abstract output" + + @property + def generic_type(self) -> type[Channel]: + return Channel + + def setUp(self) -> None: + self.inp = self.InputChannel("inp", DummyNode()) + self.out = self.OutputChannel("out", DummyNode()) + self.out2 = self.OutputChannel("out2", DummyNode()) + + def test_connection_validity(self): + with self.assertRaises( + TypeError, + msg="Can't connect to non-channels" + ): + self.inp.connect("not a node") + + with self.assertRaises( + ChannelConnectionError, + msg="Can't connect non-conjugate pairs" + ): + self.inp.connect(self.InputChannel("also_input", DummyNode())) + + self.inp.connect(self.out) + # A conjugate pair should work fine + + def test_length(self): + self.inp.connect(self.out) + self.out2.connect(self.inp) + self.assertEqual( + 2, + len(self.inp), + msg="Promised that channel length was number of connections" + ) + self.assertEqual( + 1, + len(self.out), + msg="Promised that channel length was number of connections" + ) + + def test_connection_reflexivity(self): + self.inp.connect(self.out) + + self.assertIs( + self.inp.connections[0], + self.out, + msg="Connecting a conjugate pair should work fine" + ) + self.assertIs( + self.out.connections[0], + self.inp, + msg="Promised connection to be reflexive" + ) + self.out.disconnect_all() + self.assertListEqual( + [], + self.inp.connections, + msg="Promised disconnection to be reflexive too" + ) + + self.out.connect(self.inp) + self.assertIs( + self.inp.connections[0], + self.out, + msg="Connecting should work in either direction" + ) + + def test_connect_and_disconnect(self): + self.inp.connect(self.out, self.out2) + # Should allow multiple (dis)connections at once + disconnected = self.inp.disconnect(self.out2, self.out) + self.assertListEqual( + [(self.inp, self.out2), (self.inp, self.out)], + disconnected, + msg="Broken connection pairs should be returned in the order they were " + "broken" + ) + + def test_iterability(self): + self.inp.connect(self.out) + self.out2.connect(self.inp) + for i, conn in enumerate(self.inp): + self.assertIs( + self.inp.connections[i], + conn, + msg="Promised channels to be iterable over connections" + ) + + @skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestDataChannels(TestCase): def setUp(self) -> None: - self.ni1 = InputData(label="numeric", node=DummyNode(), default=1, type_hint=int | float) - self.ni2 = InputData(label="numeric", node=DummyNode(), default=1, type_hint=int | float) - self.no = OutputData(label="numeric", node=DummyNode(), default=0, type_hint=int | float) - self.no_empty = OutputData(label="not_data", node=DummyNode(), type_hint=int | float) + self.ni1 = InputData( + label="numeric", node=DummyNode(), default=1, type_hint=int|float + ) + self.ni2 = InputData( + label="numeric", node=DummyNode(), default=1, type_hint=int|float + ) + self.no = OutputData( + label="numeric", node=DummyNode(), default=0, type_hint=int|float + ) + self.no_empty = OutputData( + label="not_data", node=DummyNode(), type_hint=int|float + ) self.si = InputData(label="list", node=DummyNode(), type_hint=list) - self.so1 = OutputData(label="list", node=DummyNode(), default=["foo"], type_hint=list) - self.so2 = OutputData(label="list", node=DummyNode(), default=["foo"], type_hint=list) - - self.unhinted = InputData(label="unhinted", node=DummyNode) + self.so1 = OutputData( + label="list", node=DummyNode(), default=["foo"], type_hint=list + ) def test_mutable_defaults(self): + so2 = OutputData( + label="list", node=DummyNode(), default=["foo"], type_hint=list + ) self.so1.default.append("bar") self.assertEqual( - len(self.so2.default), + len(so2.default), len(self.so1.default) - 1, - msg="Mutable defaults should avoid sharing between instances" + msg="Mutable defaults should avoid sharing between different instances" ) - def test_connections(self): - - with self.subTest("Test connection reflexivity and value updating"): - self.assertEqual(self.no.value, 0) - self.ni1.connect(self.no) - self.assertIn(self.no, self.ni1.connections) - self.assertIn(self.ni1, self.no.connections) - self.assertNotEqual(self.no.value, self.ni1.value) - self.ni1.fetch() - self.assertEqual(self.no.value, self.ni1.value) - - with self.subTest("Test disconnection"): - disconnected = self.ni2.disconnect(self.no) - self.assertEqual( - len(disconnected), - 0, - msg="There were no connections to begin with, nothing should be there" - ) - disconnected = self.ni1.disconnect(self.no) - self.assertEqual( - [], self.ni1.connections, msg="No connections should be left" - ) - self.assertEqual( - [], - self.no.connections, - msg="Disconnection should also have been reflexive" - ) - self.assertListEqual( - disconnected, - [(self.ni1, self.no)], - msg="Expected a list of the disconnected pairs." - ) + def test_fetch(self): + self.no.value = NotData + self.ni1.value = 1 - with self.subTest("Test multiple connections"): - self.no.connect(self.ni1, self.ni2) - self.assertEqual(2, len(self.no.connections), msg="Should connect to all") + self.ni1.connect(self.no_empty) + self.ni1.connect(self.no) - with self.subTest("Test iteration"): - self.assertTrue(all([con in self.no.connections for con in self.no])) + self.assertEqual( + self.ni1.value, + 1, + msg="Data should not be getting pushed on connection" + ) - with self.subTest("Data should update on fetch"): - self.ni1.disconnect_all() + self.ni1.fetch() + self.assertEqual( + self.ni1.value, + 1, + msg="NotData values should not be getting pulled, so no update expected" + ) - self.no.value = NotData - self.ni1.value = 1 + self.no.value = 3 + self.ni1.fetch() + self.assertEqual( + self.ni1.value, + 3, + msg="Data fetch should to first connected value that's actually data," + "in this case skipping over no_empty" + ) - self.ni1.connect(self.no_empty) - self.ni1.connect(self.no) - self.assertEqual( - self.ni1.value, - 1, - msg="Data should not be getting pushed on connection" - ) - self.ni1.fetch() - self.assertEqual( - self.ni1.value, - 1, - msg="NotData values should not be getting pulled" - ) - self.no.value = 3 - self.ni1.fetch() - self.assertEqual( - self.ni1.value, - 3, - msg="Data fetch should to first connected value that's actually data," - "in this case skipping over no_empty" - ) - self.no_empty.value = 4 - self.ni1.fetch() - self.assertEqual( - self.ni1.value, - 4, - msg="As soon as no_empty actually has data, it's position as 0th " - "element in the connections list should give it priority" - ) + self.no_empty.value = 4 + self.ni1.fetch() + self.assertEqual( + self.ni1.value, + 4, + msg="As soon as no_empty actually has data, it's position as 0th " + "element in the connections list should give it priority" + ) - def test_connection_validity_tests(self): + def test_connection_validity(self): self.ni1.type_hint = int | float | bool # Override with a larger set self.ni2.type_hint = int # Override with a smaller set - with self.assertRaises(TypeError): - self.ni1.connect("Not a channel at all") - self.no.connect(self.ni1) self.assertIn( self.no, self.ni1.connections, - "Input types should be allowed to be a super-set of output types" + msg="Input types should be allowed to be a super-set of output types" ) with self.assertRaises( @@ -146,7 +221,7 @@ def test_connection_validity_tests(self): self.assertIn( self.so1, self.ni2.connections, - "With strict connections turned off, we should allow type-violations" + msg="With strict connections turned off, we should allow type-violations" ) def test_copy_connections(self): @@ -189,19 +264,40 @@ def test_value_receiver(self): msg="Value-linked nodes should automatically get new values" ) + self.ni2.value = 3 + self.assertEqual( + self.ni1.value, + new_value, + msg="Coupling is uni-directional, the partner should not push values back" + ) + + with self.assertRaises( + TypeError, + msg="Only data channels of the same class are valid partners" + ): + self.ni1.value_receiver = self.no + + with self.assertRaises( + ValueError, + msg="Must not couple to self to avoid infinite recursion" + ): + self.ni1.value_receiver = self.ni1 + with self.assertRaises( ValueError, msg="Linking should obey type hint requirements", ): self.ni1.value_receiver = self.si - self.si.strict_hints = False - self.ni1.value_receiver = self.si # Should work fine if the receiver is not - # strictly checking hints + with self.subTest("Value receivers avoiding type checking"): + self.si.strict_hints = False + self.ni1.value_receiver = self.si # Should work fine if the receiver is not + # strictly checking hints - self.ni1.value_receiver = self.unhinted - self.unhinted.value_receiver = self.ni2 - # Should work fine if either is unhinted + unhinted = InputData(label="unhinted", node=DummyNode()) + self.ni1.value_receiver = unhinted + unhinted.value_receiver = self.ni2 + # Should work fine if either lacks a hint def test_value_assignment(self): self.ni1.value = 2 # Should be fine when value matches hint @@ -241,44 +337,6 @@ def test_ready(self): self.ni1._value = "Not numeric at all" # Bypass type checking self.assertFalse(self.ni1.ready) - def test_input_coupling(self): - self.assertNotEqual( - self.ni2.value, - 2, - msg="Ensure we start from a setup that the next test is meaningful" - ) - self.ni1.value = 2 - self.ni1.value_receiver = self.ni2 - self.assertEqual( - self.ni2.value, - 2, - msg="Coupled value should get updated on coupling" - ) - self.ni1.value = 3 - self.assertEqual( - self.ni2.value, - 3, - msg="Coupled value should get updated after partner update" - ) - self.ni2.value = 4 - self.assertEqual( - self.ni1.value, - 3, - msg="Coupling is uni-directional, the partner should not push values back" - ) - - with self.assertRaises( - TypeError, - msg="Only input data channels are valid partners" - ): - self.ni1.value_receiver = self.no - - with self.assertRaises( - ValueError, - msg="Must not couple to self to avoid infinite recursion" - ): - self.ni1.value_receiver = self.ni1 - class TestSignalChannels(TestCase): def setUp(self) -> None: From 4f76dc5de1f0ef7083f8b860d50867456f2cf60c Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 13:09:41 -0800 Subject: [PATCH 02/41] Never allow input data updates while the parent node is running --- pyiron_workflow/channels.py | 28 +++++++++++++++++++++------- tests/unit/test_channels.py | 8 ++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index 32745420..f3a35271 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -324,6 +324,12 @@ def value(self): @value.setter def value(self, new_value): + self._type_check_new_value(new_value) + if self.value_receiver is not None: + self.value_receiver.value = new_value + self._value = new_value + + def _type_check_new_value(self, new_value): if ( self.strict_hints and new_value is not NotData @@ -334,9 +340,6 @@ def value(self, new_value): f"The channel {self.label} cannot take the value `{new_value}` because " f"it is not compliant with the type hint {self.type_hint}" ) - if self.value_receiver is not None: - self.value_receiver.value = new_value - self._value = new_value @property def value_receiver(self) -> InputData | OutputData | None: @@ -455,15 +458,26 @@ def fetch(self) -> None: Raises: RuntimeError: If the parent node is `running`. """ + for out in self.connections: + if out.value is not NotData: + self.value = out.value + break + + @property + def value(self): + return self._value + + @value.setter + def value(self, new_value): if self.node.running: raise RuntimeError( f"Parent node {self.node.label} of {self.label} is running, so value " f"cannot be updated." ) - for out in self.connections: - if out.value is not NotData: - self.value = out.value - break + self._type_check_new_value(new_value) + if self.value_receiver is not None: + self.value_receiver.value = new_value + self._value = new_value class OutputData(DataChannel): diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py index a2c4369e..2c694b42 100644 --- a/tests/unit/test_channels.py +++ b/tests/unit/test_channels.py @@ -303,6 +303,14 @@ def test_value_assignment(self): self.ni1.value = 2 # Should be fine when value matches hint self.ni1.value = NotData # Should be able to clear the data + self.ni1.node.running = True + with self.assertRaises( + RuntimeError, + msg="Input data should be locked while its node runs" + ): + self.ni1.value = 3 + self.ni1.node.running = False + with self.assertRaises( TypeError, msg="Should not be able to take values of the wrong type" From a9381c8c6dda5c143c50d755832091587f8d9ec7 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 13:18:01 -0800 Subject: [PATCH 03/41] Update Node docs and make tests for the base class Including testing each of the run boolean flags --- pyiron_workflow/node.py | 78 ++++++++------ tests/unit/test_node.py | 218 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 33 deletions(-) create mode 100644 tests/unit/test_node.py diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 0a9fa5fc..ed4c5fc8 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -1,6 +1,8 @@ """ A base class for objects that can form nodes in the graph representation of a computational workflow. + +The workhorse class for the entire concept. """ from __future__ import annotations @@ -65,39 +67,46 @@ def wrapped_method(node: Node, *args, **kwargs): # rather node:Node class Node(HasToDict, ABC): """ Nodes are elements of a computational graph. - They have input and output data channels that interface with the outside - world, and a callable that determines what they actually compute, and input and - output signal channels that can be used to customize the execution flow of their - graph; - Together these channels represent edges on the dual data and execution computational - graphs. - - Nodes can be run in a variety of ways.. - Non-exhaustively, they can be run in a "push" paradigm where they do their - calculation and then trigger downstream calculations; in a "pull" mode where they - first make sure all their upstream dependencies then run themselves (but not - anything downstream); or they may be forced to run their calculation with exactly - the input they have right now. - These and more options are available, and for more information look at the `run` - method. - - Nodes may have a `parent` node that owns them as part of a sub-graph. - - Every node must be named with a `label`, and may use this label to attempt to create - a working directory in memory for itself if requested. + They have input and output data channels, and input and output signal channels (for + running and having ran, by default), and can be connected together via these + channels to form computational graphs. + When running, they perform some computation (which must be defined in child + classes.) + + Running is always delayed, so no computation is performed unless _some form_ of + run request given by the user (e.g., obviously, invoking `.run()`). + + The options for running a node are enumerated in the `run` method, and convenience + shortcuts for particular sets of options are provided in `execute`, `pull`, and by + calling an instantiated node. + In all cases, the nodes input data can be updated before any running operations + by passing keyword-value pairs to the run invocation. + Because this happens first, _if_ the run invocation updates the input values some + other way, these supplied values will get overwritten. + + A non-exhaustive summary of running styles is: nodes can be run in a "push" + paradigm where they do their calculation and then trigger downstream calculations; + in a "pull" mode where they first make sure all their upstream dependencies then + run themselves (but not anything downstream); or they may be forced to run their + calculation with exactly the input they have right now. + For more information look at the documentation of the `run` method. + + Nodes may have a parent node that owns them as part of a sub-graph. + + Every node must be named with a label, and may use this label to attempt to create + a working directory in the filesystem for itself if requested. These labels also help to identify nodes in the wider context of (potentially nested) computational graphs. - By default, nodes' signals input comes with `run` and `ran` IO ports, which invoke - the `run()` method and emit after running the node, respectfully. - (Whether we get all the way to emitting the `ran` signal depends on how the node - was invoked -- it is possible to computing things with the node without sending - any more signals downstream.) - These signal connections can be made manually by reference to the node signals - channel, or with the `>` symbol to indicate a flow of execution. This syntactic - sugar can be mixed between actual signal channels (output signal > input signal), - or nodes, but when referring to nodes it is always a shortcut to the `run`/`ran` - channels. + Execution flow should be automated wherever possible for user convenience (namely + when the data graph forms a directed acyclic graph (DAG), of which the acyclic part + is the only thing that might fail). + Execution flow can also be specified manually using signal connections. + These connections can be made using the same syntax as data connections, or with + some syntactic sugar where the the `>` symbol is used to indicate a flow of + execution from upstream dow. This syntactic sugar can be mixed between actual + signal channels (output signal > input signal), or nodes, but when referring to + nodes it is always a shortcut to the `run`/`ran` channels. The `run()` method returns a representation of the node output (possible a futures object, if the node is running on an executor), and consequently the `pull`, @@ -107,8 +116,6 @@ class Node(HasToDict, ABC): allows its input channels to be updated using keyword arguments corresponding to the channel labels, performing a batch-update of all supplied input and then proceeding. - As such, _if_ the run invocation updates the input values some other way, these - supplied values will get overwritten. Nodes have a status, which is currently represented by the `running` and `failed` boolean flag attributes. @@ -133,7 +140,12 @@ class Node(HasToDict, ABC): `process_run_result` once `on_run` finishes. They may optionally add additional signal channels to the signals IO. - # TODO: Everything with (de)serialization for storage + # TODO: + - Everything with (de)serialization for storage + - Integration with more powerful tools for remote execution (anything obeying + the standard interface of a `submit` method taking the callable and + arguments and returning a futures object should work, as long as it can + handle serializing dynamically defined objects. Attributes: connected (bool): Whether _any_ of the IO (including signals) are connected. diff --git a/tests/unit/test_node.py b/tests/unit/test_node.py new file mode 100644 index 00000000..5d497283 --- /dev/null +++ b/tests/unit/test_node.py @@ -0,0 +1,218 @@ +from concurrent.futures import Future +import os +from sys import version_info +import unittest + +from pyiron_workflow.channels import InputData, OutputData +from pyiron_workflow.io import Inputs, Outputs +from pyiron_workflow.node import Node + + +def add_one(x): + return x + 1 + + +class ANode(Node): + """To de-abstract the class""" + + def __init__(self, label): + super().__init__(label=label) + self._inputs = Inputs(InputData("x", self, type_hint=int)) + self._outputs = Outputs(OutputData("y", self, type_hint=int)) + + @property + def inputs(self) -> Inputs: + return self._inputs + + @property + def outputs(self) -> Inputs: + return self._outputs + + @property + def on_run(self): + return add_one + + @property + def run_args(self) -> dict: + return {"x": self.inputs.x.value} + + def process_run_result(self, run_output): + self.outputs.y.value = run_output + return run_output + + def to_dict(self): + pass + + +@unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") +class TestNode(unittest.TestCase): + def setUp(self): + n1 = ANode("start") + n2 = ANode("middle") + n3 = ANode("end") + n1.inputs.x = 42 + n2.inputs.x = n1.outputs.y + n3.inputs.x = n2.outputs.y + self.n1 = n1 + self.n2 = n2 + self.n3 = n3 + + def test_set_input_values(self): + n = ANode("some_node") + n.set_input_values(x=2) + self.assertEqual( + 2, + n.inputs.x.value, + msg="Post-instantiation update of inputs should also work" + ) + + n.set_input_values(y=3) + # Missing keys may throw a warning, but are otherwise allowed to pass + + with self.assertRaises( + TypeError, + msg="Type checking should be applied", + ): + n.set_input_values(x="not an int") + + n.deactivate_strict_hints() + n.set_input_values(x="not an int") + self.assertEqual( + "not an int", + n.inputs.x.value, + msg="It should be possible to deactivate type checking from the node level" + ) + + def test_check_readiness(self): + with self.assertRaises( + ValueError, + msg="When input is not data, we should fail early" + ): + self.n3.run(run_data_tree=False, fetch_input=False, check_readiness=True) + + self.assertFalse( + self.n3.failed, + msg="The benefit of the readiness check should be that we don't actually " + "qualify as failed" + ) + + with self.assertRaises( + TypeError, + msg="If we bypass the check, we should get the failing function error" + ): + self.n3.run(run_data_tree=False, fetch_input=False, check_readiness=False) + + self.assertTrue( + self.n3.failed, + msg="If the node operation itself fails, the status should be failed" + ) + + self.n3.inputs.x = 0 + with self.assertRaises( + ValueError, + msg="When status is failed, we should fail early, even if input data is ok" + ): + self.n3.run(run_data_tree=False, fetch_input=False, check_readiness=True) + + with self.assertRaises( + RuntimeError, + msg="If we manage to run with bad input, being in a failed state still " + "stops us" + ): + self.n3.run(run_data_tree=False, fetch_input=False, check_readiness=False) + + self.n3.failed = False + self.assertEqual( + 1, + self.n3.run(run_data_tree=False, fetch_input=False, check_readiness=True), + msg="After manually resetting the failed state and providing good input, " + "running should proceed" + ) + + def test_fetch_input(self): + self.n1.outputs.y.value = 0 + with self.assertRaises( + ValueError, + msg="Without input, we should not achieve readiness" + ): + self.n2.run(run_data_tree=False, fetch_input=False, check_readiness=True) + + self.assertEqual( + add_one(self.n1.outputs.y.value), + self.n2.run(run_data_tree=False, fetch_input=True), + msg="After fetching the upstream data, should run fine" + ) + + def test_run_data_tree(self): + self.assertEqual( + add_one(add_one(add_one(self.n1.inputs.x.value))), + self.n3.run(run_data_tree=True), + msg="Should pull start down to end, even with no flow defined" + ) + + def test_emit_ran_signal(self): + self.n1 > self.n2 + + self.n1.run(emit_ran_signal=False) + self.assertFalse( + self.n2.inputs.x.ready, + msg="Without emitting the ran signal, nothing should happen downstream" + ) + + self.n1.run(emit_ran_signal=True) + self.assertEqual( + add_one(add_one(self.n1.inputs.x.value)), + self.n2.outputs.y.value, + msg="With the connection and signal, we should have pushed downstream " + "execution" + ) + + def test_force_local_execution(self): + self.n1.executor = True + out = self.n1.run(force_local_execution=False) + with self.subTest("Test running with an executor fulfills promises"): + self.assertIsInstance( + out, + Future, + msg="With an executor, we expect a futures object back" + ) + self.assertTrue( + self.n1.running, + msg="The running flag should be true while it's running, and " + "(de)serialization is time consuming enough that we still expect" + "this to be the case" + ) + self.assertFalse( + self.n1.ready, + msg="While running, the node should not be ready." + ) + with self.assertRaises( + RuntimeError, + msg="Running nodes should not be allowed to get their input updated", + ): + self.n1.inputs.x = 42 + + self.n2.executor = True + self.n2.inputs.x = 0 + self.assertEqual( + 1, + self.n2.run(force_local_execution=True), + msg="Forcing local execution should do just that." + ) + + def test_working_directory(self): + self.assertFalse( + os.path.isdir(self.n1.label), + msg="No working directory should be made unless asked for" + ) + wd = self.n1.working_directory + self.assertTrue( + os.path.isdir(self.n1.label), + msg="Now we asked for it" + ) + wd.delete() + self.assertFalse( + os.path.isdir(self.n1.label), + msg="Just want to make sure we cleaned up after ourselves" + ) + From f13899933a478e4d53929af6afaa47710a26c305 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 13:48:07 -0800 Subject: [PATCH 04/41] Draft module-level overview for API docs/use in readme --- pyiron_workflow/__init__.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pyiron_workflow/__init__.py b/pyiron_workflow/__init__.py index b2636e88..566dfc9f 100644 --- a/pyiron_workflow/__init__.py +++ b/pyiron_workflow/__init__.py @@ -1 +1,30 @@ +""" +`pyiron_workflow` is a python framework for constructing computational workflows in a +graph-based format. +The intent of such a framework is to improve the reliability and shareability of +computational workflows, as well as providing supporting infrastructure for the +storage and retrieval of data, and executing computations on remote resources (with a +special emphasis on HPC environments common in academic research). +It is a key goal that writing such workflows should be as easy as possible, and simple +cases should be _almost_ as simple as writing and running plain python functions. + +Key features: +- Single point of import +- Easy "nodeification" of regular python code +- Macro nodes, so complex workflows can be built by composition +- (Optional) type checking for data connections +- (Optional) remote execution of individual nodes (currently only very simple + single-core, same-machine parallel processes) +- Both acyclic (execution flow is automated) and cyclic (execution flow must be + specified) graphs allowed +- Easy extensibility by collecting packages of nodes together for sharing/reusing + +Planned: +- Storage of executed workflows, including restarting from a partially executed workflow +- Support for more complex remote execution, especially leveraging `pympipool` +- Infrastructure that supports and encourages of FAIR principles for node packages and + finished workflows +- Ontological hinting for data channels in order to provide guided workflow design +- GUI on top for code-lite/code-free visual scripting +""" from pyiron_workflow.workflow import Workflow From 70f0d98f06c5b519c7bae0b5375c1c20bce78832 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 13:48:19 -0800 Subject: [PATCH 05/41] Test single point of import --- tests/unit/test_pyiron_workflow.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/unit/test_pyiron_workflow.py diff --git a/tests/unit/test_pyiron_workflow.py b/tests/unit/test_pyiron_workflow.py new file mode 100644 index 00000000..7f1f77e4 --- /dev/null +++ b/tests/unit/test_pyiron_workflow.py @@ -0,0 +1,10 @@ +from sys import version_info +import unittest + + +@unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") +class TestModule(unittest.TestCase): + def test_single_point_of_entry(self): + from pyiron_workflow import Workflow + # That's it, let's just make sure the main class is available at the topmost + # level From 35b5c0e84049ad2bb071b3b5b88e9ea157628f93 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 13:53:10 -0800 Subject: [PATCH 06/41] :bug: fix the expected error to be raised Being running stops us from being ready, so we don't get all the way to the runtime error but stop at the readiness check --- tests/unit/test_function.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index 1a33fcc1..07e9f8b6 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -229,8 +229,8 @@ def test_statuses(self): # Can't really test "running" until we have a background executor, so fake a bit n.running = True - with self.assertRaises(RuntimeError): - # Running nodes can't be run + with self.assertRaises(ValueError): + # Running nodes aren't ready and so can't be run n.run() n.running = False @@ -403,10 +403,10 @@ def test_return_value(self): Future, msg="Running with an executor should return the future" ) - with self.assertRaises(RuntimeError): + with self.assertRaises(ValueError): # The executor run should take a second # So we can double check that attempting to run while already running - # raises an error + # raises an error since running nodes aren't ready node.run() node.future.result() # Wait for the remote execution to finish From 300ad1cfaea5e3cec368ab8546fb21bacbe12000 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 14:21:34 -0800 Subject: [PATCH 07/41] Update IO docstring --- pyiron_workflow/io.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/pyiron_workflow/io.py b/pyiron_workflow/io.py index 0932a57d..9a540be4 100644 --- a/pyiron_workflow/io.py +++ b/pyiron_workflow/io.py @@ -1,5 +1,8 @@ """ Collections of channel objects. + +These also support the syntactic sugar of treating value assignments and new +connections on the same footing. """ from __future__ import annotations @@ -34,19 +37,9 @@ class IO(HasToDict, ABC): When assigning something to an attribute holding an existing channel, if the assigned object is a `Channel`, then an attempt is made to make a `connection` between the two channels, otherwise we fall back on a value assignment that must - be defined in child classes under `_assign_value_to_existing_channel`, i.e. - >>> some_io.some_existing_channel = 5 - - is equivalent to - >>> some_io._assign_value_to_existing_channel( - ... some_io["some_existing_channel"], 5 - ... ) - - and - >>> some_io.some_existing_channel = some_other_channel - - is equivalent to - >>> some_io.some_existing_channel.connect(some_other_channel) + be defined in child classes under `_assign_value_to_existing_channel`. + This provides syntactic sugar such that both new connections and new values can + be assigned with a simple `=`. """ def __init__(self, *channels: Channel): @@ -172,10 +165,6 @@ def __setstate__(self, state): class DataIO(IO, ABC): - """ - Extends the base IO class with helper methods relevant to data channels. - """ - def _assign_a_non_channel_value(self, channel: DataChannel, value) -> None: channel.value = value From 5f567f079309f6a02cb945b620ae63fb3b11587a Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 14:27:20 -0800 Subject: [PATCH 08/41] Update Function docstring --- pyiron_workflow/function.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index e16cff32..cf4a65a2 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -41,9 +41,7 @@ class Function(Node): $N$ to 1 in case you _want_ a tuple returned) and to dodge constraints on the automatic scraping routine (namely, that there be _at most_ one `return` expression). - (Additional properties like storage priority and ontological type are forthcoming - as kwarg dictionaries with keys corresponding to the channel labels (i.e. the node - arguments of the node function, or the output labels provided).) + (Additional properties like storage priority and ontological type are forthcoming.) Actual function node instances can either be instances of the base node class, in which case the callable node function *must* be provided OR they can be instances @@ -63,8 +61,6 @@ class Function(Node): post-run callbacks defined in `Node` -- such that run results are used to populate the output channels. - After a node is instantiated, its input can be updated as `*args` and/or `**kwargs` - on call. `run()` and its aliases return the output of the executed function, or a futures object if the node is set to use an executor. @@ -305,8 +301,9 @@ class Function(Node): `Workflow` class. Comments: - - Using the `self` argument for function nodes is not currently supported. + Using the `self` argument for function nodes is not fully supported; it will + raise an error when combined with an executor, and otherwise behaviour is not + guaranteed. """ def __init__( From 7fd250eeea450842006f99302ae7f10c70a97fe9 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 14:49:22 -0800 Subject: [PATCH 09/41] Update function tests And move a bit more into the abstract node tests --- tests/unit/test_function.py | 185 +++++------------------------------- tests/unit/test_node.py | 24 +++-- 2 files changed, 44 insertions(+), 165 deletions(-) diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index 07e9f8b6..9a9f5a80 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -5,10 +5,7 @@ import warnings from pyiron_workflow.channels import NotData, ChannelConnectionError -from pyiron_workflow.files import DirectoryObject -from pyiron_workflow.function import ( - Function, SingleValue, function_node, single_value_node -) +from pyiron_workflow.function import Function, SingleValue, function_node def throw_error(x: Optional[int] = None): @@ -49,9 +46,10 @@ def test_instantiation(self): with self.subTest("Args and kwargs at initialization"): node = Function(plus_one) self.assertIs( - node.outputs.y.value, NotData, - msg="Nodes should not run at instantiation", + node.outputs.y.value, + msg="Sanity check that output just has the standard not-data value at " + "instantiation", ) node.inputs.x = 10 self.assertIs( @@ -63,8 +61,8 @@ def test_instantiation(self): self.assertEqual( node.outputs.y.value, 11, - msg=f"Slow nodes should still run when asked! Expected 11 but got " - f"{node.outputs.y.value}" + msg=f"Expected the run to update the output -- did the test function" + f"change or something?" ) node = Function(no_default, 1, y=2, output_labels="output") @@ -72,10 +70,9 @@ def test_instantiation(self): self.assertEqual( no_default(1, 2), node.outputs.output.value, - msg="Nodes should allow input initialization by arg and kwarg" + msg="Nodes should allow input initialization by arg _and_ kwarg" ) node(2, y=3) - node.run() self.assertEqual( no_default(2, 3), node.outputs.output.value, @@ -159,108 +156,23 @@ def bilinear(x, y): "use at the class level" ) - def test_signals(self): - @function_node() - def linear(x): - return x - - @function_node() - def times_two(y): - return 2 * y - - l = linear(x=1) - t2 = times_two( - output_labels=["double"], - y=l.outputs.x - ) - self.assertIs( - t2.outputs.double.value, - NotData, - msg=f"Without updates, expected the output to be {NotData} but got " - f"{t2.outputs.double.value}" - ) - - # Nodes should _all_ have the run and ran signals - t2.signals.input.run = l.signals.output.ran - l.run() - self.assertEqual( - t2.outputs.double.value, 2, - msg="Running the upstream node should trigger a run here" - ) - - with self.subTest("Test syntactic sugar"): - t2.signals.input.run.disconnect_all() - l > t2 - self.assertIn( - l.signals.output.ran, - t2.signals.input.run.connections, - msg="> should be equivalent to run/ran connection" - ) - - t2.signals.input.run.disconnect_all() - l > t2.signals.input.run - self.assertIn( - l.signals.output.ran, - t2.signals.input.run.connections, - msg="> should allow us to mix and match nodes and signal channels" - ) - - t2.signals.input.run.disconnect_all() - l.signals.output.ran > t2 - self.assertIn( - l.signals.output.ran, - t2.signals.input.run.connections, - msg="Mixing and matching should work both directions" - ) - - t2.signals.input.run.disconnect_all() - l > t2 > l - self.assertTrue( - l.signals.input.run.connections[0] is t2.signals.output.ran - and t2.signals.input.run.connections[0] is l.signals.output.ran, - msg="> should allow chaining signal connections" - ) - def test_statuses(self): n = Function(plus_one) self.assertTrue(n.ready) self.assertFalse(n.running) self.assertFalse(n.failed) - # Can't really test "running" until we have a background executor, so fake a bit - n.running = True - with self.assertRaises(ValueError): - # Running nodes aren't ready and so can't be run - n.run() - n.running = False - n.inputs.x = "Can't be added together with an int" - with self.assertRaises(TypeError): - # The function error should get passed up + with self.assertRaises( + TypeError, + msg="We expect the int+str type error because there were no type hints " + "guarding this function from running with bad data" + ): n.run() self.assertFalse(n.ready) self.assertFalse(n.running) self.assertTrue(n.failed) - n.inputs.x = 1 - self.assertFalse( - n.ready, - msg="Should not be ready while it has failed status" - ) - - n.failed = False # Manually reset the failed status - self.assertTrue( - n.ready, - msg="Input is ok, not running, not failed -- should be ready!" - ) - n.run() - self.assertTrue(n.ready) - self.assertFalse(n.running) - self.assertFalse( - n.failed, - msg="Should be back to a good state and ready to run again" - ) - def test_with_self(self): def with_self(self, x: float) -> float: # Note: Adding internal state to the node like this goes against the best @@ -296,7 +208,8 @@ def with_self(self, x: float) -> float: self.assertEqual( node.some_counter, 1, - msg="Function functions should be able to modify attributes on the node object." + msg="Function functions should be able to modify attributes on the node " + "object." ) node.executor = True @@ -378,37 +291,23 @@ def test_return_value(self): node = Function(plus_one) with self.subTest("Run on main process"): - return_on_call = node(1) - self.assertEqual( - return_on_call, - plus_one(1), - msg="Run output should be returned on call" - ) - node.inputs.x = 2 return_on_explicit_run = node.run() self.assertEqual( return_on_explicit_run, plus_one(2), - msg="On explicit run, the most recent input data should be used and the " - "result should be returned" + msg="On explicit run, the most recent input data should be used and " + "the result should be returned" ) - with self.subTest("Run on executor"): - node.executor = True - - return_on_explicit_run = node.run() - self.assertIsInstance( - return_on_explicit_run, - Future, - msg="Running with an executor should return the future" + return_on_call = node(1) + self.assertEqual( + return_on_call, + plus_one(1), + msg="Run output should be returned on call" + # This is a duplicate test, since __call__ just invokes run, but it is + # such a core promise that let's just double-check it ) - with self.assertRaises(ValueError): - # The executor run should take a second - # So we can double check that attempting to run while already running - # raises an error since running nodes aren't ready - node.run() - node.future.result() # Wait for the remote execution to finish def test_copy_connections(self): node = Function(plus_one) @@ -662,41 +561,9 @@ def test_easy_output_connection(self): "from assignment at instantiation" ) - def test_working_directory(self): - n_f = Function(plus_one) - self.assertTrue(n_f._working_directory is None) - self.assertIsInstance(n_f.working_directory, DirectoryObject) - self.assertTrue(str(n_f.working_directory.path).endswith(n_f.label)) - n_f.working_directory.delete() - - def test_disconnection(self): - n1 = Function(no_default, output_labels="out") - n2 = Function(no_default, output_labels="out") - n3 = Function(no_default, output_labels="out") - n4 = Function(plus_one) - - n3.inputs.x = n1.outputs.out - n3.inputs.y = n2.outputs.out - n4.inputs.x = n3.outputs.out - n2 > n3 > n4 - disconnected = n3.disconnect() - self.assertListEqual( - disconnected, - [ - # Inputs - (n3.inputs.x, n1.outputs.out), - (n3.inputs.y, n2.outputs.out), - # Outputs - (n3.outputs.out, n4.inputs.x), - # Signals (inputs, then output) - (n3.signals.input.run, n2.signals.output.ran), - (n3.signals.output.ran, n4.signals.input.run), - ], - msg="Expected to find pairs (starting with the node disconnect was called " - "on) of all broken connections among input, output, and signals." - ) - - def test_pulling_without_any_parents(self): + def test_nested_declaration(self): + # It's really just a silly case of running without a parent, where you don't + # store references to all the nodes declared node = SingleValue( plus_one, x=SingleValue( diff --git a/tests/unit/test_node.py b/tests/unit/test_node.py index 5d497283..37bec924 100644 --- a/tests/unit/test_node.py +++ b/tests/unit/test_node.py @@ -50,7 +50,7 @@ def setUp(self): n1 = ANode("start") n2 = ANode("middle") n3 = ANode("end") - n1.inputs.x = 42 + n1.inputs.x = 0 n2.inputs.x = n1.outputs.y n3.inputs.x = n2.outputs.y self.n1 = n1 @@ -151,18 +151,18 @@ def test_run_data_tree(self): ) def test_emit_ran_signal(self): - self.n1 > self.n2 + self.n1 > self.n2 > self.n3 # Chained connection declaration self.n1.run(emit_ran_signal=False) self.assertFalse( - self.n2.inputs.x.ready, + self.n3.inputs.x.ready, msg="Without emitting the ran signal, nothing should happen downstream" ) self.n1.run(emit_ran_signal=True) self.assertEqual( - add_one(add_one(self.n1.inputs.x.value)), - self.n2.outputs.y.value, + add_one(add_one(add_one(self.n1.inputs.x.value))), + self.n3.outputs.y.value, msg="With the connection and signal, we should have pushed downstream " "execution" ) @@ -191,12 +191,24 @@ def test_force_local_execution(self): msg="Running nodes should not be allowed to get their input updated", ): self.n1.inputs.x = 42 + self.assertEqual( + 1, + out.result(), + msg="If we wait for the remote execution to finish, it should give us" + "the right thing" + ) + self.assertEqual( + 1, + self.n1.outputs.y.value, + msg="The callback on the executor should ensure the output processing " + "happens" + ) self.n2.executor = True self.n2.inputs.x = 0 self.assertEqual( 1, - self.n2.run(force_local_execution=True), + self.n2.run(fetch_input=False, force_local_execution=True), msg="Forcing local execution should do just that." ) From 37c2262d6dd13af372794582438af40daaef5e2d Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 6 Nov 2023 15:02:44 -0800 Subject: [PATCH 10/41] Test the call aliases --- tests/unit/test_node.py | 64 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/unit/test_node.py b/tests/unit/test_node.py index 37bec924..6ec20479 100644 --- a/tests/unit/test_node.py +++ b/tests/unit/test_node.py @@ -212,6 +212,70 @@ def test_force_local_execution(self): msg="Forcing local execution should do just that." ) + def test_execute(self): + self.n1.outputs.y = 0 # Prime the upstream data source for fetching + self.n2 > self.n3 + self.assertEqual( + self.n2.run(fetch_input=False, emit_ran_signal=False, x=10) + 1, + self.n2.execute(x=11), + msg="Execute should _not_ fetch in the upstream data" + ) + self.assertFalse( + self.n3.ready, + msg="Executing should not be triggering downstream runs, even though we " + "made a ran/run connection" + ) + + self.n2.inputs.x._value = "manually override the desired int" + with self.assertRaises( + TypeError, + msg="Execute should be running without a readiness check and hitting the " + "string + int error" + ): + self.n2.execute() + + def test_pull(self): + self.n2 > self.n3 + self.n1.inputs.x = 0 + by_run = self.n2.run( + run_data_tree=True, + fetch_input=True, + emit_ran_signal=False + ) + self.n1.inputs.x = 1 + self.assertEqual( + by_run + 1, + self.n2.pull(), + msg="Pull should be running the upstream node" + ) + self.assertFalse( + self.n3.ready, + msg="Pulling should not be triggering downstream runs, even though we " + "made a ran/run connection" + ) + + def test___call__(self): + # __call__ is just a pull that punches through macro walls, so we'll need to + # test it again over in macro to really make sure it's working + self.n2 > self.n3 + self.n1.inputs.x = 0 + by_run = self.n2.run( + run_data_tree=True, + fetch_input=True, + emit_ran_signal=False + ) + self.n1.inputs.x = 1 + self.assertEqual( + by_run + 1, + self.n2(), + msg="A call should be running the upstream node" + ) + self.assertFalse( + self.n3.ready, + msg="Calling should not be triggering downstream runs, even though we " + "made a ran/run connection" + ) + def test_working_directory(self): self.assertFalse( os.path.isdir(self.n1.label), From dbddd142915496c38d267648a1dae1fd474bd62b Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 11:46:20 -0800 Subject: [PATCH 11/41] Update Composite docs Using a format of "promises" the class makes about its behaviour --- pyiron_workflow/composite.py | 71 ++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 1f8376da..d524e253 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -24,37 +24,41 @@ class Composite(Node, ABC): """ - A base class for nodes that have internal structure -- i.e. they hold a sub-graph. - - Item and attribute access is modified to give access to owned nodes. - Adding a node with the `add` functionality or by direct attribute assignment sets - this object as the parent of that node. - - Guarantees that each owned node is unique, and does not belong to any other parents. - - Offers a class method (`wrap_as`) to give easy access to the node-creating - decorators. - - Offers a creator (the `create` method) which allows instantiation of other workflow - objects. - This method behaves _differently_ on the composite class and its instances -- on - instances, any created nodes get their `parent` attribute automatically set to the - composite instance being used. - - Specifies the required `on_run()` and `run_args` to call `run()` on a subset of - owned `starting_nodes`, thus kick-starting computation on the owned sub-graph. - Both the specification of these starting nodes and specifying execution signals to - propagate execution through the graph is left to the user/child classes. - In the case of non-cyclic workflows (i.e. DAGs in terms of data flow), both - starting nodes and execution flow can be specified by invoking execution flow can - be determined automatically. - - Also specifies `process_run_result` such that the `run` method (and its aliases) - return a new dot-accessible dictionary of keys and values created from the - composite output IO panel. - - Does not specify `input` and `output` as demanded by the parent class; this - requirement is still passed on to children. + A base class for nodes that have internal graph structure -- i.e. they hold a + collection of child nodes and their computation is to execute that graph. + + Promises: + - The class offers access... + - To the node-izing `pyiron_workflow` decorators + - To a creator for other `pyiron_workflow` objects (namely nodes) + - From the class level, this simply creates these objects + - From the instance level, created nodes get the instance as their parent + - Child nodes... + - Can be added by... + - Creating them from the instance + - Passing a node instance to the adding method + - Setting the composite instance as the node's parent + - Assigning a node instance as an attribute + - Can be accessed by... + - Attribute access using their node label + - Attribute or item access in the child nodes collection + - Iterating over the composite instance + - Each have a unique label (within the scope of this composite) + - Have no other parent + - Can be replaced in-place with another node that has commensurate IO + - The length of a composite instance is its number of child nodes + - Running the composite... + - Runs the child nodes (either using manually specified execution signals, or + leveraging a helper tool that automates this process for data DAGs -- + details are left to child classes) + - Returns a dot-dictionary of output IO + - Composite IO... + - Is some subset of the child nodes IO + - Default channel labels indicate both child and child's channel labels + - Default behaviour is to expose all unconnected child nodes' IO + - Can be given new labels + - Can force some child node's IO to appear + - Can force some child node's IO to _not_ appear Attributes: inputs/outputs_map (bidict|None): Maps in the form @@ -82,6 +86,11 @@ class Composite(Node, ABC): add(node: Node): Add the node instance to this subgraph. remove(node: Node): Break all connections the node has, remove it from this subgraph, and set its parent to `None`. + (de)activate_strict_hints(): Recursively (de)activate strict type hints. + replace(owned_node: Node | str, replacement: Node | type[Node]): Replaces an + owned node with a new node, as long as the new node's IO is commensurate + with the node being replaced. + register(): A short-cut to registering a new node package with the node creator. """ wrap_as = Wrappers() From 7739aeb7fb8b726ceb6f7a212b2cf8a5d3bbc4b7 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 12:14:10 -0800 Subject: [PATCH 12/41] Improve working directory test --- tests/unit/test_node.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_node.py b/tests/unit/test_node.py index 6ec20479..5b0521af 100644 --- a/tests/unit/test_node.py +++ b/tests/unit/test_node.py @@ -4,6 +4,7 @@ import unittest from pyiron_workflow.channels import InputData, OutputData +from pyiron_workflow.files import DirectoryObject from pyiron_workflow.io import Inputs, Outputs from pyiron_workflow.node import Node @@ -277,16 +278,28 @@ def test___call__(self): ) def test_working_directory(self): + self.assertTrue( + self.n1._working_directory is None, + msg="Sanity check -- No working directory should be made unless asked for" + ) self.assertFalse( os.path.isdir(self.n1.label), - msg="No working directory should be made unless asked for" + msg="Sanity check -- No working directory should be made unless asked for" + ) + self.assertIsInstance( + self.n1.working_directory, + DirectoryObject, + msg="Directory should be created on first access" + ) + self.assertTrue( + str(self.n1.working_directory.path).endswith(self.n1.label), + msg="Directory name should be based off of label" ) - wd = self.n1.working_directory self.assertTrue( os.path.isdir(self.n1.label), - msg="Now we asked for it" + msg="Now we asked for it, it should be there" ) - wd.delete() + self.n1.working_directory.delete() self.assertFalse( os.path.isdir(self.n1.label), msg="Just want to make sure we cleaned up after ourselves" From aa019450327d059bdef721d4130e4c9b27a80876 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 12:29:09 -0800 Subject: [PATCH 13/41] Update composite promises --- pyiron_workflow/composite.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index d524e253..e3a201db 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -27,7 +27,7 @@ class Composite(Node, ABC): A base class for nodes that have internal graph structure -- i.e. they hold a collection of child nodes and their computation is to execute that graph. - Promises: + Promises (in addition parent class promises): - The class offers access... - To the node-izing `pyiron_workflow` decorators - To a creator for other `pyiron_workflow` objects (namely nodes) @@ -43,10 +43,12 @@ class Composite(Node, ABC): - Attribute access using their node label - Attribute or item access in the child nodes collection - Iterating over the composite instance + - Can be removed - Each have a unique label (within the scope of this composite) - Have no other parent - Can be replaced in-place with another node that has commensurate IO - - The length of a composite instance is its number of child nodes + - Have their working directory nested inside the composite's + - The length of a composite instance is its number of child nodes - Running the composite... - Runs the child nodes (either using manually specified execution signals, or leveraging a helper tool that automates this process for data DAGs -- From 27ce1d5d36e7b0a07c7243c36120d7fa5f6ffb54 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 13:01:50 -0800 Subject: [PATCH 14/41] :bug: actually iterate over child nodes --- pyiron_workflow/composite.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index e3a201db..72fb4359 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -139,12 +139,12 @@ def outputs_map(self, new_map: dict | bidict | None): def activate_strict_hints(self): super().activate_strict_hints() - for node in self.nodes: + for node in self: node.activate_strict_hints() def deactivate_strict_hints(self): super().deactivate_strict_hints() - for node in self.nodes: + for node in self: node.deactivate_strict_hints() @property From fff90cfd18af765e8d752c4077b108715de9594d Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 14:18:00 -0800 Subject: [PATCH 15/41] Pull up the IO rebuild check after node replacement --- pyiron_workflow/composite.py | 52 ++++++++++++++++++++++++++++++++++++ pyiron_workflow/macro.py | 51 ----------------------------------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 72fb4359..6f78733e 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -442,8 +442,60 @@ def replace(self, owned_node: Node | str, replacement: Node | type[Node]) -> Nod self.add(replacement) if is_starting_node: self.starting_nodes.append(replacement) + + # Finally, make sure the IO is constructible with this new node, which will + # catch things like incompatible IO maps + try: + # Make sure node-level IO is pointing to the new node and that macro-level + # IO gets safely reconstructed + self._rebuild_data_io() + except Exception as e: + # If IO can't be successfully rebuilt using this node, revert changes and + # raise the exception + self.replace(replacement, owned_node) # Guaranteed to work since + # replacement in the other direction was already a success + raise e + return owned_node + def _rebuild_data_io(self): + """ + Try to rebuild the IO. + + If an error is encountered, revert back to the existing IO then raise it. + """ + old_inputs = self.inputs + old_outputs = self.outputs + connection_changes = [] # For reversion if there's an error + try: + self._inputs = self._build_inputs() + self._outputs = self._build_outputs() + for old, new in [(old_inputs, self.inputs), (old_outputs, self.outputs)]: + for old_channel in old: + if old_channel.connected: + # If the old channel was connected to stuff, we'd better still + # have a corresponding channel and be able to copy these, or we + # should fail hard. + # But, if it wasn't connected, we don't even care whether or not + # we still have a corresponding channel to copy to + new_channel = new[old_channel.label] + new_channel.copy_connections(old_channel) + swapped_conenctions = old_channel.disconnect_all() # Purge old + connection_changes.append( + (new_channel, old_channel, swapped_conenctions) + ) + except Exception as e: + for new_channel, old_channel, swapped_conenctions in connection_changes: + new_channel.disconnect(*swapped_conenctions) + old_channel.connect(*swapped_conenctions) + self._inputs = old_inputs + self._outputs = old_outputs + e.message = ( + f"Unable to rebuild IO for {self.label}; reverting to old IO." + f"{e.message}" + ) + raise e + @classmethod @wraps(Creator.register) def register(cls, domain: str, package_identifier: str) -> None: diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index f3a9edc3..a55057e5 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -228,44 +228,6 @@ def _update_children(self, children_from_another_process): super()._update_children(children_from_another_process) self._rebuild_data_io() - def _rebuild_data_io(self): - """ - Try to rebuild the IO. - - If an error is encountered, revert back to the existing IO then raise it. - """ - old_inputs = self.inputs - old_outputs = self.outputs - connection_changes = [] # For reversion if there's an error - try: - self._inputs = self._build_inputs() - self._outputs = self._build_outputs() - for old, new in [(old_inputs, self.inputs), (old_outputs, self.outputs)]: - for old_channel in old: - if old_channel.connected: - # If the old channel was connected to stuff, we'd better still - # have a corresponding channel and be able to copy these, or we - # should fail hard. - # But, if it wasn't connected, we don't even care whether or not - # we still have a corresponding channel to copy to - new_channel = new[old_channel.label] - new_channel.copy_connections(old_channel) - swapped_conenctions = old_channel.disconnect_all() # Purge old - connection_changes.append( - (new_channel, old_channel, swapped_conenctions) - ) - except Exception as e: - for new_channel, old_channel, swapped_conenctions in connection_changes: - new_channel.disconnect(*swapped_conenctions) - old_channel.connect(*swapped_conenctions) - self._inputs = old_inputs - self._outputs = old_outputs - e.message = ( - f"Unable to rebuild IO for {self.label}; reverting to old IO." - f"{e.message}" - ) - raise e - def _configure_graph_execution(self): run_signals = self.disconnect_run() @@ -292,19 +254,6 @@ def _reconnect_run(self, run_signal_pairs_to_restore): for pairs in run_signal_pairs_to_restore: pairs[0].connect(pairs[1]) - def replace(self, owned_node: Node | str, replacement: Node | type[Node]): - replaced_node = super().replace(owned_node=owned_node, replacement=replacement) - try: - # Make sure node-level IO is pointing to the new node and that macro-level - # IO gets safely reconstructed - self._rebuild_data_io() - except Exception as e: - # If IO can't be successfully rebuilt using this node, revert changes and - # raise the exception - self.replace(replacement, replaced_node) # Guaranteed to work since - # replacement in the other direction was already a success - raise e - def to_workfow(self): raise NotImplementedError From a02adee49e2a77aa0f426be8521515860ee2faa9 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 14:19:52 -0800 Subject: [PATCH 16/41] Test the promises made in Composite directly Some of the tests are pulled (and often modified) from child classes --- tests/unit/test_composite.py | 581 +++++++++++++++++++++++++++++++++++ tests/unit/test_macro.py | 295 ------------------ tests/unit/test_workflow.py | 159 +--------- 3 files changed, 583 insertions(+), 452 deletions(-) create mode 100644 tests/unit/test_composite.py diff --git a/tests/unit/test_composite.py b/tests/unit/test_composite.py new file mode 100644 index 00000000..64d5677b --- /dev/null +++ b/tests/unit/test_composite.py @@ -0,0 +1,581 @@ +from sys import version_info +import unittest + +from pyiron_workflow._tests import ensure_tests_in_python_path +from pyiron_workflow.channels import NotData +from pyiron_workflow.composite import Composite +from pyiron_workflow.io import Outputs, Inputs +from pyiron_workflow.topology import CircularDataFlowError + + +def plus_one(x: int = 0) -> int: + y = x + 1 + return y + + +class AComposite(Composite): + def __init__(self, label): + super().__init__(label=label) + + def _get_linking_channel(self, child_reference_channel, composite_io_key): + return child_reference_channel # IO by reference + + @property + def inputs(self) -> Inputs: + return self._build_inputs() # Dynamic IO reflecting current children + + @property + def outputs(self) -> Outputs: + return self._build_outputs() # Dynamic IO reflecting current children + + +@unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") +class TestComposite(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + ensure_tests_in_python_path() + super().setUpClass() + + def test_node_decorator_access(self): + @Composite.wrap_as.function_node("y") + def foo(x: int = 0) -> int: + return x + 1 + + from_class = foo() + self.assertEqual(from_class.run(), 1, msg="Node should be fully functioning") + self.assertIsNone( + from_class.parent, + msg="Wrapping from the class should give no parent" + ) + + comp = AComposite("my_composite") + + @comp.wrap_as.function_node("y") + def bar(x: int = 0) -> int: + return x + 2 + + from_instance = bar() + self.assertEqual(from_instance.run(), 2, msg="Node should be fully functioning") + self.assertIsNone( + from_instance.parent, + msg="Wrappers are not creators, wrapping from the instance makes no " + "difference" + ) + + def test_creator_access_and_registration(self): + comp = AComposite("my_composite") + comp.register("demo", "static.demo_nodes") + + # Test invocation + comp.create.demo.OptionallyAdd(label="by_add") + # Test invocation with attribute assignment + comp.by_assignment = comp.create.demo.OptionallyAdd() + node = AComposite.create.demo.OptionallyAdd() + + self.assertSetEqual( + set(comp.nodes.keys()), + set(["by_add", "by_assignment"]), + msg=f"Expected one node label generated automatically from the class and " + f"the other from the attribute assignment, but got {comp.nodes.keys()}" + ) + self.assertIsNone( + node.parent, + msg="Creating from the class directly should not parent the created nodes" + ) + + def test_node_addition(self): + comp = AComposite("my_composite") + + # Validate the four ways to add a node + comp.add(Composite.create.Function(plus_one, label="foo")) + comp.create.Function(plus_one, label="bar") + comp.baz = comp.create.Function(plus_one, label="whatever_baz_gets_used") + Composite.create.Function(plus_one, label="qux", parent=comp) + # node = Composite.create.Function(plus_one, label="quux") + # node.parent = comp + self.assertListEqual( + list(comp.nodes.keys()), + ["foo", "bar", "baz", "qux",], # "quux"], + msg="Expected every above syntax to add a node OK" + ) + comp.boa = comp.qux + self.assertListEqual( + list(comp.nodes.keys()), + ["foo", "bar", "baz", "boa"], # "quux"], + msg="Reassignment should remove the original instance" + ) + + def test_node_access(self): + node = Composite.create.Function(plus_one) + comp = AComposite("my_composite") + comp.child = node + self.assertIs( + comp.child, + node, + msg="Access should be possible by attribute" + ) + self.assertIs( + comp.nodes.child, + node, + msg="Access should be possible by attribute on nodes collection" + ) + self.assertIs( + comp.nodes["child"], + node, + msg="Access should be possible by item on nodes collection" + ) + + for n in comp: + self.assertIs( + node, + n, + msg="Should be able to iterate through (the one and only) nodes" + ) + + def test_node_removal(self): + comp = AComposite("my_composite") + comp.owned = Composite.create.Function(plus_one) + node = Composite.create.Function(plus_one) + comp.foo = node + # Add it to starting nodes manually, otherwise it's only there at run time + comp.starting_nodes = [comp.foo] + # Connect it inside the composite + comp.foo.inputs.x = comp.owned.outputs.y + + disconnected = comp.remove(node) + self.assertIsNone(node.parent, msg="Removal should de-parent") + self.assertFalse(node.connected, msg="Removal should disconnect") + self.assertListEqual( + [(node.inputs.x, comp.owned.outputs.y)], + disconnected, + msg="Removal should return destroyed connections" + ) + self.assertListEqual( + comp.starting_nodes, + [], + msg="Removal should also remove from starting nodes" + ) + + node_owned = comp.owned + disconnections = comp.remove(node_owned.label) + self.assertEqual( + node_owned.parent, + None, + msg="Should be able to remove nodes by label as well as by object" + ) + self.assertListEqual( + [], + disconnections, + msg="node1 should have no connections left" + ) + + def test_label_uniqueness(self): + comp = AComposite("my_composite") + comp.foo = Composite.create.Function(plus_one) + + comp.strict_naming = True + # Validate name preservation for each node addition path + with self.assertRaises(AttributeError, msg="We have 'foo' at home"): + comp.add(comp.create.Function(plus_one, label="foo")) + + with self.assertRaises(AttributeError, msg="We have 'foo' at home"): + comp.create.Function(plus_one, label="foo") + + with self.assertRaises( + AttributeError, + msg="The provided label is ok, but then assigning to baz should give " + "trouble since that name is already occupied" + ): + comp.foo = Composite.create.Function(plus_one, label="whatever") + + with self.assertRaises(AttributeError, msg="We have 'foo' at home"): + Composite.create.Function(plus_one, label="foo", parent=comp) + + # with self.assertRaises(AttributeError, msg="We have 'foo' at home"): + # node = Composite.create.Function(plus_one, label="foo") + # node.parent = comp + + with self.subTest("Make sure trivial re-assignment has no impact"): + original_foo = comp.foo + n_nodes = len(comp.nodes) + comp.foo = original_foo + self.assertIs( + original_foo, + comp.foo, + msg="Reassigning a node to the same name should have no impact", + ) + self.assertEqual( + n_nodes, + len(comp.nodes), + msg="Reassigning a node to the same name should have no impact", + ) + + print("\nKEYS", list(comp.nodes.keys())) + comp.strict_naming = False + comp.add(Composite.create.Function(plus_one, label="foo")) + print("\nKEYS", list(comp.nodes.keys())) + self.assertEqual( + 2, + len(comp), + msg="Without strict naming, we should be able to add to an existing name" + ) + self.assertListEqual( + ["foo", "foo0"], + list(comp.nodes.keys()), + msg="When adding a node with an existing name and relaxed naming, the new " + "node should get an index on its label so each label is still unique" + ) + + def test_singular_ownership(self): + comp1 = AComposite("one") + comp1.create.Function(plus_one, label="node1") + node2 = AComposite.create.Function( + plus_one, label="node2", parent=comp1, x=comp1.node1.outputs.y + ) + self.assertTrue(node2.connected, msg="Sanity check that node connection works") + + comp2 = AComposite("two") + with self.assertRaises(ValueError, msg="Can't belong to two parents"): + comp2.add(node2) + comp1.remove(node2) + comp2.add(node2) + self.assertEqual( + node2.parent, + comp2, + msg="Freed nodes should be able to join other parents" + ) + + def test_replace(self): + n1 = Composite.create.SingleValue(plus_one) + n2 = Composite.create.SingleValue(plus_one) + n3 = Composite.create.SingleValue(plus_one) + + @Composite.wrap_as.function_node(("y", "minus")) + def x_plus_minus_z(x: int = 0, z=2) -> tuple[int, int]: + """ + A commensurate but different node: has _more_ than the necessary channels, + but old channels are all there with the same hints + """ + return x + z, x - z + + replacement = x_plus_minus_z() + + @Composite.wrap_as.single_value_node("y") + def different_input_channel(z: int = 0) -> int: + return z + 10 + + @Composite.wrap_as.single_value_node("z") + def different_output_channel(x: int = 0) -> int: + return x + 100 + + comp = AComposite("my_composite") + comp.n1 = n1 + comp.n2 = n2 + comp.n3 = n3 + comp.n2.inputs.x = comp.n1 + comp.n3.inputs.x = comp.n2 + comp.inputs_map = {"n1__x": "x"} + comp.outputs_map = {"n3__y": "y"} + comp.set_run_signals_to_dag_execution() + + with self.subTest("Verify success cases"): + self.assertEqual(3, comp.run().y, msg="Sanity check") + + comp.replace(n1, replacement) + out = comp.run(x=0) + self.assertEqual( + (0+2) + 1 + 1, out.y, msg="Should be able to replace by instance" + ) + self.assertEqual( + 0 - 2, out.n1__minus, msg="Replacement output should also appear" + ) + comp.replace(replacement, n1) + self.assertFalse( + replacement.connected, msg="Replaced nodes should be disconnected" + ) + self.assertIsNone( + replacement.parent, msg="Replaced nodes should be orphaned" + ) + + comp.replace("n2", replacement) + out = comp.run(x=0) + self.assertEqual( + (0 + 1) + 2 + 1, out.y, msg="Should be able to replace by label" + ) + self.assertEqual(1 - 2, out.n2__minus) + comp.replace(replacement, n2) + + comp.replace(n3, x_plus_minus_z) + out = comp.run(x=0) + self.assertEqual( + (0 + 1) + 2 + 1, out.y, msg="Should be able to replace with a class" + ) + self.assertEqual(2 - 2, out.n3__minus) + self.assertIsNot( + comp.n3, + replacement, + msg="Sanity check -- when replacing with class, a _new_ instance " + "should be created" + ) + comp.replace(comp.n3, n3) + + comp.n1 = x_plus_minus_z + self.assertEqual( + (0+2) + 1 + 1, + comp.run(x=0).y, + msg="Assigning a new _class_ to an existing node should be a shortcut " + "for replacement" + ) + comp.replace(comp.n1, n1) # Return to original state + + comp.n1 = different_input_channel + self.assertEqual( + (0 + 10) + 1 + 1, + comp.run(n1__z=0).y, + msg="Different IO should be compatible as long as what's missing is " + "not connected" + ) + comp.replace(comp.n1, n1) + + comp.n3 = different_output_channel + self.assertEqual( + (0 + 1) + 1 + 100, + comp.run(x=0).n3__z, + msg="Different IO should be compatible as long as what's missing is " + "not connected" + ) + comp.replace(comp.n3, n3) + + with self.subTest("Verify failure cases"): + self.assertEqual(3, comp.run().y, msg="Sanity check") + + another_comp = AComposite("another") + another_node = x_plus_minus_z(parent=another_comp) + + with self.assertRaises( + ValueError, + msg="Should fail when replacement has a parent" + ): + comp.replace(comp.n1, another_node) + + another_comp.remove(another_node) + another_node.inputs.x = replacement.outputs.y + with self.assertRaises( + ValueError, + msg="Should fail when replacement is connected" + ): + comp.replace(comp.n1, another_node) + + another_node.disconnect() + with self.assertRaises( + ValueError, + msg="Should fail if the node being replaced isn't a child" + ): + comp.replace(replacement, another_node) + + @Composite.wrap_as.single_value_node("y") + def wrong_hint(x: float = 0) -> float: + return x + 1.1 + + with self.assertRaises( + TypeError, + msg="Should not be able to replace with the wrong type hints" + ): + comp.n1 = wrong_hint + + with self.assertRaises( + AttributeError, + msg="Should not be able to replace with any missing connected channels" + ): + comp.n2 = different_input_channel + + with self.assertRaises( + AttributeError, + msg="Should not be able to replace with any missing connected channels" + ): + comp.n2 = different_output_channel + + self.assertEqual( + 3, + comp.run().y, + msg="Failed replacements should always restore the original state " + "cleanly" + ) + + def test_working_directory(self): + comp = AComposite("my_composite") + comp.plus_one = Composite.create.Function(plus_one) + self.assertTrue( + str(comp.plus_one.working_directory.path).endswith(comp.plus_one.label), + msg="Child nodes should have their own working directories nested inside" + ) + comp.working_directory.delete() # Clean up + + def test_length(self): + comp = AComposite("my_composite") + comp.child = Composite.create.Function(plus_one) + l1 = len(comp) + comp.child2 = Composite.create.Function(plus_one) + self.assertEqual( + l1 + 1, + len(comp), + msg="Expected length to count the number of children" + ) + + def test_run(self): + comp = AComposite("my_composite") + comp.create.SingleValue(plus_one, label="n1", x=0) + comp.create.SingleValue(plus_one, label="n2", x=comp.n1) + comp.create.SingleValue(plus_one, label="n3", x=42) + comp.n1 > comp.n2 + comp.starting_nodes = [comp.n1] + + comp.run() + self.assertEqual( + 2, + comp.n2.outputs.y.value, + msg="Expected to start from starting node and propagate" + ) + self.assertIs( + NotData, + comp.n3.outputs.y.value, + msg="n3 was omitted from the execution diagram, it should not have run" + ) + + def test_set_run_signals_to_dag(self): + # Like the run test, but manually invoking this first + + comp = AComposite("my_composite") + comp.create.SingleValue(plus_one, label="n1", x=0) + comp.create.SingleValue(plus_one, label="n2", x=comp.n1) + comp.create.SingleValue(plus_one, label="n3", x=42) + comp.set_run_signals_to_dag_execution() + comp.run() + self.assertEqual( + 1, + comp.n1.outputs.y.value, + msg="Expected all nodes to run" + ) + self.assertEqual( + 2, + comp.n2.outputs.y.value, + msg="Expected all nodes to run" + ) + self.assertEqual( + 43, + comp.n3.outputs.y.value, + msg="Expected all nodes to run" + ) + + comp.n1.inputs.x = comp.n2 + with self.assertRaises( + CircularDataFlowError, + msg="Should not be able to automate graphs with circular data" + ): + comp.set_run_signals_to_dag_execution() + + def test_return(self): + comp = AComposite("my_composite") + comp.n1 = Composite.create.SingleValue(plus_one, x=0) + not_dottable_string = "can't dot this" + not_dottable_name_node = comp.create.SingleValue( + plus_one, x=42, label=not_dottable_string + ) + comp.starting_nodes = [comp.n1, not_dottable_name_node] + out = comp.run() + self.assertEqual( + 1, + comp.outputs.n1__y.value, + msg="Sanity check that the output has been filled and is stored under the " + "name we think it is" + ) + # Make sure the returned object is functionally a dot-dict + self.assertEqual(1, out["n1__y"], msg="Should work with item-access") + self.assertEqual(1, out.n1__y, msg="Should work with dot-access") + # We can give nodes crazy names, but then we're stuck with item access + self.assertIs( + not_dottable_name_node, + comp.nodes[not_dottable_string], + msg="Should be able to access the node by item" + ) + self.assertEqual( + 43, + out[not_dottable_string + "__y"], + msg="Should always be able to fall back to item access with crazy labels" + ) + + def test_io_maps(self): + # input and output, renaming, accessing connected, and deactivating disconnected + comp = AComposite("my_composite") + comp.n1 = Composite.create.SingleValue(plus_one, x=0) + comp.n2 = Composite.create.SingleValue(plus_one, x=comp.n1) + comp.n3 = Composite.create.SingleValue(plus_one, x=comp.n2) + comp.m = Composite.create.SingleValue(plus_one, x=42) + comp.inputs_map = { + "n1__x": "x", # Rename + "n2__x": "intermediate_x", # Expose + "m__x": None, # Hide + } + comp.outputs_map = { + "n3__y": "y", # Rename + "n2__y": "intermediate_y", # Expose, + "m__y": None, # Hide + } + self.assertIn("x", comp.inputs.labels, msg="Should be renamed") + self.assertIn("y", comp.outputs.labels, msg="Should be renamed") + self.assertIn("intermediate_x", comp.inputs.labels, msg="Should be exposed") + self.assertIn("intermediate_y", comp.outputs.labels, msg="Should be exposed") + self.assertNotIn("m__x", comp.inputs.labels, msg="Should be hidden") + self.assertNotIn("m__y", comp.outputs.labels, msg="Should be hidden") + self.assertNotIn("m__y", comp.outputs.labels, msg="Should be hidden") + + comp.set_run_signals_to_dag_execution() + out = comp.run() + self.assertEqual( + 3, + out.y, + msg="New names should be propagated to the returned value" + ) + self.assertNotIn( + "m__y", + list(out.keys()), + msg="IO filtering should be evident in returned value" + ) + self.assertEqual( + 43, + comp.m.outputs.y.value, + msg="The child channel should still exist and have run" + ) + self.assertEqual( + 1, + comp.inputs.intermediate_x.value, + msg="IO should be up-to-date post-run" + ) + self.assertEqual( + 2, + comp.outputs.intermediate_y.value, + msg="IO should be up-to-date post-run" + ) + + def test_de_activate_strict_connections(self): + comp = AComposite("my_composite") + comp.sub_comp = AComposite("sub") + comp.sub_comp.n1 = Composite.create.SingleValue(plus_one, x=0) + self.assertTrue( + comp.sub_comp.n1.inputs.x.strict_hints, + msg="Sanity check that test starts in the expected condition" + ) + comp.deactivate_strict_hints() + self.assertFalse( + comp.sub_comp.n1.inputs.x.strict_hints, + msg="Deactivating should propagate to children" + ) + comp.activate_strict_hints() + self.assertTrue( + comp.sub_comp.n1.inputs.x.strict_hints, + msg="Activating should propagate to children" + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index 0ee9d78b..085f8236 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -81,58 +81,6 @@ def build_graph(self): msg="Subclasses should be able to simply override the graph_creator arg" ) - def test_key_map(self): - m = Macro( - add_three_macro, - inputs_map={"one__x": "my_input"}, - outputs_map={ - "three__result": "my_output", - "two__result": "intermediate" - }, - ) - self.assertSetEqual( - set(m.inputs.labels), - set(("my_input",)), - msg="Input should be relabelled, but not added to or taken away from" - ) - self.assertSetEqual( - set(m.outputs.labels), - set(("my_output", "intermediate")), - msg="Output should be relabelled and expanded" - ) - - with self.subTest("Make new names can be used as usual"): - x = 0 - out = m(my_input=x) - self.assertEqual( - out.my_output, - add_one(add_one(add_one(x))), - msg="Expected output but relabeled should be accessible" - ) - self.assertEqual( - out.intermediate, - add_one(add_one(x)), - msg="New, internally connected output that was specifically requested " - "should be accessible" - ) - - with self.subTest("IO can be disabled"): - m = Macro( - add_three_macro, - inputs_map={"one__x": None}, - outputs_map={"three__result": None}, - ) - self.assertEqual( - len(m.inputs.labels), - 0, - msg="Only inputs should have been disabled" - ) - self.assertEqual( - len(m.outputs.labels), - 0, - msg="Only outputs should have been disabled" - ) - def test_nesting(self): def nested_macro(macro): macro.a = SingleValue(add_one) @@ -240,249 +188,6 @@ def only_starting(macro): with self.assertRaises(ValueError): Macro(only_starting) - def test_replace_node(self): - macro = Macro(add_three_macro) - - adds_three_node = Macro( - add_three_macro, - inputs_map={"one__x": "x"}, - outputs_map={"three__result": "result"} - ) - adds_one_node = macro.two - - self.assertEqual( - macro(one__x=0).three__result, - 3, - msg="Sanity check" - ) - - with self.subTest("Verify successful cases"): - - macro.replace(adds_one_node, adds_three_node) - self.assertEqual( - macro(one__x=0).three__result, - 5, - msg="Result should be bigger after replacing an add_one node with an " - "add_three macro" - ) - self.assertFalse( - adds_one_node.connected, - msg="Replaced node should get disconnected" - ) - self.assertIsNone( - adds_one_node.parent, - msg="Replaced node should get orphaned" - ) - - add_one_class = macro.wrap_as.single_value_node()(add_one) - self.assertTrue(issubclass(add_one_class, SingleValue), msg="Sanity check") - macro.replace(adds_three_node, add_one_class) - self.assertEqual( - macro(one__x=0).three__result, - 3, - msg="Should be possible to replace with a class instead of an instance" - ) - - macro.replace("two", adds_three_node) - self.assertEqual( - macro(one__x=0).three__result, - 5, - msg="Should be possible to replace by label" - ) - - macro.two.replace_with(adds_one_node) - self.assertEqual( - macro(one__x=0).three__result, - 3, - msg="Nodes should have syntactic sugar for invoking replacement" - ) - - @Macro.wrap_as.function_node() - def add_two(x): - result = x + 2 - return result - macro.two = add_two - self.assertEqual( - macro(one__x=0).three__result, - 4, - msg="Composite should allow replacement when a class is assigned" - ) - - self.assertListEqual( - macro.starting_nodes, - [macro.one], - msg="Sanity check" - ) - new_starter = add_two() - macro.one.replace_with(new_starter) - self.assertListEqual( - macro.starting_nodes, - [new_starter], - msg="Replacement should be reflected in the starting nodes" - ) - self.assertIs( - macro.inputs.one__x.value_receiver, - new_starter.inputs.x, - msg="Replacement should be reflected in composite IO" - ) - - with self.subTest("Verify failure cases"): - another_macro = Macro(add_three_macro) - another_node = Macro( - add_three_macro, - inputs_map={"one__x": "x"}, - outputs_map={"three__result": "result"}, - ) - another_macro.now_its_a_child = another_node - - with self.assertRaises( - ValueError, - msg="Should fail when replacement has a parent" - ): - macro.replace(macro.two, another_node) - - another_macro.remove(another_node) - another_node.inputs.x = another_macro.outputs.three__result - with self.assertRaises( - ValueError, - msg="Should fail when replacement is connected" - ): - macro.replace(macro.two, another_node) - - another_node.disconnect() - an_ok_replacement = another_macro.two - another_macro.remove(an_ok_replacement) - with self.assertRaises( - ValueError, - msg="Should fail if the node being replaced isn't a child" - ): - macro.replace(another_node, an_ok_replacement) - - @Macro.wrap_as.function_node() - def add_two_incompatible_io(not_x): - result_is_not_my_name = not_x + 2 - return result_is_not_my_name - - with self.assertRaises( - AttributeError, - msg="Replacing via class assignment should fail if the class has " - "incompatible IO" - ): - macro.two = add_two_incompatible_io - - def test_macro_connections_after_replace(self): - # If the macro-level IO is going to change after replacing a child, - # it had better still be able to recreate all the macro-level IO connections - # For macro IO channels that weren't connected, we don't really care - # If it fails to replace, it had better revert to its original state - - macro = Macro(add_three_macro, one__x=0) - downstream = SingleValue(add_one, x=macro.outputs.three__result) - downstream.pull() - self.assertEqual( - 0 + (1 + 1 + 1) + 1, - downstream.outputs.result.value, - msg="Sanity check that our test setup is what we want: macro->single" - ) - - def add_two(x): - result = x + 2 - return result - compatible_replacement = SingleValue(add_two) - - macro.replace(macro.three, compatible_replacement) - downstream.pull() - self.assertEqual( - len(downstream.inputs.x.connections), - 1, - msg="After replacement, the downstream node should still have exactly one " - "connection to the macro" - ) - self.assertIs( - downstream.inputs.x.connections[0], - macro.outputs.three__result, - msg="The one connection should be the living, updated macro IO channel" - ) - self.assertEqual( - 0 + (1 + 1 + 2) + 1, - downstream.outputs.result.value, - msg="The whole flow should still function after replacement, but with the " - "new behaviour (and extra 1 added)" - ) - - def different_signature(x): - # When replacing the final node of add_three_macro, the rebuilt IO will - # no longer have three__result, but rather three__changed_output_label, - # which will break existing macro-level IO if the macro output is connected - changed_output_label = x + 3 - return changed_output_label - - incompatible_replacement = SingleValue( - different_signature, - label="original_label" - ) - with self.assertRaises( - AttributeError, - msg="macro.three__result is connected output, but can't be found in the " - "rebuilt IO, so an exception is expected" - ): - macro.replace(macro.three, incompatible_replacement) - self.assertIs( - macro.three, - compatible_replacement, - msg="Failed replacements should get reverted, putting the original node " - "back" - ) - self.assertIs( - macro.three.outputs.result.value_receiver, - macro.outputs.three__result, - msg="Failed replacements should get reverted, restoring the link between " - "child IO and macro IO" - ) - self.assertIs( - downstream.inputs.x.connections[0], - macro.outputs.three__result, - msg="Failed replacements should get reverted, and macro IO should be as " - "it was before" - ) - self.assertFalse( - incompatible_replacement.connected, - msg="Failed replacements should get reverted, leaving the replacement in " - "its original state" - ) - self.assertEqual( - "original_label", - incompatible_replacement.label, - msg="Failed replacements should get reverted, leaving the replacement in " - "its original state" - ) - macro > downstream - # If we want to push, we need to define a connection formally - macro.run(one__x=1) - # Fresh input to make sure updates are actually going through - self.assertEqual( - 1 + (1 + 1 + 2) + 1, - downstream.outputs.result.value, - msg="Final integration test that replacements get reverted, the macro " - "function and downstream results should be the same as before" - ) - - downstream.disconnect() - macro.replace(macro.three, incompatible_replacement) - self.assertIs( - macro.three, - incompatible_replacement, - msg="Since it is only incompatible with the external connections and we " - "broke those first, replacement is expected to work fine now" - ) - macro(one__x=2) - self.assertEqual( - 2 + (1 + 1 + 3), - macro.outputs.three__changed_output_label.value, - msg="For all to be working, we need the result with the new behaviour " - "at its new location" - ) - def test_with_executor(self): macro = Macro(add_three_macro) downstream = SingleValue(add_one, x=macro.outputs.three__result) diff --git a/tests/unit/test_workflow.py b/tests/unit/test_workflow.py index c54aa2b7..90512425 100644 --- a/tests/unit/test_workflow.py +++ b/tests/unit/test_workflow.py @@ -7,7 +7,6 @@ from pyiron_workflow._tests import ensure_tests_in_python_path from pyiron_workflow.channels import NotData -from pyiron_workflow.files import DirectoryObject from pyiron_workflow.util import DotDict from pyiron_workflow.workflow import Workflow @@ -24,139 +23,6 @@ def setUpClass(cls) -> None: ensure_tests_in_python_path() super().setUpClass() - def test_node_addition(self): - wf = Workflow("my_workflow") - - # Validate the four ways to add a node - wf.add(Workflow.create.Function(plus_one, label="foo")) - wf.create.Function(plus_one, label="bar") - wf.baz = wf.create.Function(plus_one, label="whatever_baz_gets_used") - Workflow.create.Function(plus_one, label="qux", parent=wf) - self.assertListEqual(list(wf.nodes.keys()), ["foo", "bar", "baz", "qux"]) - wf.boa = wf.qux - self.assertListEqual( - list(wf.nodes.keys()), - ["foo", "bar", "baz", "boa"], - msg="Reassignment should remove the original instance" - ) - - wf.strict_naming = False - # Validate name incrementation - wf.add(Workflow.create.Function(plus_one, label="foo")) - wf.create.Function(plus_one, label="bar") - wf.baz = wf.create.Function( - plus_one, - label="without_strict_you_can_override_by_assignment" - ) - Workflow.create.Function(plus_one, label="boa", parent=wf) - self.assertListEqual( - list(wf.nodes.keys()), - [ - "foo", "bar", "baz", "boa", - "foo0", "bar0", "baz0", "boa0", - ] - ) - - with self.subTest("Make sure trivial re-assignment has no impact"): - original_foo = wf.foo - n_nodes = len(wf.nodes) - wf.foo = original_foo - self.assertIs( - original_foo, - wf.foo, - msg="Reassigning a node to the same name should have no impact", - ) - self.assertEqual( - n_nodes, - len(wf.nodes), - msg="Reassigning a node to the same name should have no impact", - ) - - with self.subTest("Make sure strict naming causes a bunch of attribute errors"): - wf.strict_naming = True - # Validate name preservation - with self.assertRaises(AttributeError): - wf.add(wf.create.Function(plus_one, label="foo")) - - with self.assertRaises(AttributeError): - wf.create.Function(plus_one, label="bar") - - with self.assertRaises(AttributeError): - wf.baz = wf.create.Function(plus_one, label="whatever_baz_gets_used") - - with self.assertRaises(AttributeError): - Workflow.create.Function(plus_one, label="boa", parent=wf) - - def test_node_removal(self): - wf = Workflow("my_workflow") - wf.owned = Workflow.create.Function(plus_one) - node = Workflow.create.Function(plus_one) - wf.foo = node - # Add it to starting nodes manually, otherwise it's only there at run time - wf.starting_nodes = [wf.foo] - # Connect it inside the workflow - wf.foo.inputs.x = wf.owned.outputs.y - - wf.remove(node) - self.assertIsNone(node.parent, msg="Removal should de-parent") - self.assertFalse(node.connected, msg="Removal should disconnect") - self.assertListEqual( - wf.starting_nodes, - [], - msg="Removal should also remove from starting nodes" - ) - - def test_node_packages(self): - wf = Workflow("my_workflow") - wf.register("demo", "static.demo_nodes") - - # Test invocation - wf.create.demo.OptionallyAdd(label="by_add") - # Test invocation with attribute assignment - wf.by_assignment = wf.create.demo.OptionallyAdd() - - self.assertSetEqual( - set(wf.nodes.keys()), - set(["by_add", "by_assignment"]), - msg=f"Expected one node label generated automatically from the class and " - f"the other from the attribute assignment, but got {wf.nodes.keys()}" - ) - - def test_double_workfloage_and_node_removal(self): - wf1 = Workflow("one") - wf1.create.Function(plus_one, label="node1") - node2 = Workflow.create.Function( - plus_one, label="node2", parent=wf1, x=wf1.node1.outputs.y - ) - self.assertTrue(node2.connected) - - wf2 = Workflow("two") - with self.assertRaises(ValueError): - # Can't belong to two workflows at once - wf2.add(node2) - disconnections = wf1.remove(node2) - self.assertFalse(node2.connected, msg="Removal should first disconnect") - self.assertListEqual( - disconnections, - [(node2.inputs.x, wf1.node1.outputs.y)], - msg="Disconnections should be returned by removal" - ) - wf2.add(node2) - self.assertEqual(node2.parent, wf2) - - node1 = wf1.node1 - disconnections = wf1.remove(node1.label) - self.assertEqual( - node1.parent, - None, - msg="Should be able to remove nodes by label as well as by object" - ) - self.assertListEqual( - [], - disconnections, - msg="node1 should have no connections left" - ) - def test_workflow_io(self): wf = Workflow("wf") wf.create.Function(plus_one, label="n1") @@ -184,24 +50,6 @@ def test_workflow_io(self): self.assertEqual(out.out, 3) self.assertEqual(out.intermediate, 2) - def test_node_decorator_access(self): - @Workflow.wrap_as.function_node("y") - def plus_one(x: int = 0) -> int: - return x + 1 - - self.assertEqual(plus_one().run(), 1) - - def test_working_directory(self): - wf = Workflow("wf") - self.assertTrue(wf._working_directory is None) - self.assertIsInstance(wf.working_directory, DirectoryObject) - self.assertTrue(str(wf.working_directory.path).endswith(wf.label)) - wf.create.Function(plus_one) - self.assertTrue( - str(wf.plus_one.working_directory.path).endswith(wf.plus_one.label) - ) - wf.working_directory.delete() - def test_no_parents(self): wf = Workflow("wf") wf2 = Workflow("wf2") @@ -357,13 +205,10 @@ def test_return_value(self): self.assertEqual( return_on_explicit_run["b__y"], 2 + 2, - msg="On explicit run, the most recent input data should be used and the " - "result should be returned" + msg="On explicit run, the most recent input data should be used and " + "the result should be returned" ) - # Note: We don't need to test running on an executor, because Workflows can't - # do that yet - def test_execution_automation(self): @Workflow.wrap_as.single_value_node("out") def foo(x, y): From bcce14d16b069e734526a2e90d676cf67439706e Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 14:40:43 -0800 Subject: [PATCH 17/41] Make Macro's graph_creator available as a class method For symmetry with Function --- pyiron_workflow/macro.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index a55057e5..163b6b72 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -169,15 +169,32 @@ def __init__( outputs_map: Optional[dict | bidict] = None, **kwargs, ): + if not callable(graph_creator): + # Children of `Function` may explicitly provide a `node_function` static + # method so the node has fixed behaviour. + # In this case, the `__init__` signature should be changed so that the + # `node_function` argument is just always `None` or some other non-callable. + # If a callable `node_function` is not received, you'd better have it as an + # attribute already! + if not hasattr(self, "graph_creator"): + raise AttributeError( + f"If `None` is provided as a `graph_creator`, a `graph_creator` " + f"property must be defined instead, e.g. when making child classes" + f"of `Macro` with specific behaviour" + ) + else: + # If a callable graph creator is received, use it + self.graph_creator = graph_creator + self._parent = None super().__init__( - label=label if label is not None else graph_creator.__name__, + label=label if label is not None else self.graph_creator.__name__, parent=parent, strict_naming=strict_naming, inputs_map=inputs_map, outputs_map=outputs_map, ) - graph_creator(self) + self.graph_creator(self) self._configure_graph_execution() self._inputs: Inputs = self._build_inputs() @@ -277,9 +294,10 @@ def as_node(graph_creator: callable[[Macro], None]): { "__init__": partialmethod( Macro.__init__, - graph_creator, - **node_class_kwargs, - ) + None, + **node_class_kwargs + ), + "graph_creator": staticmethod(graph_creator), }, ) From 7d9238eb954915d2e4a044babb816691fd984003 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 14:50:30 -0800 Subject: [PATCH 18/41] Use setUp --- tests/unit/test_composite.py | 296 +++++++++++++++++------------------ 1 file changed, 142 insertions(+), 154 deletions(-) diff --git a/tests/unit/test_composite.py b/tests/unit/test_composite.py index 64d5677b..d6281e80 100644 --- a/tests/unit/test_composite.py +++ b/tests/unit/test_composite.py @@ -36,6 +36,10 @@ def setUpClass(cls) -> None: ensure_tests_in_python_path() super().setUpClass() + def setUp(self) -> None: + self.comp = AComposite("my_composite") + super().setUp() + def test_node_decorator_access(self): @Composite.wrap_as.function_node("y") def foo(x: int = 0) -> int: @@ -48,8 +52,7 @@ def foo(x: int = 0) -> int: msg="Wrapping from the class should give no parent" ) - comp = AComposite("my_composite") - + comp = self.comp @comp.wrap_as.function_node("y") def bar(x: int = 0) -> int: return x + 2 @@ -63,20 +66,19 @@ def bar(x: int = 0) -> int: ) def test_creator_access_and_registration(self): - comp = AComposite("my_composite") - comp.register("demo", "static.demo_nodes") + self.comp.register("demo", "static.demo_nodes") # Test invocation - comp.create.demo.OptionallyAdd(label="by_add") + self.comp.create.demo.OptionallyAdd(label="by_add") # Test invocation with attribute assignment - comp.by_assignment = comp.create.demo.OptionallyAdd() + self.comp.by_assignment = self.comp.create.demo.OptionallyAdd() node = AComposite.create.demo.OptionallyAdd() self.assertSetEqual( - set(comp.nodes.keys()), + set(self.comp.nodes.keys()), set(["by_add", "by_assignment"]), msg=f"Expected one node label generated automatically from the class and " - f"the other from the attribute assignment, but got {comp.nodes.keys()}" + f"the other from the attribute assignment, but got {self.comp.nodes.keys()}" ) self.assertIsNone( node.parent, @@ -84,48 +86,45 @@ def test_creator_access_and_registration(self): ) def test_node_addition(self): - comp = AComposite("my_composite") - # Validate the four ways to add a node - comp.add(Composite.create.Function(plus_one, label="foo")) - comp.create.Function(plus_one, label="bar") - comp.baz = comp.create.Function(plus_one, label="whatever_baz_gets_used") - Composite.create.Function(plus_one, label="qux", parent=comp) + self.comp.add(Composite.create.Function(plus_one, label="foo")) + self.comp.create.Function(plus_one, label="bar") + self.comp.baz = self.comp.create.Function(plus_one, label="whatever_baz_gets_used") + Composite.create.Function(plus_one, label="qux", parent=self.comp) # node = Composite.create.Function(plus_one, label="quux") # node.parent = comp self.assertListEqual( - list(comp.nodes.keys()), + list(self.comp.nodes.keys()), ["foo", "bar", "baz", "qux",], # "quux"], msg="Expected every above syntax to add a node OK" ) - comp.boa = comp.qux + self.comp.boa = self.comp.qux self.assertListEqual( - list(comp.nodes.keys()), + list(self.comp.nodes.keys()), ["foo", "bar", "baz", "boa"], # "quux"], msg="Reassignment should remove the original instance" ) def test_node_access(self): node = Composite.create.Function(plus_one) - comp = AComposite("my_composite") - comp.child = node + self.comp.child = node self.assertIs( - comp.child, + self.comp.child, node, msg="Access should be possible by attribute" ) self.assertIs( - comp.nodes.child, + self.comp.nodes.child, node, msg="Access should be possible by attribute on nodes collection" ) self.assertIs( - comp.nodes["child"], + self.comp.nodes["child"], node, msg="Access should be possible by item on nodes collection" ) - for n in comp: + for n in self.comp: self.assertIs( node, n, @@ -133,31 +132,30 @@ def test_node_access(self): ) def test_node_removal(self): - comp = AComposite("my_composite") - comp.owned = Composite.create.Function(plus_one) + self.comp.owned = Composite.create.Function(plus_one) node = Composite.create.Function(plus_one) - comp.foo = node + self.comp.foo = node # Add it to starting nodes manually, otherwise it's only there at run time - comp.starting_nodes = [comp.foo] + self.comp.starting_nodes = [self.comp.foo] # Connect it inside the composite - comp.foo.inputs.x = comp.owned.outputs.y + self.comp.foo.inputs.x = self.comp.owned.outputs.y - disconnected = comp.remove(node) + disconnected = self.comp.remove(node) self.assertIsNone(node.parent, msg="Removal should de-parent") self.assertFalse(node.connected, msg="Removal should disconnect") self.assertListEqual( - [(node.inputs.x, comp.owned.outputs.y)], + [(node.inputs.x, self.comp.owned.outputs.y)], disconnected, msg="Removal should return destroyed connections" ) self.assertListEqual( - comp.starting_nodes, + self.comp.starting_nodes, [], msg="Removal should also remove from starting nodes" ) - node_owned = comp.owned - disconnections = comp.remove(node_owned.label) + node_owned = self.comp.owned + disconnections = self.comp.remove(node_owned.label) self.assertEqual( node_owned.parent, None, @@ -170,58 +168,57 @@ def test_node_removal(self): ) def test_label_uniqueness(self): - comp = AComposite("my_composite") - comp.foo = Composite.create.Function(plus_one) + self.comp.foo = Composite.create.Function(plus_one) - comp.strict_naming = True + self.comp.strict_naming = True # Validate name preservation for each node addition path with self.assertRaises(AttributeError, msg="We have 'foo' at home"): - comp.add(comp.create.Function(plus_one, label="foo")) + self.comp.add(self.comp.create.Function(plus_one, label="foo")) with self.assertRaises(AttributeError, msg="We have 'foo' at home"): - comp.create.Function(plus_one, label="foo") + self.comp.create.Function(plus_one, label="foo") with self.assertRaises( AttributeError, msg="The provided label is ok, but then assigning to baz should give " "trouble since that name is already occupied" ): - comp.foo = Composite.create.Function(plus_one, label="whatever") + self.comp.foo = Composite.create.Function(plus_one, label="whatever") with self.assertRaises(AttributeError, msg="We have 'foo' at home"): - Composite.create.Function(plus_one, label="foo", parent=comp) + Composite.create.Function(plus_one, label="foo", parent=self.comp) # with self.assertRaises(AttributeError, msg="We have 'foo' at home"): # node = Composite.create.Function(plus_one, label="foo") # node.parent = comp with self.subTest("Make sure trivial re-assignment has no impact"): - original_foo = comp.foo - n_nodes = len(comp.nodes) - comp.foo = original_foo + original_foo = self.comp.foo + n_nodes = len(self.comp.nodes) + self.comp.foo = original_foo self.assertIs( original_foo, - comp.foo, + self.comp.foo, msg="Reassigning a node to the same name should have no impact", ) self.assertEqual( n_nodes, - len(comp.nodes), + len(self.comp.nodes), msg="Reassigning a node to the same name should have no impact", ) - print("\nKEYS", list(comp.nodes.keys())) - comp.strict_naming = False - comp.add(Composite.create.Function(plus_one, label="foo")) - print("\nKEYS", list(comp.nodes.keys())) + print("\nKEYS", list(self.comp.nodes.keys())) + self.comp.strict_naming = False + self.comp.add(Composite.create.Function(plus_one, label="foo")) + print("\nKEYS", list(self.comp.nodes.keys())) self.assertEqual( 2, - len(comp), + len(self.comp), msg="Without strict naming, we should be able to add to an existing name" ) self.assertListEqual( ["foo", "foo0"], - list(comp.nodes.keys()), + list(self.comp.nodes.keys()), msg="When adding a node with an existing name and relaxed naming, the new " "node should get an index on its label so each label is still unique" ) @@ -268,28 +265,27 @@ def different_input_channel(z: int = 0) -> int: def different_output_channel(x: int = 0) -> int: return x + 100 - comp = AComposite("my_composite") - comp.n1 = n1 - comp.n2 = n2 - comp.n3 = n3 - comp.n2.inputs.x = comp.n1 - comp.n3.inputs.x = comp.n2 - comp.inputs_map = {"n1__x": "x"} - comp.outputs_map = {"n3__y": "y"} - comp.set_run_signals_to_dag_execution() + self.comp.n1 = n1 + self.comp.n2 = n2 + self.comp.n3 = n3 + self.comp.n2.inputs.x = self.comp.n1 + self.comp.n3.inputs.x = self.comp.n2 + self.comp.inputs_map = {"n1__x": "x"} + self.comp.outputs_map = {"n3__y": "y"} + self.comp.set_run_signals_to_dag_execution() with self.subTest("Verify success cases"): - self.assertEqual(3, comp.run().y, msg="Sanity check") + self.assertEqual(3, self.comp.run().y, msg="Sanity check") - comp.replace(n1, replacement) - out = comp.run(x=0) + self.comp.replace(n1, replacement) + out = self.comp.run(x=0) self.assertEqual( (0+2) + 1 + 1, out.y, msg="Should be able to replace by instance" ) self.assertEqual( 0 - 2, out.n1__minus, msg="Replacement output should also appear" ) - comp.replace(replacement, n1) + self.comp.replace(replacement, n1) self.assertFalse( replacement.connected, msg="Replaced nodes should be disconnected" ) @@ -297,57 +293,57 @@ def different_output_channel(x: int = 0) -> int: replacement.parent, msg="Replaced nodes should be orphaned" ) - comp.replace("n2", replacement) - out = comp.run(x=0) + self.comp.replace("n2", replacement) + out = self.comp.run(x=0) self.assertEqual( (0 + 1) + 2 + 1, out.y, msg="Should be able to replace by label" ) self.assertEqual(1 - 2, out.n2__minus) - comp.replace(replacement, n2) + self.comp.replace(replacement, n2) - comp.replace(n3, x_plus_minus_z) - out = comp.run(x=0) + self.comp.replace(n3, x_plus_minus_z) + out = self.comp.run(x=0) self.assertEqual( (0 + 1) + 2 + 1, out.y, msg="Should be able to replace with a class" ) self.assertEqual(2 - 2, out.n3__minus) self.assertIsNot( - comp.n3, + self.comp.n3, replacement, msg="Sanity check -- when replacing with class, a _new_ instance " "should be created" ) - comp.replace(comp.n3, n3) + self.comp.replace(self.comp.n3, n3) - comp.n1 = x_plus_minus_z + self.comp.n1 = x_plus_minus_z self.assertEqual( (0+2) + 1 + 1, - comp.run(x=0).y, + self.comp.run(x=0).y, msg="Assigning a new _class_ to an existing node should be a shortcut " "for replacement" ) - comp.replace(comp.n1, n1) # Return to original state + self.comp.replace(self.comp.n1, n1) # Return to original state - comp.n1 = different_input_channel + self.comp.n1 = different_input_channel self.assertEqual( (0 + 10) + 1 + 1, - comp.run(n1__z=0).y, + self.comp.run(n1__z=0).y, msg="Different IO should be compatible as long as what's missing is " "not connected" ) - comp.replace(comp.n1, n1) + self.comp.replace(self.comp.n1, n1) - comp.n3 = different_output_channel + self.comp.n3 = different_output_channel self.assertEqual( (0 + 1) + 1 + 100, - comp.run(x=0).n3__z, + self.comp.run(x=0).n3__z, msg="Different IO should be compatible as long as what's missing is " "not connected" ) - comp.replace(comp.n3, n3) + self.comp.replace(self.comp.n3, n3) with self.subTest("Verify failure cases"): - self.assertEqual(3, comp.run().y, msg="Sanity check") + self.assertEqual(3, self.comp.run().y, msg="Sanity check") another_comp = AComposite("another") another_node = x_plus_minus_z(parent=another_comp) @@ -356,7 +352,7 @@ def different_output_channel(x: int = 0) -> int: ValueError, msg="Should fail when replacement has a parent" ): - comp.replace(comp.n1, another_node) + self.comp.replace(self.comp.n1, another_node) another_comp.remove(another_node) another_node.inputs.x = replacement.outputs.y @@ -364,14 +360,14 @@ def different_output_channel(x: int = 0) -> int: ValueError, msg="Should fail when replacement is connected" ): - comp.replace(comp.n1, another_node) + self.comp.replace(self.comp.n1, another_node) another_node.disconnect() with self.assertRaises( ValueError, msg="Should fail if the node being replaced isn't a child" ): - comp.replace(replacement, another_node) + self.comp.replace(replacement, another_node) @Composite.wrap_as.single_value_node("y") def wrong_hint(x: float = 0) -> float: @@ -381,111 +377,105 @@ def wrong_hint(x: float = 0) -> float: TypeError, msg="Should not be able to replace with the wrong type hints" ): - comp.n1 = wrong_hint + self.comp.n1 = wrong_hint with self.assertRaises( AttributeError, msg="Should not be able to replace with any missing connected channels" ): - comp.n2 = different_input_channel + self.comp.n2 = different_input_channel with self.assertRaises( AttributeError, msg="Should not be able to replace with any missing connected channels" ): - comp.n2 = different_output_channel + self.comp.n2 = different_output_channel self.assertEqual( 3, - comp.run().y, + self.comp.run().y, msg="Failed replacements should always restore the original state " "cleanly" ) def test_working_directory(self): - comp = AComposite("my_composite") - comp.plus_one = Composite.create.Function(plus_one) + self.comp.plus_one = Composite.create.Function(plus_one) self.assertTrue( - str(comp.plus_one.working_directory.path).endswith(comp.plus_one.label), + str(self.comp.plus_one.working_directory.path).endswith(self.comp.plus_one.label), msg="Child nodes should have their own working directories nested inside" ) - comp.working_directory.delete() # Clean up + self.comp.working_directory.delete() # Clean up def test_length(self): - comp = AComposite("my_composite") - comp.child = Composite.create.Function(plus_one) - l1 = len(comp) - comp.child2 = Composite.create.Function(plus_one) + self.comp.child = Composite.create.Function(plus_one) + l1 = len(self.comp) + self.comp.child2 = Composite.create.Function(plus_one) self.assertEqual( l1 + 1, - len(comp), + len(self.comp), msg="Expected length to count the number of children" ) def test_run(self): - comp = AComposite("my_composite") - comp.create.SingleValue(plus_one, label="n1", x=0) - comp.create.SingleValue(plus_one, label="n2", x=comp.n1) - comp.create.SingleValue(plus_one, label="n3", x=42) - comp.n1 > comp.n2 - comp.starting_nodes = [comp.n1] - - comp.run() + self.comp.create.SingleValue(plus_one, label="n1", x=0) + self.comp.create.SingleValue(plus_one, label="n2", x=self.comp.n1) + self.comp.create.SingleValue(plus_one, label="n3", x=42) + self.comp.n1 > self.comp.n2 + self.comp.starting_nodes = [self.comp.n1] + + self.comp.run() self.assertEqual( 2, - comp.n2.outputs.y.value, + self.comp.n2.outputs.y.value, msg="Expected to start from starting node and propagate" ) self.assertIs( NotData, - comp.n3.outputs.y.value, + self.comp.n3.outputs.y.value, msg="n3 was omitted from the execution diagram, it should not have run" ) def test_set_run_signals_to_dag(self): # Like the run test, but manually invoking this first - - comp = AComposite("my_composite") - comp.create.SingleValue(plus_one, label="n1", x=0) - comp.create.SingleValue(plus_one, label="n2", x=comp.n1) - comp.create.SingleValue(plus_one, label="n3", x=42) - comp.set_run_signals_to_dag_execution() - comp.run() + self.comp.create.SingleValue(plus_one, label="n1", x=0) + self.comp.create.SingleValue(plus_one, label="n2", x=self.comp.n1) + self.comp.create.SingleValue(plus_one, label="n3", x=42) + self.comp.set_run_signals_to_dag_execution() + self.comp.run() self.assertEqual( 1, - comp.n1.outputs.y.value, + self.comp.n1.outputs.y.value, msg="Expected all nodes to run" ) self.assertEqual( 2, - comp.n2.outputs.y.value, + self.comp.n2.outputs.y.value, msg="Expected all nodes to run" ) self.assertEqual( 43, - comp.n3.outputs.y.value, + self.comp.n3.outputs.y.value, msg="Expected all nodes to run" ) - comp.n1.inputs.x = comp.n2 + self.comp.n1.inputs.x = self.comp.n2 with self.assertRaises( CircularDataFlowError, msg="Should not be able to automate graphs with circular data" ): - comp.set_run_signals_to_dag_execution() + self.comp.set_run_signals_to_dag_execution() def test_return(self): - comp = AComposite("my_composite") - comp.n1 = Composite.create.SingleValue(plus_one, x=0) + self.comp.n1 = Composite.create.SingleValue(plus_one, x=0) not_dottable_string = "can't dot this" - not_dottable_name_node = comp.create.SingleValue( + not_dottable_name_node = self.comp.create.SingleValue( plus_one, x=42, label=not_dottable_string ) - comp.starting_nodes = [comp.n1, not_dottable_name_node] - out = comp.run() + self.comp.starting_nodes = [self.comp.n1, not_dottable_name_node] + out = self.comp.run() self.assertEqual( 1, - comp.outputs.n1__y.value, + self.comp.outputs.n1__y.value, msg="Sanity check that the output has been filled and is stored under the " "name we think it is" ) @@ -495,7 +485,7 @@ def test_return(self): # We can give nodes crazy names, but then we're stuck with item access self.assertIs( not_dottable_name_node, - comp.nodes[not_dottable_string], + self.comp.nodes[not_dottable_string], msg="Should be able to access the node by item" ) self.assertEqual( @@ -506,31 +496,30 @@ def test_return(self): def test_io_maps(self): # input and output, renaming, accessing connected, and deactivating disconnected - comp = AComposite("my_composite") - comp.n1 = Composite.create.SingleValue(plus_one, x=0) - comp.n2 = Composite.create.SingleValue(plus_one, x=comp.n1) - comp.n3 = Composite.create.SingleValue(plus_one, x=comp.n2) - comp.m = Composite.create.SingleValue(plus_one, x=42) - comp.inputs_map = { + self.comp.n1 = Composite.create.SingleValue(plus_one, x=0) + self.comp.n2 = Composite.create.SingleValue(plus_one, x=self.comp.n1) + self.comp.n3 = Composite.create.SingleValue(plus_one, x=self.comp.n2) + self.comp.m = Composite.create.SingleValue(plus_one, x=42) + self.comp.inputs_map = { "n1__x": "x", # Rename "n2__x": "intermediate_x", # Expose "m__x": None, # Hide } - comp.outputs_map = { + self.comp.outputs_map = { "n3__y": "y", # Rename "n2__y": "intermediate_y", # Expose, "m__y": None, # Hide } - self.assertIn("x", comp.inputs.labels, msg="Should be renamed") - self.assertIn("y", comp.outputs.labels, msg="Should be renamed") - self.assertIn("intermediate_x", comp.inputs.labels, msg="Should be exposed") - self.assertIn("intermediate_y", comp.outputs.labels, msg="Should be exposed") - self.assertNotIn("m__x", comp.inputs.labels, msg="Should be hidden") - self.assertNotIn("m__y", comp.outputs.labels, msg="Should be hidden") - self.assertNotIn("m__y", comp.outputs.labels, msg="Should be hidden") - - comp.set_run_signals_to_dag_execution() - out = comp.run() + self.assertIn("x", self.comp.inputs.labels, msg="Should be renamed") + self.assertIn("y", self.comp.outputs.labels, msg="Should be renamed") + self.assertIn("intermediate_x", self.comp.inputs.labels, msg="Should be exposed") + self.assertIn("intermediate_y", self.comp.outputs.labels, msg="Should be exposed") + self.assertNotIn("m__x", self.comp.inputs.labels, msg="Should be hidden") + self.assertNotIn("m__y", self.comp.outputs.labels, msg="Should be hidden") + self.assertNotIn("m__y", self.comp.outputs.labels, msg="Should be hidden") + + self.comp.set_run_signals_to_dag_execution() + out = self.comp.run() self.assertEqual( 3, out.y, @@ -543,36 +532,35 @@ def test_io_maps(self): ) self.assertEqual( 43, - comp.m.outputs.y.value, + self.comp.m.outputs.y.value, msg="The child channel should still exist and have run" ) self.assertEqual( 1, - comp.inputs.intermediate_x.value, + self.comp.inputs.intermediate_x.value, msg="IO should be up-to-date post-run" ) self.assertEqual( 2, - comp.outputs.intermediate_y.value, + self.comp.outputs.intermediate_y.value, msg="IO should be up-to-date post-run" ) def test_de_activate_strict_connections(self): - comp = AComposite("my_composite") - comp.sub_comp = AComposite("sub") - comp.sub_comp.n1 = Composite.create.SingleValue(plus_one, x=0) + self.comp.sub_comp = AComposite("sub") + self.comp.sub_comp.n1 = Composite.create.SingleValue(plus_one, x=0) self.assertTrue( - comp.sub_comp.n1.inputs.x.strict_hints, + self.comp.sub_comp.n1.inputs.x.strict_hints, msg="Sanity check that test starts in the expected condition" ) - comp.deactivate_strict_hints() + self.comp.deactivate_strict_hints() self.assertFalse( - comp.sub_comp.n1.inputs.x.strict_hints, + self.comp.sub_comp.n1.inputs.x.strict_hints, msg="Deactivating should propagate to children" ) - comp.activate_strict_hints() + self.comp.activate_strict_hints() self.assertTrue( - comp.sub_comp.n1.inputs.x.strict_hints, + self.comp.sub_comp.n1.inputs.x.strict_hints, msg="Activating should propagate to children" ) From 6e88ef30b7fa8ef66efae58a7c3ab9743ca80859 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 15:02:56 -0800 Subject: [PATCH 19/41] Define and test Macro promises --- pyiron_workflow/macro.py | 35 ++++++--- tests/unit/test_macro.py | 157 +++++++++++++++++++++++++++------------ 2 files changed, 132 insertions(+), 60 deletions(-) diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index 163b6b72..c380b0c4 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -15,8 +15,6 @@ if TYPE_CHECKING: from bidict import bidict - from pyiron_workflow.node import Node - class Macro(Composite): """ @@ -24,16 +22,13 @@ class Macro(Composite): pre-populated workflow that is the same every time you instantiate it. At instantiation, the macro uses a provided callable to build and wire the graph, - then builds a static IO interface for this graph. (By default, unconnected IO is - passed using the same formalism as workflows to combine node and channel names, but - this can be overriden to rename the channels in the IO panel and/or to expose - channels that already have an internal connection.) - - Like function nodes, initial values for input can be set using kwargs, and the node - will (by default) attempt to update at the end of the instantiation process. - - It is intended that subclasses override the initialization signature and provide - the graph creation directly from their own method. + then builds a static IO interface for this graph. (See the parent class docstring + for more details, but by default and as with workflows, unconnected IO is + represented by combining node and channel names, but can be controlled in more + detail with maps.) + This IO is _value linked_ to the child IO, so that their values stay synchronized, + but the child nodes of a macro form an isolated sub-graph. + As with function nodes, sub-classes may define a method for creating the graph. As with workflows, all DAG macros can determine their execution flow automatically, if you have cycles in your data flow, or otherwise want more control over the @@ -43,6 +38,22 @@ class Macro(Composite): both then no further checks of their validity/reasonableness are performed, so be careful. + Promises (in addition parent class promises): + - IO is... + - Only built at instantiation, after child node replacement, or at request, so + it is "static" for improved efficiency + - By value, i.e. the macro has its own IO channel instances and children are + duly encapsulated inside their own sub-graph + - Value-linked to the values of their corresponding child nodes' IO -- i.e. + updating a macro input value changes a child node's input value, and a + child node updating its output value changes a macro output value (if that + child's output is regularly included in the macro's output, e.g. because it + is disconnected or otherwise included in the outputs map) + - Macros will attempt to set the execution graph automatically for DAGs, as long as + no execution flow is set in the function that builds the sub-graph + - A default node label can be generated using the name of the callable that builds + the graph. + Examples: Let's consider the simplest case of macros that just consecutively add 1 to their input: diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index 085f8236..4940fc9e 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -26,7 +26,108 @@ def add_three_macro(macro): @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestMacro(unittest.TestCase): - def test_labels(self): + def test_static_input(self): + m = Macro(add_three_macro) + inp = m.inputs + inp_again = m.inputs + self.assertIs( + inp, inp_again, msg="Should not be rebuilding just to look at it" + ) + m._rebuild_data_io() + new_inp = m.inputs + self.assertIsNot( + inp, new_inp, msg="After rebuild we should get a new object" + ) + + def test_io_independence(self): + m = Macro(add_three_macro) + self.assertIsNot( + m.inputs.one__x, + m.one.inputs.x, + msg="Expect input to be by value, not by reference" + ) + self.assertIsNot( + m.outputs.three__result, + m.three.outputs.result, + msg="Expect output to be by value, not by reference" + ) + self.assertFalse( + m.connected, + msg="Macro should talk to its children by value links _not_ graph " + "connections" + ) + + def test_value_links(self): + m = Macro(add_three_macro) + self.assertIs( + m.one.inputs.x, + m.inputs.one__x.value_receiver, + msg="Sanity check that value link exists" + ) + self.assertIs( + m.outputs.three__result, + m.three.outputs.result.value_receiver, + msg="Sanity check that value link exists" + ) + self.assertNotEqual( + 42, m.one.inputs.x.value, msg="Sanity check that we start from expected" + ) + self.assertNotEqual( + 42, + m.three.outputs.result.value, + msg="Sanity check that we start from expected" + ) + m.inputs.one__x.value = 0 + self.assertEqual( + 0, m.one.inputs.x.value, msg="Expected values to stay synchronized" + ) + m.three.outputs.result.value = 0 + self.assertEqual( + 0, m.outputs.three__result.value, msg="Expected values to stay synchronized" + ) + + def test_execution_automation(self): + fully_automatic = add_three_macro + + def fully_defined(macro): + add_three_macro(macro) + macro.one > macro.two > macro.three + macro.starting_nodes = [macro.one] + + def only_order(macro): + add_three_macro(macro) + macro.two > macro.three + + def only_starting(macro): + add_three_macro(macro) + macro.starting_nodes = [macro.one] + + m_auto = Macro(fully_automatic) + m_user = Macro(fully_defined) + + x = 0 + expected = add_one(add_one(add_one(x))) + self.assertEqual( + m_auto(one__x=x).three__result, + expected, + "DAG macros should run fine without user specification of execution." + ) + self.assertEqual( + m_user(one__x=x).three__result, + expected, + "Macros should run fine if the user nicely specifies the exeuction graph." + ) + + with self.subTest("Partially specified execution should fail"): + # We don't yet check for _crappy_ user-defined execution, + # But we should make sure it's at least valid in principle + with self.assertRaises(ValueError): + Macro(only_order) + + with self.assertRaises(ValueError): + Macro(only_starting) + + def test_default_label(self): m = Macro(add_three_macro) self.assertEqual( m.label, @@ -37,7 +138,7 @@ def test_labels(self): m2 = Macro(add_three_macro, label=label) self.assertEqual(m2.label, label, msg="Should be able to specify a label") - def test_wrapper_function(self): + def test_creation_from_decorator(self): m = Macro(add_three_macro) self.assertIs( @@ -62,7 +163,7 @@ def test_wrapper_function(self): msg="Macros should get output updated, just like other nodes" ) - def test_subclass(self): + def test_creation_from_subclass(self): class MyMacro(Macro): def build_graph(self): add_three_macro(self) @@ -147,47 +248,6 @@ def nested_macro(macro): msg="Should return None when parent is None" ) - def test_execution_automation(self): - fully_automatic = add_three_macro - - def fully_defined(macro): - add_three_macro(macro) - macro.one > macro.two > macro.three - macro.starting_nodes = [macro.one] - - def only_order(macro): - add_three_macro(macro) - macro.two > macro.three - - def only_starting(macro): - add_three_macro(macro) - macro.starting_nodes = [macro.one] - - m_auto = Macro(fully_automatic) - m_user = Macro(fully_defined) - - x = 0 - expected = add_one(add_one(add_one(x))) - self.assertEqual( - m_auto(one__x=x).three__result, - expected, - "DAG macros should run fine without user specification of execution." - ) - self.assertEqual( - m_user(one__x=x).three__result, - expected, - "Macros should run fine if the user nicely specifies the exeuction graph." - ) - - with self.subTest("Partially specified execution should fail"): - # We don't yet check for _crappy_ user-defined execution, - # But we should make sure it's at least valid in principle - with self.assertRaises(ValueError): - Macro(only_order) - - with self.assertRaises(ValueError): - Macro(only_starting) - def test_with_executor(self): macro = Macro(add_three_macro) downstream = SingleValue(add_one, x=macro.outputs.three__result) @@ -266,8 +326,6 @@ def test_pulling_from_inside_a_macro(self): macro.inputs.one__x = 0 # Set value # Now macro.one.inputs.x has both value and a connection - print("MACRO ONE INPUT X", macro.one.inputs.x.value, macro.one.inputs.x.connections) - self.assertEqual( 0 + 1 + 1, macro.two.pull(run_parent_trees_too=False), @@ -314,10 +372,13 @@ def grab_connections(macro): self.assertListEqual( initial_labels, list(m.nodes.keys()), - msg="Labels should be restored after failing to pull because of acyclicity" + msg="Labels should be restored after failing to pull because of " + "acyclicity" ) self.assertTrue( - all(c is ic for (c, ic) in zip(grab_connections(m), initial_connections)), + all( + c is ic for (c, ic) in zip(grab_connections(m), initial_connections) + ), msg="Connections should be restored after failing to pull because of " "cyclic data flow" ) From 8a31793a515fc4dcfba3bc6ff85f3c9e494e8eb1 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 15:16:20 -0800 Subject: [PATCH 20/41] Make and test Workflow promises --- pyiron_workflow/workflow.py | 4 +++ tests/unit/test_workflow.py | 52 +++++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/pyiron_workflow/workflow.py b/pyiron_workflow/workflow.py index 2b7611a6..0bbe1bc3 100644 --- a/pyiron_workflow/workflow.py +++ b/pyiron_workflow/workflow.py @@ -46,6 +46,10 @@ class Workflow(Composite): you should consider reformulating it as a `Macro`, which operates somewhat more efficiently. + Promises (in addition parent class promises): + - Workflows are living, their IO always reflects their current state of child nodes + - Workflows are parent-most objects, they cannot be a sub-graph of a larger graph + Examples: We allow adding nodes to workflows in five equivalent ways: >>> from pyiron_workflow.workflow import Workflow diff --git a/tests/unit/test_workflow.py b/tests/unit/test_workflow.py index 90512425..54485e47 100644 --- a/tests/unit/test_workflow.py +++ b/tests/unit/test_workflow.py @@ -23,34 +23,52 @@ def setUpClass(cls) -> None: ensure_tests_in_python_path() super().setUpClass() - def test_workflow_io(self): + def test_io(self): wf = Workflow("wf") wf.create.Function(plus_one, label="n1") wf.create.Function(plus_one, label="n2") wf.create.Function(plus_one, label="n3") - with self.subTest("Workflow IO should be drawn from its nodes"): - self.assertEqual(len(wf.inputs), 3) - self.assertEqual(len(wf.outputs), 3) + inp = wf.inputs + inp_again = wf.inputs + self.assertIsNot( + inp, inp_again, msg="Workflow input should always get rebuilt" + ) + + n_in = len(wf.inputs) + n_out = len(wf.outputs) + wf.create.Function(plus_one, label="n4") + self.assertEqual( + n_in + 1, len(wf.inputs), msg="Workflow IO should be drawn from its nodes" + ) + self.assertEqual( + n_out + 1, len(wf.outputs), msg="Workflow IO should be drawn from its nodes" + ) + n_in = len(wf.inputs) + n_out = len(wf.outputs) wf.n3.inputs.x = wf.n2.outputs.y wf.n2.inputs.x = wf.n1.outputs.y + self.assertEqual( + n_in -2, len(wf.inputs), msg="New connections should get reflected" + ) + self.assertEqual( + n_out - 2, len(wf.outputs), msg="New connections should get reflected" + ) - with self.subTest("Only unconnected channels should count"): - self.assertEqual(len(wf.inputs), 1) - self.assertEqual(len(wf.outputs), 1) + wf.inputs_map = {"n1__x": "inp"} + self.assertIs(wf.n1.inputs.x, wf.inputs.inp, msg="IO should be renamable") - with self.subTest( - "IO should be re-mappable, including exposing internally connected " - "channels" - ): - wf.inputs_map = {"n1__x": "inp"} - wf.outputs_map = {"n3__y": "out", "n2__y": "intermediate"} - out = wf(inp=0) - self.assertEqual(out.out, 3) - self.assertEqual(out.intermediate, 2) + self.assertNotIn(wf.n2.outputs.y, wf.outputs, msg="Ensure starting condition") + self.assertIn(wf.n3.outputs.y, wf.outputs, msg="Ensure starting condition") + wf.outputs_map = {"n3__y": None, "n2__y": "intermediate"} + self.assertIn(wf.n2.outputs.y, wf.outputs, msg="IO should be exposable") + self.assertIs( + wf.n2.outputs.y, wf.outputs.intermediate, msg="IO should be by reference" + ) + self.assertNotIn(wf.n3.outputs.y, wf.outputs, msg="IO should be hidable") - def test_no_parents(self): + def test_is_parentmost(self): wf = Workflow("wf") wf2 = Workflow("wf2") wf2.parent = None # Is already the value and should ignore this From 53557cfe4092f13606a1b23620000743cd6dfa9d Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 16:05:34 -0800 Subject: [PATCH 21/41] :bug: handle bidict None duplication For disabling multiple IO channels --- pyiron_workflow/composite.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 6f78733e..e56007cd 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -58,9 +58,10 @@ class Composite(Node, ABC): - Is some subset of the child nodes IO - Default channel labels indicate both child and child's channel labels - Default behaviour is to expose all unconnected child nodes' IO - - Can be given new labels - - Can force some child node's IO to appear - - Can force some child node's IO to _not_ appear + - Bijective maps can be used to... + - Rename IO + - Force a child node's IO to appear + - Force a child node's IO to _not_ appear Attributes: inputs/outputs_map (bidict|None): Maps in the form @@ -121,13 +122,26 @@ def __init__( @property def inputs_map(self) -> bidict | None: + if self._inputs_map is not None: + for k, v in self._inputs_map.items(): + if v is None: + self._inputs_map[k] = self._deduplicate_none(k) return self._inputs_map @inputs_map.setter def inputs_map(self, new_map: dict | bidict | None): - new_map = new_map if new_map is None else bidict(new_map) + if new_map is not None: + new_map = dict(new_map) + for k, v in new_map.items(): + if v is None: + new_map[k] = self._deduplicate_none(k) + new_map = bidict(new_map) self._inputs_map = new_map + @staticmethod + def _deduplicate_none(k): + return (None, f"{k} disabled") + @property def outputs_map(self) -> bidict | None: return self._outputs_map @@ -244,7 +258,7 @@ def _build_io( default_key = f"{node.label}__{channel_label}" try: io_panel_key = key_map[default_key] - if io_panel_key is not None: + if io_panel_key is not tuple: io[io_panel_key] = self._get_linking_channel( channel, io_panel_key ) From 2eda95049f5f88b363046f7394f76a21c98c7e70 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 16:19:01 -0800 Subject: [PATCH 22/41] :bug: check for instance type --- pyiron_workflow/composite.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index e56007cd..9b05b59f 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -258,7 +258,8 @@ def _build_io( default_key = f"{node.label}__{channel_label}" try: io_panel_key = key_map[default_key] - if io_panel_key is not tuple: + if not isinstance(io_panel_key, tuple): + # Tuples indicate that the channel has been deactivated io[io_panel_key] = self._get_linking_channel( channel, io_panel_key ) From 811fb79f30625f7e4f4ec8f63f9184be602831f5 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 16:19:18 -0800 Subject: [PATCH 23/41] Apply de-duplication of None values to both I and O --- pyiron_workflow/composite.py | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 9b05b59f..a01ca59c 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -122,34 +122,30 @@ def __init__( @property def inputs_map(self) -> bidict | None: - if self._inputs_map is not None: - for k, v in self._inputs_map.items(): - if v is None: - self._inputs_map[k] = self._deduplicate_none(k) - return self._inputs_map + return self._as_bidict_with_deduplicated_nones(self._inputs_map) @inputs_map.setter def inputs_map(self, new_map: dict | bidict | None): - if new_map is not None: - new_map = dict(new_map) - for k, v in new_map.items(): - if v is None: - new_map[k] = self._deduplicate_none(k) - new_map = bidict(new_map) - self._inputs_map = new_map - - @staticmethod - def _deduplicate_none(k): - return (None, f"{k} disabled") + self._inputs_map = self._as_bidict_with_deduplicated_nones(new_map) @property def outputs_map(self) -> bidict | None: - return self._outputs_map + return self._as_bidict_with_deduplicated_nones(self._outputs_map) @outputs_map.setter def outputs_map(self, new_map: dict | bidict | None): - new_map = new_map if new_map is None else bidict(new_map) - self._outputs_map = new_map + self._outputs_map = self._as_bidict_with_deduplicated_nones(new_map) + + @staticmethod + def _as_bidict_with_deduplicated_nones( + some_map: dict | bidict | None + ) -> bidict | None: + if some_map is not None: + for k, v in some_map.items(): + if v is None: + some_map[k] = (None, f"{k} disabled") + some_map = bidict(some_map) + return some_map def activate_strict_hints(self): super().activate_strict_hints() From 6eab6ae400a5297e679a27b4ec3308d5b8e3b022 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 16:34:06 -0800 Subject: [PATCH 24/41] :bug: make sure the map is persistent --- pyiron_workflow/composite.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index a01ca59c..d7eae717 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -122,30 +122,36 @@ def __init__( @property def inputs_map(self) -> bidict | None: - return self._as_bidict_with_deduplicated_nones(self._inputs_map) + self._deduplicate_nones(self._inputs_map) + return self._inputs_map @inputs_map.setter def inputs_map(self, new_map: dict | bidict | None): - self._inputs_map = self._as_bidict_with_deduplicated_nones(new_map) + self._deduplicate_nones(new_map) + if new_map is not None: + new_map = bidict(new_map) + self._inputs_map = new_map @property def outputs_map(self) -> bidict | None: - return self._as_bidict_with_deduplicated_nones(self._outputs_map) + self._deduplicate_nones(self._outputs_map) + return self._outputs_map @outputs_map.setter def outputs_map(self, new_map: dict | bidict | None): - self._outputs_map = self._as_bidict_with_deduplicated_nones(new_map) + self._deduplicate_nones(new_map) + if new_map is not None: + new_map = bidict(new_map) + self._outputs_map = new_map @staticmethod - def _as_bidict_with_deduplicated_nones( + def _deduplicate_nones( some_map: dict | bidict | None - ) -> bidict | None: + ) -> dict | bidict | None: if some_map is not None: for k, v in some_map.items(): if v is None: some_map[k] = (None, f"{k} disabled") - some_map = bidict(some_map) - return some_map def activate_strict_hints(self): super().activate_strict_hints() From 05770344a9c03169084f7dcd993284f8f5cbd8ef Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 16:35:41 -0800 Subject: [PATCH 25/41] Pull the bijectivity tests up to Composite --- tests/unit/test_composite.py | 36 +++++++++++++++++++++++++++ tests/unit/test_workflow.py | 48 ------------------------------------ 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/tests/unit/test_composite.py b/tests/unit/test_composite.py index d6281e80..89f13bea 100644 --- a/tests/unit/test_composite.py +++ b/tests/unit/test_composite.py @@ -1,6 +1,8 @@ from sys import version_info import unittest +from bidict import ValueDuplicationError + from pyiron_workflow._tests import ensure_tests_in_python_path from pyiron_workflow.channels import NotData from pyiron_workflow.composite import Composite @@ -546,6 +548,40 @@ def test_io_maps(self): msg="IO should be up-to-date post-run" ) + def test_io_map_bijectivity(self): + with self.assertRaises( + ValueDuplicationError, + msg="Should not be allowed to map two children's channels to the same label" + ): + self.comp.inputs_map = {"n1__x": "x", "n2__x": "x"} + + self.comp.inputs_map = {"n1__x": "x"} + with self.assertRaises( + ValueDuplicationError, + msg="Should not be allowed to update a second child's channel onto an " + "existing mapped channel" + ): + self.comp.inputs_map["n2__x"] = "x" + + with self.subTest("Ensure we can use None to turn multiple off"): + self.comp.inputs_map = {"n1__x": None, "n2__x": None} # At once + # Or in a row + self.comp.inputs_map = {} + self.comp.inputs_map["n1__x"] = None + self.comp.inputs_map["n2__x"] = None + self.comp.inputs_map["n3__x"] = None + print("\nMAP", self.comp.inputs_map) + self.assertEqual( + 3, + len(self.comp.inputs_map), + msg="All entries should be stored" + ) + self.assertEqual( + 0, + len(self.comp.inputs), + msg="No IO should be left exposed" + ) + def test_de_activate_strict_connections(self): self.comp.sub_comp = AComposite("sub") self.comp.sub_comp.n1 = Composite.create.SingleValue(plus_one, x=0) diff --git a/tests/unit/test_workflow.py b/tests/unit/test_workflow.py index 54485e47..7657aff1 100644 --- a/tests/unit/test_workflow.py +++ b/tests/unit/test_workflow.py @@ -3,8 +3,6 @@ from time import sleep import unittest -from bidict import ValueDuplicationError - from pyiron_workflow._tests import ensure_tests_in_python_path from pyiron_workflow.channels import NotData from pyiron_workflow.util import DotDict @@ -299,52 +297,6 @@ def matches_expectations(results): with self.assertRaises(ValueError): cyclic() - def test_io_label_maps_are_bijective(self): - - with self.subTest("Null case"): - Workflow( - "my_workflow", - Workflow.create.Function(plus_one, label="foo1"), - Workflow.create.Function(plus_one, label="foo2"), - inputs_map={ - "foo1__x": "x1", - "foo2__x": "x2" - }, - outputs_map=None - ) - - with self.subTest("At instantiation"): - with self.assertRaises(ValueDuplicationError): - Workflow( - "my_workflow", - Workflow.create.Function(plus_one, label="foo1"), - Workflow.create.Function(plus_one, label="foo2"), - inputs_map={ - "foo1__x": "x", - "foo2__x": "x" - } - ) - - with self.subTest("Post-facto assignment"): - wf = Workflow( - "my_workflow", - Workflow.create.Function(plus_one, label="foo1"), - Workflow.create.Function(plus_one, label="foo2"), - ) - wf.outputs_map = None - with self.assertRaises(ValueDuplicationError): - wf.inputs_map = {"foo1__x": "x", "foo2__x": "x"} - - with self.subTest("Post-facto update"): - wf = Workflow( - "my_workflow", - Workflow.create.Function(plus_one, label="foo1"), - Workflow.create.Function(plus_one, label="foo2"), - ) - wf.inputs_map = {"foo1__x": "x1", "foo2__x": "x2"} - with self.assertRaises(ValueDuplicationError): - wf.inputs_map["foo2__x"] = "x1" - def test_pull_and_executors(self): def add_three_macro(macro): macro.one = Workflow.create.SingleValue(plus_one) From 25546826659f949805c33fd057f195497270d863 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 16:57:25 -0800 Subject: [PATCH 26/41] Reformat Function as promises --- pyiron_workflow/function.py | 46 +++++++++++++------------------------ tests/unit/test_function.py | 4 ++++ 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index cf4a65a2..e4a36f92 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -20,28 +20,6 @@ class Function(Node): """ Function nodes wrap an arbitrary python function. - Node IO, including type hints, is generated automatically from the provided - function. - Input data for the wrapped function can be provided as any valid combination of - `*arg` and `**kwarg` at both initialization and on calling the node. - - On running, the function node executes this wrapped function with its current input - and uses the results to populate the node output. - - Function nodes must be instantiated with a callable to deterimine their function, - and a string to name each returned value of that callable. (If you really want to - return a tuple, just have multiple return values but only one output label -- there - is currently no way to mix-and-match, i.e. to have multiple return values at least - one of which is a tuple.) - - The node label (unless otherwise provided), IO channel names, IO types, and input - defaults for the node are produced _automatically_ from introspection of the node - function. - Explicit output labels can be provided to modify the number of return values (from - $N$ to 1 in case you _want_ a tuple returned) and to dodge constraints on the - automatic scraping routine (namely, that there be _at most_ one `return` - expression). - (Additional properties like storage priority and ontological type are forthcoming.) Actual function node instances can either be instances of the base node class, in which case the callable node function *must* be provided OR they can be instances @@ -57,12 +35,18 @@ class Function(Node): Further, functions with multiple return branches that return different types or numbers of return values may or may not work smoothly, depending on the details. - Output is updated according to `process_run_result` -- which gets invoked by the - post-run callbacks defined in `Node` -- such that run results are used to populate - the output channels. - - `run()` and its aliases return the output of the executed function, or a futures - object if the node is set to use an executor. + Promises: + - IO channels are constructed automatically from the wrapped function + - This includes type hints (if any) + - This includes defaults (if any) + - By default one channel is created for each returned value (from a tuple)... + - Output channel labels are taken from the returned value, but may be overriden + - A single tuple output channel can be forced by manually providing exactly one + output label + - Running the node executes the wrapped function and returns its result + - Input updates can be made with `*args` as well as the usual `**kwargs`, following + the same input order as the wrapped function. + - A default label can be scraped from the name of the wrapped function Args: node_function (callable): The function determining the behaviour of the node. @@ -579,8 +563,10 @@ class SingleValue(Function, HasChannel): available directly at the node level (at least those which don't conflict with the existing node namespace). - This also allows the entire node to be used as a reference to its output channel - when making data connections, e.g. `some_node.input.some_channel = my_svn_instance`. + Promises (in addition parent class promises): + - Attribute and item access will finally attempt to access the output value + - The entire node can be used in place of its output value for connections, e.g. + `some_node.input.some_channel = my_svn_instance`. """ def __init__( diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index 9a9f5a80..3dc813dc 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -139,6 +139,10 @@ def test_label_choices(self): switch = Function(multiple_branches, output_labels="bool") self.assertListEqual(switch.outputs.labels, ["bool"]) + def test_default_label(self): + n = Function(plus_one) + self.assertEqual(plus_one.__name__, n.label) + def test_availability_of_node_function(self): @function_node() def linear(x): From 29a4b29dc6be4294823fce3bcd828f61cc871087 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 21:03:45 -0800 Subject: [PATCH 27/41] Finish parent promises Don't allow the manual reassignment of the parent attribute, but always pass through add or remove (after instantiation -- at instantiation we can handle it) --- pyiron_workflow/composite.py | 14 +++++++------- pyiron_workflow/node.py | 15 +++++++++++++-- pyiron_workflow/workflow.py | 6 +++--- tests/unit/test_composite.py | 12 +++++------- tests/unit/test_workflow.py | 7 +------ 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index d7eae717..83dbe714 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -35,15 +35,15 @@ class Composite(Node, ABC): - From the instance level, created nodes get the instance as their parent - Child nodes... - Can be added by... - - Creating them from the instance + - Creating them from the creator on a composite _instance_ - Passing a node instance to the adding method - - Setting the composite instance as the node's parent + - Setting the composite instance as the node's parent at node instantiation - Assigning a node instance as an attribute - Can be accessed by... - Attribute access using their node label - Attribute or item access in the child nodes collection - Iterating over the composite instance - - Can be removed + - Can be removed by method - Each have a unique label (within the scope of this composite) - Have no other parent - Can be replaced in-place with another node that has commensurate IO @@ -205,7 +205,7 @@ def _update_children(self, children_from_another_process: DotDict[str, Node]): replace your own nodes with them, and set yourself as their parent. """ for child in children_from_another_process.values(): - child.parent = self + child._parent = self self.nodes = children_from_another_process def disconnect_run(self) -> list[tuple[Channel, Channel]]: @@ -328,7 +328,7 @@ def add(self, node: Node, label: Optional[str] = None) -> None: self.nodes[label] = node node.label = label - node.parent = self + node._parent = self return node def _get_unique_label(self, label): @@ -393,7 +393,7 @@ def remove(self, node: Node | str) -> list[tuple[Channel, Channel]]: (list[tuple[Channel, Channel]]): Any connections that node had. """ node = self.nodes[node] if isinstance(node, str) else node - node.parent = None + node._parent = None disconnected = node.disconnect() if node in self.starting_nodes: self.starting_nodes.remove(node) @@ -519,7 +519,7 @@ def register(cls, domain: str, package_identifier: str) -> None: cls.create.register(domain=domain, package_identifier=package_identifier) def __setattr__(self, key: str, node: Node): - if isinstance(node, Node) and key != "parent": + if isinstance(node, Node) and key != "_parent": self.add(node, label=key) elif ( isinstance(node, type) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index ed4c5fc8..1cbcaf0d 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -216,7 +216,7 @@ def __init__( """ super().__init__(*args, **kwargs) self.label: str = label - self.parent = parent + self._parent = None if parent is not None: parent.add(self) self.running = False @@ -268,6 +268,17 @@ def process_run_result(self, run_output): run_output: The results of a `self.on_run(self.run_args)` call. """ + @property + def parent(self) -> Composite | None: + return self._parent + + @parent.setter + def parent(self, new_parent: Composite | None) -> None: + raise ValueError( + "Please change parentage by adding/removing the node to/from the relevant" + "parent" + ) + def run( self, run_data_tree: bool = False, @@ -814,7 +825,7 @@ def replace_with(self, other: Node | type[Node]): def __getstate__(self): state = self.__dict__ - state["parent"] = None + state["_parent"] = None # I am not at all confident that removing the parent here is the _right_ # solution. # In order to run composites on a parallel process, we ship off just the nodes diff --git a/pyiron_workflow/workflow.py b/pyiron_workflow/workflow.py index 0bbe1bc3..789f57c9 100644 --- a/pyiron_workflow/workflow.py +++ b/pyiron_workflow/workflow.py @@ -257,11 +257,11 @@ def deserialize(self, source): raise NotImplementedError @property - def parent(self) -> None: + def _parent(self) -> None: return None - @parent.setter - def parent(self, new_parent: None): + @_parent.setter + def _parent(self, new_parent: None): # Currently workflows are not allowed to have a parent -- maybe we want to # change our minds on this in the future? If we do, we can just expose `parent` # as a kwarg and roll back this private var/property/setter protection and let diff --git a/tests/unit/test_composite.py b/tests/unit/test_composite.py index 89f13bea..a26cf025 100644 --- a/tests/unit/test_composite.py +++ b/tests/unit/test_composite.py @@ -93,17 +93,15 @@ def test_node_addition(self): self.comp.create.Function(plus_one, label="bar") self.comp.baz = self.comp.create.Function(plus_one, label="whatever_baz_gets_used") Composite.create.Function(plus_one, label="qux", parent=self.comp) - # node = Composite.create.Function(plus_one, label="quux") - # node.parent = comp self.assertListEqual( list(self.comp.nodes.keys()), - ["foo", "bar", "baz", "qux",], # "quux"], + ["foo", "bar", "baz", "qux"], msg="Expected every above syntax to add a node OK" ) self.comp.boa = self.comp.qux self.assertListEqual( list(self.comp.nodes.keys()), - ["foo", "bar", "baz", "boa"], # "quux"], + ["foo", "bar", "baz", "boa"], msg="Reassignment should remove the original instance" ) @@ -190,9 +188,9 @@ def test_label_uniqueness(self): with self.assertRaises(AttributeError, msg="We have 'foo' at home"): Composite.create.Function(plus_one, label="foo", parent=self.comp) - # with self.assertRaises(AttributeError, msg="We have 'foo' at home"): - # node = Composite.create.Function(plus_one, label="foo") - # node.parent = comp + with self.assertRaises(ValueError, msg="Parentage can't be set directly"): + node = Composite.create.Function(plus_one, label="foo") + node.parent = self.comp with self.subTest("Make sure trivial re-assignment has no impact"): original_foo = self.comp.foo diff --git a/tests/unit/test_workflow.py b/tests/unit/test_workflow.py index 7657aff1..c4c73dc3 100644 --- a/tests/unit/test_workflow.py +++ b/tests/unit/test_workflow.py @@ -69,15 +69,10 @@ def test_io(self): def test_is_parentmost(self): wf = Workflow("wf") wf2 = Workflow("wf2") - wf2.parent = None # Is already the value and should ignore this - with self.assertRaises(TypeError): - # We currently specify workflows shouldn't get parents, this just verifies - # the spec. If that spec changes, test instead that you _can_ set parents! - wf2.parent = "not None" with self.assertRaises(TypeError): # Setting a non-None value to parent raises the type error from the setter - wf2.parent = wf + wf.sub_wf = wf2 def test_with_executor(self): From 414d1c55b9dd8eb3274f76f8c6fd41ae31fa8964 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 21:29:08 -0800 Subject: [PATCH 28/41] Update node docs to the "promises" format This leans more heavily on the documentation of `attributes` and `methods` as well as `examples` in child classes to highlight the nitty-gritty of implementation, and makes the rest of the docstring shorter and more abstract (but hopefully also clearer) --- pyiron_workflow/node.py | 117 ++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 64 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 1cbcaf0d..77411cfc 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -67,70 +67,56 @@ def wrapped_method(node: Node, *args, **kwargs): # rather node:Node class Node(HasToDict, ABC): """ Nodes are elements of a computational graph. - They have input and output data channels, and input and output signal channels (for - running and having ran, by default), and can be connected together via these - channels to form computational graphs. - When running, they perform some computation (which must be defined in child - classes.) - - Running is always delayed, so no computation is performed unless _some form_ of - run request given by the user (e.g., obviously, invoking `.run()`). - - The options for running a node are enumerated in the `run` method, and convenience - shortcuts for particular sets of options are provided in `execute`, `pull`, and by - calling an instantiated node. - In all cases, the nodes input data can be updated before any running operations - by passing keyword-value pairs to the run invocation. - Because this happens first, _if_ the run invocation updates the input values some - other way, these supplied values will get overwritten. - - A non-exhaustive summary of running styles is: nodes can be run in a "push" - paradigm where they do their calculation and then trigger downstream calculations; - in a "pull" mode where they first make sure all their upstream dependencies then - run themselves (but not anything downstream); or they may be forced to run their - calculation with exactly the input they have right now. - For more information look at the documentation of the `run` method. - - Nodes may have a parent node that owns them as part of a sub-graph. - - Every node must be named with a label, and may use this label to attempt to create - a working directory in the filesystem for itself if requested. - These labels also help to identify nodes in the wider context of (potentially - nested) computational graphs. - - Execution flow should be automated wherever possible for user convenience (namely - when the data graph forms a directed acyclic graph (DAG), of which the acyclic part - is the only thing that might fail). - Execution flow can also be specified manually using signal connections. - These connections can be made using the same syntax as data connections, or with - some syntactic sugar where the the `>` symbol is used to indicate a flow of - execution from upstream dow. This syntactic sugar can be mixed between actual - signal channels (output signal > input signal), or nodes, but when referring to - nodes it is always a shortcut to the `run`/`ran` channels. - - The `run()` method returns a representation of the node output (possible a futures - object, if the node is running on an executor), and consequently the `pull`, - `execute`, and `__call__` shortcuts to `run` also return the same thing. - - Invoking the `run` method (or one of its aliases) of an already instantiated node - allows its input channels to be updated using keyword arguments corresponding to - the channel labels, performing a batch-update of all supplied input and then - proceeding. - - Nodes have a status, which is currently represented by the `running` and `failed` - boolean flag attributes. - These are updated automatically when the node's operation is invoked, e.g. with - `run`, `execute`, `pull`, or by calling the node instance. - - Nodes can be run on the main python process that owns them, or by setting their - `executor` attribute to `True`, in which case a - `pyiron_workflow.executors.CloudPickleExecutor` will be used to run the node on a - new process on a single core (in the future, the interface will look a little - different and you'll have more options than that). - In case they are run with an executor, their `future` attribute will be populated - with the resulting future object. - WARNING: Executors are currently only working when the node executable function does - not use `self`. + They have inputs and outputs to interface with the wider world, and perform some + operation. + By connecting multiple nodes' inputs and outputs together, computational graphs can + be formed. + These can be collected under a parent, such that new graphs can be composed of + one or more sub-graphs. + + Promises: + - Nodes perform some computation, but this is delayed and won't happen until asked + for (the nature of the computation is left to child classes). + - Nodes have input and output for interfacing with the outside world + - Which can be connected to output/input to form a computation graph + - These have a data flavour, to control the flow of information + - And a signal flavour, to control the flow of execution + - Execution flows can be specified manually, but in the case of data flows + which form directed acyclic graphs (DAGs), this can be automated + - When running their computation, nodes may or may not: + - First update their input data values using kwargs + - (Note that since this happens first, if the "fetching" step later occurs, + any values provided here will get overwritten by data that is flowing + on the data graph) + - Then instruct their parent node to ask all of the nodes + upstream in its data connections to run (recursively to the parent-most + super-graph) + - Ask for the nodes upstream of them to run (in the local context of their own + parent) + - Fetch the latest output data, prioritizing the first actual data among their + each of their inputs connections + - Check if they are ready to run, i.e. + - Status is neither running nor failed + - Input is all ready, i.e. each input has data and that data is + commensurate with type hints (if any) + - Submit their computation to an executor for remote processing, or ignore any + executor suggested and force the computation to be local (i.e. in the same + python process that owns the node) + - If computation is non-local, the node status will stay running and the + futures object returned by the executor will be accessible + - Emit their run-completed output signal to trigger runs in nodes downstream in + the execution flow + - Running the node (and all aliases of running) return a representation of data + held by the output channels + - If an error is encountered _after_ reaching the state of actually computing the + node's task, the status will get set to failure + - Nodes have a label by which they are identified + - Nodes may open a working directory related to their label, their parent(age) and + the python process working directory + + WARNING: Executors are currently only working when the node executable function + does not use `self`. + NOTE: Executors are only allowed in a "push" paradigm, and you will get an exception if you try to `pull` and one of the upstream nodes uses an executor. @@ -180,6 +166,7 @@ class Node(HasToDict, ABC): Methods: __call__: An alias for `pull` that aggressively runs upstream nodes even _outside_ the local scope (i.e. runs parents' dependencies as well). + (de)activate_strict_hints: Recursively (de)activate strict hints among data IO. disconnect: Remove all connections, including signals. draw: Use graphviz to visualize the node, its IO and, if composite in nature, its internal structure. @@ -192,6 +179,8 @@ class Node(HasToDict, ABC): downstream). "Upstream" may optionally break out of the local scope to run parent nodes' dependencies as well (all the way until the parent-most object is encountered). + replace_with: If the node belongs to a parent, attempts to replace itself in + that parent with a new provided node. run: Run the node function from `on_run`. Handles status automatically. Various execution options are available as boolean flags. set_input_values: Allows input channels' values to be updated without any From 8f2b40c5ed6d71239b8881171df04e0d5625de13 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 21:29:22 -0800 Subject: [PATCH 29/41] Reorder node tests to better match the order of the promises --- tests/unit/test_node.py | 76 ++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/tests/unit/test_node.py b/tests/unit/test_node.py index 5b0521af..5c18e8d6 100644 --- a/tests/unit/test_node.py +++ b/tests/unit/test_node.py @@ -84,6 +84,27 @@ def test_set_input_values(self): msg="It should be possible to deactivate type checking from the node level" ) + def test_run_data_tree(self): + self.assertEqual( + add_one(add_one(add_one(self.n1.inputs.x.value))), + self.n3.run(run_data_tree=True), + msg="Should pull start down to end, even with no flow defined" + ) + + def test_fetch_input(self): + self.n1.outputs.y.value = 0 + with self.assertRaises( + ValueError, + msg="Without input, we should not achieve readiness" + ): + self.n2.run(run_data_tree=False, fetch_input=False, check_readiness=True) + + self.assertEqual( + add_one(self.n1.outputs.y.value), + self.n2.run(run_data_tree=False, fetch_input=True), + msg="After fetching the upstream data, should run fine" + ) + def test_check_readiness(self): with self.assertRaises( ValueError, @@ -130,44 +151,6 @@ def test_check_readiness(self): "running should proceed" ) - def test_fetch_input(self): - self.n1.outputs.y.value = 0 - with self.assertRaises( - ValueError, - msg="Without input, we should not achieve readiness" - ): - self.n2.run(run_data_tree=False, fetch_input=False, check_readiness=True) - - self.assertEqual( - add_one(self.n1.outputs.y.value), - self.n2.run(run_data_tree=False, fetch_input=True), - msg="After fetching the upstream data, should run fine" - ) - - def test_run_data_tree(self): - self.assertEqual( - add_one(add_one(add_one(self.n1.inputs.x.value))), - self.n3.run(run_data_tree=True), - msg="Should pull start down to end, even with no flow defined" - ) - - def test_emit_ran_signal(self): - self.n1 > self.n2 > self.n3 # Chained connection declaration - - self.n1.run(emit_ran_signal=False) - self.assertFalse( - self.n3.inputs.x.ready, - msg="Without emitting the ran signal, nothing should happen downstream" - ) - - self.n1.run(emit_ran_signal=True) - self.assertEqual( - add_one(add_one(add_one(self.n1.inputs.x.value))), - self.n3.outputs.y.value, - msg="With the connection and signal, we should have pushed downstream " - "execution" - ) - def test_force_local_execution(self): self.n1.executor = True out = self.n1.run(force_local_execution=False) @@ -213,6 +196,23 @@ def test_force_local_execution(self): msg="Forcing local execution should do just that." ) + def test_emit_ran_signal(self): + self.n1 > self.n2 > self.n3 # Chained connection declaration + + self.n1.run(emit_ran_signal=False) + self.assertFalse( + self.n3.inputs.x.ready, + msg="Without emitting the ran signal, nothing should happen downstream" + ) + + self.n1.run(emit_ran_signal=True) + self.assertEqual( + add_one(add_one(add_one(self.n1.inputs.x.value))), + self.n3.outputs.y.value, + msg="With the connection and signal, we should have pushed downstream " + "execution" + ) + def test_execute(self): self.n1.outputs.y = 0 # Prime the upstream data source for fetching self.n2 > self.n3 From cb47ed2abba2741e622dc3affc0d2c64c474c8dd Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 21:32:21 -0800 Subject: [PATCH 30/41] Remove redundant topology tools Which are no longer necessary as long as macros are a walled garden and workflows are parent-most --- pyiron_workflow/node.py | 17 ----------------- tests/unit/test_macro.py | 39 --------------------------------------- 2 files changed, 56 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 77411cfc..87af0d88 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -640,23 +640,6 @@ def __gt__(self, other: InputSignal | Node): other.connect_output_signal(self.signals.output.ran) return True - def get_parent_proximate_to(self, composite: Composite) -> Composite | None: - parent = self.parent - while parent is not None and parent.parent is not composite: - parent = parent.parent - return parent - - def get_first_shared_parent(self, other: Node) -> Composite | None: - our, their = self, other - while our.parent is not None: - while their.parent is not None: - if our.parent is their.parent: - return our.parent - their = their.parent - our = our.parent - their = other - return None - def copy_io( self, other: Node, diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index 4940fc9e..a01d08fe 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -209,45 +209,6 @@ def nested_macro(macro): m = Macro(nested_macro) self.assertEqual(m(a__x=0).d__result, 8) - m2 = Macro(nested_macro) - - with self.subTest("Test Node.get_parent_proximate_to"): - self.assertIs( - m.b, - m.b.two.get_parent_proximate_to(m), - msg="Should return parent closest to the passed composite" - ) - - self.assertIsNone( - m.b.two.get_parent_proximate_to(m2), - msg="Should return None when composite is not in parentage" - ) - - with self.subTest("Test Node.get_first_shared_parent"): - self.assertIs( - m.b, - m.b.two.get_first_shared_parent(m.b.three), - msg="Should get the parent when parents are the same" - ) - self.assertIs( - m, - m.b.two.get_first_shared_parent(m.c.two), - msg="Should find first matching object in parentage" - ) - self.assertIs( - m, - m.b.two.get_first_shared_parent(m.d), - msg="Should work when depth is not equal" - ) - self.assertIsNone( - m.b.two.get_first_shared_parent(m2.b.two), - msg="Should return None when no shared parent exists" - ) - self.assertIsNone( - m.get_first_shared_parent(m.b), - msg="Should return None when parent is None" - ) - def test_with_executor(self): macro = Macro(add_three_macro) downstream = SingleValue(add_one, x=macro.outputs.three__result) From d674887d642c5d491d742056ce7001733c39e3f7 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 21:35:18 -0800 Subject: [PATCH 31/41] Make method private It should "technically" be public in that it gets accessed from outside its own instance, but it's just part of the plumbing and I don't want it popping up on users' tab-completion lists --- pyiron_workflow/channels.py | 4 ++-- pyiron_workflow/node.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index f3a35271..196a3388 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -504,7 +504,7 @@ def __call__(self) -> None: def generic_type(self) -> type[Channel]: return SignalChannel - def connect_output_signal(self, signal: OutputSignal): + def _connect_output_signal(self, signal: OutputSignal): self.connect(signal) @@ -552,5 +552,5 @@ def __str__(self): ) def __gt__(self, other: InputSignal | Node): - other.connect_output_signal(self) + other._connect_output_signal(self) return True diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 87af0d88..80084ba5 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -630,14 +630,14 @@ def __str__(self): f"{str(self.signals)}" ) - def connect_output_signal(self, signal: OutputSignal): + def _connect_output_signal(self, signal: OutputSignal): self.signals.input.run.connect(signal) def __gt__(self, other: InputSignal | Node): """ Allows users to connect run and ran signals like: `first_node > second_node`. """ - other.connect_output_signal(self.signals.output.ran) + other._connect_output_signal(self.signals.output.ran) return True def copy_io( From ab2435a404fb7319a868f98387e6e1f90c1f4a1a Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Sat, 11 Nov 2023 05:37:59 +0000 Subject: [PATCH 32/41] Format black --- pyiron_workflow/composite.py | 4 +--- pyiron_workflow/macro.py | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 83dbe714..283bd1f4 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -145,9 +145,7 @@ def outputs_map(self, new_map: dict | bidict | None): self._outputs_map = new_map @staticmethod - def _deduplicate_nones( - some_map: dict | bidict | None - ) -> dict | bidict | None: + def _deduplicate_nones(some_map: dict | bidict | None) -> dict | bidict | None: if some_map is not None: for k, v in some_map.items(): if v is None: diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index c380b0c4..ab0636c6 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -303,11 +303,7 @@ def as_node(graph_creator: callable[[Macro], None]): graph_creator.__name__.title().replace("_", ""), # fnc_name to CamelCase (Macro,), # Define parentage { - "__init__": partialmethod( - Macro.__init__, - None, - **node_class_kwargs - ), + "__init__": partialmethod(Macro.__init__, None, **node_class_kwargs), "graph_creator": staticmethod(graph_creator), }, ) From 75881f7bf9d971065b719f599f945b3adab34ae4 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 10 Nov 2023 21:52:13 -0800 Subject: [PATCH 33/41] Add a short sleep --- tests/unit/test_macro.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index a01d08fe..0a8006c5 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -238,6 +238,8 @@ def test_with_executor(self): ) returned_nodes = result.result() # Wait for the process to finish + from time import sleep + sleep(1) self.assertIsNot( original_one, returned_nodes.one, From 9a47ca9e64b4db4c691350d4caac2864aee6b4ff Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 11 Nov 2023 09:30:22 -0800 Subject: [PATCH 34/41] Update example notebook --- notebooks/workflow_example.ipynb | 343 ++++++++++++++++++------------- 1 file changed, 202 insertions(+), 141 deletions(-) diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 32414fef..4860dba5 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -5,16 +5,23 @@ "id": "5edfe456-c5b8-4347-a74f-1fb19fdff91b", "metadata": {}, "source": [ - "# Pyiron workflows: Introduction and Syntax\n", + "# Pyiron workflows: A ground-up walkthrough of syntax and features\n", "\n", - "Here we will highlight:\n", - "- How to instantiate a node\n", - "- How to make reusable node classes\n", - "- How to connect node inputs and outputs together\n", + "Contents:\n", + "- From function to node\n", + "- Making reusable node classes\n", + "- Connecting nodes to form a graph\n", "- SingleValue nodes and syntactic sugar\n", "- Workflows: keeping your computational graphs organized\n", - "- Using pre-defined nodes \n", - "- Macro nodes" + "- Node packages: making nodes re-usable\n", + "- Macro nodes: complex computations by composing sub-graphs\n", + "- Dragons and the future: remote execution, cyclic flows, and more\n", + "\n", + "To jump straight to how to use `pyiron_workflow`, go look at the quickstart guide -- this jumps straight to using `Workflow` as a single-point-of-access, creating nodes with decorators, and leveraging node packages to form complex graphs.\n", + "\n", + "Here we start from the ground up and do \"silly\" things like importing _just_ the `Function` class directly from the `pyiron_workflow` package. This isn't meant to show actual practical, recommended use-styles, but rather is indended as a pedagogical deep-dive that builds knowledge from the ground up. While the quickstart is aimed at users who just want to get running, this is intended for people who want to develop nodes for others to use, or for people who are stuck or seeing unexpected behaviour and want a better understanding of what `pyiron_workflow` is doing under the hood.\n", + "\n", + "The next recommendation is to simply read the class and method docstrings directly!" ] }, { @@ -24,7 +31,7 @@ "source": [ "## Instantiating a node\n", "\n", - "Simple nodes can be defined on-the-fly by passing any callable to the `Function(Node)` class. This transforms the function into a node instance which has input and output, can be connected to other nodes in a workflow, and can run the function it stores.\n", + "Simple nodes can be defined on-the-fly by passing any callable to a special `Node` class, `Function(Node)`, which transforms the function into a class. Instances of this node have input and output, can be connected to other nodes in a workflow, and can run the function it stores.\n", "\n", "Input and output channels are _automatically_ extracted from the signature and return value(s) of the function. (Note: \"Nodized\" functions must have _at most_ one `return` expression!)" ] @@ -238,12 +245,41 @@ "id": "58ed9b25-6dde-488d-9582-d49d405793c6", "metadata": {}, "source": [ - "This node also exploits type hinting! New values are checked against the node type hint, so trying to assign an incommensurate value will raise an error:" + "This node also exploits type hinting! Like the variable names, these hints get scraped automatically and added to the channels:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "09eee102-f8f1-4d2d-806f-01254c2483dc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(int, int, int)" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "adder_node.inputs.x.type_hint, adder_node.inputs.y.type_hint, adder_node.outputs.sum_.type_hint" + ] + }, + { + "cell_type": "markdown", + "id": "8382ba4d-9bf7-4057-8da8-0ee411d95c18", + "metadata": {}, + "source": [ + "New values are checked against the node type hint, so trying to assign an incommensurate value will raise an error:" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 14, "id": "ac0fe993-6c82-48c8-a780-cbd0c97fc386", "metadata": {}, "outputs": [], @@ -261,7 +297,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 15, "id": "a63b2cc0-9030-45ad-8d37-d11e16e61369", "metadata": {}, "outputs": [], @@ -272,7 +308,17 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 17, + "id": "3ed72790-899b-408b-bdfd-ebcf03f4c7e3", + "metadata": {}, + "outputs": [], + "source": [ + "# adder_node.failed = False # Reset if you force-failed by messing with the private value" + ] + }, + { + "cell_type": "code", + "execution_count": 18, "id": "15742a49-4c23-4d4a-84d9-9bf19677544c", "metadata": {}, "outputs": [ @@ -282,7 +328,7 @@ "3" ] }, - "execution_count": 12, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -302,7 +348,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 19, "id": "0c8f09a7-67c4-4c6c-a021-e3fea1a16576", "metadata": {}, "outputs": [ @@ -312,7 +358,7 @@ "30" ] }, - "execution_count": 13, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } @@ -332,7 +378,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 20, "id": "69b59737-9e09-4b4b-a0e2-76a09de02c08", "metadata": {}, "outputs": [ @@ -342,7 +388,7 @@ "31" ] }, - "execution_count": 14, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -356,7 +402,7 @@ "id": "f233f3f7-9576-4400-8e92-a1f6109d7f9b", "metadata": {}, "source": [ - "Note for advanced users: when the node has an executor set, running returns a futures object for the calculation, whose `.result()` will eventually be the function output." + "Note for advanced users: when the node has an executor set, running returns a futures object for the calculation, whose `.result()` will eventually be the function output. This result object can also be accessed on the node's `.result` attribute as long as it's running." ] }, { @@ -368,14 +414,14 @@ "\n", "If we're going to use a node many times, we may want to define a new sub-class of `Function` to handle this.\n", "\n", - "The can be done directly by inheriting from `Function` and overriding it's `__init__` function so that the core functionality of the node (i.e. the node function and output labels) are set in stone, but even easier is to use the `function_node` decorator to do this for you! \n", + "The can be done directly by inheriting from `Function` and overriding it's `__init__` function and/or directly defining the `node_function` property so that the core functionality of the node (i.e. the node function and output labels) are set in stone, but even easier is to use the `function_node` decorator to do this for you! \n", "\n", "The decorator also lets us explicitly choose the names of our output channels by passing the `output_labels` argument to the decorator -- as a string to create a single channel for the returned values, or as a list of strings equal to the number of returned values in a returned tuple." ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 21, "id": "61b43a9b-8dad-48b7-9194-2045e465793b", "metadata": {}, "outputs": [], @@ -385,7 +431,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 22, "id": "647360a9-c971-4272-995c-aa01e5f5bb83", "metadata": {}, "outputs": [ @@ -422,7 +468,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 23, "id": "b8c845b7-7088-43d7-b106-7a6ba1c571ec", "metadata": {}, "outputs": [ @@ -455,11 +501,11 @@ "\n", "Multiple nodes can be used together to build a computational graph, with each node performing a particular operation in the overall workflow.\n", "\n", - "The input and output of nodes can be chained together by connecting their data channels.\n", + "The input and output of nodes can be chained together by connecting their data channels to form a data graph.\n", "\n", - "The flow of execution can be manually configured by using other \"signal\" channels. However, for acyclic graphs (DAGs), execution flow can be automatically determined from the topology of the data connections.\n", + "The flow of execution can be manually configured by using other \"signal\" channels to form an execution graph. However, for data graphs that are a directed acyclic graph (DAG), the execution flow can be automatically determined from the topology of the data connections!\n", "\n", - "The `run` command we saw above has several boolean flags for controlling the style of execution. The two main run modes are with a \"pull\" paradigm, where everything upstream is run first then the node invoking `pull` gets run; and with a \"push\" paradigm (the default for `run`), where the node invoking `run` gets run and then runs everything downstream. Calling an instantiated node runs a particularly aggressive version of `pull`.\n", + "The `run` command we saw above has several boolean flags for controlling the style of execution. The two main run modes are with a \"pull\" paradigm, where everything upstream on the graph of data connections is run first then the node invoking `pull` gets run; and with a \"push\" paradigm (the default for `run`), where the node invoking `run` gets run and then runs everything downstream on the execution graph. Calling an instantiated node runs a particularly aggressive version of `pull`.\n", "\n", "We'll talk more about grouping nodes together inside a `Workflow` object, but without a parent workflow, only the `pull` method will automate execution signals; trying to push data downstream using `run` requires specifying the execution flow manually.\n", "\n", @@ -468,7 +514,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 24, "id": "2e418abf-7059-4e1e-9b9f-b3dc0a4b5e35", "metadata": { "tags": [] @@ -490,7 +536,7 @@ "2" ] }, - "execution_count": 18, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } @@ -524,7 +570,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 25, "id": "f3b0b700-683e-43cb-b374-48735e413bc9", "metadata": {}, "outputs": [ @@ -534,7 +580,7 @@ "4" ] }, - "execution_count": 19, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -560,7 +606,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 26, "id": "59c29856-c77e-48a1-9f17-15d4c58be588", "metadata": {}, "outputs": [ @@ -596,7 +642,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 27, "id": "1a4e9693-0980-4435-aecc-3331d8b608dd", "metadata": {}, "outputs": [], @@ -608,7 +654,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 28, "id": "7c4d314b-33bb-4a67-bfb9-ed77fba3949c", "metadata": {}, "outputs": [ @@ -647,7 +693,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 29, "id": "61ae572f-197b-4a60-8d3e-e19c1b9cc6e2", "metadata": {}, "outputs": [ @@ -657,7 +703,7 @@ "4" ] }, - "execution_count": 23, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } @@ -688,7 +734,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 30, "id": "6569014a-815b-46dd-8b47-4e1cd4584b3b", "metadata": {}, "outputs": [ @@ -702,7 +748,7 @@ }, { "data": { - "image/png": "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BBcAAGANggsAALAGwQUAAFiD4AIAAKxBcAEAANYguAAAAGsQXAAAgDWCCi5lZWVKTU2V2+1WZmamampqfrD/+fPntWHDBk2ePFkJCQmaOnWqtm3bFlTBAAAgesU53aGiokKrV69WWVmZ5syZo5deekkLFy5UQ0ODfvrTn/a7z5IlS/TFF19o69at+tnPfqaWlhZdvHjxiosHAADRxWWMMU52mD17tjIyMlReXt7bNmPGDC1evFilpaV9+ldVVemee+7RyZMnNWbMmKCKbG9vl8fjkd/vV2JiYlBfAwAADK9QXL8dTRVduHBBtbW1ysvLC2jPy8vTwYMH+91n165dysrK0rPPPquJEydq+vTpWrt2rb799tsB3+f8+fNqb28P2AAAABxNFbW2tqqrq0tJSUkB7UlJSWpubu53n5MnT2r//v1yu93auXOnWltb9cgjj+jcuXMD3udSWlqqkpISJ6UBAIAoENTNuS6XK+C1MaZPW4/u7m65XC7t2LFD2dnZWrRokTZv3qzXX399wFGX9evXy+/3926nTp0KpkwAABBhHI24jBs3TrGxsX1GV1paWvqMwvTwer2aOHGiPB5Pb9uMGTNkjNHp06c1bdq0PvskJCQoISHBSWkAACAKOBpxiY+PV2ZmpqqrqwPaq6urlZub2+8+c+bM0dmzZ/XVV1/1tn366aeKiYnRpEmTgigZAABEK8dTRUVFRXr11Ve1bds2HT9+XGvWrFFTU5MKCwslXZrmWb58eW//e++9V2PHjtWKFSvU0NCgffv26YknntCvf/1rjRw5cug+CTBIXd1Gh0606e/1Z3ToRJu6uh09WAcACCPH67gUFBSora1NmzZtks/nU3p6uiorKzV58mRJks/nU1NTU2//n/zkJ6qurtZvfvMbZWVlaezYsVqyZImefvrpofsUwCBVHfOpZHeDfP7O3javx63i/DQtSPeGsTIAwGA4XsclHFjHBUOh6phPD28/qsv/w/fcVl6+NIPwAgBDKOzruAC26uo2Ktnd0Ce0SOptK9ndwLQRAFzlCC6ICocbzwVMD13OSPL5O3W48dzwFQUAcIzggqjQ0jFwaAmmHwAgPAguiArjR7uHtB8AIDwILogK2alj5PW41f/6zpdu0PV63MpODe4PgQIAhgfBBVEhNsal4vw0SeoTXnpeF+enKTZmoGgDALgaEFwQNRak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", + "image/png": "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", "text/plain": [ "
" ] @@ -757,7 +803,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 31, "id": "1cd000bd-9b24-4c39-9cac-70a3291d0660", "metadata": {}, "outputs": [], @@ -784,7 +830,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 32, "id": "7964df3c-55af-4c25-afc5-9e07accb606a", "metadata": {}, "outputs": [ @@ -804,7 +850,8 @@ "n1 = greater_than_half(label=\"n1\")\n", "\n", "wf = Workflow(\"my_wf\", n1) # As args at init\n", - "wf.create.SingleValue(n1.node_function, output_labels=\"p1\", label=\"n2\") # Instantiating from the class with a function\n", + "wf.create.SingleValue(n1.node_function, output_labels=\"p1\", label=\"n2\") \n", + "# ^ Instantiating from the class with a function\n", "wf.add(greater_than_half(label=\"n3\")) # Instantiating then passing to node adder\n", "wf.n4 = greater_than_half(label=\"will_get_overwritten_with_n4\") # Set attribute to instance\n", "greater_than_half(label=\"n5\", parent=wf) # By passing the workflow to the node\n", @@ -825,7 +872,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 34, "id": "809178a5-2e6b-471d-89ef-0797db47c5ad", "metadata": {}, "outputs": [ @@ -879,7 +926,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 35, "id": "52c48d19-10a2-4c48-ae81-eceea4129a60", "metadata": {}, "outputs": [ @@ -889,7 +936,7 @@ "{'ay': 3, 'a + b + 2': 7}" ] }, - "execution_count": 28, + "execution_count": 35, "metadata": {}, "output_type": "execute_result" } @@ -912,12 +959,12 @@ "id": "e3f4b51b-7c28-47f7-9822-b4755e12bd4d", "metadata": {}, "source": [ - "We can see now why we've been trying to givesuccinct string labels to our `Function` node outputs instead of just arbitrary expressions! The expressions are typically not dot-accessible:" + "We can see now why we've been trying to give succinct string labels to our `Function` node outputs instead of just arbitrary expressions! The expressions are typically not dot-accessible:" ] }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 36, "id": "bb35ba3e-602d-4c9c-b046-32da9401dd1c", "metadata": {}, "outputs": [ @@ -927,7 +974,7 @@ "(7, 3)" ] }, - "execution_count": 29, + "execution_count": 36, "metadata": {}, "output_type": "execute_result" } @@ -946,7 +993,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 37, "id": "2b0d2c85-9049-417b-8739-8a8432a1efbe", "metadata": {}, "outputs": [ @@ -965,127 +1012,127 @@ "clustersimple\n", "\n", "simple: Workflow\n", - "\n", - "clustersimplesum\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "sum: AddNode\n", - "\n", - "\n", - "clustersimplesumInputs\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "Inputs\n", - "\n", - "\n", - "clustersimplesumOutputs\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "Outputs\n", - "\n", "\n", "clustersimpleInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Inputs\n", "\n", "\n", "clustersimpleOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Outputs\n", "\n", "\n", "clustersimplea\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "a: AddOne\n", "\n", - "\n", - "clustersimpleaInputs\n", + "\n", + "clustersimpleaOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Outputs\n", "\n", - "\n", - "clustersimpleaOutputs\n", + "\n", + "clustersimpleaInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Inputs\n", "\n", "\n", "clustersimpleb\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "b: AddOne\n", "\n", "\n", "clustersimplebInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Inputs\n", "\n", "\n", "clustersimplebOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Outputs\n", "\n", + "\n", + "clustersimplesum\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "sum: AddNode\n", + "\n", + "\n", + "clustersimplesumInputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Inputs\n", + "\n", + "\n", + "clustersimplesumOutputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Outputs\n", + "\n", "\n", "\n", "clustersimpleInputsrun\n", @@ -1264,10 +1311,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 30, + "execution_count": 37, "metadata": {}, "output_type": "execute_result" } @@ -1294,14 +1341,14 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 38, "id": "ae500d5e-e55b-432c-8b5f-d5892193cdf5", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "9fad22dbcc8940cbaa936a48e84d054c", + "model_id": "02ee15d39f8741a6a901417d9b9a26b9", "version_major": 2, "version_minor": 0 }, @@ -1320,10 +1367,10 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 31, + "execution_count": 38, "metadata": {}, "output_type": "execute_result" }, @@ -1366,7 +1413,7 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 39, "id": "2114d0c3-cdad-43c7-9ffa-50c36d56d18f", "metadata": {}, "outputs": [ @@ -1574,10 +1621,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 32, + "execution_count": 39, "metadata": {}, "output_type": "execute_result" } @@ -1594,6 +1641,14 @@ "Note: the `draw` call returns a `graphviz.graphs.Digraphs` object; these get natively rendered alright in jupyter notebooks, as seen above, but you can also snag the object in a variable and do everything else graphviz allows, e.g. using the `render` method on the object to save it to file. Cf. the graphviz docs for details." ] }, + { + "cell_type": "markdown", + "id": "7a4e235d-905f-4763-a1ff-d0c3e24c591c", + "metadata": {}, + "source": [ + "Workflows are \"living\" objects -- their IO is (re)created on access, so it is always up-to-date with the latest state of the workflow's children (who's there and who they're connected to), and (unless you explicitly tell it otherwise) a workflow will always re-compute the execution flow at run-time. This makes them incredibly convenient for working with as you put together a new computational graph, but is not particularly computationally efficeint." + ] + }, { "cell_type": "markdown", "id": "d1f3b308-28b2-466b-8cf5-6bfd806c08ca", @@ -1601,12 +1656,12 @@ "source": [ "# Macros\n", "\n", - "Once you have a workflow that you're happy with, you may want to store it as a macro so it can be stored in a human-readable way, reused, and shared. Automated conversion of an existing `Workflow` instance into a `Macro` subclass is still on the TODO list, but defining a new macro is pretty easy: they are just composite nodes that have a function defining their graph setup:" + "Once you have a workflow that you're happy with, you may want to store it as a macro so it can be stored in a human-readable way, reused, shared, and executed with more efficiency than the \"living\" `Workflow` instance. Automated conversion of an existing `Workflow` instance into a `Macro` subclass is still on the TODO list, but defining a new macro is pretty easy: they are just composite nodes that have a function defining their graph setup:" ] }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 40, "id": "c71a8308-f8a1-4041-bea0-1c841e072a6d", "metadata": {}, "outputs": [], @@ -1616,7 +1671,7 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 41, "id": "2b9bb21a-73cd-444e-84a9-100e202aa422", "metadata": {}, "outputs": [ @@ -1634,7 +1689,7 @@ "13" ] }, - "execution_count": 34, + "execution_count": 41, "metadata": {}, "output_type": "execute_result" } @@ -1663,6 +1718,14 @@ "macro(add_one__x=10).add_three__result" ] }, + { + "cell_type": "markdown", + "id": "d4f797d6-8d88-415f-bb9c-00f3e1b15e37", + "metadata": {}, + "source": [ + "Even in the abscence of an automated converter, it should be easy to take the workflow you've been developing and copy-paste that code into a function -- then bam, you've got a macro!" + ] + }, { "cell_type": "markdown", "id": "bd5099c4-1c01-4a45-a5bb-e5087595db9f", @@ -1673,7 +1736,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 42, "id": "3668f9a9-adca-48a4-84ea-13add965897c", "metadata": {}, "outputs": [ @@ -1683,7 +1746,7 @@ "{'intermediate': 102, 'plus_three': 103}" ] }, - "execution_count": 35, + "execution_count": 42, "metadata": {}, "output_type": "execute_result" } @@ -1714,14 +1777,14 @@ "source": [ "## Nesting\n", "\n", - "Composite nodes can be nested to abstract workflows into simpler components -- i.e. macros can be added to workflows, and macros can be used inside of macros.\n", + "Composite nodes can be nested to abstract workflows into simpler components -- i.e. macros can be added to workflows, and macros can be used inside of macros. This is a critically important feature because it allows us to easily create more and more complex workflows by \"composing\" simple(r) sub-graphs together!\n", "\n", "For our final example, let's define a macro for doing Lammps minimizations, then use this in a workflow to compare energies between different phases." ] }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 43, "id": "9aaeeec0-5f88-4c94-a6cc-45b56d2f0111", "metadata": {}, "outputs": [], @@ -1751,7 +1814,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 44, "id": "a832e552-b3cc-411a-a258-ef21574fc439", "metadata": {}, "outputs": [], @@ -1778,7 +1841,7 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 45, "id": "b764a447-236f-4cb7-952a-7cba4855087d", "metadata": {}, "outputs": [ @@ -3002,10 +3065,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 38, + "execution_count": 45, "metadata": {}, "output_type": "execute_result" } @@ -3016,7 +3079,7 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 46, "id": "b51bef25-86c5-4d57-80c1-ab733e703caf", "metadata": {}, "outputs": [ @@ -3037,7 +3100,7 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 47, "id": "091e2386-0081-436c-a736-23d019bd9b91", "metadata": {}, "outputs": [ @@ -3078,7 +3141,7 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 48, "id": "4cdffdca-48d3-4486-9045-48102c7e5f31", "metadata": {}, "outputs": [ @@ -3116,7 +3179,7 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 49, "id": "ed4a3a22-fc3a-44c9-9d4f-c65bc1288889", "metadata": {}, "outputs": [ @@ -3146,7 +3209,7 @@ }, { "cell_type": "code", - "execution_count": 43, + "execution_count": 50, "id": "5a985cbf-c308-4369-9223-b8a37edb8ab1", "metadata": {}, "outputs": [ @@ -3207,7 +3270,9 @@ "source": [ "## Parallelization\n", "\n", - "You can currently run nodes in a single-core background process by setting that node's `executor` to `True`. The plan is to eventually lean on `pympipool` for more powerful executors that allow for multiple cores, interaction with HPC clusters, etc. We may also leverage the `Submitter` in `pyiron_contrib.tinybase` so that multiple nodes can lean on the same resources.\n", + "You can currently run nodes in a single-core background process by setting that node's `executor` to `True`. If you're interested you can dig into the test suite to see a number of examples for this.\n", + "\n", + "The much more powerful executors in pyiron's `pympipool` appear to also be working, which should allow for graph nodes to run using multiple cores, interact with HPC clusters, etc. We may also leverage the `Submitter` in `pyiron_contrib.tinybase` so that multiple nodes can lean on the same resources.\n", "\n", "Unfortunately, _nested_ executors are not yet working. So if you set a macro to use an executor, none of its (grand...)children may specify an executor.\n", "\n", @@ -3253,7 +3318,7 @@ }, { "cell_type": "code", - "execution_count": 44, + "execution_count": 51, "id": "0b373764-b389-4c24-8086-f3d33a4f7fd7", "metadata": {}, "outputs": [ @@ -3267,7 +3332,7 @@ " 17.230249999999995]" ] }, - "execution_count": 44, + "execution_count": 51, "metadata": {}, "output_type": "execute_result" } @@ -3304,7 +3369,7 @@ }, { "cell_type": "code", - "execution_count": 45, + "execution_count": 52, "id": "0dd04b4c-e3e7-4072-ad34-58f2c1e4f596", "metadata": {}, "outputs": [ @@ -3363,7 +3428,7 @@ }, { "cell_type": "code", - "execution_count": 46, + "execution_count": 53, "id": "2dfb967b-41ac-4463-b606-3e315e617f2a", "metadata": {}, "outputs": [ @@ -3387,7 +3452,7 @@ }, { "cell_type": "code", - "execution_count": 47, + "execution_count": 54, "id": "2e87f858-b327-4f6b-9237-c8a557f29aeb", "metadata": {}, "outputs": [ @@ -3395,14 +3460,10 @@ "name": "stdout", "output_type": "stream", "text": [ - "0.460 > 0.2\n", - "0.990 > 0.2\n", - "0.321 > 0.2\n", - "0.663 > 0.2\n", - "0.231 > 0.2\n", - "0.695 > 0.2\n", - "0.122 <= 0.2\n", - "Finally 0.122\n" + "0.242 > 0.2\n", + "0.470 > 0.2\n", + "0.011 <= 0.2\n", + "Finally 0.011\n" ] } ], From 724bce77852ae062979803be85367b3013a069da Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 11 Nov 2023 09:39:34 -0800 Subject: [PATCH 35/41] Rename and re-run the example notebook --- ...{workflow_example.ipynb => deepdive.ipynb} | 260 +++++++++--------- 1 file changed, 131 insertions(+), 129 deletions(-) rename notebooks/{workflow_example.ipynb => deepdive.ipynb} (93%) diff --git a/notebooks/workflow_example.ipynb b/notebooks/deepdive.ipynb similarity index 93% rename from notebooks/workflow_example.ipynb rename to notebooks/deepdive.ipynb index 4860dba5..d3549fe2 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/deepdive.ipynb @@ -250,7 +250,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 10, "id": "09eee102-f8f1-4d2d-806f-01254c2483dc", "metadata": {}, "outputs": [ @@ -260,7 +260,7 @@ "(int, int, int)" ] }, - "execution_count": 13, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -279,7 +279,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 11, "id": "ac0fe993-6c82-48c8-a780-cbd0c97fc386", "metadata": {}, "outputs": [], @@ -297,7 +297,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 12, "id": "a63b2cc0-9030-45ad-8d37-d11e16e61369", "metadata": {}, "outputs": [], @@ -308,7 +308,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 13, "id": "3ed72790-899b-408b-bdfd-ebcf03f4c7e3", "metadata": {}, "outputs": [], @@ -318,7 +318,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 14, "id": "15742a49-4c23-4d4a-84d9-9bf19677544c", "metadata": {}, "outputs": [ @@ -328,7 +328,7 @@ "3" ] }, - "execution_count": 18, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } @@ -348,7 +348,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 15, "id": "0c8f09a7-67c4-4c6c-a021-e3fea1a16576", "metadata": {}, "outputs": [ @@ -358,7 +358,7 @@ "30" ] }, - "execution_count": 19, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -378,7 +378,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 16, "id": "69b59737-9e09-4b4b-a0e2-76a09de02c08", "metadata": {}, "outputs": [ @@ -388,7 +388,7 @@ "31" ] }, - "execution_count": 20, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -421,7 +421,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 17, "id": "61b43a9b-8dad-48b7-9194-2045e465793b", "metadata": {}, "outputs": [], @@ -431,7 +431,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 18, "id": "647360a9-c971-4272-995c-aa01e5f5bb83", "metadata": {}, "outputs": [ @@ -468,7 +468,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 19, "id": "b8c845b7-7088-43d7-b106-7a6ba1c571ec", "metadata": {}, "outputs": [ @@ -514,7 +514,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 20, "id": "2e418abf-7059-4e1e-9b9f-b3dc0a4b5e35", "metadata": { "tags": [] @@ -524,8 +524,6 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel ran was not connected to run, andthus could not disconnect from it.\n", - " warn(\n", "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", " warn(\n" ] @@ -536,7 +534,7 @@ "2" ] }, - "execution_count": 24, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -570,7 +568,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 21, "id": "f3b0b700-683e-43cb-b374-48735e413bc9", "metadata": {}, "outputs": [ @@ -580,7 +578,7 @@ "4" ] }, - "execution_count": 25, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } @@ -606,7 +604,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 22, "id": "59c29856-c77e-48a1-9f17-15d4c58be588", "metadata": {}, "outputs": [ @@ -642,7 +640,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 23, "id": "1a4e9693-0980-4435-aecc-3331d8b608dd", "metadata": {}, "outputs": [], @@ -654,7 +652,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 24, "id": "7c4d314b-33bb-4a67-bfb9-ed77fba3949c", "metadata": {}, "outputs": [ @@ -693,7 +691,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 25, "id": "61ae572f-197b-4a60-8d3e-e19c1b9cc6e2", "metadata": {}, "outputs": [ @@ -703,7 +701,7 @@ "4" ] }, - "execution_count": 29, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -734,7 +732,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 26, "id": "6569014a-815b-46dd-8b47-4e1cd4584b3b", "metadata": {}, "outputs": [ @@ -748,7 +746,7 @@ }, { "data": { - "image/png": "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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGdCAYAAADAAnMpAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAn/UlEQVR4nO3df1Tb133/8ZcQBrme0Q52DEqMqeIlDpg1CeJAwPOyNTG100O/Pt0W2sx20tpbcJM2mJPua+adEnx6Dm2Xek62QOLETo7nH2Ork6w+ozQ6Z62DQzYfY3xOPbIli+kgtggDTiXSFqjF5/uHD3ytCBw+QuYi8Xyc8znHurpXeuvc46MX9/P5XDksy7IEAABgSIrpAgAAwMJGGAEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgVKrpAmZifHxcly9f1tKlS+VwOEyXAwAAZsCyLA0PD+vmm29WSsr06x8JEUYuX76snJwc02UAAIAY9Pb2auXKldM+nxBhZOnSpZKufpiMjAzD1QAAgJkIhULKycmZ/B6fTkKEkYlTMxkZGYQRAAASzCddYsEFrAAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjEmLTsxslPG7pTPeQ+odHtGKpS8XeTDlT+O0bAADm0oINI60XAqo/2aVAcGSyzeN2qa4iXxsLPAYrAwBgYVmQp2laLwS088i5iCAiSX3BEe08ck6tFwKGKgMAYOFZcGEkPG6p/mSXrCmem2irP9ml8PhUPQAAQLwtuDBypnsoakXkWpakQHBEZ7qH5q4oAAAWsAUXRvqHpw8isfQDAACzs+DCyIqlrrj2AwAAsxNTGGlsbJTX65XL5ZLP51NbW9u0fR955BE5HI6oY+3atTEXPRvF3kx53C5NdwOvQ1fvqin2Zs5lWbMSHrf09vuD+ufzl/T2+4Nc7wIASCi2b+1tbm5WdXW1GhsbtW7dOr3wwgvatGmTurq6tGrVqqj+zzzzjL7zne9MPr5y5YruvPNO/cmf/MnsKo+RM8Whuop87TxyTg4p4kLWiYBSV5GfMPuNcIsyACDROSzLsvVndElJiQoLC9XU1DTZlpeXp82bN6uhoeETx7/++uv64he/qO7ubuXm5s7oPUOhkNxut4LBoDIyMuyUO61k+BKfuEX54xM4EaOathQmzGcBACSfmX5/21oZGRsbU0dHh3bv3h3RXl5ervb29hm9xsGDB3X//fdfN4iMjo5qdHR08nEoFLJT5oxsLPBoQ352wu7A+km3KDt09RblDfnZCfOZAAALk60wMjAwoHA4rKysrIj2rKws9fX1feL4QCCgH/3oRzp27Nh1+zU0NKi+vt5OaTFxpjhUunrZDX+fG8HOLcqJ+hkBAAtDTBewOhyRf2lblhXVNpVXXnlFv/3bv63Nmzdft19tba2CweDk0dvbG0uZSY1blAEAycLWysjy5cvldDqjVkH6+/ujVks+zrIsHTp0SFu3blVaWtp1+6anpys9Pd1OaQsOtygDAJKFrZWRtLQ0+Xw++f3+iHa/36+ysrLrjj116pT++7//W9u3b7dfJaIk4y3KAICFyfZpmpqaGr300ks6dOiQ3nnnHe3atUs9PT2qqqqSdPUUy7Zt26LGHTx4UCUlJSooKJh91Zi8RVlSVCBJxFuUAQALl+19RiorKzU4OKi9e/cqEAiooKBALS0tk3fHBAIB9fT0RIwJBoM6ceKEnnnmmfhUDUlX7whq2lIYdYtydoLdogwAWNhs7zNiwo3YZySZhMethL1FGQCQvG7IPiOYnxL5FmUAABbcD+UBAID5hTACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAo2IKI42NjfJ6vXK5XPL5fGpra7tu/9HRUe3Zs0e5ublKT0/X6tWrdejQoZgKBgAAySXV7oDm5mZVV1ersbFR69at0wsvvKBNmzapq6tLq1atmnLMgw8+qA8//FAHDx7U7/zO76i/v19XrlyZdfEAACDxOSzLsuwMKCkpUWFhoZqamibb8vLytHnzZjU0NET1b21t1Ze+9CVdvHhRmZmZMRUZCoXkdrsVDAaVkZER02sAAIC5NdPvb1unacbGxtTR0aHy8vKI9vLycrW3t0855oc//KGKior0ve99T7fccotuv/12Pfnkk/r1r39t560BAECSsnWaZmBgQOFwWFlZWRHtWVlZ6uvrm3LMxYsXdfr0ablcLr322msaGBjQ1772NQ0NDU173cjo6KhGR0cnH4dCITtlAgCABBLTBawOhyPisWVZUW0TxsfH5XA4dPToURUXF+uBBx7Qvn379Morr0y7OtLQ0CC32z155OTkxFImAABIALbCyPLly+V0OqNWQfr7+6NWSyZ4PB7dcsstcrvdk215eXmyLEsffPDBlGNqa2sVDAYnj97eXjtlAgCABGIrjKSlpcnn88nv90e0+/1+lZWVTTlm3bp1unz5sj766KPJtnfffVcpKSlauXLllGPS09OVkZERcQAAgORk+zRNTU2NXnrpJR06dEjvvPOOdu3apZ6eHlVVVUm6uqqxbdu2yf4PPfSQli1bpq985Svq6urSm2++qW9+85v66le/qsWLF8fvkwAAgIRke5+RyspKDQ4Oau/evQoEAiooKFBLS4tyc3MlSYFAQD09PZP9f+u3fkt+v19f//rXVVRUpGXLlunBBx/Ut7/97fh9CgAAkLBs7zNiAvuMAACQeG7IPiMAAADxRhgBAABGEUYAAIBRhBEAAGAUYQQAABhFGAEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgFGEEAAAYRRgBAABGEUYAAIBRhBEAAGAUYQQAABhFGAEAAEYRRgAAgFGppgtAYgiPWzrTPaT+4RGtWOpSsTdTzhSH6bIAAEmAMIJP1HohoPqTXQoERybbPG6X6irytbHAY7AyAEAy4DQNrqv1QkA7j5yLCCKS1Bcc0c4j59R6IWCoMgBAsiCMYFrhcUv1J7tkTfHcRFv9yS6Fx6fqAQDAzBBGMK0z3UNRKyLXsiQFgiM60z00d0UBAJIOYQTT6h+ePojE0g8AgKkQRjCtFUtdce0HAMBUCCOYVrE3Ux63S9PdwOvQ1btqir2Zc1kWACDJEEYwLWeKQ3UV+ZIUFUgmHtdV5LPfCABgVggjuK6NBR41bSlUtjvyVEy226WmLYXsMwIAmDU2PcMn2ljg0Yb8bHZgBQDcEIQRzIgzxaHS1ctMlwEASEKcpgEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUTGFkcbGRnm9XrlcLvl8PrW1tU3b96c//akcDkfU8Z//+Z8xFw0AAJKH7TDS3Nys6upq7dmzR52dnVq/fr02bdqknp6e6477r//6LwUCgcnjtttui7loAACQPGyHkX379mn79u3asWOH8vLytH//fuXk5Kipqem641asWKHs7OzJw+l0xlw0AABIHrbCyNjYmDo6OlReXh7RXl5ervb29uuOvfvuu+XxeHTffffpJz/5yXX7jo6OKhQKRRwAACA52QojAwMDCofDysrKimjPyspSX1/flGM8Ho8OHDigEydO6NVXX9WaNWt033336c0335z2fRoaGuR2uyePnJwcO2UCAIAEEtNv0zgckT+QZllWVNuENWvWaM2aNZOPS0tL1dvbq6efflq///u/P+WY2tpa1dTUTD4OhUIEEgAAkpStlZHly5fL6XRGrYL09/dHrZZczz333KP33ntv2ufT09OVkZERcQAAgORkK4ykpaXJ5/PJ7/dHtPv9fpWVlc34dTo7O+XxeOy8NQAASFK2T9PU1NRo69atKioqUmlpqQ4cOKCenh5VVVVJunqK5dKlSzp8+LAkaf/+/fr0pz+ttWvXamxsTEeOHNGJEyd04sSJ+H4SAACQkGyHkcrKSg0ODmrv3r0KBAIqKChQS0uLcnNzJUmBQCBiz5GxsTE9+eSTunTpkhYvXqy1a9fqX/7lX/TAAw/E71MAAICE5bAsyzJdxCcJhUJyu90KBoNcPwIAQIKY6fc3v00DAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKNSTRcAAMB8FR63dKZ7SP3DI1qx1KVib6acKQ7TZSUdwggAAFNovRBQ/ckuBYIjk20et0t1FfnaWOAxWFny4TQNAAAf03ohoJ1HzkUEEUnqC45o55Fzar0QMFRZciKMAABwjfC4pfqTXbKmeG6irf5kl8LjU/VALAgjAABc40z3UNSKyLUsSYHgiM50D81dUUmOMAIAwDX6h6cPIrH0wycjjAAAcI0VS11x7YdPRhgBAOAaxd5MedwuTXcDr0NX76op9mbOZVlJjTACAMA1nCkO1VXkS1JUIJl4XFeRz34jcUQYAQDgYzYWeNS0pVDZ7shTMdlul5q2FLLPSJyx6RkAAFPYWODR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v/rVr5SSEvm15HQ6Jf3/v8Yxt0pLS6Pm8I033lBRUZEWLVoUvzeK6+WwiKuJ2+IOHjxodXV1WdXV1daSJUusn//855ZlWdbu3butrVu3TvY/duyYlZqaaj333HNWIBCYPH7xi1+Y+ghJx+6cfBx309wYdudleHjYWrlypfXHf/zH1n/8x39Yp06dsm677TZrx44dpj5CUrI7Ly+//LKVmppqNTY2Wu+//751+vRpq6ioyCouLjb1EZLO8PCw1dnZaXV2dlqSrH379lmdnZ2Tt1t/fE4mbu3dtWuX1dXVZR08eJBbexei5557zsrNzbXS0tKswsJC69SpU5PPPfzww9a99947+fjee++1JEUdDz/88NwXnsTszMnHEUZuHLvz8s4771j333+/tXjxYmvlypVWTU2N9atf/WqOq05+dufl2WeftfLz863FixdbHo/H+tM//VPrgw8+mOOqk9dPfvKT635PTDUnP/3pT627777bSktLsz796U9bTU1Nca/LYVmsfQEAAHO4ZgQAABhFGAEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgFGEEAAAYRRgBAABGEUYAAIBRhBEAAGDU/wMHxRPbcikYmQAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -803,7 +801,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 27, "id": "1cd000bd-9b24-4c39-9cac-70a3291d0660", "metadata": {}, "outputs": [], @@ -830,7 +828,7 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 28, "id": "7964df3c-55af-4c25-afc5-9e07accb606a", "metadata": {}, "outputs": [ @@ -872,7 +870,7 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 29, "id": "809178a5-2e6b-471d-89ef-0797db47c5ad", "metadata": {}, "outputs": [ @@ -926,7 +924,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 30, "id": "52c48d19-10a2-4c48-ae81-eceea4129a60", "metadata": {}, "outputs": [ @@ -936,7 +934,7 @@ "{'ay': 3, 'a + b + 2': 7}" ] }, - "execution_count": 35, + "execution_count": 30, "metadata": {}, "output_type": "execute_result" } @@ -964,7 +962,7 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 31, "id": "bb35ba3e-602d-4c9c-b046-32da9401dd1c", "metadata": {}, "outputs": [ @@ -974,7 +972,7 @@ "(7, 3)" ] }, - "execution_count": 36, + "execution_count": 31, "metadata": {}, "output_type": "execute_result" } @@ -993,7 +991,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 32, "id": "2b0d2c85-9049-417b-8739-8a8432a1efbe", "metadata": {}, "outputs": [ @@ -1012,126 +1010,126 @@ "clustersimple\n", "\n", "simple: Workflow\n", - "\n", - "clustersimpleInputs\n", + "\n", + "clustersimpleb\n", "\n", - "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "b: AddOne\n", + "\n", + "\n", + "clustersimplebInputs\n", + "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", - "\n", - "clustersimpleOutputs\n", + "\n", + "clustersimplebOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", - "\n", - "clustersimplea\n", + "\n", + "clustersimplesum\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "a: AddOne\n", + "\n", + "sum: AddNode\n", "\n", - "\n", - "clustersimpleaOutputs\n", + "\n", + "clustersimplesumInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Inputs\n", "\n", - "\n", - "clustersimpleaInputs\n", + "\n", + "clustersimplesumOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", - "\n", - "\n", - "clustersimpleb\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "b: AddOne\n", + "\n", + "Outputs\n", "\n", - "\n", - "clustersimplebInputs\n", + "\n", + "clustersimpleInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", - "\n", - "clustersimplebOutputs\n", + "\n", + "clustersimpleOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", - "\n", - "clustersimplesum\n", + "\n", + "clustersimplea\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "sum: AddNode\n", + "\n", + "a: AddOne\n", "\n", - "\n", - "clustersimplesumInputs\n", + "\n", + "clustersimpleaInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", - "\n", - "clustersimplesumOutputs\n", + "\n", + "clustersimpleaOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "\n", @@ -1311,10 +1309,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 37, + "execution_count": 32, "metadata": {}, "output_type": "execute_result" } @@ -1341,14 +1339,14 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 33, "id": "ae500d5e-e55b-432c-8b5f-d5892193cdf5", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "02ee15d39f8741a6a901417d9b9a26b9", + "model_id": "55f7b5a7a3704dd98ddfe767bd36d833", "version_major": 2, "version_minor": 0 }, @@ -1367,10 +1365,10 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 38, + "execution_count": 33, "metadata": {}, "output_type": "execute_result" }, @@ -1413,7 +1411,7 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 34, "id": "2114d0c3-cdad-43c7-9ffa-50c36d56d18f", "metadata": {}, "outputs": [ @@ -1621,10 +1619,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 39, + "execution_count": 34, "metadata": {}, "output_type": "execute_result" } @@ -1661,7 +1659,7 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 35, "id": "c71a8308-f8a1-4041-bea0-1c841e072a6d", "metadata": {}, "outputs": [], @@ -1671,7 +1669,7 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 36, "id": "2b9bb21a-73cd-444e-84a9-100e202aa422", "metadata": {}, "outputs": [ @@ -1689,7 +1687,7 @@ "13" ] }, - "execution_count": 41, + "execution_count": 36, "metadata": {}, "output_type": "execute_result" } @@ -1736,7 +1734,7 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 37, "id": "3668f9a9-adca-48a4-84ea-13add965897c", "metadata": {}, "outputs": [ @@ -1746,7 +1744,7 @@ "{'intermediate': 102, 'plus_three': 103}" ] }, - "execution_count": 42, + "execution_count": 37, "metadata": {}, "output_type": "execute_result" } @@ -1784,7 +1782,7 @@ }, { "cell_type": "code", - "execution_count": 43, + "execution_count": 38, "id": "9aaeeec0-5f88-4c94-a6cc-45b56d2f0111", "metadata": {}, "outputs": [], @@ -1814,7 +1812,7 @@ }, { "cell_type": "code", - "execution_count": 44, + "execution_count": 39, "id": "a832e552-b3cc-411a-a258-ef21574fc439", "metadata": {}, "outputs": [], @@ -1841,7 +1839,7 @@ }, { "cell_type": "code", - "execution_count": 45, + "execution_count": 40, "id": "b764a447-236f-4cb7-952a-7cba4855087d", "metadata": {}, "outputs": [ @@ -3065,10 +3063,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 45, + "execution_count": 40, "metadata": {}, "output_type": "execute_result" } @@ -3079,7 +3077,7 @@ }, { "cell_type": "code", - "execution_count": 46, + "execution_count": 41, "id": "b51bef25-86c5-4d57-80c1-ab733e703caf", "metadata": {}, "outputs": [ @@ -3100,7 +3098,7 @@ }, { "cell_type": "code", - "execution_count": 47, + "execution_count": 42, "id": "091e2386-0081-436c-a736-23d019bd9b91", "metadata": {}, "outputs": [ @@ -3141,7 +3139,7 @@ }, { "cell_type": "code", - "execution_count": 48, + "execution_count": 43, "id": "4cdffdca-48d3-4486-9045-48102c7e5f31", "metadata": {}, "outputs": [ @@ -3179,7 +3177,7 @@ }, { "cell_type": "code", - "execution_count": 49, + "execution_count": 44, "id": "ed4a3a22-fc3a-44c9-9d4f-c65bc1288889", "metadata": {}, "outputs": [ @@ -3209,7 +3207,7 @@ }, { "cell_type": "code", - "execution_count": 50, + "execution_count": 45, "id": "5a985cbf-c308-4369-9223-b8a37edb8ab1", "metadata": {}, "outputs": [ @@ -3318,7 +3316,7 @@ }, { "cell_type": "code", - "execution_count": 51, + "execution_count": 46, "id": "0b373764-b389-4c24-8086-f3d33a4f7fd7", "metadata": {}, "outputs": [ @@ -3332,7 +3330,7 @@ " 17.230249999999995]" ] }, - "execution_count": 51, + "execution_count": 46, "metadata": {}, "output_type": "execute_result" } @@ -3369,7 +3367,7 @@ }, { "cell_type": "code", - "execution_count": 52, + "execution_count": 47, "id": "0dd04b4c-e3e7-4072-ad34-58f2c1e4f596", "metadata": {}, "outputs": [ @@ -3428,7 +3426,7 @@ }, { "cell_type": "code", - "execution_count": 53, + "execution_count": 48, "id": "2dfb967b-41ac-4463-b606-3e315e617f2a", "metadata": {}, "outputs": [ @@ -3452,7 +3450,7 @@ }, { "cell_type": "code", - "execution_count": 54, + "execution_count": 49, "id": "2e87f858-b327-4f6b-9237-c8a557f29aeb", "metadata": {}, "outputs": [ @@ -3460,10 +3458,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "0.242 > 0.2\n", - "0.470 > 0.2\n", - "0.011 <= 0.2\n", - "Finally 0.011\n" + "0.851 > 0.2\n", + "0.497 > 0.2\n", + "0.779 > 0.2\n", + "0.321 > 0.2\n", + "0.560 > 0.2\n", + "0.462 > 0.2\n", + "0.049 <= 0.2\n", + "Finally 0.049\n" ] } ], From 290a28282d570dc6d7bd2b0f7262aa431d8bbf54 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 11 Nov 2023 12:59:44 -0800 Subject: [PATCH 36/41] Add quickstart notebook --- notebooks/quickstart.ipynb | 657 +++++++++++++++++++++++++++++++++++++ 1 file changed, 657 insertions(+) create mode 100644 notebooks/quickstart.ipynb diff --git a/notebooks/quickstart.ipynb b/notebooks/quickstart.ipynb new file mode 100644 index 00000000..7357ef2c --- /dev/null +++ b/notebooks/quickstart.ipynb @@ -0,0 +1,657 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "96fdb45b-624c-4301-a0cf-44874b0693b1", + "metadata": {}, + "source": [ + "# Pyiron workflows: quickstart\n", + "\n", + "You can start converting python functions to `pyiron_workflow` nodes by wrapping them with decorators accessible from our single-point-of-entry, the `Workflow` class:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4655322e-5755-455e-aff7-30067a999b7d", + "metadata": {}, + "outputs": [], + "source": [ + "from pyiron_workflow import Workflow" + ] + }, + { + "cell_type": "markdown", + "id": "8d6274b4-880d-40d7-9ce9-63d05c4a60e2", + "metadata": {}, + "source": [ + "## From function to node\n", + "\n", + "Let's start with a super simple function that only returns a single thing" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4022f7b6-1192-454f-bc15-98d8242fedaf", + "metadata": {}, + "outputs": [], + "source": [ + "@Workflow.wrap_as.single_value_node()\n", + "def add_one(x):\n", + " y = x + 1\n", + " return y\n", + "\n", + "node = add_one()" + ] + }, + { + "cell_type": "markdown", + "id": "7c04df9a-856d-4015-87f5-b8ce3b0d87df", + "metadata": {}, + "source": [ + "This node object can be run just like the function it wraps" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "4520136f-d8a7-4721-9eb3-52b271cce33f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "43" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "node(42)" + ] + }, + { + "cell_type": "markdown", + "id": "d5e804d6-93ab-43a0-a330-31b76b719a18", + "metadata": {}, + "source": [ + "But is also a class instance with input and output channels (note that here the output value takes its name based on what came after the `return` statement)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "e3577e45-f693-4ef4-80ed-743d2a8e0557", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "node.inputs.x = 0\n", + "node.run()\n", + "node.outputs.y.value" + ] + }, + { + "cell_type": "markdown", + "id": "4c4969d5-dd73-413d-af69-8f568b890247", + "metadata": {}, + "source": [ + "So other than being delayed, these nodes behave a _lot_ like the regular python functions that wrap them:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "768e99e8-901e-4f2b-9a80-4efe25d59e67", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", + " warn(\n" + ] + }, + { + "data": { + "text/plain": [ + "5" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "calculation = add_one(add_one(add_one(2)))\n", + "calculation()" + ] + }, + { + "cell_type": "markdown", + "id": "bfbdc0bb-fba0-45d9-b1bf-c0dfa07871c2", + "metadata": {}, + "source": [ + "But they are actually nodes, and what we saw above is just syntactic sugar for building a _graph_ connecting the inputs and outputs of the nodes:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "f1f7c7e2-0300-4be7-afd7-4a490bac06f9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "3" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "n1 = add_one()\n", + "n2 = add_one()\n", + "n3 = add_one()\n", + "\n", + "n2.inputs.x = n1.outputs.y\n", + "n3.inputs.x = n2.outputs.y\n", + "\n", + "n1.inputs.x = 0\n", + "n3()" + ] + }, + { + "cell_type": "markdown", + "id": "dfa3db51-31d7-43c8-820a-6e5f3525837e", + "metadata": {}, + "source": [ + "## Putting it together in a workflow\n", + "\n", + "We can work with nodes all by themselves, but since the whole point is to connect them together to make a computation graph, we can get extra tools by intentionally making these children of a `Workflow` node.\n", + "\n", + "The `Workflow` class not only gives us access to the decorators for defining new nodes, but also lets us register modules of existing nodes and use them. Let's put together a workflow that uses both an existing node from a package, and a `Function` node that is more general than we used above in that it allows us to have multiple return values. This function node will also exploit our ability to name outputs and give type hints:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "4c80aee3-a8e4-444c-9260-3078f8d617a4", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clustermy_workflow\n", + "\n", + "my_workflow: Workflow\n", + "\n", + "clustermy_workflowInputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Inputs\n", + "\n", + "\n", + "clustermy_workflowOutputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Outputs\n", + "\n", + "\n", + "clustermy_workflowarrays\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "arrays: SquareRange\n", + "\n", + "\n", + "clustermy_workflowarraysInputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Inputs\n", + "\n", + "\n", + "clustermy_workflowarraysOutputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Outputs\n", + "\n", + "\n", + "clustermy_workflowplot\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "plot: Scatter\n", + "\n", + "\n", + "clustermy_workflowplotInputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Inputs\n", + "\n", + "\n", + "clustermy_workflowplotOutputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Outputs\n", + "\n", + "\n", + "\n", + "clustermy_workflowInputsrun\n", + "\n", + "run\n", + "\n", + "\n", + "\n", + "clustermy_workflowOutputsran\n", + "\n", + "ran\n", + "\n", + "\n", + "\n", + "\n", + "clustermy_workflowInputsarrays__x\n", + "\n", + "arrays__x: int\n", + "\n", + "\n", + "\n", + "clustermy_workflowarraysInputsx\n", + "\n", + "x: int\n", + "\n", + "\n", + "\n", + "clustermy_workflowInputsarrays__x->clustermy_workflowarraysInputsx\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clustermy_workflowOutputsplot__fig\n", + "\n", + "plot__fig\n", + "\n", + "\n", + "\n", + "clustermy_workflowarraysInputsrun\n", + "\n", + "run\n", + "\n", + "\n", + "\n", + "clustermy_workflowarraysOutputsran\n", + "\n", + "ran\n", + "\n", + "\n", + "\n", + "\n", + "clustermy_workflowarraysOutputsx\n", + "\n", + "x: ndarray\n", + "\n", + "\n", + "\n", + "clustermy_workflowplotInputsx\n", + "\n", + "x: Union\n", + "\n", + "\n", + "\n", + "clustermy_workflowarraysOutputsx->clustermy_workflowplotInputsx\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clustermy_workflowarraysOutputsx_sq\n", + "\n", + "x_sq: ndarray\n", + "\n", + "\n", + "\n", + "clustermy_workflowplotInputsy\n", + "\n", + "y: Union\n", + "\n", + "\n", + "\n", + "clustermy_workflowarraysOutputsx_sq->clustermy_workflowplotInputsy\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clustermy_workflowplotInputsrun\n", + "\n", + "run\n", + "\n", + "\n", + "\n", + "clustermy_workflowplotOutputsran\n", + "\n", + "ran\n", + "\n", + "\n", + "\n", + "\n", + "clustermy_workflowplotOutputsfig\n", + "\n", + "fig\n", + "\n", + "\n", + "\n", + "clustermy_workflowplotOutputsfig->clustermy_workflowOutputsplot__fig\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import numpy as np\n", + "\n", + "wf = Workflow(\"my_workflow\")\n", + "\n", + "@Workflow.wrap_as.function_node([\"x\", \"x_sq\"])\n", + "def square_range(x: int) -> tuple[np.ndarray, np.ndarray]:\n", + " x = np.arange(x)\n", + " return x, (x**2)\n", + "\n", + "wf.register(\"plotting\", \"pyiron_workflow.node_library.plotting\")\n", + "\n", + "wf.arrays = square_range()\n", + "wf.plot = wf.create.plotting.Scatter(\n", + " x=wf.arrays.outputs.x,\n", + " y=wf.arrays.outputs.x_sq\n", + ")\n", + "\n", + "wf.draw()" + ] + }, + { + "cell_type": "markdown", + "id": "ffc897e4-0f12-4231-8ebe-82862c890de5", + "metadata": {}, + "source": [ + "We can see that the workflow automatically exposes unconnected IO of its children and gives them a name based on the child node's name and that node's IO name.\n", + "\n", + "Let's run our workflow and look at the result:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c499c0ed-7af5-491a-b340-2d2f4f48529c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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hmNZloLBYNjvnDZ/XelujOHE8/xNnjxZb7/9tmpqakLb6NGjNWvWLNXU1Cg5ObnTPjGZx6hdyhoDGzdutPr162c9++yz1sGDB61FixZZ55xzjvXRRx9ZlmVZS5cutebMmRPq/+GHH1pnn322tXjxYuvgwYPWs88+a/Xr18/6y1/+EqshdCvS8f3+97+3SktLrcOHD1sHDhywli5dakmyNm3aFKshdKupqcmqrq62qqurLUnWihUrrOrqauvjjz+2LMv582dZkY/RSXM4f/58y+PxWOXl5VZ9fX1o+/zzz0N9nD6HfRmjk+awuLjYqqystGpra6233nrLuv/++62kpCRr27ZtlmU5f/4sK/IxOmn+uvL1u13iYR4TKnxYlmX98Y9/tAYPHmylpqZal112WYdb4ObOnWtdc801HfqXl5dbl156qZWammoNGTLEWr16teGKIxPJ+JYvX25deOGFVlpamnXeeedZ48ePt1555ZUYVN077be0fX2bO3euZVmJMX+RjtFJcxhuXJKs559/PtTH6XPYlzE6aQ7vuOOO0P8v3/zmN63JkyeHfpQty/nzZ1mRj9FJ89eVr4ePeJhHl2X9/1UlAAAABiTMNR8AAMAZCB8AAMAowgcAADCK8AEAAIwifAAAAKMIHwAAwCjCBwAAMIrwAQAAjCJ8AAAAowgfAADAKMIHAAAwivABAACM+j9THZLTIiRtrwAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "out = wf(arrays__x=5)\n", + "out.plot__fig" + ] + }, + { + "cell_type": "markdown", + "id": "f69983f7-c110-4ea1-8da1-009b7c5410af", + "metadata": {}, + "source": [ + "Unless it's turned off, `pyiron_workflow` will make sure that all new nodes and connections obey type hints (where provided). For instance, if we try to pass a non-int to our `square_range` node, we'll get an error:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "04a19675-c98d-4255-8583-a567cda45e08", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The channel x cannot take the value `5.5` because it is not compliant with the type hint \n" + ] + } + ], + "source": [ + "try:\n", + " wf.arrays.inputs.x = 5.5\n", + "except TypeError as e:\n", + " message = e.args[0]\n", + " print(message)" + ] + }, + { + "cell_type": "markdown", + "id": "be52f21f-2aa3-4182-88a5-815f2153a703", + "metadata": {}, + "source": [ + "## Composing complex workflows from macros\n", + "\n", + "There's just one last step: once we have a workflow we're happy with, we can package it as a \"macro\"! This lets us make more and more complex workflows by composing sub-graphs.\n", + "\n", + "We don't yet have an automated tool for converting workflows into macros, but we can create them by decorating a function that takes a macro instance and builds its graph, so we can just copy-and-paste our workflow above into a decorated function! While we're here, we'll take advantage of the option to define \"maps\" to give our IO prettier names (this is also available for workflows, we just didn't bother)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "f67312c0-7028-4569-8b3a-d9e2fe88df48", + "metadata": {}, + "outputs": [], + "source": [ + "@Workflow.wrap_as.macro_node()\n", + "def my_square_plot(macro):\n", + " macro.arrays = square_range()\n", + " macro.plot = macro.create.plotting.Scatter(\n", + " x=macro.arrays.outputs.x,\n", + " y=macro.arrays.outputs.x_sq\n", + " )\n", + " macro.inputs_map = {\"arrays__x\": \"n\"}\n", + " macro.outputs_map = {\n", + " \"arrays__x\": \"x\",\n", + " \"arrays__x_sq\": \"y\",\n", + " \"plot__fig\": \"fig\"\n", + " }\n", + " # Note that we also forced regularly hidden IO to be exposed!\n", + " # We can also hide IO that's usually exposed by mapping to `None`" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "b43f7a86-4579-4476-89a9-9d7c5942c3fb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'square_plot__fig': ,\n", + " 'shifted_square_plot__fig': }" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "wf2 = Workflow(\"my_composed_workflow\")\n", + "\n", + "wf2.square_plot = my_square_plot(n=10)\n", + "wf2.shift = add_one(wf2.square_plot.outputs.x)\n", + "wf2.shifted_square_plot = wf2.create.plotting.Scatter(\n", + " x=wf2.shift,\n", + " y=wf2.square_plot.outputs.y,\n", + ")\n", + "wf2()" + ] + }, + { + "cell_type": "markdown", + "id": "3b30dbca-3d89-44df-b47a-951aedcb939d", + "metadata": {}, + "source": [ + "## What else?\n", + "\n", + "To learn more, take a look at the `deepdive.ipynb` notebook, and/or start looking through the class docstrings. Here's a brief map of what you're still missing:\n", + "\n", + "### Features that are currently available but in alpha stage\n", + "- Distributing node execution onto remote processes\n", + " - Single core parallel python processes is available by setting the `.executor = True`\n", + "- Acyclic graphs\n", + " - Execution for graphs whose data flow topology is a DAG happens automatically, but you're always free to specify this manually with `Signals`, and indeed _must_ specify the execution flow manually for cyclic graphs -- but cyclic graphs _are_ possible!\n", + "- Complex flow nodes\n", + " - If, While, and For nodes are all available for more complex flow control\n", + "- A node library for atomistic simulations with Lammps\n", + " \n", + "### Features coming shortly\n", + "- Storing workflow results and restarting partially executed workflows\n", + "- More and richer node packages\n", + "\n", + "### Features planned\n", + "- \"FAIR\" principles for node packages and package registration\n", + "- Ontological typing and guided workflow design (see our `ironflow` project for a working prototype)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cf7a44a7-cf8e-4077-9683-909d89f5a5ef", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 2f2c79be87406420c5bdd79252755ab1c63eb81c Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 11 Nov 2023 13:08:35 -0800 Subject: [PATCH 37/41] Add badges to readme --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 01cdfb62..eeaa7026 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,17 @@ # pyiron_workflow +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/pyiron/pyiron_workflow/HEAD) +[![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/0b4c75adf30744a29de88b5959246882)](https://app.codacy.com/gh/pyiron/pyiron_workflow/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![Coverage Status](https://coveralls.io/repos/github/pyiron/pyiron_workflow/badge.svg?branch=main)](https://coveralls.io/github/pyiron/pyiron_workflow?branch=main) + +[//]: # ([![Documentation Status](https://readthedocs.org/projects/pyiron_workflow/badge/?version=latest)](https://pyiron_workflow.readthedocs.io/en/latest/)) + +[![Anaconda](https://anaconda.org/conda-forge/pyiron_workflow/badges/version.svg)](https://anaconda.org/conda-forge/pyiron_workflow) +[![Last Updated](https://anaconda.org/conda-forge/pyiron_workflow/badges/latest_release_date.svg +)](https://anaconda.org/conda-forge/pyiron_workflow) +[![Platform](https://anaconda.org/conda-forge/pyiron_workflow/badges/platforms.svg)](https://anaconda.org/conda-forge/pyiron_workflow) +[![Downloads](https://anaconda.org/conda-forge/pyiron_workflow/badges/downloads.svg)](https://anaconda.org/conda-forge/pyiron_workflow) ## Overview From fc6dcd0e0585d86aeb86e7f5be0a83fe26dae212 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 11 Nov 2023 13:19:37 -0800 Subject: [PATCH 38/41] Update overview --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eeaa7026..64a3ae13 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,23 @@ ## Overview -This repository is home to the pyiron code for structuring workflows as graph objects, with different computational elements as nodes and data and execution signals travelling along edges. It is currently in an alpha state, changing quickly, and not yet feature-complete. +`pyiron_workflow` is a framework for constructing workflows as computational graphs from simple python functions. Its objective is to make it as easy as possible to create reliable, reusable, and sharable workflows, with a special focus on research workflows for HPC environments. + +Nodes are formed from python functions with simple decorators, and the resulting nodes can have their data inputs and outputs connected. + +By allowing (but not demanding, in the case of data DAGs) users to specify the execution flow, both cyclic and acyclic graphs are supported. + +By scraping type hints from decorated functions, both new data values and new graph connections are (optionally) required to conform to hints, making workflows strongly typed. + +Individual node computations can be shipped off to parallel processes for scalability. (This is an alpha-feature at time of writing and limited to single core parallel python processes; full support of [`pympipool`](https://github.com/pyiron/pympipool) is under active development) + +Once you're happy with a workflow, it can be easily turned it into a macro for use in other workflows. This allows the clean construction of increasingly complex computation graphs by composing simpler graphs. + +Nodes (including macros) can be stored in plain text, and registered by future workflows for easy access. This encourages and supports an ecosystem of useful nodes, so you don't need to re-invent the wheel. (This is an alpha-feature, with full support of [FAIR](https://en.wikipedia.org/wiki/FAIR_data) principles for node packages planned.) ## The absolute basics -`pyiron_workflow` offers a single-point-of-entry in the form of the `Workflow` object, and uses decorators to make it easy to turn regular python functions into "nodes" that can be put in a computation graph: +`pyiron_workflow` offers a single-point-of-entry in the form of the `Workflow` object, and uses decorators to make it easy to turn regular python functions into "nodes" that can be put in a computation graph. ```python from pyiron_workflow import Workflow From 3b65a356edf89369844c5857ecb927e5579b254f Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 11 Nov 2023 13:24:23 -0800 Subject: [PATCH 39/41] Add tailing readme sections --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 64a3ae13..68c9e215 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,14 @@ out.b__sum wf.draw() ``` -![](docs/_static/demo.png) \ No newline at end of file +![](docs/_static/demo.png) + +## Installation + +`conda install -c conda-forge pyiron_workflow` + +To unlock the associated node packages and ensure that the demo notebooks run, also make sure your conda environment has the packages listed in our [notebooks dependencies](.ci_support/environment-notebooks.yml) + +## Learning more + +Check out the demo [notebooks](notebooks), read through the docstrings, and don't be scared to raise an issue on this GitHub repo! \ No newline at end of file From 4538443ebc0dc145d48a42eaa58d650796c7bd07 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 11 Nov 2023 13:41:55 -0800 Subject: [PATCH 40/41] Update readme code example --- README.md | 62 +++++++++++++++++++++++++------- docs/_static/demo.png | Bin 87392 -> 0 bytes docs/_static/readme_diagram.png | Bin 0 -> 66041 bytes docs/_static/readme_shifted.png | Bin 0 -> 10735 bytes 4 files changed, 49 insertions(+), 13 deletions(-) delete mode 100644 docs/_static/demo.png create mode 100644 docs/_static/readme_diagram.png create mode 100644 docs/_static/readme_shifted.png diff --git a/README.md b/README.md index 68c9e215..500ac786 100644 --- a/README.md +++ b/README.md @@ -29,30 +29,66 @@ Once you're happy with a workflow, it can be easily turned it into a macro for u Nodes (including macros) can be stored in plain text, and registered by future workflows for easy access. This encourages and supports an ecosystem of useful nodes, so you don't need to re-invent the wheel. (This is an alpha-feature, with full support of [FAIR](https://en.wikipedia.org/wiki/FAIR_data) principles for node packages planned.) -## The absolute basics +## Example `pyiron_workflow` offers a single-point-of-entry in the form of the `Workflow` object, and uses decorators to make it easy to turn regular python functions into "nodes" that can be put in a computation graph. +Nodes can be used by themselves and -- other than being "delayed" in that their computation needs to be requested after they're instantiated -- they feel an awful lot like the regular python functions they wrap: + ```python from pyiron_workflow import Workflow -@Workflow.wrap_as.function_node("sum") -def x_plus_y(x: int = 0, y: int = 0) -> int: - return x + y +@Workflow.wrap_as.single_value_node() +def add_one(x): + return x + 1 + +add_one(add_one(add_one(x=0)))() +>>> 3 +``` -wf = Workflow("my_workflow") -wf.a1 = x_plus_y() -wf.a2 = x_plus_y() -wf.b = x_plus_y(x=wf.a1.outputs.sum, y=wf.a2.outputs.sum) +But the intent is to collect them together into a workflow and leverage existing nodes: -out = wf(a1__x=0, a1__y=1, a2__x=2, a2__y=3) -out.b__sum ->>> 6 +```python +from pyiron_workflow import Workflow -wf.draw() +@Workflow.wrap_as.single_value_node() +def add_one(x): + return x + 1 + +@Workflow.wrap_as.macro_node() +def add_three_macro(macro): + macro.start = add_one() + macro.middle = add_one(x=macro.start) + macro.end = add_one(x=macro.middle) + macro.inputs_map = {"start__x": "x"} + macro.outputs_map = {"end__x + 1": "y"} + +Workflow.register( + "plotting", + "pyiron_workflow.node_library.plotting" +) + +wf = Workflow("add_5_and_plot") +wf.add_one = add_one() +wf.add_three = add_three_macro(x=wf.add_one) +wf.plot = wf.create.plotting.Scatter( + x=wf.add_one, + y=wf.add_three.outputs.y +) + +diagram = wf.draw() + +import numpy as np +fig = wf(add_one__x=np.arange(5)).plot__fig ``` -![](docs/_static/demo.png) +Which gives the workflow `diagram` + +![](docs/_static/readme_diagram.png) + +And the resulting `fig` + +![](docs/_static/readme_shifted.png) ## Installation diff --git a/docs/_static/demo.png b/docs/_static/demo.png deleted file mode 100644 index 60cf5223842aa325db69a41eb4f12a82f97e8540..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 87392 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 GeEu(e-#7&T diff --git a/docs/_static/readme_diagram.png b/docs/_static/readme_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..b5e242731422842d3b0cf63e4739b65449c0fe18 GIT binary patch literal 66041 zcmcG$gGe6$5C|niO6;|=Tk_7Ft2W-mbko7Y`?zO8>W}cg>Xc-ZP*$ox zHsoxNo#kBa=i;gP`t?P&MX>I&oI&u5bBj>6j@ZeJjv|yDd@D+BG3B3cP0a=ND7qx@ z^a(4EysnZXYB@|z29i0-cHCXoLZub)?o$QeilYDhP2T}Kmf7#W|DqC;qxwkne?R5; z_A2y$KMP2BODXa1UEqoqSsGmG|N9u)$3c(&^N~~mtWO@a{qI82_^^AR8T7^dPgs?#_A{ygA>>Z`*T@+CO#^h4 zzgs4TRqWo*zH3fk2414tH9+7vPF2`8W`*4DbbR37?U@u>HF+$mPUk-`n3S-(=0O!e zwL<+y{ohXJqTp>-JkamQ6c&13#}L5!-OB`h``<~qD8Q^lCkgp-0&ztr+5hf)v2U>U zBkBNWj?2sM9GYO!g;BT17)0{QM}+Y2-jBcjdk8zJ+TaXhI9OE~eyC6d{8-`mzZ?FX zp$%}3K2k!b66^nWCxpTuR&b}9p8j8W_;_{>H@et1Q(`#{R}X4zi#4k19akNgQ-!5; zM0}?a8y|CAtxIiw4)Oo2o$8^o@&gNI{CCzaP#SE*dD{;)?(RDxUIDF&Lv`1GFjNLI zal#~8=ihwc^SGpe*6(}8Oc=`h+AK6jUSHYS+5PrR_%*s&@`Ubfok!uYb1kpHNKR)x zL4K$Am+(jrYWSJ}{u?wzXUQiRpA%E?&lRJ&i;v!-iuq%-%vqanZ@MbmJCkW0;1=9h zmrvw0E7)dSj*br9mOs8e8?|ksW~rBzHh3oDdq_3(?UCT=lGE)z7R3HL7!eHZM^8#z zMatH^LR9nj&l`JjNH(G+q1JGLCf~+$)WXX630h5GN{P%N){~JzI~RwwhGJn47PT1u zEA!2V;bIi)&CUL~dR5GX%sK|h#0saq;e^-TtzBJxD^Q_}AceOd&TDP83djY}s!Xq9 z3+n_7X(8AQ_@_9e?`-Dl1hH;>j;z_sO3h|U@0VpaL`Ii~R!i2G{!IbQcp!GB+ruG5ok+CLXQiO1f{>m}(rhl5UNiRG10DV7weV z4Jf?){CA2!bPw0A>0GT#B&SIG$Y8+jS^AUiYu3qyMPnLo9!kTwV#sS5V)U(_kR1?; z__7yD&j|Yd;SE-m(N#4`*i@*$K+!Namzb#ni%)ToDOG;l;_(lU<`}&#qH3b0SN(ck ztH1@Ta_YZ?@3)^G^~f^Tw|Bn3uz0I^u}***fLL2Bp_gy(NGO-Db=r9KZY0}Rgl~Jz z)qXV=<^T@un|>>kZbVU$#ivB>rPQWZ@@;L!d;3EIhh`NIdnXE(OO|l%EH3TZ93VI) zehs_7?6!Kb+Jn!{r0u7w$lD+gC7$Nhb${wRzRlO`&?}jJ(r3^uI za(r$QAsKP=aZcK+=;+pLaSXm<_a_e{zs2_SKusK}ksg7oTcc1BNZHNNofm3F8`+-c zXRLn$342%id%N}5Z5hvu*PNb>7tzvjl=WSIC}(?zrJ2e zTlOvG*2)Wm!PHEdFN*>;`Q2T8Mn^@BAlbqYg<3!8A2KK#R~9Oy2JeVCe2>en^Ow%L zjpR|NR@`;H=T&{a=F3inG+Kw7*LO%R4i?id-o;#s$(g7UJgu}goo%Iluuzr8u|9CH zGsy;m!9nPGV9*uXTUe@W95#G&sKPP665X4;|kG)&E?Lp7f17tPtX_B9TulLB@YHd6E3S) z)OO*sd3~2b^%rIfFs=q+Vc)$$>rQ#&PIDVw@|hOuYPT032~O`lnywe95ENpVt4f~L zZaLju*7gz%+nUI6J+T2EGSE=VDpaeO-PBY{L;sRCzCZ^#4MlZdmDwXLa#GB|0vdS zf3%+Y=C(`f)33F-1P{BjGgJMseMCeQzSLa&z_n4Xc#t}xr90W z9t%u}+;sU^%(8b?1=e92EIpd7~u2dlA<=&NQssi}k1uLRzt5l2jqL=!6ry}lVTL{8+Sl^7!5uQl)` zWVDEuYrfR-MJ9yMDf~8Gq`Wgz$>CD=fT|$*wx-}_pypohnTbHvyGJ$B4t__z7ChK! z?YlI=mQjNa^*7pWKE6r5M1AR>cyJhCTJ!DO0AfEVDaG~bNIAC7(tUU(Sa}b!6qs#3y)@0faeqy^?8zE1^0wop&EdnwaZ zS#rGuR7T|V~m z5`_A&hN<@ZyV+lkSm8u`*pDxv(UvN(TQC?%T|FSwZR_s-)=5DuW#U56n(7u@k;F~B zdim?UdV8`+=EUOY>5!sRy)A1X5-GSpdsN4H@pN9V^wSWu{`Kq-wbjK*oIsMo3-`n! z)4_-_X-SCr@Ywx~ zjrN8$4aJZgT5MHh!6Q6V=g)i>H7X&oC5}bjXgXK&tz2D3Z{|yewIg4l&iYdv>S8$o zQnC~xWQ%WC?*J_oHv!({*|Jw+xkcDR{FAGs-v!>RXjN8s0v&qQ=Q-tEupM>16Amc) zDRaHiJa8zaHi9J29GC7G;E)+KZP*{XExry83EsD!3}SH=W2HNUe6*g{s0*`{ecTfK zkLiTHo719;Ezp!ZGUGcz;aUIP9Vr;YpLyEgG*L?`*ua@G7|67h&RsDB`D-{BFlInkd6 zp8Tk;jz4$aF11*?Gk$Z(aUFK)=DT(if0!0$HkWb#&H2gl^~byFFb}Lw`Q&GGJkSJkcrz~OW|`xk(8ADl!!X-Ou@h}9}-+4n~HdJ_ab1JeS@5ol+@j2mdL^3 zh}V?os0F<+9>Yj0n^GhNsR<3FKztEi8n6~{FGGV^_2 z&x*aXE8X#uq@t&XswOuScH-u{X)Bvr3t?G+kzQERL9sCziA7L(%e@&1q4lL4 z@;m0#4TNn^m=R5e<`YVfuZ|Qyg3Nsp_PfJrGt~0OS}QsVcU;Uy?y)Gk;$7t%%gGFA zW80h@7JYIp(O|{)ME5}VeLMsOpY(CwNZr}a2n?R9{JTMp(csU(R!S$XFHR7WM@>!b z&fQDr5v_Y%hMsLUa~J0g4G>IpI2QdqF02o$6x%~9>qk?r3)|z-$%&$`3`Llq>#p@7 z9q|YDCX29aEYv>FshtO;)$Vnca#<2z{q~0mg46c9@jey0t=>qww5G9k@wf>m=U=m{ z6|36^d+aYlJvLtZs2_9OR;l5m!J%|rAp+=Sm}zG?vnj>3kKT|yNrNj3o4x7gOxYKk zgvys%c4kL_z@$L3X1!;KYrY&E#M#6i>{*}e&WD|7RUKTMW7Q#t8+!|7^c%J3X7{aE zBqb~?YK~YsJMN?-_USq5ts_#OA1xc)q`DndHg2JlA<@ZN&JWugX>suzLc}S2n#yDH zYupa+S-85^*^B`4op*Bw}o1{v16*qLpcOrPw?wKka~hw!Vq++B@1h&7KhFxWT$ z9l37m<1)b>8~!@$*W%EzXi(yRIX>Ixpgcf6J+a9RCQ|D`etm<8h{&h%`DfUbjo|br zaWR&W;WNx3PPz@zW29AFHll}bY!lWt7K2pt$K;gwa(C4cJIIjHqrz5Cs62zK07ql0 z(vp{Vug*L5uDxv@hx8JP#>>iIvcx09BS^zuZgMh&pA9T;OPL4G+!>IytyqJcZk|s!D>5njg{>uH621MD&^Zp)qL^j zB$?w~bp8{@n{WFvrt<46^K)LsUii!wT%HrqT0`Gn#^u5hQc|)S2kvlCB1uzHdgtWb zPw4w3w{(C;>wHKRkc;xmSMiAFh;Y9(BhB6Vj|lN-6%KYdHq6;{eFcK+y)|mxi;{ah zVAyqk{FgxN3tK(gG#68HG)XjOZRzyf+~Qel6>hzS<8=f)lnjY0({S-we8DSEM#SfY z4-%qwsY=^uzI}}FD9!e`^sK*e+ff8@sE+LPAYekqlu%15YmNtpD~abnSIAsy8p=e4 zH@X&TLP!PdOZ%J}QL*eC99nuu%A&l7Z>k9@m-~La!CPB@!tVCA=19xs0m|ZNgOG8y zUNE1Wy*^c;C@fC)UEXg3!l!lKQ2qucuN$(57>B*G(Cvx%j#Eywrm3-*Z0qSexFuUv z>`0*VN_>|+qL|yu91x}fLfj;T6(scT{2Ig{J}b>X!lyI4`;Ct>0*y;of$}zAy5oPU zfUdvr*Fb2wRw?s!L-wfnoFNH6f#KLB{d^I&zvz7j6sK?M^*%-2%mp0=Q=Y=!9 zOmV5-x-0j)YIn81a6hk-BMT2^=D6BHV&LK959OsiLVa5JY0Kt)lzu-bw$G?`Lv5y0 zPb|$R@~do;W0L!1A)I`C^sh!-;F~-qq&bLPD!zkKn;NBy(;@l|a#W!c&WDR5=_ou= zP{)_o&(v6SUqvVNPURZb@flHqpq#IewNOwvJUTqPK2du?-!&oE=ys0xz|>$nhUdD4~}h}gj1VdLDB`8qpthcfm5t!dnT`V4950WOpNU<7Ifv|Gjc8- z{O){wD<-CKS{*Yv&SZiCFrP4_`Se8r5@GrH&p@#!+@itP`>}pzM>-^NHp<#~{%x6C z(s&z90M|bUs8;RpdBN6=!_gsRve;TcgO86iIDGA$GH1e+#>&d-aG|4QBA0m!?4=eT z5q{t&(f861(IUjZeNUF%sc{TIZ0MS&X8d_p-=j1!5%;~E^Kp^((r}EJ1DE?xQ=`bF zI7uJ|`xZ$(iqc`3I z`^~2+{CcmKjI5XpuTSovg_AK>)d`&LGy`^qda@lO;5+Gk7e&3;D9=W-ab8I7eNMvE z6vW7Q{ks-{%=q%jsB+Z%_f9j4@7zpw%#<1MBSg+w!{x(ao5UI#5N#<~_No1Jy`$uG zx!J(9ToQ8sh-K3Aj5S2HV5L5xY-1$zLg%#5_=r*hldSJYu2~Vy^UZ7uVRAX8n+`#4 zuXx=~>-HN=p*(!4HCg&xQqsxeWiOxW{ktYN+k8<^9=lzvXQk7(k@58f51QO5c0|4p zw_11@+|s6@_uF<T1XRD}Si{W0As#upj7LcD z{WfykJto$!RvVQXr63}{oH1&md&ceSWo|1X(RN4sAHZ677OKhp+->D)cf0g^t1RVY zMAaX~WOOOM0Dhq`nTSs^HW`1h%}~TWF6;M&&h;YO9OKsW(#xN*05hWZvhX}w>m1p9 zOxi~{B1|d3^QC)4cuh=}>Dni3W3m#9ug2ZT#2ARnkD)~T0pH8ZrH^L$Kfvcm1qs>% zx^4gLu64*Pr~Qz(wiQMmTC%g3xq%PWbvu^=^t02qG4&0Iufb4aIX^w>HD=wGlka$+ zkN0{xG9@3)W^+N-mst)DJt^$ime?Wvckd$iYL(1JGHP^>ssbesw?=Ui5a;>E9quUV zW#$}d-aK)2mtMMQXM)S*O!|VBWZk?-Eh;RtI?a7v3-1kF zQe0qDpiuraL=*PjgGc4iSf54nVGzdD+9L1Y_d;B0W0QydnJUSA!OU!;B}PM0$Nlv) z&|&tdDmzK{F}@0VeSboypqf}^oImbFe88GX+o%DDKfLPmKH_p=(rGyipm|?J^GC54 zqbjMYV3V4MBRXJ`XzTo9d8P|Gi)27Un-vst9yu&6Wti&-`$^+P!U>^Vm42p?m&f@K zwjPf1Nd^>vbb3ue%Zphy78ZfpJ_H8Fp}mWJVR7iH+}*bg$$rvV88(JD4|`eqwv*y+ z!aQAQpYm80J1GW4{MA);eG`Lfco zAq)Jv`r|#>H{JHGh!$}Yu3V_6Y&DzmbKIl&dj@?b^Zl>-N&{$s6CA{lME#LSUwkD` z3Ye)osTuS{%dTjyJXP%5_AU-E!}^N)dZ!sDrTpHx-MK@r!_|qH*rgJ*FGDb-5G=fs z%~!!(xzQ(ZyTGjBOKp;3uS5RUXx?+>9P+T&Y0INWYxs9R$2J4L1cZ{RxrTl%r~|(V z@PBl7*KCi`f;%45{q@XSq@DIg^M(bTEj*Kc(1@z@0RK3f@Zdr^!v+3I5zhzdMsoM& z0PiE*|D_HSRY0-%Ux!}*#>yzYq1pRlBW?&4A&&0f#b#Xr!zK}pyFlHZ7pR4)Mrr}a zSomLU-tC62gHL`lsKA%g-`U^09d7>qw1uGCp0VJcsXq#-v=ZGkvQV^CpIr4j!} z2L{Y>jDM&XMSqglg3Nmhl}6XcMfTy2w>A&o|G$1h?u(V++hzko;WdyBE#X~6x19m2 zguk}yDEhGZlNJw^)ywaWn&|rIw{QQ~I@n1I6Kv27B9R$T0B`0B`fqnZ?%U)q@Znmt zf49d)AvKZaZ`+~ZpTRx~VK2|YMMw;o-CFDabGnOtAAfn&0_#QsjLJa$PqoXLy`&2$ zmj9fZcAh+v_cacA(u(s-iAi5l_Y8rSoUC)(PzZqh-J^``+@EQ#2m?Vv=I8M_Oc|lN zbFe>4YbL|iZ!a{%`KGG`YZ4Mj)f>atfW-atei%h9Puciz_F6Yhur$<}#qr{JWyL6* zl&__+;hZ43x1S*Z*W}*;yC|gT4ORt6vifd1aKyZw-Kuh57-phu`Z*Jcs329Uur*$` zjT<0mH$p$z8hexVev^Ma3u@~N9kjI6>X_~^wC>%Rt_b=1gsvyLGGO-3i-O?)_}Sha zL5k`6{;0BEOM&cqyI~2C=)xI-DndfS&NnzhMyYIGcxFQxe7=_@x~F(A4e6SUwkzA= zI<+ogdyO}`dq)Z&KyqLFd-~6jsp-xfZ)hacBM$~d?i2fJ5y^)WQ^8sO6CjiITE0V# zmgF2U;?ew>jOC_|L|@ind)Gmz(Zvkh%bD8rS$gofArb zWpy47tjE+$ZD;Fg$wMvg9^>GN{--Ze?CBx&@=e1Jtx%0rlV|m;A#B&koLNL8?s+5* z3RS?#U(5h~j|M+29&?HdlTU~->T2o;27;=nPBjd3AAPz1a8N0c1(?*uB#*fsxOE2R z-!=+px_p=R_!QGmn62!7L1c*0@;=n}dTN=7&2%7nsUz%Zt}+9l4Upa4U8kMNp_b=m zIRCGu<)R$dL^K~~@p>YdHa9no`8CPO$?u_|CHX9?_G6x6#@n5gl$5l!wb>o~5%==; ze(>;NhU5AGFJJdW!Q*lJYmM6UbjolFk%44BN61;H$o1GA(IZ1qByvvY!Y{v`AGKC>T3JOP^LL(RVDG*uGOrk zE}tDN$1-Q=C>_781Xk8p`4s-8-CgWggQ-IH_Vy)jH7acvY`(!0ql7y{XfEXcZ?m8K zb28x5I(o@DIyhv!`GO4#SB)SSvU73aM!NKtSo4bFze*!fPL;-s8kz_@`7duYJp+^WJRDNUm}H*nGXGT;*;pTRYMe zn!+3-9jIA|`rBJRUQ1aSPvq)2TD;O)Iu`rR+tP5FfQoAx!GNyQPwp$IN@18G&IDdTQ?am zKKAwNSACE|v>H|7qv^V8nwlhFqa+mZnDrX^KzHzKXlNaA+quAC>`Se8c!AM$3Z+Kf zkGZX<#a_QgfqcOxGq^fGv6_S7`_s`PW#{N< zxWCxEvbtL0xGoJ z>F?mR1mlGh^RzA7R=4e*-d;oG;i{X5$8eSHg2PdDZS4~Rf{xEvPbVuZ33YXKX=rIP zRP%}Xo#@@&-K}OS!!k0Sd3kxEV_~g=0PBlqyBj0j4p8sl;DCyTCSY__8Jk?NEB&=U zuk#jOXE<4FXXlsetBd8GoxX3>l5eG@TgJv>8oVzuhRlLOLx1MxqTAZqg1wZGmBn1} zxp)a#T3T}R^n5}}8k#}h*53XtGV+!!_*|V!H0sDN91Z@a{l(N+e_R_*F4XhsK1Nqh z&mBrCs*eQ)OsYlNQs7v@X40^;N2U3ke{E?IW!8a-DJni9CME_k@wxH(JcO9T1o!dd zmY-75UH$#WU=yorYK9tpeQ$#(npO@S1EY0hB-*4u@mqX+P-CM=YHBKkNwYi%?8)JN zE6x)Dpdec}rYrKF=eK_tCM5iC>fjQndN95+m)t&F?KLtsKDgLxbX@MhBKO#!a@m{j z&kzrxXJVqEqEddfIsV^SfRA81het+woyx%;blk3~yL-sF92JwOsccYK%SMuslJeEW zkY=?VRTv4^pCL(#)xHD{uahw~6O6XWL<~aa^f3$b-{~mebX~F7In91U{EpTKIqd(4 z(aOfZl9$H@8Qk9AANKJs+TlvH0!YUvqHo?D?C(>uvPOayIth;SDZu&1cV^#dBMe=)jzo3ajRy@$kgm9-C_Z=3FfgwBek=$H&KMdmIY~2j}wY zN=jNfL%;PiY)0?;Y#9e2-R4502#ARS^^z8ej+etZTGkYEy8|f#BUQF~<4`??SFa$a z3m{we>QBkhut`6xbbSl>{P{5%nVgXk4OsJYN=gWbEU=YGc-6vWgSUV`8cxT;LK=tZ zz{WUyZA~ImBFt&e|I?@a!^3BMd_6dByZo{&YqdW&A(=g0cX9bsn| z7aT@ixFDO9a^zPRoBaikf4@22ol62~c6+3eRBxQf3h^0LH~2N*e8y}B%bT23pM&*o zo4I>>Zu4sb+!9xuEY`mZ-hV$`C13S;YpmA`KV9uY*^^pAYXsmqO0F7IHxRisUWoAu zG$kNlv|;IZa|DLOuaLVyz!mWVo0?vMV3!sXyLWq*3kwwDis~f>_dyXbm?+Z0Bw__l zpU6O}P`*~2W??qS6xb;W3d-m&m5}msPR&ZIuNm}a6`IeesS{ZBKNWe=x_^p$FwmIz zo>a&UY~h>y{NEu(B5zGABFK-=>BZg>OYIDyhC?H?Tx zT3r*dROL6@D+36gzClf^kSc3tMsL^|UO2Ez z)J8D9uA1eatICa#*_@N8%2=TQj>=KC<)lRe*?q|K2W4(n-70FHe# zwvAyC5fRPCMGsoe5ytbgHMA6db>;B>5;DtqT`MalfE@Yl(^^5lqMAK!hcHB*l64I> z|2PyGQGuJy?XJe@b+oqngOE<^j-~@OTBq=YxUB=#0`uX+JCuBUH7#BfP(7CDg?Mcp z9k8YH6#|oBz~ptIDhH^WYkdiCbahkNLNr>$;D-J=#~lAYdCg%&w}cQ_J~1@(4P5dtPBBjz z#M#^h01?0~!jqDoveX|xPV+i^U~XM@4tp5;lKG-R6=`X0mD5DLUpvd1j)!k=Y;>)3N55cVGFt74SzY@Y5n;N};C;Ne zkOm-SJ2t<-+K=7Zk;il}H48L&0Ok{w?`qi^zhGc6xVgT{9kY;#An$Mc`lP$BkA{Ka z*YF8Z3m!bv^|Bu|MmNT3drT?|8WJ4rG*Vht25!PMlC4=73&3&iqTcg#xYAlnR8$nQ zF`D-zAtAwPp@9rkdjx!@yQk+5U=FbSMIh~30q|72z)3(di)RiM>$jrLxq!i>;JCQk zDoXncWu&;o4ayo>9#PEoSEDK3-8gzV?UglT#`$V0Ty>Mv(6zkMdd6iUe0(flt;S*X zAt<;7dX2hODUp%5b@lZJ2n4qx*6aYL5Dj&et-3l906>GOQe#qHyY#*~km|x$$4UT- z-B)AOoJMM0;L@Pgu2pIE8Y~A;irPxK_wOGAz!(X8z4tksr9_w{PE?0o?=4>vjzmV+??&e2k*p1{CWI zB+S`iFN>6v)Dpn=aqCR5aphK1QML`p&l7sSWT33<)~u6%`t)hC%oGRm<8;@0jpP^ko4ZviaxLsXy#orr{moT$ju-HE zF)rmk*GSXd`cqv&At6486?Dk<1aut~)aSIcp8;xfl3?YlK}|p%YFZ$B3E1X)C8h4c zL6(uBEl}{Zs%%!l%!SF=FBL)LN;IeKJShl*-MQLZW(2Cm(xAxAN>-fipS+w@0|5Bi z^&T+*UT(S6$@T;>o54MB5u5MxsYt14+Mz-%o(AOF(?o7-X@KIlVIvtqo~765BY4pE zgjYad`wp4DzP{CL^*5G=vrjRMYKO-o3ft2rfV*Roa0Y=rEd!Hn04w{d?U$V*7*z9= zt>){9fwlvO_1lcI?x5o-tkh|5K7~xch1K`k>z1%)MhHs-C4odDN%@_EguTxJ3lep6 z;~mLSm~j57q@<()NJa|3^DQC${rlY~fB)WlHv#VD)-$mnE)V-S2Gif?IaTH6DKiLP z>~PFk#z>2U1#N<8P4LP(O7li&3=a?Q@9#JD_ZRkgRcQ9-r1?mzsuFz+J76b(0fPW;?(fH{(9|ha<5|xiv&fcD zkp_zatdTxg6g;cEygbag%3>@SEcoto|0{;z7Es=SH%H6C5e-CH!q1no_Sc(7+b(k0 z&ynZns&3c|9$JcQ)#AczcWI7i2nb zTZ>l?4VA(X_O7lua!K4C_sIZWIJ;|WYwvdL?e6+aoPf%wS{izroVd1etY@L*emA@NY4Y%PozYyOJD6a{KiKU#JID<2afplWltuxzvra?wb(E-+s zu0^eOJ1v@vVs#_&d9KzKkRwo18SkQD{{%WImO1uY{IX@KbJgBlz?Uyi@bR;@Kj09t z`en-{X~NV|*@uv(Gdbmo&YQ!50Npo=92oZ)s!0YrJeU7ZUjr0=^X?rkAgs;ZQZPrq z{wip$`$``^*EmSQsS3-u+fmHi41nAt0Mf76XPODiLHU@mmF&fy?oBE+(J9cP-u=n_B(yAYHK}E+kL4*N%8SSslM0lw-p71zDBLf%Uj(7ZM^0O z!NJoUYaIe;rIw+;Z+{?b_!ByLRxl56JA|qT`o3pWKo@^Zt-;58dh^i5QgRzXmo+Io zV@sh_<6IYihzhC1I9#aCTjN**23{$AFYOaPR?7&Tj4It?4>*@wQC|=J9vU8Qa`2~} z_5q+;ZN(lZ7G-D#Rwdrz{lJA&T<%7z!sYrXW>AnMX0Vf#>?=ydz`_!-+8ejLxhVz8 zbZ`Ii?yfv2SU{3~0J!bu?tUw+Gz(2Oe*d^tu)tFQBWoZIsW>^mFLy?87qhkFT%~K*3aKWUYpa5pWm$EVr$#Bvo@K+#>fr8ZoC=RL)=-(~D z;c`_rb3tFfE&*LQ-f-uWqko69elHC4WYMnnQ_atM?T-pb^6Uwz1<+lP^}#gL zKW$%S<5@o@Cj*K~77U0F2%gf7+@xrIT zVgk%8`ule=;I)9^SIFf-Ck06GVIX3GdebN|2pqc0b_mFK*li5tbWPp^j%$GR5S^Q; zqv0mHQfb9O%s`q5&MRQhF!ZTa+2}yP`%uh=e?G`jNE`2Wb#%;B%#?tgzJC20XmP*> zWdDCrB7k{}6{HAEX&4X#AqO6#SWfhNQTG)@R)3#{!iHC(niTdW|$o#Gc8ekgZQ zmEFBXCovwh1(6I6lSxZO(GaI%X89eX6vhj~so98TjDwE`mDBKBd^DC9vV-s4zmF1` zoUE|m@4O{PalUHz?xheyIp#IF`09A7A9 z;}Q@^Xls*}ne+p-U8T8=*D@|eDKYA{279I(MI$94OUpkPXdJ&?{U}!d7eev)t?2jR zbvHH%b^`G1x5`OhpNdhfh>1r52uW~anZ7sIPP?-jRoZ|}WNlAaRK%?D0jCng9V+x< zyhIaZ-6XKAEGiO*G9_<419lFMuff5;oP<8Jx?Z3w>VUl9O0BtOm5HM1cI&pw)+%E~ z-4=2PV0#OGiVr?Dl!PnTxHr}wm{aUWp!}Z!qH=J0x*nsZJ0!O^UBL*97ptk#`+zI~ z^7tRd3>fC!cRBK8HnY{Q|Hnx)$1J2|Wk(Aw0bwsUACVvodVq~h%g7kAzi$Vs($}CM zN&$f+V586BGlNPGru!qKqC_1WN}2V{?Xm&ebaQiC+}t#W1wUy)?NW*kh-C5OPfRAw zpr+=n@TC~^VlC`(w~||#9y=x%z7ykrU1DxHItJSDw*!~~)f}Vdzx|pj;hVp6a0Zx* zRA!&pt2re8HI%b$FG%b#H_D{>ezp}_ofw(`69qUQVYgHj)Td$KGyq4A7dUqSg_pn5 zr8Z4XX%KDzBgqhW2wn&6gaz6)QGg630-r2%$n2KT0w~J>9v@(B2|zD$*v#_oHs1K8 zuefCOC-E%*`6DVV{g5Q=Gta!+8?{(KGs_G+vDXIE27$@wemo@k027l&L?jLP0YfGM11J*SmzVQ`AW%iq`SMj77z z{0yxtT;<(QKhXD1?v&Rhs;@ z4y+DLAzz*yfCK`QLfJ`b{CYq%J8vn2SUosC7E{CnOx51OVR+SY>=%BS=^&~!>V*nk zAYk80+S;;!iUCdw%j}b&8q&}=1oO~}%Nkw<7>v?F6r`_1n00^^^h=doOS)E-nNC0` z#aYD5G#1t~v2ujiN2_)_q$#_el^8^mdIpGBJY>=p z8BYX^QLEeZ_3BLkeb(TCL8x!#uk>_Cp=yXkW{H#HueSLa$w`VDE-7>Y*VS1K100HH z*P~LJu8nczID+aXJ}&22h|7}L?FVsY^Cgbv$4!fZ=V07)Z6EPcTiEe4EbQq>-U4Ln zoNK&ap(d)U0n3+lr)FJ>_et+ZxuP{^@*~I$M@x0{4I5X@rNyebasJzHoHNv#`MB>%1)uhG<|l3roAJ z;)D9Fy>p+Fa_EG|et5cgB1el3> z6+jcYXmfy#iqdn{Rz;e1d(wb=dF}2vgSQ_3T>2LP&BN*sqrn{_jg4e z>(EVpFV~Yy*@6W8O3AJV@x}J5kW(a7H?eDP!7EE^@L=u{)5U^p;(gb2 z`;gen(@nZ~fmP?Z%PU`-#W-S$5>I%dbZ_}m=Z)b;*%LG3;V_eCvLN;PU@-L^mo!1- z?EC`33BE&7-*Nq}Ifm2u?j1v@YgjEHQVr)@j6Ua^bRFSj zA;9>{3Nd2!w4u;X1D=e9po}ZMTmttsZ<^nD;p?mn-B(ZU%%C}%ZPU(OLT`?2r%$l6 zjNkH*CMQ?#Q79FCdfo}9BB`%G*r9m{b4JZm zlk1PP+;1Zn4!9E_d`SeoW=-=kqyV#9-D;1!`}=-^J)&>AyL=-BreuAYll5$T0Fr0L z6G6PLE*Y4TY%`-+Ud0(0hN`k4VlinAgVcG5ssD-){ju4(IpBT&&N}S){z#N44w#eG z?%m}=*%7K&&68F0;)-~W4GiB($*yc6j{qQnp(`-GBX%+W!@qJxX^RqVoCC#0N%PL zj`8BRSimi6VZu*YaT<6$8#z5*s=S20gA-6-gsZ( z=yx|g`;-|qJm@T0%_;OPjY5g2cGOcO!h)tNqFc3iMk-BhNl;N8g!v!(oki23pqtHQ z?}%1~x@Rbmdut0n!SI`I&Fnko7V?-+_FXIL<>%$y`b6@egbjR+s!PQ5dN3WnP*hBr zM%Kuj{rUsPb+X9bo;4pom{1oz+hZg?I}>pl2~$x+Db&0h>2V?*xGGa0h5l-!626<* ziFaaf`cuth?x**T7MA%!`7=U<(e;%P(U`R_3!|rAjB#j5xIoT}s!Qbio<3Lm0JfJY z2h~UVe77SJG`CO)e8a3>;gse)p;~ua9jB<<9LaYb0c1(XRFX`0I9_j@1VZG|L znd{eNLCDr1p{0W7Fy##?s&H8NSLv+b?GyM-(z)~0jm+|=9&2c?UPBo4$$ZggoE{zg z^YRF`${*0oiNo#}^voK&v5d3F?m98X*9{(ToM$YRtQDx5RQzk-l*~K7C+iTtxz^40 zIFh5Pn*2B^7}3Kq9dLO4$1n3CvoZd@gc_ls^#(S9=XC?!PwzlD7;eZt*g5gPIL~tP zNxLE*ys_m5(}WK}pXg)M6_(iuf#34CrP+(cH?B-jTXJvCy4t{tDdN#V$6de;#wvZg}CfHsB727VR=R~3Y{B|_Drxhk-Ju~6}@%v-) z>=O+e4Q$(~fXwI-GPT0*54~5|Ame?=_dG9+x~?Sh^KDLXmaiY&7+m2jJg#t*Ot8+; zYaDQe*1gR%c`bW(%rWO=I4Tony3$O8$Kf3N@&%VducW6#^^c)MAHy}Gx@O2U2`*k2 zy~E0S{z*5p3+GjbENgs7hUw#M#e%?}+JOo<_>nqhPq_;B*pyOem?WnqCrSS5BqskV z4a)oFHZnK%3b^1=ZLxL-S0~j|Q-m?(0~vEpNgJ$bJ)-A9o_oik&Dx)xRid|v_a7{G z!3pZGrNQ`bN9euk=q6U0iM*$CU{c?+#Kl#=Q8#K^emMLPY7S`Mb{Z?04(r;72Dy8N z#{vewE)>**Z_R!O%P5eK$V{Z3o>#vTMSl}~ZNvjYkXj#97QzT22j5G+8niab&R*PF z3(v0=NK8&<_um@FLeJ%l5I$L7*=i}{5PlA3@On$wMo&(2Zk81`Vr@st2=(9s+9nC9 z)D+q4U0-@-k1U5iP1WK=I`E+=Gi-4qSd>27;0Sg_ln~nGt89lmoRD#ZHyC!zRm{E= z6?MDH(c7i3{gk05MD$)QW$&rj`UUE@xD+{qSz?O`=wN_G+QPR%^^^C@n@^haqyst! zV(uAA7lkHF&qVXv&~6p0akZFiaElRVHOD`f!g8;>RuPnSPKwGKXzU^QM$1!U@KU>$ zI5c5c$9k^nMFKSgV`uW}#z@1k@ZZU?Q8&c3W#05iS>H8ZP4#?9V};lDo#!WuTT6;I z-m@=x8H8_cD=y}&CYnYB6o>{beJLcP_S&ZE@Hr(oBsX3%xcq1Ww5?g zawA4zOIfV2%Gf2ar zOcIKu6l|@&d$u(rJSLYR3i}`{ZMclR$Ou<5{Elj9vNKrr6~!wMx8?ocB|3WF#DTXT zME#JCjW&HjP_Os-*_onc`v zX(?8TrRaJ-Rs2x!{|Z=A8Qv;WpO^S82<59&M}4N}&G82F3o!$9SKj;1&;hlF<`kBW zmaHb<2cOTX1dgdEJ(yPeD2)b*ebbW>T=8(iVG(>--NeXU7PVes`+idYPrd8$s~Z!y zf#=bM&z8}_F_HBPSSjqf?BBaPQuCIi{n_^5FvUVf!vYtpA0$PbL}iB((H1(yhQSE7 zG1EZSqNop{EywkIM)*Yb+IbAc9gT7=Zhw1Aw{@0DXd@Tw8m58@@5qU4%e(f6Ki3G3 zb_dEW00{iGuW~`S+43Z+QWH_bm7?)2@r%~zy-GU|n}LI0Oz@v+&F@h%v5e9gTffLc zhcdl?C5erhD}F01lwj=;gH!(?n^AlmI;6=-M7^X7%y{a5ww`H`NZ;Qlc|4xONpv(2 zuDp!3$XFm@&boGbyroS{9V(vRO}o8;3OO90c;>iZyOKI3v-YFHNYa|QTPad<{M(5U zxu(p(Ci+~dc)|-KGEKB#QGEwNV$Zl}M+Y>Oy4iQO?V5r4Q|(@*bV)mRkTis%qgID; z%IH50=>q0P)>CO~EHb8@)Hh`yiY}=%G_e&WZ|8}) z(1K!e99LZTeor;|HKUtk;ra0OmSD2VTAs6|r5+W-twD#kf&s(j?ksjxh5CHpk~U<} zl;h^hxyevn*I!8y5nDP5;TO&)V|A^lWI8Ik;CMzOq|*JHFfQ;s5iMQyK+ z!!vixnK$r7T$fNS4jt6PtsIWS5YyeGUC>mK{ER!TSKrE$r9PHThP0zR9~yNyJ&}LW zy14jOU-Q|0HG>y#8~iMnE32z^^0WrtTVtfBKU3On>Gq-!9i2GQ>QG+SGse&`%zXOM z^x~r#GwJj7QD#yhZ}a0F9=vSbj;RR>(*?*Ov~BBQ?_GGKZ^y*a@h=?`@aw}6&nT$Q*N;mFOCCA z_P#pNRb5?qUn-l@7-wHJbFV*;T7pSm14b#UfMZdkZen!L(W{53)W=g;SmrrCte7)T z*zl=#C-KO9=`V$g@c@3p$~PUizGPM>Eb=eneW2B;F?Xw^2=WX`O@BFlwFT!{Let*5 z|7YjvJKDWDpOdwlDS;zizF!`2-+T`}lQg5!K3UaLXf%El<^w8}*96UGkt&a3Jja51 zRO-~cA5CmK6NCAaIAdl%d z=2UV9j+LB$4LJI&yFx4*!FO5@xXyhKJ=J?97N;sytN0loYO{|vr*J%cxBRt_b?0j) z#rklS;c|$&^cTp(wNLB?S6`OqPO&3}t*H!JLMy6Do4lDC4#2#7=f0Dko>Z{kdpwn@ zpX0PD`ss}Iw4pRY0ryAm7YHg&r1?co%h2RdQyb9mDKHKCR%lhd^WA^N@QZr0eY98P z^OV8Z3DJAK<;A{1)5~^@fm)rqx%>8qdJZyUX|g0I;fbsx%bfZ_DrsVDy0$k~=h)Rt zIrORmkmimz0sJIGm#G^*n?Yv0#x101j~00HI!_2?63)|Em4=iJDH!#gCSA zM9rJg^~d{;o~$cfZ99d8emj?D$1-nwOT)H5Zs=<|wr~@>Yw`c^b=FZ;b#1#RL_)e7 zr9-+~I^47fN{5tmH%d1W(%m5-A>G~GUDDmnnfQM1`2IQPjPZ=|i~*bNUUSVg=RL3M zcinq~vU#cem6J_bN%xOi!T^yh$ul4BscjW!>P_#yA4*4-Sex(1PuIEhG7U3@f^2a; zKMt+Q}>h)yT!u*5+FgDt^Es$Ch^D{5CF! zPk3Xtj{1#uew|7~<CecYtd~!b7ADZl7GKHW8Ou;@JA9RHhG*s5<5_+rZUvbOZbkV~ zrwf^^7j%7>83q>+X-LRME*W*!!It-7h*yrRApVTuf)n$0FGU95WkN$qS0pEe`6HE4 z+XkEQnA(d5DEkBTFgtV3R)5%QR4XxG@4#8<*N4IvQU8C9?>94Xo zM|}qym3}$CT(}qZ6|{&?hJ)hqSK$A$3+>ZM0=L@ZD zH?*-y99X+ZTw`hYBgy3sMxQzMM8s{!LQzNqxc1ep5;PVf2pvZnU44sgQ zGVKY(ml>iUGB%*N=oQRf!U*NtCE?V|rSA6aRUOK)>g|qIsyu#`|556^F*uT434#mt zG9rG~;LcEzgBB^&NgRSNCfO#993MbhcK8guy+2`#FEtBCR??~dKs9= z;)=x1hJtO#FX5P_QZyx=5b)r8&T15xsvBzq#md? z!k_mIdR@OOnLQu+Y3Xz8AGiQa7K7}jr~%QP-VL&*qp5eYmog#Mp_P(Kwjmj8>o_xmm1r75~rJi!e zK*2HDcrk%q8&o-srFGq4{MI~uEnhR&V-J2oDP{W)$xzBIR@p0a0L)bW`2(GvyuV+g z$wV!m9Pgb)6lcDh3IM#26n-aV4UP9UHmvOdXvUzYRs3K%oluJUd7uV+tBcBqo~`N# zOin%O9n&`SXdxLM-)y{K7%Z(6&Db>)x{JR7IIAYLE($1 ziJMqh{GD!owl%FE&dMg4SP=ygBV>(A?xr2y zG^%)Z&DQPtPMie*l0g(gC8)srOkjlIGRpRsxfx)4+{(U2Us--USY}Cp-0vuOwH-K8 z_@)Cx6q3vn2~X<$t^TksMCV~wEtH6xsib__{Y(z{Yo+fSr;9bZ_h%~{=Do;cdt_?g+E8jd6-PxpT5Wc>C>s?MZjj%uwc<1taGh)s3N_i&}|DSzp> zwH&~6DRYWPGm0`eEi)ZHxgpnxdGvUNFhxe`wMp}i6T@6cc4dYM1_k~%3Tc4dgx~0c z=s}n>=nAt`S%knv+E(EtxOwkc)5ZAi2&H_Bd;b%wbu+O;ES`;*)S2$(%bY{R~?(R9Oc||{yn8> zs@S^n=)>rVLa_T-sQ3?yLB5wP$6x4x@?_z|ktJsRgvJ>H?(2 zxe<6|XL6z$=u}ixAJo(;zVzchGlJL>tAOUq1aAZw9P~FY07t0@P?r3{KEQm(vl>Xs zK3+mlsV;BapK-vNMa#mXqT!mb@g2jZ2A78W)Ba!Djfo&B5>CSJ-@dw1-p|y&cX4{V zSBiz?M+>Ob!-ZN9Ci$6mzd-}I#$X(V=N|$CmxGh@`2&^%q!KW+L%?2s{>9_{jrv<$ z0P#s{X_0Uxg#m1Q{p>~^+%QaKi_SOI$foL8*8Y>H-$-8vil0*1VdYZ5+#SMKi^W{fqZ9 zyeR!%z6LWK?^-n#{r^0%G~6w{vD(GqksRs}G-b8eOKY36qP-a}gldM(@qA=ro*#hJOa+YDS<>=7^%sCmRCqmka>!-<`h~2j zI?P9|382Kq#}J)+a-HioyzSlHwdeb@YW>@KRn)TfFD^X;Lju1O6L|X!u>f1}_AgNg zeaCmQb94RS$=rYFvPfH4Sez3(Ki+J)>=jpEKVCH}sH);D-%eE@yEcLx1zG*#bE2ze zucRKI!vq}=BQ1Yzm9(|x!R>>6iQHmY4SPK7^%xx$g^BxC{hfsc;}Zyf*|X}OlgCDS z@d{FSWRtUr?IsD?*FNg=8fR}tjY~_=(^Kg8F+jJth*0)TDl4P5d?WG<)(=})_X!I? zaNx+i5gdKMwxblCR@Nl-7*_rqbkribxLXLto`M2#;^-0GtlcK=tB})N+X(fILSRJsx|{%<6os3-t`kN#JS4 z1PhZ$%f&Zq-;(lJLwy5Lg%rE`u`y*Rv#(#F^MC!?KXI{20XZ8o z0v@dd5JYL!{n5|doXqU8^WJ?=V|lG^ufza6lD|IxL3&+i!0lL{0&+%DUZwf@ubG+A zLOR>>m9p~l^WPz~CUSnw^pg7WC8wmwrIU|}Vo2G*U1m(|*Dd93R5rZed1T`IH7cq3 zghSrk#?8dUQ`uv(dAYMaFY+0Ce7n__Gd_Y~+e?ug-y)d~--K23eFH>-!U#&^$mdPH z*Vm|?-}GoF?YUOjaH=<@nM9ApsDYe;dTtv}6FT2K6Ri~w! zJQ~}Cr}|{(mTKffRcM9{*mGXghMJE{MLkGUfa=@OEUUQ7y1jDNJSHY)WN9gNq^#+7 zvUJV1_WIDNxu3h~1ci^EAB&!ul%qDs~4`(p9V>{GZF$<(eJDS=I<-lK;<35(!E`jbJ6NR*iG#oX^XE_Zc~+C6w)T+V)q)sUEZi>l zSB-`52uLHg?)eG*?CWi#>?i^XD~M9gBcs6m$4kP;IEGGl;g@$~W{V&DW5AiX?DN#a zAZ1UGPD-48eT5bv@|j7Pfs@iScffu(+IJultg&ddlUho}wGf4dS) zX3&e_%f1hn`?F#!_=asw5^R zf~9-(&*9?pI59VZy2Ty5f(9S?8U9YQNdmA>ndbv&1f$*paJ27%@{8j?ha|!Eb^+oC z@uiPopU^Td)OkECDU#oF=cxVd)yNCIwC&?A*=Xx9-(FI&c}Xb)vVj!9;RP6FaEOa#PFz`^@Pma*2D!>Y0Z-a#z8%UZE$v42m{WcmT z1fjlOz^6WAwqW;o0Rs(YV{dlEHfIwR_u$IQ0Fa#OyoLzsglU661)G~jk9W;aNvEf_&y!tp8AKrnpXvY3V4Q&@ z?g(%%Ncn0tnE)SWVrrUEQi3Ws4)$Rf`4m_HZo%k+vN@E5KNhf>V2=gxCx~gJCMFUB zIK3CFUYT1_-RrHZ){2UX?=&=s!otE#51f`O^`$( z2JJw|9=4@GX{Dm0GYlSKEUjJ{E9Ge6cgv@7VCp~b@efB{!=S9f=QP6xeRg%nbLBL_ zr*q$APixgL6X&M~`_nqei)^Yj-{L3;KX6npxqkhSSH0|+BoTrOa@xcnf2p@}H{Zy` zGHLbUsA)or4}AwDKT6T0tbqZAP+CO&Q4{~?xCD0N)6FES(-j}IQvgrfJzPw)+ixr0 zdkQ~?5TAKYP=HNLOdMyCJp>k&)CGeYa%5tn1H|P!K!0HqGc$cq+=1|$Ox#e#0N$HO zX?+nU8RnpX58}MWbG%Mv_<}oJdPZAR)4o9x%Mh^4E`nv2oD~6tz4Z2H$`9xJ<#0Is&#_KJD`gjAewHSJ;F*woUp>#yyqJ)heKM%nzq{l2kqR%2rd zh*V)q=z(=)#=L6V5u{Yt0A1W+$R=^+1>j$3FiG`5jmY|!I`Vu2yq_Uhc3gpob@p%9!M89|W}~H}uHK)cN&37fC^C=E`sCn^|fd$qjb?W}5n z$k4C}n)=cAI;Ne{Ya))5<9;v$bS8h>?zu>kT*3Li;ztp+4CSX5fVP1^)+b{8<>Zfeo5r4{FP+qpj+`FLRjFK)#g4@KYveWadM|O>oc8#o6*u|8I>hYIqKFqFb zjJ`60olRGu=MmHyNWg?L#=ISXM5*W?|GQKXorEVfOlG=XMAGi6Pv`lL9}8d2*XF+) z-jU7=ERY*%Cd8B(_;)L_ja&?6bRuszK+c&FMetdmvdnJ6?3Hq{?M-J#P475^!DJG9 zTF*Jth?Ncv0izye9-kC5m~CbmH8I1po#96;#LZ=Uv%4>ux2LdF2S&r{Vm53-Ez0!y z$q^A6ARtMgpsxPmDoq)6_NH5r@`T)O=nHqb+z+6DKA!={M7X(>(-tUj8`lMC$YD>n z-s95C;FWDhAG6vsYS}VvT+sYehZO#)PT4+|<{JnfE#HK32Z>`6*-~-Hxc4O6{qMgB zUOO8xfMH;67#_bbS-PhXo(XO`-^0IG=>5Cqj;euD^Vx9;vsk^@Tf&zqx%oYjZ2zwO zd_+yMLUv8!a=Zc@uPDd=2|tl2)nF=akk;)mDp3A+fP%!yBgH`p3H~Q-3&rbcX>Q1s zzb~1#r$A311V7tn$X!s=zwEkKEO6RGff!5Pi|da@HUuKrw++qzq`Li7f}{_EJal|y zVwn73>{UAOi4w@E>m#Sr#KEhyXVWv-Y(RFW9ua<1 z>lyCApLJ-JU5CG8Wol*?1uh|qRy2HqAQ5a~U95)Up*DdsM?AwpACje_nx3b_`Is8m zlAM1?LLC32TmM(BBT+MmrtembBGqqvT#J;xtfM@M3bvC>jouOS@0DRgepc;CYt0M) zjb7O{<1j)HWp&Vb?a(5o;a7gWR}L!()dtf7GaP7KtF4&3ZPX3%m9LF3px#O7PBlOQ ze(#+_CT-;P@Q+!)+<3%9trv1k-bQ$DDw5Kv&`NWBe|P?@YF1!YJ`s46rZy(^_Ey$| zQd0DJ_mn~{B=|QV0ZWn4H~ZU_37o;a`b-z~~}}NvHK4h#XSs*{9Z}Yq9E; zqK)O4zP<;QTU+KE_y{c+w5@*pW?Q`8)%b zl#-0fzgq8R_9qH9J>FcE*ylB;-Vb@y2nO`<-;~2^DE`sa{QCmJB(O=IX#TVEbDv2W zYHll+?>GH%@CPDkd(0Ij+w57v=v1xi=V+w4IxFZ8Fn_=`DC=?wBWgCN#Rz>I0W$@2 zmwecbaxMWgSTrvyYO_s-14k|=M^_+z44*d|Rw$HeM&5M19=qByC))xvIpW9g7^LB^ z@hD2?T=3&ukPul>NAu8Ngyl6N8^pN751*GrKg3{-)Ms0{eUDR!z{=tr-}<=t;o3=s zSiqmY%_JC!91~^S?aqFDep~6!>%$VU8i8(n`!xsbjEsVhb7zuc%4}hqZN2t?BRCpZ zDZm}u5&t(2FVDijdVDp)K;9K-*KUy*7r7J`dTg2Lweos#&nboQ0as=(Fl8x0+4uFZ zN=Uy`=$mfs*XFh(ifkcu8aK{b3$|cX=qi#S=m!UN`cFoxA;K zx5bQ_w_Zr-o7|XxJpnr>-r+Mh?RvwmW*2Ul6MX; zAsB-m)>Uc47oYa;?3wv8&u?q3x_2I)5Bi{PUIa^fOqaV6hSKfl{L({#-Bnwy{%LJLf|HD28ULf0#o;IG522u_>81b?DVEXe@+|Ywd*mzk7U^L(`vjz!&4xgda02H^Y-+H5o#@a3`30)Ac4lN=ZI2!n~#D%8;n z{4)2T^dR_8=}}&>UAj*ks*}z7qG34Mxb4~$xfji5_^p)e0De|UV&!gn+?JC=?sdLw z>C;7Lc1u+GOaD0!I$mvG^^5SUWENo8e@c(t(Xvt`m;uY1V!a&~{tEUH>0;GQ629Xx ztN)Zx>xoF5o#8g7_a$aZL@J5FEP?#msg?JQJtFR@RneI6j<+i?uM;0}(#y%awa31O+){oElSnfp7GvH|H3*%#fDcX- zdY6yk5oXwSE=h$9)8#hBK2-lbZ0M%pC|*iBuUk~GMA$&R2;(hzkNA@tjNQOx>_Ge}|Zf;?}*AjhWw694May6)%pWdcs}7+T%Ah_0jX`Zn@&?G?QiX zgJVhaqwDVOuJfuN-t{`!Q;(L@;&VuNTuTmYXErn1_&_g7Htfi334XR^f2ETxqh@28!yEto%4 zb$nbk_Am9HpMMJAT~f38YVpoUYkepEj?l)Q)Tkq%*|2X{W7}(+D#m&@uT7S6R`fNx zE~sYrL8E|Y!)JHbtgo*xp2voK`DVx|UeJwe`F@jo79?pGzBJ!#Ch<-1w1N89fnk38 zUyal=bgCda<{2jYM6%Tw<;+#c?qBE)c9SNCyU=W zc;xqO5`OhbGk(=NnhkTAyO91U&8S-W#UCdUy)pk%#il14Y>eQ|t{!ph7nY-4lLubw zYbAKXiOrNE`ja$5e&Ud)^LG*kX1cIxR!?L`cS|j~E=m`uJAJY*5WY_?cZ?h~8}t<> z(F!$v?#{|tOQY1Fzxm7T6|{U5QMksoT>O4E;ahSdWu){iM7^aE{O?$av65d;;ywDJ3S%E?v$V*Wk)zzx8|bZX9MklIF~mBZfyK>% zzdpoJsVK%oOSZjO=0`?7+)6u4GEqSq(_~mzBIWDJd5ohQODvbXtF$Xmsl1-YFFOga zC)Jcc=qO$7Crdp{l%XW}KD@OKx)8MewFT+j26jtuHi~CAMtyGC!^z1BtRS2FYny#X z{%1*j@1orHuPkssK=;al6#lqkjq20rw0j^`K6jV_KLk=t3eYWF( z7k2Oq(2WcVXsn>hHi`+k`ximzC!k5jeYo#tF|q zbU8KjMSor%fSLuqZ~}2?~%ct&mx|@xfu` zk)~GnOaW9V5r5q(;Bdk+ttsB~KZ-XW2W?;%`Un+RE)V+FbX?J=oeDrAr)cHywML-6 z7dgDqhFQSQIHXGjvL@sWW3&Eq2iX(`8`t&J6Rmlwqu`gSI<{i)Z6}PnUk}yK> z5+9w_jHz}56CC_lwVhYtpn%Ns+^xr)iybuf0;g4=?XB+VJn=oBw9$mFUxQmQF*S8M zB8FxI)CWw|fP%upwL$LXjalO~IA9R4I~{7Kf|d%}ZU>xDnoM{&I5_cxMxZa$J75n( z4GUfoUtC;hy6)tJ;{ zViY?>yuXIQ0720*G74@vE~ZUSO~EKCDUGh%1+=$E-27z+QWc`w9pv z8Rg}eZ(acXE*JQ_P{;&qzQ7&6S|#d5{&(WjPf|+EVRXVoe8n?`vAM))Ap=$<_AHHE zA_hQvRD{9Wa~Rmi|0M}&kABVm@HKwZElHC%4OC_(B9e`6XyKO+K{&V-uyHL6A}2prow|KROt&g#0-P*FxDbe>%wI} zsESn@je5TKi$}6N4Z6&X8&jQKyS)_<`?Jh}%vM-<$zoelyGaP4#^QI}-?p1wREZ>Y zUV#P;T3)n(9vSQO%+b$|8o3N1_{2|cMXFbmF_b%5&m07Y;+3hajM!G*c3CUqz)54O z`Wu2ka*he>Z=-uh^5zqKID*+Pvgvql`To{FCO-et+y9fV`q~XYQ*qW2+!qsOM~E~0 z^c`upI6jVPTQ;o3D?KDfmfK?Hby?B#6!EAqWD-8?Z~p%Nc9zo*H=wgkls0_Qpd^@v z2L}g;Kke4TxYw+CYJm6nZ_Vspmr+Iu`$v-~_zRH{#^w{Kj7MR!Q>#9(7udMlC$SE5 zqOB>uZi@VQ7E993@W^Hm78=}qPF|cKv4r<+0rIgpkR?}FxGnZUoZ7Mq!KX2jAr27$a;9NyIyX+Rd*#8jhE>#5ZsM zJ-L7X{*8%^1&*(jRQIgh*&&?_2<3(p-P`?5*#VOL*xVdp%EyW6at3<(>!quv?)#gw z*+w^R;rmszo!#Bwq5;T%^i(w>3qdf0<_I?LijuIn3^v4~ZFs+QAwvoYvLcLN1al>6 zMc3(%RA{97?XJv~E74u#G-&ycA!P3ZMbbDRa>~A4$5%%ZqqZ74swOc3W(7#UjtmfQ zx3kplv~wpqbgAsg2@S7gz)!oB9=*5H-47qE6Mf4gzH~aAweG|*9 z!U~nVhjQHk=&))9e0JK{te#K0qLXPA{Zy)nzFu7t25g z+IqWX_4D02{aNbnS5?I&biLwpMkN@8=Q5vZ z0Zm~tB?myChVAWbUCtE`yVr$wGE#o z@Mk~{1-Ab~Qw@i{yc66re1yqrAnNXGDIRxJgC~8bHiXH9KLrG{>qsZ zF)Rdz^Sbk4wQPn5|1#WjXap@ zD_WP7I~zCD$Rl3gd8Zc|zfn~Aow7w0utX4BA#V|AOh<^Wv8^f zxKSLrd>%My)KUHp@* zni4Q5Bv-EPK2=+i#A&y~OojC6(6aP-C>40|_o0$n*KH38A za`@C2&{6C;887^FGu!~?@!L=3zQB53ySj4$TVj4mi4kxI8bE2e;C9rg>2~l3Y!Tx# zGd((wr+5HV0(yL~iJ~y)H&D%?^8MSLc`IKua6@tV|BcZ2H!hbAo~q2i#KmN z(OE8vD$NG~;RoS^Z)PUup@AFqZ$JJrKYqGXG8pU+7?QNqiNF35S46D+_IP_yl|f*8 zHDn3;It>Ek2rM_5RaLQ|BP8$y9VNvUR;`Z#q~HzC7)VG+=%R{%G2r~_i~ihdM2ghq zMc`YJ#js4;1<$(!4lXXR23zwG3x?}e&(~fpxtUs7(EuC#%?n@_v;h~Tnuy&9x%uhg zC~N-Jn`^)Zh3A?A5=|oOYx$_}GagI%i8S8?8Pr}!O|v~7uVj0xlk}I=cB6UExeWRS zQ%>Rl2e^lB!^(T!w4a@Zh>^R!Et}}?MIP>P9uXK67v=LL9!YY_p3chE5EhnOIO8-b zv%EyLRCi;HfIS7e^6x&u{rqh z!YXpw1u7$7T$xB>jv1~vLZw!yZ|8jt139FiId-i@iTcmOtLELcZ+4h&DdRkr1c@>0 zonmiq39rZG`Q-DWfhE^5afyqKE$&tIx!Cyao_S;#pnoO@5HsUfTJk!;na#ScAZQLqaxj z2|6Z!HtOv-)yIiw-b(Y&cv*EzdXO!?xq*k7&BS)5ZjO8RN(1IzbC~Z?iMgw1H@|1g znfcgML7hq7t4Jex=J4#3yQq|Q)ISM&U%<2Cp;1q0N0^nZr@9jWLwGuX0pl}%bP@ja zj0=?S9MQ?4XHtWy8iu)mukn%Vz|Dhom(0(_lN8liC;c_M(Qo@tGdLK6g!gCDIQ59*!9 z%VY~O^S`i*T|I_1gf}8|#@Md^)y$^}8}L?y#jARVPB>t$m~?qVR)f3pzSX|Vd}MkX zc#+TFVCsu50Lp~7nl->9_0O*VOb`V0e@wR0)x&#hszHXwZwLYJ5cs&8CDR|FV1=*T zJ}=+$19rjf^h^Fn_!7idyte@wWicPu2>H?ezlhyOE4Uh?%A|g&y0tmx@pBn!{dI#Kr7-v~0E-NwG!VEqhNl_fVzDg`&rI+269_6LY z6&VF)Xg(9}w%I~D7py6a17kwzu-yDHO8bgZcH#wrhZ^EvyW)#(Xt0*I(kP%kGqhS4 z0@7ncS@bdyera`|bm{>=^f25bbmoNrbnZ$#m+X6n8i(=-O9)i7pcU&{@ z(VmMH_QdDzAYeI7;WFfU&!17+VQ;&6^iGv`irw;@no^jl11ABA63(f z)P#HmHr}|Y|2s)%I$k^PTU+uQw;uCQ<%f?DuYQzD^W3N+>yBzXvD^li2W+>lVoi$j3}!fYGr4cO5x_~E=Od$bcy9c zLpIrtV|<*YUbxkicN=rnCW30{Sfc2($iFu}(ipsK4C_1}rDH=H$>F7A`(xYL2@Sp^ zt~tTo3|424py>FKX17mDXF-UgU`FFH^Y48Wi&+&NL?G49-;YG0a{BX=~ zxy$x{GF5S!?B5k89b%iZ!za~T@G=iGJHQ+-452T7+Ud4Xz3v;>_&e#W=M*@fb~Uzw zfdH+s+s5Ji;_1RajpBgtYT!>#hE#Flcc6ldP8Qplja3C`SIGht|%%+g$7tF@wrn1NrWH|5#k7XLqD zIuSzv+Q0A%_{|U$M!T0G1)<2WA)bP;WZY8rO}U0$MMyLmMm5YfR2a&4n+MSUxbR#CvVU+2!>?;q=#==O2ZwL1bW0@=XcV6I zJ9t`_mZw8SSfJSUZ>UH{_h(wS`$iBd^tRZX20fkQ#CBb%xL6z&&^b5au>B)ph^$ z?P@vJi*s$D8(5>c@T)Nli;11h@SDTuIM|Aro;E>B$|sw z-|sUQGUq={edl&f-0lz;*73yiUgsG2#aUt*$(&#io7vhjY-g%Py*f}Ho5y%r<52FG zME%Lx)$NDiAYro?O^a|W51mc+Q;-&hp6C=$<_fZ7WPo|+$-xi9Itp5-B_8YDx?K{H zNTv^!*OFx_69``$Ghg~;qEEem@$*66botBXnQ>1vT%h(_iiAMU8Od6dU8?d zUmQ7(1jV>{E4g&vR|#97a~-jJe8>h|nnQ=Ca4`%waHASMk17kGzY=CQ_!m{<#=kQEgTH}Nv2F01& z@GSZUr8wV4C~o=6<3(=w`?Uiou~({HS1Nav<&o{kcrp1CRc5{%L+X;zQY_^>tnDS& z*#-57*6}WRj6QaL@l?df@GV&`wFoj!@os&7sRNlXt_k4d6lvuer&sO|j76AEI zHms`2jBlKR<6~I&f=+r$lQ4HzX6bh_6-r)dQON-S+`Uf)ZJi7!CCGQMfLH+puS=IF zNNC{pLOINEssj{9@ipuXyYP356ZkOX zNJEXC4411M6qI?6sNDmq)`ZJZ7*}#E4%V?XuXjvp92;m`x~2GPO!aLc@sJ6cgk-s| z|4F@Np0#uHJRY;18f!}dR5bgD27lD;oQ%t~A8WxUD%oa%`6 zlAI)mOk02J?CE@rfMw_>W>w8eec$@;+faubH(Bb;eOJn2hwbq6>xDCI?Io4FU+Cbo+*##RV>fKHcRP!qM-U9Wy}`1Ae_LR9Adl`Os}9@kT+ z`u3CD@t;|)!^gOYdbR888-2I5{zT51jzj9OMLELtTaO9rS!<4gI4sCD|K3JScH$iR z6YJK8Ph?X8h-Q&=_B$dX?EI$1>M^G-RG|~NxiB~=0mrAkB>^|}XQ(jv z?dGx4=hUmjtUzB}S4q3YeisqxZ}+e~JsfI_{7K4SzZuZGAZf~lNi{1Ypz{46;5j6K znXP_+Tyx`v;5Ns?OJQe8(w>)+*aU<8xaK2x2~vt(oK<|Qt`Z>4$t?%bcCH?ca?VY( zGu1jR`-07Q4^wzCf&KkC@+VJSO)Q{q6mbxp1M2b~%(d~H?r1VZtfHk8Que8ga{PY5 z!dR@X?D-y-UnVfxb0mh2wLxD>uhlsg2B4f=v-EwF`O|;ip+OGI#B`d(ob|B8#`cyEl3Zs6FzTy+zrq(#Jk?(R?$*&mm zCZ5GMw6FIZt}oWz2xyPU(CoHCgdW`@!*0Ze9|Aqye*Vm>hQGOr^Js9-Vo%J2TQUCn z03k>pd#XgJb@bZkNAgvW5aV)v@iQamdeU7tXo7Irc$i{8zehFaPDh8d zzMhUP7a2!gUCE-BJ$aRwQIYbENM)R+6^NSdkmwV#SRP~+JPavEY>Yds$ zubf{C8slZR(%4*6N;cu6+U%*IUtk$+vrrM5`#4=?*5LGZU&e_8lf1nd z@{=6An(*Q3xPn{4U9;AU_T!583KH|bN>D1?jSYi$BbHC?GF6T{0z{VhuyZM1U$>eG zTalrj`Am93J(e-{yr(&v5jeJiuZC1vcRsl|JKOze_C(+KEBNN)(r$-YaPht(rXXWR z;t6(mChrheS5LgCwtwRjtZ$~1K8ZnACaX|Qf>H*_PD!wM6y`>u3}z6e9yxVr7!ggj zu%UJ)87KkAJiBf36Ep1E++UE8GjSyq;bYCEE{LwLw7Z6>!izOni*3~19d>?fdZS+1fUDoS^ z?mm^NoCRWMT1=cQF|q2>PwO=M!b&J)nOQEKl6OWI_nC}{5|qSf0s;wh`IbOK{@5xzF|C8Sy=^IHjL?Ak*5XQeEX+Jy{2;gW+)S}#` zCl-4c`MTq@?N=G5p2qA=PDT6e+cdG76lvYMCBk{_YwoUZA49Ch+PRJUggwN`?tSo zKz?;(khbF;f5n>I^zt2so@Q!RO@ow;Yxv3~ao0gX=T4EBAR|GeA_U>XyYxWofvyUf zSK?k=Sga<=l`S&M)KOB*wnxgH1!tNn5a$6n4vl_O&a1phP01t&D8@}TZ(c)%jUgTS z$Yi)#>+T2`P0q2ts8J6cSN4ygzapj<`FoWo#v%B5bPl|F=O=nEMDv7o|9D{h)PkqQ z9;_>_)OW`IX^>@=RpBhUELr3*L8BBN{)BlGMpWiNA|W#x^;a?^9X+^jekz35tZ>^0 z(MRYucY9OuD`6i+IDgID6-Vc1m9&x*0v`y67Xz9qH0QM4w+^4ou}^G4KB`X=J=)36 zx{0l%vwPhBb%pQG1`YpI1XhhMtc5i{bU!?3a}H?Q+G(iM~(jcM@= zIu~mRt6zVx>!Z-G^Y-orB;FY5C;j_5RwJXhJSVu8rF_vWHcg^wp;{i!UP6m$mIb`!R@0PFDcsYAe_=omMtY=bh+X-mY^2c_c@885xDAWm42|ar7BFjg;%~ z1&sb2Q>4fFq7a>q9YmZo+ny&KeT%GDVFk@X>AiE83wzqGD*_gIhC3!$69?^aLiKef zuh!&O<|@+hfdu)Y@2a+Mmh;Rw^pg`_VEW-LA(gkN3S=VTc z(b8|7AY3mKfX10fL8*~CDX^zP$+WT#-1c2+Y;qr*UZ{s(mV4t z_BfqbXsJsRD|3tqGyehS`h6d2)`!qj0FZo0;+BlwAo(w z2O*+(fm$?~Jxa_V@hIhUQo4#)%tllLM)PlTUrtf@_S~AvigJ#sT0_?x-{TJ&zBlQZ zGohF?&MdO#s5iQgS32^*Q+>6)^j+5JgkZSB4IB*HkLY*pkL z7XFS#ec@q}io1-^%FI5yQZd!`qV^Tg8|t6`o_YUz%C~n@qE~8!_=L_6Yg~b1TeV`NSw&e=uRYFp`MCXO zsmi6?IRotR=u%oz!pGrs>NJ*_HZFLnD3GP)EUwtJ$dk?cUI}|nNXGDaxWT7Z)=*;L z-KG>czg~d|9|D4K325$k1yZ%mpIPal^5Ry{*9K5)%OP6v@+YBVQ2`cXCVOL9Pk?I@ z+{PT>BLtWZ(Utuq*8|^6u7G70%^l-EdiD2jcQG{6CH>tQ#zicpM*Js-SZby-wVtzU zGcUymZ`&)i5d{!4m@v`fQ^}5lOL%J_gad35tk3wdfrPCLehA;Ke1}%*+X6=Sv)#qG z;TCVhd{D>TZK)Zrc^AyQ7kIS56APPyv6g%6$w6yoQX5=EFcZ`h=8A}|i5X<(?_8>F za~mP-6U11Y1U(1)n`PjXd-vMT&*dKp*6pO^Jed>-qTpT!!l`M+wRSSS6SuZM$4^(; zgv}32;^|3?A4QvQ*LiAubdT)yALU$@UpUa|749eR>WbeYkPgK($^)qKmDY>wf+T1_ zN4?f+e#;)*H}J-@16&+rHyG%YJCpWB^+Kds*a&~tO4GR2Q=F)Pc)0;P;mi%G; z(=;Tk$6Qu)V>nz~Uk5vODCiolR)NbQSh|M)goSe+h*J|hIh}& z;no-%zaIkxF|?-!<7%cm=52Xeny&?z-uC#-@xwlXk-l2Yj6I<<1(&4N-lmwis@u_2X145jUPoB{;sxMQx>ExIDv zTZflFImW_Me%-J6$5d2sC$5F4ZoJq^htQQ_OHvm?LFo01%FqR0(ILdZLrxMoBde&g zPOypy2e( zPFu63vezzL%bYKI$;EkgTULe^gh&$0Z!=`aH`Xu9aX`^($$<*cklh*{Pnr)P5Ey_8 z8!|Sg{N{|QrLEn#8A7;F1HyXK7TB~;1&FlB-M{kl1N%w5qTcwVr#`1%Z#WSa?@gpT zZ(-(U2s?nTd~@%*S+*<6%LC@B0jhj{7;uf?1+b{5rluw)k^$!$i8VWz;< zlUc}lX(xqODn_Z1Rz<|r1erB^6rHm(D1I%6TusvCK97^lnqx|Te3XbeYT1b~-E|?Mt$Lk=4X%=~+yG1{szQWIi)-ik*iIejE4)^1!0+hfFON5z z{xl*di%kM(s*RWZ?#qAV%1fM#e%&%_YvH&D2j|)dp0=b*)bY`k%|gkC;ZqDJGi~QZ zGQTlR!GJgfLs!q4-;Ku-fObv9Z0Zf_T(;3k1 z>Oy++5UQ$Tzc~fmP3o7A3&AFzpsxVF@4(ntVR3Odz}+Pa5|tkWHdi;mOLc*IgTVmO zABitQQRdVgW$9Syoa?h{@lcpXu^;W6={WV!@f@qK@9-0~#q%Y0F7R%dK6d%ubOxhC z+?ck1Ss^$GniT_ig=KZG6(W@nu(plrWL;{_!49)aka;<_I2 ziUlvA$~p#p*%-3I;8_#2_K4QT76#c;QYJ{va{8Yg~8e`}QIUlWApcV)6{`Dhrwn*Q&Eb z$TEAUJ%v1w=cB06myTKR5oY=i!ithV#DvY(oHyu@O+6R7v4k$WncSGvkZg zb)wxB`9078SgdX(o>k7|SdN1)qxzot5RQ9UJTLXgMa~F1?4jLhSQ%Ub-xa}~rr;m; zQbGe26AOEKgYU-Whs5F1)-Pdy`nO&+4S=ng-1W~CyOTS^r=n+4bd)<(6WrgK1&{?R z8xIz6V*jP7w}C6D{T#pERQq~b?8sLi6ho~PkREDzN`KR&KAF2mrT0UQb$c>p7Ly+( z=75v8u$t+}(V1)zX`X}Xn}p%-5W3ua69T4Zfig|;P{XZnR9JM@@i)b8{kRI%+rSRV z_CU?jblM+E4;)v86(>a3`&w~jK{@qTWKy?*$gm*i>?ZslDr%jU_hI?Fw&u=31sTOQ zzv4&+Gb+#mEycU@*n{aa1hmDfK%)}>#yMej6CNKmWO%$|6BX@0)fbLyxD2@ZV_@886n%iyn~)bA2cqcKk)`l^k=SAlB1%T+cKk_HXX>Q;CPbZ&O)0iK{v& zq;7%Vtg+|6FR9aO>FE9@Vj5uv6@n;W3!V7007mvLi&aFjDyePG{fPO0NYpI1wL!oO z>V8PQO&st?8F5jg#f}Jq#q0Zh$ zypSRi#IWg9S3Up^W2y_2y9VYY?o%+7a}X!1wH^V|+{Cwvkh;v?U(1Qi9h6iWcI+4g ze;XA*gPH+CGsk*rU`|qy--6Yyz(Gc@@~C*@jAKDe2`# zLO$Fqcrcl`RYqX3idc8f!MAZNv->sy%kpTvusdyd6XLXAXX-emDxY5zv;x6h@gLH)uB!!@~piqr{TH3W!IXhtiY1$LoOqYdg#W~y&q+R#n0Uy&tyNPiAAyUcKd&wO_ zPrdi=ENdc?W~C9>=qUe(2@lL$f71AyxIr(AZzmu@(hcxNM)!no`FsIfWL#QWHb&lb z^f}Rr6+3ITu?(#d%`8VU31KG7`Yv=Gde`83&2Xv>u@QCXBsdrwTES8f@&xr)OH2or z#Fr`1^3?Liw6v2h5IYoA6`NNQWI)=O`>qnQSAU z`gv4sr{Ux<#g*teZ%vTNVNsW2NCw*zhd>^txMNT!RwrD;Ogsu^|^09l9HG|t`D?x#IvpVOQ5nwFrzDZQQ zeFtD_6)m1=5Q|Cf=*D8I#&;;lZb%HF{unx#n)2N0;gW~)EHbKHgz%!N^8TJ2PBkSu zRHAe5almrX(iDe@jN9F6@OoF99c+ykpO#^r9}Ya97jt0+r3p!@iR~IbG~;yGeh`(n zRznXDDHe#^|JaoHi;1wEBIgGMX?yh#X15PQYmNaiEoARYYS8ujyrNQRfsiBJQQe;1 z+(Qz(5}?>&ncT%BZkBiBSa#xA_7Yom**;Wi0)hK}AjB%y&_Ws76Ia-aSy-E7wN7V= zSQS)_w>e!MI`XL~_%0#rBO8_`ASD@hLIw5;#gWbP5i)85`{q%`hsY~x-%Cf_zG4)W z3+Iwx_VY9P-g(0RShqo{EY&C?*FGaHhk562@0NH6(cl~s zEwZv-$V*YF9x8f~{+zJzISGA6hiEu+uo93fq2}}6OKIp?>(yPqPZ@B`uQQ(TMZOWj zy2ppaP|jTG<(g?pCLQ3Lxs|MO51)w04bHuSe;tyf@A^d5fz)rka;^>0IMKpb>5#`t zKZ<_B(9j|EOcD_g={d)C@1rH7mVWZ}d;MHKFR&xj{m~xh`0%ic6&alLjK(!w^}D3R z7%-%SdUN;!#SG)Z9EcFz^YmSiiY(B_JSx z8WF z$<>1g?Hd9CfD`@f{`esRH+~$|21DU*mY`{KdiTQ!809N?;=n#>;($>|S1^1FO4(0D z&LuY31QL@RGa81?zk0&nq@(&7>vJC8k1H(Q5Hfzb&Bs{n6{X#p>kG9TE>(LVHm;4(k>Wv!exziI&=fPA$hVBjYBd6;q z=d&(jL9^4C1hrZpWUdd`>P;sX>W6C_cp<@!^T;_J^OzVGOIE**Eq3+>Tg}Hk;y$T1 z9fx9{w1yUXkQUWm{29Ph;TX6Y)t5qZqr;$Hjl(3{Xw7}y*4wkGNl2X8`U#Y6-V~ky zr}{%Y-IKOQ+cA+>Z+3R}g(kpm`4lin+$+qm&1-5(2Y8Ht;tL>AqyH8a`OBK$0gS8O zP5X(dDRDsP%u4@J%MTwA==uk!-fX9|KT*B0yXsdQk$|cYB|7|BAE8}^!?rZwX4hXm zE<6lKEOY{Y`d{h^$fmr}M&5w%fas5o(Qj^fOr(4xSi+{h`Q~f*^ho|9|?ncDNK}NkL4<9%|Qjj^k!h$$9vM)SaXCk;L|Az(W zp=K+yEvEg(W-BK{2fL)e49<5)(Tuh$@E#nh^Gu8t+AZ3ROxuSxLqX(Qtk3wm6?R7p z#Pya*d;Ohl*-ry1P1YN&5-gphda-$TdSZR^k_UCbqJN0x9BD?eyHwW63QdAfj0qd~ zZca0TlG|<{yIlvaLkG$g2>`*b!k`oSO)GhNZs28yMg;%j5s+4U2#!;A<83;wTLxrk zI*+cm&fP4l*qa)W?)>9;I{;FT|2hSz#x%Jc z^&Ky*ev8kJHI*VA5>AZmS@7DBAF92rpx=Y47L1;Oy>PEEL{Z9RJoIwSIfvNBcdFS0 zV;W*g6O`e9>50E9r%3N%NW{*k%zDI2Vevl!mkl!r5ZClHg3Plsj4{G@H^s%Vaz zMzcV&dEl|XbB-zZo zk0D0}(Z3?~mwyU1s(6>@Hq{L;F1m7pWL{7zB?e+}1!JV>rys*O{XWMy#xhDJ1w58{ z-WM3=UM9RSc*@0qH6g!vVYIr|US*LLeS9?S&-3Z`RE^5kn8y0o`)WPnO>Q(zPJ2%F zz4d9e*V^$2^Vsm1(05Y=aQa%?pRE0bnsn3pgQz%Bmv_(d9{;fU_#+&R)sw!bSZV&( z4DPPTRWIwzX=ioZ8SAZ)QlmqTSvNf`>tB9Sri{f7VPiqfMpRv0gmM1GM^!ORHjGRulobl*1?&`31nTeoG}Y# zmo<-tYlHD@J3oK#{iuhZ^ChuuOJSMQ8N1HFRdL&l1*N^Rc}p==Le>mF4;TB86c;Ez zE%;3BxuzhE=-Qb70~+l}<940?3%RVOZ7tW1cC zYWp2%PA{elDyEuTZ?Cem#6Ngczfqxu(u!pHV<{I6}qhQd{pY{qlt?dUsQ z@~wUhSEqVgJ3^P%ild5u?`SJ%>n#)1vM<8qph;rk%IBB;knwJ%;^j@gy3wf!`3cEDlk;(8?XNTH~Da3B# zRfrI!NGEkz&Uv@Q%-4)u%b%%y?eM=U{Bw-HqnKn3j>Z$3zo^oIhmlY&}c-AsJcKMCM^Bv$F#o zGK1$d@!|>xG4ki4d=$W^S}JQdj|sIPD%MvMp_wTV;0%C;`rdf!Dw=qIJ6Jq|SWcP!t zdAj$=CGUdR?%(zE+Un}+qc;04J=Jw|^om12as0Y+;{@!haPs{5h*e7p9y08sFGczz z3vS-I*|)Wrh)&6}u5%Y9jr2f@OJND8^Wc%v2R>VUVk070(W;;rn6(@04dPpVrqW3V!gJp@hWU5mWO!uACfsd|19?!0)_{L8V3(m%C z43YJ=g+cRiC;Rmn*2~kw&9V|5Z2-0kOy znb9u5u%%u_wf8UXhyxo9Bf8azC;{c-`(1B&MZ!n=`iVHLH#SYzykEw1~a<2P2vt z0wt$;cXz7ah@Ls>?B^TMO&D`2DtLEvddyD$IfE8k18h9!1xSK~dKaFR5C$K+BOy=6 zzdD}X;gAq1d~)nsv3ErDP%hd}gYNoqibA{K)6V=upIhwwvu;Nsd;ISAxKQin zGrTY4L>LrYl5vl)J%I!(AhM`eot}0KoslJc#>F+eZ|Pz{3%qzw0E`q7Imz?>HM-b) z=HFr9`+1}+K`ooJTq)PK3VsJeVaD9)?z4BP+tHXt*ooP9 zPrLo-oRyRG6?_IwvFD$oBho`1g81Xa!K(>W3j6SMd=Q*;#bomzfH#eu=bQ$aG zrE(alUDQ>PQ8WVwZjIp(|BTt>PGXRv{eA^lqu&aT=u{C>yQb*-s`f7&$oJn41zE44 znugyu_{@ASlzTNNTk3F`LAa}9>T^D14~kR8p@m;)`$E#CRCNcEj6RkC1N7JbQWrz{uk zFvd$V1#w;@zlxQllMC3J@`lip;(e12N^4bmPcDP$l4__aqO_MQWXL`HQHOn1Izu0m zohoHiHq+Y>A$s*8An2bTy?mI8qUV&7m^!i)uxcItw5DWvCj$PLQA1-Ag4{>lPtJ>3 zg0AC#y$vfl`gCT0g1>-S5;U4#K-{}uqux`8p>ljlo&eEnbPqnwzBO4nTg6l^2~FHi z;4seRCovm*sw5?#32=Rd5-GAxfl^UkCmXRsg5bNtcHx6r>bna(3|BlOG)}6&8@~`x zR&i6tiG0Iy_F`%(Gh#EI6?~!StGOTqJ56x?E(+GVGFw=~#6aKfZbtvTw_9LhN=&Nv z>sLJW5&2ASvN*`>E)C0+GtQM}uB^GVliwe0Ees?q%@{OZBwpcbElc_GPLjh< zMfnk#F+xCPq=)dgj4T5=0Lc?iuVgaCghnEcvtZv+!Ir*?mTh<$F)L=H+*9pRndP>#&+~p1Xj9^MklX7Kj>bWTaCAk^!6 zYoq3R?r6tlI=*#R+AZH)aU4IsE~J)+4}8-V{8Rje7BfIA9W2Y3SSw^o8TGn8D zI}yyrnp^kC;CzE)a`bCkpHc`#$M@UEyNi2eWTb3igR?FfAzn`*W!r4KZ6FpZy|Y1c zD|5TJzuc-;n5;yNByA&s-f&wMXJnWw9>9I-XNdO{ZV(s={>h&*TA`AVTUjz`^xP^@ z57qwv>l$qVBOEs+=SXf}%WeLAVkANU-hA$r5a7n>70{r^pyom30)m6X~;VIaeaID<+dTT#cw^@_N3Wi z-%C#hoP9*^OwdHtRsp6VI)Au-ZYFDMVov7uHmF4>i8X$3iC_TkSM4^ z`yG)Qlz(vd#rsd5=B~tr<*!^niuvLKVTqG>JEn+In~)@&jFtpHK1?8e!ywJe`mW$| zb!JhK;VWn+pp4%^-EA?|!Q3rN#%iO7PGGnyF_5h^5dWV1Ipaq-t_};yf}+US7Y6R` z&{4!sOi0c6X>9OFaLE&F6&O=GFjtCCTGA0#f`ZV4f{i&SbHncE=YBL7_d`dxk#;W@ zPe=;W_=3UWlM0OW=5|Z*+s(yZdTe3fo=nwwouM%LU1Lkg^!JwZB`SR|WHm<;7CSlW zs3T8Mfsk*K&P3pY)$llxGnH4|@5{6zXo}ri+rYu8t!g-j`%s)4jARELmJ+d z*H|?+OQ2^<$009yWYt@DXlc`8f9@ZI&+IUC&eg0G`T?GRKhWbpR<&?TnlNO^>sAp@ z%n$e_8~wLv*w~s?AN-~6tW0BIxODSeLVF*1o~nmrh(bD7)^6q**o4&Vqtj*G6RY2B zUxmf1`vm!RkQ9&^Fkk=89RJ=PeK??qqyYl6LbI$^lHj#{nEb40+-u#0=K9dLA|J2< z^N(@uX48Od{%+}m@mcCgjxL^cZTS|}GFAS#RTM2*AzlV@B9<+AzXj#81^KiE74Y6| z(P`s4Juej}{^=l+)`sevGd}7nVxG1bku=Sk==(;Z4$P&BYc>}=U-Ngu>U8hiFnrb7 zgtFN^1^)$QHOl>U@)mJ^+tFHEW@knt8LjQ+rBTZ;Yndmj8Sc zCr&txn704x*HInuwj=45)vTD1y9~R?OFkg9Ngy=1Lbrk#S)6N|=r> z-oxq7-|^hsCFnLF3a#%UJV28ovj<|=0!}GXJ{6+-g|7lOzJob#ge&KYP#M%3-@xbR z1+FJLr;u1v4g&B5)SyC)!lIe}&#$oua&$c=_)Lljq%`ZiVzv$O-rF!F`S1NDZ)_(E z4W$FIM#lB|}a>P9Kp_ zSP%ayHsFeu!kf-+g$hB)OqZpul8CZ}rL*wN>e}TQwDW=+MKnebNGCdT$6#*@IGwl= zPrwnD3z_W7EF1l?0j^GHM1Q?RC2#iMr3AYep5Hp+2AB6S!5m#1-czc!VqaRnOmvAk+SO!c-8s4_nJ3Iy0T*gpO`q5Tv=@Y-Jfv5=NXFfx>sn9QLUlXw;A85U zc$6wEEh@2Tgo=Vg1JDvBAY%U^X@G!2>?B*RFB_h%Ca&Pk>oOQ&`!IO8B4c(-CQ8nT zMd$3j=Ez^QH~o_{x=EQ}i|8r0&x+)^X5eYSgMJ|EFDWZ)rt~Pt3X$fa zie^z=Iuiu8g^Ox=UgZ_LqL<@V3^>DQOH6G~IO4OQ!sMVjM&Y{v-%8U8_(aB<7v&!& z@@KTcoyMhycMSbmdlmCeBr?QKUgqdV{Y=l9gua14`<9)dM1E$-l%YJ{%vAATfT#f>L8K*+<^QugtjjwMQ1>F z;H6dsP)m8A1Zl^E!U){E+_+=H%5o25O`ezl0{}SaY&RV_bQ!H4luUc^D5VfOt{7EJ zVR_Ac^j>KcaV-t;(=3D-?*r&?VZ;xPd2DxucvC&gKG?D4$q86~60%6$*>YZMM*J7* z6WR4qzlwN+YqJ)xLt`rbJZj2|%aEhM4(i^xazE(Mtop;6ku;{@%e>0!F4oSdtwkLS zC^{VXA$(1kuL+LGM@v|2B&D-{&s<#WhkHd47q$inJlv{c{3+BPHVWMn|Fg1(5pz*# z*lz%`Kq~3VV4d_>tjo4}a8#uGwxJU&&Sus({p-luV(OEK&Un(91EK^Mc1TP-zO5L= zluWccXU;0eSJ{$v?Hg<4Yu{rZhY%LIdU5xFXyElM{y7KA1mx;{g2`XCJC(+lF*dSz zC-{#}w~q(~%fU#7t;4;#9Gl>}T0~Ldz;noLU21G_R5q7N2+}JlgD6&_)^Ryj!e>|_ zTXH;46NH`!a$%+T)>}GZD<8ED4>Cj~eE5a`UMZYeg+n8=GPrp_DGwy3!-?*8*S$3~E3P=o}J7P;Ubgig6qEowS>A*dezTs6TAFt5( z{3#7@tur_m&tU*+*@j`!T3ZI)>(myOf{=sWEB4X~2&1Fo9scxKO#Pzt&MfYHPJ+8l z#1r!Ved|ClXvR6Hpsy^ShP_}POOLym*|6RiUyVSI za9b^GkSimNc&!_9MuHdx%b0uJHxlMO>`5TlK#dq^{w33&T0TZZ?x0eJ?O0DMa&A(q zCMKrq=e7~U&Zg{l{W0e5(nF7Isuvz|<}TK)m=djk4?!e^}QW(iT zOYAs*i(jqHIhZ45b>Xq`Pyo^CC|PYDf|<;P2?q&^PFJf#1x^|$zU~&YO~5{rl+T9` zxwRzCudWh%jNXB;Lh`HaKQtq~WJJyBDhl1vRAtoDi&0B6lA&XI)+pBTjhh!4Qx#T7 z31OsK_stG0k_C<+s#mktWEJfve#WH4E-4tTnzkVrKljj=OiOW!`)|43ryrSfZi677P)$Jo zh^_?0*q$EqWGeUfGy4NpFON^$R)%ou1Ut65*$%hi)5K4XoH2+8k?`I$_FTK@`FWB3 zEfFb!v3=Dfp#jnU`oD!D0LrB!_VY-Gzb4aP*djfWHEvg2vA`kqJ0NP7ArtD}PCJz- ztkO5_5_TZqr>J?o$~opOGWv7gXi z-yikoFaznu)Jvkz3WQlPopyy?KHq(;l`BYm&l0b`1*Ii)_ z9CAW5L*?BER-eiblnOapB&GNK#T?+gmNUe}dnJ2j0JPxNGzag##t7Lw`JoNY`*Zz1 z|2y{78tZHN=d9v-J&D-|rZ%)o;oD+#VJAEZS!%_4 z77rplHcy1dMpi{~pE_D+MxR(mO$j@96*U?U@TtUe!?J%ghhgE&tE&4FE9(n+{9PUd zEh^hSDm!E-x_;cS`<_B-FpVxsahVaG zvkrDU-@P*<*O5)c4X>c=x=?L-y~QT-Nox9#Q($uli;ksHMjR3eJ=iT!0~MEeUQWh0 zjK0mr2kVc2%K0rRW$j_KQ`N4KhEUgJMgztt)jS>kFcf|P3%zq=qbbyr(mnWTkY?gc zOkQNnqe<*Fcv9MjaR}9cS-0k%1X@u>VD;K=fu}=2qM;%F$;nR-vLE}^sVDbwdBRi% z%V3U~Dd?k8dMQ$S7rm3htJ@zEODrTq&4u^Gipwv$g+#uo#tdz8wAF&Mv(T6#5>@0^ z)WZ_mm^$(gWZ+<1XSTgjKf}}Dc48O%Hs|?9q2tH2=Q(wnN_dW>$oGB}KLFZsLH-y; z*70AU+Vp;K;Q37QYI-;-Ux&auU8jVq6^?s5d` zy^2AV8AgnXUmg{5CxIw)Y(#UQc>X=Gd=h3|engdZo7`Cqo)LEgLwOX5p&7wT=riYkp3GSR~~aSy~ilMoGUi_v~o>T|xIv zf$D^2kRjn5G@B#@HW z$lx!`*4;(S8@}uZ4e0l)|He`Oh2VBZ2~fgZtKl;6c^{Q?RM6%=7_Q36UTy$Yw<^4; zeDm$XBOwZ)o~s^}Uzr+z=YR&uCiAp%7OC9OD!y*rfamRn67-8(~SP*h!%scoaA z@;9&kQM+z@OV&72T?6qy2=dU zeI>j(9gJ}TIs_|QM$UlrjJRDgaHI0Dn?|Q8W_=MImQixLK*NeVH71Agh3{)3k(}$Z z)q?)3c}(du279!d8UYGJmt8!az(e4zk}aj__x8N}D{;mQf!UwoNPO4j39}7Uv$L`a zFqi=PU1^_7&(&Ji#RVI&7lu+(fJB)i*4}tSjFYj}L9!|y7vIJ!_4a~>*Z$=*yxBOB zw(D=QP;m^D!jql}B3W%zdsi|k1jc+C;Wl43$Fg%=^`IOz4n)Kt(=E_y_lUYkEXj$S z9I;7~45K?Zb~blD37!L9Xk|1h5UtN!ujU5-2m>h{A8frhzP-yVgfR*3jX1--C{0cs zQwVAWu1J!q@^7z>6U!-z;E$MM$y97I#`i)iy?o_9sCGvuY`e*&B*FYsx4?!A1;>&X zYRfeAxU((x(ocOf#Bbn7V{=)){rc6HJpH(L^LYRFF#{Y0V2PE1 zxG_>)-d0!C5A;mDHKI$2jJ`7~jJGW($7|Fv>w@jpFP6lk z=nSv8o60t3@MkOu=+*j5MmJ}&4~*uD+%hw%MJw@>fx}5oj2?E&W9Vgl|8POMfct?v zN0P%bLNvv4Dt!-X?5h)?^l)tNRL>T>#b)1_y`NaHnpDp|63#YS!p0Fk5 zCv&kz7sf)4Z!+>gY5~iH3VnayN4;`4UDf_4%>7HV0BVBURe5n)#9QVO4n|;$EEP%x z@$)_}iv{|5Qg`II<#?cDDiQQ9U?hv3R2Gf5`T1nVcRw9HMlvhGB>c>FZL4mIWVy5w zHWKPFYJ`K^G`!5PoRw}#{mF1yvA=1XDdeJ3L^>vX{(`5_oizD$LM!a@FtwBTH0<=w z?5Q+Treu4)4K$TEB}g#MH@T>o`$f@J8OLJJ`DD+3-fnY_!Vk(`dY`Tfn41!dKKun2a9GdXBkeY^rS-?-r4##Q0H>r45N!BIE|+DpD%ei)io z1DyJ!7_^6zs_su;PLrLbp9%7ZzmsY;cAbcF6LcSpQ1`1VJ?)BgN^o+`rp6+=*Jb~E zjD5rz%U%K_28ZujH22CRFWjH+E(Fl_i-$m@ySo(o;eLqcrn16QRyU!YH@w`@9kqJmdd1OZNZ7?H73 zei&Rp)B}(GRiPg7YlAEwS3mh_TcG4-ZZ>SS z{}M&!=cLhDJu?Q4z)#J=JH)unN;WO_lRJmr=N}ItxJLM;sx-T*#MN|nI7xf>e ztC>n{>j3iFM&d9rTb7$`u`(?})7Kd5BAKT6uc&;;&f>_rhd=v_KLODQH8M~4DjpAb zJl_n)i3dX&x_k1CWAJB-noe+Sr?RbIZxx>Dk~35MNuOQIdL)_@aKi zGNn?O+>Kh>qc-DODc9nng`;n9G~piV^Y6dDgWcZ^W9k&tk}YLDnyQsKL3z0h*lAe{ z%O)z>e=8jdmG6JSt(+@I%G?lmzX-y8SHg`wMN$R+e^cs)tZO`+d2k(-V8&G|Q$M%b z(9?Ug{s`P#rx#1A8yL-D16&mdlDy#Jz78#LLn)Y2Ffm~5{{Hjqs=1OnqK%p>FhDsmQfwsg+g*fBSQ}$v0ut<(b-oa105MD-TF!e;# zelI;;I2CwgKaA{Eqkf$v+M`%?BB#sH&%&7fp?ZW;vH@{p%|%!Bp(Z;%g&^d|Bu`~}Q+I3p1HLEK80KD7+F>&)MSkaJ`Khv;6$124)mH zQHrkYKG*{Q_(!~jN>D`^`9rE1&uIPhl_}weYzNjOTB_{1*8N;%Msw_AVW2-ui+vz9 z6_kpOs-ptK9}V{gM%sQv)_eLLYJALpbb38GalA3jK#{fV!-V@r=QCGtYOqNgdu_wo zsyt-<$@e~M$zn>fJ4QNDoAmthI5NxoMiB1y7@p&`jO3(g=xqzXowCQVkDv=Va8On> zwUY2)d)r5F-Sn2#Q6^N2^UQ|7E^LnQymBFy?OkUEl8j#!CSSaLOthnnURegyS963(#M5QaxeSf+Yo z%*8cuLxh}mf?6r_x3Lvo3Mn|uVzaB`Pi`XcKJ%CLi^|;mvGgHfL&X=pYO4plKu~$= zzn%w|qw=ZCMj{(qF#dRG_8T;{nztV1e2z8rS;4ZXQGY;fFn6uiwRp=1l~r zYuAW5Y2*mK=h>HnQRAYzFrCR5?7Vg%dGo4Fqk-|w0S5%@e0Sd{B#c7R@cg1|(Ijz2 zM>ALKgXpSuav$IXoc& zYSZ0aTME)6PNy%;gCc%x^QCthMhxX)25IOJ0=}%xSa>4}3`oQd0!F0~FPW?sI)sRi zg#(6cec+%=2m`~468f0pcD4Xtchv|JP6j@iiON|TV}FI6{61QeS4&%pY(|cOe4i?u zrVPp*an0TG+ukjc$i>#Sjuqja*781PnyjSwL-C}iF!v*6`!dN^ohfQWi?P)!7u$>q z3&E+CLKj0nGCNU#lLFjB^7#cMMg+RKoa`_);H?$+7Uf|X-GuXf`;wUSC7Q)=B(mPE z>-V3*E%#6`$48K-zMxS>!Pw3^GfC<(rsNi7%d&xV7B#{DqGXh48@&so0l4L*2!JCx zm&QBMV?fs17HI#ny%-p;y!gJk`>s3T_4d{uRn@27#H_5Zb8h;QltHAqtM@V#Wo~7) z`?Sm{wvhv!p>ssOYi$c0d`$$x!=-~sjw&S;5o0@z2>u4Tghw!MzhiGGqmjap;-vK9 zTPp@H#h6Q@52Ta096B#k^`~&aefHuq0*k3e^;nhzN6_*STv_F4EwvcOl-^Y?g8OR# zt=Tx1Vx$^6K87S8R+{pS+Ew0x3iGRqLJ+n&nWf>My;40ZJiCUZjz2CV4KGb(r{l*f z*c62P3@splUGJ3dwfDSxJ9ymdTmb{rO^`sLgrOHT=R5?D36SKd; zS0f7#Mn*`z_!%F%yjI9D&Uz3SaC6EB zQb{Q8b-|xn9B@Kva|)0AEVx-DkhJqIegc%{;`B9A@DVxO!0iV5r9tN6R_R*j)gKL& zp$)lEnZY>Ro#x*(GhOYrCcK9{E3u{ulFb2k;H+vzQdf6upU}F-@ih<@*|qV08iQ5Z zN01YKz3RaHl9B95)+f1_O451;JGm&3RfZ5OjzPt3?mXNkjs9%etE^=(sIf+ zeu8hBn`<~JkJ?=80hz;{Dw9g421fHSg@r1>LXcFXVIX9Fb2pvu{5V@?+eZjkFWPu~ z9u1bia!7y?lf=BimdWB?eZY#?$%mB7(j-v#zyw7qEom`x>nq&NwXJ*84KT={WVg;n zF2nj26QnktTt+Q^wfZXT=blTI&N&%3|7gGJy|NP^);aY*=ppGwi0Hh5eznvBIo&xq zBf{rnwCX0sy%_WV*V|V{McGB+Vi3|L-5`i`3Jf9Af}{!vLw9#~Hv&pXDQSRocZ)-J zcSv``aL@SpefR#kcilhto+U0=^Uj%b_SyT{`hI+}hI^C=^ixsEeJb+j zU|(%E5Tg0~A$QAX2^PU^{G$o_wL!W=x`ZbNdaAk56$Y=1YuSugow|iU3uesA)Soba0%Q9aYR}M1@>2cn)wlRd+qo7kBTSgcmC9?Y>_)RI4G6_j?OAYNw7$n zKH;zk;7kRZ-gXU_w^gq52lgYb@Gf1R>73UUtm<;R{FU~1$Y+os454>I4Vc~!;GhMO z{lsH`ryIW<7o_X6;y^(g^zNB#GcIad3R#-ngJiPdmTLV7NcESb50yT?z6Is7HgDy$ zD)vmN$SqAxO~lH|wtMQq!}60;&JyJ=O38Xb8n-;NDWdJ-@&k7LTF}_tt!Yfxrj&Df z_^l4*!f`k|{P@J{u733)$JbiQvffI;DubmO3F-KoE47HMfHm!5wXgT}!`d4ibaiRN&hB0c|JiF&((eE^0`CQAr3e5SuExv9#NZ!lY zr!~Qg^6RqyoxqW(w$_1)U3w!!2SZnRGuY`V3+oKF&}<&^DuZN@|Zvrhc+;BaW{H_BQWV_T%wb8wGp z!6c>0pqqNta?q052##1BM#ZCcsbOdod&R}#-KR=c zmpuealG8y zeGjsUuf4$CvU_xa9YUVm`uNj(I;K~ZUNfUJb-HCF-*vL zeJ&-2jRAM@Pg2R{zlAqhE5Mz3#C!((d%J3w9hhwB&s!65^VyHMN@iki6rGPFd?$ws zXU-H4vC+?{T-YlNiI}Y6q481kJ2;dCU&~PQa$hS5jW5%tpk=;nELcy-{XIy~doO=j zz{utK+m0Mh@4{<=c4wCCn_t2uTtB_{?t6OM4WzVWjs9APuB$X8zqwMO8*1?OC7vVW zOecu-PP?V@eYh2gy>(#QQP)Q*ClVh2GcmS|h2p%e`0H9TB&TQ()3xSErD%>`oUM+a zpKj?`RoD4>Y~G8rgC?pC-!P;2_a}J6n$gfc)idNYVmGuR^A5%RSz{mHx1z3T@5dkI znAJ}{tUIk>onMNEp-*h_xIqUk9a8V*$q&O+a$TyH+v)osxU&7qZn4#A`-SsjisY>i z$ET3@Q>y(i9>TbtTEAeh@J<)&?YJ2BXpB*!T3=bQSA=^>`^4sp5MsEsIMRv3q?91u z-R&oAbSbOPrcs)9=4{h4zEu2j8@A__37uDP>uR86qs|oV!f3`JuCCeG^Pzf65Ow3y zbmN}Pl4-uY&g6DJ^hIJXOIN6^*KjLP`^JjAKVv-_8iQju{oN;q#Db?VNrG) zq^hwp%;j>9i=xqNvcW(i%o;0hqH+DRmJ7Ap+yi4xFQ4;=LC|24kFI&UO0t_~H}Z^f z^0@buO|cfl`#aLeyaImxRAeQP`(1R>$R>5VuUA0$U zBz=hVe094aSJAgUQ*3D}8o%)T#50#*!wL$8b&rJJ2}Qcf)Eyq%ypkn(X7_%HSHm~{ z1{@wZv)dG1((8)~Xtn}+r~!f{Ff_Q>>TJVmngl}g`> zCB@XM%0_tifqTIe{^ro-We}Gl+|&8^5-u}_Ea;FNSQO*x(9e89SUat8TpUXtdw?5Z z_cL)8_2b#CU|%4Q_&QGXPELgpMMbuUqamsk7hSyI5K;THa5FsA zIsE3$*Oti&(0RIeO?>J$KkBEkwM{L^{$i3^9SjSS&+6HzoA|q>ZeA)zh#Xk6#gZ@T zwT%YzqGE2HNq>1#Lh9mIx6?6w>7nylbfCi72$6^aN~~Ic&{g(akZOp`n~t7Jfi(0s zkOV2P!(v>xQb4+lvpW9sB6PK^uU4ZMUP{@N6n27ko|refC><_4YZSK9(79`MADMEb`5 zpz$B{P4fYy| zLyj)&PC3k23feWy`zwbT1EdBV%4R(ADo&f7lIZDf(r7=PP;CwpQwuU3zQ3}RWJMKe zI7($mETNF3pb`~b!}iJzJ@FBp_0m25=`VA)DT&VegJx)OVh76jtKB1?RP`C%PSb~a z#pQQ;R6AY#jMc=fM7Nrp#HRD1GC!edyQQZD{WwpLce*@N9Wna}y#kBv^&Dt|xD#I* zUI0$+WtqV;4eu0nbp9Uihc3i`=E-+hm0Of)Y_E|He9;AMJ`9lm;)1zxbgf#_Cn;Hb z^x2Ru*1eC`N^r=5Sn`e`AX<(crDeZPr;8KvVj zhZ@;eT7yZ=;ZTL$QUH~PS|hJmjm{>@4v60vXr_?ok=Ej9;@i)hecj@O=l{khs$%Vs z`$X-V#P*GasML7#L=SRBk=?W5Rfws^UJZ6n`X<%^BcV zR9i38uuV>h6L&WMHA*Dsn;hR$azj>f7|EF1+Ww0iLV#_3PSswTiHiQhJiAHU0=PvHO?f!QGvu2b0Lc>UKoa z>DipMI(RI2JsbGUxbCNve<) z4U13;iQ(pr!9~r%OJm=S3C>Xa{TGXLqJ62I2feXsL{sS>AWoeQ(zZ$>o2;Vbjzpb{ za&Bk|g%CySIejnAEtg+nz8Qn5yKSUa?Y|CTgLH|}Eo+6>90xY=lKTV`=y_#U+5iVF zW~&kvq_=9*`{G!gb-K%&wJ5f3Q56Y#E)$qZTS^~zpcFeFj*j5y;!?g}sN&QdX?+tf zv8Br`H-^lWJ_7gR9coWr2#rFs$$E0myemtNN1-7(;#H0lwc5^28)ZDK z${TWdnEjQKmuc#-BgRELsql_MsGH&%(-wL8^y`kpJL3-3w`?&saNWZw9GH>MG472{ z&{tC4P_|``&OOS+X8E{N_aZ{xO4+Gxw#bIIMunXPLz2o9`zMu!&3K1Wz6qsYkmcGP z{QRTcdECnuSa`Lh``O9QTnLoYrj@kprQB(EyV^~{K-w>rI3Csiv zw-W6Ui;yd{<<7pKcBAjzNn#D|&WWrCCZba~`2^TEy|D{}=@W>W+vgIt#n!MP!dBGY z{E%;ora$|dR4Q(K_sd7y1NwRlvs~-AL*DI}Si`l)++sUSiSt&tbF6g1a(daPOUfO_ z$~8M2y_qYiUr*^B0V}%U3nW%0u8fbS#)Xq(#zq?h@VrFVSS><)Neu<#8^z*`*Pk)S z9Y1`hFlZZ)+=odt+Nb{t5?a#vF*f)!uXgXkf(r_&D<^ODhtdIdD}qjqH%@`(t%9Qt zj8k|YTP$#LuQRyGUMn+sxR>WZF~4IRcXu9#*Cmuq1bR>`_P~^E*`U@jaOm zjhJhk<=$a4Rs{)zm4I>KiT?V2!X%#;t$+RD${y#51YT%p|2IWr(+;{9TcUcu*ZaA? zc<@y|(6{r#`$aMo-$V*SA=n8ybnMsQU+Fg}Gj%AZ*BPKH2>W>_h?RJ6wRicZ-u+du zsPVU3tj_!VlDZ?kkNi$|Fp&5w0@sS`Hox;?3Uk+TeD+p|3$(x>Wp}R>mD)3jOQclB zP$V5{2ehP{^N{FEW85;1K&b{t&Xc|tuN4t=SVUz6Gsa#nZ+R*(!i&JV$2eZn;?<&K zT?*|ENO~!DZ>a3Zro;WsFyha>VE^6=@agp4mv?1;YXNxWgi%?!dJJrHFT8@Y(P|@3 zF`%XPDW3GBlyK)R4SuadY*R}(Av%3@l*Nr7SK%I)Kru2#m>yHb>!cPGSVS;K9c;a{ z%8LpsH#9IkfalHCXaZ$b(Jme=&!(BzLOy>9?Se%42s_YX{!{%aTN*8OrwcCDi-g%d zKdJTzpG>;Mgnr@W$#}63*6s;rCD}>Tx!LqOl%-*^!rbQ5Sdv!?j?u=Uo)pM=!CdeP zh5aIZGc(JAPd?PYoP^@^SjoRP1RFyqF)c#+@=XRP=zav02XWk7ClGh*pkz!^&hGW( zaxyXnB;O0yz_!qC`(lr^Jgj&ps4eI*_P8?Pk8I9Y({Nk9M=ciUt(a;J2Spe62SX zF5wYWzfITHYC2R}FSC{>l!Y_v!(u&e!45W&TuQqt%vl*IFIoD14RM`D?z(txXZW_hT*gZqr%UPT%TsV z5zGwvmlxaYi}E&m&JjQFke5QySb$GojPlhsq&2ks%!E!|TV$At|P^!K3^#o&7Ag)V(BMn8|v!uYU?{>nS z-=YxFLU8;(4a@)RV_MmlzIAbXFm(}>u5=>Bz|N^LWdXhML#k-*;Wa8ePU3Rm1((^3bNu{91!CiXOjRgI|C8 zWxE4z<!Lvhy);?I%BcsIHpxjAKXm>NyESPEnqT|>&8 zA%zl3og{(uE7_l%nUEWI+@@XPh`+QEe4!W92;`ZGV3|QD5@_>jZugOYo*4RWr)8ZL z{L3CykxVE{Lm9{2oRu(l^cq}xM9`Ylx5P}(^Woq1O1T?lycBXJYM3MP77NpjFghw z6t;O{IVx{d?lk`oqa8>+tmbl#T;2IJd7&VuZL)s;fWKLYzJR3P^*P|3K-UKBIG!9t zn$VfurpQBRTLP8nLgZdTqj(C_%Fp~pJyz#At}A3F=yO7Wl`T{C;dw4x8)oH`}t*h1+dL zVrQ4Q-F?|XuGP-C{a3^Ny4iF8$1B z=}I^8s_w}bd-c*mA(XJnM)sHy6|xyAIlZTk7A?>NM+rkLx^YIAC)RB|uXGrdJin7+ z)Rs5$?|4&_nNe3Qn;UoS8U1H>? zGQW3a@4)|`vi;tD4}0S(sBa2~7n+ncRz#jYmMjhNZyJ-l;@>G^GbC&e15Nf#cR7SA zPqkU)S+k1W04!?xqHg*?wN~=$pl%Zm-OU?M5`uugO1FLC2`&8FEza`ysdiFo$4XOTYLmLn`g2il~RT-5RV zkJ=GNRf_sOc)`);?0I{L;S9S7B08r6orj0|u;efud*gYdUizPMc9JBV{Fx%j?@C+c zd!wE%WnLE!pSB9g6AoOT%+agO7cIM6TU#H?34^K})9dn}OWSqUc`*N~UVnZ4pm8kq zfZP4!tC2F-J)M1`N50;X2wS;u?>C(oKXdtK?Km=sh<~&(64-n(l}F#~CR?NNJ##nN z>2Jt&BTn*=1nj+hDuVKUQnz))9DT{r2QI37+%1-vm-}5(TdI>nIOgX)E zei|c-n$2HHcO&CTVi?su#VIWwB}+lR)>_2=dH=h-s#*-iGY8kSavz>(m-kYVl9DU9 znAZ*ZH1E<8lLUsiz>C|?zBQ3zm}~U#z1h-bsw3>aJ7p6_@iwP0B@v55)oDfD{N|V~ zz_U_W*z&;yc0dK>f!8Ume?#iQD}_#ozKvC_vLRn#s!q1q+SmKvs}`@^`1+Q_2^B=C z3K*WA+29yPq>D>?dUbmj-B$yS+n?N? zwuP_g2DPGpHu^zmGs(yKTXKJq)NXQ5LTj!0{FCfaOu_ZrC3AtaSy-b|b#6=+J@Me> z0r^P5^&@@Vdngii{U)uA(wn+(fg*iKyr3y#g$+iVtP(lBh_#$|rjhbr$H-63elo2k z3r`}-%OmQ>7Q*khetV1hT40r>+}bB8@#MQ5A-2&(M-!b!*}d`R7pI~=EEn0PcZvGq zX{%9VLEt@oEaZT9v23CuU`>nlow`HBjIcu{9lLqmw;p>ww1|-}ti=CYJwFl7OmrK! zrmF!|JZeQ4`jLT-F5bI*hX_xOLPn*$>EYhFAW}8To4cIHX}6)PefMZI8lsie`0#Ft zlkR9Lx;kB#{+i%azd)PfZ%QbCn@y|0qRTQLNH1T7f^NXD&K!n_4ZOItvB6B3=uNFa z1j7hz3B@?dbtY$}LHg`KVe9|Elwjc+?| zu|kYzzunRxiAJB!{A(_K(8c9KgQlmTs_CD>g(7+vwdj39C-QkdN`lA#cX}vY;me5& zshzgBcg!aD>E`5)9_-F4OrJU@E8`NFwdsZ3&${VImVL}$WM@eq6et;6dg+6)6@$AL zMLVkL{kP*CO$V=9d0a5>i~&tTytp89og<3ISoniyy^gMGz)3Uo3GA@Qq!!D8F3Oc~ zrkEXNM_HrvWRq9qX8vu02#e42gTegeX~Og$iq$wIV>)X&-fQ1mHSGZQxYv*1F!L~_fAPPbmmzU(vl4J==p3p zc2lkQ8u|BEwn+9rz)EH>O7veZo9GK_BfW}BA2q%XkUL^d<`tuLo+OIuv0RR#<-1vO z$mv4_A(^lnr)j$Ln_3uRQdH>H7eQmM`*Z6s@w(|2XEWROTQ=C~S)8%;{WQ=LM$XFh zj50ELLdQ;1VR1qrq!37_*=DX;Ug#0SkIY3kB;nElzC)t1|Ba^KM>ZxVLGst@HI0Tt z$%ml<;FLvf9XC(x2J85Hz_2t~n%AoT9GX^(1=dJR0eL+=Zd7nu2Iv^cxb= z@SJ7g#)xMjmr2ul=;hN4b8LP^+r036qNv5s#reiKcU z*u27X`VFL2w!<>iDvbPAdKn$R4gPiyRbAbu{Pj4#8tT5Qqw-N8k>e;a0wZP`<~ef+||T`S4o zPBA~z&HUg?klu2j^I`fJ!VW!!{&gq{EV6=sBj%q5sbA5~)uRsQdi zkB`H;Y5)7AA$f6ym<=?;T`2iNW@hjZH0EzliYmAXf&cZC6B>|Ak<0n7@lOcbSBhge z*IcR3AO8DcIey@halt5eYg10dx!a$1Y}w~TOtWE%gi(EVPYiq7pwCXdRvv-YAd(+@ zp6w!(0!R!E+JxYuBgNjjk*1eUFB862{e zLff4TC@Tn1UZ%2ArwSfcV!$`cHQo{9jN^<6;6`VpM1A(93d#Gv&OP0Bs?YxnR)8C z33|Pjx8ggFu7<6&oFQvxx6*q3CLvG<;u8*zM@wfa0ymOYuA<0ljjerk9IdZK0|Ml_ z{?DnS&?XdFiYk&kwoeW1pOqi#zK0{+41V72a_3|!Q*%9d15$=tSAVc9iOk}&5a=~a zi0={AOrZ24nZ~HG_H=q|nUlu0JG@$xP*Drb&vW203VUrvi|IAm*}IZ+Q7=AoKGz^l zvE=lAnxaPhzdqNUmA%VgM~HR58RCPD^uzyyy^^Ki?4bFt#^g>gZoB2V`)|&J0V>}1 z{Q7rfbn!+)hl(LFjoSDl`H{wxP$7&Lgu$GDd;FT4y+<($GQh1_?DKy;lyDHw4PCSH z9sznz9p-M699#jrqx6Fr^^W%M+m&z#=6nfcA zoG!yan94pgg|flLD=0xjnw&n)us)8JvEP8;zC4p@5E0EUnF#JFFZ*4=0D zwi?-o2^Bsnwd7oQ|A6U>$7#|28-GGyr4-1Vf;gWg;WC=)8aidHI4+YLuF&Zlj~d#E3(2=O11KTpb2abB8{WCHrSv4T_^_P zFR}lhrt$yOM*o}6+f#P$f|*{{t*x2pxGf^YqqUh5`p=mC{`j;3pFpd*9HlxBA~l8EiaL_Lm4N5sSP8~DUg9KDRg`C{nN zc3Hm*X;FFk`vfIXQonSF>*0_*M~6--pCu9C!`~Ubs;a0cu^4^w=7)ASkmiKhbm=zv zh_I;tFdEK#*^|N(y0m1FrpT0^>Elr}2lnQY_&w4f$ zRXtW1_p|MTXj`vrjz>(Ax``Xgb3Rwrju)#*I}ZR6GNzv%rz~JNw<3d;CJVcBmznf}NVjQTbvUVA1+*`t23{@$naGvCjIPe#nwtVy}PP3RrzL z8a((c#|jpc4P*2{FHoJL!ot;(E-FKyqd?(2qC#SLih;F6nh$BZ94&IYIT=Tg@dGN# z>)$eBOJI#Q54>u%@^+@H$_zTte>9xF9V^o92OicPhDR^3SASG{pDkP?59pWdkEkix zuYOQJT#Dp4tZD*OXc6e_4acg7M@Kk<)@o`5{i0W_K&0mrkUI&+AXa_-8o3#RqrQ2m zBeH}wavpwKg-I=%rlutF0Z`%YHk|3O*R0@&V3IlvxKCI1b`b0KDES-`Q2CteBSwJ) zq&Bi;V%Gr2t-)lev9|l_hZ6HdHmy$3!9#U#%FWG9jY=z0b*S@0gopQjfz~EhC;EL7g_>Sc+K&P>JQO@!U|Zo!wJLKJA-gzk4w$Mw-piNy1tZt8^if!fLj(Z z8XyRACmG_(eze|i3e+Wp4}a1FR#kQw8-O}g#1~w2bRtf`6ZQ*jy(5A0ho9w-nh@Ws zD&OU&O&2e>PjJMp-z6vtg@0AcKU}81O$L+9a9avz_m>o2ZZ~WRK%Q}1j;8S3F4>+a z35?`uOa?Z>#||Z^R8SJT;RPhWE0wS>D72wyVC!3?%fUg-UwD_`}TLcJOD5Ua7 z@A;h7X*-X;W`)+i2WxJ(Q!!o&Mjz0!AfE3H7)Axxk`3)@w6(oYMn}fSdE5_ICURx( zpQ(;54i#2}lw-YGs-|YEjuELUBwdSRWq4%5jd~dHNih+$fg${Rwy>2c~ z-hcSOWj64sdh?#!fem(sJ*?ZBYSY%pu$XtK;SmE5+T z>r@y&JekX|Gx-4lvutDsq0qPD~!h zwVsJ1Nw#4C9ku}G0OfKt*3}}8uNjhKzsMOltpPmB>34d(&t^@dxH|XFQo{+DcE@RV z057xUj7=4I6>3%U?c2B7sNtCzEBJA~SW0JSXZ0ooSRYdl0h5MNT=Q3RXFc?>!Uk)- zsisIrbDn!0A!1+|MZWcu7=lF>zL)%v(;Y8>wa?0N;(^1bFUN|B@;4f5Qg|N^mL$?1L=BS<=PRx6 zRL$oB`y0a&A6cdNO*|4OU{Z|2N(GPzuvPw?`ynCWf|HOS7{6hgP3;C_nxbjaw7Baj zkWcx5STxhVH1;+n;RDIL{?C#UpInWpDJwS%uBS-=FVH@L0!K!g@x8qXYSYi~*ko3O z?C=8M7@F9ZWoRW(5^ezq;i;`FgOM1c_0kpIL+ep9oE&oX)swKefZ9G<>2@r&8e0*@duSH0(aWzp3LBS5#syANW9E_=IX#50o=nupZm8HSe z0|H&;<>j0%+Zww~w?0T9)aMU+ZGFnrodMFXJo!^0iX695!{23s&z*6Zw)O(Yf_p<` zpGr#bXvPN3O)2j@6J9><0Pdf{sSj?u6CC00pr3@ToLx;;SP~m{MhPx#0g)o4>!Zxu zZEmlXpK;aNKiIvKk1H~UhEx?rPF?{}tzL>?F>sZ70_FkgjXaQ?#^x1mH@Er@S7DR& z?mR%TO0#HlPmWDp+a7szbhJRDBmhuo1pdJ$mq}y9-vH1+vLbrI!s7WFl*F<>R1t?E zL-$gj+OFk`-7v3b`uc1;r1!DO0Kph^+&ZV_XCOgwQ2AaTOc=8r{I*G1irY4R3y=XE z=%zXRVn+4y28)bz1+6W2*?Ak#LgTpJhnz*LpakI-edpLGh#IC-Iyz|2hFd4hOoDm1 z%Hdnnb<$yYdAu>>JPy4Esei2rzSZQR?Wtsu!f#Cm-oA=JSwvlwA(AT3UK0dd5fFMB zq!WWZjO_J4Ui8e-kq37tKf9zPE=kuX1*mj|*VYPsOidMppD(8f*we`;u}4$>_5sCB z{rOTP0@wj87XyT{=>oT{&yJOv4>SH~f_uyXFqtR@x?n&KQM?*4YYy|c^cfX zm+DYx^!#2INt=0odg43dOHL07NO0$4ISl zT<7+=c1vW{9LjyIQh#!%L_WsC!lJf5zkfZ_n;%S*nOMh-76D-(fiW2@JI#EnUFIL& zm$J-_fj~}jh{7r(+>d&n)~tQB^!rVj0(yE5IOv%&+ze(iQ-Hfl3Ie20)Ea^Y0b^E@ za2ugbSJ|#_V`POHz^^aTT&B#ubr}6jGu#=8*bx;0 zV2ICb@H|7*35y4AAX`7J29r2nNl=<-@O(-y9i?}DevsxgB)2`+kg8?d@F5`JE`a-; z32tZY7zE;hNd8HEdoBYUU+{FoP!l*Xzzozb3P(&Hlb*0G9w4}b5U2WH)c6tL1$)ux z$cPDhYiit^`c#%F z*Oh^Q0Z0gKTPI*(gLT2?#Qi++I9f*>u%hAHoEe!su1GGbO{S5A)v(=2RuRcCE<%b$UYdlW%;{h`mpqS|nmW+D!tg zH%^4MSe@(M+PCjNmP%pl@^Y4x;I+6{|Lpcivp;|K@2g{^iZyL&lmmr{WNFI(vL`PY zhWddX5l!~T-*a7%lK-zZ=uG=&wUK`hJ@LAcZ>bx4oOGU}(u^R1CMTsR`Qy1>!2bZI CSeozv literal 0 HcmV?d00001 diff --git a/docs/_static/readme_shifted.png b/docs/_static/readme_shifted.png new file mode 100644 index 0000000000000000000000000000000000000000..7daa81dacd0e0c1ce2251e73b9f79463bbf59351 GIT binary patch literal 10735 zcmeHN2T)Y$mcAkiI0O+Dlw?A5kf4Yp0Y@cCQlfw)kt8|Crg2md5KtsXB}&dYi-3|d z2$C~R&UDkTr$^tunH}ftzS^qYeN|i4bT@Q!?;p-NUpT+#U0HF8LnjX*2tpw#aa$fi zh};ll-!SPx_zO?l*Kv5^w-LQ>qhPLYW3OYShsfyISeTgGm>4}gWv6FlZDel7&B4dP zb?KC$jg5u104JyEzkh+l+{%EHCo3-nE<$D@p=ymFR62y8eeXpQjS%GG1IgRJC^|&U z51{XM+Pl^*_tc-E`zibr=_Q&oAyPgul7~57-{vek+eX~*Kt24LZ0cmhAcL>a%YdjV zBV#LT+U9P}BpI=TvXKsrUClN;x0*k`_0jHlc&sMkW^}_Jf}t$D-)w#lh@YPB!c%Lh}kUzgT#fh%|VG^YHH$2!B_CVGYx3BBQ!K;A5g$1 zQB(|Nz?X-o3Oz~P?6s$}%^yBIv+pqCc0qS#UR_^v3&NR;4*q~tauMHv&8mgr)VKLCgk!3;o$%{DUG42L6}Xa$j-fsL5GHzW zJK7_%qq{rd*I$X)RkLq5H8u5>I_7wJdrvGooX>=H$(GV=i&c}}*pR|+J(~DmySrk@MRFv?aY>rGx zNH}lWpD)9voEq{XdmmD1PC>5bv_9|m=(%H)|M;u5*?#7k8ONjtluY~c`Suq&EI-8K zD)vf*ceivU+dY_F-%|;1-8gXIKy|Z`iAiUBywojh5W9K;TvnsVie)b1#;H>kL(J{4 z6_J92cH8dU=$`CzjwqDCpq=BWSN3J~h<#skjo!EjM>DxB{ZjMsqoKC;4UwG@sywTS zdYvg7dwVt(7M3_p78X&J49%{1so-(D67vuRP0e%_EtG8|h6Jg!5h|BHYIeP^J z1?%)7Jm@(1PrFLj={$mYZmFQxRh+gbg4cIeTcrGtU%LNxW5X^mIM}!;j7MiGE+HYg zq(m?-BQa52Hc>%RQc`E?V@=IP4h}ODZxRG?x@ztIHQm7$hy5@}GW{xG7Q4cxV=-l7 zd2orhV%sM{noQ%4v~=SOt^IF;gA;FgP(EMl>gu}4%d3)=l{F^MiXfz%`d+ux4^2s8 zugqx}PdiABTxUW~oXsb7d;a`6c7zK3em@E2^cy+hwp~1KeT;eUK6=XoO~;_1tenu5 zqC(>5?d8=uIGFD5e}x!j9( zS@E33V4kj?Jb5zLa!f2z&|cQa$fyrC)XU3D3~!Hjt$4r@44VtNTj!(-i)>ViCVSRm z9!^{-F~SU0HijyC=vommIn>*>v;V>+Se>}?^i2}HzOm1N^o~)dzIz^ zKLr0rYq4UtEQ`^PWEAA&#C|PNLMc%4)LMi#zrI-Icu@HExO`*hECM#C;ixW}Hcg_HE;LUqL}UY-&P_3^`%gQM=nyPRlj49*kplB}z(4EPi#d zDBOloa|C~VOG{5%thl(#;>wCu(SvktDLFaju~|rt+UV-+OKk0;FXQzZf{Y*loN)66 z=0=)2I`0@LJsukydoa?F5+5Zc{6?o`#YiQG1-dU|@Zv$O3{LeA$Tj~zQ!|LN0RWo6~1 zchr7$XZ`AEJlkOg=L1ZMhDWMBy4u>_d^o7RY@jTP75%hY5UVG>&r_^^THji;1~2P}WhH;<4*c)C~>q%F6nVj*am+ zZ0E&6(lL4jW<*#d1CDJMVV3jfeI@4+w|;IeL!;&HO>F< zsnx+HH9Q`loRrkomuFn?4<4O3*_bHZ(`xeW_?ykqu9o%*KuNE0`jUVC@M-= zS#LkGa93_|X=Kr^cAuSo2~uc1B`buR%YiCQZ~1lIfW#yH8I^taUH|&}`tjB6U(<4O zR-1o1h`jV1f3|g*k)$^?t^8m@S(%0h6?1nv3Ma-892SL)$DygXnw88XiTe(*U`m!Fslh=3` z?5LozOn4s^y6AgXkC5h4l`t`7adD6CbPeZfW6cZ|Mn=Y}vBpN}vejn6zCz2`L5DF4 z0*%CN%&TQRthpUTj?j^`G}>c;vFscnR)eELM!^>uuF{N4dkSiq9Z8kXU{KDCL-$=RZ*C`8;fO|QNR)uA3R7Ea^4^p ztedw>Nlw1Q*%85S9S|Lz5i9OTDVSJV3M_)Hx4omIP8ZM=8LQV(4LUY+W}&k) zNg*&iJOz09-l2_Sw6vIyo-~lb4C}a~$B);KSiH~AzczbxRHgSPbQ5?}N zv{uZg5hSE^=Qn!N7Ci)%Z`!Ve$9%B#Re6#1RB~+WSkRzBTa4&qQ&Yx`^}<3vC?@V# z0htYTy^$@#Vt#o3(`ghldM8W2Da=-CvE%F4O9lNxLGe&vr3LaG4mpg~`Z(<@*RA&% zXUxd$j?3BXY*_-_CM75T%$ES%Q$s_e3P;5D^UndVU%yLDy|li$32a;p=FjBED&GLp z3^@$@poO1R$paDwGGPWcv{!zGn_KDLy?3;l4yXBQG3)LRjw~!Lsu&q16&Dv@HSN0! zSU6MpWdF|>F1+}71TJ#^B|8(5=TWmW2ar?PrESwU&IIJ*a{4a`qyJU3`oYcl&T7E& zUfcgP$hSa4S*ZRkGycm5*%y5tJdiwLrvjT7)}jX{}#UuoE52iJ1u*i z=$$(BOQ+_)K($$+Z=w2)Nt+j$A#ds`-^K&xR-E3Uu#~uCTZ4HjGc;_)b$DWHC;#(_ z${(iZXhdzT7`sOC)j0Dn*?Pdd+5_2CJ~Wek2RApNA7C3o#|lwM%c3xu0qp zl(_;RE=bWlgO;ctYP1i{7dbhD8zv6rgHFG!LE`plW`S>Yh&i-7{U)AFABdQAMz%Y zo#<`P;*`#`^mD{~lpIyVEEeU`C^d$_cFu@wYjMjMbM!t!R zWH0DX+u3npme)?&`f)5C>;9SU!Q*UpaI(&Tthrc5y7u+)SQ&BKjVbYo)%s7G@ zrd!Oxb15BMo9W2Yt|9_H?d#MWBkFA?APh{=)6)}3E^?}`_X={5Xdie7pXTlSoMf%7 zSJ{2Q*?6H0&{mk6s|Yqk@nSiyv!f%Hd1sge$gx_EzR$U&Z@|`q%*LwOolhU&fA9)tqyZ-|5~mu=AhU z*_BjOf}u77ZeC*Kvmh8wuhqbutbCR=aqAS#^KglM-x%H+6T~z^?)NXr@W3!d=JbDH zLi$PsA3$!rVsA#uSL)fU_!v&J^YGaI7}4zP{vn{jRfyi61CsC=vwpBYl-t0avHve! z#)rL4Kg2IL$?9vtZjrK6GA&pN*z!))=0KvC*CEOFuSM2fot<%DDH_#y9UHsPGWQtf zlVXlO$JS^Zbj~AAWoTDIu0?9XiVHb4YGQpbGcQjGETj6y#?I9ikqmEIPD;W2tgL%d z!5kDb>iH(2VPRo56ZL^QQwP3V84?7KmN#O%B#7lR2Gaq*(5M^*k2g~kahc1i%Qa|W z0)%o-PES_`4aS7tv4ljcI3!qJQlbu|;4})Jik;SzIRFRehW?0CF)Du(WC&Lj;8Zkf zLpAMw*3F-eUusp56CyYR68;1uQD=&e;a%{^B`^}vX}<%hr?WaFps95QC_yDZ3U@{_ zcu-gy_WPMP($dm&wB%%Dlj&Wa-k5jTk(FrYE80N?V_10}@CDd$1ScxWaiB}Ku^Ed8ObE^Ex~Ug^f89{ER` z->m8NmUOo+2Yqn74whQTQJ9I8lvG!1>sz41_)YX)!S0sd-hC(tfOfCfCgS@Eo?+cz zqSIrt%pZf3$PW4ykjXX^OR}xtik*1n^an|>b4E~2)xg^UI8RW`(U-T`!hjkP_v)KjsWr|B?q07@hVjFuB!5g_8@6A})pgT>6J0==%os`-c9 zFg+PPD-#XtoeFY3PO^4(`CrWn2v{@z={0p7l={Ehgw|vZi-TnXwzC1U5qy`Em(2>) zVGsoBeClwk92%=IF*TI}Bi6$8_54QkdQvjV1%g1(Bhq{xdaBBC-h~R0k&y)Aq@fYX z5Cne{1U40NZWI*xLX`lGVreah*Q>%j#4M)l9fB zsu&XUSNsoM#ULyWr=*|&wjw;ZN7f0GJ_r1-ZON%@c6yqWX%l1|;3KHJuxqAa+1c4P z!~9QeCcUvG64T-*^fM73-cUT^+EPGj4eyw zjpr3`=uQEJHNL+7e9az*%}t*?N?6~QWa95HYJY0q|T?Hck$0qaMl@m~F|I-mM!d^wC5ej^T!PxvbTP=~6yUeFP;QP-2eA)FW=C&nE-c+h5T`d-;C76V!6 z<>^Tl`sPnA;&Z$pSco9_=y3-me%RcCg;{t8{A6-!3IkmX0RX*Bf<5${RWrCF{~=45Lj3ktNazh6mS zJ|-yW_}2O;Tx+628(AR+}#gC zFkTQA*32rs$XYuH!EC};;@$#lrI%0yE$3e3&{>Z#iuy92Y>7^B4x1hQ8>(kjJSc6GGS zu&)~9qwmv}>j%QIp28M-*1mkKVVmYx&LCoyps3$Ld;5My#V+=JZ9!gM(%ZK;q=GKp zv$oE;WcQcNwGqt#5BV)%={1*%W7= zf}uY`;cQ<SFp1A-G{1*#jb!`F z6WlO}d1t;bm|H%IfsU?qu*`WK+9?+}IXUM-3(VR4LqFqh<06i z$~?VX$J_%@6c7+#Q@sV(`0R_&v4ExVDQmr-*`lai?n9=i$CS-QR~e)fgJvktl}&aTQ!Q))vNN+}Ya7Wg_8~k1q*a z7wsRkwX=I%?M{&lLc+ym=lzsu2h(8l$yg(1&^}*=_cSxLpRBB`*zDmqF-^Qfig4DU zm%l9+-R34$dG8btkJ7-vz-g3_kPwhEojs|~l*ifC;<~%LC4w)>V> zK`6n#jN1qR)guTPkP8hnHjlNnhu!(x@(EdQaeh@;(-^xpqn4>%m6<2jzbm@G84-H| z8&ihg=>MIEX9BUO1-;}q8v*5ds@MhliWEF9HXZ<#`jm?)#HM=V$?N;M0UqS)Nq@N1%wkgn7J=G-C!Y`NM>JWk|TV> z7SpW?CJzK}L8+#BCLT@!k@o6zp6K59I_YkWr^#+B_ZazVLMV^u?O?P~S5J>Rn83Lp>Gbiti+klzgtbvCpIL+S zzF6;_rzg@TCKpOZqtw9#(o+q}({DP7`OF$k9;%?FWv*vnpstW8n+HdcQlUaiyNtxz zq+t4sZBuL^uGHb!R5zjGuxpm~3Rn!^c;v$G0)# zU)wHxEgHtyL1xwJs8*DD6B;C9-%;!Tnxpwo@bSjI{WP~l)_>Me1m2AxlA^M=({F13 F`ftV~Y`p*g literal 0 HcmV?d00001 From 4ec4d723c1a2978e538683b32161fdc96b4bb289 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 11 Nov 2023 13:56:49 -0800 Subject: [PATCH 41/41] Update the docs badge But leave it commented out, since the docs are still empty. For the main page I expect this, but I still don't get why the API docs are giving me a 404 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 500ac786..e01134ae 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Codacy Badge](https://app.codacy.com/project/badge/Grade/0b4c75adf30744a29de88b5959246882)](https://app.codacy.com/gh/pyiron/pyiron_workflow/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Coverage Status](https://coveralls.io/repos/github/pyiron/pyiron_workflow/badge.svg?branch=main)](https://coveralls.io/github/pyiron/pyiron_workflow?branch=main) -[//]: # ([![Documentation Status](https://readthedocs.org/projects/pyiron_workflow/badge/?version=latest)](https://pyiron_workflow.readthedocs.io/en/latest/)) +[//]: # ([![Documentation Status](https://readthedocs.org/projects/pyiron-workflow/badge/?version=latest)](https://pyiron-workflow.readthedocs.io/en/latest/?badge=latest)) [![Anaconda](https://anaconda.org/conda-forge/pyiron_workflow/badges/version.svg)](https://anaconda.org/conda-forge/pyiron_workflow) [![Last Updated](https://anaconda.org/conda-forge/pyiron_workflow/badges/latest_release_date.svg