Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"duration_voltage_combinations": {
"items": {
"duration": {
"oneOf": [
{
"type": "float"
},
{
"type": "array",
"items": {
"type": "float"
}
}
],
"ui_element": "float_parameter_sweep"
},
"voltage": {
"oneOf": [
{
"type": "float"
},
{
"type": "array",
"items": {
"type": "float"
}
}
],
"ui_element": "float_parameter_sweep"
}
},
"type": "array",
"minLength": 1
},
"title": "Voltage Levels and Durations",
"description": "A list of duration and voltage combinations for the SEClamp stimulus.",
"ui_element": "voltage_duration_ui_element"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
## Voltage duration UI element

ui_element: `voltage_duration_ui_element`

Reference schema [voltage_duration_ui_element](reference_schemas/voltage_duration_ui_element.json)


### UI design

<img src="designs/voltage_duration_ui_element.png" width="300" />

The user can add new DurationVoltageCombination by clicking on a button.

They can delete any DurationVoltageCombination, unless there is only one left, in which case it cannot be deleted.


### Example Pydantic implementation

```py

class DurationVoltageCombination(ComplexVariableHolder):
"""Class for storing pairs of duration and voltage combinations for stimulation protocols."""

voltage: float | list[float] = Field(
title="Voltage for each level",
description="The voltage for each level, given in millivolts (mV).",
json_schema_extra={
"ui_element": "float_parameter_sweep",
Copy link
Copy Markdown
Contributor

@g-bar g-bar Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to nest ui_element (at least for now), the FE can simply hardcode the ParameterSweep components for the DurationVoltageCombination, in fact this isn't in the reference schema, so it's not even visible to the FE. (Or if you want it make sure it's in the reference schema).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added "ui_element": "float_parameter_sweep", to the reference schema, in both voltage and duration so that it is visible to the FE

Copy link
Copy Markdown
Contributor

@g-bar g-bar Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no use for this nested ui_element, since the 'voltage' and 'duration' fields are hardcoded in the schema there is no point to "dynamically" render the inner elements since they never change for the voltage duration ui_element, this would only useful with a "generic" ui_element where the inner fields can vary.

I'm not sure how even making this generic would work, since the pydantic models are static so there is no way to define the field names dynamically.

So it should be removed from the schema, I will just ignore it for now, and hardcode ParameterSweep component for both of those fields on the FE.

"unit": "mV",
},
)

duration: NonNegativeFloat | list[NonNegativeFloat] = Field(
title="Duration for each level",
description="The duration for each level, given in milliseconds (ms).",
json_schema_extra={
"ui_element": "float_parameter_sweep",
"unit": "ms",
},
)


class Block:

duration_voltage_combinations: list[DurationVoltageCombination] = Field(
title="Voltage Levels and Durations",
description="A list of duration and voltage combinations for the SEClamp stimulus. "
"The first duration starts at time 0, "
"and each subsequent duration starts when the previous one ends.",
json_schema_extra={
"ui_element": "voltage_duration_ui_element",
},
)

```
12 changes: 12 additions & 0 deletions obi_one/core/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pydantic import PrivateAttr

from obi_one.core.base import OBIBaseModel
from obi_one.core.block_subunit.complex_variable_holder import ComplexVariableHolder
from obi_one.core.param import MultiValueScanParam
from obi_one.core.parametric_multi_values import ParametericMultiValue

Expand Down Expand Up @@ -60,6 +61,17 @@ def multiple_value_parameters(
multi_values = list(value)

elif isinstance(value, list):
# list[ComplexVariableHolder] special case
if len(value) > 0 and isinstance(value[0], ComplexVariableHolder):
for i, complex_variable_holder in enumerate(value):
self._multiple_value_parameters.extend(
complex_variable_holder.multiple_value_parameters(
base_location_list=[category_name, block_key, key, i]
if block_key
else [category_name, key, i]
)
)
continue
multi_values = value

else:
Expand Down
Empty file.
57 changes: 57 additions & 0 deletions obi_one/core/block_subunit/complex_variable_holder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from pydantic import Field, NonNegativeFloat, PrivateAttr

from obi_one.core.base import OBIBaseModel
from obi_one.core.param import MultiValueScanParam
from obi_one.core.schema import SchemaKey, UIElement
from obi_one.core.units import Units


class ComplexVariableHolder(OBIBaseModel, extra="forbid"):
"""Has structure List[Tuple[AnyOf(int, float, list[float], list[int])]]."""

_multiple_value_parameters: list[MultiValueScanParam] = PrivateAttr(default=[])

# checks this for parameter scan
def multiple_value_parameters(self, base_location_list: list[str]) -> list[MultiValueScanParam]:
self._multiple_value_parameters = []

for key, value in self.__dict__.items():
multi_values = []
if isinstance(value, list):
multi_values.append(
{
"value": value,
"location_list": [*base_location_list, key],
}
)

for multi_value in multi_values:
self._multiple_value_parameters.append(
MultiValueScanParam(
location_list=multi_value["location_list"], values=multi_value["value"]
)
)

return self._multiple_value_parameters


class DurationVoltageCombination(ComplexVariableHolder):
"""Class for storing pairs of duration and voltage combinations for stimulation protocols."""

voltage: float | list[float] = Field(
title="Voltage for each level",
description="The voltage for each level, given in millivolts (mV).",
json_schema_extra={
SchemaKey.UI_ELEMENT: UIElement.FLOAT_PARAMETER_SWEEP,
SchemaKey.UNITS: Units.MILLIVOLTS,
},
)

duration: NonNegativeFloat | list[NonNegativeFloat] = Field(
title="Duration for each level",
description="The duration for each level, given in milliseconds (ms).",
json_schema_extra={
SchemaKey.UI_ELEMENT: UIElement.FLOAT_PARAMETER_SWEEP,
SchemaKey.UNITS: Units.MILLISECONDS,
},
)
42 changes: 26 additions & 16 deletions obi_one/core/scan_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from importlib.metadata import version
from itertools import product
from pathlib import Path
from typing import Any

import entitysdk
from pydantic import PrivateAttr, ValidationError
Expand Down Expand Up @@ -58,7 +59,7 @@ def multiple_value_parameters(self, *, display: bool = False) -> list[MultiValue
for attr_name, attr_value in self.form.__dict__.items():
# Check if the attribute is a dictionary of Block instances
if isinstance(attr_value, dict) and all(
isinstance(dict_val, Block) for dict_key, dict_val in attr_value.items()
isinstance(dict_val, Block) for dict_val in attr_value.values()
):
category_name = attr_name
category_blocks_dict = attr_value
Expand Down Expand Up @@ -108,6 +109,27 @@ def coordinate_parameters(self) -> list[SingleCoordinateScanParams]:
msg = "coordinate_parameters() must be implemented by a subclass of Scan."
raise NotImplementedError(msg)

def set_nested_single_coordinate_scan_param_value(
self, single_coord_config: ScanConfigsUnion, location_list: list, value: Any
) -> Any:
if location_list == []:
return value

if isinstance(single_coord_config, (list, dict, tuple)):
single_coord_config[location_list[0]] = (
self.set_nested_single_coordinate_scan_param_value(
single_coord_config[location_list[0]], location_list[1:], value
)
)
return single_coord_config

single_coord_config.__dict__[location_list[0]] = (
self.set_nested_single_coordinate_scan_param_value(
single_coord_config.__dict__[location_list[0]], location_list[1:], value
)
)
return single_coord_config

def create_single_configs(self) -> list[SingleConfigMixin]:
"""Coordinate instance.

Expand Down Expand Up @@ -135,21 +157,9 @@ def create_single_configs(self) -> list[SingleConfigMixin]:
# Change the value of the multi parameter from a list to the single value of the
# coordinate
for scan_param in single_coordinate_scan_params.scan_params:
level_0_val = single_coord_config.__dict__[scan_param.location_list[0]]

# If the first level is a Block
if isinstance(level_0_val, Block):
level_0_val.__dict__[scan_param.location_list[1]] = scan_param.value

# If the first level is a category dictionary
if isinstance(level_0_val, dict):
level_1_val = level_0_val[scan_param.location_list[1]]
if isinstance(level_1_val, Block):
level_1_val.__dict__[scan_param.location_list[2]] = scan_param.value
else:
msg = f"Non Block parameter {level_1_val} found in Form dictionary: \
{level_0_val}"
raise TypeError(msg)
single_coord_config = self.set_nested_single_coordinate_scan_param_value(
single_coord_config, scan_param.location_list, scan_param.value
)

try:
# Cast the form to its single_config_class_name type
Expand Down
1 change: 1 addition & 0 deletions obi_one/core/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ class UIElement(StrEnum):
STRING_INPUT = "string_input"
STRING_SELECTION = "string_selection"
STRING_SELECTION_ENHANCED = "string_selection_enhanced"
VOLTAGE_DURATION = "voltage_duration"
Loading
Loading