diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 8d21f40c6..f6fd2ab9a 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -34,20 +34,7 @@ "execution_count": 1, "id": "8aca3b9b-9ba6-497a-ba9e-abdb15a6a5df", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "89ec887909114967be06c171de9e83c6", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "from pyiron_workflow.function import Function" ] @@ -299,14 +286,6 @@ "adder_node.outputs.sum_.value" ] }, - { - "cell_type": "markdown", - "id": "263f5b24-113f-45d9-82cc-0475c59da587", - "metadata": {}, - "source": [ - "Note that assigning data to channels with `=` is actually just syntactic sugar for calling the `update` method of the underlying channel:" - ] - }, { "cell_type": "code", "execution_count": 12, @@ -325,7 +304,7 @@ } ], "source": [ - "adder_node.inputs.x.update(2)\n", + "adder_node.inputs.x = 2\n", "adder_node.update()" ] }, @@ -432,13 +411,13 @@ "text": [ "class name = SubtractNode\n", "label = subtract_node\n", - "default output = 1\n" + "default output = -1\n" ] } ], "source": [ "@function_node(\"diff\")\n", - "def subtract_node(x: int | float = 2, y: int | float = 1) -> int | float:\n", + "def subtract_node(x: int | float = 1, y: int | float = 2) -> int | float:\n", " return x - y\n", "\n", "sn = subtract_node()\n", @@ -449,6 +428,38 @@ "print(\"default output =\", sn.outputs.diff.value)" ] }, + { + "cell_type": "markdown", + "id": "77642993-63c3-41a3-a963-a406de33553c", + "metadata": {}, + "source": [ + "The decorator is just dynamically defining a new child of the `Function` class. These children have their behaviour available in the static method `node_function` so we can access it right from the class level, e.g. to modify the behaviour:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "b8c845b7-7088-43d7-b106-7a6ba1c571ec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "default output = 1\n" + ] + } + ], + "source": [ + "@function_node(\"square_diff\")\n", + "def subtract_and_sqaure_node(x: int | float = 1, y: int | float = 2) -> int | float:\n", + " return subtract_node.node_function(x, y)**2\n", + " \n", + "ssq = subtract_and_sqaure_node()\n", + "ssq()\n", + "print(\"default output =\", ssq.outputs.square_diff.value)" + ] + }, { "cell_type": "markdown", "id": "9b9220b0-833d-4c6a-9929-5dfa60a47d14", @@ -471,7 +482,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 18, "id": "2e418abf-7059-4e1e-9b9f-b3dc0a4b5e35", "metadata": { "tags": [] @@ -521,7 +532,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 19, "id": "59c29856-c77e-48a1-9f17-15d4c58be588", "metadata": {}, "outputs": [ @@ -557,7 +568,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 20, "id": "1a4e9693-0980-4435-aecc-3331d8b608dd", "metadata": {}, "outputs": [], @@ -569,7 +580,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 21, "id": "7c4d314b-33bb-4a67-bfb9-ed77fba3949c", "metadata": {}, "outputs": [ @@ -608,7 +619,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 22, "id": "61ae572f-197b-4a60-8d3e-e19c1b9cc6e2", "metadata": {}, "outputs": [ @@ -648,24 +659,24 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 23, "id": "6569014a-815b-46dd-8b47-4e1cd4584b3b", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "array([0.45174171, 0.42157923, 0.505547 , 0.47028098, 0.43732173,\n", - " 0.50225988, 0.9376775 , 0.61550209, 0.81934053, 0.32220586])" + "array([0.6816222 , 0.60285251, 0.31984666, 0.38336884, 0.95586544,\n", + " 0.20915899, 0.73614411, 0.67259937, 0.84499503, 0.10539287])" ] }, - "execution_count": 22, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -721,7 +732,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 24, "id": "1cd000bd-9b24-4c39-9cac-70a3291d0660", "metadata": {}, "outputs": [], @@ -748,7 +759,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 25, "id": "7964df3c-55af-4c25-afc5-9e07accb606a", "metadata": {}, "outputs": [ @@ -789,7 +800,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 26, "id": "809178a5-2e6b-471d-89ef-0797db47c5ad", "metadata": {}, "outputs": [ @@ -843,7 +854,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 27, "id": "52c48d19-10a2-4c48-ae81-eceea4129a60", "metadata": {}, "outputs": [ @@ -853,7 +864,7 @@ "{'ay': 3, 'a + b + 2': 7}" ] }, - "execution_count": 26, + "execution_count": 27, "metadata": {}, "output_type": "execute_result" } @@ -873,7 +884,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 28, "id": "bb35ba3e-602d-4c9c-b046-32da9401dd1c", "metadata": {}, "outputs": [ @@ -883,7 +894,7 @@ "(7, 3)" ] }, - "execution_count": 27, + "execution_count": 28, "metadata": {}, "output_type": "execute_result" } @@ -902,7 +913,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 29, "id": "2b0d2c85-9049-417b-8739-8a8432a1efbe", "metadata": {}, "outputs": [ @@ -915,309 +926,315 @@ "\n", "\n", - "\n", + "\n", "\n", "clustersimple\n", - "\n", - "simple: Workflow\n", - "\n", - "clustersimpleInputs\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "Inputs\n", - "\n", + "\n", + "simple: Workflow\n", "\n", "clustersimpleOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "clustersimplea\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "a: AddOne\n", + "\n", + "a: AddOne\n", "\n", "\n", "clustersimpleaInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clustersimpleaOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "clustersimpleb\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "b: AddOne\n", + "\n", + "b: AddOne\n", "\n", "\n", "clustersimplebInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clustersimplebOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "clustersimplesum\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "sum: AddNode\n", + "\n", + "sum: AddNode\n", "\n", "\n", "clustersimplesumInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clustersimplesumOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", + "\n", + "\n", + "clustersimpleInputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Inputs\n", "\n", "\n", "\n", "clustersimpleInputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", - "\n", + "\n", "clustersimpleOutputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", - "\n", + "\n", "\n", - "clustersimpleInputsx\n", - "\n", - "x\n", + "clustersimpleInputsax\n", + "\n", + "ax\n", "\n", "\n", - "\n", + "\n", "clustersimpleaInputsx\n", - "\n", - "x\n", + "\n", + "x\n", "\n", - "\n", + "\n", "\n", - "clustersimpleInputsx->clustersimpleaInputsx\n", - "\n", - "\n", - "\n", + "clustersimpleInputsax->clustersimpleaInputsx\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clustersimpleInputsb__x\n", + "\n", + "b__x\n", "\n", "\n", - "\n", + "\n", "clustersimplebInputsx\n", - "\n", - "x\n", + "\n", + "x\n", "\n", - "\n", + "\n", "\n", - "clustersimpleInputsx->clustersimplebInputsx\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clustersimpleOutputsy\n", - "\n", - "y\n", + "clustersimpleInputsb__x->clustersimplebInputsx\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clustersimpleOutputssum\n", - "\n", - "sum\n", + "clustersimpleOutputsay\n", + "\n", + "ay\n", "\n", - "\n", + "\n", "\n", + "clustersimpleOutputsa + b + 2\n", + "\n", + "a + b + 2\n", + "\n", + "\n", + "\n", "clustersimpleaInputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", - "\n", + "\n", "clustersimpleaOutputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", "\n", - "\n", + "\n", "clustersimplebInputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", "\n", "clustersimpleaOutputsran->clustersimplebInputsrun\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", "\n", "\n", - "\n", + "\n", "clustersimpleaOutputsy\n", - "\n", - "y\n", + "\n", + "y\n", "\n", - "\n", + "\n", "\n", - "clustersimpleaOutputsy->clustersimpleOutputsy\n", - "\n", - "\n", - "\n", + "clustersimpleaOutputsy->clustersimpleOutputsay\n", + "\n", + "\n", + "\n", "\n", "\n", - "\n", + "\n", "clustersimplesumInputsx\n", - "\n", - "x\n", + "\n", + "x\n", "\n", "\n", "\n", "clustersimpleaOutputsy->clustersimplesumInputsx\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", "\n", "\n", - "\n", + "\n", "clustersimplebOutputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", "\n", - "\n", + "\n", "clustersimplesumInputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", "\n", "clustersimplebOutputsran->clustersimplesumInputsrun\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", "\n", "\n", - "\n", + "\n", "clustersimplebOutputsy\n", - "\n", - "y\n", + "\n", + "y\n", "\n", "\n", - "\n", + "\n", "clustersimplesumInputsy\n", - "\n", - "y\n", + "\n", + "y\n", "\n", "\n", "\n", "clustersimplebOutputsy->clustersimplesumInputsy\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", "\n", "\n", - "\n", + "\n", "clustersimplesumOutputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", "\n", - "\n", + "\n", "clustersimplesumOutputssum\n", - "\n", - "sum\n", + "\n", + "sum\n", "\n", - "\n", + "\n", "\n", - "clustersimplesumOutputssum->clustersimpleOutputssum\n", - "\n", - "\n", - "\n", + "clustersimplesumOutputssum->clustersimpleOutputsa + b + 2\n", + "\n", + "\n", + "\n", "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 28, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } @@ -1238,10 +1255,30 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 30, "id": "ae500d5e-e55b-432c-8b5f-d5892193cdf5", "metadata": {}, "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "11fa1336d10a42f4936ce22a299f191d", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "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" + ] + }, { "name": "stdout", "output_type": "stream", @@ -1252,16 +1289,16 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 29, + "execution_count": 30, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -1296,7 +1333,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 31, "id": "2114d0c3-cdad-43c7-9ffa-50c36d56d18f", "metadata": {}, "outputs": [ @@ -1309,205 +1346,205 @@ "\n", "\n", - "\n", + "\n", "\n", "clusterwith_prebuilt\n", - "\n", - "with_prebuilt: Workflow\n", + "\n", + "with_prebuilt: Workflow\n", "\n", "clusterwith_prebuiltInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clusterwith_prebuiltOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "\n", "clusterwith_prebuiltInputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", "\n", "clusterwith_prebuiltOutputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputsname\n", - "\n", - "name\n", + "clusterwith_prebuiltInputsstructure__name\n", + "\n", + "structure__name\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputscrystalstructure\n", - "\n", - "crystalstructure\n", + "clusterwith_prebuiltInputsstructure__crystalstructure\n", + "\n", + "structure__crystalstructure\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputsa\n", - "\n", - "a\n", + "clusterwith_prebuiltInputsstructure__a\n", + "\n", + "structure__a\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputsc\n", - "\n", - "c\n", + "clusterwith_prebuiltInputsstructure__c\n", + "\n", + "structure__c\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputscovera\n", - "\n", - "covera\n", + "clusterwith_prebuiltInputsstructure__covera\n", + "\n", + "structure__covera\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputsu\n", - "\n", - "u\n", + "clusterwith_prebuiltInputsstructure__u\n", + "\n", + "structure__u\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputsorthorhombic\n", - "\n", - "orthorhombic\n", + "clusterwith_prebuiltInputsstructure__orthorhombic\n", + "\n", + "structure__orthorhombic\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputscubic\n", - "\n", - "cubic\n", + "clusterwith_prebuiltInputsstructure__cubic\n", + "\n", + "structure__cubic\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputsn_ionic_steps\n", - "\n", - "n_ionic_steps: int\n", + "clusterwith_prebuiltInputscalc__n_ionic_steps\n", + "\n", + "calc__n_ionic_steps: int\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputsn_print\n", - "\n", - "n_print: int\n", + "clusterwith_prebuiltInputscalc__n_print\n", + "\n", + "calc__n_print: int\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputstemperature\n", - "\n", - "temperature\n", + "clusterwith_prebuiltInputscalc__temperature\n", + "\n", + "calc__temperature\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltInputspressure\n", - "\n", - "pressure\n", + "clusterwith_prebuiltInputscalc__pressure\n", + "\n", + "calc__pressure\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputscells\n", - "\n", - "cells\n", + "clusterwith_prebuiltOutputscalc__cells\n", + "\n", + "calc__cells\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsdisplacements\n", - "\n", - "displacements\n", + "clusterwith_prebuiltOutputscalc__displacements\n", + "\n", + "calc__displacements\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsenergy_pot\n", - "\n", - "energy_pot\n", + "clusterwith_prebuiltOutputscalc__energy_pot\n", + "\n", + "calc__energy_pot\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsenergy_tot\n", - "\n", - "energy_tot\n", + "clusterwith_prebuiltOutputscalc__energy_tot\n", + "\n", + "calc__energy_tot\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsforce_max\n", - "\n", - "force_max\n", + "clusterwith_prebuiltOutputscalc__force_max\n", + "\n", + "calc__force_max\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsforces\n", - "\n", - "forces\n", + "clusterwith_prebuiltOutputscalc__forces\n", + "\n", + "calc__forces\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsindices\n", - "\n", - "indices\n", + "clusterwith_prebuiltOutputscalc__indices\n", + "\n", + "calc__indices\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputspositions\n", - "\n", - "positions\n", + "clusterwith_prebuiltOutputscalc__positions\n", + "\n", + "calc__positions\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputspressures\n", - "\n", - "pressures\n", + "clusterwith_prebuiltOutputscalc__pressures\n", + "\n", + "calc__pressures\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputstotal_displacements\n", - "\n", - "total_displacements\n", + "clusterwith_prebuiltOutputscalc__total_displacements\n", + "\n", + "calc__total_displacements\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsunwrapped_positions\n", - "\n", - "unwrapped_positions\n", + "clusterwith_prebuiltOutputscalc__unwrapped_positions\n", + "\n", + "calc__unwrapped_positions\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsvolume\n", - "\n", - "volume\n", + "clusterwith_prebuiltOutputscalc__volume\n", + "\n", + "calc__volume\n", "\n", - "\n", + "\n", "\n", - "clusterwith_prebuiltOutputsfig\n", - "\n", - "fig\n", + "clusterwith_prebuiltOutputsplot__fig\n", + "\n", + "plot__fig\n", "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 30, + "execution_count": 31, "metadata": {}, "output_type": "execute_result" } @@ -1528,7 +1565,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 32, "id": "c71a8308-f8a1-4041-bea0-1c841e072a6d", "metadata": {}, "outputs": [], @@ -1538,17 +1575,25 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 33, "id": "2b9bb21a-73cd-444e-84a9-100e202aa422", "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": [ "13" ] }, - "execution_count": 32, + "execution_count": 33, "metadata": {}, "output_type": "execute_result" } @@ -1587,7 +1632,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 34, "id": "3668f9a9-adca-48a4-84ea-13add965897c", "metadata": {}, "outputs": [ @@ -1597,7 +1642,7 @@ "{'intermediate': 102, 'plus_three': 103}" ] }, - "execution_count": 33, + "execution_count": 34, "metadata": {}, "output_type": "execute_result" } @@ -1635,7 +1680,7 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 35, "id": "9aaeeec0-5f88-4c94-a6cc-45b56d2f0111", "metadata": {}, "outputs": [], @@ -1658,13 +1703,14 @@ "\n", "@Workflow.wrap_as.single_value_node()\n", "def per_atom_energy_difference(structure1, energy1, structure2, energy2):\n", + " # The unrelaxed structure is fine, we're just using it to get n_atoms\n", " de = (energy2[-1]/len(structure2)) - (energy1[-1]/len(structure1))\n", " return de" ] }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 36, "id": "a832e552-b3cc-411a-a258-ef21574fc439", "metadata": {}, "outputs": [], @@ -1691,7 +1737,7 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 37, "id": "b764a447-236f-4cb7-952a-7cba4855087d", "metadata": {}, "outputs": [ @@ -1704,1089 +1750,1221 @@ "\n", "\n", - "\n", + "\n", "\n", "clusterphase_preference\n", - "\n", - "phase_preference: Workflow\n", + "\n", + "phase_preference: Workflow\n", "\n", "clusterphase_preferenceInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clusterphase_preferenceOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "clusterphase_preferenceelement\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "element: UserInput\n", + "\n", + "element: UserInput\n", "\n", "\n", "clusterphase_preferenceelementInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clusterphase_preferenceelementOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "clusterphase_preferencemin_phase1\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "min_phase1: LammpsMinimize\n", + "\n", + "min_phase1: LammpsMinimize\n", "\n", "\n", "clusterphase_preferencemin_phase1Inputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clusterphase_preferencemin_phase1Outputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "clusterphase_preferencemin_phase2\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "min_phase2: LammpsMinimize\n", + "\n", + "min_phase2: LammpsMinimize\n", "\n", "\n", "clusterphase_preferencemin_phase2Inputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clusterphase_preferencemin_phase2Outputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "clusterphase_preferencecompare\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "compare: PerAtomEnergyDifference\n", + "\n", + "compare: PerAtomEnergyDifference\n", "\n", "\n", "clusterphase_preferencecompareInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Inputs\n", + "\n", + "Inputs\n", "\n", "\n", "clusterphase_preferencecompareOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", - "Outputs\n", + "\n", + "Outputs\n", "\n", "\n", "\n", "clusterphase_preferenceInputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferenceOutputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsuser_input\n", - "\n", - "user_input\n", + "clusterphase_preferenceInputselement\n", + "\n", + "element\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferenceelementInputsuser_input\n", - "\n", - "user_input\n", + "\n", + "user_input\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsuser_input->clusterphase_preferenceelementInputsuser_input\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputselement->clusterphase_preferenceelementInputsuser_input\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputscrystalstructure\n", - "\n", - "crystalstructure\n", + "clusterphase_preferenceInputsphase1\n", + "\n", + "phase1\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencemin_phase1Inputscrystalstructure\n", - "\n", - "crystalstructure\n", + "\n", + "crystalstructure\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputscrystalstructure->clusterphase_preferencemin_phase1Inputscrystalstructure\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputsphase1->clusterphase_preferencemin_phase1Inputscrystalstructure\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputscrystalstructure\n", - "\n", - "crystalstructure\n", - "\n", - "\n", - "\n", - "clusterphase_preferenceInputscrystalstructure->clusterphase_preferencemin_phase2Inputscrystalstructure\n", - "\n", - "\n", - "\n", - "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsa\n", - "\n", - "a\n", + "clusterphase_preferenceInputslattice_guess1\n", + "\n", + "lattice_guess1\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputsa\n", - "\n", - "a\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputslattice_guess\n", + "\n", + "lattice_guess\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsa->clusterphase_preferencemin_phase1Inputsa\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputslattice_guess1->clusterphase_preferencemin_phase1Inputslattice_guess\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputsa\n", - "\n", - "a\n", - "\n", - "\n", - "\n", - "clusterphase_preferenceInputsa->clusterphase_preferencemin_phase2Inputsa\n", - "\n", - "\n", - "\n", - "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsc\n", - "\n", - "c\n", + "clusterphase_preferenceInputsmin_phase1__structure__c\n", + "\n", + "min_phase1__structure__c\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputsc\n", - "\n", - "c\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputsstructure__c\n", + "\n", + "structure__c\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsc->clusterphase_preferencemin_phase1Inputsc\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputsmin_phase1__structure__c->clusterphase_preferencemin_phase1Inputsstructure__c\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputsc\n", - "\n", - "c\n", - "\n", - "\n", - "\n", - "clusterphase_preferenceInputsc->clusterphase_preferencemin_phase2Inputsc\n", - "\n", - "\n", - "\n", - "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputscovera\n", - "\n", - "covera\n", + "clusterphase_preferenceInputsmin_phase1__structure__covera\n", + "\n", + "min_phase1__structure__covera\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputscovera\n", - "\n", - "covera\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputsstructure__covera\n", + "\n", + "structure__covera\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputscovera->clusterphase_preferencemin_phase1Inputscovera\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputscovera\n", - "\n", - "covera\n", - "\n", - "\n", - "\n", - "clusterphase_preferenceInputscovera->clusterphase_preferencemin_phase2Inputscovera\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputsmin_phase1__structure__covera->clusterphase_preferencemin_phase1Inputsstructure__covera\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsu\n", - "\n", - "u\n", + "clusterphase_preferenceInputsmin_phase1__structure__u\n", + "\n", + "min_phase1__structure__u\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputsu\n", - "\n", - "u\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputsstructure__u\n", + "\n", + "structure__u\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsu->clusterphase_preferencemin_phase1Inputsu\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputsu\n", - "\n", - "u\n", - "\n", - "\n", - "\n", - "clusterphase_preferenceInputsu->clusterphase_preferencemin_phase2Inputsu\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputsmin_phase1__structure__u->clusterphase_preferencemin_phase1Inputsstructure__u\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsorthorhombic\n", - "\n", - "orthorhombic\n", + "clusterphase_preferenceInputsmin_phase1__structure__orthorhombic\n", + "\n", + "min_phase1__structure__orthorhombic\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputsorthorhombic\n", - "\n", - "orthorhombic\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputsstructure__orthorhombic\n", + "\n", + "structure__orthorhombic\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsorthorhombic->clusterphase_preferencemin_phase1Inputsorthorhombic\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputsorthorhombic\n", - "\n", - "orthorhombic\n", - "\n", - "\n", - "\n", - "clusterphase_preferenceInputsorthorhombic->clusterphase_preferencemin_phase2Inputsorthorhombic\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputsmin_phase1__structure__orthorhombic->clusterphase_preferencemin_phase1Inputsstructure__orthorhombic\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputscubic\n", - "\n", - "cubic\n", + "clusterphase_preferenceInputsmin_phase1__structure__cubic\n", + "\n", + "min_phase1__structure__cubic\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputscubic\n", - "\n", - "cubic\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputsstructure__cubic\n", + "\n", + "structure__cubic\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputscubic->clusterphase_preferencemin_phase1Inputscubic\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputsmin_phase1__structure__cubic->clusterphase_preferencemin_phase1Inputsstructure__cubic\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputscubic\n", - "\n", - "cubic\n", - "\n", - "\n", - "\n", - "clusterphase_preferenceInputscubic->clusterphase_preferencemin_phase2Inputscubic\n", - "\n", - "\n", - "\n", - "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsn_ionic_steps\n", - "\n", - "n_ionic_steps: int\n", + "clusterphase_preferenceInputsmin_phase1__calc__n_ionic_steps\n", + "\n", + "min_phase1__calc__n_ionic_steps: int\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputsn_ionic_steps\n", - "\n", - "n_ionic_steps: int\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputscalc__n_ionic_steps\n", + "\n", + "calc__n_ionic_steps: int\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsn_ionic_steps->clusterphase_preferencemin_phase1Inputsn_ionic_steps\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputsn_ionic_steps\n", - "\n", - "n_ionic_steps: int\n", + "clusterphase_preferenceInputsmin_phase1__calc__n_ionic_steps->clusterphase_preferencemin_phase1Inputscalc__n_ionic_steps\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferenceInputsn_ionic_steps->clusterphase_preferencemin_phase2Inputsn_ionic_steps\n", - "\n", - "\n", - "\n", - "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsn_print\n", - "\n", - "n_print: int\n", + "clusterphase_preferenceInputsmin_phase1__calc__n_print\n", + "\n", + "min_phase1__calc__n_print: int\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputsn_print\n", - "\n", - "n_print: int\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputscalc__n_print\n", + "\n", + "calc__n_print: int\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputsn_print->clusterphase_preferencemin_phase1Inputsn_print\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputsn_print\n", - "\n", - "n_print: int\n", - "\n", - "\n", - "\n", - "clusterphase_preferenceInputsn_print->clusterphase_preferencemin_phase2Inputsn_print\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputsmin_phase1__calc__n_print->clusterphase_preferencemin_phase1Inputscalc__n_print\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputspressure\n", - "\n", - "pressure\n", + "clusterphase_preferenceInputsmin_phase1__calc__pressure\n", + "\n", + "min_phase1__calc__pressure\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputspressure\n", - "\n", - "pressure\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputscalc__pressure\n", + "\n", + "calc__pressure\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceInputspressure->clusterphase_preferencemin_phase1Inputspressure\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceInputsmin_phase1__calc__pressure->clusterphase_preferencemin_phase1Inputscalc__pressure\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputspressure\n", - "\n", - "pressure\n", + "\n", + "\n", + "clusterphase_preferenceInputsphase2\n", + "\n", + "phase2\n", "\n", - "\n", - "\n", - "clusterphase_preferenceInputspressure->clusterphase_preferencemin_phase2Inputspressure\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputscrystalstructure\n", + "\n", + "crystalstructure\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsphase2->clusterphase_preferencemin_phase2Inputscrystalstructure\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceOutputscells\n", - "\n", - "cells\n", + "clusterphase_preferenceInputslattice_guess2\n", + "\n", + "lattice_guess2\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputslattice_guess\n", + "\n", + "lattice_guess\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputslattice_guess2->clusterphase_preferencemin_phase2Inputslattice_guess\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceOutputsdisplacements\n", - "\n", - "displacements\n", + "clusterphase_preferenceInputsmin_phase2__structure__c\n", + "\n", + "min_phase2__structure__c\n", "\n", - "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputsstructure__c\n", + "\n", + "structure__c\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsmin_phase2__structure__c->clusterphase_preferencemin_phase2Inputsstructure__c\n", + "\n", + "\n", + "\n", + "\n", + "\n", "\n", - "clusterphase_preferenceOutputsenergy_tot\n", - "\n", - "energy_tot\n", + "clusterphase_preferenceInputsmin_phase2__structure__covera\n", + "\n", + "min_phase2__structure__covera\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputsstructure__covera\n", + "\n", + "structure__covera\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsmin_phase2__structure__covera->clusterphase_preferencemin_phase2Inputsstructure__covera\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceOutputsforce_max\n", - "\n", - "force_max\n", + "clusterphase_preferenceInputsmin_phase2__structure__u\n", + "\n", + "min_phase2__structure__u\n", "\n", - "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputsstructure__u\n", + "\n", + "structure__u\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsmin_phase2__structure__u->clusterphase_preferencemin_phase2Inputsstructure__u\n", + "\n", + "\n", + "\n", + "\n", + "\n", "\n", - "clusterphase_preferenceOutputsforces\n", - "\n", - "forces\n", + "clusterphase_preferenceInputsmin_phase2__structure__orthorhombic\n", + "\n", + "min_phase2__structure__orthorhombic\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputsstructure__orthorhombic\n", + "\n", + "structure__orthorhombic\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsmin_phase2__structure__orthorhombic->clusterphase_preferencemin_phase2Inputsstructure__orthorhombic\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceOutputsindices\n", - "\n", - "indices\n", + "clusterphase_preferenceInputsmin_phase2__structure__cubic\n", + "\n", + "min_phase2__structure__cubic\n", "\n", - "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputsstructure__cubic\n", + "\n", + "structure__cubic\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsmin_phase2__structure__cubic->clusterphase_preferencemin_phase2Inputsstructure__cubic\n", + "\n", + "\n", + "\n", + "\n", + "\n", "\n", - "clusterphase_preferenceOutputspositions\n", - "\n", - "positions\n", + "clusterphase_preferenceInputsmin_phase2__calc__n_ionic_steps\n", + "\n", + "min_phase2__calc__n_ionic_steps: int\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputscalc__n_ionic_steps\n", + "\n", + "calc__n_ionic_steps: int\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsmin_phase2__calc__n_ionic_steps->clusterphase_preferencemin_phase2Inputscalc__n_ionic_steps\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceOutputspressures\n", - "\n", - "pressures\n", + "clusterphase_preferenceInputsmin_phase2__calc__n_print\n", + "\n", + "min_phase2__calc__n_print: int\n", "\n", - "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputscalc__n_print\n", + "\n", + "calc__n_print: int\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsmin_phase2__calc__n_print->clusterphase_preferencemin_phase2Inputscalc__n_print\n", + "\n", + "\n", + "\n", + "\n", + "\n", "\n", - "clusterphase_preferenceOutputssteps\n", - "\n", - "steps\n", + "clusterphase_preferenceInputsmin_phase2__calc__pressure\n", + "\n", + "min_phase2__calc__pressure\n", "\n", - "\n", - "\n", - "clusterphase_preferenceOutputstotal_displacements\n", - "\n", - "total_displacements\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputscalc__pressure\n", + "\n", + "calc__pressure\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceInputsmin_phase2__calc__pressure->clusterphase_preferencemin_phase2Inputscalc__pressure\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceOutputsunwrapped_positions\n", - "\n", - "unwrapped_positions\n", + "clusterphase_preferenceOutputsmin_phase1__calc__cells\n", + "\n", + "min_phase1__calc__cells\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceOutputsvolume\n", - "\n", - "volume\n", + "clusterphase_preferenceOutputsmin_phase1__calc__displacements\n", + "\n", + "min_phase1__calc__displacements\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceOutputsde\n", - "\n", - "de\n", + "clusterphase_preferenceOutputsmin_phase1__calc__energy_tot\n", + "\n", + "min_phase1__calc__energy_tot\n", "\n", - "\n", + "\n", "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__force_max\n", + "\n", + "min_phase1__calc__force_max\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__forces\n", + "\n", + "min_phase1__calc__forces\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__indices\n", + "\n", + "min_phase1__calc__indices\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__positions\n", + "\n", + "min_phase1__calc__positions\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__pressures\n", + "\n", + "min_phase1__calc__pressures\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__steps\n", + "\n", + "min_phase1__calc__steps\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__total_displacements\n", + "\n", + "min_phase1__calc__total_displacements\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__unwrapped_positions\n", + "\n", + "min_phase1__calc__unwrapped_positions\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase1__calc__volume\n", + "\n", + "min_phase1__calc__volume\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__cells\n", + "\n", + "min_phase2__calc__cells\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__displacements\n", + "\n", + "min_phase2__calc__displacements\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__energy_tot\n", + "\n", + "min_phase2__calc__energy_tot\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__force_max\n", + "\n", + "min_phase2__calc__force_max\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__forces\n", + "\n", + "min_phase2__calc__forces\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__indices\n", + "\n", + "min_phase2__calc__indices\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__positions\n", + "\n", + "min_phase2__calc__positions\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__pressures\n", + "\n", + "min_phase2__calc__pressures\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__steps\n", + "\n", + "min_phase2__calc__steps\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__total_displacements\n", + "\n", + "min_phase2__calc__total_displacements\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__unwrapped_positions\n", + "\n", + "min_phase2__calc__unwrapped_positions\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputsmin_phase2__calc__volume\n", + "\n", + "min_phase2__calc__volume\n", + "\n", + "\n", + "\n", + "clusterphase_preferenceOutputscompare__de\n", + "\n", + "compare__de\n", + "\n", + "\n", + "\n", "clusterphase_preferenceelementInputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferenceelementOutputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferenceelementOutputsuser_input\n", - "\n", - "user_input\n", + "\n", + "user_input\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Inputsname\n", - "\n", - "name\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Inputselement\n", + "\n", + "element\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceelementOutputsuser_input->clusterphase_preferencemin_phase1Inputsname\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceelementOutputsuser_input->clusterphase_preferencemin_phase1Inputselement\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Inputsname\n", - "\n", - "name\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Inputselement\n", + "\n", + "element\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferenceelementOutputsuser_input->clusterphase_preferencemin_phase2Inputsname\n", - "\n", - "\n", - "\n", + "clusterphase_preferenceelementOutputsuser_input->clusterphase_preferencemin_phase2Inputselement\n", + "\n", + "\n", + "\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencemin_phase1Inputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencemin_phase1Outputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencemin_phase1Outputsstructure\n", - "\n", - "structure\n", + "\n", + "structure\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencecompareInputsstructure1\n", - "\n", - "structure1\n", + "\n", + "structure1\n", "\n", "\n", "\n", "clusterphase_preferencemin_phase1Outputsstructure->clusterphase_preferencecompareInputsstructure1\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputscells\n", - "\n", - "cells\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__cells\n", + "\n", + "calc__cells\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputscells->clusterphase_preferenceOutputscells\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__cells->clusterphase_preferenceOutputsmin_phase1__calc__cells\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputsdisplacements\n", - "\n", - "displacements\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__displacements\n", + "\n", + "calc__displacements\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputsdisplacements->clusterphase_preferenceOutputsdisplacements\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__displacements->clusterphase_preferenceOutputsmin_phase1__calc__displacements\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputsenergy_pot\n", - "\n", - "energy_pot\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputsenergy\n", + "\n", + "energy\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencecompareInputsenergy1\n", - "\n", - "energy1\n", + "\n", + "energy1\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputsenergy_pot->clusterphase_preferencecompareInputsenergy1\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputsenergy->clusterphase_preferencecompareInputsenergy1\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputsenergy_tot\n", - "\n", - "energy_tot\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__energy_tot\n", + "\n", + "calc__energy_tot\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputsenergy_tot->clusterphase_preferenceOutputsenergy_tot\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__energy_tot->clusterphase_preferenceOutputsmin_phase1__calc__energy_tot\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputsforce_max\n", - "\n", - "force_max\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__force_max\n", + "\n", + "calc__force_max\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputsforce_max->clusterphase_preferenceOutputsforce_max\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__force_max->clusterphase_preferenceOutputsmin_phase1__calc__force_max\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputsforces\n", - "\n", - "forces\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__forces\n", + "\n", + "calc__forces\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputsforces->clusterphase_preferenceOutputsforces\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__forces->clusterphase_preferenceOutputsmin_phase1__calc__forces\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputsindices\n", - "\n", - "indices\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__indices\n", + "\n", + "calc__indices\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputsindices->clusterphase_preferenceOutputsindices\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__indices->clusterphase_preferenceOutputsmin_phase1__calc__indices\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputspositions\n", - "\n", - "positions\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__positions\n", + "\n", + "calc__positions\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputspositions->clusterphase_preferenceOutputspositions\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__positions->clusterphase_preferenceOutputsmin_phase1__calc__positions\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputspressures\n", - "\n", - "pressures\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__pressures\n", + "\n", + "calc__pressures\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputspressures->clusterphase_preferenceOutputspressures\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__pressures->clusterphase_preferenceOutputsmin_phase1__calc__pressures\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputssteps\n", - "\n", - "steps\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__steps\n", + "\n", + "calc__steps\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputssteps->clusterphase_preferenceOutputssteps\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__steps->clusterphase_preferenceOutputsmin_phase1__calc__steps\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputstotal_displacements\n", - "\n", - "total_displacements\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__total_displacements\n", + "\n", + "calc__total_displacements\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputstotal_displacements->clusterphase_preferenceOutputstotal_displacements\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__total_displacements->clusterphase_preferenceOutputsmin_phase1__calc__total_displacements\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputsunwrapped_positions\n", - "\n", - "unwrapped_positions\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__unwrapped_positions\n", + "\n", + "calc__unwrapped_positions\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputsunwrapped_positions->clusterphase_preferenceOutputsunwrapped_positions\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__unwrapped_positions->clusterphase_preferenceOutputsmin_phase1__calc__unwrapped_positions\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase1Outputsvolume\n", - "\n", - "volume\n", + "\n", + "\n", + "clusterphase_preferencemin_phase1Outputscalc__volume\n", + "\n", + "calc__volume\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase1Outputsvolume->clusterphase_preferenceOutputsvolume\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase1Outputscalc__volume->clusterphase_preferenceOutputsmin_phase1__calc__volume\n", + "\n", + "\n", + "\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencemin_phase2Inputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencemin_phase2Outputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencemin_phase2Outputsstructure\n", - "\n", - "structure\n", + "\n", + "structure\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencecompareInputsstructure2\n", - "\n", - "structure2\n", + "\n", + "structure2\n", "\n", "\n", "\n", "clusterphase_preferencemin_phase2Outputsstructure->clusterphase_preferencecompareInputsstructure2\n", - "\n", - "\n", - "\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputscells\n", - "\n", - "cells\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__cells\n", + "\n", + "calc__cells\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputscells->clusterphase_preferenceOutputscells\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputsdisplacements\n", - "\n", - "displacements\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__cells->clusterphase_preferenceOutputsmin_phase2__calc__cells\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__displacements\n", + "\n", + "calc__displacements\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputsdisplacements->clusterphase_preferenceOutputsdisplacements\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__displacements->clusterphase_preferenceOutputsmin_phase2__calc__displacements\n", + "\n", + "\n", + "\n", "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputsenergy_pot\n", - "\n", - "energy_pot\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputsenergy\n", + "\n", + "energy\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencecompareInputsenergy2\n", - "\n", - "energy2\n", + "\n", + "energy2\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputsenergy_pot->clusterphase_preferencecompareInputsenergy2\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputsenergy_tot\n", - "\n", - "energy_tot\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputsenergy->clusterphase_preferencecompareInputsenergy2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__energy_tot\n", + "\n", + "calc__energy_tot\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputsenergy_tot->clusterphase_preferenceOutputsenergy_tot\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputsforce_max\n", - "\n", - "force_max\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__energy_tot->clusterphase_preferenceOutputsmin_phase2__calc__energy_tot\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__force_max\n", + "\n", + "calc__force_max\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputsforce_max->clusterphase_preferenceOutputsforce_max\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputsforces\n", - "\n", - "forces\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__force_max->clusterphase_preferenceOutputsmin_phase2__calc__force_max\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__forces\n", + "\n", + "calc__forces\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputsforces->clusterphase_preferenceOutputsforces\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputsindices\n", - "\n", - "indices\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__forces->clusterphase_preferenceOutputsmin_phase2__calc__forces\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__indices\n", + "\n", + "calc__indices\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputsindices->clusterphase_preferenceOutputsindices\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputspositions\n", - "\n", - "positions\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__indices->clusterphase_preferenceOutputsmin_phase2__calc__indices\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__positions\n", + "\n", + "calc__positions\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputspositions->clusterphase_preferenceOutputspositions\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputspressures\n", - "\n", - "pressures\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__positions->clusterphase_preferenceOutputsmin_phase2__calc__positions\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__pressures\n", + "\n", + "calc__pressures\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputspressures->clusterphase_preferenceOutputspressures\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputssteps\n", - "\n", - "steps\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__pressures->clusterphase_preferenceOutputsmin_phase2__calc__pressures\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__steps\n", + "\n", + "calc__steps\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputssteps->clusterphase_preferenceOutputssteps\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputstotal_displacements\n", - "\n", - "total_displacements\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__steps->clusterphase_preferenceOutputsmin_phase2__calc__steps\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__total_displacements\n", + "\n", + "calc__total_displacements\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputstotal_displacements->clusterphase_preferenceOutputstotal_displacements\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputsunwrapped_positions\n", - "\n", - "unwrapped_positions\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__total_displacements->clusterphase_preferenceOutputsmin_phase2__calc__total_displacements\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__unwrapped_positions\n", + "\n", + "calc__unwrapped_positions\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputsunwrapped_positions->clusterphase_preferenceOutputsunwrapped_positions\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "clusterphase_preferencemin_phase2Outputsvolume\n", - "\n", - "volume\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__unwrapped_positions->clusterphase_preferenceOutputsmin_phase2__calc__unwrapped_positions\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "clusterphase_preferencemin_phase2Outputscalc__volume\n", + "\n", + "calc__volume\n", + "\n", + "\n", "\n", - "clusterphase_preferencemin_phase2Outputsvolume->clusterphase_preferenceOutputsvolume\n", - "\n", - "\n", - "\n", + "clusterphase_preferencemin_phase2Outputscalc__volume->clusterphase_preferenceOutputsmin_phase2__calc__volume\n", + "\n", + "\n", + "\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencecompareInputsrun\n", - "\n", - "run\n", + "\n", + "run\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencecompareOutputsran\n", - "\n", - "ran\n", + "\n", + "ran\n", "\n", "\n", "\n", - "\n", + "\n", "clusterphase_preferencecompareOutputsde\n", - "\n", - "de\n", + "\n", + "de\n", "\n", - "\n", + "\n", "\n", - "clusterphase_preferencecompareOutputsde->clusterphase_preferenceOutputsde\n", - "\n", - "\n", - "\n", + "clusterphase_preferencecompareOutputsde->clusterphase_preferenceOutputscompare__de\n", + "\n", + "\n", + "\n", "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 36, + "execution_count": 37, "metadata": {}, "output_type": "execute_result" } @@ -2797,7 +2975,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 38, "id": "b51bef25-86c5-4d57-80c1-ab733e703caf", "metadata": {}, "outputs": [ @@ -2818,10 +2996,18 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 39, "id": "091e2386-0081-436c-a736-23d019bd9b91", "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" + ] + }, { "name": "stdout", "output_type": "stream", @@ -2837,6 +3023,108 @@ "print(f\"{wf.inputs.element.value}: E({wf.inputs.phase2.value}) - E({wf.inputs.phase1.value}) = {out.compare__de:.2f} eV/atom\")" ] }, + { + "cell_type": "markdown", + "id": "7985d84f-b842-4a8d-95d5-eca3d19a78c8", + "metadata": {}, + "source": [ + "We can also replace entire node in a workflow or macro with a new node, booting the old one out and inserting the new one including all its connections. Because the connections are recreated, the replacement node _must_ have compatible IO to the node being replaced.\n", + "\n", + "There are several syntacic approaches for doing this, including invoking replacement methods from the workflow (or macro) or from the node being replaced, or a new (compatible!) class can be assigned directly to an existing node. We'll use the last approach, which makes a new instance of the supplied class and replaces the target node with it.\n", + "\n", + "Let's replace the calculation type for phase 1 -- let's switch it from a `CalcMin` to a `CalcStatic`; both of these take a `job` as input and give `structure` and `energy` as output, so we won't have any trouble connecting our new node in lieue of the old one." + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "4cdffdca-48d3-4486-9045-48102c7e5f31", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel job was not connected to job, 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", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel element was not connected to user_input, andthus could not disconnect from it.\n", + " warn(\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel structure was not connected to structure1, andthus could not disconnect from it.\n", + " warn(\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel energy was not connected to energy1, andthus could not disconnect from it.\n", + " warn(\n" + ] + } + ], + "source": [ + "replacee = wf.min_phase1.calc \n", + "wf.min_phase1.calc = Macro.create.atomistics.CalcStatic" + ] + }, + { + "cell_type": "markdown", + "id": "8dd7d2f9-313d-4e38-b467-823c48d0afa0", + "metadata": {}, + "source": [ + "Since we're no longer allowing our first phase to relax while the second phase still can, we would expect the second phase to have a much lower energy than the first one. If our lattice guess for the first phase is bad enough, this could even switch the preferred phase!\n", + "\n", + "Look at Al's fcc-hcp energy difference using this new workflow. We'll always let hcp relax, but freeze the fcc cell so we can see the impact of a good and bad lattice guess." + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "ed4a3a22-fc3a-44c9-9d4f-c65bc1288889", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The job JUSTAJOBNAME was saved and received the ID: 9558\n", + "The job JUSTAJOBNAME was saved and received the ID: 9558\n", + "Al: E(hcp) - E(fcc) = -5.57 eV/atom\n" + ] + } + ], + "source": [ + "# Bad guess\n", + "out = wf(element=\"Al\", phase1=\"fcc\", phase2=\"hcp\", lattice_guess1=3, lattice_guess2=3.1)\n", + "print(f\"{wf.inputs.element.value}: E({wf.inputs.phase2.value}) - E({wf.inputs.phase1.value}) = {out.compare__de:.2f} eV/atom\")" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "5a985cbf-c308-4369-9223-b8a37edb8ab1", + "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" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The job JUSTAJOBNAME was saved and received the ID: 9558\n", + "The job JUSTAJOBNAME was saved and received the ID: 9558\n", + "Al: E(hcp) - E(fcc) = 0.03 eV/atom\n" + ] + } + ], + "source": [ + "# Good guess\n", + "out = wf(element=\"Al\", phase1=\"fcc\", phase2=\"hcp\", lattice_guess1=4.05, lattice_guess2=3.2)\n", + "print(f\"{wf.inputs.element.value}: E({wf.inputs.phase2.value}) - E({wf.inputs.phase1.value}) = {out.compare__de:.2f} eV/atom\")" + ] + }, { "cell_type": "markdown", "id": "f447531e-3e8c-4c7e-a579-5f9c56b75a5b", @@ -2899,7 +3187,7 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 43, "id": "0b373764-b389-4c24-8086-f3d33a4f7fd7", "metadata": {}, "outputs": [ @@ -2913,7 +3201,7 @@ " 17.230249999999995]" ] }, - "execution_count": 39, + "execution_count": 43, "metadata": {}, "output_type": "execute_result" } @@ -2950,10 +3238,21 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 44, "id": "0dd04b4c-e3e7-4072-ad34-58f2c1e4f596", "metadata": {}, - "outputs": [], + "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 true, 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" + ] + } + ], "source": [ "@Workflow.wrap_as.single_value_node()\n", "def add(a, b):\n", @@ -2998,7 +3297,7 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 45, "id": "2dfb967b-41ac-4463-b606-3e315e617f2a", "metadata": {}, "outputs": [ @@ -3022,7 +3321,7 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 46, "id": "2e87f858-b327-4f6b-9237-c8a557f29aeb", "metadata": {}, "outputs": [ @@ -3030,12 +3329,12 @@ "name": "stdout", "output_type": "stream", "text": [ - "0.499 > 0.2\n", - "0.879 > 0.2\n", - "0.993 > 0.2\n", - "0.606 > 0.2\n", - "0.126 <= 0.2\n", - "Finally 0.126\n" + "0.406 > 0.2\n", + "0.999 > 0.2\n", + "0.827 > 0.2\n", + "0.417 > 0.2\n", + "0.120 <= 0.2\n", + "Finally 0.120\n" ] } ], diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index 5fe20999b..4cfee805a 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -2,20 +2,21 @@ Channels are access points for information to flow into and out of nodes. Data channels carry, unsurprisingly, data. -Output data channels will attempt to push their new value to all their connected input -data channels on update, while input data channels will reject any updates if their -parent node is running. -In this way, data channels facilitate forward propagation of data through a graph. -They hold data persistently. +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 updated. -Output signal channels must be accessed by the owning node directly, and then trigger -all the input signal channels to which they are connected. +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, but rather fire for an effect. +They do not hold any data and have no `value` attribute, but rather fire for an effect. """ from __future__ import annotations @@ -32,23 +33,27 @@ ) if typing.TYPE_CHECKING: - from pyiron_workflow.composite import Composite from pyiron_workflow.node import Node +class ChannelConnectionError(Exception): + pass + + 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, and - store all of their current connections in a list. - This connection information is duplicated in that it is stored on _both_ channels - that form the connection. + 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. + 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 what to do on an - attempted connection, `connect`. + Child classes must define a string representation, `__str__`, and their + `generic_type` which is a parent of both themselves and their output/input partner. Attributes: label (str): The name of the channel. @@ -78,15 +83,58 @@ def __init__( def __str__(self): pass + @property @abstractmethod + def generic_type(self) -> type[Channel]: + """Input and output class pairs should share this parent class""" + + def _valid_connection(self, other: Channel) -> bool: + """ + Logic for determining if a connection is valid. + + Connections should have the same generic type, but not the same type -- i.e. + they should be an input/output pair of some connection type. + """ + return isinstance(other, self.generic_type) and not isinstance( + other, self.__class__ + ) + def connect(self, *others: Channel) -> None: """ - How to handle connections to other channels. + Form a connection between this and one or more other channels. + 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. + + Raises: + (ChannelConnectionError): If the other channel is of the correct generic + type, but nonetheless not a valid connection. + (TypeError): If the other channel is not an instance of this channel's + generic type. """ - pass + for other in others: + if other in self.connections: + continue + elif self._valid_connection(other): + self.connections.append(other) + other.connections.append(self) + else: + if isinstance(other, self.generic_type): + raise ChannelConnectionError( + f"{self.label} ({self.__class__.__name__}) and {other.label} " + f"({other.__class__.__name__}) share a generic type but were " + f"not a valid connection. Check channel classes, type hints, " + f"etc." + ) + else: + raise TypeError( + f"Can only connect two {self.generic_type.__name__} objects, " + f"but {self.label} ({self.__class__.__name__}) got {other} " + f"({type(other)})" + ) def disconnect(self, *others: Channel) -> list[tuple[Channel, Channel]]: """ @@ -126,9 +174,6 @@ def connected(self) -> bool: """ return len(self.connections) > 0 - def _already_connected(self, other: Channel) -> bool: - return other in self.connections - def __iter__(self): return self.connections.__iter__() @@ -139,6 +184,24 @@ def __len__(self): def channel(self) -> Channel: return self + def copy_connections(self, other: Channel) -> None: + """ + Adds all the connections in another channel to this channel's connections. + + If an exception is encountered, all the new connections are disconnected before + the exception is raised. + """ + new_connections = [] + try: + for connect_to in other.connections: + # We do them one at a time in case any fail, so we can undo those that + # worked + self.connect(connect_to) + new_connections.append(connect_to) + except Exception as e: + self.disconnect(*new_connections) + raise e + def to_dict(self) -> dict: return { "label": self.label, @@ -172,16 +235,16 @@ class DataChannel(Channel, ABC): (In the future they may optionally have a storage history limit.) (In the future they may optionally have an ontological type.) - The `value` held by a channel can be manually assigned, but should normally be set - by the `update` method. - In neither case is the type hint strictly enforced. + Note that for the sake of computational efficiency, assignments to the `value` + property are not type-checked; type-checking occurs only for connections where both + channels have a type hint, and when a value is being copied from another channel + with the `copy_value` method. - Type hinting is strictly enforced in one situation: when making connections to - other channels and at least one data channel has a non-None value for its type hint. - In this case, we insist that the output type hint be _as or more 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_connections = False` on the relevant input channel. + 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_connections = False` on the relevant input + channel. 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 @@ -216,11 +279,58 @@ def __init__( node: Node, default: typing.Optional[typing.Any] = NotData, type_hint: typing.Optional[typing.Any] = None, + value_receiver: typing.Optional[InputData] = None, ): super().__init__(label=label, node=node) + self._value = NotData + self._value_receiver = None self.default = default self.value = default self.type_hint = type_hint + self.value_receiver = value_receiver + + @property + def value(self): + return self._value + + @value.setter + def value(self, new_value): + 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: + """ + Another data channel of the same type to whom new values are always pushed + (without type checking of any sort, not even when forming the couple!) + + Useful for macros, so that the IO of owned nodes and IO at the macro level can + be kept synchronized. + """ + return self._value_receiver + + @value_receiver.setter + def value_receiver(self, new_partner: InputData | OutputData | None): + if new_partner is not None: + if not isinstance(new_partner, self.__class__): + raise TypeError( + f"The {self.__class__.__name__} {self.label} got a coupling " + f"partner {new_partner} but requires something of the same type" + ) + + if new_partner is self: + raise ValueError( + f"{self.__class__.__name__} {self.label} cannot couple to itself" + ) + + new_partner.value = self.value + + self._value_receiver = new_partner + + @property + def generic_type(self) -> type[Channel]: + return DataChannel @property def ready(self) -> bool: @@ -239,63 +349,12 @@ def ready(self) -> bool: def _value_is_data(self): return self.value is not NotData - def update(self, value) -> None: - """ - Store a new value and trigger before- and after-update routines. - - Args: - value: The value to store. - """ - self._before_update() - self.value = value - self._after_update() - - def _before_update(self) -> None: - """ - A tool for child classes to do things before the value changed during an update. - """ - pass - - def _after_update(self) -> None: - """ - A tool for child classes to do things after the value changed during an update. - """ - pass - - def connect(self, *others: DataChannel) -> None: - """ - For all others for which the connection is valid (one input, one output, both - data channels), adds this to the other's list of connections and the other to - this list of connections. - Then the input channel gets updated with the output channel's current value. - - Args: - *others (DataChannel): - - Raises: - TypeError: When one of others is not a `DataChannel` - """ - for other in others: - if self._valid_connection(other): - self.connections.append(other) - other.connections.append(self) - out, inp = self._figure_out_who_is_who(other) - if out.value is not NotData: - inp.update(out.value) - else: - if isinstance(other, DataChannel): - warn( - f"{self.label} ({self.__class__.__name__}) and {other.label} " - f"({other.__class__.__name__}) were not a valid connection" - ) - else: - raise TypeError( - f"Can only connect two channels, but {self.label} " - f"({self.__class__.__name__}) got a {other} ({type(other)})" - ) + @property + def _has_hint(self): + return self.type_hint is not None def _valid_connection(self, other) -> bool: - if self._is_IO_pair(other) and not self._already_connected(other): + if super()._valid_connection(other): if self._both_typed(other): out, inp = self._figure_out_who_is_who(other) if not inp.strict_connections: @@ -310,11 +369,8 @@ def _valid_connection(self, other) -> bool: else: return False - def _is_IO_pair(self, other: DataChannel) -> bool: - return isinstance(other, DataChannel) and not isinstance(other, self.__class__) - def _both_typed(self, other: DataChannel) -> bool: - return self.type_hint is not None and other.type_hint is not None + return self._has_hint and other._has_hint def _figure_out_who_is_who(self, other: DataChannel) -> (OutputData, InputData): return (self, other) if isinstance(self, OutputData) else (other, self) @@ -322,6 +378,22 @@ def _figure_out_who_is_who(self, other: DataChannel) -> (OutputData, InputData): def __str__(self): return str(self.value) + def copy_value(self, other: DataChannel) -> None: + """ + Copies the other channel's value. Unlike normal value assignment, the new value + (if it is data) must comply with this channel's type hint (if any). + """ + if ( + self._has_hint + and other._value_is_data + and not valid_value(other.value, self.type_hint) + ): + raise TypeError( + f"Channel{self.label} cannot copy value from {other.label} because " + f"value {other.value} does not match type hint {self.type_hint}" + ) + self.value = other.value + def to_dict(self) -> dict: d = super().to_dict() d["value"] = repr(self.value) @@ -331,8 +403,7 @@ def to_dict(self) -> dict: class InputData(DataChannel): """ - On `update`, Input channels will only `update` if their parent node is not - `running`. + `fetch()` updates input data value to the first available data among connections. The `strict_connections` parameter controls whether connections are subject to type checking requirements. @@ -347,6 +418,7 @@ def __init__( node: Node, default: typing.Optional[typing.Any] = NotData, type_hint: typing.Optional[typing.Any] = None, + value_receiver: typing.Optional[InputData] = None, strict_connections: bool = True, ): super().__init__( @@ -354,15 +426,29 @@ def __init__( node=node, default=default, type_hint=type_hint, + value_receiver=value_receiver, ) self.strict_connections = strict_connections - def _before_update(self) -> None: + def fetch(self) -> None: + """ + Sets `value` to the first value among connections that is something other than + `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. + + Raises: + RuntimeError: If the parent node is `running`. + """ 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 def activate_strict_connections(self) -> None: self.strict_connections = True @@ -372,16 +458,7 @@ def deactivate_strict_connections(self) -> None: class OutputData(DataChannel): - """ - On `update`, Output channels propagate their value (as long as it's actually data) - to all the input channels to which they are connected by invoking their `update` - method. - """ - - def _after_update(self) -> None: - if self._value_is_data: - for inp in self.connections: - inp.update(self.value) + pass class SignalChannel(Channel, ABC): @@ -401,41 +478,9 @@ class SignalChannel(Channel, ABC): def __call__(self) -> None: pass - def connect(self, *others: SignalChannel) -> None: - """ - For all others for which the connection is valid (one input, one output, both - data channels), adds this to the other's list of connections and the other to - this list of connections. - - Args: - *others (SignalChannel): The other channels to attempt a connection to - - Raises: - TypeError: When one of others is not a `SignalChannel` - """ - for other in others: - if self._valid_connection(other): - self.connections.append(other) - other.connections.append(self) - else: - if isinstance(other, SignalChannel): - warn( - f"{self.label} ({self.__class__.__name__}) and {other.label} " - f"({other.__class__.__name__}) were not a valid connection" - ) - else: - raise TypeError( - f"Can only connect two signal channels, but {self.label} " - f"({self.__class__.__name__}) got a {other} ({type(other)})" - ) - - def _valid_connection(self, other) -> bool: - return self._is_IO_pair(other) and not self._already_connected(other) - - def _is_IO_pair(self, other) -> bool: - return isinstance(other, SignalChannel) and not isinstance( - other, self.__class__ - ) + @property + def generic_type(self) -> type[Channel]: + return SignalChannel def connect_output_signal(self, signal: OutputSignal): self.connect(signal) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 59cb2a93d..1bb1abc29 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -5,7 +5,7 @@ from __future__ import annotations -from abc import ABC +from abc import ABC, abstractmethod from functools import partial from typing import Literal, Optional, TYPE_CHECKING @@ -19,7 +19,7 @@ from pyiron_workflow.util import logger, DotDict, SeabornColors if TYPE_CHECKING: - from pyiron_workflow.channels import Channel + from pyiron_workflow.channels import Channel, InputData, OutputData class Composite(Node, ABC): @@ -106,7 +106,7 @@ def __init__( self._outputs_map = None self.inputs_map = inputs_map self.outputs_map = outputs_map - self.nodes: DotDict[str:Node] = DotDict() + self.nodes: DotDict[str, Node] = DotDict() self.starting_nodes: list[Node] = [] self._creator = self.create self.create = self._owned_creator # Override the create method from the class @@ -138,17 +138,6 @@ def _owned_creator(self): """ return OwnedCreator(self, self._creator) - @property - def executor(self) -> None: - return None - - @executor.setter - def executor(self, new_executor): - if new_executor is not None: - raise NotImplementedError( - "Running composite nodes with an executor is not yet supported" - ) - def to_dict(self): return { "label": self.label, @@ -160,11 +149,31 @@ def on_run(self): return self.run_graph @staticmethod - def run_graph(self): - for node in self.starting_nodes: + def run_graph(_nodes: dict[Node], _starting_nodes: list[Node]): + for node in _starting_nodes: node.run() + return _nodes + + @property + def run_args(self) -> dict: + return {"_nodes": self.nodes, "_starting_nodes": self.starting_nodes} + + def process_run_result(self, run_output): + if run_output is not self.nodes: + # Then we probably ran on a parallel process and have an unpacked future + self._update_children(run_output) return DotDict(self.outputs.to_value_dict()) + def _update_children(self, children_from_another_process: DotDict[str, Node]): + """ + If you receive a new dictionary of children, e.g. from unpacking a futures + object of your own children you sent off to another process for computation, + replace your own nodes with them, and set yourself as their parent. + """ + for child in children_from_another_process.values(): + child.parent = self + self.nodes = children_from_another_process + def disconnect_run(self) -> list[tuple[Channel, Channel]]: """ Disconnect all `signals.input.run` connections on all child nodes. @@ -249,35 +258,78 @@ def get_data_digraph(self) -> dict[str, set[str]]: return digraph - @property - def run_args(self) -> dict: - return {"self": self} - def _build_io( self, - io: Inputs | Outputs, - target: Literal["inputs", "outputs"], - key_map: dict[str, str] | None, + i_or_o: Literal["inputs", "outputs"], + key_map: dict[str, str | None] | None, ) -> Inputs | Outputs: + """ + Build an IO panel for exposing child node IO to the outside world at the level + of the composite node's IO. + + Args: + target [Literal["inputs", "outputs"]]: Whether this is I or O. + key_map [dict[str, str]|None]: A map between the default convention for + mapping child IO to composite IO (`"{node.label}__{channel.label}"`) and + whatever label you actually want to expose to the composite user. Also + allows non-standards channel exposure, i.e. exposing + internally-connected channels (which would not normally be exposed) by + providing a string-to-string map, or suppressing unconnected channels + (which normally would be exposed) by providing a string-None map. + + Returns: + (Inputs|Outputs): The populated panel. + """ key_map = {} if key_map is None else key_map + io = Inputs() if i_or_o == "inputs" else Outputs() for node in self.nodes.values(): - panel = getattr(node, target) + panel = getattr(node, i_or_o) for channel_label in panel.labels: channel = panel[channel_label] default_key = f"{node.label}__{channel_label}" try: - if key_map[default_key] is not None: - io[key_map[default_key]] = channel + io_panel_key = key_map[default_key] + if io_panel_key is not None: + io[io_panel_key] = self._get_linking_channel( + channel, io_panel_key + ) except KeyError: if not channel.connected: - io[default_key] = channel + io[default_key] = self._get_linking_channel( + channel, default_key + ) return io + @abstractmethod + def _get_linking_channel( + self, + child_reference_channel: InputData | OutputData, + composite_io_key: str, + ) -> InputData | OutputData: + """ + Returns the channel that will be the link between the provided child channel, + and the composite's IO at the given key. + + The returned channel should be fully compatible with the provided child channel, + i.e. same type, same type hint... (For instance, the child channel itself is a + valid return, which would create a composite IO panel that works by reference.) + + Args: + child_reference_channel (InputData | OutputData): The child channel + composite_io_key (str): The key under which this channel will be stored on + the composite's IO. + + Returns: + (Channel): A channel with the same type, type hint, etc. as the reference + channel passed in. + """ + pass + def _build_inputs(self) -> Inputs: - return self._build_io(Inputs(), "inputs", self.inputs_map) + return self._build_io("inputs", self.inputs_map) def _build_outputs(self) -> Outputs: - return self._build_io(Outputs(), "outputs", self.outputs_map) + return self._build_io("outputs", self.outputs_map) def add(self, node: Node, label: Optional[str] = None) -> None: """ @@ -353,17 +405,96 @@ def _ensure_node_is_not_duplicated(self, node: Node, label: str): ) del self.nodes[node.label] - def remove(self, node: Node | str): - if isinstance(node, Node): - node.parent = None - node.disconnect() - del self.nodes[node.label] + def remove(self, node: Node | str) -> list[tuple[Channel, Channel]]: + """ + Remove a node from the `nodes` collection, disconnecting it and setting its + `parent` to None. + + Args: + node (Node|str): The node (or its label) to remove. + + Returns: + (list[tuple[Channel, Channel]]): Any connections that node had. + """ + node = self.nodes[node] if isinstance(node, str) else node + node.parent = None + disconnected = node.disconnect() + if node in self.starting_nodes: + self.starting_nodes.remove(node) + del self.nodes[node.label] + return disconnected + + def replace(self, owned_node: Node | str, replacement: Node | type[Node]) -> Node: + """ + Replaces a node currently owned with a new node instance. + The replacement must not belong to any other parent or have any connections. + The IO of the new node must be a perfect superset of the replaced node, i.e. + channel labels need to match precisely, but additional channels may be present. + After replacement, the new node will have the old node's connections, label, + and belong to this composite. + The labels are swapped, such that the replaced node gets the name of its + replacement (which might be silly, but is useful in case you want to revert the + change and swap the replaced node back in!) + + If replacement fails for some reason, the replacement and replacing node are + both returned to their original state, and the composite is left unchanged. + + Args: + owned_node (Node|str): The node to replace or its label. + replacement (Node | type[Node]): The node or class to replace it with. (If + a class is passed, it has all the same requirements on IO compatibility + and simply gets instantiated.) + + Returns: + (Node): The node that got removed + """ + if isinstance(owned_node, str): + owned_node = self.nodes[owned_node] + + if owned_node.parent is not self: + raise ValueError( + f"The node being replaced should be a child of this composite, but " + f"another parent was found: {owned_node.parent}" + ) + + if isinstance(replacement, Node): + if replacement.parent is not None: + raise ValueError( + f"Replacement node must have no parent, but got " + f"{replacement.parent}" + ) + if replacement.connected: + raise ValueError("Replacement node must not have any connections") + elif issubclass(replacement, Node): + replacement = replacement(label=owned_node.label) else: - del self.nodes[node] + raise TypeError( + f"Expected replacement node to be a node instance or node subclass, but " + f"got {replacement}" + ) + + replacement.copy_io(owned_node) # If the replacement is incompatible, we'll + # fail here before we've changed the parent at all. Since the replacement was + # first guaranteed to be an unconnected orphan, there is not yet any permanent + # damage + is_starting_node = owned_node in self.starting_nodes + self.remove(owned_node) + replacement.label, owned_node.label = owned_node.label, replacement.label + self.add(replacement) + if is_starting_node: + self.starting_nodes.append(replacement) + return owned_node def __setattr__(self, key: str, node: Node): if isinstance(node, Node) and key != "parent": self.add(node, label=key) + elif ( + isinstance(node, type) + and issubclass(node, Node) + and key in self.nodes.keys() + ): + # When a class is assigned to an existing node, try a replacement + self.replace(key, node) else: super().__setattr__(key, node) @@ -427,6 +558,16 @@ def __getattr__(self, item): return value + def __getstate__(self): + # Compatibility with python <3.11 + return self.__dict__ + + def __setstate__(self, state): + # Because we override getattr, we need to use __dict__ assignment directly in + # __setstate__ + self.__dict__["_parent"] = state["_parent"] + self.__dict__["_creator"] = state["_creator"] + class OwnedNodePackage: """ @@ -443,3 +584,9 @@ def __getattr__(self, item): if issubclass(value, Node): value = partial(value, parent=self._parent) return value + + def __getstate__(self): + return self.__dict__ + + def __setstate__(self, state): + self.__dict__ = state diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index 25c2c9a56..34b85c91c 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -3,7 +3,7 @@ import inspect import warnings from functools import partialmethod -from typing import get_args, get_type_hints, Optional, TYPE_CHECKING +from typing import Any, get_args, get_type_hints, Optional, TYPE_CHECKING from pyiron_workflow.channels import InputData, OutputData, NotData from pyiron_workflow.has_channel import HasChannel @@ -233,8 +233,8 @@ class Function(Node): Because we provided a good initial value for `x`, we get our result right away. Using the decorator is the recommended way to create new node classes, but this - magic is just equivalent to these two more verbose ways of defining a new class. - The first is to override the `__init__` method directly: + magic is just equivalent to creating a child class with the `node_function` + already defined as a `staticmethod`: >>> from typing import Literal, Optional >>> >>> class AlphabetModThree(Function): @@ -254,24 +254,6 @@ class Function(Node): ... letter = ["a", "b", "c"][i % 3] ... return letter - The second effectively does the same thing, but leverages python's - `functools.partialmethod` to do so much more succinctly. - In this example, note that the function is declared _before_ `__init__` is set, - so that it is available in the correct scope (above, we could place it - afterwards because we were accessing it through self). - >>> from functools import partialmethod - >>> - >>> class Adder(Function): - ... @staticmethod - ... def adder(x: int = 0, y: int = 0) -> int: - ... sum = x + y - ... return sum - ... - ... __init__ = partialmethod( - ... Function.__init__, - ... adder, - ... ) - Finally, let's put it all together by using both of these nodes at once. Instead of setting input to a particular data value, we'll set it to be another node's output channel, thus forming a connection. @@ -322,14 +304,29 @@ def __init__( output_labels: Optional[str | list[str] | tuple[str]] = None, **kwargs, ): + if not callable(node_function): + # 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, "node_function"): + raise AttributeError( + f"If `None` is provided as a `node_function`, a `node_function` " + f"property must be defined instead, e.g. when making child classes" + f"of `Function` with specific behaviour" + ) + else: + # If a callable node function is received, use it + self.node_function = node_function + super().__init__( - label=label if label is not None else node_function.__name__, + label=label if label is not None else self.node_function.__name__, parent=parent, # **kwargs, ) - self.node_function = node_function - self._inputs = None self._outputs = None self._output_labels = self._get_output_labels(output_labels) @@ -477,32 +474,24 @@ def on_run(self): def run_args(self) -> dict: kwargs = self.inputs.to_value_dict() if "self" in self._input_args: - if self.executor is not None: - raise NotImplementedError( - f"The node {self.label} cannot be run on an executor because it " - f"uses the `self` argument and this functionality is not yet " - f"implemented" + if self.executor: + raise ValueError( + f"Function node {self.label} uses the `self` argument, but this " + f"can't yet be run with executors" ) kwargs["self"] = self return kwargs - def process_run_result(self, function_output): + def process_run_result(self, function_output: Any | tuple) -> Any | tuple: """ Take the results of the node function, and use them to update the node output. - - By extracting this as a separate method, we allow the node to pass the actual - execution off to another entity and release the python process to do other - things. In such a case, this function should be registered as a callback - so that the node can finishing "running" and push its data forward when that - execution is finished. """ - if len(self.outputs) == 0: - return - elif len(self.outputs) == 1: - function_output = (function_output,) - - for out, value in zip(self.outputs, function_output): - out.update(value) + for out, value in zip( + self.outputs, + (function_output,) if len(self.outputs) == 1 else function_output, + ): + out.value = value + return function_output def _convert_input_args_and_kwargs_to_input_kwargs(self, *args, **kwargs): reverse_keys = list(self._input_args.keys())[::-1] @@ -646,9 +635,10 @@ def as_node(node_function: callable): { "__init__": partialmethod( Function.__init__, - node_function, + None, output_labels=output_labels, - ) + ), + "node_function": staticmethod(node_function), }, ) @@ -671,9 +661,10 @@ def as_single_value_node(node_function: callable): { "__init__": partialmethod( SingleValue.__init__, - node_function, + None, output_labels=output_labels, - ) + ), + "node_function": staticmethod(node_function), }, ) diff --git a/pyiron_workflow/interfaces.py b/pyiron_workflow/interfaces.py index 36cd2c858..02d826de8 100644 --- a/pyiron_workflow/interfaces.py +++ b/pyiron_workflow/interfaces.py @@ -8,9 +8,7 @@ from pyiron_base.interfaces.singleton import Singleton -# from pyiron_contrib.executors import CloudpickleProcessPoolExecutor as Executor # from pympipool.mpi.executor import PyMPISingleTaskExecutor as Executor - from pyiron_workflow.executors import CloudpickleProcessPoolExecutor as Executor from pyiron_workflow.function import ( @@ -60,23 +58,17 @@ def Workflow(self): @property def standard(self): - try: - return self._standard - except AttributeError: - from pyiron_workflow.node_library.standard import nodes + from pyiron_workflow.node_package import NodePackage + from pyiron_workflow.node_library.standard import nodes - self.register("_standard", *nodes) - return self._standard + return NodePackage(*nodes) @property def atomistics(self): - try: - return self._atomistics - except AttributeError: - from pyiron_workflow.node_library.atomistics import nodes + from pyiron_workflow.node_package import NodePackage + from pyiron_workflow.node_library.atomistics import nodes - self.register("_atomistics", *nodes) - return self._atomistics + return NodePackage(*nodes) @property def meta(self): @@ -87,11 +79,15 @@ def meta(self): return self._meta def register(self, domain: str, *nodes: list[type[Node]]): - if domain in self.__dir__(): - raise AttributeError(f"{domain} is already an attribute of {self}") - from pyiron_workflow.node_package import NodePackage - - setattr(self, domain, NodePackage(*nodes)) + raise NotImplementedError( + "Registering new node packages is currently not playing well with " + "executors. We hope to return this feature soon." + ) + # if domain in self.__dir__(): + # raise AttributeError(f"{domain} is already an attribute of {self}") + # from pyiron_workflow.node_package import NodePackage + # + # setattr(self, domain, NodePackage(*nodes)) class Wrappers(metaclass=Singleton): diff --git a/pyiron_workflow/io.py b/pyiron_workflow/io.py index ceebf57ef..f9b341dbf 100644 --- a/pyiron_workflow/io.py +++ b/pyiron_workflow/io.py @@ -161,6 +161,15 @@ def to_dict(self): "channels": {l: c.to_dict() for l, c in self.channel_dict.items()}, } + def __getstate__(self): + # Compatibility with python <3.11 + return self.__dict__ + + def __setstate__(self, state): + # Because we override getattr, we need to use __dict__ assignment directly in + # __setstate__ the same way we need it in __init__ + self.__dict__["channel_dict"] = state["channel_dict"] + class DataIO(IO, ABC): """ @@ -168,7 +177,7 @@ class DataIO(IO, ABC): """ def _assign_a_non_channel_value(self, channel: DataChannel, value) -> None: - channel.update(value) + channel.value = value def to_value_dict(self): return {label: channel.value for label, channel in self.channel_dict.items()} @@ -194,6 +203,10 @@ def activate_strict_connections(self): def deactivate_strict_connections(self): [c.deactivate_strict_connections() for c in self] + def fetch(self): + for c in self: + c.fetch() + class Outputs(DataIO): @property diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index 838ad5782..1fb567565 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -8,12 +8,15 @@ from functools import partialmethod from typing import Optional, TYPE_CHECKING +from pyiron_workflow.channels import InputData, OutputData from pyiron_workflow.composite import Composite from pyiron_workflow.io import Outputs, Inputs if TYPE_CHECKING: from bidict import bidict + from pyiron_workflow.node import Node + class Macro(Composite): """ @@ -114,7 +117,7 @@ class Macro(Composite): ... macro.b = macro.create.SingleValue(add_one, x=0) ... macro.c = macro.create.SingleValue(add_one, x=0) >>> - >>> m = Macro(modified_start_macro) + >>> m = Macro(modified_flow_macro) >>> m.outputs.to_value_dict() >>> m(a__x=1, b__x=2, c__x=3) {'a__result': 2, 'b__result': 3, 'c__result': 4} @@ -133,6 +136,27 @@ class Macro(Composite): Manually controlling execution flow is necessary for cyclic graphs (cf. the while loop meta-node), but best to avoid when possible as it's easy to miss intended connections in complex graphs. + + We can also modify an existing macro at runtime by replacing nodes within it, as + long as the replacement has fully compatible IO. There are three syntacic ways + to do this. Let's explore these by going back to our `add_three_macro` and + replacing each of its children with a node that adds 2 instead of 1. + >>> @Macro.wrap_as.single_value_node() + ... def add_two(x): + ... result = x + 2 + ... return result + >>> + >>> adds_six_macro = Macro(add_three_macro) + >>> # With the replace method + >>> # (replacement target can be specified by label or instance, + >>> # the replacing node can be specified by instance or class) + >>> adds_six_macro.replace(adds_six_macro.one, add_two()) + >>> # With the replace_with method + >>> adds_six_macro.two.replace_with(add_two()) + >>> # And by assignment of a compatible class to an occupied node label + >>> adds_six_macro.three = add_two + >>> adds_six_macro(inp=1) + {'three__result': 7} """ def __init__( @@ -161,6 +185,39 @@ def __init__( self.update_input(**kwargs) + def _get_linking_channel( + self, + child_reference_channel: InputData | OutputData, + composite_io_key: str, + ) -> InputData | OutputData: + """ + Build IO by value: create a new channel just like the child's channel. + + In the case of input data, we also form a value link from the composite channel + down to the child channel, so that the child will stay up-to-date. + """ + composite_channel = child_reference_channel.__class__( + label=composite_io_key, + node=self, + default=child_reference_channel.default, + type_hint=child_reference_channel.type_hint, + ) + composite_channel.value = child_reference_channel.value + + if isinstance(composite_channel, InputData): + composite_channel.strict_connections = ( + child_reference_channel.strict_connections + ) + composite_channel.value_receiver = child_reference_channel + elif isinstance(composite_channel, OutputData): + child_reference_channel.value_receiver = composite_channel + else: + raise TypeError( + "This should not be an accessible state, please contact the developers" + ) + + return composite_channel + @property def inputs(self) -> Inputs: return self._inputs @@ -169,6 +226,48 @@ def inputs(self) -> Inputs: def outputs(self) -> Outputs: return self._outputs + 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() @@ -195,6 +294,19 @@ 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 diff --git a/pyiron_workflow/meta.py b/pyiron_workflow/meta.py index 948d4bb1d..2f8020e1c 100644 --- a/pyiron_workflow/meta.py +++ b/pyiron_workflow/meta.py @@ -159,7 +159,7 @@ def make_loop(macro): # Connect each body node output to the output interface's respective input for body_node, inp in zip(body_nodes, interface.inputs): inp.connect(body_node.outputs[label]) - if body_node.executor is not None: + if body_node.executor: raise NotImplementedError( "Right now the output interface gets run after each body node," "if the body nodes can run asynchronously we need something " diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 374c01bed..1199d3802 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -10,17 +10,53 @@ from concurrent.futures import Future from typing import Any, Literal, Optional, TYPE_CHECKING +from pyiron_workflow.channels import NotData from pyiron_workflow.draw import Node as GraphvizNode +from pyiron_workflow.executors import CloudpickleProcessPoolExecutor as Executor from pyiron_workflow.files import DirectoryObject from pyiron_workflow.has_to_dict import HasToDict from pyiron_workflow.io import Signals, InputSignal, OutputSignal +from pyiron_workflow.type_hinting import valid_value from pyiron_workflow.util import SeabornColors if TYPE_CHECKING: import graphviz + from pyiron_workflow.channels import Channel from pyiron_workflow.composite import Composite - from pyiron_workflow.io import Inputs, Outputs + from pyiron_workflow.io import IO, Inputs, Outputs + + +def manage_status(node_method): + """ + Decorates methods of nodes that might be time-consuming, i.e. their main run + functionality. + + Sets `running` to true until the method completes and either fails or returns + something other than a `concurrent.futures.Future` instance; sets `failed` to true + if the method raises an exception; raises a `RuntimeError` if the node is already + `running` or `failed`. + """ + + def wrapped_method(node: Node, *args, **kwargs): # rather node:Node + if node.running: + raise RuntimeError(f"{node.label} is already running") + elif node.failed: + raise RuntimeError(f"{node.label} has a failed status") + + node.running = True + try: + out = node_method(node, *args, **kwargs) + return out + except Exception as e: + node.failed = True + out = None + raise e + finally: + # Leave the status as running if the method returns a future + node.running = isinstance(out, Future) + + return wrapped_method class Node(HasToDict, ABC): @@ -54,7 +90,8 @@ class Node(HasToDict, ABC): The `run()` method returns a representation of the node output (possible a futures object, if the node is running on an executor), and consequently `update()` also - returns this output if the node is `ready`. + returns this output if the node is `ready`. Both `run()` and `update()` will raise + errors if the node is already running or has a failed status. Calling an already instantiated node allows its input channels to be updated using keyword arguments corresponding to the channel labels, performing a batch-update of @@ -67,8 +104,11 @@ class Node(HasToDict, ABC): Their value is controlled automatically in the defined `run` and `finish_run` methods. - Nodes can be run on the main python process that owns them, or by assigning an - appropriate executor to their `executor` attribute. + 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 @@ -146,7 +186,10 @@ def __init__( # TODO: Provide support for actually computing stuff with the executor self.signals = self._build_signal_channels() self._working_directory = None - self.executor = None + self.executor = False + # We call it an executor, but it's just whether to use one. + # This is a simply stop-gap as we work out more sophisticated ways to reference + # (or create) an executor process without ever trying to pickle a `_thread.lock` self.future: None | Future = None @property @@ -168,71 +211,134 @@ def on_run(self) -> callable[..., Any | tuple]: pass @property + @abstractmethod def run_args(self) -> dict: """ Any data needed for `on_run`, will be passed as **kwargs. """ - return {} - def process_run_result(self, run_output: Any | tuple) -> None: + @abstractmethod + def process_run_result(self, run_output): """ What to _do_ with the results of `on_run` once you have them. + By extracting this as a separate method, we allow the node to pass the actual + execution off to another entity and release the python process to do other + things. In such a case, this function should be registered as a callback + so that the node can process the result of that process. + Args: - run_output (tuple): The results of a `self.on_run(self.run_args)` call. + run_output: The results of a `self.on_run(self.run_args)` call. """ - pass - def run(self) -> Any | tuple | Future: + @manage_status + def execute(self): + """ + Perform the node's operation with its current data. + + Execution happens directly on this python process. + """ + return self.process_run_result(self.on_run(**self.run_args)) + + def run(self): + """ + Update the input (with whatever is currently available -- does _not_ trigger + any other nodes to run) and use it to perform the node's operation. + + If executor information is specified, execution happens on that process, a + callback is registered, and futures object is returned. + + Once complete, fire `ran` signal to propagate execution in the computation graph + that owns this node (if any). + """ + self.update_input() + return self._run(finished_callback=self.finish_run_and_emit_ran) + + def pull(self): + raise NotImplementedError + # Need to implement everything for on-the-fly construction of the upstream + # graph and its execution + # Then, + self.update_input() + return self._run(finished_callback=self.finish_run) + + def update_input(self, **kwargs) -> None: + """ + Fetch the latest and highest-priority input values from connections, then + overwrite values with keywords arguments matching input channel labels. + + Any channel that has neither a connection nor a kwarg update at time of call is + left unchanged. + + Throws a warning if a keyword is provided that cannot be found among the input + keys. + + If you really want to update just a single value without any other side-effects, + this can always be accomplished by following the full semantic path to the + channel's value: `my_node.input.my_channel.value = "foo"`. + + Args: + **kwargs: input key - input value (including channels for connection) pairs. + """ + self.inputs.fetch() + for k, v in kwargs.items(): + if k in self.inputs.labels: + self.inputs[k] = v + else: + warnings.warn( + f"The keyword '{k}' was not found among input labels. If you are " + f"trying to update a node keyword, please use attribute assignment " + f"directly instead of calling" + ) + + @manage_status + def _run(self, finished_callback: callable) -> Any | tuple | Future: """ Executes the functionality of the node defined in `on_run`. Handles the status of the node, and communicating with any remote computing resources. """ - if self.running: - raise RuntimeError(f"{self.label} is already running") - - self.running = True - self.failed = False - - if self.executor is None: - try: - run_output = self.on_run(**self.run_args) - except Exception as e: - self.running = False - self.failed = True - raise e - return self.finish_run(run_output) + if not self.executor: + run_output = self.on_run(**self.run_args) + return finished_callback(run_output) else: # Just blindly try to execute -- as we nail down the executor interaction # we'll want to fail more cleanly here. - self.future = self.executor.submit(self.on_run, **self.run_args) - self.future.add_done_callback(self.finish_run) + executor = Executor() + self.future = executor.submit(self.on_run, **self.run_args) + self.future.add_done_callback(finished_callback) return self.future def finish_run(self, run_output: tuple | Future) -> Any | tuple: """ - Switch the node status, process the run result, then fire the ran signal. + Switch the node status, then process and return the run result. - By extracting this as a separate method, we allow the node to pass the actual - execution off to another entity and release the python process to do other - things. In such a case, this function should be registered as a callback - so that the node can finish "running" and, e.g. push its data forward when that - execution is finished. In such a case, a `concurrent.futures.Future` object is - expected back and must be unpacked. + Sets the `failed` status to true if an exception is encountered. """ if isinstance(run_output, Future): run_output = run_output.result() self.running = False try: - self.process_run_result(run_output) - self.signals.output.ran() - return run_output + processed_output = self.process_run_result(run_output) + return processed_output except Exception as e: self.failed = True raise e + def finish_run_and_emit_ran(self, run_output: tuple | Future) -> Any | tuple: + processed_output = self.finish_run(run_output) + self.signals.output.ran() + return processed_output + + finish_run_and_emit_ran.__doc__ = ( + finish_run.__doc__ + + """ + + Finally, fire the `ran` signal. + """ + ) + def _build_signal_channels(self) -> Signals: signals = Signals() signals.input.run = InputSignal("run", self, self.run) @@ -283,24 +389,6 @@ def fully_connected(self): and self.signals.fully_connected ) - def update_input(self, **kwargs) -> None: - """ - Match keywords to input channel labels and update input values. - - Args: - **kwargs: input label - input value (including channels for connection) - pairs. - """ - for k, v in kwargs.items(): - if k in self.inputs.labels: - self.inputs[k] = v - else: - warnings.warn( - f"The keyword '{k}' was not found among input labels. If you are " - f"trying to update a node keyword, please use attribute assignment " - f"directly instead of calling" - ) - def __call__(self, **kwargs) -> None: self.update_input(**kwargs) return self.run() @@ -370,3 +458,186 @@ def get_first_shared_parent(self, other: Node) -> Composite | None: our = our.parent their = other return None + + def copy_io( + self, + other: Node, + connections_fail_hard: bool = True, + values_fail_hard: bool = False, + ) -> None: + """ + Copies connections and values from another node's IO onto this node's IO. + Other channels with no connections are ignored for copying connections, and all + data channels without data are ignored for copying data. + Otherwise, default behaviour is to throw an exception if any of the other node's + connections fail to copy, but failed value copies are simply ignored (e.g. + because this node does not have a channel with a commensurate label or the + value breaks a type hint). + This error throwing/passing behaviour can be controlled with boolean flags. + + In the case that an exception is thrown, all newly formed connections are broken + and any new values are reverted to their old state before the exception is + raised. + + Args: + other (Node): The other node whose IO to copy. + connections_fail_hard: Whether to raise exceptions encountered when copying + connections. (Default is True.) + values_fail_hard (bool): Whether to raise exceptions encountered when + copying values. (Default is False.) + """ + new_connections = self._copy_connections(other, fail_hard=connections_fail_hard) + try: + self._copy_values(other, fail_hard=values_fail_hard) + except Exception as e: + for this, other in new_connections: + this.disconnect(other) + raise e + + def _copy_connections( + self, + other: Node, + fail_hard: bool = True, + ) -> list[tuple[Channel, Channel]]: + """ + Copies all the connections in another node to this one. + Expects all connected channels on the other node to have a counterpart on this + node -- i.e. the same label, type, and (for data) a type hint compatible with + all the existing connections being copied. + This requirement can be optionally relaxed such that any failures encountered + when attempting to make a connection (i.e. this node has no channel with a + corresponding label as the other node, or the new connection fails its validity + check), such that we simply continue past these errors and make as many + connections as we can while ignoring errors. + + This node may freely have additional channels not present in the other node. + The other node may have additional channels not present here as long as they are + not connected. + + If an exception is going to be raised, any connections copied so far are + disconnected first. + + Args: + other (Node): the node whose connections should be copied. + fail_hard (bool): Whether to raise an error an exception is encountered + when trying to reproduce a connection. (Default is True; revert new + connections then raise the exception.) + + Returns: + list[tuple[Channel, Channel]]: A list of all the newly created connection + pairs (for reverting changes). + """ + new_connections = [] + for my_panel, other_panel in [ + (self.inputs, other.inputs), + (self.outputs, other.outputs), + (self.signals.input, other.signals.input), + (self.signals.output, other.signals.output), + ]: + for key, channel in other_panel.items(): + for target in channel.connections: + try: + my_panel[key].connect(target) + new_connections.append((my_panel[key], target)) + except Exception as e: + if fail_hard: + # If you run into trouble, unwind what you've done + for this, other in new_connections: + this.disconnect(other) + raise e + else: + continue + return new_connections + + def _copy_values( + self, + other: Node, + fail_hard: bool = False, + ) -> list[tuple[Channel, Any]]: + """ + Copies all data from input and output channels in the other node onto this one. + Ignores other channels that hold non-data. + Failures to find a corresponding channel on this node (matching label, type, and + compatible type hint) are ignored by default, but can optionally be made to + raise an exception. + + If an exception is going to be raised, any values updated so far are + reverted first. + + Args: + other (Node): the node whose data values should be copied. + fail_hard (bool): Whether to raise an error an exception is encountered + when trying to duplicate a value. (Default is False, just keep going + past other's channels with no compatible label here and past values + that don't match type hints here.) + + Returns: + list[tuple[Channel, Any]]: A list of tuples giving channels whose value has + been updated and what it used to be (for reverting changes). + """ + old_values = [] + for my_panel, other_panel in [ + (self.inputs, other.inputs), + (self.outputs, other.outputs), + ]: + for key, to_copy in other_panel.items(): + if to_copy.value is not NotData: + try: + old_value = my_panel[key].value + my_panel[key].copy_value(to_copy) + old_values.append((my_panel[key], old_value)) + except Exception as e: + if fail_hard: + # If you run into trouble, unwind what you've done + for channel, value in old_values: + channel.value = value + raise e + else: + continue + return old_values + + def replace_with(self, other: Node | type[Node]): + """ + If this node has a parent, invokes `self.parent.replace(self, other)` to swap + out this node for the other node in the parent graph. + + The replacement must have fully compatible IO, i.e. its IO must be a superset of + this node's IO with all the same labels and type hints (although the latter is + not strictly enforced and will only cause trouble if there is an incompatibility + that causes trouble in the process of copying over connections) + + Args: + other (Node|type[Node]): The replacement. + """ + if self.parent is not None: + self.parent.replace(self, other) + else: + warnings.warn(f"Could not replace {self.label}, as it has no parent.") + + def __getstate__(self): + state = self.__dict__ + 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 + # and starting nodes. + # When the parallel process returns these, they're obviously different + # instances, so we re-parent them back to the receiving composite. + # At the same time, we want to make sure that the _old_ children get orphaned. + # Of course, we could do that directly in the composite method, but it also + # works to do it here. + # Something I like about this, is it also means that when we ship groups of + # nodes off to another process with cloudpickle, they're definitely not lugging + # along their parent, its connections, etc. with them! + # This is all working nicely as demonstrated over in the macro test suite. + # However, I have a bit of concern that when we start thinking about + # serialization for storage instead of serialization to another process, this + # might introduce a hard-to-track-down bug. + # For now, it works and I'm going to be super pragmatic and go for it, but + # for the record I am admitting that the current shallowness of my understanding + # may cause me/us headaches in the future. + # -Liam + return self.__dict__ + + def __setstate__(self, state): + self.__dict__ = state diff --git a/pyiron_workflow/util.py b/pyiron_workflow/util.py index 61dae6c7c..eff32b27d 100644 --- a/pyiron_workflow/util.py +++ b/pyiron_workflow/util.py @@ -13,6 +13,13 @@ def __setattr__(self, key, value): def __dir__(self): return set(super().__dir__() + list(self.keys())) + def __getstate__(self): + return self.__dict__ + + def __setstate__(self, state): + for k, v in state.items(): + self.__dict__[k] = v + class SeabornColors: """ diff --git a/pyiron_workflow/workflow.py b/pyiron_workflow/workflow.py index 7177593b7..5817fa7e7 100644 --- a/pyiron_workflow/workflow.py +++ b/pyiron_workflow/workflow.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from bidict import bidict + from pyiron_workflow.channels import InputData, OutputData from pyiron_workflow.node import Node @@ -184,6 +185,16 @@ def __init__( for node in nodes: self.add(node) + def _get_linking_channel( + self, + child_reference_channel: InputData | OutputData, + composite_io_key: str, + ) -> InputData | OutputData: + """ + Build IO by reference: just return the child's channel itself. + """ + return child_reference_channel + @property def inputs(self) -> Inputs: return self._build_inputs() @@ -192,11 +203,10 @@ def inputs(self) -> Inputs: def outputs(self) -> Outputs: return self._build_outputs() - @staticmethod - def run_graph(self): + def run(self): if self.automate_execution: self.set_run_signals_to_dag_execution() - return super().run_graph(self) + return super().run() def to_node(self): """ diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py index 4dfd1d7f2..c6e711fe5 100644 --- a/tests/unit/test_channels.py +++ b/tests/unit/test_channels.py @@ -2,7 +2,7 @@ from sys import version_info from pyiron_workflow.channels import ( - InputData, OutputData, InputSignal, OutputSignal, NotData + InputData, OutputData, InputSignal, OutputSignal, NotData, ChannelConnectionError ) @@ -23,6 +23,7 @@ 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.so1 = OutputData(label="list", node=DummyNode(), default=["foo"], type_hint=list) self.so2 = OutputData(label="list", node=DummyNode(), default=["foo"], type_hint=list) @@ -42,6 +43,8 @@ def test_connections(self): 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"): @@ -73,29 +76,40 @@ def test_connections(self): with self.subTest("Test iteration"): self.assertTrue(all([con in self.no.connections for con in self.no])) - with self.subTest("Don't push NotData"): - self.no.disconnect_all() + with self.subTest("Data should update on fetch"): + self.ni1.disconnect_all() + self.no.value = NotData self.ni1.value = 1 + + self.ni1.connect(self.no_empty) self.ni1.connect(self.no) self.assertEqual( self.ni1.value, 1, - msg="NotData should not be getting pushed on connection" + 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.ni2.value = 2 self.no.value = 3 - self.ni2.connect(self.no) + self.ni1.fetch() self.assertEqual( - self.ni2.value, + self.ni1.value, 3, - msg="Actual data should be getting pushed" + msg="Data fetch should to first connected value that's actually data," + "in this case skipping over no_empty" ) - self.no.update(NotData) + self.no_empty.value = 4 + self.ni1.fetch() self.assertEqual( - self.ni2.value, - 3, - msg="NotData should not be getting pushed on updates" + 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): @@ -112,19 +126,17 @@ def test_connection_validity_tests(self): "Input types should be allowed to be a super-set of output types" ) - self.no.connect(self.ni2) - self.assertNotIn( - self.no, - self.ni2.connections, - "Input types should not be allowed to be a sub-set of output types" - ) + with self.assertRaises( + ChannelConnectionError, + msg="Input types should not be allowed to be a sub-set of output types" + ): + self.no.connect(self.ni2) - self.so1.connect(self.ni2) - self.assertNotIn( - self.so1, - self.ni2.connections, - "Totally different types should not allow connections" - ) + with self.assertRaises( + ChannelConnectionError, + msg="Totally different type hints should not allow connections" + ): + self.so1.connect(self.ni2) self.ni2.strict_connections = False self.so1.connect(self.ni2) @@ -134,6 +146,73 @@ def test_connection_validity_tests(self): "With strict connections turned off, we should allow type-violations" ) + def test_copy_connections(self): + self.ni1.connect(self.no) + self.ni2.connect(self.no_empty) + self.ni2.copy_connections(self.ni1) + self.assertListEqual( + self.ni2.connections, + [self.no_empty, *self.ni1.connections], + msg="Copying should be additive, existing connections should still be there" + ) + + self.ni2.disconnect(*self.ni1.connections) + self.ni1.connections.append(self.so1) # Manually include a poorly-typed conn + with self.assertRaises( + ChannelConnectionError, + msg="Should not be able to connect to so1 because of type hint " + "incompatibility" + ): + self.ni2.copy_connections(self.ni1) + self.assertListEqual( + self.ni2.connections, + [self.no_empty], + msg="On failing, copy should revert the copying channel to its orignial " + "state" + ) + + def test_copy_value(self): + self.ni1.value = 2 + self.ni2.copy_value(self.ni1) + self.assertEqual( + self.ni2.value, + self.ni1.value, + msg="Should be able to copy values matching type hints" + ) + + self.ni2.copy_value(self.no_empty) + self.assertIs( + self.ni2.value, + NotData, + msg="Should be able to copy values that are not-data" + ) + + with self.assertRaises( + TypeError, + msg="Should not be able to copy values of the wrong type" + ): + self.ni2.copy_value(self.so1) + + self.ni2.type_hint = None + self.ni2.copy_value(self.ni1) + self.assertEqual( + self.ni2.value, + self.ni1.value, + msg="Should be able to copy any data if we have no type hint" + ) + self.ni2.copy_value(self.so1) + self.assertEqual( + self.ni2.value, + self.so1.value, + msg="Should be able to copy any data if we have no type hint" + ) + self.ni2.copy_value(self.no_empty) + self.assertEqual( + self.ni2.value, + NotData, + msg="Should be able to copy not-data if we have no type hint" + ) + def test_ready(self): with self.subTest("Test defaults and not-data"): without_default = InputData(label="without_default", node=DummyNode()) @@ -155,19 +234,43 @@ def test_ready(self): self.ni1.value = "Not numeric at all" self.assertFalse(self.ni1.ready) - def test_update(self): - self.no.connect(self.ni1, self.ni2) - self.no.update(42) - for inp in self.no.connections: - self.assertEqual( - self.no.value, - inp.value, - msg="Value should have been passed downstream" - ) + 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" + ) - self.ni1.node.running = True - with self.assertRaises(RuntimeError): - self.no.update(42) + 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): diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index 1420d06da..4f2958996 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -4,12 +4,7 @@ import unittest import warnings -# from pyiron_contrib.executors import CloudpickleProcessPoolExecutor as Executor -# from pympipool.mpi.executor import PyMPISingleTaskExecutor as Executor - -from pyiron_workflow.executors import CloudpickleProcessPoolExecutor as Executor - -from pyiron_workflow.channels import NotData +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 @@ -147,6 +142,23 @@ def test_label_choices(self): switch = Function(multiple_branches, output_labels="bool") self.assertListEqual(switch.outputs.labels, ["bool"]) + def test_availability_of_node_function(self): + @function_node() + def linear(x): + return x + + @function_node() + def bilinear(x, y): + xy = linear.node_function(x) * linear.node_function(y) + return xy + + self.assertEqual( + bilinear(2, 3).run(), + 2 * 3, + msg="Children of `Function` should have their `node_function` exposed for " + "use at the class level" + ) + def test_signals(self): @function_node() def linear(x): @@ -227,7 +239,7 @@ def test_statuses(self): # The function error should get passed up n.run() self.assertFalse(n.ready) - # self.assertFalse(n.running) + self.assertFalse(n.running) self.assertTrue(n.failed) n.inputs.x = 1 @@ -236,14 +248,18 @@ def test_statuses(self): msg="Should not be ready while it has failed status" ) - n.run() + n.failed = False # Manually reset the failed status self.assertTrue( n.ready, - msg="A manual run() call bypasses checks, so readiness should reset" + 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="Re-running should reset failed status") + 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: @@ -283,12 +299,14 @@ def with_self(self, x: float) -> float: msg="Function functions should be able to modify attributes on the node object." ) - node.executor = Executor() - with self.assertRaises(NotImplementedError): - # Submitting node_functions that use self is still raising - # TypeError: cannot pickle '_thread.lock' object - # For now we just fail cleanly + node.executor = True + with self.assertRaises( + ValueError, + msg="We haven't implemented any way to update a function node's `self` when" + "it runs on an executor, so trying to do so should fail hard" + ): node.run() + node.executor = False def with_messed_self(x: float, self) -> float: return x + 0.1 @@ -377,7 +395,7 @@ def test_return_value(self): ) with self.subTest("Run on executor"): - node.executor = Executor() + node.executor = True return_on_explicit_run = node.run() self.assertIsInstance( @@ -392,6 +410,138 @@ def test_return_value(self): node.run() node.future.result() # Wait for the remote execution to finish + def test_copy_connections(self): + node = Function(plus_one) + + upstream = Function(plus_one) + to_copy = Function(plus_one, x=upstream.outputs.y) + downstream = Function(plus_one, x=to_copy.outputs.y) + upstream > to_copy > downstream + + wrong_io = Function( + returns_multiple, x=upstream.outputs.y, y=upstream.outputs.y + ) + downstream.inputs.x.connect(wrong_io.outputs.y) + + with self.subTest("Successful copy"): + node._copy_connections(to_copy) + self.assertIn(upstream.outputs.y, node.inputs.x.connections) + self.assertIn(upstream.signals.output.ran, node.signals.input.run) + self.assertIn(downstream.inputs.x, node.outputs.y.connections) + self.assertIn(downstream.signals.input.run, node.signals.output.ran) + node.disconnect() # Make sure you've got a clean slate + + def plus_one_hinted(x: int = 0) -> int: + y = x + 1 + return y + + hinted_node = Function(plus_one_hinted) + + with self.subTest("Ensure failed copies fail cleanly"): + with self.assertRaises(AttributeError, msg="Wrong labels"): + node._copy_connections(wrong_io) + self.assertFalse( + node.connected, + msg="The x-input connection should have been copied, but should be " + "removed when the copy fails." + ) + + with self.assertRaises( + ChannelConnectionError, + msg="An unhinted channel is not a valid connection for a hinted " + "channel, and should raise and exception" + ): + hinted_node._copy_connections(to_copy) + hinted_node.disconnect()# Make sure you've got a clean slate + node.disconnect() # Make sure you've got a clean slate + + with self.subTest("Ensure that failures can be continued past"): + node._copy_connections(wrong_io, fail_hard=False) + self.assertIn(upstream.outputs.y, node.inputs.x.connections) + self.assertIn(downstream.inputs.x, node.outputs.y.connections) + + hinted_node._copy_connections(to_copy, fail_hard=False) + self.assertFalse( + hinted_node.inputs.connected, + msg="Without hard failure the copy should be allowed to proceed, but " + "we don't actually expect any connections to get copied since the " + "only one available had type hint problems" + ) + self.assertTrue( + hinted_node.outputs.connected, + msg="Without hard failure the copy should be allowed to proceed, so " + "the output should connect fine since feeding hinted to un-hinted " + "is a-ok" + ) + + def test_copy_values(self): + @function_node() + def reference(x=0, y: int = 0, z: int | float = 0, omega=None, extra_here=None): + out = 42 + return out + + @function_node() + def all_floats(x=1.1, y=1.1, z=1.1, omega=NotData, extra_there=None) -> float: + out = 42.1 + return out + + # Instantiate the nodes and run them (so they have output data too) + ref = reference() + floats = all_floats() + ref() + floats() + + ref._copy_values(floats) + self.assertEqual( + ref.inputs.x.value, + 1.1, + msg="Untyped channels should copy freely" + ) + self.assertEqual( + ref.inputs.y.value, + 0, + msg="Typed channels should ignore values where the type check fails" + ) + self.assertEqual( + ref.inputs.z.value, + 1.1, + msg="Typed channels should copy values that conform to their hint" + ) + self.assertEqual( + ref.inputs.omega.value, + None, + msg="NotData should be ignored when copying" + ) + self.assertEqual( + ref.outputs.out.value, + 42.1, + msg="Output data should also get copied" + ) + # Note also that these nodes each have extra channels the other doesn't that + # are simply ignored + + @function_node() + def extra_channel(x=1, y=1, z=1, not_present=42): + out = 42 + return out + + extra = extra_channel() + extra() + + ref.inputs.x = 0 # Revert the value + with self.assertRaises( + TypeError, + msg="Type hint should prevent update when we fail hard" + ): + ref._copy_values(floats, fail_hard=True) + + ref._copy_values(extra) # No problem + with self.assertRaises( + AttributeError, + msg="Missing a channel that holds data is also grounds for failure" + ): + ref._copy_values(extra, fail_hard=True) + @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestSingleValue(unittest.TestCase): diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index c928d4be8..73bae0f59 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -1,6 +1,8 @@ +from concurrent.futures import Future from functools import partialmethod -import unittest from sys import version_info +from time import sleep +import unittest from pyiron_workflow.channels import NotData from pyiron_workflow.function import SingleValue @@ -237,6 +239,319 @@ 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) + downstream = SingleValue(add_one, x=macro.outputs.three__result) + macro > downstream + macro(one__x=0) + # Or once pull exists: macro.one__x = 0; 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) + macro(one__x=0) + 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(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) + macro > downstream # Later we can just pull() instead + + original_one = macro.one + macro.executor = True + + self.assertIs( + NotData, + macro.outputs.three__result.value, + msg="Sanity check that test is in right starting condition" + ) + + result = macro(one__x=0) + self.assertIsInstance( + result, + Future, + msg="Should be running as a parallel process" + ) + self.assertIs( + NotData, + downstream.outputs.result.value, + msg="Downstream events should not yet have triggered either, we should wait" + "for the callback when the result is ready" + ) + + returned_nodes = result.result() # Wait for the process to finish + self.assertIsNot( + original_one, + returned_nodes.one, + msg="Executing in a parallel process should be returning new instances" + ) + # self.assertIs( + # returned_nodes.one, + # macro.nodes.one, + # msg="Returned nodes should be taken as children" + # ) # You can't do this, result.result() is returning new instances each call + self.assertIs( + macro, + macro.nodes.one.parent, + msg="Returned nodes should get the macro as their parent" + # Once upon a time there was some evidence that this test was failing + # stochastically, but I just ran the whole test suite 6 times and this test + # 8 times and it always passed fine, so maybe the issue is resolved... + ) + self.assertIsNone( + original_one.parent, + msg="Original nodes should be orphaned" + # Note: At time of writing, this is accomplished in Node.__getstate__, + # which feels a bit dangerous... + ) + self.assertEqual( + 0 + 3, + macro.outputs.three__result.value, + msg="And of course we expect the calculation to actually run" + ) + self.assertIs( + downstream.inputs.x.connections[0], + macro.outputs.three__result, + msg="The macro should still be connected to " + ) + sleep(0.2) # Give a moment for the ran signal to emit and downstream to run + # I'm a bit surprised this sleep is necessary + self.assertEqual( + 0 + 3 + 1, + downstream.outputs.result.value, + msg="The finishing callback should also fire off the ran signal triggering" + "downstream execution" + ) + if __name__ == '__main__': unittest.main() diff --git a/tests/unit/test_status_management.py b/tests/unit/test_status_management.py new file mode 100644 index 000000000..e860cdfb3 --- /dev/null +++ b/tests/unit/test_status_management.py @@ -0,0 +1,47 @@ +from concurrent.futures import Future +from sys import version_info +from unittest import TestCase, skipUnless + +from pyiron_workflow.node import manage_status + + +class FauxNode: + def __init__(self): + self.running = False + self.failed = False + + @manage_status + def success(self, x): + return x / 2 + + @manage_status + def failure(self): + return 1 / 0 + + @manage_status + def future(self): + return Future() + + +@skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") +class TestStatusManagement(TestCase): + def setUp(self) -> None: + self.node = FauxNode() + + def test_success(self): + out = self.node.success(4) + self.assertFalse(self.node.running) + self.assertFalse(self.node.failed) + self.assertEqual(out, 2) + + def test_failure(self): + with self.assertRaises(ZeroDivisionError): + self.node.failure() + self.assertFalse(self.node.running) + self.assertTrue(self.node.failed) + + def test_future(self): + out = self.node.future() + self.assertTrue(self.node.running) + self.assertFalse(self.node.failed) + self.assertIsInstance(out, Future) diff --git a/tests/unit/test_workflow.py b/tests/unit/test_workflow.py index a169989b9..0c0e09ae8 100644 --- a/tests/unit/test_workflow.py +++ b/tests/unit/test_workflow.py @@ -1,6 +1,7 @@ -import unittest +from concurrent.futures import Future from sys import version_info from time import sleep +import unittest from bidict import ValueDuplicationError @@ -66,6 +67,26 @@ def test_node_addition(self): 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") @@ -93,10 +114,28 @@ def test_double_workfloage_and_node_removal(self): with self.assertRaises(ValueError): # Can't belong to two workflows at once wf2.add(node2) - wf1.remove(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) - self.assertFalse(node2.connected) + + 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") @@ -156,13 +195,50 @@ def test_no_parents(self): # Setting a non-None value to parent raises the type error from the setter wf2.parent = wf - def test_executor(self): + def test_with_executor(self): + wf = Workflow("wf") - with self.assertRaises(NotImplementedError): - # Submitting callables that use self is still raising - # TypeError: cannot pickle '_thread.lock' object - # For now we just fail cleanly - wf.executor = "literally anything other than None should raise the error" + wf.a = wf.create.SingleValue(plus_one) + wf.b = wf.create.SingleValue(plus_one, x=wf.a) + + original_a = wf.a + wf.executor = True + + self.assertIs( + NotData, + wf.outputs.b__y.value, + msg="Sanity check that test is in right starting condition" + ) + + result = wf(a__x=0) + self.assertIsInstance( + result, + Future, + msg="Should be running as a parallel process" + ) + + returned_nodes = result.result() # Wait for the process to finish + self.assertIsNot( + original_a, + returned_nodes.a, + msg="Executing in a parallel process should be returning new instances" + ) + self.assertIs( + wf, + wf.nodes.a.parent, + msg="Returned nodes should get the macro as their parent" + ) + self.assertIsNone( + original_a.parent, + msg="Original nodes should be orphaned" + # Note: At time of writing, this is accomplished in Node.__getstate__, + # which feels a bit dangerous... + ) + self.assertEqual( + 0 + 1 + 1, + wf.outputs.b__y.value, + msg="And of course we expect the calculation to actually run" + ) def test_parallel_execution(self): wf = Workflow("wf")