From 1037479d6b7a4c299412400d5783c8dde40f76ae Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:13:15 +0200 Subject: [PATCH 01/25] Update FrequencyRange component --- gui_dev/bun.lockb | Bin 239053 -> 246999 bytes gui_dev/src/App.jsx | 16 +- gui_dev/src/components/TitledBox.jsx | 8 +- gui_dev/src/pages/Settings/FrequencyRange.jsx | 156 +++++++++-------- gui_dev/src/pages/Settings/Settings.jsx | 165 +++++++++++------- py_neuromodulation/gui/backend/app_backend.py | 2 +- py_neuromodulation/utils/types.py | 6 +- pyproject.toml | 2 +- 8 files changed, 203 insertions(+), 152 deletions(-) diff --git a/gui_dev/bun.lockb b/gui_dev/bun.lockb index ae7d827b00f431779b90f1d41f170aebf07b6c4f..cafed1a34c58bcd17f9621a80286e99fce910eea 100755 GIT binary patch delta 53049 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 N+N*p_KkN6S{s;G{)@lF% delta 48320 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

=`)nfwRClpa3`zTmXIq3W1BjPr%Q> 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 diff --git a/gui_dev/src/App.jsx b/gui_dev/src/App.jsx index a97f8c75..dd5928ab 100644 --- a/gui_dev/src/App.jsx +++ b/gui_dev/src/App.jsx @@ -45,14 +45,14 @@ export const App = () => { const connectSocket = useSocketStore((state) => state.connectSocket); const disconnectSocket = useSocketStore((state) => state.disconnectSocket); - useEffect(() => { - console.log("Connecting socket from App component..."); - connectSocket(); - return () => { - console.log("Disconnecting socket from App component..."); - disconnectSocket(); - }; - }, [connectSocket, disconnectSocket]); + // useEffect(() => { + // console.log("Connecting socket from App component..."); + // connectSocket(); + // return () => { + // console.log("Disconnecting socket from App component..."); + // disconnectSocket(); + // }; + // }, [connectSocket, disconnectSocket]); return ( diff --git a/gui_dev/src/components/TitledBox.jsx b/gui_dev/src/components/TitledBox.jsx index 56f0266b..a86b6009 100644 --- a/gui_dev/src/components/TitledBox.jsx +++ b/gui_dev/src/components/TitledBox.jsx @@ -1,4 +1,4 @@ -import { Container } from "@mui/material"; +import { Box, Container } from "@mui/material"; /** * Component that uses the Box component to render an HTML fieldset element @@ -13,14 +13,14 @@ export const TitledBox = ({ children, ...props }) => ( - {title} {children} - + ); diff --git a/gui_dev/src/pages/Settings/FrequencyRange.jsx b/gui_dev/src/pages/Settings/FrequencyRange.jsx index 5def783b..cdf27f24 100644 --- a/gui_dev/src/pages/Settings/FrequencyRange.jsx +++ b/gui_dev/src/pages/Settings/FrequencyRange.jsx @@ -1,88 +1,94 @@ -import { useState } from "react"; +import { TextField, Button, Box, Typography, IconButton } from "@mui/material"; +import { Add, Close } from "@mui/icons-material"; -// const onChange = (key, newValue) => { -// settings.frequencyRanges[key] = newValue; -// }; - -// Object.entries(settings.frequencyRanges).map(([key, value]) => ( -// -// )); +export const FrequencyRange = ({ name, range, onChange, onRemove }) => { + const handleChange = (field, value) => { + onChange(name, { ...range, [field]: value }); + }; -/** */ + return ( + + onChange(e.target.value, range, name)} + sx={{ mr: 1, width: "30%" }} + /> + handleChange("frequency_low_hz", e.target.value)} + label="Low Hz" + sx={{ mr: 1, width: "30%" }} + /> + handleChange("frequency_high_hz", e.target.value)} + label="High Hz" + sx={{ mr: 1, width: "30%" }} + /> + onRemove(name)} color="error"> + + + + ); +}; -/** - * - * @param {String} key - * @param {Array} freqRange - * @param {Function} onChange - * @returns - */ -export const FrequencyRange = ({ settings }) => { - const [frequencyRanges, setFrequencyRanges] = useState(settings || {}); - // Handle changes in the text fields - const handleInputChange = (label, key, newValue) => { - setFrequencyRanges((prevState) => ({ - ...prevState, - [label]: { - ...prevState[label], - [key]: newValue, - }, - })); +export const FrequencyRangeList = ({ ranges, onChange }) => { + const handleChange = (newName, newRange, oldName = newName) => { + const updatedRanges = { ...ranges }; + if (newName !== oldName) { + delete updatedRanges[oldName]; + } + updatedRanges[newName] = newRange; + onChange(["frequency_ranges_hz"], updatedRanges); }; - // Add a new band - const addBand = () => { - const newLabel = `Band ${Object.keys(frequencyRanges).length + 1}`; - setFrequencyRanges((prevState) => ({ - ...prevState, - [newLabel]: { frequency_high_hz: "", frequency_low_hz: "" }, - })); + const handleRemove = (name) => { + const updatedRanges = { ...ranges }; + delete updatedRanges[name]; + onChange(["frequency_ranges_hz"], updatedRanges); }; - // Remove a band - const removeBand = (label) => { - const updatedRanges = { ...frequencyRanges }; - delete updatedRanges[label]; - setFrequencyRanges(updatedRanges); + const addRange = () => { + let newName = "NewRange"; + let counter = 0; + while (ranges.hasOwnProperty(newName)) { + counter++; + newName = `NewRange${counter}`; + } + const updatedRanges = { + ...ranges, + [newName]: { frequency_low_hz: "", frequency_high_hz: "" }, + }; + onChange(["frequency_ranges_hz"], updatedRanges); }; return ( -

-
Frequency Bands
- {Object.keys(frequencyRanges).map((label) => ( -
- { - const newLabel = e.target.value; - const updatedRanges = { ...frequencyRanges }; - updatedRanges[newLabel] = updatedRanges[label]; - delete updatedRanges[label]; - setFrequencyRanges(updatedRanges); - }} - placeholder="Band Name" - /> - - handleInputChange(label, "frequency_high_hz", e.target.value) - } - placeholder="High Hz" - /> - - handleInputChange(label, "frequency_low_hz", e.target.value) - } - placeholder="Low Hz" - /> - -
+ + + Frequency Ranges + + {Object.entries(ranges).map(([name, range]) => ( + ))} - -
+ + ); }; diff --git a/gui_dev/src/pages/Settings/Settings.jsx b/gui_dev/src/pages/Settings/Settings.jsx index c2f1dff9..01706d38 100644 --- a/gui_dev/src/pages/Settings/Settings.jsx +++ b/gui_dev/src/pages/Settings/Settings.jsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import { + Box, Button, InputAdornment, Stack, @@ -8,7 +10,7 @@ import { } from "@mui/material"; import { Link } from "react-router-dom"; import { CollapsibleBox, TitledBox } from "@/components"; -import { FrequencyRange } from "./FrequencyRange"; +import { FrequencyRangeList } from "./FrequencyRange"; import { useSettingsStore } from "@/stores"; import { filterObjectByKeys } from "@/utils/functions"; @@ -44,13 +46,13 @@ const NumberField = ({ value, onChange, label }) => { value={value} onChange={handleChange} label={label} - InputProps={{ - endAdornment: ( - - Hz - - ), - }} + // InputProps={{ + // endAdornment: ( + // + // Hz + // + // ), + // }} inputProps={{ pattern: "[0-9]*", }} @@ -58,16 +60,11 @@ const NumberField = ({ value, onChange, label }) => { ); }; -const FrequencyRangeField = ({ value, onChange, label }) => ( - -); - // Map component types to their respective wrappers const componentRegistry = { boolean: BooleanField, string: StringField, number: NumberField, - FrequencyRange: FrequencyRangeField, }; const SettingsField = ({ path, Component, label, value, onChange, depth }) => { @@ -96,11 +93,25 @@ const SettingsSection = ({ onChange, depth = 0, }) => { - if (Object.keys(settings).length === 0) { - return null; - } const boxTitle = title ? title : formatKey(path[path.length - 1]); + // If we receive a primitive value, we need to render a component + + if (typeof settings !== "object") { + const Component = componentRegistry[typeof settings]; + if (!Component) { + console.error(`Invalid component type: ${typeof settings}`); + return null; + } + return ( + + {boxTitle} + + + ); + } + + // If we receive a nested object, we iterate over it and render recursively return ( {Object.entries(settings).map(([key, value]) => { @@ -144,6 +155,7 @@ const SettingsSection = ({ }; const SettingsContent = () => { + const [selectedFeature, setSelectedFeature] = useState(""); const settings = useSettingsStore((state) => state.settings); const updateSettings = useSettingsStore((state) => state.updateSettings); @@ -151,6 +163,8 @@ const SettingsContent = () => { return
Loading settings...
; } + console.log(settings); + const handleChange = (path, value) => { updateSettings((settings) => { let current = settings; @@ -181,32 +195,23 @@ const SettingsContent = () => { "project_subcortex_settings", ]; + const generalSettingsKeys = [ + "sampling_rate_features_hz", + "segment_length_features_ms", + ]; + return ( - - - - - - - {preprocessingSettingsKeys.map((key) => ( + + + {generalSettingsKeys.map((key) => ( { depth={0} /> ))} - +
- - {postprocessingSettingsKeys.map((key) => ( - - ))} - + + + - - {Object.entries(enabledFeatures).map(([feature, featureSettings]) => ( - + + {preprocessingSettingsKeys.map((key) => ( + + ))} + + + + {postprocessingSettingsKeys.map((key) => ( + + ))} + + + + + - - ))} - + + + {Object.entries(enabledFeatures).map( + ([feature, featureSettings]) => ( + + + + ) + )} + + + ); }; diff --git a/py_neuromodulation/gui/backend/app_backend.py b/py_neuromodulation/gui/backend/app_backend.py index b173ced7..10a8c158 100644 --- a/py_neuromodulation/gui/backend/app_backend.py +++ b/py_neuromodulation/gui/backend/app_backend.py @@ -100,7 +100,7 @@ async def get_settings(): async def update_settings(data: dict): try: self.pynm_state.settings = NMSettings.model_validate(data) - self.logger.info(self.pynm_state.settings.features) + self.logger.debug(self.pynm_state.settings.features) return self.pynm_state.settings.model_dump() except ValueError as e: raise HTTPException( diff --git a/py_neuromodulation/utils/types.py b/py_neuromodulation/utils/types.py index 7886685d..06a6cf7b 100644 --- a/py_neuromodulation/utils/types.py +++ b/py_neuromodulation/utils/types.py @@ -124,12 +124,10 @@ def process_for_frontend(self) -> dict[str, Any]: """ Process the model for frontend use, adding __field_type__ information. """ - result = {} + result: dict[str, Any] = {"__field_type__": self.__class__.__name__} for field_name, field_value in self.__dict__.items(): if isinstance(field_value, NMBaseModel): - processed_value = field_value.process_for_frontend() - processed_value["__field_type__"] = field_value.__class__.__name__ - result[field_name] = processed_value + result[field_name] = field_value.process_for_frontend() elif isinstance(field_value, list): result[field_name] = [ item.process_for_frontend() diff --git a/pyproject.toml b/pyproject.toml index c20736da..095dcf80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ dev = [ "pytest-cov", "pytest-sugar", "notebook", - "watchdog", + "uvicorn[standard]", ] docs = [ "py-neuromodulation[dev]", From 447fc201897c58eeb83b54e6548e0e6260c60708 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:47:09 +0200 Subject: [PATCH 02/25] Fix Number input fields --- gui_dev/src/pages/Settings/FrequencyRange.jsx | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/gui_dev/src/pages/Settings/FrequencyRange.jsx b/gui_dev/src/pages/Settings/FrequencyRange.jsx index cdf27f24..34c9aaf7 100644 --- a/gui_dev/src/pages/Settings/FrequencyRange.jsx +++ b/gui_dev/src/pages/Settings/FrequencyRange.jsx @@ -1,39 +1,66 @@ -import { TextField, Button, Box, Typography, IconButton } from "@mui/material"; +import { + TextField, + Button, + Box, + Typography, + IconButton, + Stack, +} from "@mui/material"; import { Add, Close } from "@mui/icons-material"; +const NumberField = ({ ...props }) => ( + +); + export const FrequencyRange = ({ name, range, onChange, onRemove }) => { const handleChange = (field, value) => { onChange(name, { ...range, [field]: value }); }; return ( - + onChange(e.target.value, range, name)} - sx={{ mr: 1, width: "30%" }} /> - handleChange("frequency_low_hz", e.target.value)} label="Low Hz" - sx={{ mr: 1, width: "30%" }} /> - handleChange("frequency_high_hz", e.target.value)} label="High Hz" - sx={{ mr: 1, width: "30%" }} /> - onRemove(name)} color="error"> + onRemove(name)} + color="primary" + disableRipple + sx={{ m: 0, p: 0 }} + > - + ); }; @@ -68,7 +95,7 @@ export const FrequencyRangeList = ({ ranges, onChange }) => { }; return ( - + Frequency Ranges @@ -89,6 +116,6 @@ export const FrequencyRangeList = ({ ranges, onChange }) => { > Add Range - + ); }; From 1b066deacae9b4c8efe9a787f159ec5ec07dd9ec Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:51:38 +0200 Subject: [PATCH 03/25] Disable autocomplete from TextFields --- gui_dev/src/theme.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gui_dev/src/theme.js b/gui_dev/src/theme.js index 5e2b5f11..cc99909c 100644 --- a/gui_dev/src/theme.js +++ b/gui_dev/src/theme.js @@ -22,6 +22,11 @@ export const theme = createTheme({ disableRipple: true, }, }, + MuiTextField: { + defaultProps: { + autoComplete: "off", + }, + }, MuiStack: { defaultProps: { alignItems: "center", From 3cc373c49abd693ce3bc6512746b3526a50896e8 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:51:49 +0200 Subject: [PATCH 04/25] Fix FrequencyRangeList --- gui_dev/src/pages/Settings/FrequencyRange.jsx | 109 +++++++++++++----- gui_dev/src/pages/Settings/Settings.jsx | 56 ++++++--- gui_dev/src/stores/settingsStore.js | 21 +++- gui_dev/src/utils/functions.js | 1 + py_neuromodulation/gui/backend/app_backend.py | 10 +- 5 files changed, 147 insertions(+), 50 deletions(-) diff --git a/gui_dev/src/pages/Settings/FrequencyRange.jsx b/gui_dev/src/pages/Settings/FrequencyRange.jsx index 34c9aaf7..e881b459 100644 --- a/gui_dev/src/pages/Settings/FrequencyRange.jsx +++ b/gui_dev/src/pages/Settings/FrequencyRange.jsx @@ -1,12 +1,7 @@ -import { - TextField, - Button, - Box, - Typography, - IconButton, - Stack, -} from "@mui/material"; +import { useState } from "react"; +import { TextField, Button, IconButton, Stack } from "@mui/material"; import { Add, Close } from "@mui/icons-material"; +import { debounce } from "@/utils"; const NumberField = ({ ...props }) => ( ( /> ); -export const FrequencyRange = ({ name, range, onChange, onRemove }) => { - const handleChange = (field, value) => { - onChange(name, { ...range, [field]: value }); +export const FrequencyRange = ({ + id, + name, + range, + onChangeName, + onChangeRange, + onRemove, +}) => { + const [localName, setLocalName] = useState(name); + + const debouncedChangeName = debounce((newName) => { + onChangeName(newName, name); + }, 1000); + + const handleNameChange = (e) => { + const newName = e.target.value; + setLocalName(newName); + debouncedChangeName(newName); + }; + + const handleNameBlur = () => { + onChangeName(localName, name); + }; + + const handleKeyPress = (e) => { + if (e.key === "Enter") { + console.log(e.target.value, name); + onChangeName(localName, name); + } + }; + + const handleRangeChange = (field, value) => { + onChangeRange(id, { ...range, [field]: value }); }; return ( onChange(e.target.value, range, name)} + onChange={handleNameChange} + onBlur={handleNameBlur} + onKeyPress={handleKeyPress} /> handleChange("frequency_low_hz", e.target.value)} + onChange={(e) => handleRangeChange("frequency_low_hz", e.target.value)} label="Low Hz" /> handleChange("frequency_high_hz", e.target.value)} + onChange={(e) => handleRangeChange("frequency_high_hz", e.target.value)} label="High Hz" /> { ); }; -export const FrequencyRangeList = ({ ranges, onChange }) => { - const handleChange = (newName, newRange, oldName = newName) => { +export const FrequencyRangeList = ({ + ranges, + rangeOrder, + onChange, + onOrderChange, +}) => { + const handleChangeRange = (name, newRange) => { const updatedRanges = { ...ranges }; - if (newName !== oldName) { - delete updatedRanges[oldName]; + updatedRanges[name] = newRange; + onChange(["frequency_ranges_hz"], updatedRanges); + }; + + const handleChangeName = (newName, oldName) => { + if (oldName === newName) { + return; } - updatedRanges[newName] = newRange; + + const updatedRanges = { ...ranges, [newName]: ranges[oldName] }; + delete updatedRanges[oldName]; onChange(["frequency_ranges_hz"], updatedRanges); + + const updatedOrder = rangeOrder.map((name) => + name === oldName ? newName : name + ); + onOrderChange(updatedOrder); }; const handleRemove = (name) => { const updatedRanges = { ...ranges }; delete updatedRanges[name]; onChange(["frequency_ranges_hz"], updatedRanges); + + const updatedOrder = rangeOrder.filter((item) => item !== name); + onOrderChange(updatedOrder); }; const addRange = () => { let newName = "NewRange"; let counter = 0; - while (ranges.hasOwnProperty(newName)) { + // Find first available name + while (Object.hasOwn(ranges, newName)) { counter++; newName = `NewRange${counter}`; } + const updatedRanges = { ...ranges, - [newName]: { frequency_low_hz: "", frequency_high_hz: "" }, + [newName]: { frequency_low_hz: 1, frequency_high_hz: 500 }, }; onChange(["frequency_ranges_hz"], updatedRanges); + + const updatedOrder = [...rangeOrder, newName]; + onOrderChange(updatedOrder); }; return ( - - Frequency Ranges - - {Object.entries(ranges).map(([name, range]) => ( + {rangeOrder.map((name, index) => ( ))} diff --git a/gui_dev/src/pages/Settings/Settings.jsx b/gui_dev/src/pages/Settings/Settings.jsx index 01706d38..188b996c 100644 --- a/gui_dev/src/pages/Settings/Settings.jsx +++ b/gui_dev/src/pages/Settings/Settings.jsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Box, Button, + ButtonGroup, InputAdornment, Stack, Switch, @@ -96,7 +97,6 @@ const SettingsSection = ({ const boxTitle = title ? title : formatKey(path[path.length - 1]); // If we receive a primitive value, we need to render a component - if (typeof settings !== "object") { const Component = componentRegistry[typeof settings]; if (!Component) { @@ -104,10 +104,17 @@ const SettingsSection = ({ return null; } return ( - - {boxTitle} - - + + // + // {boxTitle} + // + // ); } @@ -158,6 +165,12 @@ const SettingsContent = () => { const [selectedFeature, setSelectedFeature] = useState(""); const settings = useSettingsStore((state) => state.settings); const updateSettings = useSettingsStore((state) => state.updateSettings); + const frequencyRangeOrder = useSettingsStore( + (state) => state.frequencyRangeOrder + ); + const updateFrequencyRangeOrder = useSettingsStore( + (state) => state.updateFrequencyRangeOrder + ); if (!settings) { return
Loading settings...
; @@ -209,7 +222,7 @@ const SettingsContent = () => { gap={2} p={2} > - + {generalSettingsKeys.map((key) => ( { @@ -302,15 +317,28 @@ export const Settings = () => { return ( - + + + ); }; diff --git a/gui_dev/src/stores/settingsStore.js b/gui_dev/src/stores/settingsStore.js index f9930d06..7064ec98 100644 --- a/gui_dev/src/stores/settingsStore.js +++ b/gui_dev/src/stores/settingsStore.js @@ -25,11 +25,13 @@ const uploadSettingsToServer = async (settings) => { export const useSettingsStore = createStore("settings", (set, get) => ({ settings: null, + frequencyRangeOrder: [], isLoading: false, error: null, retryCount: 0, setSettings: (settings) => set({ settings }), + setFrequencyRangeOrder: (order) => set({ frequencyRangeOrder: order }), fetchSettingsWithDelay: () => { set({ isLoading: true, error: null }); @@ -46,7 +48,11 @@ export const useSettingsStore = createStore("settings", (set, get) => ({ throw new Error("Failed to fetch settings"); } const data = await response.json(); - set({ settings: data, retryCount: 0 }); + set({ + settings: data, + frequencyRangeOrder: Object.keys(data.frequency_ranges_hz || {}), + retryCount: 0, + }); } catch (error) { console.log("Error fetching settings:", error); set((state) => ({ @@ -65,8 +71,13 @@ export const useSettingsStore = createStore("settings", (set, get) => ({ resetRetryCount: () => set({ retryCount: 0 }), + updateFrequencyRangeOrder: (newOrder) => { + set({ frequencyRangeOrder: newOrder }); + }, + updateSettings: async (updater) => { const currentSettings = get().settings; + const currentOrder = get().frequencyRangeOrder; // Apply the update optimistically set((state) => { @@ -75,18 +86,22 @@ export const useSettingsStore = createStore("settings", (set, get) => ({ const newSettings = get().settings; + // Update the frequency range order + const newOrder = Object.keys(newSettings.frequency_ranges_hz || {}); + set({ frequencyRangeOrder: newOrder }); + try { const result = await uploadSettingsToServer(newSettings); if (!result.success) { // Revert the local state if the server update failed - set({ settings: currentSettings }); + set({ settings: currentSettings, frequencyRangeOrder: currentOrder }); } return result; } catch (error) { // Revert the local state if there was an error - set({ settings: currentSettings }); + set({ settings: currentSettings, frequencyRangeOrder: currentOrder }); throw error; } }, diff --git a/gui_dev/src/utils/functions.js b/gui_dev/src/utils/functions.js index 04289d5c..1f8ae90a 100644 --- a/gui_dev/src/utils/functions.js +++ b/gui_dev/src/utils/functions.js @@ -17,6 +17,7 @@ export function debounce(func, wait) { timeout = setTimeout(later, wait); }; } + export const flattenDictionary = (dict, parentKey = "", result = {}) => { for (let key in dict) { const newKey = parentKey ? `${parentKey}.${key}` : key; diff --git a/py_neuromodulation/gui/backend/app_backend.py b/py_neuromodulation/gui/backend/app_backend.py index 10a8c158..114b9f04 100644 --- a/py_neuromodulation/gui/backend/app_backend.py +++ b/py_neuromodulation/gui/backend/app_backend.py @@ -72,7 +72,9 @@ def __init__( def push_features_to_frontend(self, feature_queue: Queue) -> None: while True: - time.sleep(0.002) # NOTE: should be adapted depending on feature sampling rate + time.sleep( + 0.002 + ) # NOTE: should be adapted depending on feature sampling rate if feature_queue.empty() is False: self.logger.info("data in feature queue") features = feature_queue.get() @@ -98,11 +100,13 @@ async def get_settings(): @self.post("/api/settings") async def update_settings(data: dict): + print(data) try: self.pynm_state.settings = NMSettings.model_validate(data) self.logger.debug(self.pynm_state.settings.features) return self.pynm_state.settings.model_dump() except ValueError as e: + self.logger.error(f"Error updating settings: {e}") raise HTTPException( status_code=422, detail={"error": "Validation failed", "details": str(e)}, @@ -122,7 +126,7 @@ async def handle_stream_control(data: dict): experiment_name=data["experiment_name"], websocket_manager_features=self.websocket_manager_features, ) - + # this also fails due to pickling error # self.push_features_process = Process( # target=self.push_features_to_frontend, @@ -388,5 +392,3 @@ async def websocket_endpoint(websocket: WebSocket): # # Serve the index.html for any path that doesn't match an API route # print(Path.cwd()) # return FileResponse("frontend/index.html") - - From 2518990bf84d4e8fad0d9385284376bdebba09b9 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Mon, 7 Oct 2024 01:09:54 +0200 Subject: [PATCH 05/25] Add subclass of Pydantic Field that carries additional metadata for frontend --- .../processing/normalization.py | 2 +- py_neuromodulation/processing/resample.py | 3 +- .../utils/pydantic_extensions.py | 129 ++++++++++++++++++ py_neuromodulation/utils/types.py | 87 +++--------- 4 files changed, 150 insertions(+), 71 deletions(-) create mode 100644 py_neuromodulation/utils/pydantic_extensions.py diff --git a/py_neuromodulation/processing/normalization.py b/py_neuromodulation/processing/normalization.py index 3345b122..66419cec 100644 --- a/py_neuromodulation/processing/normalization.py +++ b/py_neuromodulation/processing/normalization.py @@ -2,10 +2,10 @@ import numpy as np from typing import TYPE_CHECKING, Callable, Literal, get_args +from pydantic import Field from py_neuromodulation.utils.types import ( NMBaseModel, - Field, NormMethod, NMPreprocessor, ) diff --git a/py_neuromodulation/processing/resample.py b/py_neuromodulation/processing/resample.py index e793a824..73014d3a 100644 --- a/py_neuromodulation/processing/resample.py +++ b/py_neuromodulation/processing/resample.py @@ -1,7 +1,8 @@ """Module for resampling.""" import numpy as np -from py_neuromodulation.utils.types import NMBaseModel, Field, NMPreprocessor +from pydantic import Field +from py_neuromodulation.utils.types import NMBaseModel, NMPreprocessor class ResamplerSettings(NMBaseModel): diff --git a/py_neuromodulation/utils/pydantic_extensions.py b/py_neuromodulation/utils/pydantic_extensions.py new file mode 100644 index 00000000..65d6cb4e --- /dev/null +++ b/py_neuromodulation/utils/pydantic_extensions.py @@ -0,0 +1,129 @@ +from typing import Any, get_type_hints +from pydantic.fields import FieldInfo, _FieldInfoInputs, _FromFieldInfoInputs +from pydantic import BaseModel, ConfigDict, model_serializer +from pydantic_core import PydanticUndefined +from typing_extensions import Unpack, TypedDict +from pprint import pformat + + +class _NMExtraFieldInputs(TypedDict, total=False): + """Additional fields to add on top of the pydantic FieldInfo""" + + meta: dict[str, Any] + + +class _NMFieldInfoInputs(_FieldInfoInputs, _NMExtraFieldInputs, total=False): + """Combine pydantic FieldInfo inputs with PyNM additional inputs""" + + pass + + +class _NMFromFieldInfoInputs(_FromFieldInfoInputs, _NMExtraFieldInputs, total=False): + """Combine pydantic FieldInfo.from_field inputs with PyNM additional inputs""" + + pass + + +class NMFieldInfo(FieldInfo): + # Add default values for any other custom fields here + _default_values = {} + + def __init__(self, **kwargs: Unpack[_NMFieldInfoInputs]) -> None: + extra_fields = get_type_hints(_NMExtraFieldInputs) + for field, field_type in extra_fields.items(): + # If no default value, try to instantiate the field type with no arguments, if it fails, set to None + try: + value = ( + kwargs.pop(field, self._default_values.get(field, field_type())), # type: ignore + ) + except Exception: + value = None + + setattr(self, field, value) + super().__init__(**kwargs) + + @staticmethod + def from_field( + default: Any = PydanticUndefined, + **kwargs: Unpack[_NMFromFieldInfoInputs], + ) -> "NMFieldInfo": + if "annotation" in kwargs: + raise TypeError('"annotation" is not permitted as a Field keyword argument') + return NMFieldInfo(default=default, **kwargs) + + def __repr_args__(self): + yield from super().__repr_args__() + extra_fields = get_type_hints(_NMExtraFieldInputs) + for field in extra_fields: + value = getattr(self, field) + yield field, value + + +def NMField( + default: Any = PydanticUndefined, + **kwargs: Unpack[_NMFromFieldInfoInputs], +) -> Any: + return NMFieldInfo.from_field(default=default, **kwargs) + + +class NMBaseModel(BaseModel): + model_config = ConfigDict(validate_assignment=False, extra="allow") + + def __init__(self, *args, **kwargs) -> None: + if kwargs: + super().__init__(**kwargs) + else: + field_names = list(self.model_fields.keys()) + kwargs = {} + for i in range(len(args)): + kwargs[field_names[i]] = args[i] + super().__init__(**kwargs) + + def __str__(self): + return pformat(self.model_dump()) + + def __repr__(self): + return pformat(self.model_dump()) + + def validate(self) -> Any: # type: ignore + return self.model_validate(self.model_dump()) + + def __getitem__(self, key): + return getattr(self, key) + + def __setitem__(self, key, value) -> None: + setattr(self, key, value) + + @classmethod + def get_fields(cls) -> dict[str, NMFieldInfo]: + return cls.model_fields # type: ignore + + @model_serializer + def serialize_model(self) -> dict[str, Any]: + result = {"__field_type__": self.__class__.__name__} + for field_name, field_info in self.model_fields.items(): + value = getattr(self, field_name) + if isinstance(value, NMBaseModel): + result[field_name] = value.serialize_model() + elif isinstance(value, list): + result[field_name] = [ + item.serialize_model() if isinstance(item, NMBaseModel) else item + for item in value + ] + elif isinstance(value, dict): + result[field_name] = { + k: v.serialize_model() if isinstance(v, NMBaseModel) else v + for k, v in value.items() + } + else: + result[field_name] = value + + # Extract unit information from Annotated type + if get_origin(field_info.annotation) is Annotated: + metadata = get_args(field_info.annotation)[1:] + for item in metadata: + if isinstance(item, dict) and "unit" in item: + result[f"{field_name}_metadata"] = {"unit": item["unit"]} + break + + return result diff --git a/py_neuromodulation/utils/types.py b/py_neuromodulation/utils/types.py index 06a6cf7b..1aad1b08 100644 --- a/py_neuromodulation/utils/types.py +++ b/py_neuromodulation/utils/types.py @@ -1,9 +1,18 @@ from os import PathLike from math import isnan -from typing import Any, Literal, Protocol, TYPE_CHECKING, runtime_checkable -from pydantic import ConfigDict, Field, model_validator, BaseModel +from typing import ( + Literal, + Protocol, + TYPE_CHECKING, + runtime_checkable, +) +from pydantic import ( + BaseModel, + model_validator, +) from pydantic_core import ValidationError, InitErrorDetails -from pprint import pformat +from .pydantic_extensions import NMBaseModel, NMField + from collections.abc import Sequence from datetime import datetime @@ -54,6 +63,7 @@ "minmax", ] + ################################### ######## PROTOCOL CLASSES ######## ################################### @@ -87,68 +97,11 @@ def __init__(self, sfreq: float, settings: "NMSettings") -> None: ... def process(self, data: "np.ndarray") -> "np.ndarray": ... -################################### -######## PYDANTIC CLASSES ######## -################################### - - -class NMBaseModel(BaseModel): - model_config = ConfigDict(validate_assignment=False, extra="allow") - - def __init__(self, *args, **kwargs) -> None: - if kwargs: - super().__init__(**kwargs) - else: - field_names = list(self.model_fields.keys()) - kwargs = {} - for i in range(len(args)): - kwargs[field_names[i]] = args[i] - super().__init__(**kwargs) - - def __str__(self): - return pformat(self.model_dump()) - - def __repr__(self): - return pformat(self.model_dump()) - - def validate(self) -> Any: # type: ignore - return self.model_validate(self.model_dump()) - - def __getitem__(self, key): - return getattr(self, key) - - def __setitem__(self, key, value) -> None: - setattr(self, key, value) - - def process_for_frontend(self) -> dict[str, Any]: - """ - Process the model for frontend use, adding __field_type__ information. - """ - result: dict[str, Any] = {"__field_type__": self.__class__.__name__} - for field_name, field_value in self.__dict__.items(): - if isinstance(field_value, NMBaseModel): - result[field_name] = field_value.process_for_frontend() - elif isinstance(field_value, list): - result[field_name] = [ - item.process_for_frontend() - if isinstance(item, NMBaseModel) - else item - for item in field_value - ] - elif isinstance(field_value, dict): - result[field_name] = { - k: v.process_for_frontend() if isinstance(v, NMBaseModel) else v - for k, v in field_value.items() - } - else: - result[field_name] = field_value - - return result - - class FrequencyRange(NMBaseModel): - frequency_low_hz: float = Field(gt=0) - frequency_high_hz: float = Field(gt=0) + # frequency_low_hz: Annotated[list[float], {"unit": "Hz"}] = Field(gt=0) + # frequency_high_hz: FrequencyHz = Field(gt=0) + frequency_low_hz: float = NMField(gt=0, meta={"unit": "Hz"}) + frequency_high_hz: float = NMField(gt=0, meta={"unit": "Hz"}) def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -236,14 +189,10 @@ def print_all(cls): for f in cls.list_all(): print(f) - @classmethod - def get_fields(cls): - return cls.model_fields - def create_validation_error( error_message: str, - loc: list[str | int] = None, + loc: list[str | int] | None = None, title: str = "Validation Error", input_type: Literal["python", "json"] = "python", hide_input: bool = False, From 166ad98832a28e109c9d8208bed6aad460c2f2cf Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:34:57 +0200 Subject: [PATCH 06/25] display Pydantic errors in Settings page --- gui_dev/bun.lockb | Bin 246999 -> 254737 bytes gui_dev/package.json | 21 +- .../src/components/StatusBar/StatusBar.jsx | 56 ++- gui_dev/src/pages/Settings/Dropdown.jsx | 18 +- gui_dev/src/pages/Settings/FrequencyRange.jsx | 8 +- gui_dev/src/pages/Settings/Settings.jsx | 348 +++++++++++------- gui_dev/src/pages/Settings/index.js | 1 - gui_dev/src/stores/createStore.js | 19 +- gui_dev/src/stores/settingsStore.js | 106 +++--- py_neuromodulation/__init__.py | 5 +- py_neuromodulation/gui/backend/app_backend.py | 62 +++- .../utils/pydantic_extensions.py | 48 +-- py_neuromodulation/utils/types.py | 25 +- pyproject.toml | 5 +- 14 files changed, 441 insertions(+), 281 deletions(-) delete mode 100644 gui_dev/src/pages/Settings/index.js diff --git a/gui_dev/bun.lockb b/gui_dev/bun.lockb index cafed1a34c58bcd17f9621a80286e99fce910eea..929e237a89336bd38e10ff03594c899074767620 100755 GIT binary patch delta 67914 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
    VTk5U~Y5Tp>CvH_K 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 km?ub%7 diff --git a/gui_dev/package.json b/gui_dev/package.json index 1a1cf43f..9329eca5 100644 --- a/gui_dev/package.json +++ b/gui_dev/package.json @@ -17,27 +17,26 @@ "plotly.js-basic-dist-min": "^2.35.2", "react": "next", "react-dom": "next", - "react-icons": "^5.3.0", "react-router-dom": "^6.26.2", "zustand": "next" }, "devDependencies": { - "@babel/core": "^7.25.2", - "@babel/eslint-parser": "^7.25.1", - "@babel/preset-env": "^7.25.4", - "@babel/preset-react": "^7.24.7", - "@eslint/compat": "^1.1.1", - "@vitejs/plugin-react": "^4.3.1", + "@babel/core": "^7.25.7", + "@babel/eslint-parser": "^7.25.7", + "@babel/preset-env": "^7.25.7", + "@babel/preset-react": "^7.25.7", + "@eslint/compat": "^1.2.0", + "@vitejs/plugin-react": "^4.3.2", "@welldone-software/why-did-you-render": "^8.0.3", "babel-plugin-react-compiler": "latest", - "eslint": "^9.10.0", + "eslint": "^9.12.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jsdoc": "^50.2.3", - "eslint-plugin-react": "^7.36.1", + "eslint-plugin-jsdoc": "^50.3.1", + "eslint-plugin-react": "^7.37.1", "eslint-plugin-react-compiler": "latest", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.12", "prettier": "^3.3.3", - "vite": "^5.4.6" + "vite": "^5.4.8" } } diff --git a/gui_dev/src/components/StatusBar/StatusBar.jsx b/gui_dev/src/components/StatusBar/StatusBar.jsx index 36aaf1dc..6bffbe15 100644 --- a/gui_dev/src/components/StatusBar/StatusBar.jsx +++ b/gui_dev/src/components/StatusBar/StatusBar.jsx @@ -1,13 +1,28 @@ +import { useState } from "react"; + import { ResizeHandle } from "./ResizeHandle"; import { SocketStatus } from "./SocketStatus"; import { WebviewStatus } from "./WebviewStatus"; +import { useSettingsStore } from "@/stores"; import { useWebviewStore } from "@/stores"; -import { Stack } from "@mui/material"; +import { Popover, Stack, Typography } from "@mui/material"; export const StatusBar = () => { - const { isWebView } = useWebviewStore((state) => state.isWebView); + const isWebView = useWebviewStore((state) => state.isWebView); + const validationErrors = useSettingsStore((state) => state.validationErrors); + + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleOpenErrorsPopover = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseErrorsPopover = () => { + setAnchorEl(null); + }; return ( { bgcolor="background.level1" borderTop="2px solid" borderColor="background.level3" + height="2rem" > - + {validationErrors?.length > 0 && ( + <> + + {validationErrors?.length} errors found in Settings + + + + + {validationErrors.map((error, index) => ( + + {index} - [{error.type}] {error.msg} + + ))} + + + + )} + {/* */} {/* Current experiment */} {/* Current stream */} {/* Current activity */} diff --git a/gui_dev/src/pages/Settings/Dropdown.jsx b/gui_dev/src/pages/Settings/Dropdown.jsx index ad662eef..9dbc9833 100644 --- a/gui_dev/src/pages/Settings/Dropdown.jsx +++ b/gui_dev/src/pages/Settings/Dropdown.jsx @@ -1,34 +1,30 @@ import { useState } from "react"; -import "../App.css"; var stringJson = '{"sampling_rate_features_hz":10.0,"segment_length_features_ms":1000.0,"frequency_ranges_hz":{"theta":{"frequency_low_hz":4.0,"frequency_high_hz":8.0},"alpha":{"frequency_low_hz":8.0,"frequency_high_hz":12.0},"low beta":{"frequency_low_hz":13.0,"frequency_high_hz":20.0},"high beta":{"frequency_low_hz":20.0,"frequency_high_hz":35.0},"low gamma":{"frequency_low_hz":60.0,"frequency_high_hz":80.0},"high gamma":{"frequency_low_hz":90.0,"frequency_high_hz":200.0},"HFA":{"frequency_low_hz":200.0,"frequency_high_hz":400.0}},"preprocessing":["raw_resampling","notch_filter","re_referencing"],"raw_resampling_settings":{"resample_freq_hz":1000.0},"preprocessing_filter":{"bandstop_filter":true,"bandpass_filter":true,"lowpass_filter":true,"highpass_filter":true,"bandstop_filter_settings":{"frequency_low_hz":100.0,"frequency_high_hz":160.0},"bandpass_filter_settings":{"frequency_low_hz":2.0,"frequency_high_hz":200.0},"lowpass_filter_cutoff_hz":200.0,"highpass_filter_cutoff_hz":3.0},"raw_normalization_settings":{"normalization_time_s":30.0,"normalization_method":"zscore","clip":3.0},"postprocessing":{"feature_normalization":true,"project_cortex":false,"project_subcortex":false},"feature_normalization_settings":{"normalization_time_s":30.0,"normalization_method":"zscore","clip":3.0},"project_cortex_settings":{"max_dist_mm":20.0},"project_subcortex_settings":{"max_dist_mm":5.0},"features":{"raw_hjorth":true,"return_raw":true,"bandpass_filter":false,"stft":false,"fft":true,"welch":true,"sharpwave_analysis":true,"fooof":false,"nolds":false,"coherence":false,"bursts":true,"linelength":true,"mne_connectivity":false,"bispectrum":false},"fft_settings":{"windowlength_ms":1000,"log_transform":true,"features":{"mean":true,"median":false,"std":false,"max":false},"return_spectrum":false},"welch_settings":{"windowlength_ms":1000,"log_transform":true,"features":{"mean":true,"median":false,"std":false,"max":false},"return_spectrum":false},"stft_settings":{"windowlength_ms":1000,"log_transform":true,"features":{"mean":true,"median":false,"std":false,"max":false},"return_spectrum":false},"bandpass_filter_settings":{"segment_lengths_ms":{"theta":1000,"alpha":500,"low beta":333,"high beta":333,"low gamma":100,"high gamma":100,"HFA":100},"bandpower_features":{"activity":true,"mobility":false,"complexity":false},"log_transform":true,"kalman_filter":false},"kalman_filter_settings":{"Tp":0.1,"sigma_w":0.7,"sigma_v":1.0,"frequency_bands":["theta","alpha","low_beta","high_beta","low_gamma","high_gamma","HFA"]},"burst_settings":{"threshold":75.0,"time_duration_s":30.0,"frequency_bands":["low beta","high beta","low gamma"],"burst_features":{"duration":true,"amplitude":true,"burst_rate_per_s":true,"in_burst":true}},"sharpwave_analysis_settings":{"sharpwave_features":{"peak_left":false,"peak_right":false,"trough":false,"width":false,"prominence":true,"interval":true,"decay_time":false,"rise_time":false,"sharpness":true,"rise_steepness":false,"decay_steepness":false,"slope_ratio":false},"filter_ranges_hz":[{"frequency_low_hz":5.0,"frequency_high_hz":80.0},{"frequency_low_hz":5.0,"frequency_high_hz":30.0}],"detect_troughs":{"estimate":true,"distance_troughs_ms":10.0,"distance_peaks_ms":5.0},"detect_peaks":{"estimate":true,"distance_troughs_ms":10.0,"distance_peaks_ms":5.0},"estimator":{"mean":["interval"],"median":[],"max":["prominence","sharpness"],"min":[],"var":[]},"apply_estimator_between_peaks_and_troughs":true},"mne_connectivity":{"method":"plv","mode":"multitaper"},"coherence":{"features":{"mean_fband":true,"max_fband":true,"max_allfbands":true},"method":{"coh":true,"icoh":true},"channels":[],"frequency_bands":["high beta"]},"fooof":{"aperiodic":{"exponent":true,"offset":true,"knee":true},"periodic":{"center_frequency":false,"band_width":false,"height_over_ap":false},"windowlength_ms":800.0,"peak_width_limits":{"frequency_low_hz":0.5,"frequency_high_hz":12.0},"max_n_peaks":3,"min_peak_height":0.0,"peak_threshold":2.0,"freq_range_hz":{"frequency_low_hz":2.0,"frequency_high_hz":40.0},"knee":true},"nolds_features":{"raw":true,"frequency_bands":["low beta"],"features":{"sample_entropy":false,"correlation_dimension":false,"lyapunov_exponent":true,"hurst_exponent":false,"detrended_fluctuation_analysis":false}},"bispectrum":{"f1s":{"frequency_low_hz":5.0,"frequency_high_hz":35.0},"f2s":{"frequency_low_hz":5.0,"frequency_high_hz":35.0},"compute_features_for_whole_fband_range":true,"frequency_bands":["theta","alpha","low_beta","high_beta"],"components":{"absolute":true,"real":true,"imag":true,"phase":true},"bispectrum_features":{"mean":true,"sum":true,"var":true}}}'; const nm_settings = JSON.parse(stringJson); -const filterByKeys = (dict, keys) => { +const filterByKeys = (obj, keys) => { const filteredDict = {}; keys.forEach((key) => { if (typeof key === "string") { // Top-level key - if (dict.hasOwnProperty(key)) { - filteredDict[key] = dict[key]; + if (obj.hasOwn(key)) { + filteredDict[key] = obj[key]; } } else if (typeof key === "object") { // Nested key const topLevelKey = Object.keys(key)[0]; const nestedKeys = key[topLevelKey]; - if ( - dict.hasOwnProperty(topLevelKey) && - typeof dict[topLevelKey] === "object" - ) { - filteredDict[topLevelKey] = filterByKeys(dict[topLevelKey], nestedKeys); + if (obj.hasOwn(topLevelKey) && typeof obj[topLevelKey] === "object") { + filteredDict[topLevelKey] = filterByKeys(obj[topLevelKey], nestedKeys); } } }); return filteredDict; }; -const Dropdown = ({ onChange, keysToInclude }) => { +export const Dropdown = ({ onChange, keysToInclude }) => { const filteredSettings = filterByKeys(nm_settings, keysToInclude); const [selectedOption, setSelectedOption] = useState(""); @@ -62,5 +58,3 @@ const Dropdown = ({ onChange, keysToInclude }) => { ); }; - -export default Dropdown; diff --git a/gui_dev/src/pages/Settings/FrequencyRange.jsx b/gui_dev/src/pages/Settings/FrequencyRange.jsx index e881b459..83ee09d5 100644 --- a/gui_dev/src/pages/Settings/FrequencyRange.jsx +++ b/gui_dev/src/pages/Settings/FrequencyRange.jsx @@ -138,7 +138,11 @@ export const FrequencyRangeList = ({ const updatedRanges = { ...ranges, - [newName]: { frequency_low_hz: 1, frequency_high_hz: 500 }, + [newName]: { + __field_type__: "FrequencyRange", + frequency_low_hz: 1, + frequency_high_hz: 500, + }, }; onChange(["frequency_ranges_hz"], updatedRanges); @@ -147,7 +151,7 @@ export const FrequencyRangeList = ({ }; return ( - + {rangeOrder.map((name, index) => ( { - // console.log(key); return key .split("_") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) @@ -24,15 +25,32 @@ const formatKey = (key) => { }; // Wrapper components for each type -const BooleanField = ({ value, onChange }) => ( +const BooleanField = ({ value, onChange, error }) => ( onChange(e.target.checked)} /> ); -const StringField = ({ value, onChange, label }) => ( - +const StringField = ({ value, onChange, label, error }) => ( + onChange(e.target.value)} + label={label} + sx={{ + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: error ? "error.main" : "inherit", + }, + "&:hover fieldset": { + borderColor: error ? "error.main" : "primary.main", + }, + "&.Mui-focused fieldset": { + borderColor: error ? "error.main" : "primary.main", + }, + }, + }} + /> ); -const NumberField = ({ value, onChange, label }) => { +const NumberField = ({ value, onChange, label, error }) => { const handleChange = (event) => { const newValue = event.target.value; // Only allow numbers and decimal point @@ -47,6 +65,19 @@ const NumberField = ({ value, onChange, label }) => { value={value} onChange={handleChange} label={label} + sx={{ + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: error ? "error.main" : "inherit", + }, + "&:hover fieldset": { + borderColor: error ? "error.main" : "primary.main", + }, + "&.Mui-focused fieldset": { + borderColor: error ? "error.main" : "primary.main", + }, + }, + }} // InputProps={{ // endAdornment: ( // @@ -66,24 +97,22 @@ const componentRegistry = { boolean: BooleanField, string: StringField, number: NumberField, + Array: Dropdown, }; -const SettingsField = ({ path, Component, label, value, onChange, depth }) => { +const SettingsField = ({ path, Component, label, value, onChange, error }) => { return ( - - {label} - onChange(path, newValue)} - label={label} - /> - + + + {label} + onChange(path, newValue)} + label={label} + error={error} + /> + + ); }; @@ -92,10 +121,23 @@ const SettingsSection = ({ title = null, path = [], onChange, - depth = 0, + errors, }) => { const boxTitle = title ? title : formatKey(path[path.length - 1]); + // Function to get the error corresponding to this field or its children + const getFieldError = (fieldPath) => { + if (!errors) return null; + + return errors.find((error) => { + const errorPath = error.loc.join("."); + const currentPath = fieldPath.join("."); + return ( + errorPath === currentPath || errorPath.startsWith(currentPath + ".") + ); + }); + }; + // If we receive a primitive value, we need to render a component if (typeof settings !== "object") { const Component = componentRegistry[typeof settings]; @@ -103,32 +145,35 @@ const SettingsSection = ({ console.error(`Invalid component type: ${typeof settings}`); return null; } + + const error = getFieldError(path); + return ( - // - // {boxTitle} - // - // ); } // If we receive a nested object, we iterate over it and render recursively return ( - + <> {Object.entries(settings).map(([key, value]) => { if (key === "__field_type__") return null; + if (value === null) return null; const newPath = [...path, key]; const label = key; const isPydanticModel = typeof value === "object" && "__field_type__" in value; + const error = getFieldError(newPath); + const fieldType = isPydanticModel ? value.__field_type__ : typeof value; const Component = componentRegistry[fieldType]; @@ -142,29 +187,34 @@ const SettingsSection = ({ label={formatKey(label)} value={value} onChange={onChange} - depth={depth + 1} + error={error} /> ); } else { return ( - + + + ); } })} - + ); }; -const SettingsContent = () => { - const [selectedFeature, setSelectedFeature] = useState(""); +export const Settings = () => { + // Get all necessary state from the settings store const settings = useSettingsStore((state) => state.settings); - const updateSettings = useSettingsStore((state) => state.updateSettings); + const uploadSettings = useSettingsStore((state) => state.uploadSettings); + const resetSettings = useSettingsStore((state) => state.resetSettings); + const validationErrors = useSettingsStore((state) => state.validationErrors); + + // This is needed so that the frequency ranges stay in order between updates const frequencyRangeOrder = useSettingsStore( (state) => state.frequencyRangeOrder ); @@ -172,20 +222,35 @@ const SettingsContent = () => { (state) => state.updateFrequencyRangeOrder ); + // Here I handle the selected feature in the feature settings component + const [selectedFeature, setSelectedFeature] = useState(""); + + useEffect(() => { + uploadSettings(null, true); // validateOnly = true + }, [settings]); + + // This has to be after all the hooks, otherwise React will complain if (!settings) { return

    Loading settings...
    ; } - console.log(settings); - - const handleChange = (path, value) => { - updateSettings((settings) => { + // This are the callbacks for the different buttons + const handleChangeSettings = async (path, value) => { + uploadSettings((settings) => { let current = settings; for (let i = 0; i < path.length - 1; i++) { current = current[path[i]]; } current[path[path.length - 1]] = value; - }); + }, true); // validateOnly = true + }; + + const handleSaveSettings = () => { + uploadSettings(() => settings); + }; + + const handleResetSettings = async () => { + await resetSettings(); }; const featureSettingsKeys = Object.keys(settings.features) @@ -214,109 +279,102 @@ const SettingsContent = () => { ]; return ( - - - - {generalSettingsKeys.map((key) => ( + + {/* SETTINGS LAYOUT */} + + {/* GENERAL SETTINGS + FREQUENCY RANGES */} + + + {generalSettingsKeys.map((key) => ( + + ))} + + + + + + + + {/* POSTPROCESSING + PREPROCESSING SETTINGS */} + + {preprocessingSettingsKeys.map((key) => ( ))} - - - - - - - {preprocessingSettingsKeys.map((key) => ( - - ))} - - - - {postprocessingSettingsKeys.map((key) => ( - - ))} - - - - - + + {postprocessingSettingsKeys.map((key) => ( - - - {Object.entries(enabledFeatures).map( - ([feature, featureSettings]) => ( - - - - ) - )} + ))} + + + {/* FEATURE SETTINGS */} + + + + + + + {Object.entries(enabledFeatures).map( + ([feature, featureSettings]) => ( + + + + ) + )} + - - - - ); -}; + + {/* END SETTINGS LAYOUT */} + -export const Settings = () => { - return ( - - + {/* BUTTONS */} { borderColor={"divider"} p={1} > - + {/* */} + diff --git a/gui_dev/src/pages/Settings/index.js b/gui_dev/src/pages/Settings/index.js deleted file mode 100644 index 16355023..00000000 --- a/gui_dev/src/pages/Settings/index.js +++ /dev/null @@ -1 +0,0 @@ -export { TextField } from './TextField'; diff --git a/gui_dev/src/stores/createStore.js b/gui_dev/src/stores/createStore.js index 762d9340..e683a35c 100644 --- a/gui_dev/src/stores/createStore.js +++ b/gui_dev/src/stores/createStore.js @@ -2,16 +2,23 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { devtools, persist as persistMiddleware } from "zustand/middleware"; -export const createStore = (name, initializer, persist = false) => { +export const createStore = ( + name, + initializer, + persist = false, + dev = false +) => { const fn = persist ? persistMiddleware(immer(initializer), name) : immer(initializer); - return create( - devtools(fn, { - name: name, - }) - ); + const dev_fn = dev + ? devtools(fn, { + name: name, + }) + : fn; + + return create(dev_fn); }; export const createPersistStore = (name, initializer) => { diff --git a/gui_dev/src/stores/settingsStore.js b/gui_dev/src/stores/settingsStore.js index 7064ec98..634c445f 100644 --- a/gui_dev/src/stores/settingsStore.js +++ b/gui_dev/src/stores/settingsStore.js @@ -4,34 +4,18 @@ const INITIAL_DELAY = 3000; // wait for Flask const RETRY_DELAY = 1000; // ms const MAX_RETRIES = 100; -const uploadSettingsToServer = async (settings) => { - try { - const response = await fetch("/api/settings", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(settings), - }); - if (!response.ok) { - throw new Error("Failed to update settings"); - } - return { success: true }; - } catch (error) { - console.error("Error updating settings:", error); - return { success: false, error }; - } -}; - export const useSettingsStore = createStore("settings", (set, get) => ({ settings: null, + lastValidSettings: null, frequencyRangeOrder: [], isLoading: false, error: null, + validationErrors: null, retryCount: 0, - setSettings: (settings) => set({ settings }), - setFrequencyRangeOrder: (order) => set({ frequencyRangeOrder: order }), + updateLocalSettings: (updater) => { + set((state) => updater(state.settings)); + }, fetchSettingsWithDelay: () => { set({ isLoading: true, error: null }); @@ -40,16 +24,22 @@ export const useSettingsStore = createStore("settings", (set, get) => ({ }, INITIAL_DELAY); }, - fetchSettings: async () => { + fetchSettings: async (reset = false) => { + console.log("Fetching settings..."); try { - console.log("Fetching settings..."); - const response = await fetch("/api/settings"); + const response = await fetch( + `/api/settings${reset ? "?reset=true" : ""}` + ); + if (!response.ok) { throw new Error("Failed to fetch settings"); } + const data = await response.json(); + set({ settings: data, + lastValidSettings: data, frequencyRangeOrder: Object.keys(data.frequency_ranges_hz || {}), retryCount: 0, }); @@ -60,6 +50,8 @@ export const useSettingsStore = createStore("settings", (set, get) => ({ retryCount: state.retryCount + 1, })); + console.log(get().retryCount); + if (get().retryCount < MAX_RETRIES) { await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); return get().fetchSettings(); @@ -71,38 +63,62 @@ export const useSettingsStore = createStore("settings", (set, get) => ({ resetRetryCount: () => set({ retryCount: 0 }), + resetSettings: async () => { + await get().fetchSettings(true); + }, + updateFrequencyRangeOrder: (newOrder) => { set({ frequencyRangeOrder: newOrder }); }, - updateSettings: async (updater) => { - const currentSettings = get().settings; - const currentOrder = get().frequencyRangeOrder; - - // Apply the update optimistically - set((state) => { - updater(state.settings); - }); - - const newSettings = get().settings; + uploadSettings: async (updater, validateOnly = false) => { + if (updater) { + set((state) => { + updater(state.settings); + }); + } - // Update the frequency range order - const newOrder = Object.keys(newSettings.frequency_ranges_hz || {}); - set({ frequencyRangeOrder: newOrder }); + const currentSettings = get().settings; try { - const result = await uploadSettingsToServer(newSettings); + const response = await fetch( + `/api/settings${validateOnly ? "?validate_only=true" : ""}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(currentSettings), + } + ); - if (!result.success) { - // Revert the local state if the server update failed - set({ settings: currentSettings, frequencyRangeOrder: currentOrder }); + const data = await response.json(); + + if (!response.ok) { + throw new Error("Failed to upload settings to backend"); } - return result; + if (data.valid) { + // Settings are valid + set({ + lastValidSettings: currentSettings, + validationErrors: null, + }); + return true; + } else { + // Settings are invalid + set({ + validationErrors: data.errors, + }); + // Note: We don't revert the settings here, keeping the potentially invalid state + return false; + } } catch (error) { - // Revert the local state if there was an error - set({ settings: currentSettings, frequencyRangeOrder: currentOrder }); - throw error; + console.error( + `Error ${validateOnly ? "validating" : "updating"} settings:`, + error + ); + return false; } }, })); diff --git a/py_neuromodulation/__init__.py b/py_neuromodulation/__init__.py index 688393fd..864df345 100644 --- a/py_neuromodulation/__init__.py +++ b/py_neuromodulation/__init__.py @@ -4,11 +4,12 @@ from importlib.metadata import version from py_neuromodulation.utils.logging import NMLogger + ##################################### # Globals and environment variables # ##################################### -__version__ = version("py_neuromodulation") # get version from pyproject.toml +__version__ = version("py_neuromodulation") # Check if the module is running headless (no display) for tests and doc builds PYNM_HEADLESS: bool = not os.environ.get("DISPLAY") @@ -18,6 +19,7 @@ os.environ["LSLAPICFG"] = str(PYNM_DIR / "lsl_api.cfg") # LSL config file + # Set environment variable MNE_LSL_LIB (required to import Stream below) LSL_DICT = { "windows_32bit": "windows/x86/liblsl.1.16.2.dll", @@ -36,6 +38,7 @@ PLATFORM = platform.system().lower().strip() ARCH = platform.architecture()[0] + match PLATFORM: case "windows": KEY = PLATFORM + "_" + ARCH diff --git a/py_neuromodulation/gui/backend/app_backend.py b/py_neuromodulation/gui/backend/app_backend.py index 114b9f04..9c7b0a88 100644 --- a/py_neuromodulation/gui/backend/app_backend.py +++ b/py_neuromodulation/gui/backend/app_backend.py @@ -15,6 +15,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from pydantic import ValidationError from . import app_pynm from .app_socket import WebSocketManager @@ -93,23 +94,62 @@ async def healthcheck(): #################### ##### SETTINGS ##### #################### - @self.get("/api/settings") - async def get_settings(): - return self.pynm_state.settings.process_for_frontend() + async def get_settings( + reset: bool = Query(False, description="Reset settings to default"), + ): + if reset: + settings = NMSettings.get_default() + else: + settings = self.pynm_state.settings + + return settings.serialize_with_metadata() @self.post("/api/settings") - async def update_settings(data: dict): - print(data) + async def update_settings(data: dict, validate_only: bool = Query(False)): try: - self.pynm_state.settings = NMSettings.model_validate(data) - self.logger.debug(self.pynm_state.settings.features) - return self.pynm_state.settings.model_dump() - except ValueError as e: - self.logger.error(f"Error updating settings: {e}") + # First, validate with Pydantic + try: + validated_settings = NMSettings.model_validate(data) + except ValidationError as e: + if not validate_only: + # If validation failed but we wanted to upload, return error + self.logger.error(f"Error validating settings: {e}") + raise HTTPException( + status_code=422, + detail={ + "error": "Error validating settings", + "details": str(e), + }, + ) + # Else return list of errors + return { + "valid": False, + "errors": [err for err in e.errors()], + "details": str(e), + } + + # If validation succesful, return or update settings + if validate_only: + return { + "valid": True, + "settings": validated_settings.serialize_with_metadata(), + } + + self.pynm_state.settings = validated_settings + self.logger.info("Settings successfully updated") + + return { + "valid": True, + "settings": self.pynm_state.settings.serialize_with_metadata(), + } + + # If something else than validation went wrong, return error + except Exception as e: + self.logger.error(f"Error validating/updating settings: {e}") raise HTTPException( status_code=422, - detail={"error": "Validation failed", "details": str(e)}, + detail={"error": "Error uploading settings", "details": str(e)}, ) ######################## diff --git a/py_neuromodulation/utils/pydantic_extensions.py b/py_neuromodulation/utils/pydantic_extensions.py index 65d6cb4e..556f751c 100644 --- a/py_neuromodulation/utils/pydantic_extensions.py +++ b/py_neuromodulation/utils/pydantic_extensions.py @@ -1,6 +1,6 @@ from typing import Any, get_type_hints from pydantic.fields import FieldInfo, _FieldInfoInputs, _FromFieldInfoInputs -from pydantic import BaseModel, ConfigDict, model_serializer +from pydantic import BaseModel, ConfigDict, SerializationInfo, model_serializer from pydantic_core import PydanticUndefined from typing_extensions import Unpack, TypedDict from pprint import pformat @@ -9,7 +9,7 @@ class _NMExtraFieldInputs(TypedDict, total=False): """Additional fields to add on top of the pydantic FieldInfo""" - meta: dict[str, Any] + custom_metadata: dict[str, Any] class _NMFieldInfoInputs(_FieldInfoInputs, _NMExtraFieldInputs, total=False): @@ -29,17 +29,7 @@ class NMFieldInfo(FieldInfo): _default_values = {} def __init__(self, **kwargs: Unpack[_NMFieldInfoInputs]) -> None: - extra_fields = get_type_hints(_NMExtraFieldInputs) - for field, field_type in extra_fields.items(): - # If no default value, try to instantiate the field type with no arguments, if it fails, set to None - try: - value = ( - kwargs.pop(field, self._default_values.get(field, field_type())), # type: ignore - ) - except Exception: - value = None - - setattr(self, field, value) + self.custom_metadata = kwargs.pop("custom_metadata", {}) super().__init__(**kwargs) @staticmethod @@ -94,36 +84,34 @@ def __getitem__(self, key): def __setitem__(self, key, value) -> None: setattr(self, key, value) - @classmethod - def get_fields(cls) -> dict[str, NMFieldInfo]: - return cls.model_fields # type: ignore + @property + def fields(self) -> dict[str, FieldInfo | NMFieldInfo]: + return self.model_fields # type: ignore + + def serialize_with_metadata(self): + result: dict[str, Any] = {"__field_type__": self.__class__.__name__} - @model_serializer - def serialize_model(self) -> dict[str, Any]: - result = {"__field_type__": self.__class__.__name__} - for field_name, field_info in self.model_fields.items(): + for field_name, field_info in self.fields.items(): value = getattr(self, field_name) if isinstance(value, NMBaseModel): - result[field_name] = value.serialize_model() + result[field_name] = value.serialize_with_metadata() elif isinstance(value, list): result[field_name] = [ - item.serialize_model() if isinstance(item, NMBaseModel) else item + item.serialize_with_metadata() + if isinstance(item, NMBaseModel) + else item for item in value ] elif isinstance(value, dict): result[field_name] = { - k: v.serialize_model() if isinstance(v, NMBaseModel) else v + k: v.serialize_with_metadata() if isinstance(v, NMBaseModel) else v for k, v in value.items() } else: result[field_name] = value # Extract unit information from Annotated type - if get_origin(field_info.annotation) is Annotated: - metadata = get_args(field_info.annotation)[1:] - for item in metadata: - if isinstance(item, dict) and "unit" in item: - result[f"{field_name}_metadata"] = {"unit": item["unit"]} - break - + if isinstance(field_info, NMFieldInfo): + for tag, value in field_info.custom_metadata.items(): + result[f"__{tag}__"] = value return result diff --git a/py_neuromodulation/utils/types.py b/py_neuromodulation/utils/types.py index 1aad1b08..f5921281 100644 --- a/py_neuromodulation/utils/types.py +++ b/py_neuromodulation/utils/types.py @@ -1,21 +1,14 @@ from os import PathLike from math import isnan -from typing import ( - Literal, - Protocol, - TYPE_CHECKING, - runtime_checkable, -) -from pydantic import ( - BaseModel, - model_validator, -) +from typing import Literal, TYPE_CHECKING +from pydantic import BaseModel, model_validator from pydantic_core import ValidationError, InitErrorDetails from .pydantic_extensions import NMBaseModel, NMField from collections.abc import Sequence from datetime import datetime + if TYPE_CHECKING: import numpy as np from py_neuromodulation import NMSettings @@ -26,7 +19,6 @@ _PathLike = str | PathLike - FeatureName = Literal[ "raw_hjorth", "return_raw", @@ -69,8 +61,7 @@ ################################### -@runtime_checkable -class NMFeature(Protocol): +class NMFeature: def __init__( self, settings: "NMSettings", ch_names: Sequence[str], sfreq: int | float ) -> None: ... @@ -91,17 +82,15 @@ def calc_feature(self, data: "np.ndarray") -> dict: ... -class NMPreprocessor(Protocol): - def __init__(self, sfreq: float, settings: "NMSettings") -> None: ... - +class NMPreprocessor: def process(self, data: "np.ndarray") -> "np.ndarray": ... class FrequencyRange(NMBaseModel): # frequency_low_hz: Annotated[list[float], {"unit": "Hz"}] = Field(gt=0) # frequency_high_hz: FrequencyHz = Field(gt=0) - frequency_low_hz: float = NMField(gt=0, meta={"unit": "Hz"}) - frequency_high_hz: float = NMField(gt=0, meta={"unit": "Hz"}) + frequency_low_hz: float = NMField(gt=0, custom_metadata={"unit": "Hz"}) + frequency_high_hz: float = NMField(gt=0, custom_metadata={"unit": "Hz"}) def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index 095dcf80..395b003b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ dependencies = [ "nolds", "numpy >= 1.21.2", "pandas >= 2.0.0", - "scikit-image", "scikit-learn >= 0.24.2", "scikit-optimize", "scipy >= 1.7.1", @@ -55,12 +54,10 @@ dependencies = [ "statsmodels", "mne-lsl>=1.2.0", "pynput", - "pyqt5", "pydantic>=2.7.3", "pywebview", "fastapi", - "uvicorn>=0.30.6", - "websockets>=13.0", + "uvicorn[standard]>=0.30.6", "seaborn >= 0.11", ] From 446b194e11c4385aef8ff3b80c4b183d4e26e4a1 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Mon, 14 Oct 2024 14:00:24 +0200 Subject: [PATCH 07/25] Refactor SettingsSection recursive rendering component --- .../src/components/StatusBar/StatusBar.jsx | 55 +----- gui_dev/src/pages/Settings/Settings.jsx | 175 ++++++++++++------ gui_dev/src/stores/uiStore.js | 21 +++ 3 files changed, 147 insertions(+), 104 deletions(-) diff --git a/gui_dev/src/components/StatusBar/StatusBar.jsx b/gui_dev/src/components/StatusBar/StatusBar.jsx index 6bffbe15..f011e2b9 100644 --- a/gui_dev/src/components/StatusBar/StatusBar.jsx +++ b/gui_dev/src/components/StatusBar/StatusBar.jsx @@ -1,28 +1,15 @@ -import { useState } from "react"; - import { ResizeHandle } from "./ResizeHandle"; import { SocketStatus } from "./SocketStatus"; import { WebviewStatus } from "./WebviewStatus"; -import { useSettingsStore } from "@/stores"; - -import { useWebviewStore } from "@/stores"; -import { Popover, Stack, Typography } from "@mui/material"; +import { useUiStore, useWebviewStore } from "@/stores"; +import { Stack } from "@mui/material"; export const StatusBar = () => { const isWebView = useWebviewStore((state) => state.isWebView); - const validationErrors = useSettingsStore((state) => state.validationErrors); - - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - - const handleOpenErrorsPopover = (event) => { - setAnchorEl(event.currentTarget); - }; + const createStatusBarContent = useUiStore((state) => state.statusBarContent); - const handleCloseErrorsPopover = () => { - setAnchorEl(null); - }; + const StatusBarContent = createStatusBarContent(); return ( { borderColor="background.level3" height="2rem" > - {validationErrors?.length > 0 && ( - <> - - {validationErrors?.length} errors found in Settings - + {StatusBarContent && } - - - {validationErrors.map((error, index) => ( - - {index} - [{error.type}] {error.msg} - - ))} - - - - )} {/* */} {/* Current experiment */} {/* Current stream */} diff --git a/gui_dev/src/pages/Settings/Settings.jsx b/gui_dev/src/pages/Settings/Settings.jsx index 839a89ad..60b238d6 100644 --- a/gui_dev/src/pages/Settings/Settings.jsx +++ b/gui_dev/src/pages/Settings/Settings.jsx @@ -4,6 +4,7 @@ import { Button, ButtonGroup, InputAdornment, + Popover, Stack, Switch, TextField, @@ -14,7 +15,7 @@ import { Link } from "react-router-dom"; import { CollapsibleBox, TitledBox } from "@/components"; import { FrequencyRangeList } from "./FrequencyRange"; import { Dropdown } from "./Dropdown"; -import { useSettingsStore } from "@/stores"; +import { useSettingsStore, useStatusBarContent } from "@/stores"; import { filterObjectByKeys } from "@/utils/functions"; const formatKey = (key) => { @@ -116,6 +117,17 @@ const SettingsField = ({ path, Component, label, value, onChange, error }) => { ); }; +// Function to get the error corresponding to this field or its children +const getFieldError = (fieldPath, errors) => { + if (!errors) return null; + + return errors.find((error) => { + const errorPath = error.loc.join("."); + const currentPath = fieldPath.join("."); + return errorPath === currentPath || errorPath.startsWith(currentPath + "."); + }); +}; + const SettingsSection = ({ settings, title = null, @@ -124,29 +136,32 @@ const SettingsSection = ({ errors, }) => { const boxTitle = title ? title : formatKey(path[path.length - 1]); + /* + 3 possible cases: + 1. Primitive type || 2. Object with component -> Don't iterate, render directly + 3. Object without component or 4. Array -> Iterate and render recursively + */ + + const type = typeof settings; + const isObject = type === "object" && !Array.isArray(settings); + const isArray = Array.isArray(settings); + + // __field_type__ should be always present + if (isObject && !settings.__field_type__) { + console.log(settings); + throw new Error("Invalid settings object"); + } + const fieldType = isObject ? settings.__field_type__ : type; + const Component = componentRegistry[fieldType]; - // Function to get the error corresponding to this field or its children - const getFieldError = (fieldPath) => { - if (!errors) return null; - - return errors.find((error) => { - const errorPath = error.loc.join("."); - const currentPath = fieldPath.join("."); - return ( - errorPath === currentPath || errorPath.startsWith(currentPath + ".") - ); - }); - }; - - // If we receive a primitive value, we need to render a component - if (typeof settings !== "object") { - const Component = componentRegistry[typeof settings]; + // Case 1: Primitive type -> Don't iterate, render directly + if (!isObject && !isArray) { if (!Component) { - console.error(`Invalid component type: ${typeof settings}`); + console.error(`Invalid component type: ${type}`); return null; } - const error = getFieldError(path); + const error = getFieldError(path, errors); return ( - {Object.entries(settings).map(([key, value]) => { - if (key === "__field_type__") return null; - if (value === null) return null; - - const newPath = [...path, key]; - const label = key; - const isPydanticModel = - typeof value === "object" && "__field_type__" in value; - - const error = getFieldError(newPath); + // Case 2: Object with component -> Don't iterate, render directly + if (isObject && Component) { + return ( + + ); + } - const fieldType = isPydanticModel ? value.__field_type__ : typeof value; + // Case 3: Object without component or 4. Array -> Iterate and render recursively + if ((isObject && !Component) || isArray) { + return ( + + {/* Handle recursing through both objects and arrays */} + {(isArray ? settings : Object.entries(settings)).map((item, index) => { + const [key, value] = isArray ? [index.toString(), item] : item; + if (key.startsWith("__")) return null; // Skip metadata fields - const Component = componentRegistry[fieldType]; + const newPath = [...path, key]; - if (Component) { return ( - ); - } else { - return ( - - - - ); - } - })} + })} + + ); + } + + // Default case: return null and log an error + console.error(`Invalid settings object, returning null`); + return null; +}; + +const StatusBarSettingsInfo = () => { + const validationErrors = useSettingsStore((state) => state.validationErrors); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleOpenErrorsPopover = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseErrorsPopover = () => { + setAnchorEl(null); + }; + + return ( + <> + {validationErrors?.length > 0 && ( + <> + + {validationErrors?.length} errors found in Settings + + + + {validationErrors.map((error, index) => ( + + {index} - [{error.type}] {error.msg} + + ))} + + + + )} ); }; @@ -213,6 +277,7 @@ export const Settings = () => { const uploadSettings = useSettingsStore((state) => state.uploadSettings); const resetSettings = useSettingsStore((state) => state.resetSettings); const validationErrors = useSettingsStore((state) => state.validationErrors); + useStatusBarContent(StatusBarSettingsInfo); // This is needed so that the frequency ranges stay in order between updates const frequencyRangeOrder = useSettingsStore( @@ -229,6 +294,8 @@ export const Settings = () => { uploadSettings(null, true); // validateOnly = true }, [settings]); + // Inject validation error info into status bar + // This has to be after all the hooks, otherwise React will complain if (!settings) { return
    Loading settings...
    ; diff --git a/gui_dev/src/stores/uiStore.js b/gui_dev/src/stores/uiStore.js index a229ec06..25b44382 100644 --- a/gui_dev/src/stores/uiStore.js +++ b/gui_dev/src/stores/uiStore.js @@ -1,4 +1,5 @@ import { createPersistStore } from "./createStore"; +import { useEffect } from "react"; export const useUiStore = createPersistStore("ui", (set, get) => ({ activeDrawer: null, @@ -28,4 +29,24 @@ export const useUiStore = createPersistStore("ui", (set, get) => ({ state.accordionStates[id] = defaultState; } }), + + // Hook to inject UI elements into the status bar + statusBarContent: () => {}, + setStatusBarContent: (content) => set({ statusBarContent: content }), + clearStatusBarContent: () => set({ statusBarContent: null }), })); + +// Use this hook from Page components to inject page-specific UI elements into the status bar +export const useStatusBarContent = (content) => { + const createStatusBarContent = () => content; + + const setStatusBarContent = useUiStore((state) => state.setStatusBarContent); + const clearStatusBarContent = useUiStore( + (state) => state.clearStatusBarContent + ); + + useEffect(() => { + setStatusBarContent(createStatusBarContent); + return () => clearStatusBarContent(); + }, [content, setStatusBarContent, clearStatusBarContent]); +}; From 820e80bc517524ca70e74704e8dd3a73700627a8 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:28:40 +0100 Subject: [PATCH 08/25] delete unused components --- .../src/pages/Settings/DragAndDropList.jsx | 65 ------- .../pages/Settings/DragAndDropList.module.css | 72 ------- gui_dev/src/pages/Settings/Dropdown.jsx | 60 ------ gui_dev/src/pages/Settings/FrequencyRange.jsx | 176 ------------------ .../Settings/FrequencySettings.module.css | 67 ------- gui_dev/src/pages/Settings/TextField.jsx | 77 -------- .../src/pages/Settings/TextField.module.css | 67 ------- 7 files changed, 584 deletions(-) delete mode 100644 gui_dev/src/pages/Settings/DragAndDropList.jsx delete mode 100644 gui_dev/src/pages/Settings/DragAndDropList.module.css delete mode 100644 gui_dev/src/pages/Settings/Dropdown.jsx delete mode 100644 gui_dev/src/pages/Settings/FrequencyRange.jsx delete mode 100644 gui_dev/src/pages/Settings/FrequencySettings.module.css delete mode 100644 gui_dev/src/pages/Settings/TextField.jsx delete mode 100644 gui_dev/src/pages/Settings/TextField.module.css diff --git a/gui_dev/src/pages/Settings/DragAndDropList.jsx b/gui_dev/src/pages/Settings/DragAndDropList.jsx deleted file mode 100644 index afa74b02..00000000 --- a/gui_dev/src/pages/Settings/DragAndDropList.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useRef } from "react"; -import styles from "./DragAndDropList.module.css"; -import { useOptionsStore } from "@/stores"; - -export const DragAndDropList = () => { - const { options, setOptions, addOption, removeOption } = useOptionsStore(); - - const predefinedOptions = [ - { id: 1, name: "raw_resampling" }, - { id: 2, name: "notch_filter" }, - { id: 3, name: "re_referencing" }, - { id: 4, name: "preprocessing_filter" }, - { id: 5, name: "raw_normalization" }, - ]; - - const dragOption = useRef(0); - const draggedOverOption = useRef(0); - - function handleSort() { - const optionsClone = [...options]; - const temp = optionsClone[dragOption.current]; - optionsClone[dragOption.current] = optionsClone[draggedOverOption.current]; - optionsClone[draggedOverOption.current] = temp; - setOptions(optionsClone); - } - - return ( -
    -

    List

    - {options.map((option, index) => ( -
    (dragOption.current = index)} - onDragEnter={() => (draggedOverOption.current = index)} - onDragEnd={handleSort} - onDragOver={(e) => e.preventDefault()} - > -

    - {[option.id, ". ", option.name.replace("_", " ")]} -

    - -
    - ))} -
    -

    Add Elements

    - {predefinedOptions.map((option) => ( - - ))} -
    -
    - ); -}; diff --git a/gui_dev/src/pages/Settings/DragAndDropList.module.css b/gui_dev/src/pages/Settings/DragAndDropList.module.css deleted file mode 100644 index 76bd7861..00000000 --- a/gui_dev/src/pages/Settings/DragAndDropList.module.css +++ /dev/null @@ -1,72 +0,0 @@ -.dragDropList { - max-width: 400px; - margin: 0 auto; - padding: 20px; -} - -.title { - text-align: center; - color: #333; - margin-bottom: 20px; -} - -.item { - background-color: #f0f0f0; - border-radius: 8px; - padding: 15px; - margin-bottom: 10px; - cursor: move; - transition: background-color 0.3s ease; - display: flex; - justify-content: space-between; - align-items: center; -} - -.item:hover { - background-color: #e0e0e0; -} - -.itemText { - margin: 0; - color: #333; - font-size: 16px; -} - -.removeButton { - background-color: #ff4d4d; - border: none; - border-radius: 4px; - color: white; - padding: 5px 10px; - cursor: pointer; - transition: background-color 0.3s ease; -} - -.removeButton:hover { - background-color: #ff1a1a; -} - -.addSection { - margin-top: 20px; - text-align: center; -} - -.subtitle { - color: #333; - margin-bottom: 10px; -} - -.addButton { - background-color: #4CAF50; - border: none; - border-radius: 4px; - color: white; - padding: 10px 15px; - margin: 5px; - cursor: pointer; - transition: background-color 0.3s ease; -} - -.addButton:hover { - background-color: #45a049; -} \ No newline at end of file diff --git a/gui_dev/src/pages/Settings/Dropdown.jsx b/gui_dev/src/pages/Settings/Dropdown.jsx deleted file mode 100644 index 9dbc9833..00000000 --- a/gui_dev/src/pages/Settings/Dropdown.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useState } from "react"; - -var stringJson = - '{"sampling_rate_features_hz":10.0,"segment_length_features_ms":1000.0,"frequency_ranges_hz":{"theta":{"frequency_low_hz":4.0,"frequency_high_hz":8.0},"alpha":{"frequency_low_hz":8.0,"frequency_high_hz":12.0},"low beta":{"frequency_low_hz":13.0,"frequency_high_hz":20.0},"high beta":{"frequency_low_hz":20.0,"frequency_high_hz":35.0},"low gamma":{"frequency_low_hz":60.0,"frequency_high_hz":80.0},"high gamma":{"frequency_low_hz":90.0,"frequency_high_hz":200.0},"HFA":{"frequency_low_hz":200.0,"frequency_high_hz":400.0}},"preprocessing":["raw_resampling","notch_filter","re_referencing"],"raw_resampling_settings":{"resample_freq_hz":1000.0},"preprocessing_filter":{"bandstop_filter":true,"bandpass_filter":true,"lowpass_filter":true,"highpass_filter":true,"bandstop_filter_settings":{"frequency_low_hz":100.0,"frequency_high_hz":160.0},"bandpass_filter_settings":{"frequency_low_hz":2.0,"frequency_high_hz":200.0},"lowpass_filter_cutoff_hz":200.0,"highpass_filter_cutoff_hz":3.0},"raw_normalization_settings":{"normalization_time_s":30.0,"normalization_method":"zscore","clip":3.0},"postprocessing":{"feature_normalization":true,"project_cortex":false,"project_subcortex":false},"feature_normalization_settings":{"normalization_time_s":30.0,"normalization_method":"zscore","clip":3.0},"project_cortex_settings":{"max_dist_mm":20.0},"project_subcortex_settings":{"max_dist_mm":5.0},"features":{"raw_hjorth":true,"return_raw":true,"bandpass_filter":false,"stft":false,"fft":true,"welch":true,"sharpwave_analysis":true,"fooof":false,"nolds":false,"coherence":false,"bursts":true,"linelength":true,"mne_connectivity":false,"bispectrum":false},"fft_settings":{"windowlength_ms":1000,"log_transform":true,"features":{"mean":true,"median":false,"std":false,"max":false},"return_spectrum":false},"welch_settings":{"windowlength_ms":1000,"log_transform":true,"features":{"mean":true,"median":false,"std":false,"max":false},"return_spectrum":false},"stft_settings":{"windowlength_ms":1000,"log_transform":true,"features":{"mean":true,"median":false,"std":false,"max":false},"return_spectrum":false},"bandpass_filter_settings":{"segment_lengths_ms":{"theta":1000,"alpha":500,"low beta":333,"high beta":333,"low gamma":100,"high gamma":100,"HFA":100},"bandpower_features":{"activity":true,"mobility":false,"complexity":false},"log_transform":true,"kalman_filter":false},"kalman_filter_settings":{"Tp":0.1,"sigma_w":0.7,"sigma_v":1.0,"frequency_bands":["theta","alpha","low_beta","high_beta","low_gamma","high_gamma","HFA"]},"burst_settings":{"threshold":75.0,"time_duration_s":30.0,"frequency_bands":["low beta","high beta","low gamma"],"burst_features":{"duration":true,"amplitude":true,"burst_rate_per_s":true,"in_burst":true}},"sharpwave_analysis_settings":{"sharpwave_features":{"peak_left":false,"peak_right":false,"trough":false,"width":false,"prominence":true,"interval":true,"decay_time":false,"rise_time":false,"sharpness":true,"rise_steepness":false,"decay_steepness":false,"slope_ratio":false},"filter_ranges_hz":[{"frequency_low_hz":5.0,"frequency_high_hz":80.0},{"frequency_low_hz":5.0,"frequency_high_hz":30.0}],"detect_troughs":{"estimate":true,"distance_troughs_ms":10.0,"distance_peaks_ms":5.0},"detect_peaks":{"estimate":true,"distance_troughs_ms":10.0,"distance_peaks_ms":5.0},"estimator":{"mean":["interval"],"median":[],"max":["prominence","sharpness"],"min":[],"var":[]},"apply_estimator_between_peaks_and_troughs":true},"mne_connectivity":{"method":"plv","mode":"multitaper"},"coherence":{"features":{"mean_fband":true,"max_fband":true,"max_allfbands":true},"method":{"coh":true,"icoh":true},"channels":[],"frequency_bands":["high beta"]},"fooof":{"aperiodic":{"exponent":true,"offset":true,"knee":true},"periodic":{"center_frequency":false,"band_width":false,"height_over_ap":false},"windowlength_ms":800.0,"peak_width_limits":{"frequency_low_hz":0.5,"frequency_high_hz":12.0},"max_n_peaks":3,"min_peak_height":0.0,"peak_threshold":2.0,"freq_range_hz":{"frequency_low_hz":2.0,"frequency_high_hz":40.0},"knee":true},"nolds_features":{"raw":true,"frequency_bands":["low beta"],"features":{"sample_entropy":false,"correlation_dimension":false,"lyapunov_exponent":true,"hurst_exponent":false,"detrended_fluctuation_analysis":false}},"bispectrum":{"f1s":{"frequency_low_hz":5.0,"frequency_high_hz":35.0},"f2s":{"frequency_low_hz":5.0,"frequency_high_hz":35.0},"compute_features_for_whole_fband_range":true,"frequency_bands":["theta","alpha","low_beta","high_beta"],"components":{"absolute":true,"real":true,"imag":true,"phase":true},"bispectrum_features":{"mean":true,"sum":true,"var":true}}}'; -const nm_settings = JSON.parse(stringJson); - -const filterByKeys = (obj, keys) => { - const filteredDict = {}; - keys.forEach((key) => { - if (typeof key === "string") { - // Top-level key - if (obj.hasOwn(key)) { - filteredDict[key] = obj[key]; - } - } else if (typeof key === "object") { - // Nested key - const topLevelKey = Object.keys(key)[0]; - const nestedKeys = key[topLevelKey]; - if (obj.hasOwn(topLevelKey) && typeof obj[topLevelKey] === "object") { - filteredDict[topLevelKey] = filterByKeys(obj[topLevelKey], nestedKeys); - } - } - }); - return filteredDict; -}; - -export const Dropdown = ({ onChange, keysToInclude }) => { - const filteredSettings = filterByKeys(nm_settings, keysToInclude); - const [selectedOption, setSelectedOption] = useState(""); - - const handleChange = (event) => { - const newValue = event.target.value; - setSelectedOption(newValue); - onChange(keysToInclude, newValue); - }; - - return ( -
    - -
    - ); -}; diff --git a/gui_dev/src/pages/Settings/FrequencyRange.jsx b/gui_dev/src/pages/Settings/FrequencyRange.jsx deleted file mode 100644 index 83ee09d5..00000000 --- a/gui_dev/src/pages/Settings/FrequencyRange.jsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useState } from "react"; -import { TextField, Button, IconButton, Stack } from "@mui/material"; -import { Add, Close } from "@mui/icons-material"; -import { debounce } from "@/utils"; - -const NumberField = ({ ...props }) => ( - -); - -export const FrequencyRange = ({ - id, - name, - range, - onChangeName, - onChangeRange, - onRemove, -}) => { - const [localName, setLocalName] = useState(name); - - const debouncedChangeName = debounce((newName) => { - onChangeName(newName, name); - }, 1000); - - const handleNameChange = (e) => { - const newName = e.target.value; - setLocalName(newName); - debouncedChangeName(newName); - }; - - const handleNameBlur = () => { - onChangeName(localName, name); - }; - - const handleKeyPress = (e) => { - if (e.key === "Enter") { - console.log(e.target.value, name); - onChangeName(localName, name); - } - }; - - const handleRangeChange = (field, value) => { - onChangeRange(id, { ...range, [field]: value }); - }; - - return ( - - - handleRangeChange("frequency_low_hz", e.target.value)} - label="Low Hz" - /> - handleRangeChange("frequency_high_hz", e.target.value)} - label="High Hz" - /> - onRemove(name)} - color="primary" - disableRipple - sx={{ m: 0, p: 0 }} - > - - - - ); -}; - -export const FrequencyRangeList = ({ - ranges, - rangeOrder, - onChange, - onOrderChange, -}) => { - const handleChangeRange = (name, newRange) => { - const updatedRanges = { ...ranges }; - updatedRanges[name] = newRange; - onChange(["frequency_ranges_hz"], updatedRanges); - }; - - const handleChangeName = (newName, oldName) => { - if (oldName === newName) { - return; - } - - const updatedRanges = { ...ranges, [newName]: ranges[oldName] }; - delete updatedRanges[oldName]; - onChange(["frequency_ranges_hz"], updatedRanges); - - const updatedOrder = rangeOrder.map((name) => - name === oldName ? newName : name - ); - onOrderChange(updatedOrder); - }; - - const handleRemove = (name) => { - const updatedRanges = { ...ranges }; - delete updatedRanges[name]; - onChange(["frequency_ranges_hz"], updatedRanges); - - const updatedOrder = rangeOrder.filter((item) => item !== name); - onOrderChange(updatedOrder); - }; - - const addRange = () => { - let newName = "NewRange"; - let counter = 0; - // Find first available name - while (Object.hasOwn(ranges, newName)) { - counter++; - newName = `NewRange${counter}`; - } - - const updatedRanges = { - ...ranges, - [newName]: { - __field_type__: "FrequencyRange", - frequency_low_hz: 1, - frequency_high_hz: 500, - }, - }; - onChange(["frequency_ranges_hz"], updatedRanges); - - const updatedOrder = [...rangeOrder, newName]; - onOrderChange(updatedOrder); - }; - - return ( - - {rangeOrder.map((name, index) => ( - - ))} - - - ); -}; diff --git a/gui_dev/src/pages/Settings/FrequencySettings.module.css b/gui_dev/src/pages/Settings/FrequencySettings.module.css deleted file mode 100644 index a638ad93..00000000 --- a/gui_dev/src/pages/Settings/FrequencySettings.module.css +++ /dev/null @@ -1,67 +0,0 @@ -.container { - background-color: #f9f9f9; /* Light gray background for the container */ - padding: 20px; - border-radius: 10px; /* Rounded corners */ - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); /* Subtle shadow */ - max-width: 600px; - margin: auto; - } - - .header { - font-size: 1.5rem; - color: #333; /* Darker text color */ - margin-bottom: 20px; - text-align: center; - } - - .bandContainer { - display: flex; - align-items: center; - margin-bottom: 15px; - padding: 10px; - border: 1px solid #ddd; /* Light border for each band */ - border-radius: 8px; /* Rounded corners for individual bands */ - background-color: #fff; /* White background for bands */ - box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1); /* Light shadow for depth */ - } - - .bandNameInput, .frequencyInput { - border: 1px solid #ccc; /* Light gray border */ - border-radius: 5px; /* Slightly rounded corners */ - padding: 8px; - margin-right: 10px; - font-size: 0.875rem; - } - - .bandNameInput::placeholder, .frequencyInput::placeholder { - color: #aaa; /* Light gray placeholder text */ - } - - .removeButton, .addButton { - border: none; - border-radius: 5px; /* Rounded corners */ - padding: 8px 12px; - font-size: 0.875rem; - cursor: pointer; - transition: background-color 0.3s, color 0.3s; - } - - .removeButton { - background-color: #e57373; /* Light red color */ - color: white; - } - - .removeButton:hover { - background-color: #d32f2f; /* Darker red on hover */ - } - - .addButton { - background-color: #4caf50; /* Green color */ - color: white; - margin-top: 10px; - } - - .addButton:hover { - background-color: #388e3c; /* Darker green on hover */ - } - \ No newline at end of file diff --git a/gui_dev/src/pages/Settings/TextField.jsx b/gui_dev/src/pages/Settings/TextField.jsx deleted file mode 100644 index 86ab07b8..00000000 --- a/gui_dev/src/pages/Settings/TextField.jsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState, useEffect } from "react"; -import { - Box, - Grid, - TextField as MUITextField, - Typography, -} from "@mui/material"; -import { useSettingsStore } from "@/stores"; -import styles from "./TextField.module.css"; - -const flattenDictionary = (dict, parentKey = "", result = {}) => { - for (let key in dict) { - const newKey = parentKey ? `${parentKey}.${key}` : key; - if (typeof dict[key] === "object" && dict[key] !== null) { - flattenDictionary(dict[key], newKey, result); - } else { - result[newKey] = dict[key]; - } - } - return result; -}; - -const filterByKeys = (flatDict, keys) => { - const filteredDict = {}; - keys.forEach((key) => { - if (flatDict.hasOwnProperty(key)) { - filteredDict[key] = flatDict[key]; - } - }); - return filteredDict; -}; - -export const TextField = ({ keysToInclude }) => { - const settings = useSettingsStore((state) => state.settings); - const flatSettings = flattenDictionary(settings); - const filteredSettings = filterByKeys(flatSettings, keysToInclude); - const [textLabels, setTextLabels] = useState({}); - - useEffect(() => { - setTextLabels(filteredSettings); - }, [settings]); - - const handleTextFieldChange = (label, value) => { - setTextLabels((prevLabels) => ({ - ...prevLabels, - [label]: value, - })); - }; - - // Function to format the label - const formatLabel = (label) => { - const labelAfterDot = label.split(".").pop(); // Get everything after the last dot - return labelAfterDot.replace(/_/g, " "); // Replace underscores with spaces - }; - - return ( -
    - {Object.keys(textLabels).map((label, index) => ( -
    - - handleTextFieldChange(label, e.target.value)} - className={styles.textFieldInput} - /> -
    - ))} -
    - ); -}; diff --git a/gui_dev/src/pages/Settings/TextField.module.css b/gui_dev/src/pages/Settings/TextField.module.css deleted file mode 100644 index 37791c40..00000000 --- a/gui_dev/src/pages/Settings/TextField.module.css +++ /dev/null @@ -1,67 +0,0 @@ -/* TextField.module.css */ - -/* Container for the text fields */ -.textFieldContainer { - display: flex; - flex-direction: column; - margin: 1.5rem 0; /* Increased margin for better spacing */ - } - - /* Row for each text field */ - .textFieldRow { - display: flex; - flex-direction: column; /* Stack label and input vertically */ - margin-bottom: 1rem; /* Increased margin for better separation */ - } - - /* Label for each text field */ - .textFieldLabel { - margin-bottom: 0.5rem; /* Space between label and input */ - font-weight: 600; /* Increased weight for better visibility */ - color: #333; /* Dark gray for the label */ - font-size: 1.1rem; /* Increased font size for the label */ - transition: all 0.2s ease; /* Smooth transition for label */ - } - - /* Input field styles */ - .textFieldInput { - padding: 12px 14px; /* Padding for a filled look */ - border: 1px solid #ccc; /* Light gray border */ - border-radius: 4px; /* Rounded corners */ - width: 100%; /* Full width */ - font-size: 1rem; /* Font size */ - background-color: #f5f5f5; /* Light background color for filled effect */ - transition: border-color 0.2s ease, background-color 0.2s ease; /* Smooth transitions */ - box-shadow: none; /* Remove default shadow */ - height: 48px; /* Fixed height for a more square appearance */ - } - - /* Focus styles for the input */ - .textFieldInput:focus { - border-color: #1976d2; /* Blue border color on focus */ - background-color: #fff; /* Change background to white on focus */ - outline: none; /* Remove default outline */ - } - - /* Hover effect for the input */ - .textFieldInput:hover { - border-color: #1976d2; /* Change border color on hover */ - } - - /* Placeholder styles */ - .textFieldInput::placeholder { - color: #aaa; /* Light gray placeholder text */ - opacity: 1; /* Ensure placeholder is fully opaque */ - } - - /* Hide the number input spinners in webkit browsers */ - .textFieldInput::-webkit-inner-spin-button, - .textFieldInput::-webkit-outer-spin-button { - -webkit-appearance: none; /* Remove default styling */ - margin: 0; /* Remove margin */ - } - - /* Hide the number input spinners in Firefox */ - .textFieldInput[type='number'] { - -moz-appearance: textfield; /* Use textfield appearance */ - } \ No newline at end of file From 9e3f2b26d8a96d505b66fe089a9acfea59de6811 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:29:23 +0100 Subject: [PATCH 09/25] Change NMSettings.get_default() from reading settings from file to reading them from the default class members --- py_neuromodulation/stream/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_neuromodulation/stream/settings.py b/py_neuromodulation/stream/settings.py index 7e25ec24..c8ac742c 100644 --- a/py_neuromodulation/stream/settings.py +++ b/py_neuromodulation/stream/settings.py @@ -250,7 +250,7 @@ def from_file(PATH: _PathLike) -> "NMSettings": @staticmethod def get_default() -> "NMSettings": - return NMSettings.from_file(PYNM_DIR / "default_settings.yaml") + return NMSettings() @staticmethod def list_normalization_methods() -> list[NormMethod]: From dd535908a8d8d0bd301eb4419176966319667bbf Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:29:51 +0100 Subject: [PATCH 10/25] fix zustand version --- gui_dev/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui_dev/package.json b/gui_dev/package.json index 9329eca5..2850e8bf 100644 --- a/gui_dev/package.json +++ b/gui_dev/package.json @@ -18,7 +18,7 @@ "react": "next", "react-dom": "next", "react-router-dom": "^6.26.2", - "zustand": "next" + "zustand": "latest" }, "devDependencies": { "@babel/core": "^7.25.7", From 56d7806b6a1e2ee8c27bd425f0f27b6002600f4a Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Fri, 8 Nov 2024 14:48:26 +0100 Subject: [PATCH 11/25] Add classes NMSequenceModel and NMValueModel --- py_neuromodulation/default_settings.yaml | 28 +- py_neuromodulation/features/bursts.py | 2 +- py_neuromodulation/stream/settings.py | 38 +-- .../utils/pydantic_extensions.py | 252 +++++++++++++++++- py_neuromodulation/utils/types.py | 117 ++------ 5 files changed, 309 insertions(+), 128 deletions(-) diff --git a/py_neuromodulation/default_settings.yaml b/py_neuromodulation/default_settings.yaml index c98b2c0e..0019a78d 100644 --- a/py_neuromodulation/default_settings.yaml +++ b/py_neuromodulation/default_settings.yaml @@ -1,4 +1,15 @@ ---- +# We +# should +# have +# a +# brief +# explanation +# of +# the +# settings +# format +# here + ######################## ### General settings ### ######################## @@ -51,12 +62,8 @@ preprocessing_filter: lowpass_filter: true highpass_filter: true bandpass_filter: true - bandstop_filter_settings: - frequency_low_hz: 100 - frequency_high_hz: 160 - bandpass_filter_settings: - frequency_low_hz: 3 - frequency_high_hz: 200 + bandstop_filter_settings: [100, 160] # [low_hz, high_hz] + bandpass_filter_settings: [3, 200] # [hz, _hz] lowpass_filter_cutoff_hz: 200 highpass_filter_cutoff_hz: 3 @@ -162,10 +169,8 @@ sharpwave_analysis_settings: decay_steepness: false slope_ratio: false filter_ranges_hz: - - frequency_low_hz: 5 - frequency_high_hz: 80 - - frequency_low_hz: 5 - frequency_high_hz: 30 + - [5, 80] + - [5, 30] detect_troughs: estimate: true distance_troughs_ms: 10 @@ -174,6 +179,7 @@ sharpwave_analysis_settings: estimate: true distance_troughs_ms: 5 distance_peaks_ms: 10 + # TONI: Reverse this setting? e.g. interval: [mean, var] estimator: mean: [interval] median: [] diff --git a/py_neuromodulation/features/bursts.py b/py_neuromodulation/features/bursts.py index 83d63bf8..2ff8ceac 100644 --- a/py_neuromodulation/features/bursts.py +++ b/py_neuromodulation/features/bursts.py @@ -11,7 +11,7 @@ from py_neuromodulation.utils.types import BoolSelector, NMBaseModel, NMFeature from typing import TYPE_CHECKING, Callable -from py_neuromodulation.utils.types import create_validation_error +from py_neuromodulation.utils.pydantic_extensions import create_validation_error if TYPE_CHECKING: from py_neuromodulation import NMSettings diff --git a/py_neuromodulation/stream/settings.py b/py_neuromodulation/stream/settings.py index c8ac742c..39a9b6fd 100644 --- a/py_neuromodulation/stream/settings.py +++ b/py_neuromodulation/stream/settings.py @@ -4,15 +4,15 @@ from typing import ClassVar from pydantic import Field, model_validator -from py_neuromodulation import PYNM_DIR, logger, user_features +from py_neuromodulation import logger, user_features from py_neuromodulation.utils.types import ( BoolSelector, FrequencyRange, - PreprocessorName, _PathLike, NMBaseModel, NormMethod, + PreprocessorList, ) from py_neuromodulation.processing.filter_preprocessing import FilterSettings @@ -72,11 +72,14 @@ class NMSettings(NMBaseModel): } # Preproceessing settings - preprocessing: list[PreprocessorName] = [ - "raw_resampling", - "notch_filter", - "re_referencing", - ] + preprocessing: PreprocessorList = PreprocessorList( + [ + "raw_resampling", + "notch_filter", + "re_referencing", + ] + ) + raw_resampling_settings: ResamplerSettings = ResamplerSettings() preprocessing_filter: FilterSettings = FilterSettings() raw_normalization_settings: NormalizationSettings = NormalizationSettings() @@ -144,26 +147,29 @@ def validate_settings(self): if self.bandpass_filter_settings.kalman_filter: self.kalman_filter_settings.validate_fbands(self) - for k, v in self.frequency_ranges_hz.items(): - if not isinstance(v, FrequencyRange): - self.frequency_ranges_hz[k] = FrequencyRange.create_from(v) + # TONI: not needed after NMSequenceModel, remove in the future + # for k, v in self.frequency_ranges_hz.items(): + # if not isinstance(v, FrequencyRange): + # self.frequency_ranges_hz[k] = FrequencyRange.create_from(v) return self def reset(self) -> "NMSettings": self.features.disable_all() - self.preprocessing = [] + self.preprocessing = PreprocessorList() self.postprocessing.disable_all() return self def set_fast_compute(self) -> "NMSettings": self.reset() self.features.fft = True - self.preprocessing = [ - "raw_resampling", - "notch_filter", - "re_referencing", - ] + self.preprocessing = PreprocessorList( + [ + "raw_resampling", + "notch_filter", + "re_referencing", + ] + ) self.postprocessing.feature_normalization = True self.postprocessing.project_cortex = False self.postprocessing.project_subcortex = False diff --git a/py_neuromodulation/utils/pydantic_extensions.py b/py_neuromodulation/utils/pydantic_extensions.py index 556f751c..be677243 100644 --- a/py_neuromodulation/utils/pydantic_extensions.py +++ b/py_neuromodulation/utils/pydantic_extensions.py @@ -1,11 +1,48 @@ -from typing import Any, get_type_hints -from pydantic.fields import FieldInfo, _FieldInfoInputs, _FromFieldInfoInputs -from pydantic import BaseModel, ConfigDict, SerializationInfo, model_serializer -from pydantic_core import PydanticUndefined +from typing import Any, get_type_hints, TypeVar, Generic, Literal, overload from typing_extensions import Unpack, TypedDict +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic_core import PydanticUndefined, ValidationError, InitErrorDetails +from pydantic.fields import FieldInfo, _FieldInfoInputs, _FromFieldInfoInputs from pprint import pformat +def create_validation_error( + error_message: str, + loc: list[str | int] | None = None, + title: str = "Validation Error", + input_type: Literal["python", "json"] = "python", + hide_input: bool = False, +) -> ValidationError: + """ + Factory function to create a Pydantic v2 ValidationError instance from a single error message. + + Args: + error_message (str): The error message for the ValidationError. + loc (List[str | int], optional): The location of the error. Defaults to None. + title (str, optional): The title of the error. Defaults to "Validation Error". + input_type (Literal["python", "json"], optional): Whether the error is for a Python object or JSON. Defaults to "python". + hide_input (bool, optional): Whether to hide the input value in the error message. Defaults to False. + + Returns: + ValidationError: A Pydantic ValidationError instance. + """ + if loc is None: + loc = [] + + line_errors = [ + InitErrorDetails( + type="value_error", loc=tuple(loc), input=None, ctx={"error": error_message} + ) + ] + + return ValidationError.from_exception_data( + title=title, + line_errors=line_errors, + input_type=input_type, + hide_input=hide_input, + ) + + class _NMExtraFieldInputs(TypedDict, total=False): """Additional fields to add on top of the pydantic FieldInfo""" @@ -60,14 +97,36 @@ class NMBaseModel(BaseModel): model_config = ConfigDict(validate_assignment=False, extra="allow") def __init__(self, *args, **kwargs) -> None: - if kwargs: - super().__init__(**kwargs) - else: - field_names = list(self.model_fields.keys()) - kwargs = {} - for i in range(len(args)): - kwargs[field_names[i]] = args[i] + """Pydantic does not support positional arguments by default. + This is a workaround to support positional arguments for models like FrequencyRange. + It converts positional arguments to kwargs and then calls the base class __init__. + """ + if not args: + # Simple case - just use kwargs super().__init__(**kwargs) + return + + field_names = list(self.model_fields.keys()) + # If we have more positional args than fields, that's an error + if len(args) > len(field_names): + raise ValueError( + f"Too many positional arguments. Expected at most {len(field_names)}, got {len(args)}" + ) + + # Convert positional args to kwargs, checking for duplicates if args: + complete_kwargs = {} + for i, arg in enumerate(args): + field_name = field_names[i] + if field_name in kwargs: + raise ValueError( + f"Got multiple values for field '{field_name}': " + f"positional argument and keyword argument" + ) + complete_kwargs[field_name] = arg + + # Add remaining kwargs + complete_kwargs.update(kwargs) + super().__init__(**complete_kwargs) def __str__(self): return pformat(self.model_dump()) @@ -115,3 +174,174 @@ def serialize_with_metadata(self): for tag, value in field_info.custom_metadata.items(): result[f"__{tag}__"] = value return result + + +################################# +#### Generic Pydantic models #### +################################# + + +def create_alias_property(index: int, alias: str, classname: str): + """Creates a property that accesses the root sequence at the given index""" + + def getter(self): + return self.root[index] + + def setter(self, value): + if isinstance(self.root, tuple): + new_values = list(self.root) + new_values[index] = value + self.root = tuple(new_values) + else: + self.root[index] = value + + return property( + fget=getter, + fset=setter, + doc=f"Alias '{alias}' for position [{index}] of class '{classname}'.", + ) + + +T = TypeVar("T") +C = TypeVar("C", list, tuple) + + +class NMSequenceModel(NMBaseModel, Generic[C]): + """Base class for sequence models with a single root value""" + + root: C = NMField(default_factory=list) + + # Class variable for aliases - override in subclasses + __aliases__: dict[int, list[str]] = {} + + def __init__(self, *args, **kwargs) -> None: + # Generate properties programatically (not used currently) + # for index, aliases in self.__aliases__.items(): + # for alias in aliases: + # if not hasattr(self.__class__, alias): + # setattr( + # self.__class__, + # alias, + # create_alias_property(index, alias, self.__class__.__name__), + # ) + + if len(args) == 1 and isinstance(args[0], (list, tuple)): + kwargs["root"] = args[0] + elif len(args) == 1: + kwargs["root"] = [args[0]] + elif len(args) > 1: # Add this case + kwargs["root"] = tuple(args) + super().__init__(**kwargs) + + def __iter__(self): # type: ignore[reportIncompatibleMethodOverride] + return iter(self.root) + + def __getitem__(self, idx): + return self.root[idx] + + def __len__(self): + return len(self.root) + + def model_dump(self): # type: ignore[reportIncompatibleMethodOverride] + return self.root + + def model_dump_json(self, **kwargs): + import json + + return json.dumps(self.root, **kwargs) + + def serialize_with_metadata(self) -> dict[str, Any]: + result = {"__field_type__": self.__class__.__name__, "value": self.root} + + # Add any field metadata from the root field + field_info = self.model_fields.get("root") + if isinstance(field_info, NMFieldInfo): + for tag, value in field_info.custom_metadata.items(): + result[f"__{tag}__"] = value + + return result + + @model_validator(mode="before") + @classmethod + def validate_input(cls, value: Any) -> dict[str, Any]: + # If it's a dict, just return it + if isinstance(value, dict): + if "root" in value: + return value + + # Check for aliased fields if class has aliases defined + if hasattr(cls, "__aliases__"): + # Collect all possible alias names for each position + alias_values = [] + max_index = max(cls.__aliases__.keys()) if cls.__aliases__ else -1 + + # Try to find a value for each position using its aliases + for i in range(max_index + 1): + aliases = cls.__aliases__.get(i, []) + value_found = None + + # Try each alias for this position + for alias in aliases: + if alias in value: + value_found = value[alias] + break + + if value_found is not None: + alias_values.append(value_found) + else: + # If we're missing any position's value, don't use aliases + break + + # If we found all values through aliases, use them + if len(alias_values) == max_index + 1: + return {"root": alias_values} + + # if it's a sequence, return the value as the root + if isinstance(value, (list, tuple)): + return {"root": value} + + # Else, make it a list + return {"root": [value]} + + +class NMValueModel(NMBaseModel, Generic[T]): + """Base class for single-value models that behave like their contained type""" + + root: T + + @model_validator(mode="before") + @classmethod + def validate_input(cls, value: Any) -> dict[str, Any]: + if isinstance(value, dict): + if "root" in value: + return value + # If it's a dict without root, assume the first value is our value + if len(value) > 0: + return {"root": next(iter(value.values()))} + return {"root": None} + return {"root": value} + + def __str__(self) -> str: + return str(self.root) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({repr(self.root)})" + + def model_dump(self): # type: ignore[reportIncompatibleMethodOverride] + return self.root + + def model_dump_json(self, **kwargs): + import json + + return json.dumps(self.root, **kwargs) + + def serialize_with_metadata(self) -> dict[str, Any]: + result = {"__field_type__": self.__class__.__name__, "value": self.root} + + # Add any field metadata from the root field + field_info = self.model_fields.get("root") + if isinstance(field_info, NMFieldInfo): + for tag, value in field_info.custom_metadata.items(): + result[f"__{tag}__"] = value + + return result diff --git a/py_neuromodulation/utils/types.py b/py_neuromodulation/utils/types.py index f5921281..a2c22db9 100644 --- a/py_neuromodulation/utils/types.py +++ b/py_neuromodulation/utils/types.py @@ -1,9 +1,8 @@ from os import PathLike from math import isnan from typing import Literal, TYPE_CHECKING -from pydantic import BaseModel, model_validator -from pydantic_core import ValidationError, InitErrorDetails -from .pydantic_extensions import NMBaseModel, NMField +from pydantic import BaseModel, ConfigDict, model_validator +from .pydantic_extensions import NMBaseModel, NMSequenceModel from collections.abc import Sequence from datetime import datetime @@ -86,66 +85,43 @@ class NMPreprocessor: def process(self, data: "np.ndarray") -> "np.ndarray": ... -class FrequencyRange(NMBaseModel): - # frequency_low_hz: Annotated[list[float], {"unit": "Hz"}] = Field(gt=0) - # frequency_high_hz: FrequencyHz = Field(gt=0) - frequency_low_hz: float = NMField(gt=0, custom_metadata={"unit": "Hz"}) - frequency_high_hz: float = NMField(gt=0, custom_metadata={"unit": "Hz"}) +class PreprocessorList(NMSequenceModel[list[PreprocessorName]]): + model_config = ConfigDict(arbitrary_types_allowed=True) + # Useless contructor to prevent linter from complaining def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - def __getitem__(self, item: int): - match item: - case 0: - return self.frequency_low_hz - case 1: - return self.frequency_high_hz - case _: - raise IndexError(f"Index {item} out of range") - def as_tuple(self) -> tuple[float, float]: - return (self.frequency_low_hz, self.frequency_high_hz) +class FrequencyRange(NMSequenceModel[tuple[float, float]]): + """Frequency range as (low, high) tuple""" - def __iter__(self): # type: ignore - return iter(self.as_tuple()) + __aliases__ = { + 0: ["frequency_low_hz", "low_frequency_hz"], + 1: ["frequency_high_hz", "high_frequency_hz"], + } + + # Useless contructor to prevent linter from complaining + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) @model_validator(mode="after") def validate_range(self): - if not (isnan(self.frequency_high_hz) or isnan(self.frequency_low_hz)): - assert ( - self.frequency_high_hz > self.frequency_low_hz - ), "Frequency high must be greater than frequency low" + low, high = self.root + if not (isnan(low) or isnan(high)): + assert high > low, "High frequency must be greater than low frequency" return self - @classmethod - def create_from(cls, input) -> "FrequencyRange": - match input: - case FrequencyRange(): - return input - case dict() if "frequency_low_hz" in input and "frequency_high_hz" in input: - return FrequencyRange( - input["frequency_low_hz"], input["frequency_high_hz"] - ) - case Sequence() if len(input) == 2: - return FrequencyRange(input[0], input[1]) - case _: - raise ValueError("Invalid input for FrequencyRange creation.") - - @model_validator(mode="before") - @classmethod - def check_input(cls, input): - match input: - case dict() if "frequency_low_hz" in input and "frequency_high_hz" in input: - return input - case Sequence() if len(input) == 2: - return {"frequency_low_hz": input[0], "frequency_high_hz": input[1]} - case _: - raise ValueError( - "Value for FrequencyRange must be a dictionary, " - "or a sequence of 2 numeric values, " - f"but got {input} instead." - ) + # Alias properties + @property + def frequency_low_hz(self) -> float: + """Lower frequency bound in Hz""" + return self.root[0] + + @property + def frequency_high_hz(self) -> float: + """Upper frequency bound in Hz""" + return self.root[1] class BoolSelector(NMBaseModel): @@ -179,43 +155,6 @@ def print_all(cls): print(f) -def create_validation_error( - error_message: str, - loc: list[str | int] | None = None, - title: str = "Validation Error", - input_type: Literal["python", "json"] = "python", - hide_input: bool = False, -) -> ValidationError: - """ - Factory function to create a Pydantic v2 ValidationError instance from a single error message. - - Args: - error_message (str): The error message for the ValidationError. - loc (List[str | int], optional): The location of the error. Defaults to None. - title (str, optional): The title of the error. Defaults to "Validation Error". - input_type (Literal["python", "json"], optional): Whether the error is for a Python object or JSON. Defaults to "python". - hide_input (bool, optional): Whether to hide the input value in the error message. Defaults to False. - - Returns: - ValidationError: A Pydantic ValidationError instance. - """ - if loc is None: - loc = [] - - line_errors = [ - InitErrorDetails( - type="value_error", loc=tuple(loc), input=None, ctx={"error": error_message} - ) - ] - - return ValidationError.from_exception_data( - title=title, - line_errors=line_errors, - input_type=input_type, - hide_input=hide_input, - ) - - ################# ### GUI TYPES ### ################# From 34a44127e116acdebf1965600e4bdc28c3c2e7cd Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Fri, 8 Nov 2024 17:07:23 +0100 Subject: [PATCH 12/25] bugfix: in NMBaseModel.validate() method, change model_validate() for model_validate_string() which correctly interprets the string keys produced by model_dump() as member variables --- py_neuromodulation/utils/pydantic_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_neuromodulation/utils/pydantic_extensions.py b/py_neuromodulation/utils/pydantic_extensions.py index be677243..e679eec4 100644 --- a/py_neuromodulation/utils/pydantic_extensions.py +++ b/py_neuromodulation/utils/pydantic_extensions.py @@ -135,7 +135,7 @@ def __repr__(self): return pformat(self.model_dump()) def validate(self) -> Any: # type: ignore - return self.model_validate(self.model_dump()) + return self.model_validate_strings(self.model_dump()) def __getitem__(self, key): return getattr(self, key) From f56262110795fb75be69e0d3511817401630ab3c Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:49:58 +0100 Subject: [PATCH 13/25] Improve error collection for the Settings page --- gui_dev/src/pages/Settings/Settings.jsx | 66 ++--- .../Settings/components/FrequencyRange.jsx | 182 ++++++++++++ py_neuromodulation/default_settings.yaml | 6 +- py_neuromodulation/features/bandpower.py | 88 ++++-- py_neuromodulation/features/bursts.py | 7 +- py_neuromodulation/features/coherence.py | 9 +- .../features/feature_processor.py | 8 +- py_neuromodulation/features/fooof.py | 9 +- .../features/mne_connectivity.py | 28 +- py_neuromodulation/features/oscillatory.py | 3 +- py_neuromodulation/filter/kalman_filter.py | 23 +- py_neuromodulation/gui/backend/app_backend.py | 1 + py_neuromodulation/gui/backend/app_pynm.py | 2 - .../processing/data_preprocessor.py | 4 +- .../processing/filter_preprocessing.py | 32 +- .../processing/normalization.py | 16 +- py_neuromodulation/processing/projection.py | 4 +- py_neuromodulation/processing/resample.py | 6 +- py_neuromodulation/stream/settings.py | 54 ++-- py_neuromodulation/stream/stream.py | 6 +- .../utils/pydantic_extensions.py | 273 +++++++++++------- py_neuromodulation/utils/types.py | 148 +++++++++- tests/test_osc_features.py | 4 +- 23 files changed, 723 insertions(+), 256 deletions(-) create mode 100644 gui_dev/src/pages/Settings/components/FrequencyRange.jsx diff --git a/gui_dev/src/pages/Settings/Settings.jsx b/gui_dev/src/pages/Settings/Settings.jsx index 60b238d6..045603be 100644 --- a/gui_dev/src/pages/Settings/Settings.jsx +++ b/gui_dev/src/pages/Settings/Settings.jsx @@ -2,8 +2,6 @@ import { useEffect, useState } from "react"; import { Box, Button, - ButtonGroup, - InputAdornment, Popover, Stack, Switch, @@ -13,8 +11,7 @@ import { } from "@mui/material"; import { Link } from "react-router-dom"; import { CollapsibleBox, TitledBox } from "@/components"; -import { FrequencyRangeList } from "./FrequencyRange"; -import { Dropdown } from "./Dropdown"; +import { FrequencyRangeList } from "./components/FrequencyRange"; import { useSettingsStore, useStatusBarContent } from "@/stores"; import { filterObjectByKeys } from "@/utils/functions"; @@ -30,28 +27,33 @@ const BooleanField = ({ value, onChange, error }) => ( onChange(e.target.checked)} /> ); -const StringField = ({ value, onChange, label, error }) => ( - onChange(e.target.value)} - label={label} - sx={{ - "& .MuiOutlinedInput-root": { - "& fieldset": { - borderColor: error ? "error.main" : "inherit", - }, - "&:hover fieldset": { - borderColor: error ? "error.main" : "primary.main", - }, - "&.Mui-focused fieldset": { - borderColor: error ? "error.main" : "primary.main", - }, - }, - }} - /> -); +const errorStyle = { + "& .MuiOutlinedInput-root": { + "& fieldset": { borderColor: "error.main" }, + "&:hover fieldset": { + borderColor: "error.main", + }, + "&.Mui-focused fieldset": { + borderColor: "error.main", + }, + }, +}; + +const StringField = ({ value, onChange, label, error }) => { + const errorSx = error ? errorStyle : {}; + return ( + onChange(e.target.value)} + label={label} + sx={{ ...errorSx }} + /> + ); +}; const NumberField = ({ value, onChange, label, error }) => { + const errorSx = error ? errorStyle : {}; + const handleChange = (event) => { const newValue = event.target.value; // Only allow numbers and decimal point @@ -66,19 +68,7 @@ const NumberField = ({ value, onChange, label, error }) => { value={value} onChange={handleChange} label={label} - sx={{ - "& .MuiOutlinedInput-root": { - "& fieldset": { - borderColor: error ? "error.main" : "inherit", - }, - "&:hover fieldset": { - borderColor: error ? "error.main" : "primary.main", - }, - "&.Mui-focused fieldset": { - borderColor: error ? "error.main" : "primary.main", - }, - }, - }} + sx={{ ...errorSx }} // InputProps={{ // endAdornment: ( // @@ -98,7 +88,6 @@ const componentRegistry = { boolean: BooleanField, string: StringField, number: NumberField, - Array: Dropdown, }; const SettingsField = ({ path, Component, label, value, onChange, error }) => { @@ -376,6 +365,7 @@ export const Settings = () => { rangeOrder={frequencyRangeOrder} onOrderChange={updateFrequencyRangeOrder} onChange={handleChangeSettings} + errors={validationErrors} />
    diff --git a/gui_dev/src/pages/Settings/components/FrequencyRange.jsx b/gui_dev/src/pages/Settings/components/FrequencyRange.jsx new file mode 100644 index 00000000..16333166 --- /dev/null +++ b/gui_dev/src/pages/Settings/components/FrequencyRange.jsx @@ -0,0 +1,182 @@ +import { useState } from "react"; +import { TextField, Button, IconButton, Stack } from "@mui/material"; +import { Add, Close } from "@mui/icons-material"; +import { debounce } from "@/utils"; + +const NumberField = ({ ...props }) => ( + +); + +export const FrequencyRange = ({ + name, + range, + onChangeName, + onChangeRange, + onRemove, + error, +}) => { + const [localName, setLocalName] = useState(name); + + const debouncedChangeName = debounce((newName) => { + onChangeName(newName, name); + }, 1000); + + const handleNameChange = (e) => { + const newName = e.target.value; + setLocalName(newName); + debouncedChangeName(newName); + }; + + const handleNameBlur = () => { + onChangeName(localName, name); + }; + + const handleKeyPress = (e) => { + if (e.key === "Enter") { + console.log(e.target.value, name); + onChangeName(localName, name); + } + }; + + const handleRangeChange = (name, field, value) => { + // onChangeRange takes the name of the range as the first argument + onChangeRange(name, { ...range, [field]: value }); + }; + + return ( + + + + handleRangeChange(name, "frequency_low_hz", e.target.value) + } + label="Low Hz" + /> + + handleRangeChange(name, "frequency_high_hz", e.target.value) + } + label="High Hz" + /> + onRemove(name)} + color="primary" + disableRipple + sx={{ m: 0, p: 0 }} + > + + + + ); +}; + +export const FrequencyRangeList = ({ + ranges, + rangeOrder, + onChange, + onOrderChange, + errors, +}) => { + const handleChangeRange = (name, newRange) => { + const updatedRanges = { ...ranges }; + updatedRanges[name] = newRange; + onChange(["frequency_ranges_hz"], updatedRanges); + }; + + const handleChangeName = (newName, oldName) => { + if (oldName === newName) { + return; + } + + const updatedRanges = { ...ranges, [newName]: ranges[oldName] }; + delete updatedRanges[oldName]; + onChange(["frequency_ranges_hz"], updatedRanges); + + const updatedOrder = rangeOrder.map((name) => + name === oldName ? newName : name + ); + onOrderChange(updatedOrder); + }; + + const handleRemove = (name) => { + const updatedRanges = { ...ranges }; + delete updatedRanges[name]; + onChange(["frequency_ranges_hz"], updatedRanges); + + const updatedOrder = rangeOrder.filter((item) => item !== name); + onOrderChange(updatedOrder); + }; + + const addRange = () => { + let newName = "NewRange"; + let counter = 0; + // Find first available name + while (Object.hasOwn(ranges, newName)) { + counter++; + newName = `NewRange${counter}`; + } + + const updatedRanges = { + ...ranges, + [newName]: { + __field_type__: "FrequencyRange", + frequency_low_hz: 1, + frequency_high_hz: 500, + }, + }; + onChange(["frequency_ranges_hz"], updatedRanges); + + const updatedOrder = [...rangeOrder, newName]; + onOrderChange(updatedOrder); + }; + + return ( + + {rangeOrder.map((name, index) => ( + + ))} + + + ); +}; diff --git a/py_neuromodulation/default_settings.yaml b/py_neuromodulation/default_settings.yaml index 0019a78d..81c533e3 100644 --- a/py_neuromodulation/default_settings.yaml +++ b/py_neuromodulation/default_settings.yaml @@ -168,7 +168,7 @@ sharpwave_analysis_settings: rise_steepness: false decay_steepness: false slope_ratio: false - filter_ranges_hz: + filter_ranges_hz: # list of [low_hz, high_hz] - [5, 80] - [5, 30] detect_troughs: @@ -228,8 +228,8 @@ nolds_settings: frequency_bands: [low_beta] mne_connectiviy_settings: - method: plv - mode: multitaper + method: plv # One of ['coh', 'cohy', 'imcoh', 'cacoh', 'mic', 'mim', 'plv', 'ciplv', 'ppc', 'pli', 'dpli','wpli', 'wpli2_debiased', 'gc', 'gc_tr'] + mode: multitaper # One of ['multitaper', 'fourier', 'cwt_morlet'] bispectrum_settings: f1s: [5, 35] diff --git a/py_neuromodulation/features/bandpower.py b/py_neuromodulation/features/bandpower.py index dac13f4b..72dab126 100644 --- a/py_neuromodulation/features/bandpower.py +++ b/py_neuromodulation/features/bandpower.py @@ -2,8 +2,13 @@ from collections.abc import Sequence from typing import TYPE_CHECKING from pydantic import field_validator - from py_neuromodulation.utils.types import NMBaseModel, BoolSelector, NMFeature +from py_neuromodulation.utils.pydantic_extensions import ( + NMField, + NMErrorList, + create_validation_error, +) +from py_neuromodulation import logger if TYPE_CHECKING: from py_neuromodulation.stream.settings import NMSettings @@ -17,15 +22,18 @@ class BandpowerFeatures(BoolSelector): class BandPowerSettings(NMBaseModel): - segment_lengths_ms: dict[str, int] = { - "theta": 1000, - "alpha": 500, - "low beta": 333, - "high beta": 333, - "low gamma": 100, - "high gamma": 100, - "HFA": 100, - } + segment_lengths_ms: dict[str, int] = NMField( + default={ + "theta": 1000, + "alpha": 500, + "low beta": 333, + "high beta": 333, + "low gamma": 100, + "high gamma": 100, + "HFA": 100, + }, + custom_metadata={"field_type": "FrequencySegmentLength"}, + ) bandpower_features: BandpowerFeatures = BandpowerFeatures() log_transform: bool = True kalman_filter: bool = False @@ -33,24 +41,58 @@ class BandPowerSettings(NMBaseModel): @field_validator("bandpower_features") @classmethod def bandpower_features_validator(cls, bandpower_features: BandpowerFeatures): - assert ( - len(bandpower_features.get_enabled()) > 0 - ), "Set at least one bandpower_feature to True." - + if not len(bandpower_features.get_enabled()) > 0: + raise create_validation_error( + error_message="Set at least one bandpower_feature to True.", + location=["bandpass_filter_settings", "bandpower_features"], + ) return bandpower_features - def validate_fbands(self, settings: "NMSettings") -> None: + def validate_fbands(self, settings: "NMSettings") -> NMErrorList: + """_summary_ + + :param settings: _description_ + :type settings: NMSettings + :raises create_validation_error: _description_ + :raises create_validation_error: _description_ + :raises ValueError: _description_ + """ + errors: NMErrorList = NMErrorList() + for fband_name, seg_length_fband in self.segment_lengths_ms.items(): - assert seg_length_fband <= settings.segment_length_features_ms, ( - f"segment length {seg_length_fband} needs to be smaller than " - f" settings['segment_length_features_ms'] = {settings.segment_length_features_ms}" - ) + # Check that all frequency bands are defined in settings.frequency_ranges_hz + if fband_name not in settings.frequency_ranges_hz: + logger.warning( + f"Frequency band {fband_name} in bandpass_filter_settings.segment_lengths_ms" + " is not defined in settings.frequency_ranges_hz" + ) + + # Check that all segment lengths are smaller than settings.segment_length_features_ms + if not seg_length_fband <= settings.segment_length_features_ms: + errors.add_error( + f"segment length {seg_length_fband} needs to be smaller than " + f" settings['segment_length_features_ms'] = {settings.segment_length_features_ms}", + location=[ + "bandpass_filter_settings", + "segment_lengths_ms", + fband_name, + ], + ) + # Check that all frequency bands defined in settings.frequency_ranges_hz for fband_name in settings.frequency_ranges_hz.keys(): - assert fband_name in self.segment_lengths_ms, ( - f"frequency range {fband_name} " - "needs to be defined in settings.bandpass_filter_settings.segment_lengths_ms]" - ) + if fband_name not in self.segment_lengths_ms: + errors.add_error( + f"frequency range {fband_name} " + "needs to be defined in settings.bandpass_filter_settings.segment_lengths_ms", + location=[ + "bandpass_filter_settings", + "segment_lengths_ms", + fband_name, + ], + ) + + return errors class BandPower(NMFeature): diff --git a/py_neuromodulation/features/bursts.py b/py_neuromodulation/features/bursts.py index 2ff8ceac..e9f5b632 100644 --- a/py_neuromodulation/features/bursts.py +++ b/py_neuromodulation/features/bursts.py @@ -7,8 +7,9 @@ from collections.abc import Sequence from itertools import product -from pydantic import Field, field_validator +from pydantic import field_validator from py_neuromodulation.utils.types import BoolSelector, NMBaseModel, NMFeature +from py_neuromodulation.utils.pydantic_extensions import NMField from typing import TYPE_CHECKING, Callable from py_neuromodulation.utils.pydantic_extensions import create_validation_error @@ -46,8 +47,8 @@ class BurstFeatures(BoolSelector): class BurstsSettings(NMBaseModel): - threshold: float = Field(default=75, ge=0, le=100) - time_duration_s: float = Field(default=30, ge=0) + threshold: float = NMField(default=75, ge=0) + time_duration_s: float = NMField(default=30, ge=0, custom_metadata={"unit": "s"}) frequency_bands: list[str] = ["low_beta", "high_beta", "low_gamma"] burst_features: BurstFeatures = BurstFeatures() diff --git a/py_neuromodulation/features/coherence.py b/py_neuromodulation/features/coherence.py index 21ca471b..6a100710 100644 --- a/py_neuromodulation/features/coherence.py +++ b/py_neuromodulation/features/coherence.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated -from pydantic import Field, field_validator +from pydantic import field_validator from py_neuromodulation.utils.types import ( NMFeature, @@ -11,6 +11,7 @@ FrequencyRange, NMBaseModel, ) +from py_neuromodulation.utils.pydantic_extensions import NMField from py_neuromodulation import logger if TYPE_CHECKING: @@ -28,14 +29,16 @@ class CoherenceFeatures(BoolSelector): max_allfbands: bool = True -ListOfTwoStr = Annotated[list[str], Field(min_length=2, max_length=2)] +# TODO: make this into a pydantic model that only accepts names from +# the channels objects and does not accept the same string twice +ListOfTwoStr = Annotated[list[str], NMField(min_length=2, max_length=2)] class CoherenceSettings(NMBaseModel): features: CoherenceFeatures = CoherenceFeatures() method: CoherenceMethods = CoherenceMethods() channels: list[ListOfTwoStr] = [] - frequency_bands: list[str] = Field(default=["high_beta"], min_length=1) + frequency_bands: list[str] = NMField(default=["high_beta"], min_length=1) @field_validator("frequency_bands") def fbands_spaces_to_underscores(cls, frequency_bands): diff --git a/py_neuromodulation/features/feature_processor.py b/py_neuromodulation/features/feature_processor.py index 9e970e8f..dbb7e5c9 100644 --- a/py_neuromodulation/features/feature_processor.py +++ b/py_neuromodulation/features/feature_processor.py @@ -1,13 +1,13 @@ from typing import Type, TYPE_CHECKING -from py_neuromodulation.utils.types import NMFeature, FeatureName +from py_neuromodulation.utils.types import NMFeature, FEATURE_NAME if TYPE_CHECKING: import numpy as np from py_neuromodulation import NMSettings -FEATURE_DICT: dict[FeatureName | str, str] = { +FEATURE_DICT: dict[FEATURE_NAME | str, str] = { "raw_hjorth": "Hjorth", "return_raw": "Raw", "bandpass_filter": "BandPower", @@ -42,7 +42,7 @@ def __init__( from importlib import import_module # Accept 'str' for custom features - self.features: dict[FeatureName | str, NMFeature] = { + self.features: dict[FEATURE_NAME | str, NMFeature] = { feature_name: getattr( import_module("py_neuromodulation.features"), FEATURE_DICT[feature_name] )(settings, ch_names, sfreq) @@ -83,7 +83,7 @@ def estimate_features(self, data: "np.ndarray") -> dict: return feature_results - def get_feature(self, fname: FeatureName) -> NMFeature: + def get_feature(self, fname: FEATURE_NAME) -> NMFeature: return self.features[fname] diff --git a/py_neuromodulation/features/fooof.py b/py_neuromodulation/features/fooof.py index 683c5304..9f4b9544 100644 --- a/py_neuromodulation/features/fooof.py +++ b/py_neuromodulation/features/fooof.py @@ -9,6 +9,7 @@ BoolSelector, FrequencyRange, ) +from py_neuromodulation.utils.pydantic_extensions import NMField if TYPE_CHECKING: from py_neuromodulation import NMSettings @@ -29,11 +30,11 @@ class FooofPeriodicSettings(BoolSelector): class FooofSettings(NMBaseModel): aperiodic: FooofAperiodicSettings = FooofAperiodicSettings() periodic: FooofPeriodicSettings = FooofPeriodicSettings() - windowlength_ms: float = 800 + windowlength_ms: float = NMField(800, gt=0, custom_metadata={"unit": "ms"}) peak_width_limits: FrequencyRange = FrequencyRange(0.5, 12) - max_n_peaks: int = 3 - min_peak_height: float = 0 - peak_threshold: float = 2 + max_n_peaks: int = NMField(3, ge=0) + min_peak_height: float = NMField(0, ge=0) + peak_threshold: float = NMField(2, ge=0) freq_range_hz: FrequencyRange = FrequencyRange(2, 40) knee: bool = True diff --git a/py_neuromodulation/features/mne_connectivity.py b/py_neuromodulation/features/mne_connectivity.py index 88883771..f7a186e7 100644 --- a/py_neuromodulation/features/mne_connectivity.py +++ b/py_neuromodulation/features/mne_connectivity.py @@ -1,8 +1,9 @@ from collections.abc import Iterable import numpy as np -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from py_neuromodulation.utils.types import NMFeature, NMBaseModel +from py_neuromodulation.utils.pydantic_extensions import NMField if TYPE_CHECKING: from py_neuromodulation import NMSettings @@ -10,9 +11,30 @@ from mne import Epochs +MNE_CONNECTIVITY_METHOD = Literal[ + "coh", + "cohy", + "imcoh", + "cacoh", + "mic", + "mim", + "plv", + "ciplv", + "ppc", + "pli", + "dpli", + "wpli", + "wpli2_debiased", + "gc", + "gc_tr", +] + +MNE_CONNECTIVITY_MODE = Literal["multitaper", "fourier", "cwt_morlet"] + + class MNEConnectivitySettings(NMBaseModel): - method: str = "plv" - mode: str = "multitaper" + method: MNE_CONNECTIVITY_METHOD = NMField(default="plv") + mode: MNE_CONNECTIVITY_MODE = NMField(default="multitaper") class MNEConnectivity(NMFeature): diff --git a/py_neuromodulation/features/oscillatory.py b/py_neuromodulation/features/oscillatory.py index b8b54005..7ee8a20a 100644 --- a/py_neuromodulation/features/oscillatory.py +++ b/py_neuromodulation/features/oscillatory.py @@ -3,6 +3,7 @@ from itertools import product from py_neuromodulation.utils.types import NMBaseModel, BoolSelector, NMFeature +from py_neuromodulation.utils.pydantic_extensions import NMField from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -17,7 +18,7 @@ class OscillatoryFeatures(BoolSelector): class OscillatorySettings(NMBaseModel): - windowlength_ms: int = 1000 + windowlength_ms: int = NMField(1000, gt=0, custom_metadata={"unit": "ms"}) log_transform: bool = True features: OscillatoryFeatures = OscillatoryFeatures( mean=True, median=False, std=False, max=False diff --git a/py_neuromodulation/filter/kalman_filter.py b/py_neuromodulation/filter/kalman_filter.py index b7ae1075..0ebfe195 100644 --- a/py_neuromodulation/filter/kalman_filter.py +++ b/py_neuromodulation/filter/kalman_filter.py @@ -1,7 +1,9 @@ import numpy as np from typing import TYPE_CHECKING + from py_neuromodulation.utils.types import NMBaseModel +from py_neuromodulation.utils.pydantic_extensions import NMErrorList if TYPE_CHECKING: @@ -22,13 +24,22 @@ class KalmanSettings(NMBaseModel): "HFA", ] - def validate_fbands(self, settings: "NMSettings") -> None: - assert all( + def validate_fbands(self, settings: "NMSettings") -> NMErrorList: + errors: NMErrorList = NMErrorList() + + if not all( (item in settings.frequency_ranges_hz for item in self.frequency_bands) - ), ( - "Frequency bands for Kalman filter must also be specified in " - "bandpass_filter_settings." - ) + ): + errors.add_error( + "Frequency bands for Kalman filter must also be specified in " + "frequency_ranges_hz.", + location=[ + "kalman_filter_settings", + "frequency_bands", + ], + ) + + return errors def define_KF(Tp, sigma_w, sigma_v): diff --git a/py_neuromodulation/gui/backend/app_backend.py b/py_neuromodulation/gui/backend/app_backend.py index 9c7b0a88..abbccfee 100644 --- a/py_neuromodulation/gui/backend/app_backend.py +++ b/py_neuromodulation/gui/backend/app_backend.py @@ -110,6 +110,7 @@ async def update_settings(data: dict, validate_only: bool = Query(False)): try: # First, validate with Pydantic try: + # TODO: check if this works properly or needs model_validate_strings validated_settings = NMSettings.model_validate(data) except ValidationError as e: if not validate_only: diff --git a/py_neuromodulation/gui/backend/app_pynm.py b/py_neuromodulation/gui/backend/app_pynm.py index 282f3252..f4853e75 100644 --- a/py_neuromodulation/gui/backend/app_pynm.py +++ b/py_neuromodulation/gui/backend/app_pynm.py @@ -69,7 +69,6 @@ async def start_run_function( # time.sleep(2) # self.logger.info(f"Stream running: {self.stream.is_running}") - def setup_lsl_stream( self, lsl_stream_name: str | None = None, @@ -154,4 +153,3 @@ def setup_offline_stream( line_noise=line_noise, sampling_rate_features_hz=sampling_rate_features, ) - diff --git a/py_neuromodulation/processing/data_preprocessor.py b/py_neuromodulation/processing/data_preprocessor.py index 2946ba6c..d1677db0 100644 --- a/py_neuromodulation/processing/data_preprocessor.py +++ b/py_neuromodulation/processing/data_preprocessor.py @@ -1,12 +1,12 @@ from typing import TYPE_CHECKING, Type -from py_neuromodulation.utils.types import PreprocessorName, NMPreprocessor +from py_neuromodulation.utils.types import PREPROCESSOR_NAME, NMPreprocessor if TYPE_CHECKING: import numpy as np import pandas as pd from py_neuromodulation.stream.settings import NMSettings -PREPROCESSOR_DICT: dict[PreprocessorName, str] = { +PREPROCESSOR_DICT: dict[PREPROCESSOR_NAME, str] = { "preprocessing_filter": "PreprocessingFilter", "notch_filter": "NotchFilter", "raw_resampling": "Resampler", diff --git a/py_neuromodulation/processing/filter_preprocessing.py b/py_neuromodulation/processing/filter_preprocessing.py index e6515ae0..2c5addbc 100644 --- a/py_neuromodulation/processing/filter_preprocessing.py +++ b/py_neuromodulation/processing/filter_preprocessing.py @@ -1,22 +1,14 @@ import numpy as np -from pydantic import Field from typing import TYPE_CHECKING from py_neuromodulation.utils.types import BoolSelector, FrequencyRange, NMPreprocessor +from py_neuromodulation.utils.pydantic_extensions import NMField if TYPE_CHECKING: from py_neuromodulation import NMSettings -FILTER_SETTINGS_MAP = { - "bandstop_filter": "bandstop_filter_settings", - "bandpass_filter": "bandpass_filter_settings", - "lowpass_filter": "lowpass_filter_cutoff_hz", - "highpass_filter": "highpass_filter_cutoff_hz", -} - - class FilterSettings(BoolSelector): bandstop_filter: bool = True bandpass_filter: bool = True @@ -25,21 +17,23 @@ class FilterSettings(BoolSelector): bandstop_filter_settings: FrequencyRange = FrequencyRange(100, 160) bandpass_filter_settings: FrequencyRange = FrequencyRange(2, 200) - lowpass_filter_cutoff_hz: float = Field(default=200) - highpass_filter_cutoff_hz: float = Field(default=3) - - def get_filter_tuple(self, filter_name) -> tuple[float | None, float | None]: - filter_value = self[FILTER_SETTINGS_MAP[filter_name]] - + lowpass_filter_cutoff_hz: float = NMField( + default=200, gt=0, custom_metadata={"unit": "Hz"} + ) + highpass_filter_cutoff_hz: float = NMField( + default=3, gt=0, custom_metadata={"unit": "Hz"} + ) + + def get_filter_tuple(self, filter_name) -> FrequencyRange: match filter_name: case "bandstop_filter": - return (filter_value.frequency_high_hz, filter_value.frequency_low_hz) + return self.bandstop_filter_settings case "bandpass_filter": - return (filter_value.frequency_low_hz, filter_value.frequency_high_hz) + return self.bandpass_filter_settings case "lowpass_filter": - return (None, filter_value) + return FrequencyRange(None, self.lowpass_filter_cutoff_hz) case "highpass_filter": - return (filter_value, None) + return FrequencyRange(self.highpass_filter_cutoff_hz, None) case _: raise ValueError( "Filter name must be one of 'bandstop_filter', 'lowpass_filter', " diff --git a/py_neuromodulation/processing/normalization.py b/py_neuromodulation/processing/normalization.py index 66419cec..08008614 100644 --- a/py_neuromodulation/processing/normalization.py +++ b/py_neuromodulation/processing/normalization.py @@ -2,11 +2,11 @@ import numpy as np from typing import TYPE_CHECKING, Callable, Literal, get_args -from pydantic import Field +from py_neuromodulation.utils.pydantic_extensions import NMField from py_neuromodulation.utils.types import ( NMBaseModel, - NormMethod, + NORM_METHOD, NMPreprocessor, ) @@ -17,13 +17,13 @@ class NormalizationSettings(NMBaseModel): - normalization_time_s: float = 30 - normalization_method: NormMethod = "zscore" - clip: float = Field(default=3, ge=0) + normalization_time_s: float = NMField(30, gt=0, custom_metadata={"unit": "s"}) + normalization_method: NORM_METHOD = NMField(default="zscore") + clip: float = NMField(default=3, ge=0, custom_metadata={"unit": "a.u."}) @staticmethod - def list_normalization_methods() -> list[NormMethod]: - return list(get_args(NormMethod)) + def list_normalization_methods() -> list[NORM_METHOD]: + return list(get_args(NORM_METHOD)) class Normalizer(NMPreprocessor): @@ -55,7 +55,7 @@ def __init__( if self.using_sklearn: import sklearn.preprocessing as skpp - NORM_METHODS_SKLEARN: dict[NormMethod, Callable] = { + NORM_METHODS_SKLEARN: dict[NORM_METHOD, Callable] = { "quantile": lambda: skpp.QuantileTransformer(n_quantiles=300), "robust": skpp.RobustScaler, "minmax": skpp.MinMaxScaler, diff --git a/py_neuromodulation/processing/projection.py b/py_neuromodulation/processing/projection.py index 4cdc3610..3a83a29f 100644 --- a/py_neuromodulation/processing/projection.py +++ b/py_neuromodulation/processing/projection.py @@ -1,6 +1,6 @@ import numpy as np -from pydantic import Field from py_neuromodulation.utils.types import NMBaseModel +from py_neuromodulation.utils.pydantic_extensions import NMField from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -9,7 +9,7 @@ class ProjectionSettings(NMBaseModel): - max_dist_mm: float = Field(default=20.0, gt=0.0) + max_dist_mm: float = NMField(default=20.0, gt=0.0, custom_metadata={"unit": "mm"}) class Projection: diff --git a/py_neuromodulation/processing/resample.py b/py_neuromodulation/processing/resample.py index 73014d3a..949e9e57 100644 --- a/py_neuromodulation/processing/resample.py +++ b/py_neuromodulation/processing/resample.py @@ -1,12 +1,14 @@ """Module for resampling.""" import numpy as np -from pydantic import Field from py_neuromodulation.utils.types import NMBaseModel, NMPreprocessor +from py_neuromodulation.utils.pydantic_extensions import NMField class ResamplerSettings(NMBaseModel): - resample_freq_hz: float = Field(default=1000, gt=0) + resample_freq_hz: float = NMField( + default=1000, gt=0, custom_metadata={"unit": "Hz"} + ) class Resampler(NMPreprocessor): diff --git a/py_neuromodulation/stream/settings.py b/py_neuromodulation/stream/settings.py index 39a9b6fd..14bc29d8 100644 --- a/py_neuromodulation/stream/settings.py +++ b/py_neuromodulation/stream/settings.py @@ -1,19 +1,22 @@ """Module for handling settings.""" from pathlib import Path -from typing import ClassVar -from pydantic import Field, model_validator +from typing import Any, ClassVar +from pydantic import model_validator, ValidationError +from pydantic.functional_validators import ModelWrapValidatorHandler from py_neuromodulation import logger, user_features +from types import SimpleNamespace from py_neuromodulation.utils.types import ( BoolSelector, FrequencyRange, _PathLike, NMBaseModel, - NormMethod, + NORM_METHOD, PreprocessorList, ) +from py_neuromodulation.utils.pydantic_extensions import NMErrorList, NMField from py_neuromodulation.processing.filter_preprocessing import FilterSettings from py_neuromodulation.processing.normalization import NormalizationSettings @@ -31,7 +34,9 @@ from py_neuromodulation.features import BurstsSettings -class FeatureSelection(BoolSelector): +# TONI: this class has the proble that if a feature is absent, +# it won't default to False but to whatever is defined here as default +class FeatureSelector(BoolSelector): raw_hjorth: bool = True return_raw: bool = True bandpass_filter: bool = False @@ -59,8 +64,12 @@ class NMSettings(NMBaseModel): _instances: ClassVar[list["NMSettings"]] = [] # General settings - sampling_rate_features_hz: float = Field(default=10, gt=0) - segment_length_features_ms: float = Field(default=1000, gt=0) + sampling_rate_features_hz: float = NMField( + default=10, gt=0, custom_metadata={"unit": "Hz"} + ) + segment_length_features_ms: float = NMField( + default=1000, gt=0, custom_metadata={"unit": "ms"} + ) frequency_ranges_hz: dict[str, FrequencyRange] = { "theta": FrequencyRange(4, 8), "alpha": FrequencyRange(8, 12), @@ -91,7 +100,7 @@ class NMSettings(NMBaseModel): project_subcortex_settings: ProjectionSettings = ProjectionSettings(max_dist_mm=5) # Feature settings - features: FeatureSelection = FeatureSelection() + features: FeatureSelector = FeatureSelector() fft_settings: OscillatorySettings = OscillatorySettings() welch_settings: OscillatorySettings = OscillatorySettings() @@ -129,10 +138,23 @@ def _remove_feature(cls, feature: str) -> None: for instance in cls._instances: delattr(instance.features, feature) - @model_validator(mode="after") - def validate_settings(self): + @model_validator(mode="wrap") # type: ignore[reportIncompatibleMethodOverride] + def validate_settings(self, handler: ModelWrapValidatorHandler) -> Any: + # Perform all necessary custom validations in the settings class and also + # all validations in the feature classes that need additional information from + # the settings class + errors: NMErrorList = NMErrorList() + + try: + # validate the model + handler(self) + except ValidationError as e: + self = NMSettings.unvalidated(**self) + NMSettings.model_fields_set + errors.extend(NMErrorList(e.errors())) + if len(self.features.get_enabled()) == 0: - raise ValueError("At least one feature must be selected.") + errors.add_error("At least one feature must be selected.") # Replace spaces with underscores in frequency band names self.frequency_ranges_hz = { @@ -141,16 +163,14 @@ def validate_settings(self): if self.features.bandpass_filter: # Check BandPass settings frequency bands - self.bandpass_filter_settings.validate_fbands(self) + errors.extend(self.bandpass_filter_settings.validate_fbands(self)) # Check Kalman filter frequency bands if self.bandpass_filter_settings.kalman_filter: - self.kalman_filter_settings.validate_fbands(self) + errors.extend(self.kalman_filter_settings.validate_fbands(self)) - # TONI: not needed after NMSequenceModel, remove in the future - # for k, v in self.frequency_ranges_hz.items(): - # if not isinstance(v, FrequencyRange): - # self.frequency_ranges_hz[k] = FrequencyRange.create_from(v) + if len(errors) > 0: + raise errors.create_error() return self @@ -259,7 +279,7 @@ def get_default() -> "NMSettings": return NMSettings() @staticmethod - def list_normalization_methods() -> list[NormMethod]: + def list_normalization_methods() -> list[NORM_METHOD]: return NormalizationSettings.list_normalization_methods() def save( diff --git a/py_neuromodulation/stream/stream.py b/py_neuromodulation/stream/stream.py index 2f22aac4..240efae8 100644 --- a/py_neuromodulation/stream/stream.py +++ b/py_neuromodulation/stream/stream.py @@ -9,7 +9,7 @@ from contextlib import suppress from py_neuromodulation.stream.data_processor import DataProcessor -from py_neuromodulation.utils.types import _PathLike, FeatureName +from py_neuromodulation.utils.types import _PathLike, FEATURE_NAME from py_neuromodulation.stream.settings import NMSettings if TYPE_CHECKING: @@ -84,7 +84,7 @@ def __init__( raise ValueError("Either `channels` or `data` must be passed to `Stream`.") # If features that use frequency ranges are on, test them against nyquist frequency - use_freq_ranges: list[FeatureName] = [ + use_freq_ranges: list[FEATURE_NAME] = [ "bandpass_filter", "stft", "fft", @@ -328,7 +328,7 @@ async def run( # if self.feature_queue is not None: # self.feature_queue.put(feature_dict) - + if websocket_featues is not None: await websocket_featues.send_message(feature_dict) self.batch_count += 1 diff --git a/py_neuromodulation/utils/pydantic_extensions.py b/py_neuromodulation/utils/pydantic_extensions.py index e679eec4..ee89ccea 100644 --- a/py_neuromodulation/utils/pydantic_extensions.py +++ b/py_neuromodulation/utils/pydantic_extensions.py @@ -1,48 +1,114 @@ -from typing import Any, get_type_hints, TypeVar, Generic, Literal, overload +import copy +from typing import ( + Any, + get_origin, + get_args, + get_type_hints, + TypeVar, + Generic, + Literal, + cast, + Sequence, +) from typing_extensions import Unpack, TypedDict -from pydantic import BaseModel, ConfigDict, model_validator -from pydantic_core import PydanticUndefined, ValidationError, InitErrorDetails +from pydantic import BaseModel, ConfigDict, model_validator, model_serializer + +from pydantic_core import ( + ErrorDetails, + PydanticUndefined, + InitErrorDetails, + ValidationError, +) from pydantic.fields import FieldInfo, _FieldInfoInputs, _FromFieldInfoInputs from pprint import pformat def create_validation_error( error_message: str, - loc: list[str | int] | None = None, + location: list[str | int] = [], title: str = "Validation Error", - input_type: Literal["python", "json"] = "python", - hide_input: bool = False, + error_type="value_error", ) -> ValidationError: """ - Factory function to create a Pydantic v2 ValidationError instance from a single error message. + Factory function to create a Pydantic v2 ValidationError. Args: error_message (str): The error message for the ValidationError. loc (List[str | int], optional): The location of the error. Defaults to None. title (str, optional): The title of the error. Defaults to "Validation Error". - input_type (Literal["python", "json"], optional): Whether the error is for a Python object or JSON. Defaults to "python". - hide_input (bool, optional): Whether to hide the input value in the error message. Defaults to False. Returns: ValidationError: A Pydantic ValidationError instance. """ - if loc is None: - loc = [] - - line_errors = [ - InitErrorDetails( - type="value_error", loc=tuple(loc), input=None, ctx={"error": error_message} - ) - ] return ValidationError.from_exception_data( title=title, - line_errors=line_errors, - input_type=input_type, - hide_input=hide_input, + line_errors=[ + InitErrorDetails( + type=error_type, + loc=tuple(location), + input=None, + ctx={"error": error_message}, + ) + ], + input_type="python", + hide_input=False, ) +class NMErrorList: + """Class to handle data about Pydantic errors. + Stores data in a list of InitErrorDetails. Errors can be accessed but not modified. + + :return: _description_ + :rtype: _type_ + """ + + def __init__( + self, errors: Sequence[InitErrorDetails | ErrorDetails] | None = None + ) -> None: + self.__errors: list[InitErrorDetails | ErrorDetails] = [e for e in errors or []] + + def add_error( + self, + error_message: str, + location: list[str | int] = [], + error_type="value_error", + ) -> None: + self.__errors.append( + InitErrorDetails( + type=error_type, + loc=tuple(location), + input=None, + ctx={"error": error_message}, + ) + ) + + def create_error(self, title: str = "Validation Error") -> ValidationError: + return ValidationError.from_exception_data( + title=title, line_errors=cast(list[InitErrorDetails], self.__errors) + ) + + def extend(self, errors: "NMErrorList"): + self.__errors.extend(errors.__errors) + + def __iter__(self): + return iter(self.__errors) + + def __len__(self): + return len(self.__errors) + + def __getitem__(self, idx): + # Return a copy of the error to prevent modification + return copy.deepcopy(self.__errors[idx]) + + def __repr__(self): + return repr(self.__errors) + + def __str__(self): + return str(self.__errors) + + class _NMExtraFieldInputs(TypedDict, total=False): """Additional fields to add on top of the pydantic FieldInfo""" @@ -66,7 +132,7 @@ class NMFieldInfo(FieldInfo): _default_values = {} def __init__(self, **kwargs: Unpack[_NMFieldInfoInputs]) -> None: - self.custom_metadata = kwargs.pop("custom_metadata", {}) + self.custom_metadata: dict[str, Any] = kwargs.pop("custom_metadata", {}) super().__init__(**kwargs) @staticmethod @@ -94,16 +160,17 @@ def NMField( class NMBaseModel(BaseModel): - model_config = ConfigDict(validate_assignment=False, extra="allow") + # model_config = ConfigDict(validate_assignment=False, extra="allow") def __init__(self, *args, **kwargs) -> None: """Pydantic does not support positional arguments by default. This is a workaround to support positional arguments for models like FrequencyRange. It converts positional arguments to kwargs and then calls the base class __init__. """ + if not args: # Simple case - just use kwargs - super().__init__(**kwargs) + super().__init__(*args, **kwargs) return field_names = list(self.model_fields.keys()) @@ -128,14 +195,16 @@ def __init__(self, *args, **kwargs) -> None: complete_kwargs.update(kwargs) super().__init__(**complete_kwargs) + __init__.__pydantic_base_init__ = True # type: ignore + def __str__(self): return pformat(self.model_dump()) - def __repr__(self): - return pformat(self.model_dump()) + # def __repr__(self): + # return pformat(self.model_dump()) - def validate(self) -> Any: # type: ignore - return self.model_validate_strings(self.model_dump()) + def validate(self, context: Any | None = None) -> Any: # type: ignore + return self.model_validate(self.model_dump(), context=context) def __getitem__(self, key): return getattr(self, key) @@ -171,37 +240,69 @@ def serialize_with_metadata(self): # Extract unit information from Annotated type if isinstance(field_info, NMFieldInfo): - for tag, value in field_info.custom_metadata.items(): - result[f"__{tag}__"] = value + # Convert scalar value to dict with metadata + field_dict = { + "value": value, + # __field_type__ will be overwritte if set in custom_metadata + "__field_type__": type(value).__name__, + **{ + f"__{tag}__": value + for tag, value in field_info.custom_metadata.items() + }, + } + # Add possible values for Literal types + if get_origin(field_info.annotation) is Literal: + field_dict["__valid_values__"] = list( + get_args(field_info.annotation) + ) + + result[field_name] = field_dict return result + @classmethod + def unvalidated(cls, **data: Any) -> Any: + def process_value(value: Any, field_type: Any) -> Any: + if isinstance(value, dict) and hasattr( + field_type, "__pydantic_core_schema__" + ): + # Recursively handle nested Pydantic models + return field_type.unvalidated(**value) + elif isinstance(value, list): + # Handle lists of Pydantic models + if hasattr(field_type, "__args__") and hasattr( + field_type.__args__[0], "__pydantic_core_schema__" + ): + return [ + field_type.__args__[0].unvalidated(**item) + if isinstance(item, dict) + else item + for item in value + ] + return value + + processed_data = {} + for name, field in cls.model_fields.items(): + try: + value = data[name] + processed_data[name] = process_value(value, field.annotation) + except KeyError: + if not field.is_required(): + processed_data[name] = copy.deepcopy(field.default) + else: + raise TypeError(f"Missing required keyword argument {name!r}") + + self = cls.__new__(cls) + object.__setattr__(self, "__dict__", processed_data) + object.__setattr__(self, "__pydantic_private__", {"extra": None}) + object.__setattr__(self, "__pydantic_fields_set__", set(processed_data.keys())) + return self + ################################# #### Generic Pydantic models #### ################################# -def create_alias_property(index: int, alias: str, classname: str): - """Creates a property that accesses the root sequence at the given index""" - - def getter(self): - return self.root[index] - - def setter(self, value): - if isinstance(self.root, tuple): - new_values = list(self.root) - new_values[index] = value - self.root = tuple(new_values) - else: - self.root[index] = value - - return property( - fget=getter, - fset=setter, - doc=f"Alias '{alias}' for position [{index}] of class '{classname}'.", - ) - - T = TypeVar("T") C = TypeVar("C", list, tuple) @@ -215,16 +316,6 @@ class NMSequenceModel(NMBaseModel, Generic[C]): __aliases__: dict[int, list[str]] = {} def __init__(self, *args, **kwargs) -> None: - # Generate properties programatically (not used currently) - # for index, aliases in self.__aliases__.items(): - # for alias in aliases: - # if not hasattr(self.__class__, alias): - # setattr( - # self.__class__, - # alias, - # create_alias_property(index, alias, self.__class__.__name__), - # ) - if len(args) == 1 and isinstance(args[0], (list, tuple)): kwargs["root"] = args[0] elif len(args) == 1: @@ -303,45 +394,27 @@ def validate_input(cls, value: Any) -> dict[str, Any]: # Else, make it a list return {"root": [value]} - -class NMValueModel(NMBaseModel, Generic[T]): - """Base class for single-value models that behave like their contained type""" - - root: T - - @model_validator(mode="before") - @classmethod - def validate_input(cls, value: Any) -> dict[str, Any]: - if isinstance(value, dict): - if "root" in value: - return value - # If it's a dict without root, assume the first value is our value - if len(value) > 0: - return {"root": next(iter(value.values()))} - return {"root": None} - return {"root": value} - - def __str__(self) -> str: - return str(self.root) - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({repr(self.root)})" - - def model_dump(self): # type: ignore[reportIncompatibleMethodOverride] + @model_serializer + def ser_model(self): return self.root - def model_dump_json(self, **kwargs): - import json - - return json.dumps(self.root, **kwargs) - - def serialize_with_metadata(self) -> dict[str, Any]: - result = {"__field_type__": self.__class__.__name__, "value": self.root} - - # Add any field metadata from the root field - field_info = self.model_fields.get("root") - if isinstance(field_info, NMFieldInfo): - for tag, value in field_info.custom_metadata.items(): - result[f"__{tag}__"] = value - - return result + # Custom validator to skip the 'root' field in validation errors + @model_validator(mode="wrap") # type: ignore[reportIncompatibleMethodOverride] + def rewrite_error_locations(self, handler): + try: + return handler(self) + except ValidationError as e: + errors = [] + for err in e.errors(): + loc = list(err["loc"]) + # Find and remove 'root' from the location path + if "root" in loc: + root_idx = loc.index("root") + if root_idx < len(loc) - 1: + loc = loc[:root_idx] + loc[root_idx + 1 :] + err["loc"] = tuple(loc) + errors.append(err) + print(errors) + raise ValidationError.from_exception_data( + title="ValidationError", line_errors=errors + ) diff --git a/py_neuromodulation/utils/types.py b/py_neuromodulation/utils/types.py index a2c22db9..13b2923e 100644 --- a/py_neuromodulation/utils/types.py +++ b/py_neuromodulation/utils/types.py @@ -1,8 +1,9 @@ from os import PathLike from math import isnan -from typing import Literal, TYPE_CHECKING +from typing import Literal, TYPE_CHECKING, Any, TypeVar from pydantic import BaseModel, ConfigDict, model_validator -from .pydantic_extensions import NMBaseModel, NMSequenceModel +from .pydantic_extensions import NMBaseModel, NMSequenceModel, NMField +from abc import abstractmethod from collections.abc import Sequence from datetime import datetime @@ -18,7 +19,7 @@ _PathLike = str | PathLike -FeatureName = Literal[ +FEATURE_NAME = Literal[ "raw_hjorth", "return_raw", "bandpass_filter", @@ -35,7 +36,7 @@ "bispectrum", ] -PreprocessorName = Literal[ +PREPROCESSOR_NAME = Literal[ "preprocessing_filter", "notch_filter", "raw_resampling", @@ -43,7 +44,7 @@ "raw_normalization", ] -NormMethod = Literal[ +NORM_METHOD = Literal[ "mean", "median", "zscore", @@ -55,11 +56,6 @@ ] -################################### -######## PROTOCOL CLASSES ######## -################################### - - class NMFeature: def __init__( self, settings: "NMSettings", ch_names: Sequence[str], sfreq: int | float @@ -85,7 +81,7 @@ class NMPreprocessor: def process(self, data: "np.ndarray") -> "np.ndarray": ... -class PreprocessorList(NMSequenceModel[list[PreprocessorName]]): +class PreprocessorList(NMSequenceModel[list[PREPROCESSOR_NAME]]): model_config = ConfigDict(arbitrary_types_allowed=True) # Useless contructor to prevent linter from complaining @@ -155,6 +151,136 @@ def print_all(cls): print(f) +################################################ +### Generic Pydantic models for the frontend ### +################################################ + + +class UniqueStringSequence(NMSequenceModel[list[str]]): + """ + A sequence of strings where: + - Values must come from a predefined set + - Each value can only appear once + - Order is preserved + """ + + @property + @abstractmethod + def valid_values(self) -> list[str]: + """Each subclass must implement this to provide its valid values""" + raise NotImplementedError + + def __init__(self, **data): + valid_values = data.pop("valid_values", []) + super().__init__(**data) + object.__setattr__(self, "valid_values", valid_values) + + @model_validator(mode="after") + def validate_sequence(self): + seen = set() + validated = [] + for item in self.root: + if item not in seen and item in self.valid_values: + seen.add(item) + validated.append(item) + self.root = validated + return self + + def serialize_with_metadata(self) -> dict[str, Any]: + result = super().serialize_with_metadata() + result["__valid_values__"] = self.valid_values + return result + + +class DependentKeysList(NMSequenceModel[list[str]]): + """ + A list of strings where valid values are keys from another settings field + """ + + root: list[str] = NMField(default_factory=list) + source_dict: dict[str, Any] = NMField(default_factory=dict, exclude=True) + + def __init__(self, **data): + source_dict = data.pop("source_dict", {}) + super().__init__(**data) + object.__setattr__(self, "source_dict", source_dict) + + @model_validator(mode="after") + def validate_keys(self): + valid_keys = set(self.source_dict.keys()) + seen = set() + validated = [] + for item in self.root: + if item not in seen and item in valid_keys: + seen.add(item) + validated.append(item) + self.root = validated + return self + + def serialize_with_metadata(self) -> dict[str, Any]: + result = super().serialize_with_metadata() + result["__valid_values__"] = list(self.source_dict.keys()) + result["__dependent__"] = True # Indicates this needs dynamic updating + return result + + +class StringPairsList(NMSequenceModel[list[tuple[str, str]]]): + """ + A list of string pairs where values must come from predetermined lists + """ + + root: list[tuple[str, str]] = NMField(default_factory=list) + valid_first: list[str] = NMField(default_factory=list, exclude=True) + valid_second: list[str] = NMField(default_factory=list, exclude=True) + + def __init__(self, **data): + valid_first = data.pop("valid_first", []) + valid_second = data.pop("valid_second", []) + super().__init__(**data) + object.__setattr__(self, "valid_first", valid_first) + object.__setattr__(self, "valid_second", valid_second) + + @model_validator(mode="after") + def validate_pairs(self): + validated = [ + (first, second) + for first, second in self.root + if first in self.valid_first and second in self.valid_second + ] + self.root = validated + return self + + def serialize_with_metadata(self) -> dict[str, Any]: + result = super().serialize_with_metadata() + result["__valid_first__"] = self.valid_first + result["__valid_second__"] = self.valid_second + return result + + +# class LiteralValue(NMValueModel[str]): +# """ +# A string field that must be one of a predefined set of literals +# """ + +# valid_values: list[str] = NMField(default_factory=list, exclude=True) + +# def __init__(self, **data): +# valid_values = data.pop("valid_values", []) +# super().__init__(**data) +# object.__setattr__(self, "valid_values", valid_values) + +# @model_validator(mode="after") +# def validate_value(self): +# if self.root not in self.valid_values: +# raise ValueError(f"Value must be one of: {self.valid_values}") +# return self + +# def serialize_with_metadata(self) -> dict[str, Any]: +# result = super().serialize_with_metadata() +# result["__valid_values__"] = self.valid_values +# return result + + ################# ### GUI TYPES ### ################# diff --git a/tests/test_osc_features.py b/tests/test_osc_features.py index 8cf84111..fd2428d1 100644 --- a/tests/test_osc_features.py +++ b/tests/test_osc_features.py @@ -3,11 +3,11 @@ import numpy as np from py_neuromodulation import NMSettings, Stream, features -from py_neuromodulation.utils.types import FeatureName +from py_neuromodulation.utils.types import FEATURE_NAME def setup_osc_settings( - osc_feature_name: FeatureName, + osc_feature_name: FEATURE_NAME, osc_feature_setting: str, windowlength_ms: int, log_transform: bool, From 550bd497bc822ba6a1b0865707cfce728c8001bd Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:59:55 +0100 Subject: [PATCH 14/25] bugfix in settings wrap validator --- py_neuromodulation/stream/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_neuromodulation/stream/settings.py b/py_neuromodulation/stream/settings.py index 14bc29d8..764ee547 100644 --- a/py_neuromodulation/stream/settings.py +++ b/py_neuromodulation/stream/settings.py @@ -147,7 +147,7 @@ def validate_settings(self, handler: ModelWrapValidatorHandler) -> Any: try: # validate the model - handler(self) + self = handler(self) except ValidationError as e: self = NMSettings.unvalidated(**self) NMSettings.model_fields_set From d8581c1ad1952a0d59afbb1f6869c9b06b745073 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:24:27 +0100 Subject: [PATCH 15/25] Add Folder selection to FileBrowser component --- gui_dev/package.json | 36 +++++++-------- .../components/FileBrowser/FileBrowser.jsx | 45 +++++++++++++------ .../components/FileBrowser/QuickAccess.jsx | 1 - .../pages/SourceSelection/FileSelector.jsx | 35 ++++++++++++--- py_neuromodulation/gui/backend/app_backend.py | 1 - 5 files changed, 78 insertions(+), 40 deletions(-) diff --git a/gui_dev/package.json b/gui_dev/package.json index 2850e8bf..0e31e9cc 100644 --- a/gui_dev/package.json +++ b/gui_dev/package.json @@ -9,34 +9,34 @@ "preview": "vite preview" }, "dependencies": { - "@emotion/react": "^11.13.3", - "@emotion/styled": "^11.13.0", - "@mui/icons-material": "latest", - "@mui/material": "latest", + "@emotion/react": "^11.13.5", + "@emotion/styled": "^11.13.5", + "@mui/icons-material": "^6.1.8", + "@mui/material": "^6.1.8", "immer": "^10.1.1", "plotly.js-basic-dist-min": "^2.35.2", "react": "next", "react-dom": "next", - "react-router-dom": "^6.26.2", - "zustand": "latest" + "react-router-dom": "^7.0.1", + "zustand": "^5.0.1" }, "devDependencies": { - "@babel/core": "^7.25.7", - "@babel/eslint-parser": "^7.25.7", - "@babel/preset-env": "^7.25.7", - "@babel/preset-react": "^7.25.7", - "@eslint/compat": "^1.2.0", - "@vitejs/plugin-react": "^4.3.2", + "@babel/core": "^7.26.0", + "@babel/eslint-parser": "^7.25.9", + "@babel/preset-env": "^7.26.0", + "@babel/preset-react": "^7.25.9", + "@eslint/compat": "^1.2.3", + "@vitejs/plugin-react": "^4.3.3", "@welldone-software/why-did-you-render": "^8.0.3", "babel-plugin-react-compiler": "latest", - "eslint": "^9.12.0", + "eslint": "^9.15.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jsdoc": "^50.3.1", - "eslint-plugin-react": "^7.37.1", + "eslint-plugin-jsdoc": "^50.5.0", + "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-compiler": "latest", - "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.12", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", "prettier": "^3.3.3", - "vite": "^5.4.8" + "vite": "^5.4.11" } } diff --git a/gui_dev/src/components/FileBrowser/FileBrowser.jsx b/gui_dev/src/components/FileBrowser/FileBrowser.jsx index 3c50c6b0..a902a931 100644 --- a/gui_dev/src/components/FileBrowser/FileBrowser.jsx +++ b/gui_dev/src/components/FileBrowser/FileBrowser.jsx @@ -1,6 +1,7 @@ import { useReducer, useEffect } from "react"; import { Box, + Button, Paper, Typography, TextField, @@ -14,6 +15,7 @@ import { TableSortLabel, Modal, Select, + Stack, FormControl, InputLabel, Snackbar, @@ -89,8 +91,10 @@ const columns = [ export const FileBrowser = ({ isModal = false, directory = null, + onlyDirectories = false, + allowedExtensions = ALLOWED_EXTENSIONS, onClose, - onFileSelect, + onSelect, }) => { const [state, dispatch] = useReducer(reducer, initialState); @@ -141,11 +145,16 @@ export const FileBrowser = ({ const fetchFiles = async (path) => { try { - const files = await fileManager.getFiles({ + let files = await fileManager.getFiles({ path, - allowedExtensions: ALLOWED_EXTENSIONS.join(","), + allowedExtensions: allowedExtensions.join(","), showHidden: state.showHiddenFiles, }); + + if (onlyDirectories) { + files = files.filter((file) => file.is_directory); + } + dispatch({ type: "SET_FILES", payload: files.map((file) => ({ @@ -164,13 +173,13 @@ export const FileBrowser = ({ } }; - const handleFileClick = (file) => { + const handleRowClick = (file) => { if (file.is_directory) { dispatch({ type: "SET_CURRENT_PATH", payload: file.path }); } else if ( ALLOWED_EXTENSIONS.some((ext) => file.name.toLowerCase().endsWith(ext)) ) { - onFileSelect(file); + onSelect(file); } }; @@ -272,9 +281,9 @@ export const FileBrowser = ({ }; const FileBrowserContent = ( - - - + + + {state.drives.length > 1 && ( Drive @@ -333,7 +342,7 @@ export const FileBrowser = ({ {" "} - +
    handleFileClick(file)} + onClick={() => handleRowClick(file)} sx={{ cursor: "pointer" }} > {columns.map((column) => ( @@ -399,13 +406,25 @@ export const FileBrowser = ({ + + + {onlyDirectories && ( + + )} + + dispatch({ type: "SET_ERROR", payload: "" })} message={state.error} /> - +
    ); return isModal ? ( diff --git a/gui_dev/src/components/FileBrowser/QuickAccess.jsx b/gui_dev/src/components/FileBrowser/QuickAccess.jsx index 47dfaa34..f5778060 100644 --- a/gui_dev/src/components/FileBrowser/QuickAccess.jsx +++ b/gui_dev/src/components/FileBrowser/QuickAccess.jsx @@ -57,7 +57,6 @@ export const QuickAccessSidebar = ({ onItemClick }) => { flexGrow: 1, display: "flex", flexDirection: "column", - maxHeight: 400, overflowX: "hidden", overflowY: "auto", scrollbarWidth: "thin", diff --git a/gui_dev/src/pages/SourceSelection/FileSelector.jsx b/gui_dev/src/pages/SourceSelection/FileSelector.jsx index c5f149d8..39eaa592 100644 --- a/gui_dev/src/pages/SourceSelection/FileSelector.jsx +++ b/gui_dev/src/pages/SourceSelection/FileSelector.jsx @@ -23,15 +23,12 @@ export const FileSelector = () => { const [isSelecting, setIsSelecting] = useState(false); const [showFileBrowser, setShowFileBrowser] = useState(false); + const [showFolderBrowser, setShowFolderBrowser] = useState(false); useEffect(() => { setSourceType("lsl"); }, []); - const handleSelectFile = () => { - setShowFileBrowser(true); - }; - const handleFileSelect = (file) => { setIsSelecting(true); @@ -50,11 +47,17 @@ export const FileSelector = () => { } }; + const handleFolderSelect = (folder) => { + setShowFolderBrowser(false); + }; + return ( + {streamSetupMessage && ( { isModal={true} directory={fileBrowserDirRef.current} onClose={() => setShowFileBrowser(false)} - onFileSelect={handleFileSelect} + onSelect={handleFileSelect} + /> + )} + {showFolderBrowser && ( + setShowFolderBrowser(false)} + onSelect={handleFolderSelect} + onlyDirectories={true} /> )} diff --git a/py_neuromodulation/gui/backend/app_backend.py b/py_neuromodulation/gui/backend/app_backend.py index abbccfee..9847baf4 100644 --- a/py_neuromodulation/gui/backend/app_backend.py +++ b/py_neuromodulation/gui/backend/app_backend.py @@ -12,7 +12,6 @@ Query, WebSocket, ) -from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from pydantic import ValidationError From f8375c55125c4ace1245d47da81a80f0e3248121 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Wed, 27 Nov 2024 12:54:07 +0100 Subject: [PATCH 16/25] Add scan JSON and port selection to App --- gui_dev/package.json | 1 + gui_dev/src/main.jsx | 11 +++++++ gui_dev/vite.config.js | 1 - py_neuromodulation/gui/backend/app_manager.py | 33 ++++++++++++++++--- py_neuromodulation/stream/settings.py | 1 - .../utils/pydantic_extensions.py | 3 +- py_neuromodulation/utils/types.py | 2 +- 7 files changed, 43 insertions(+), 9 deletions(-) diff --git a/gui_dev/package.json b/gui_dev/package.json index 0e31e9cc..6c65000b 100644 --- a/gui_dev/package.json +++ b/gui_dev/package.json @@ -37,6 +37,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "prettier": "^3.3.3", + "react-scan": "^0.0.31", "vite": "^5.4.11" } } diff --git a/gui_dev/src/main.jsx b/gui_dev/src/main.jsx index f4524069..fde760f2 100644 --- a/gui_dev/src/main.jsx +++ b/gui_dev/src/main.jsx @@ -1,7 +1,18 @@ +if (JSON.parse(import.meta.env.VITE_REACT_SCAN) === true) { + import("react-scan").then(({ scan }) => { + scan({ + enabled: true, + log: true, // logs render info to console + }); + }); +} + import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App.jsx"; +// Set up react-scan + // Ignore React 19 warning about accessing element.ref const originalConsoleError = console.error; console.error = (message, ...messageArgs) => { diff --git a/gui_dev/vite.config.js b/gui_dev/vite.config.js index f38a5299..1d6fd6b3 100644 --- a/gui_dev/vite.config.js +++ b/gui_dev/vite.config.js @@ -50,7 +50,6 @@ export default defineConfig(() => { }, }, server: { - port: 54321, proxy: { "/api": { target: `http://localhost:${BACKEND_PORT}`, diff --git a/py_neuromodulation/gui/backend/app_manager.py b/py_neuromodulation/gui/backend/app_manager.py index a9829ccd..7f46163f 100644 --- a/py_neuromodulation/gui/backend/app_manager.py +++ b/py_neuromodulation/gui/backend/app_manager.py @@ -18,6 +18,9 @@ # Shared memory configuration ARRAY_SIZE = 1000 # Adjust based on your needs +SERVER_PORT = 50001 +DEV_SERVER_PORT = 54321 + def create_backend() -> "PyNMBackend": from .app_pynm import PyNMState @@ -26,7 +29,12 @@ def create_backend() -> "PyNMBackend": return PyNMBackend(pynm_state=PyNMState()) -def run_vite(shutdown_event: "Event", debug: bool = False) -> None: +def run_vite( + shutdown_event: "Event", + debug: bool = False, + scan: bool = False, + dev_port: int = DEV_SERVER_PORT, +) -> None: """Run Vite in a separate shell""" import subprocess @@ -38,6 +46,8 @@ def run_vite(shutdown_event: "Event", debug: bool = False) -> None: logging.DEBUG if debug else logging.INFO, ) + os.environ["VITE_REACT_SCAN"] = "true" if scan else "false" + def output_reader(shutdown_event: "Event", process: subprocess.Popen): logger.debug("Initialized output stream") color = ansi_color(color="magenta", bright=True, styles=["BOLD"]) @@ -70,7 +80,7 @@ def read_stream(stream, stream_name): subprocess_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0 process = subprocess.Popen( - "bun run dev", + "bun run dev --port " + str(dev_port), cwd="gui_dev", stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -175,7 +185,13 @@ class AppManager: LAUNCH_FLAG = "PYNM_RUNNING" def __init__( - self, debug: bool = False, dev: bool = True, run_in_webview=False + self, + debug: bool = False, + dev: bool = True, + run_in_webview=False, + scan=False, + server_port=SERVER_PORT, + dev_port=DEV_SERVER_PORT, ) -> None: """_summary_ @@ -190,6 +206,10 @@ def __init__( self.debug = debug self.dev = dev self.run_in_webview = run_in_webview + self.scan = scan + self.server_port = server_port + self.dev_port = dev_port + self._reset() # Prevent launching multiple instances of the app due to multiprocessing # This allows the absence of a main guard in the main script @@ -269,7 +289,12 @@ def launch(self) -> None: self.logger.info("Starting Vite server...") self.tasks["vite"] = mp.Process( target=run_vite, - kwargs={"shutdown_event": self.shutdown_event, "debug": self.debug}, + kwargs={ + "shutdown_event": self.shutdown_event, + "debug": self.debug, + "scan": self.scan, + "dev_port": self.dev_port, + }, name="Vite", ) diff --git a/py_neuromodulation/stream/settings.py b/py_neuromodulation/stream/settings.py index 764ee547..79f9985b 100644 --- a/py_neuromodulation/stream/settings.py +++ b/py_neuromodulation/stream/settings.py @@ -6,7 +6,6 @@ from pydantic.functional_validators import ModelWrapValidatorHandler from py_neuromodulation import logger, user_features -from types import SimpleNamespace from py_neuromodulation.utils.types import ( BoolSelector, diff --git a/py_neuromodulation/utils/pydantic_extensions.py b/py_neuromodulation/utils/pydantic_extensions.py index ee89ccea..461291fd 100644 --- a/py_neuromodulation/utils/pydantic_extensions.py +++ b/py_neuromodulation/utils/pydantic_extensions.py @@ -11,7 +11,7 @@ Sequence, ) from typing_extensions import Unpack, TypedDict -from pydantic import BaseModel, ConfigDict, model_validator, model_serializer +from pydantic import BaseModel, model_validator, model_serializer from pydantic_core import ( ErrorDetails, @@ -414,7 +414,6 @@ def rewrite_error_locations(self, handler): loc = loc[:root_idx] + loc[root_idx + 1 :] err["loc"] = tuple(loc) errors.append(err) - print(errors) raise ValidationError.from_exception_data( title="ValidationError", line_errors=errors ) diff --git a/py_neuromodulation/utils/types.py b/py_neuromodulation/utils/types.py index 13b2923e..59ed7201 100644 --- a/py_neuromodulation/utils/types.py +++ b/py_neuromodulation/utils/types.py @@ -1,6 +1,6 @@ from os import PathLike from math import isnan -from typing import Literal, TYPE_CHECKING, Any, TypeVar +from typing import Literal, TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, model_validator from .pydantic_extensions import NMBaseModel, NMSequenceModel, NMField from abc import abstractmethod From 9a323a8777774b3ad38409afcfa34847caafb725 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Wed, 27 Nov 2024 22:50:19 +0100 Subject: [PATCH 17/25] Fix merge errors --- gui_dev/src/main.jsx | 16 +++++----- gui_dev/src/pages/Settings/Settings.jsx | 7 ++++- gui_dev/src/stores/settingsStore.js | 23 ++------------ py_neuromodulation/gui/backend/app_backend.py | 3 +- py_neuromodulation/stream/settings.py | 30 +++++++++++++++---- py_neuromodulation/stream/stream.py | 2 +- .../utils/pydantic_extensions.py | 11 +++---- pyproject.toml | 9 +----- 8 files changed, 52 insertions(+), 49 deletions(-) diff --git a/gui_dev/src/main.jsx b/gui_dev/src/main.jsx index fde760f2..bcd9d537 100644 --- a/gui_dev/src/main.jsx +++ b/gui_dev/src/main.jsx @@ -1,11 +1,11 @@ -if (JSON.parse(import.meta.env.VITE_REACT_SCAN) === true) { - import("react-scan").then(({ scan }) => { - scan({ - enabled: true, - log: true, // logs render info to console - }); - }); -} +// if (JSON.parse(import.meta.env.VITE_REACT_SCAN) === true) { +// import("react-scan").then(({ scan }) => { +// scan({ +// enabled: true, +// log: true, // logs render info to console +// }); +// }); +// } import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; diff --git a/gui_dev/src/pages/Settings/Settings.jsx b/gui_dev/src/pages/Settings/Settings.jsx index 045603be..abf1ec1a 100644 --- a/gui_dev/src/pages/Settings/Settings.jsx +++ b/gui_dev/src/pages/Settings/Settings.jsx @@ -87,6 +87,8 @@ const NumberField = ({ value, onChange, label, error }) => { const componentRegistry = { boolean: BooleanField, string: StringField, + int: NumberField, + float: NumberField, number: NumberField, }; @@ -166,11 +168,13 @@ const SettingsSection = ({ // Case 2: Object with component -> Don't iterate, render directly if (isObject && Component) { + const value = "__value__" in settings ? settings.__value__ : settings; + return ( { const validationErrors = useSettingsStore((state) => state.validationErrors); + console.log("validationErrors:", validationErrors); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); diff --git a/gui_dev/src/stores/settingsStore.js b/gui_dev/src/stores/settingsStore.js index 68929f1e..2cf8b2b3 100644 --- a/gui_dev/src/stores/settingsStore.js +++ b/gui_dev/src/stores/settingsStore.js @@ -5,25 +5,6 @@ const INITIAL_DELAY = 3000; // wait for Flask const RETRY_DELAY = 1000; // ms const MAX_RETRIES = 100; -const uploadSettingsToServer = async (settings) => { - try { - const response = await fetch(getBackendURL("/api/settings"), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(settings), - }); - if (!response.ok) { - throw new Error("Failed to update settings"); - } - return { success: true }; - } catch (error) { - console.error("Error updating settings:", error); - return { success: false, error }; - } -}; - export const useSettingsStore = createStore("settings", (set, get) => ({ settings: null, lastValidSettings: null, @@ -99,7 +80,9 @@ export const useSettingsStore = createStore("settings", (set, get) => ({ try { const response = await fetch( - `/api/settings${validateOnly ? "?validate_only=true" : ""}`, + getBackendURL( + `/api/settings${validateOnly ? "?validate_only=true" : ""}` + ), { method: "POST", headers: { diff --git a/py_neuromodulation/gui/backend/app_backend.py b/py_neuromodulation/gui/backend/app_backend.py index 01858fe6..2dbbf490 100644 --- a/py_neuromodulation/gui/backend/app_backend.py +++ b/py_neuromodulation/gui/backend/app_backend.py @@ -1,3 +1,4 @@ +from email import errors import logging import asyncio import importlib.metadata @@ -94,9 +95,9 @@ async def update_settings(data: dict, validate_only: bool = Query(False)): # TODO: check if this works properly or needs model_validate_strings validated_settings = NMSettings.model_validate(data) except ValidationError as e: + self.logger.error(f"Error validating settings: {e}") if not validate_only: # If validation failed but we wanted to upload, return error - self.logger.error(f"Error validating settings: {e}") raise HTTPException( status_code=422, detail={ diff --git a/py_neuromodulation/stream/settings.py b/py_neuromodulation/stream/settings.py index a7e86542..89b68664 100644 --- a/py_neuromodulation/stream/settings.py +++ b/py_neuromodulation/stream/settings.py @@ -18,7 +18,10 @@ from py_neuromodulation.utils.pydantic_extensions import NMErrorList, NMField from py_neuromodulation.processing.filter_preprocessing import FilterSettings -from py_neuromodulation.processing.normalization import FeatureNormalizationSettings, NormalizationSettings +from py_neuromodulation.processing.normalization import ( + FeatureNormalizationSettings, + NormalizationSettings, +) from py_neuromodulation.processing.resample import ResamplerSettings from py_neuromodulation.processing.projection import ProjectionSettings @@ -94,7 +97,9 @@ class NMSettings(NMBaseModel): # Postprocessing settings postprocessing: PostprocessingSettings = PostprocessingSettings() - feature_normalization_settings: FeatureNormalizationSettings = FeatureNormalizationSettings() + feature_normalization_settings: FeatureNormalizationSettings = ( + FeatureNormalizationSettings() + ) project_cortex_settings: ProjectionSettings = ProjectionSettings(max_dist_mm=20) project_subcortex_settings: ProjectionSettings = ProjectionSettings(max_dist_mm=5) @@ -144,12 +149,27 @@ def validate_settings(self, handler: ModelWrapValidatorHandler) -> Any: # the settings class errors: NMErrorList = NMErrorList() + def remove_private_keys(data): + if isinstance(data, dict): + if "__value__" in data: + return data["__value__"] + else: + return { + key: remove_private_keys(value) + for key, value in data.items() + if not key.startswith("__") + } + elif isinstance(data, (list, tuple, set)): + return type(data)(remove_private_keys(item) for item in data) + else: + return data + + self = remove_private_keys(self) + try: - # validate the model - self = handler(self) + self = handler(self) # validate the model except ValidationError as e: self = NMSettings.unvalidated(**self) - NMSettings.model_fields_set errors.extend(NMErrorList(e.errors())) if len(self.features.get_enabled()) == 0: diff --git a/py_neuromodulation/stream/stream.py b/py_neuromodulation/stream/stream.py index df8338aa..97446085 100644 --- a/py_neuromodulation/stream/stream.py +++ b/py_neuromodulation/stream/stream.py @@ -10,7 +10,7 @@ from contextlib import suppress from py_neuromodulation.stream.data_processor import DataProcessor -from py_neuromodulation.utils.types import _PathLike, FeatureName +from py_neuromodulation.utils.types import _PathLike, FEATURE_NAME from py_neuromodulation.utils.file_writer import MsgPackFileWriter from py_neuromodulation.stream.settings import NMSettings from py_neuromodulation.analysis.decode import RealTimeDecoder diff --git a/py_neuromodulation/utils/pydantic_extensions.py b/py_neuromodulation/utils/pydantic_extensions.py index 461291fd..b82f1bcf 100644 --- a/py_neuromodulation/utils/pydantic_extensions.py +++ b/py_neuromodulation/utils/pydantic_extensions.py @@ -67,7 +67,10 @@ class NMErrorList: def __init__( self, errors: Sequence[InitErrorDetails | ErrorDetails] | None = None ) -> None: - self.__errors: list[InitErrorDetails | ErrorDetails] = [e for e in errors or []] + if errors is None: + self.__errors: list[InitErrorDetails | ErrorDetails] = [] + else: + self.__errors: list[InitErrorDetails | ErrorDetails] = [e for e in errors] def add_error( self, @@ -160,8 +163,6 @@ def NMField( class NMBaseModel(BaseModel): - # model_config = ConfigDict(validate_assignment=False, extra="allow") - def __init__(self, *args, **kwargs) -> None: """Pydantic does not support positional arguments by default. This is a workaround to support positional arguments for models like FrequencyRange. @@ -242,7 +243,7 @@ def serialize_with_metadata(self): if isinstance(field_info, NMFieldInfo): # Convert scalar value to dict with metadata field_dict = { - "value": value, + "__value__": value, # __field_type__ will be overwritte if set in custom_metadata "__field_type__": type(value).__name__, **{ @@ -342,7 +343,7 @@ def model_dump_json(self, **kwargs): return json.dumps(self.root, **kwargs) def serialize_with_metadata(self) -> dict[str, Any]: - result = {"__field_type__": self.__class__.__name__, "value": self.root} + result = {"__field_type__": self.__class__.__name__, "__value__": self.root} # Add any field metadata from the root field field_info = self.model_fields.get("root") diff --git a/pyproject.toml b/pyproject.toml index 29c85832..7ad64fae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,14 +70,7 @@ dependencies = [ [project.optional-dependencies] test = ["pytest>=8.0.2", "pytest-xdist"] -dev = [ - "ruff", - "pytest>=8.0.2", - "pytest-cov", - "pytest-sugar", - "notebook", - "uvicorn[standard]", -] +dev = ["ruff", "pytest>=8.0.2", "pytest-cov", "pytest-sugar", "notebook"] docs = [ "py-neuromodulation[dev]", "sphinx", From f5f810dcbfdef67fd40cf1c0376ff82335eb3c97 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:38:52 +0100 Subject: [PATCH 18/25] Fix StatusBar bug --- gui_dev/src/components/StatusBar/StatusBar.jsx | 4 +--- gui_dev/src/main.jsx | 13 +++++-------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/gui_dev/src/components/StatusBar/StatusBar.jsx b/gui_dev/src/components/StatusBar/StatusBar.jsx index f011e2b9..825b73c6 100644 --- a/gui_dev/src/components/StatusBar/StatusBar.jsx +++ b/gui_dev/src/components/StatusBar/StatusBar.jsx @@ -7,9 +7,7 @@ import { Stack } from "@mui/material"; export const StatusBar = () => { const isWebView = useWebviewStore((state) => state.isWebView); - const createStatusBarContent = useUiStore((state) => state.statusBarContent); - - const StatusBarContent = createStatusBarContent(); + const StatusBarContent = useUiStore((state) => state.statusBarContent); return ( { -// scan({ -// enabled: true, -// log: true, // logs render info to console -// }); -// }); -// } +// import { scan } from "react-scan"; +// scan({ +// enabled: true, +// log: true, // logs render info to console +// }); import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; From f571c50c7e71d58575c2b3b7a4224ccf6f019d82 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:39:37 +0100 Subject: [PATCH 19/25] Allow passing app server and Vite server ports as params for nm.App --- gui_dev/src/utils/getBackendURL.js | 4 +- py_neuromodulation/gui/backend/app_backend.py | 9 ++-- py_neuromodulation/gui/backend/app_manager.py | 42 +++++++++++++------ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/gui_dev/src/utils/getBackendURL.js b/gui_dev/src/utils/getBackendURL.js index b35c21de..efaaa485 100644 --- a/gui_dev/src/utils/getBackendURL.js +++ b/gui_dev/src/utils/getBackendURL.js @@ -1,3 +1,3 @@ export const getBackendURL = (route) => { - return "http://localhost:50001" + route; -} + return "http://localhost:" + import.meta.env.VITE_BACKEND_PORT + route; +}; diff --git a/py_neuromodulation/gui/backend/app_backend.py b/py_neuromodulation/gui/backend/app_backend.py index 2dbbf490..efdaa226 100644 --- a/py_neuromodulation/gui/backend/app_backend.py +++ b/py_neuromodulation/gui/backend/app_backend.py @@ -1,6 +1,4 @@ -from email import errors import logging -import asyncio import importlib.metadata from datetime import datetime from pathlib import Path @@ -34,6 +32,7 @@ def __init__( pynm_state: app_pynm.PyNMState, debug=False, dev=True, + dev_port: int | None = None, fastapi_kwargs: dict = {}, ) -> None: super().__init__(debug=debug, **fastapi_kwargs) @@ -45,10 +44,14 @@ def __init__( self.logger = logging.getLogger("uvicorn.error") self.logger.warning(PYNM_DIR) + cors_origins = ( + ["http://localhost:" + str(dev_port)] if dev_port is not None else [] + ) + # Configure CORS self.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:54321"], + allow_origins=cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/py_neuromodulation/gui/backend/app_manager.py b/py_neuromodulation/gui/backend/app_manager.py index 3fb7764a..ec1587da 100644 --- a/py_neuromodulation/gui/backend/app_manager.py +++ b/py_neuromodulation/gui/backend/app_manager.py @@ -12,7 +12,6 @@ if TYPE_CHECKING: from multiprocessing.synchronize import Event - from .app_backend import PyNMBackend # Shared memory configuration @@ -22,21 +21,32 @@ DEV_SERVER_PORT = 54321 -def create_backend() -> "PyNMBackend": +def create_backend(): + """Factory function passed to Uvicorn to create the web application instance. + + :return: The web application instance. + :rtype: PyNMBackend + """ from .app_pynm import PyNMState from .app_backend import PyNMBackend debug = os.environ.get("PYNM_DEBUG", "False").lower() == "true" dev = os.environ.get("PYNM_DEV", "True").lower() == "true" + dev_port = os.environ.get("PYNM_DEV_PORT", str(DEV_SERVER_PORT)) - return PyNMBackend(pynm_state=PyNMState(), debug=debug, dev=dev) + return PyNMBackend( + pynm_state=PyNMState(), + debug=debug, + dev=dev, + dev_port=int(dev_port), + ) def run_vite( shutdown_event: "Event", debug: bool = False, - scan: bool = False, dev_port: int = DEV_SERVER_PORT, + backend_port: int = SERVER_PORT, ) -> None: """Run Vite in a separate shell""" import subprocess @@ -49,7 +59,7 @@ def run_vite( logging.DEBUG if debug else logging.INFO, ) - os.environ["VITE_REACT_SCAN"] = "true" if scan else "false" + os.environ["VITE_BACKEND_PORT"] = str(backend_port) def output_reader(shutdown_event: "Event", process: subprocess.Popen): logger.debug("Initialized output stream") @@ -116,7 +126,9 @@ def read_stream(stream, stream_name): logger.info("Development server stopped") -def run_uvicorn(debug: bool = False, reload=False) -> None: +def run_uvicorn( + debug: bool = False, reload=False, server_port: int = SERVER_PORT +) -> None: from uvicorn.server import Server from uvicorn.config import LOGGING_CONFIG, Config @@ -141,7 +153,7 @@ def run_uvicorn(debug: bool = False, reload=False) -> None: host="localhost", reload=reload, factory=True, - port=50001, + port=server_port, log_level="debug" if debug else "info", log_config=log_config, ) @@ -170,17 +182,23 @@ def restart(self) -> None: def run_backend( - shutdown_event: "Event", debug: bool = False, reload: bool = True, dev: bool = True + shutdown_event: "Event", + dev: bool = True, + debug: bool = False, + reload: bool = True, + server_port: int = SERVER_PORT, + dev_port: int = DEV_SERVER_PORT, ) -> None: signal.signal(signal.SIGINT, signal.SIG_IGN) # Pass create_backend parameters through environment variables os.environ["PYNM_DEBUG"] = str(debug) os.environ["PYNM_DEV"] = str(dev) + os.environ["PYNM_DEV_PORT"] = str(dev_port) server_process = mp.Process( target=run_uvicorn, - kwargs={"debug": debug, "reload": reload}, + kwargs={"debug": debug, "reload": reload, "server_port": server_port}, name="Server", ) server_process.start() @@ -196,7 +214,6 @@ def __init__( debug: bool = False, dev: bool = True, run_in_webview=False, - scan=False, server_port=SERVER_PORT, dev_port=DEV_SERVER_PORT, ) -> None: @@ -213,7 +230,6 @@ def __init__( self.debug = debug self.dev = dev self.run_in_webview = run_in_webview - self.scan = scan self.server_port = server_port self.dev_port = dev_port @@ -293,8 +309,8 @@ def launch(self) -> None: kwargs={ "shutdown_event": self.shutdown_event, "debug": self.debug, - "scan": self.scan, "dev_port": self.dev_port, + "backend_port": self.server_port, }, name="Vite", ) @@ -307,6 +323,8 @@ def launch(self) -> None: "debug": self.debug, "reload": self.dev, "dev": self.dev, + "server_port": self.server_port, + "dev_port": self.dev_port, }, name="Backend", ) From 9cf3ff08e19119ced33f28b3c235ba54c05cfc3e Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:40:13 +0100 Subject: [PATCH 20/25] remove react-scan from dev deps --- gui_dev/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/gui_dev/package.json b/gui_dev/package.json index dafcefc7..5fa2d606 100644 --- a/gui_dev/package.json +++ b/gui_dev/package.json @@ -41,7 +41,6 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "prettier": "^3.3.3", - "react-scan": "^0.0.31", "vite": "^5.4.11" } } From 511bdbc990837d16bc06ea5004550baa0f411035 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:30:37 +0100 Subject: [PATCH 21/25] Refactor useStatusBar hook --- gui_dev/src/components/StatusBar/StatusBar.jsx | 4 +++- gui_dev/src/pages/Settings/Settings.jsx | 6 +++++- gui_dev/src/stores/uiStore.js | 13 +++++++------ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/gui_dev/src/components/StatusBar/StatusBar.jsx b/gui_dev/src/components/StatusBar/StatusBar.jsx index 825b73c6..81f1b32b 100644 --- a/gui_dev/src/components/StatusBar/StatusBar.jsx +++ b/gui_dev/src/components/StatusBar/StatusBar.jsx @@ -7,7 +7,9 @@ import { Stack } from "@mui/material"; export const StatusBar = () => { const isWebView = useWebviewStore((state) => state.isWebView); - const StatusBarContent = useUiStore((state) => state.statusBarContent); + const getStatusBarContent = useUiStore((state) => state.getStatusBarContent); + + const StatusBarContent = getStatusBarContent(); return ( { diff --git a/gui_dev/src/stores/uiStore.js b/gui_dev/src/stores/uiStore.js index 25b44382..0462871f 100644 --- a/gui_dev/src/stores/uiStore.js +++ b/gui_dev/src/stores/uiStore.js @@ -1,7 +1,7 @@ -import { createPersistStore } from "./createStore"; +import { createStore } from "./createStore"; import { useEffect } from "react"; -export const useUiStore = createPersistStore("ui", (set, get) => ({ +export const useUiStore = createStore("ui", (set, get) => ({ activeDrawer: null, toggleDrawer: (drawerName) => set((state) => { @@ -31,13 +31,14 @@ export const useUiStore = createPersistStore("ui", (set, get) => ({ }), // Hook to inject UI elements into the status bar - statusBarContent: () => {}, - setStatusBarContent: (content) => set({ statusBarContent: content }), - clearStatusBarContent: () => set({ statusBarContent: null }), + getStatusBarContent: () => null, + setStatusBarContent: (contentGetter) => + set({ getStatusBarContent: contentGetter }), + clearStatusBarContent: () => set({ getStatusBarContent: () => null }), })); // Use this hook from Page components to inject page-specific UI elements into the status bar -export const useStatusBarContent = (content) => { +export const useStatusBar = (content) => { const createStatusBarContent = () => content; const setStatusBarContent = useUiStore((state) => state.setStatusBarContent); From 59b372b5298994374a95acd6354c1a67e121981e Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:46:35 +0100 Subject: [PATCH 22/25] f --- gui_dev/src/pages/Settings/Settings.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui_dev/src/pages/Settings/Settings.jsx b/gui_dev/src/pages/Settings/Settings.jsx index 373e37de..6d9921e6 100644 --- a/gui_dev/src/pages/Settings/Settings.jsx +++ b/gui_dev/src/pages/Settings/Settings.jsx @@ -15,7 +15,7 @@ import { FrequencyRangeList, FrequencyRange, } from "./components/FrequencyRange"; -import { useSettingsStore, useStatusBarContent } from "@/stores"; +import { useSettingsStore, useStatusBar } from "@/stores"; import { filterObjectByKeys } from "@/utils/functions"; const formatKey = (key) => { @@ -275,7 +275,7 @@ export const Settings = () => { const uploadSettings = useSettingsStore((state) => state.uploadSettings); const resetSettings = useSettingsStore((state) => state.resetSettings); const validationErrors = useSettingsStore((state) => state.validationErrors); - useStatusBarContent(StatusBarSettingsInfo); + useStatusBar(StatusBarSettingsInfo); // This is needed so that the frequency ranges stay in order between updates const frequencyRangeOrder = useSettingsStore( From 35f3a89bec18841f5df1cca212ed1bdda637a875 Mon Sep 17 00:00:00 2001 From: "Toni M. Brotons" <10654467+toni-neurosc@users.noreply.github.com> Date: Thu, 28 Nov 2024 14:21:12 +0100 Subject: [PATCH 23/25] More improvements to settings --- gui_dev/src/pages/Settings/Settings.jsx | 141 +++++++------- .../Settings/components/FrequencyRange.jsx | 70 ++++--- py_neuromodulation/gui/backend/app_backend.py | 25 +-- py_neuromodulation/stream/settings.py | 35 ++-- .../utils/pydantic_extensions.py | 148 +++----------- py_neuromodulation/utils/types.py | 180 ++---------------- 6 files changed, 183 insertions(+), 416 deletions(-) diff --git a/gui_dev/src/pages/Settings/Settings.jsx b/gui_dev/src/pages/Settings/Settings.jsx index 6d9921e6..69458cbc 100644 --- a/gui_dev/src/pages/Settings/Settings.jsx +++ b/gui_dev/src/pages/Settings/Settings.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Box, Button, + InputAdornment, Popover, Stack, Switch, @@ -26,10 +27,12 @@ const formatKey = (key) => { }; // Wrapper components for each type -const BooleanField = ({ value, onChange, error }) => ( - onChange(e.target.checked)} /> +const BooleanField = ({ label, value, onChange, error }) => ( + + {label} + onChange(e.target.checked)} /> + ); - const errorStyle = { "& .MuiOutlinedInput-root": { "& fieldset": { borderColor: "error.main" }, @@ -42,19 +45,22 @@ const errorStyle = { }, }; -const StringField = ({ value, onChange, label, error }) => { +const StringField = ({ label, value, onChange, error }) => { const errorSx = error ? errorStyle : {}; return ( - onChange(e.target.value)} - label={label} - sx={{ ...errorSx }} - /> + + {label} + onChange(e.target.value)} + label={label} + sx={{ ...errorSx }} + /> + ); }; -const NumberField = ({ value, onChange, label, error }) => { +const NumberField = ({ label, value, onChange, error, unit }) => { const errorSx = error ? errorStyle : {}; const handleChange = (event) => { @@ -66,26 +72,31 @@ const NumberField = ({ value, onChange, label, error }) => { }; return ( - - // Hz - // - // ), - // }} - inputProps={{ - pattern: "[0-9]*", - }} - /> + + {label} + + {unit}, + }} + inputProps={{ + pattern: "[0-9]*", + }} + /> + ); }; +const FrequencyRangeField = ({ label, value, onChange, error }) => { + console.log(label, value); + return ; +}; + // Map component types to their respective wrappers const componentRegistry = { boolean: BooleanField, @@ -93,21 +104,27 @@ const componentRegistry = { int: NumberField, float: NumberField, number: NumberField, - // FrequencyRange: FrequencyRange, + FrequencyRange: FrequencyRangeField, }; -const SettingsField = ({ path, Component, label, value, onChange, error }) => { +const SettingsField = ({ + path, + Component, + label, + value, + onChange, + error, + unit, +}) => { return ( - - {label} - onChange(path, newValue)} - label={label} - error={error} - /> - + onChange(path, newValue)} + label={label} + error={error} + unit={unit} + /> ); }; @@ -131,48 +148,22 @@ const SettingsSection = ({ errors, }) => { const boxTitle = title ? title : formatKey(path[path.length - 1]); - /* - 3 possible cases: - 1. Primitive type || 2. Object with component -> Don't iterate, render directly - 3. Object without component or 4. Array -> Iterate and render recursively - */ - const type = typeof settings; const isObject = type === "object" && !Array.isArray(settings); const isArray = Array.isArray(settings); // __field_type__ should be always present if (isObject && !settings.__field_type__) { - console.log(settings); throw new Error("Invalid settings object"); } const fieldType = isObject ? settings.__field_type__ : type; const Component = componentRegistry[fieldType]; - // Case 1: Primitive type -> Don't iterate, render directly - if (!isObject && !isArray) { - if (!Component) { - console.error(`Invalid component type: ${type}`); - return null; - } - - const error = getFieldError(path, errors); - - return ( - - ); - } - - // Case 2: Object with component -> Don't iterate, render directly - if (isObject && Component) { - const value = "__value__" in settings ? settings.__value__ : settings; + // Case 1: Object or primitive with component -> Don't iterate, render directly + if (Component) { + const value = + isObject && "__value__" in settings ? settings.__value__ : settings; + const unit = isObject && "__unit__" in settings ? settings.__unit__ : null; return ( ); } - // Case 3: Object without component or 4. Array -> Iterate and render recursively - if ((isObject && !Component) || isArray) { + // Case 2: Object without component or Array -> Iterate and render recursively + else { return ( {/* Handle recursing through both objects and arrays */} @@ -210,15 +202,10 @@ const SettingsSection = ({ ); } - - // Default case: return null and log an error - console.error(`Invalid settings object, returning null`); - return null; }; const StatusBarSettingsInfo = () => { const validationErrors = useSettingsStore((state) => state.validationErrors); - console.log("validationErrors:", validationErrors); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); diff --git a/gui_dev/src/pages/Settings/components/FrequencyRange.jsx b/gui_dev/src/pages/Settings/components/FrequencyRange.jsx index 16333166..7fb50a23 100644 --- a/gui_dev/src/pages/Settings/components/FrequencyRange.jsx +++ b/gui_dev/src/pages/Settings/components/FrequencyRange.jsx @@ -1,5 +1,11 @@ import { useState } from "react"; -import { TextField, Button, IconButton, Stack } from "@mui/material"; +import { + TextField, + Button, + IconButton, + Stack, + Typography, +} from "@mui/material"; import { Add, Close } from "@mui/icons-material"; import { debounce } from "@/utils"; @@ -25,9 +31,10 @@ export const FrequencyRange = ({ range, onChangeName, onChangeRange, - onRemove, error, + nameEditable = false, }) => { + console.log(range); const [localName, setLocalName] = useState(name); const debouncedChangeName = debounce((newName) => { @@ -35,16 +42,19 @@ export const FrequencyRange = ({ }, 1000); const handleNameChange = (e) => { + if (!nameEditable) return; const newName = e.target.value; setLocalName(newName); debouncedChangeName(newName); }; const handleNameBlur = () => { + if (!nameEditable) return; onChangeName(localName, name); }; const handleKeyPress = (e) => { + if (!nameEditable) return; if (e.key === "Enter") { console.log(e.target.value, name); onChangeName(localName, name); @@ -58,14 +68,18 @@ export const FrequencyRange = ({ return ( - + {nameEditable ? ( + + ) : ( + {name} + )} - onRemove(name)} - color="primary" - disableRipple - sx={{ m: 0, p: 0 }} - > - - ); }; @@ -159,15 +165,25 @@ export const FrequencyRangeList = ({ return ( {rangeOrder.map((name, index) => ( - + + + handleRemove(name)} + color="primary" + disableRipple + sx={{ m: 0, p: 0 }} + > + + + ))}