From a2693b7ebad3c6ed1858c8814cb4cdaa616cfae4 Mon Sep 17 00:00:00 2001
From: Jean-Baptiste Keck <Jean-Baptiste.Keck@imag.fr>
Date: Sun, 3 Feb 2019 19:36:59 +0100
Subject: [PATCH] working python example

---
 examples/multiresolution/scalar_advection.py  |  37 +++--
 .../host/python/operator/spatial_filtering.py |  27 ++++
 hysop/core/graph/computational_node.py        |   2 +-
 hysop/core/graph/computational_operator.py    |  19 ++-
 hysop/core/graph/graph_builder.py             |  32 +++--
 hysop/numerics/remesh/kernel_generator.py     |  24 +++-
 hysop/numerics/remesh/remesh.py               |   2 +-
 hysop/operator/base/spatial_filtering.py      | 129 +++++++++++++++++-
 hysop/operator/spatial_filtering.py           |  22 +--
 9 files changed, 238 insertions(+), 56 deletions(-)
 create mode 100644 hysop/backend/host/python/operator/spatial_filtering.py

diff --git a/examples/multiresolution/scalar_advection.py b/examples/multiresolution/scalar_advection.py
index db17fa924..7fc6e61c8 100644
--- a/examples/multiresolution/scalar_advection.py
+++ b/examples/multiresolution/scalar_advection.py
@@ -8,7 +8,7 @@ def compute(args):
     from hysop.constants import Implementation
     from hysop.operators import Advection, DirectionalAdvection, StrangSplitting, \
                                 LowpassFilter, HDF_Writer
-    from hysop.methods import Remesh, TimeIntegrator, ComputeGranularity, \
+    from hysop.methods import Remesh, TimeIntegrator, \
                               Interpolation
 
     ## Function to compute initial velocity values
@@ -22,19 +22,21 @@ def compute(args):
             data[0][...] = args.velocity[::-1][i]
 
     ## Function to compute initial scalar values
-    def init_scalar(data, coords):
-        data[0][...] = 1.0
-        for x in coords[0]:
-            data[0][...] *= np.cos(x)
+    def init_scalar(data, coords, component=None):
+        if (component in (None,0)):
+            data[0][...] = 1.0
+            for x in coords[0]:
+                data[0][...] *= np.cos(x)
 
-        data[1][...] = 1.0
-        for x in coords[0]:
-            data[1][...] *= np.sin(x)
+        if (component in (None,1)):
+            data[1][...] = 1.0
+            for x in coords[0]:
+                data[1][...] *= np.sin(x)
 
     # Define domain
     dim  = args.ndim
     npts  = args.npts
-    snpts = tuple(4*(x-1)+1 for x in args.npts)
+    snpts = tuple(4*x for x in args.npts)
     box  = Box(origin=args.box_origin, length=args.box_length, dim=dim)
     
     # Get default MPI Parameters from domain (even for serial jobs)
@@ -49,7 +51,6 @@ def compute(args):
     # Setup operator method dictionnary
     # Advection-Remesh operator discretization parameters
     method = { 
-               ComputeGranularity:  args.compute_granularity,
                TimeIntegrator:      args.time_integrator,
                Remesh:              args.remesh_kernel,
                Interpolation:       args.interpolation
@@ -113,21 +114,27 @@ def compute(args):
 
         # Finally insert our splitted advection into the problem
         problem.insert(splitting)
+
+    #> Lowpass filters
+    lpfilter = LowpassFilter(input_variables={scalar: snpts},
+                             output_variables={scalar: npts},
+                             implementation=impl,
+                             method={Remesh: Remesh.L2_1})
     
     #> Operators to dump all fields
     io_params = IOParams(filename='fine', frequency=args.dump_freq)
-    df0 = HDF_Writer(name='S',
+    df0 = HDF_Writer(name='S_fin',
                              io_params=io_params,
                              variables={scalar: snpts},
                              **extra_op_kwds)
     io_params = IOParams(filename='coarse', frequency=args.dump_freq)
-    df1 = HDF_Writer(name='V',
+    df1 = HDF_Writer(name='S_coarse',
                          io_params=io_params,
-                         variables={velo: npts},
+                         variables={scalar: npts},
                          **extra_op_kwds)
     
     # Add a writer of input field at given frequency.
-    problem.insert(df0, df1)
+    problem.insert(lpfilter, df0, df1)
     problem.build(args)
 
     # If a visu_rank was provided, and show_graph was set,
@@ -200,7 +207,7 @@ if __name__=='__main__':
     parser = ScalarAdvectionArgParser()
 
     parser.set_defaults(box_origin=(0.0,), box_length=(2*np.pi,), 
-                       tstart=0.0, tend=2*np.pi, npts=(16,),
+                       tstart=0.0, tend=2*np.pi, npts=(32,),
                        dump_freq=1, cfl=0.5, velocity=(1.0,), 
                        ndim=3, compute_precision='fp64')
 
diff --git a/hysop/backend/host/python/operator/spatial_filtering.py b/hysop/backend/host/python/operator/spatial_filtering.py
new file mode 100644
index 000000000..13db58a20
--- /dev/null
+++ b/hysop/backend/host/python/operator/spatial_filtering.py
@@ -0,0 +1,27 @@
+
+from hysop.tools.types import check_instance, first_not_None
+from hysop.tools.decorators import debug
+from hysop.backend.host.host_operator import HostOperator
+from hysop.core.graph.graph import op_apply
+from hysop.fields.continuous_field import Field
+from hysop.parameters.parameter import Parameter
+from hysop.topology.cartesian_descriptor import CartesianTopologyDescriptors
+from hysop.operator.base.spatial_filtering import LowpassFilterBase
+
+class PythonLowpassFilter(LowpassFilterBase, HostOperator):
+    """
+    Python implementation for lowpass spatial filtering: small grid -> coarse grid
+    """
+
+    @op_apply
+    def apply(self, **kwds):
+        """Apply analytic formula."""
+        super(PythonLowpassFilter, self).apply(**kwds)
+        fin, fout = self.fin, self.fout
+        iratio = self.iratio
+        
+        fout[...] = 0
+        for (idx, Wi) in self.nz_weights.iteritems():
+            slc = tuple(slice(i, i+r*s, r) for (i,s,r) in zip(idx, fout.shape, iratio))
+            fout[...] += Wi*fin[slc]
+        self.dFout.exchange_ghosts()
diff --git a/hysop/core/graph/computational_node.py b/hysop/core/graph/computational_node.py
index 833cbe66e..adffa2b54 100644
--- a/hysop/core/graph/computational_node.py
+++ b/hysop/core/graph/computational_node.py
@@ -277,7 +277,7 @@ class ComputationalGraphNode(OperatorBase):
     is_domainless = property(_get_is_domainless)
 
     @classmethod
-    def expand_tensor_fields(fields):
+    def expand_tensor_fields(cls, fields):
         scalar_fields = ()
         tensor_fields = ()
         for field in fields:
diff --git a/hysop/core/graph/computational_operator.py b/hysop/core/graph/computational_operator.py
index 75dc455c2..f9953e7fb 100644
--- a/hysop/core/graph/computational_operator.py
+++ b/hysop/core/graph/computational_operator.py
@@ -58,6 +58,9 @@ class ComputationalGraphOperator(ComputationalGraphNode):
     *override* the following method:
         get_operator_requirements()
 
+    To declare that an operator does not invalidate an input_field when the
+    same field is written, *override* the get_preserved_input_fields method.
+
     An operator has to *extend* the following abstract method:
         apply(simulation, **kwds)
 
@@ -84,7 +87,7 @@ class ComputationalGraphOperator(ComputationalGraphNode):
             -> topologies/topology descriptors are checked
         initialize()
             pre_initialize()
-            handle method() -> self.method is set
+            handle method()             -> self.method is set
             get_field_requirements()    -> self.input_field_requirements it set
             get_operator_requirements() -> self.operator_requirements is set
             check_requirements_compatibility() -> updates
@@ -98,7 +101,7 @@ class ComputationalGraphOperator(ComputationalGraphNode):
         setup() (requires self.discretized to be set)
             self.ready is set
         apply() (requires self.ready to be set)
-            abtract method to be implemented
+            abtract method to be implemented by each operator
         finalize() (requires self.ready to be set)
             sets self.ready to False
 
@@ -228,6 +231,14 @@ class ComputationalGraphOperator(ComputationalGraphNode):
          requirements.update_outputs(output_field_requirements)
          return requirements
 
+    def get_preserved_input_fields(self):
+        """
+        Declare fields that should not be invalidated as a set.
+        You can only declare scalar fields that are input and output fields at the same time.
+        For example, a redistribute operation does not invalidate input fields.
+        """
+        return set()
+
     @debug
     def get_node_requirements(self):
         """
@@ -427,6 +438,10 @@ class ComputationalGraphOperator(ComputationalGraphNode):
 
         self.discrete_fields will be a tuple containing all input and
         output discrete fields.
+        
+        Discrete tensor fields are built back from discretized scalar fields
+        and are accessible from self.input_tensor_fields, self.output_tensor_fields
+        and self.discrete_tensor_fields like their scalar counterpart.
         """
         if self.discretized:
             return
diff --git a/hysop/core/graph/graph_builder.py b/hysop/core/graph/graph_builder.py
index 15365a3f6..d9930489b 100644
--- a/hysop/core/graph/graph_builder.py
+++ b/hysop/core/graph/graph_builder.py
@@ -369,8 +369,9 @@ class GraphBuilder(object):
                         istates = None if (current_level!=0) else input_states
                         cstate = self.topology_states.setdefault(ofield,
                                                 self.new_topology_state(ofield))
+                        invalidate_field = (ofield not in op.get_preserved_input_fields())
                         dstate = cstate.handle_output(opnode, otopo, ofreqs,
-                                    op, istates,
+                                    op, istates, invalidate_field,
                                     graph, edge_properties, vertex_properties)
                         output_fields[ofield] = otopo
                         output_states[ofield] = dstate
@@ -711,6 +712,7 @@ class GraphBuilder(object):
 
             # dictionnary (topology -> list of node) that are up to date (lastly written)
             # multiple fields can be up to date at the same time after a redistribute operator
+            # or after an operator that implements the get_preserved_input_fields method.
             self.write_nodes = {}
 
             # dictionnary (topology -> list of nodes) that are currently reading field:topo
@@ -1096,7 +1098,7 @@ class GraphBuilder(object):
 
 
         def handle_output(self, opnode, output_topo, oreqs,
-                operator, input_topology_states,
+                operator, input_topology_states, invalidate_field,
                 graph, edge_properties, vertex_properties):
 
             ofield = self.field
@@ -1112,19 +1114,19 @@ class GraphBuilder(object):
                 self.add_edge(graph, edge_properties,
                         write_nodes[output_topo], opnode,
                         ofield, output_topo)
-
-            # add dependency to all operators that reads this field
-            # to prevent concurent read-writes.
-            if output_topo in read_nodes:
-                for ro_node in read_nodes[output_topo]:
-                    self.add_edge(graph, edge_properties,
-                            ro_node, opnode,
-                            ofield, output_topo)
-
-            # remove read/write dependencies and states
-            # read_nodes.clear()
-            write_nodes.clear()
-            dtopology_states.clear()
+            
+            if invalidate_field:
+                # add dependency to all operators that reads this field
+                # to prevent concurent read-writes.
+                if output_topo in read_nodes:
+                    for ro_node in read_nodes[output_topo]:
+                        self.add_edge(graph, edge_properties,
+                                ro_node, opnode,
+                                ofield, output_topo)
+
+                # remove read/write dependencies and states
+                write_nodes.clear()
+                dtopology_states.clear()
 
             # add the operator node as the one that lastly wrote this field.
             # no other operators can be reading as this topology just been written.
diff --git a/hysop/numerics/remesh/kernel_generator.py b/hysop/numerics/remesh/kernel_generator.py
index c7d1802de..364b83a32 100644
--- a/hysop/numerics/remesh/kernel_generator.py
+++ b/hysop/numerics/remesh/kernel_generator.py
@@ -150,16 +150,24 @@ class Kernel(object):
 
         if verbose:
             print '  => Generating lambdas'
-
+        
+        imin=-Ms
+        imax=+Ms
         if split_polys:
-            gamma_l = sp.interpolate.PPoly(Cl,X,extrapolate=True)
-            gamma_r = sp.interpolate.PPoly(Cr,X,extrapolate=True)
+            gamma_l = sp.interpolate.PPoly(Cl,X,extrapolate=False)
+            gamma_r = sp.interpolate.PPoly(Cr,X,extrapolate=False)
             def gamma(x):
                  i = np.floor(x)
                  z = x-i
-                 return (z>=0.5)*gamma_r(i+1-z) + (z<0.5)*gamma_l(x)
+                 res = (z>=0.5)*gamma_r(i+1-z) + (z<0.5)*gamma_l(x)
+                 res[np.isnan(res)] = 0.0
+                 return res
         else:
-            gamma = sp.interpolate.PPoly(C,X,extrapolate=True)
+            gamma_ = sp.interpolate.PPoly(C,X,extrapolate=False)
+            def gamma(x):
+                 res = gamma_(x)
+                 res[np.isnan(res)] = 0.0
+                 return res
 
         self.I = X
         self.gamma = gamma
@@ -179,7 +187,9 @@ class Kernel(object):
 
     def __call__(self,x):
         Ms = self.Ms
-        return self.gamma(x)
+        res = self.gamma(x)
+        return res
+
 
 
 class SymmetricKernelGenerator(object):
@@ -433,7 +443,7 @@ if __name__=='__main__':
         fig = plt.figure()
         plt.xlabel(r'$x$')
         plt.ylabel(r'$\Lambda_{'+'{},{}'.format(p,'r')+'}$')
-        X = np.linspace(-k0.Ms,+k0.Ms,1000)
+        X = np.linspace(-k0.Ms-1,+k0.Ms+1,1000)
         s = plt.subplot(1,1,1)
         for i,k in enumerate(kernels):
             s.plot(X,k(X),label=r'$\Lambda_{'+'{},{}'.format(p,k.r)+'}$')
diff --git a/hysop/numerics/remesh/remesh.py b/hysop/numerics/remesh/remesh.py
index ad7da6671..bf2c38bd1 100644
--- a/hysop/numerics/remesh/remesh.py
+++ b/hysop/numerics/remesh/remesh.py
@@ -73,7 +73,7 @@ if __name__=='__main__':
         fig = plt.figure()
         plt.xlabel(r'$x$')
         plt.ylabel(r'$\Lambda_{'+'{},{}'.format(p,'r')+'}$')
-        X = np.linspace(-k0.Ms,+k0.Ms,1000)
+        X = np.linspace(-k0.Ms-1,+k0.Ms+1,1000)
         s = plt.subplot(1,1,1)
         for i,k in enumerate(kernels):
             s.plot(X,k(X),label=r'$\Lambda_{'+'{},{}'.format(p,k.r)+'}$')
diff --git a/hysop/operator/base/spatial_filtering.py b/hysop/operator/base/spatial_filtering.py
index f160158e5..859b68a52 100644
--- a/hysop/operator/base/spatial_filtering.py
+++ b/hysop/operator/base/spatial_filtering.py
@@ -3,13 +3,16 @@
 LowpassFilter operator generator.
 """
 from hysop.constants import Implementation
-from hysop.tools.types import check_instance, to_list, first_not_None
+from hysop.tools.types import check_instance, to_list, first_not_None, InstanceOf
+from hysop.tools.numpywrappers import npw
 from hysop.tools.decorators import debug
-from hysop.fields.continuous_field import Field
+from hysop.fields.continuous_field import Field, ScalarField
 from hysop.topology.cartesian_descriptor import CartesianTopologyDescriptors
 from hysop.parameters.scalar_parameter import ScalarParameter
 from hysop.core.graph.node_generator import ComputationalGraphNodeGenerator
-from hysop.core.graph.computational_node_fronted import ComputationalGraphNodeFrontend
+from hysop.core.graph.computational_node_frontend import ComputationalGraphNodeFrontend
+from hysop.methods import Remesh
+from hysop.numerics.remesh.remesh import RemeshKernel
 
 
 class LowpassFilterBase(object):
@@ -17,6 +20,15 @@ class LowpassFilterBase(object):
     Base implementation for lowpass spatial filtering: small grid -> coarse grid
     """
     
+    __default_method = {
+            Remesh: Remesh.L2_1,
+        }
+
+    __available_methods = {
+        Remesh: (InstanceOf(Remesh), InstanceOf(RemeshKernel)),
+    }
+
+    
     def __init__(self, input_field, output_field,
                        input_topo,  output_topo,
                        **kwds):
@@ -33,7 +45,108 @@ class LowpassFilterBase(object):
 
         self.Fin  = input_field
         self.Fout = output_field
+    
+    @classmethod
+    def default_method(cls):
+        dm = super(LowpassFilterBase, cls).default_method()
+        dm.update(cls.__default_method)
+        return dm
+
+    @classmethod
+    def available_methods(cls):
+        am = super(LowpassFilterBase, cls).available_methods()
+        am.update(cls.__available_methods)
+        return am
+
+    @classmethod
+    def supports_multiple_field_topologies(cls):
+        return True
+
+    @classmethod
+    def supports_mpi(cls):
+        return True
+    
+    def get_preserved_input_fields(self):
+        return {self.Fout}
+    
+    @debug
+    def handle_method(self,method):
+        super(LowpassFilterBase, self).handle_method(method)
+        remesh_kernel = method.pop(Remesh)
+        if isinstance(remesh_kernel, Remesh):
+            remesh_kernel = RemeshKernel.from_enum(remesh_kernel)
+        self.remesh_kernel   = remesh_kernel
+
+    @classmethod
+    def remesh_ghosts(cls, remesh_kernel):
+        """Return the minimum number of ghosts for remeshed scalars."""
+        assert remesh_kernel.n >=1 , 'Bad remeshing kernel.'
+        if remesh_kernel.n >1:
+            assert remesh_kernel.n % 2 == 0, 'Odd remeshing kernel moments.'
+        min_ghosts = int(remesh_kernel.n//2)+1
+        return min_ghosts
+    
+    @debug
+    def get_field_requirements(self):
+        requirements = super(LowpassFilterBase, self).get_field_requirements()
+        dim = self.Fin.dim
+
+        Fin_topo, Fin_requirements = requirements.get_input_requirement(self.Fin)
+        try:
+            Fin_dx = Fin_topo.space_step
+        except AttributeError:
+            Fin_dx = Fin_topo.mesh.space_step
+        
+        Fout_topo, Fout_requirements = requirements.get_output_requirement(self.Fout)
+        try:
+            Fout_dx = Fout_topo.space_step
+        except AttributeError:
+            Fout_dx = Fout_topo.mesh.space_step
+
+        ratio = Fout_dx / Fin_dx
+        msg='Destination grid is finer than source grid: {}'.format(ratio)
+        assert (ratio>=1.0).all(), msg
+        iratio = ratio.astype(npw.int32)
+        msg='Grid ratio is not an integer on at least one axis: {}'.format(ratio)
+        assert (ratio==iratio).all(), msg
+        
+        remesh_ghosts    = self.remesh_ghosts(self.remesh_kernel)
+        fine_grid_ghosts = iratio*remesh_ghosts - 1
+        Fin_requirements.min_ghosts = fine_grid_ghosts
+        
+        self.remesh_ghosts    = remesh_ghosts
+        self.fine_grid_ghosts = fine_grid_ghosts
+
+        return requirements
+
+    def compute_weights(self, iratio, product=True):
+        assert (iratio>=1).all()
+        remesh_kernel = self.remesh_kernel
+        p = remesh_kernel.n//2 + 1
+        shape = 2*p*iratio-1
+        weights = npw.zeros(dtype=npw.float64, shape=shape)
+        nz_weights = {}
+        for idx in npw.ndindex(*shape):
+            X = (npw.asarray(idx, dtype=npw.float64)+1) / iratio - p
+            if product:
+                W = npw.prod(remesh_kernel(X))
+            else:
+                # this does not seem to work
+                R = npw.sqrt(npw.dot(X,X))
+                W = remesh_kernel(R)
+            weights[idx] = W
+            if (W!=0):
+                nz_weights[idx] = W
+        Ws = weights.sum()
+        weights = weights / Ws 
+        nz_weights = {k: v/Ws for (k,v) in nz_weights.iteritems()}
+
+        assert abs(weights.sum() - 1.0) < 1e-8, weights.sum()
+        assert abs(npw.sum(nz_weights.values()) - 1.0) < 1e-8, npw.sum(nz_weights.values()) 
 
+        self.iratio     = iratio
+        self.weights    = weights
+        self.nz_weights = nz_weights
 
     @debug
     def discretize(self):
@@ -43,10 +156,16 @@ class LowpassFilterBase(object):
         dFin  = self.get_input_discrete_field(self.Fin)
         dFout = self.get_output_discrete_field(self.Fout)
 
-        import sys
-        sys.exit(1)
+        iratio =  dFin.compute_resolution / dFout.compute_resolution
+        self.compute_weights(iratio)
+        
+        remesh_ghosts    = self.remesh_ghosts
+        fine_grid_ghosts = iratio*remesh_ghosts - 1
+        fin  = dFin.sdata[dFin.local_slices(ghosts=fine_grid_ghosts)]
+        fout = dFout.compute_buffers[0]
 
         self.dFin, self.dFout = dFin, dFout
+        self.fin, self.fout = fin, fout
 
 
 
diff --git a/hysop/operator/spatial_filtering.py b/hysop/operator/spatial_filtering.py
index 7572dc482..feccbf392 100644
--- a/hysop/operator/spatial_filtering.py
+++ b/hysop/operator/spatial_filtering.py
@@ -5,9 +5,10 @@ LowpassFilter operator generator.
 from hysop.constants import Implementation
 from hysop.tools.types import check_instance, to_list, first_not_None
 from hysop.tools.decorators import debug
-from hysop.fields.continuous_field import Field
+from hysop.fields.continuous_field import Field, ScalarField
 from hysop.topology.cartesian_descriptor import CartesianTopologyDescriptors
 from hysop.parameters.scalar_parameter import ScalarParameter
+from hysop.core.graph.computational_node import ComputationalGraphNode
 from hysop.core.graph.node_generator import ComputationalGraphNodeGenerator
 from hysop.core.graph.computational_node_frontend import ComputationalGraphNodeFrontend
 
@@ -66,7 +67,7 @@ class LowpassFilterFrontend(ComputationalGraphNodeFrontend):
         check_instance(output_field, ScalarField)
         check_instance(input_topo,  CartesianTopologyDescriptors)
         check_instance(output_topo, CartesianTopologyDescriptors)
-        check_instance(base_kwds, dict, keys=str)
+        check_instance(base_kwds, dict, keys=str, allow_none=True)
         assert (input_topo != output_topo), "Same topology for input and output."
 
         super(LowpassFilterFrontend, self).__init__(input_field=input_field, input_topo=input_topo,
@@ -80,7 +81,7 @@ class LowpassFilter(ComputationalGraphNodeGenerator):
     Graphnode generator to lowpass filter multiple fields at once.
     """
     @debug
-    def __init__(self, input_variables, output_variables=None,
+    def __init__(self, input_variables, output_variables,
                  implementation=None,
                  base_kwds=None,
                  **kwds):
@@ -104,11 +105,9 @@ class LowpassFilter(ComputationalGraphNodeGenerator):
         kwds:
             Extra parameters passed to generated operators.
         """
-        input_fields = list(self.expand_tensor_fields(input_variables.keys())[0])
+        input_fields = list(ComputationalGraphNode.expand_tensor_fields(input_variables.keys())[0])
         assert len(set(input_fields)) == len(input_fields)
-        if (output_variables is None):
-            output_variables = input_variables
-        output_fields = list(self.expand_tensor_fields(output_variables.keys())[0])
+        output_fields = list(ComputationalGraphNode.expand_tensor_fields(output_variables.keys())[0])
         assert len(input_fields) == len(output_fields)
         for i, field in enumerate(output_fields):
             if (field is None):
@@ -124,7 +123,10 @@ class LowpassFilter(ComputationalGraphNodeGenerator):
         check_instance(output_variables, dict, keys=Field, values=CartesianTopologyDescriptors)
         check_instance(base_kwds, dict, keys=str)
 
-        super(LowpassFilter, self).__init__(**base_kwds)
+        super(LowpassFilter, self).__init__(
+                candidate_input_tensors=None,
+                candidate_output_tensors=None,
+                **base_kwds)
             
         self._input_fields     = input_fields
         self._output_fields    = output_fields
@@ -137,8 +139,8 @@ class LowpassFilter(ComputationalGraphNodeGenerator):
     def _generate(self):
         nodes = []
         for (ifield, ofield) in zip(self._input_fields, self._output_fields):
-            stopo = self.get_topo_descriptor(self._input_variables, ifield)
-            ttopo = self.get_topo_descriptor(self._output_variables, ofield)
+            stopo = ComputationalGraphNode.get_topo_descriptor(self._input_variables, ifield)
+            ttopo = ComputationalGraphNode.get_topo_descriptor(self._output_variables, ofield)
             impl  = self._impl
             kwds  = self._kwds.copy()
             
-- 
GitLab