Skip to content

Conversation

liamhuber
Copy link
Member

@liamhuber liamhuber commented Dec 7, 2023

Allow operations directly on output channels (e.g. addition, item access, etc.) and resolve this by dynamically injecting a new node from the standard library to handle the operation.

E.g.

from pyiron_workflow import Workflow

wf = Workflow("output_manipulation")

        wf.a = Workflow.create.standard.Add(1, 2)
        wf.b = Workflow.create.standard.Add(3, 4)
        wf.c = Workflow.create.standard.UserInput(list(range(10)))
        wf.d = Workflow.create.standard.UserInput({"foo": 42})

        class Something:
            myattr = 1

        wf.e = Workflow.create.standard.UserInput(Something())

        wf.a.outputs.add < wf.b.outputs.add
        wf.c.outputs.user_input[:5]
        wf.d.outputs.user_input["foo"]
        wf.e.outputs.user_input.myattr
        wf()
        >>> {
        ...         'a__add_LessThan_b__add__lt': True,
        ...         'c__user_input_GetItem_slice(None, 5, None)__getitem': [0, 1, 2, 3, 4],
        ...         'd__user_input_GetItem_foo__getitem': 42,
        ...         'e__user_input_GetAttr_myattr__getattr': 1
        ... }

Closes #90

TODO:

  • Empower SingleValue to be used directly
  • Update docs/notebooks

EDIT: A more powerful example with SingleValue:

from pyiron_workflow import Workflow

Workflow.register("plotting", "pyiron_workflow.node_library.plotting")

n = 5

wf = Workflow("injection_demo")

wf.half_list = wf.create.standard.UserInput(list(range(n)))
wf.other_half = wf.create.standard.UserInput(list(range(n, 2*n)))

@Workflow.wrap_as.single_value_node("arange")
def Arange(length):
    import numpy as np
    return np.arange(length)

wf.arange = Arange(3 * n)

wf.scatter = wf.create.plotting.Scatter(
    wf.half_list + wf.other_half,  # Adds two lists -- i.e. appending
    (wf.arange**2)[:2*n] # Chains the power of a numpy array with a slice
)

wf()

The output being a nice y=x^2 matplotlib scatter plot.

Note that you can't replace [:2*n] with [:len(wf.half_list + wf.other_half)]. You can almost get there by chaining together actual nodes in the standard package, but we don't yet have good enough treatment of slices to get all the way there. E.g. you could pull out a single element (the (2n)th one) like this:

wf.full_list = wf.create.standard.Add(wf.half_list, wf.other_half)
wf.length = wf.create.standard.Length(wf.full_list)
wf.number = wf.create.standard.GetItem(wf.arange, wf.length)

(I guess adding a Slice node would do it, and maybe there is some tricks nesting this in GetItem with some type checks to get the syntactic sugar version working too...)

Nonetheless, this really gets a long way towards writing "organic" python with the nodes.

Copy link

github-actions bot commented Dec 7, 2023

Binder 👈 Launch a binder notebook on branch pyiron/pyiron_workflow/inject_output_nodes

Copy link

codacy-production bot commented Dec 7, 2023

Coverage summary from Codacy

See diff coverage on Codacy

Coverage variation Diff coverage
+0.99% (target: -1.00%)
Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (ca9bac4) 2159 1812 83.93%
Head commit (2c33750) 2440 (+281) 2072 (+260) 84.92% (+0.99%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#125) 0 0 ∅ (not applicable)

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

See your quality gate settings    Change summary preferences

You may notice some variations in coverage metrics with the latest Coverage engine update. For more details, visit the documentation

Copy link

github-actions bot commented Dec 7, 2023

Pull Request Test Coverage Report for Build 7138622706

  • 179 of 206 (86.89%) changed or added relevant lines in 4 files are covered.
  • 139 unchanged lines in 4 files lost coverage.
  • Overall coverage decreased (-1.8%) to 87.436%

Changes Missing Coverage Covered Lines Changed/Added Lines %
pyiron_workflow/node_library/standard.py 98 125 78.4%
Files with Coverage Reduction New Missed Lines %
function.py 23 84.11%
node.py 25 88.93%
node_library/standard.py 27 75.86%
channels.py 64 78.06%
Totals Coverage Status
Change from base Build 7134209297: -1.8%
Covered Lines: 4127
Relevant Lines: 4720

💛 - Coveralls

This is necessary for node injection when processing output: if you're injecting a node on top of an existing result you probably want the injection immediately available, but if you're injecting it at the end of something that hasn't run yet you don't want to see an error.
@liamhuber liamhuber added the format_black trigger the Black formatting bot label Dec 8, 2023
@liamhuber
Copy link
Member Author

Ok, so I can chain in slices now just fine. The only catch is that the unary operators like len, int, bool, etc. don't merely invoke their dunders and return it, they actually check to make sure they're getting back the right type. So they TypeError out when they get a node instead 😢

Thus you need to use the dunder directly, and I think I want to replace these guys with regular methods instead, e.g. .len() instead of .__len__

from pyiron_workflow import Workflow

Workflow.register("plotting", "pyiron_workflow.node_library.plotting")

n = 5

wf = Workflow("injection_demo")

wf.half_list = wf.create.standard.UserInput(list(range(n)))
wf.other_half = wf.create.standard.UserInput(list(range(n, 2*n)))

@Workflow.wrap_as.single_value_node("arange")
def Arange(length):
    import numpy as np
    return np.arange(length)

wf.arange = Arange(3 * n)

wf.full_list = wf.half_list + wf.other_half  # Adds two lists -- i.e. appending
wf.scatter = wf.create.plotting.Scatter(
    wf.full_list,  
    (wf.arange**2)[:wf.full_list.__len__()] # Chains the power of a numpy array with a slice
)

wf()

So it doesn't execute prematurely with all those defaults and crash hard
Some operators like `len(obj)` get really pissy if `obj.__len__()` doesn't return the type they're expecting. Since we want to be returning, e.g. here, channels instead of ints, we'd better not mess with these operators. I'm following the practice of using their dunder names as the basis for a method.
The builtin functions do a stupid type check
Copy link

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@liamhuber liamhuber added format_black trigger the Black formatting bot and removed format_black trigger the Black formatting bot labels Dec 9, 2023
@liamhuber liamhuber merged commit e67b986 into main Dec 9, 2023
@liamhuber liamhuber deleted the inject_output_nodes branch December 9, 2023 22:53
liamhuber pushed a commit that referenced this pull request Aug 14, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
format_black trigger the Black formatting bot
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow output channels to be manipulated
2 participants