{
+ // Initialize with defaults for the initial event type
+ const initialEventType = 'ZeroCrossingDown';
+ const [currentEvent, setCurrentEvent] = useState(() => {
+ return {
+ name: '',
+ type: initialEventType,
+ ...eventDefaults[initialEventType]
+ };
+ });
+
+ // State to track if we're editing an existing event
+ const [editingEventId, setEditingEventId] = useState(null);
+
+ const eventTypes = [
+ 'Condition',
+ 'Schedule',
+ 'ZeroCrossing',
+ 'ZeroCrossingUp',
+ 'ZeroCrossingDown'
+ ];
+
+ const handleInputChange = (field, value) => {
+ setCurrentEvent(prev => ({
+ ...prev,
+ [field]: value
+ }));
+ };
+
+ const handleTypeChange = (newType) => {
+ // When type changes, reset the event to defaults for that type
+ const defaults = eventDefaults[newType] || {};
+ setCurrentEvent({
+ name: currentEvent.name, // Keep the name
+ type: newType,
+ ...defaults
+ });
+ };
+
+ const addEvent = () => {
+ if (currentEvent.name) {
+ // Validate required fields based on event type
+
+ // For Schedule, func_act is required
+ if (currentEvent.type === 'Schedule' && !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)) {
+ alert('Both func_evt and func_act are required for this event type');
+ return;
+ }
+
+ setEvents(prev => [...prev, { ...currentEvent, id: Date.now() }]);
+
+ // Reset to defaults for current type
+ const resetDefaults = eventDefaults[currentEvent.type] || {};
+ setCurrentEvent({
+ name: '',
+ type: currentEvent.type,
+ ...resetDefaults
+ });
+ }
+ };
+
+ const editEvent = (event) => {
+ setCurrentEvent({ ...event });
+ setEditingEventId(event.id);
+ };
+
+ const saveEditedEvent = () => {
+ if (currentEvent.name) {
+
+ // For Schedule, func_act is required
+ if (currentEvent.type === 'Schedule' && !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)) {
+ alert('Both func_evt and func_act are required for this event type');
+ return;
+ }
+
+ setEvents(prev => prev.map(event =>
+ event.id === editingEventId ? { ...currentEvent } : event
+ ));
+
+ // Reset form and exit edit mode
+ cancelEdit();
+ }
+ };
+
+ const cancelEdit = () => {
+ setEditingEventId(null);
+ // Reset to defaults for current type
+ const resetDefaults = eventDefaults[currentEvent.type] || {};
+ setCurrentEvent({
+ name: '',
+ type: currentEvent.type,
+ ...resetDefaults
+ });
+ };
+
+ const deleteEvent = (id) => {
+ setEvents(prev => prev.filter(event => event.id !== id));
+ };
+
+ return (
+
+
+
+ Events
+
+
+ {/* Add New Event Form */}
+
+
+ {editingEventId ? 'Edit Event' : 'Add New Event'}
+
+
+
+
+
+ handleInputChange('name', e.target.value)}
+ placeholder="e.g., E1, shutdown_event"
+ style={{
+ width: '80%',
+ padding: '10px',
+ backgroundColor: '#1e1e2f',
+ border: '1px solid #555',
+ borderRadius: '4px',
+ color: '#ffffff',
+ fontSize: '14px',
+ }}
+ />
+
+
+
+
+
+
+
+
+ {/* Dynamic parameter fields based on event type */}
+
+ {(() => {
+ const typeDefaults = eventDefaults[currentEvent.type] || {};
+ const allParams = new Set([
+ // don't include 'name', 'type', since included above + 'id' cannot be edited
+ ...Object.keys(currentEvent).filter(key => key !== 'name' && key !== 'type' && key !== 'id'),
+ ...Object.keys(typeDefaults)
+ ]);
+
+ return Array.from(allParams).map(key => {
+ const currentValue = currentEvent[key] || '';
+ const defaultValue = typeDefaults[key];
+ const placeholder = defaultValue !== undefined && defaultValue !== null ?
+ String(defaultValue) : '';
+
+ // Check if this is a function parameter (contains 'func' in the name)
+ const isFunctionParam = key.toLowerCase().includes('func');
+
+ return (
+
+
+ {isFunctionParam ? (
+
+ );
+ });
+ })()}
+
+
+
+ {editingEventId ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ {/* Events List */}
+
+
Defined Events ({events.length})
+
+ {events.length === 0 ? (
+
+ No events defined yet. Add an event above to get started.
+
+ ) : (
+
+ {events.map((event, index) => (
+
+
+
+
+ {event.name} ({event.type})
+
+ {editingEventId === event.id && (
+
+ Currently editing this event
+
+ )}
+
+
+
+
+
+
+
+ {/* Display parameters dynamically */}
+
+ {Object.entries(event)
+ .filter(([key]) => key !== 'id' && key !== 'name' && key !== 'type')
+ .map(([key, value]) => {
+ const isFunctionParam = key.toLowerCase().includes('func');
+
+ return (
+
+
+ {key.replace(/_/g, ' ')}:
+
+ {isFunctionParam ? (
+
+ {value || '(not defined)'}
+
+ ) : (
+
+ {value || '(not set)'}
+
+ )}
+
+ );
+ })
+ }
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+export default EventsTab;
\ No newline at end of file
diff --git a/src/convert_to_python.py b/src/convert_to_python.py
index 4f039d61..fda816bc 100644
--- a/src/convert_to_python.py
+++ b/src/convert_to_python.py
@@ -4,12 +4,14 @@
from .pathsim_utils import (
map_str_to_object,
+ map_str_to_event,
make_blocks,
make_global_variables,
get_input_index,
get_output_index,
find_block_by_id,
find_node_by_id,
+ make_var_name,
)
@@ -29,35 +31,6 @@ def convert_graph_to_python(graph_data: dict) -> str:
return template.render(context)
-def make_var_name(node: dict) -> str:
- """
- Create a variable name from the node label, ensuring it is a valid Python identifier.
- If the label contains invalid characters, they are replaced with underscores.
- If the variable name is not unique, a number is appended to make it unique.
-
- This is supposed to match the logic in NodeSidebar.jsx makeVarName function.
- """
- # Make a variable name from the label
- invalid_chars = set("!@#$%^&*()+=[]{}|;:'\",.-<>?/\\`~")
- base_var_name = node["data"]["label"].lower().replace(" ", "_")
- for char in invalid_chars:
- base_var_name = base_var_name.replace(char, "")
-
- # Make the variable name unique by appending a number if needed
- var_name = base_var_name
- var_name = f"{base_var_name}_{node['id']}"
-
- # Ensure the base variable name is a valid identifier
- if not var_name.isidentifier():
- var_name = f"var_{var_name}"
- if not var_name.isidentifier():
- raise ValueError(
- f"Variable name must be a valid identifier. {node['data']['label']} to {var_name}"
- )
-
- return var_name
-
-
def process_node_data(nodes: list[dict]) -> list[dict]:
"""
Given a list of node and edge data as dictionaries, process the nodes to create
@@ -144,6 +117,44 @@ def make_edge_data(data: dict) -> list[dict]:
return data["edges"]
+def make_events_data(data: dict) -> list[dict]:
+ """
+ Process events data from the graph data.
+
+ This function extracts event definitions from the graph data and prepares them
+ for use in the simulation.
+
+ Args:
+ data: The graph data containing "events".
+
+ Returns:
+ A list of processed events.
+ """
+ for event in data["events"]:
+ # Add pathsim class name
+ event_class = map_str_to_event.get(event["type"])
+ event["class_name"] = event_class.__name__
+ event["module_name"] = event_class.__module__
+
+ # Add expected arguments
+ event["expected_arguments"] = signature(event_class).parameters
+
+ if "func_evt" in event:
+ # replace the name of the function by something unique
+ func_evt = event["func_evt"]
+ func_evt = func_evt.replace("def func_evt", f"def {event['name']}_func_evt")
+ event["func_evt"] = f"{event['name']}_func_evt"
+ event["func_evt_desc"] = func_evt
+ if "func_act" in event:
+ # replace the name of the function by something unique
+ func_act = event["func_act"]
+ func_act = func_act.replace("def func_act", f"def {event['name']}_func_act")
+ event["func_act"] = f"{event['name']}_func_act"
+ event["func_act_desc"] = func_act
+
+ return data["events"]
+
+
def process_graph_data_from_dict(data: dict) -> dict:
"""
Process graph data from a dictionary.
@@ -161,4 +172,6 @@ def process_graph_data_from_dict(data: dict) -> dict:
# Process to add source/target variable names to edges + ports
data["edges"] = make_edge_data(data)
+ data["events"] = make_events_data(data)
+
return data
diff --git a/src/pathsim_utils.py b/src/pathsim_utils.py
index 3c5ccfbd..6bb583f7 100644
--- a/src/pathsim_utils.py
+++ b/src/pathsim_utils.py
@@ -22,6 +22,7 @@
Schedule,
)
import pathsim.blocks
+import pathsim.events
from pathsim.blocks.noise import WhiteNoise, PinkNoise
from .custom_pathsim_blocks import (
Process,
@@ -86,6 +87,14 @@
"fir": pathsim.blocks.FIR,
}
+map_str_to_event = {
+ "ZeroCrossingDown": pathsim.events.ZeroCrossingDown,
+ "ZeroCrossingUp": pathsim.events.ZeroCrossingUp,
+ "ZeroCrossing": pathsim.events.ZeroCrossing,
+ "Schedule": pathsim.events.Schedule,
+ "Condition": pathsim.events.Condition,
+}
+
def find_node_by_id(node_id: str, nodes: list[dict]) -> dict:
return next((node for node in nodes if node["id"] == node_id), None)
@@ -205,6 +214,78 @@ def auto_block_construction(node: dict, eval_namespace: dict = None) -> Block:
return block_class(**parameters)
+def auto_event_construction(event_data: dict, eval_namespace: dict = None) -> Event:
+ """
+ Automatically constructs an event object from an event data dictionary.
+
+ Args:
+ event_data: The event data dictionary containing event information.
+ eval_namespace: A namespace for evaluating expressions. Defaults to None.
+
+ Raises:
+ ValueError: If the event type is unknown or if there are issues with evaluation.
+
+ Returns:
+ The constructed event object.
+ """
+
+ if event_data["type"] not in map_str_to_event:
+ raise ValueError(f"Unknown event type: {event_data['type']}")
+
+ event_class = map_str_to_event[event_data["type"]]
+
+ parameters = get_parameters_for_event_class(
+ event_class, event_data, eval_namespace=eval_namespace
+ )
+
+ return event_class(**parameters)
+
+
+def get_parameters_for_event_class(
+ event_class: type, event_data: dict, eval_namespace: dict = None
+):
+ parameters_for_class = inspect.signature(event_class.__init__).parameters
+
+ # Create a local namespace for executing the event functions
+ # we make a copy so that event functions aren't overwritten
+ event_namespace = eval_namespace.copy()
+
+ parameters = {}
+ for k, value in parameters_for_class.items():
+ if k == "self":
+ continue
+
+ user_input = event_data[k]
+ if user_input == "":
+ if value.default is inspect._empty:
+ raise ValueError(
+ f"expected parameter for {k} in {event_data['type']} ({event_data['name']})"
+ )
+
+ # make a copy of the default value
+ if isinstance(value.default, (list, dict)):
+ parameters[k] = value.default.copy()
+ else:
+ parameters[k] = value.default
+ else:
+ if k in ["func_evt", "func_act"]:
+ # Execute func code if provided
+ func_code = event_data[k]
+ if func_code:
+ try:
+ exec(func_code, event_namespace)
+ if k not in event_namespace:
+ raise ValueError(f"{k} function not found after execution")
+ except Exception as e:
+ raise ValueError(f"Error executing {k} code: {str(e)}")
+ else:
+ raise ValueError(f"{k} code is required but not provided")
+ parameters[k] = event_namespace[k]
+ else:
+ parameters[k] = eval(user_input, event_namespace)
+ return parameters
+
+
def get_parameters_for_block_class(block_class, node, eval_namespace):
parameters_for_class = inspect.signature(block_class.__init__).parameters
parameters = {}
@@ -352,6 +433,33 @@ def make_connections(nodes, edges, blocks) -> list[Connection]:
return connections_pathsim
+def make_events(events_data: list[dict], eval_namespace: dict = None) -> list[Event]:
+ """
+ Create a list of Event objects from the provided event data.
+
+ Args:
+ events_data: A list of dictionaries containing event information.
+ eval_namespace: A namespace for evaluating expressions. Defaults to None.
+
+ Returns:
+ A list of Event objects.
+ """
+ if eval_namespace is None:
+ eval_namespace = globals()
+
+ events = []
+ for event_data in events_data:
+ event_type = event_data.get("type")
+ event_class = map_str_to_event.get(event_type)
+
+ if not event_class:
+ raise ValueError(f"Unknown event type: {event_type}")
+
+ event = auto_event_construction(event_data, eval_namespace)
+ events.append(event)
+ return events
+
+
def make_default_scope(nodes, blocks) -> tuple[Scope, list[Connection]]:
scope_default = Scope(
labels=[node["data"]["label"] for node in nodes],
@@ -374,6 +482,35 @@ def make_default_scope(nodes, blocks) -> tuple[Scope, list[Connection]]:
return scope_default, connections_pathsim
+def make_var_name(node: dict) -> str:
+ """
+ Create a variable name from the node label, ensuring it is a valid Python identifier.
+ If the label contains invalid characters, they are replaced with underscores.
+ If the variable name is not unique, a number is appended to make it unique.
+
+ This is supposed to match the logic in NodeSidebar.jsx makeVarName function.
+ """
+ # Make a variable name from the label
+ invalid_chars = set("!@#$%^&*()+=[]{}|;:'\",.-<>?/\\`~")
+ base_var_name = node["data"]["label"].lower().replace(" ", "_")
+ for char in invalid_chars:
+ base_var_name = base_var_name.replace(char, "")
+
+ # Make the variable name unique by appending a number if needed
+ var_name = base_var_name
+ var_name = f"{base_var_name}_{node['id']}"
+
+ # Ensure the base variable name is a valid identifier
+ if not var_name.isidentifier():
+ var_name = f"var_{var_name}"
+ if not var_name.isidentifier():
+ raise ValueError(
+ f"Variable name must be a valid identifier. {node['data']['label']} to {var_name}"
+ )
+
+ return var_name
+
+
def make_pathsim_model(graph_data: dict) -> tuple[Simulation, float]:
nodes = graph_data.get("nodes", [])
edges = graph_data.get("edges", [])
@@ -402,6 +539,12 @@ def make_pathsim_model(graph_data: dict) -> tuple[Simulation, float]:
blocks.append(scope_default)
connections_pathsim.extend(connections_scope_def)
+ # Create additional events
+ for node in nodes:
+ var_name = make_var_name(node)
+ eval_namespace[var_name] = find_block_by_id(node["id"], blocks)
+ events += make_events(graph_data.get("events", []), eval_namespace)
+
# Create the simulation
simulation = Simulation(
blocks,
diff --git a/src/templates/block_macros.py b/src/templates/block_macros.py
index ce20e8f5..559c3a15 100644
--- a/src/templates/block_macros.py
+++ b/src/templates/block_macros.py
@@ -39,3 +39,21 @@
{% endfor -%}
]
{%- endmacro -%}
+
+{% macro create_event(event) -%}
+{% if "func_evt" in event %}
+{{ event["func_evt_desc"] }}
+{% endif %}
+
+{% if "func_act" in event %}
+{{ event["func_act_desc"] }}
+{% endif %}
+
+{{ event["name"] }} = {{ event["module_name"] }}.{{ event["class_name"] }}(
+ {%- for arg in event["expected_arguments"] %}
+ {%- if event.get(arg) -%}
+ {{ arg }}={{ event.get(arg) }}{% if not loop.last %}, {% endif %}
+ {%- endif -%}
+ {%- endfor %}
+)
+{%- endmacro -%}
\ No newline at end of file
diff --git a/src/templates/template_with_macros.py b/src/templates/template_with_macros.py
index f30cc03f..160c07df 100644
--- a/src/templates/template_with_macros.py
+++ b/src/templates/template_with_macros.py
@@ -4,7 +4,7 @@
import matplotlib.pyplot as plt
import src
{# Import macros #}
-{% from 'block_macros.py' import create_block, create_integrator_block, create_bubbler_block, create_connections -%}
+{% from 'block_macros.py' import create_block, create_integrator_block, create_bubbler_block, create_connections, create_event -%}
# Create global variables
{% for var in globalVariables -%}
@@ -25,6 +25,12 @@
{% endfor %}
+# Create events
+{% for event in events -%}
+{{ create_event(event) }}
+events.append({{ event["name"] }})
+{% endfor %}
+
# Create connections
{{ create_connections(edges) }}
diff --git a/test/test_convert_python.py b/test/test_convert_python.py
index 1bd5fa28..a19e8adc 100644
--- a/test/test_convert_python.py
+++ b/test/test_convert_python.py
@@ -43,6 +43,7 @@
},
},
],
+ "events": [],
"edges": [
{
"source": "1",
@@ -146,6 +147,7 @@ def test_bubbler_has_reset_times():
},
],
"edges": [],
+ "events": [],
"solverParams": {
"Solver": "SSPRK22",
"dt": "0.01",
diff --git a/test/test_events.py b/test/test_events.py
new file mode 100644
index 00000000..9351cac5
--- /dev/null
+++ b/test/test_events.py
@@ -0,0 +1,23 @@
+import pytest
+from src.pathsim_utils import make_events
+
+
+events_data = [
+ {
+ "name": "my_event",
+ "type": "ZeroCrossingDown",
+ "func_evt": "def func_evt(t):\n *_, x = integrator_1()\n return 10 - x",
+ "func_act": "def func_act(t):\n source_0.off()",
+ "tolerance": "1e-8",
+ "id": 1754342253698,
+ }
+]
+
+
+def test_make_events():
+ eval_namespace = {}
+ events = make_events(events_data, eval_namespace)
+ assert len(events) == 1
+ assert events[0].func_evt.__code__.co_argcount == 1
+ assert events[0].func_act.__code__.co_argcount == 1
+ assert events[0].tolerance == 1e-08
diff --git a/test/test_files/bubbler.json b/test/test_files/bubbler.json
index 8acba0c0..f336d89e 100644
--- a/test/test_files/bubbler.json
+++ b/test/test_files/bubbler.json
@@ -57,6 +57,7 @@
"dragging": false
}
],
+ "events": [],
"edges": [
{
"id": "e2-1-to_sample_in_soluble",
diff --git a/test/test_files/constant_delay_scope.json b/test/test_files/constant_delay_scope.json
index 98623d3f..0485a98c 100644
--- a/test/test_files/constant_delay_scope.json
+++ b/test/test_files/constant_delay_scope.json
@@ -55,6 +55,7 @@
"dragging": false
}
],
+ "events": [],
"edges": [
{
"id": "e0-1",
diff --git a/test/test_files/custom_nodes.json b/test/test_files/custom_nodes.json
index 48c80685..d9d301dd 100644
--- a/test/test_files/custom_nodes.json
+++ b/test/test_files/custom_nodes.json
@@ -60,6 +60,7 @@
"dragging": false
}
],
+ "events": [],
"edges": [],
"nodeCounter": 3,
"solverParams": {
diff --git a/test/test_files/same_label.json b/test/test_files/same_label.json
index dc23833c..2ac45949 100644
--- a/test/test_files/same_label.json
+++ b/test/test_files/same_label.json
@@ -58,6 +58,7 @@
"dragging": false
}
],
+ "events": [],
"edges": [
{
"id": "e1-2",
diff --git a/test/test_files/spectrum.json b/test/test_files/spectrum.json
index 904c8ed8..e7d9f683 100644
--- a/test/test_files/spectrum.json
+++ b/test/test_files/spectrum.json
@@ -94,6 +94,7 @@
}
}
],
+ "events": [],
"edges": [
{
"id": "e3-4",