diff --git a/pyproject.toml b/pyproject.toml index 63ab43b7..af310f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] requires-python = ">=3.8" dependencies = [ - "pathsim>=0.7.0", + "pathsim>=0.8.0", "matplotlib>=3.7.0", "numpy>=1.24.0", "plotly>=6.0", diff --git a/src/components/EventsTab.jsx b/src/components/EventsTab.jsx index 247a66c8..64054610 100644 --- a/src/components/EventsTab.jsx +++ b/src/components/EventsTab.jsx @@ -9,6 +9,11 @@ const eventDefaults = { func_act: '', tolerance: '1e-16' }, + 'ScheduleList': { + times_evt: '', + func_act: '', + tolerance: '1e-16' + }, 'ZeroCrossingDown': { func_evt: '', func_act: '', @@ -47,7 +52,8 @@ const EventsTab = ({ events, setEvents }) => { const eventTypes = [ 'Condition', - 'Schedule', + 'Schedule', + 'ScheduleList', 'ZeroCrossing', 'ZeroCrossingUp', 'ZeroCrossingDown' @@ -75,13 +81,13 @@ const EventsTab = ({ events, setEvents }) => { // Validate required fields based on event type // For Schedule, func_act is required - if (currentEvent.type === 'Schedule' && !currentEvent.func_act) { + if (['Schedule', 'ScheduleList'].includes(currentEvent.type) && !currentEvent.func_act) { alert('func_act is required for Schedule events'); return; } // For other event types, both func_evt and func_act are typically required - if (currentEvent.type !== 'Schedule' && (!currentEvent.func_evt || !currentEvent.func_act)) { + if (!['Schedule', 'ScheduleList'].includes(currentEvent.type) && (!currentEvent.func_evt || !currentEvent.func_act)) { alert('Both func_evt and func_act are required for this event type'); return; } diff --git a/src/nodeConfig.js b/src/nodeConfig.js index d2017be3..db823b83 100644 --- a/src/nodeConfig.js +++ b/src/nodeConfig.js @@ -7,7 +7,7 @@ import IntegratorNode from './components/nodes/IntegratorNode'; import AdderNode from './components/nodes/AdderNode'; import ScopeNode from './components/nodes/ScopeNode'; import StepSourceNode from './components/nodes/StepSourceNode'; -import {createFunctionNode} from './components/nodes/FunctionNode'; +import { createFunctionNode } from './components/nodes/FunctionNode'; import DefaultNode from './components/nodes/DefaultNode'; import MultiplierNode from './components/nodes/MultiplierNode'; import { Splitter2Node, Splitter3Node } from './components/nodes/Splitters'; @@ -60,6 +60,32 @@ export const nodeTypes = { fir: DefaultNode }; +export const nodeMathTypes = { + sin: DefaultNode, + cos: DefaultNode, + sqrt: DefaultNode, + abs: DefaultNode, + pow: DefaultNode, + exp: DefaultNode, + log: DefaultNode, + log10: DefaultNode, + tan: DefaultNode, + sinh: DefaultNode, + cosh: DefaultNode, + tanh: DefaultNode, + atan: DefaultNode, + norm: DefaultNode, + mod: DefaultNode, + clip: DefaultNode, +} + +// add nodeMathTypes to nodeTypes +Object.keys(nodeMathTypes).forEach(type => { + if (!nodeTypes[type]) { + nodeTypes[type] = nodeMathTypes[type]; + } +}); + // Node categories for better organization export const nodeCategories = { 'Sources': { @@ -71,7 +97,7 @@ export const nodeCategories = { description: 'Signal processing and transformation nodes' }, 'Math': { - nodes: ['adder', 'multiplier', 'splitter2', 'splitter3'], + nodes: ['adder', 'multiplier', 'splitter2', 'splitter3'].concat(Object.keys(nodeMathTypes)), description: 'Mathematical operation nodes' }, 'Control': { @@ -124,8 +150,24 @@ export const getNodeDisplayName = (nodeType) => { 'scope': 'Scope', 'spectrum': 'Spectrum', 'differentiator': 'Differentiator', + 'sin': 'Sine', + 'cos': 'Cosine', + 'sqrt': 'Square Root', + 'abs': 'Absolute', + 'pow': 'Power', + 'exp': 'Exponential', + 'log': 'Logarithm', + 'log10': 'Logarithm (Base 10)', + 'tan': 'Tangent', + 'sinh': 'Hyperbolic Sine', + 'cosh': 'Hyperbolic Cosine', + 'tanh': 'Hyperbolic Tangent', + 'atan': 'Inverse Tangent', + 'norm': 'Normalization', + 'mod': 'Modulo', + 'clip': 'Clipping', }; - + return displayNames[nodeType] || nodeType.charAt(0).toUpperCase() + nodeType.slice(1); }; diff --git a/src/python/convert_to_python.py b/src/python/convert_to_python.py index fda816bc..9dc15c66 100644 --- a/src/python/convert_to_python.py +++ b/src/python/convert_to_python.py @@ -110,8 +110,14 @@ def make_edge_data(data: dict) -> list[dict]: edge["source_var_name"] = node["var_name"] edge["target_var_name"] = target_node["var_name"] - edge["source_port"] = f"[{output_index}]" - edge["target_port"] = f"[{input_index}]" + if isinstance(output_index, str): + edge["source_port"] = f"['{output_index}']" + else: + edge["source_port"] = f"[{output_index}]" + if isinstance(input_index, str): + edge["target_port"] = f"['{input_index}']" + else: + edge["target_port"] = f"[{input_index}]" block_to_input_index[target_block] += 1 return data["edges"] diff --git a/src/python/custom_pathsim_blocks.py b/src/python/custom_pathsim_blocks.py index c2b172e7..291b4dac 100644 --- a/src/python/custom_pathsim_blocks.py +++ b/src/python/custom_pathsim_blocks.py @@ -1,11 +1,12 @@ from pathsim.blocks import Block, ODE import pathsim.blocks +import pathsim.events from pathsim import Subsystem, Interface, Connection import numpy as np class Process(ODE): - name_to_output_port = {"inv": 0, "mass_flow_rate": 1} + _port_map_out = {"inv": 0, "mass_flow_rate": 1} def __init__(self, residence_time=0, initial_value=0, source_term=0): alpha = -1 / residence_time if residence_time != 0 else 0 @@ -25,8 +26,8 @@ def update(self, t): mass_rate = x / self.residence_time # first output is the inv, second is the mass_flow_rate outputs = [None, None] - outputs[self.name_to_output_port["inv"]] = x - outputs[self.name_to_output_port["mass_flow_rate"]] = mass_rate + outputs[self._port_map_out["inv"]] = x + outputs[self._port_map_out["mass_flow_rate"]] = mass_rate # update the outputs self.outputs.update_from_array(outputs) @@ -47,7 +48,7 @@ def update(self, t): class Splitter2(Splitter): - name_to_output_port = {"source1": 0, "source2": 1} + _port_map_out = {"source1": 0, "source2": 1} def __init__(self, f1=0.5, f2=0.5): """ @@ -57,7 +58,7 @@ def __init__(self, f1=0.5, f2=0.5): class Splitter3(Splitter): - name_to_output_port = {"source1": 0, "source2": 1, "source3": 2} + _port_map_out = {"source1": 0, "source2": 1, "source3": 2} def __init__(self, f1=1 / 3, f2=1 / 3, f3=1 / 3): """ @@ -78,7 +79,7 @@ def __init__(self, initial_value=0.0, reset_times=None): super().__init__(initial_value=initial_value) self.reset_times = reset_times - def create_reset_events(self): + def create_reset_events(self) -> list[pathsim.events.ScheduleList]: """Create reset events for the integrator based on the reset times. Raises: @@ -98,10 +99,15 @@ def create_reset_events(self): else: raise ValueError("reset_times must be a single value or a list of times") - return [ - pathsim.blocks.Schedule(t_start=t, t_end=t, func_act=self.reset) - for t in reset_times - ] + def func_act(_): + self.reset() + + # can be simplified after https://github.com/milanofthe/pathsim/pull/66 + event = pathsim.events.ScheduleList(times_evt=reset_times, func_act=func_act) + event.func_act = func_act + event.t_start = 0 + event.t_end = None + return [event] # BUBBLER SYSTEM @@ -115,17 +121,17 @@ class Bubbler(Subsystem): n_soluble_vials: float n_insoluble_vials: float - name_to_input_port = { - "sample_in_soluble": 0, - "sample_in_insoluble": 1, - } - name_to_output_port = { + _port_map_out = { "vial1": 0, "vial2": 1, "vial3": 2, "vial4": 3, "sample_out": 4, } + _port_map_in = { + "sample_in_soluble": 0, + "sample_in_insoluble": 1, + } def __init__( self, @@ -163,7 +169,11 @@ def __init__( add1 = pathsim.blocks.Adder() add2 = pathsim.blocks.Adder() + # can be simplified when https://github.com/milanofthe/pathsim/pull/65 is merged interface = Interface() + interface._port_map_in = self._port_map_out + interface._port_map_out = self._port_map_in + interface.__init__() # reinitialize to rebuild registers self.vials = [vial_1, vial_2, vial_3, vial_4] @@ -182,46 +192,41 @@ def __init__( interface, ] connections = [ - Connection( - interface[self.name_to_input_port["sample_in_soluble"]], col_eff1 - ), + Connection(interface["sample_in_soluble"], col_eff1), Connection(col_eff1[0], vial_1), Connection(col_eff1[1], col_eff2), Connection(col_eff2[0], vial_2), Connection(col_eff2[1], conversion_eff), Connection(conversion_eff[0], add1[0]), Connection(conversion_eff[1], add2[0]), - Connection( - interface[self.name_to_input_port["sample_in_insoluble"]], add1[1] - ), + Connection(interface["sample_in_insoluble"], add1[1]), Connection(add1, col_eff3), Connection(col_eff3[0], vial_3), Connection(col_eff3[1], col_eff4), Connection(col_eff4[0], vial_4), Connection(col_eff4[1], add2[1]), - Connection(vial_1, interface[self.name_to_output_port["vial1"]]), - Connection(vial_2, interface[self.name_to_output_port["vial2"]]), - Connection(vial_3, interface[self.name_to_output_port["vial3"]]), - Connection(vial_4, interface[self.name_to_output_port["vial4"]]), - Connection(add2, interface[self.name_to_output_port["sample_out"]]), + Connection(vial_1, interface["vial1"]), + Connection(vial_2, interface["vial2"]), + Connection(vial_3, interface["vial3"]), + Connection(vial_4, interface["vial4"]), + Connection(add2, interface["sample_out"]), ] super().__init__(blocks, connections) def _create_reset_events_one_vial( self, block, reset_times - ) -> list[pathsim.blocks.Schedule]: - events = [] - + ) -> pathsim.events.ScheduleList: def reset_itg(_): block.reset() - for t in reset_times: - events.append( - pathsim.blocks.Schedule(t_start=t, t_end=t, func_act=reset_itg) - ) - return events + event = pathsim.events.ScheduleList(times_evt=reset_times, func_act=reset_itg) + # won't be needed after https://github.com/milanofthe/pathsim/pull/66 + event.func_act = reset_itg + event.t_start = 0 + event.t_end = None + return event - def create_reset_events(self) -> list[pathsim.blocks.Schedule]: + def create_reset_events(self) -> list[pathsim.events.ScheduleList]: """Create reset events for all vials based on the replacement times. Raises: @@ -249,7 +254,7 @@ def create_reset_events(self) -> list[pathsim.blocks.Schedule]: "reset_times must be a single value or a list with the same length as the number of vials" ) for i, vial in enumerate(self.vials): - events.extend(self._create_reset_events_one_vial(vial, reset_times[i])) + events.append(self._create_reset_events_one_vial(vial, reset_times[i])) return events @@ -259,20 +264,20 @@ def create_reset_events(self) -> list[pathsim.blocks.Schedule]: class FestimWall(Block): - name_to_output_port = {"flux_0": 0, "flux_L": 1} - name_to_input_port = {"c_0": 0, "c_L": 1} + _port_map_out = {"flux_0": 0, "flux_L": 1} + _port_map_in = {"c_0": 0, "c_L": 1} def __init__( self, thickness, temperature, D_0, E_D, surface_area=1, n_vertices=100 ): - super().__init__() try: import festim as F except ImportError: raise ImportError("festim is needed for FestimWall node.") + super().__init__() - self.inputs = Register(size=2) - self.outputs = Register(size=2) + self.inputs = Register(size=2, mapping=self._port_map_in) + self.outputs = Register(size=2, mapping=self._port_map_out) self.thickness = thickness self.temperature = temperature @@ -345,10 +350,8 @@ def update(self, t): # return 0.0 # block inputs - inputs = self.inputs.to_array() - c_0 = inputs[self.name_to_input_port["c_0"]] - c_L = inputs[self.name_to_input_port["c_L"]] - # print(c_0, c_L) + c_0 = self.inputs["c_0"] + c_L = self.inputs["c_L"] if t == 0.0: flux_0, flux_L = 0, 0 @@ -359,7 +362,7 @@ def update(self, t): flux_0 *= self.surface_area flux_L *= self.surface_area - outputs = [None, None] - outputs[self.name_to_output_port["flux_0"]] = flux_0 - outputs[self.name_to_output_port["flux_L"]] = flux_L - return self.outputs.update_from_array(outputs) + + self.outputs["flux_0"] = flux_0 + self.outputs["flux_L"] = flux_L + return self.outputs diff --git a/src/python/pathsim_utils.py b/src/python/pathsim_utils.py index 110cd2eb..c2df81db 100644 --- a/src/python/pathsim_utils.py +++ b/src/python/pathsim_utils.py @@ -88,11 +88,33 @@ "fir": pathsim.blocks.FIR, } +math_blocks = { + "sin": pathsim.blocks.Sin, + "cos": pathsim.blocks.Cos, + "sqrt": pathsim.blocks.Sqrt, + "abs": pathsim.blocks.Abs, + "pow": pathsim.blocks.Pow, + "exp": pathsim.blocks.Exp, + "log": pathsim.blocks.Log, + "log10": pathsim.blocks.Log10, + "tan": pathsim.blocks.Tan, + "sinh": pathsim.blocks.Sinh, + "cosh": pathsim.blocks.Cosh, + "tanh": pathsim.blocks.Tanh, + "atan": pathsim.blocks.Atan, + "norm": pathsim.blocks.Norm, + "mod": pathsim.blocks.Mod, + "clip": pathsim.blocks.Clip, +} + +map_str_to_object.update(math_blocks) + map_str_to_event = { "ZeroCrossingDown": pathsim.events.ZeroCrossingDown, "ZeroCrossingUp": pathsim.events.ZeroCrossingUp, "ZeroCrossing": pathsim.events.ZeroCrossing, "Schedule": pathsim.events.Schedule, + "ScheduleList": pathsim.events.ScheduleList, "Condition": pathsim.events.Condition, } @@ -347,9 +369,12 @@ def get_input_index(block: Block, edge: dict, block_to_input_index: dict) -> int Returns: The input index for the block. """ - if hasattr(block, "name_to_input_port"): - return block.name_to_input_port[edge["targetHandle"]] - elif isinstance(block, Function): + + if edge["targetHandle"] is not None: + if block._port_map_in: + return edge["targetHandle"] + + if isinstance(block, Function): return int(edge["targetHandle"].replace("target-", "")) else: # make sure that the target block has only one input port (ie. that targetHandle is None) @@ -372,9 +397,11 @@ def get_output_index(block: Block, edge: dict) -> int: Returns: The output index for the block. """ - if hasattr(block, "name_to_output_port"): - return block.name_to_output_port[edge["sourceHandle"]] - elif isinstance(block, Splitter): + if edge["sourceHandle"] is not None: + if block._port_map_out: + return edge["sourceHandle"] + + if isinstance(block, Splitter): # Splitter outputs are always in order, so we can use the handle directly assert edge["sourceHandle"], edge output_index = int(edge["sourceHandle"].replace("source", "")) - 1 diff --git a/src/python/templates/block_macros.py b/src/python/templates/block_macros.py index 559c3a15..9edaadaf 100644 --- a/src/python/templates/block_macros.py +++ b/src/python/templates/block_macros.py @@ -13,7 +13,7 @@ {% macro create_integrator_block(node) -%} {{ create_block(node) }} -{%- if node["data"].get("replacement_times") %} +{%- if node["data"].get("reset_times") %} events_{{ node["var_name"] }} = {{ node["var_name"] }}.create_reset_events() events += events_{{ node["var_name"] }} {%- endif %}