From 2f53d193b99522713299d7de79634993cb19e2b6 Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Thu, 25 Jul 2024 16:09:57 -0400 Subject: [PATCH 1/9] it's a mess --- cocofest/__init__.py | 3 + cocofest/examples/getting_started/mhe_try.py | 35 ++ .../pulse_duration_optimization_mhe.py | 115 ++++++ cocofest/models/ding2003.py | 5 + cocofest/models/ding2007.py | 4 +- cocofest/models/ding2007_with_fatigue.py | 6 +- cocofest/optimization/fes_ocp_mhe.py | 363 ++++++++++++++++++ cocofest/optimization/fes_ocp_nmpc.py | 0 8 files changed, 527 insertions(+), 4 deletions(-) create mode 100644 cocofest/examples/getting_started/mhe_try.py create mode 100644 cocofest/examples/getting_started/pulse_duration_optimization_mhe.py create mode 100644 cocofest/optimization/fes_ocp_mhe.py create mode 100644 cocofest/optimization/fes_ocp_nmpc.py diff --git a/cocofest/__init__.py b/cocofest/__init__.py index 483a2870..113f4841 100644 --- a/cocofest/__init__.py +++ b/cocofest/__init__.py @@ -1,5 +1,6 @@ from .custom_objectives import CustomObjective from .custom_constraints import CustomConstraint +from .models.fes_model import FesModel from .models.ding2003 import DingModelFrequency from .models.ding2003_with_fatigue import DingModelFrequencyWithFatigue from .models.ding2007 import DingModelPulseDurationFrequency @@ -10,6 +11,8 @@ from .optimization.fes_ocp import OcpFes from .optimization.fes_identification_ocp import OcpFesId from .optimization.fes_ocp_dynamics import OcpFesMsk +# from .optimization.fes_ocp_nmpc import OcpFesNmpc +from .optimization.fes_ocp_mhe import OcpFesMhe from .integration.ivp_fes import IvpFes from .fourier_approx import FourierSeries from .identification.ding2003_force_parameter_identification import DingModelFrequencyForceParameterIdentification diff --git a/cocofest/examples/getting_started/mhe_try.py b/cocofest/examples/getting_started/mhe_try.py new file mode 100644 index 00000000..64b1afd1 --- /dev/null +++ b/cocofest/examples/getting_started/mhe_try.py @@ -0,0 +1,35 @@ +from bioptim import OdeSolver +from cocofest import OcpFesMhe, DingModelPulseDurationFrequencyWithFatigue +import numpy as np + + +time = np.linspace(0, 1, 100) +force = abs(np.sin(time * 5) + np.random.normal(scale=0.1, size=len(time))) * 100 +force_tracking = [time, force] + +minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 +mhe = OcpFesMhe(model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + # pulse_event={"min": 0.01, "max": 0.1, "bimapping": True}, + pulse_duration={ + "min": minimum_pulse_duration, + "max": 0.0006, + "bimapping": False, + }, + objective={"force_tracking": force_tracking}, + # objective={"end_node_tracking": 100}, + # , "custom": objective_functions}, + n_total_cycles=6, + n_simultaneous_cycles=3, + n_cycle_to_advance=1, + cycle_to_keep="middle", + use_sx=True, + ode_solver=OdeSolver.COLLOCATION()) + +mhe.prepare_mhe() +mhe.solve() +# sol = ocp.solve() +# sol.graphs() + diff --git a/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py b/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py new file mode 100644 index 00000000..580ad2c1 --- /dev/null +++ b/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py @@ -0,0 +1,115 @@ +""" +This example will do a 10 stimulation example with Ding's 2007 pulse duration and frequency model. +This ocp was build to match a force value of 200N at the end of the last node. +""" +import numpy as np +from bioptim import SolutionMerge, ObjectiveList, ObjectiveFcn, OdeSolver +from cocofest import DingModelPulseDurationFrequencyWithFatigue, OcpFes + +# --- Build ocp --- # +# This ocp was build to match a force value of 200N at the end of the last node. +# The stimulation will be optimized between 0.01 to 0.1 seconds and are equally spaced (a fixed frequency). +# Plus the pulsation duration will be optimized between 0 and 0.0006 seconds and are not the same across the problem. +# The flag with_fatigue is set to True by default, this will include the fatigue model + +# objective_functions = ObjectiveList() +# n_stim = 10 +# for i in range(n_stim): +# objective_functions.add(ObjectiveFcn.Lagrange.MINIMIZE_STATE, key="F", weight=1/100000, quadratic=True, phase=i) + +# --- Building force to track ---# +time = np.linspace(0, 1, 100) +force = abs(np.sin(time * 5) + np.random.normal(scale=0.1, size=len(time))) * 100 +force_tracking = [time, force] + +minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 +ocp = OcpFes().prepare_ocp( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + # pulse_event={"min": 0.01, "max": 0.1, "bimapping": True}, + pulse_duration={ + "min": minimum_pulse_duration, + "max": 0.0006, + "bimapping": False, + }, + objective={"force_tracking": force_tracking}, + # objective={"end_node_tracking": 100}, + # , "custom": objective_functions}, + use_sx=True, + ode_solver=OdeSolver.COLLOCATION(), +) + +# --- Solve the program --- # +cn_results = [] +f_results = [] +a_results = [] +tau1_results = [] +km_results = [] +time = [] +previous_stim = [] +for i in range(5): + sol = ocp.solve() + # sol.graphs(show_bounds=True) + sol_states = sol.decision_states(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) + cn_results.append(list(sol_states["Cn"][0])) + f_results.append(list(sol_states["F"][0])) + a_results.append(list(sol_states["A"][0])) + tau1_results.append(list(sol_states["Tau1"][0])) + km_results.append(list(sol_states["Km"][0])) + sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) + sol_time = list(sol_time.reshape(sol_time.shape[0])) + + # sol_time_stim_parameters = sol.decision_parameters()["pulse_apparition_time"] + + # if previous_stim: + # # stim_prev = list(sol_time_stim_parameters - sol_time[-1]) + # update_previous_stim = list(np.array(previous_stim) - sol_time[-1]) + # previous_stim = update_previous_stim + stim_prev + # else: + # stim_prev = list(sol_time_stim_parameters - sol_time[-1]) + # previous_stim = stim_prev + + if i != 0: + sol_time = [x + time[-1][-1] for x in sol_time] + + time.append(sol_time) + keys = list(sol_states.keys()) + + for key in keys: + ocp.nlp[0].x_bounds[key].max[0][0] = sol_states[key][-1][-1] + ocp.nlp[0].x_bounds[key].min[0][0] = sol_states[key][-1][-1] + for j in range(len(ocp.nlp)): + ocp.nlp[j].model = DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10, stim_prev=previous_stim) + for key in keys: + ocp.nlp[j].x_init[key].init[0][0] = sol_states[key][-1][-1] + + +# --- Show results --- # +cn_results = [j for sub in cn_results for j in sub] +f_results = [j for sub in f_results for j in sub] +a_results = [j for sub in a_results for j in sub] +tau1_results = [j for sub in tau1_results for j in sub] +km_results = [j for sub in km_results for j in sub] +time_result = [j for sub in time for j in sub] + +import matplotlib.pyplot as plt +fig, axs = plt.subplots(5) +axs[0].plot(time_result, cn_results) +axs[0].set_ylabel("Cn") +axs[1].plot(time_result, f_results) +axs[1].set_ylabel("F") +axs[2].plot(time_result, a_results) +axs[2].set_ylabel("A") +axs[3].plot(time_result, tau1_results) +axs[3].set_ylabel("Tau1") +axs[4].plot(time_result, km_results) +axs[4].set_ylabel("Km") +plt.xlabel("Time (s)") +plt.ylabel("Force (N)") +plt.show() + +print(previous_stim + time_result[-1]) + +# TODO : Add the init parameters and state parameters according to the previous solution for each phase diff --git a/cocofest/models/ding2003.py b/cocofest/models/ding2003.py index fd694434..f5d72d1f 100644 --- a/cocofest/models/ding2003.py +++ b/cocofest/models/ding2003.py @@ -34,6 +34,7 @@ def __init__( model_name: str = "ding2003", muscle_name: str = None, sum_stim_truncation: int = None, + stim_prev: list[float] = None ): super().__init__() self._model_name = model_name @@ -51,6 +52,8 @@ def __init__( self.tau2 = 0.060 # Close value from Ding's experimentation [2] (s) self.km_rest = 0.103 # Value from Ding's experimentation [1] (unitless) + self.stim_prev = stim_prev + def set_a_rest(self, model, a_rest: MX | float): # models is required for bioptim compatibility self.a_rest = a_rest @@ -145,6 +148,8 @@ def system_dynamics( ------- The value of the derivative of each state dx/dt at the current time t """ + if self.stim_prev: + t_stim_prev = self.stim_prev + t_stim_prev r0 = self.km_rest + self.r0_km_relationship # Simplification cn_dot = self.cn_dot_fun(cn, r0, t, t_stim_prev=t_stim_prev) # Equation n°1 f_dot = self.f_dot_fun( diff --git a/cocofest/models/ding2007.py b/cocofest/models/ding2007.py index 49fcf7a2..ebe56c21 100644 --- a/cocofest/models/ding2007.py +++ b/cocofest/models/ding2007.py @@ -27,9 +27,9 @@ class DingModelPulseDurationFrequency(DingModelFrequency): Muscle & Nerve: Official Journal of the American Association of Electrodiagnostic Medicine, 36(2), 214-222. """ - def __init__(self, model_name: str = "ding_2007", muscle_name: str = None, sum_stim_truncation: int = None): + def __init__(self, model_name: str = "ding_2007", muscle_name: str = None, sum_stim_truncation: int = None, stim_prev: list[float] = None): super(DingModelPulseDurationFrequency, self).__init__( - model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation + model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation, stim_prev=stim_prev ) self._with_fatigue = False self.impulse_time = None diff --git a/cocofest/models/ding2007_with_fatigue.py b/cocofest/models/ding2007_with_fatigue.py index ce2b0499..cc52285b 100644 --- a/cocofest/models/ding2007_with_fatigue.py +++ b/cocofest/models/ding2007_with_fatigue.py @@ -27,10 +27,10 @@ class DingModelPulseDurationFrequencyWithFatigue(DingModelPulseDurationFrequency """ def __init__( - self, model_name: str = "ding_2007_with_fatigue", muscle_name: str = None, sum_stim_truncation: int = None + self, model_name: str = "ding_2007_with_fatigue", muscle_name: str = None, sum_stim_truncation: int = None, stim_prev: list[float] = None ): super(DingModelPulseDurationFrequencyWithFatigue, self).__init__( - model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation + model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation, stim_prev=stim_prev ) self._with_fatigue = True @@ -137,6 +137,8 @@ def system_dynamics( ------- The value of the derivative of each state dx/dt at the current time t """ + if self.stim_prev: + t_stim_prev = self.stim_prev + t_stim_prev r0 = km + self.r0_km_relationship # Simplification cn_dot = self.cn_dot_fun(cn, r0, t, t_stim_prev=t_stim_prev) # Equation n°1 from Ding's 2003 article a_calculated = self.a_calculation(a_scale=a, impulse_time=impulse_time) # Equation n°3 from Ding's 2007 article diff --git a/cocofest/optimization/fes_ocp_mhe.py b/cocofest/optimization/fes_ocp_mhe.py new file mode 100644 index 00000000..3fa75935 --- /dev/null +++ b/cocofest/optimization/fes_ocp_mhe.py @@ -0,0 +1,363 @@ +import math + +import numpy as np +from bioptim import SolutionMerge, ObjectiveList, ObjectiveFcn, OdeSolver, Node, OptimalControlProgram, ControlType +from cocofest import DingModelPulseDurationFrequencyWithFatigue, OcpFes, CustomObjective, FesModel +# TODO relative import + + +class OcpFesMhe: + def __init__(self, + model: FesModel = None, + n_stim: int = None, + n_shooting: int = None, + final_time: int | float = None, + pulse_event: dict = None, + pulse_duration: dict = None, + pulse_intensity: dict = None, + n_total_cycles: int = None, + n_simultaneous_cycles: int = None, + n_cycle_to_advance: int = None, + cycle_to_keep: str = None, + objective: dict = None, + use_sx: bool = True, + ode_solver: OdeSolver = OdeSolver.RK4(n_integration_steps=1), + n_threads: int = 1, + ): + + super(OcpFesMhe, self).__init__() + self.model = model + self.n_stim = n_stim + self.n_shooting = n_shooting + self.final_time = final_time + self.pulse_event = pulse_event + self.pulse_duration = pulse_duration + self.pulse_intensity = pulse_intensity + self.objective = objective + self.n_total_cycles = n_total_cycles + self.n_simultaneous_cycles = n_simultaneous_cycles + self.n_cycle_to_advance = n_cycle_to_advance + self.cycle_to_keep = cycle_to_keep + self.use_sx = use_sx + self.ode_solver = ode_solver + self.n_threads = n_threads + self.ocp = None + self._mhe_sanity_check() + self.time = [] + self.states = [] + self.parameters = [] + self.previous_stim = [] + self.result_states = {} + self.result_parameters = {} + self.result_time = {} + + def prepare_mhe(self): + (pulse_event, pulse_duration, pulse_intensity, objective) = OcpFes._fill_dict( + self.pulse_event, self.pulse_duration, self.pulse_intensity, self.objective + ) + + time_min = pulse_event["min"] + time_max = pulse_event["max"] + time_bimapping = pulse_event["bimapping"] + frequency = pulse_event["frequency"] + round_down = pulse_event["round_down"] + pulse_mode = pulse_event["pulse_mode"] + + fixed_pulse_duration = pulse_duration["fixed"] + pulse_duration_min = pulse_duration["min"] + pulse_duration_max = pulse_duration["max"] + pulse_duration_bimapping = pulse_duration["bimapping"] + + fixed_pulse_intensity = pulse_intensity["fixed"] + pulse_intensity_min = pulse_intensity["min"] + pulse_intensity_max = pulse_intensity["max"] + pulse_intensity_bimapping = pulse_intensity["bimapping"] + + force_tracking = objective["force_tracking"] + end_node_tracking = objective["end_node_tracking"] + custom_objective = objective["custom"] + + OcpFes._sanity_check( + model=self.model, + n_stim=self.n_stim, + n_shooting=self.n_shooting, + final_time=self.final_time, + pulse_mode=pulse_mode, + frequency=frequency, + time_min=time_min, + time_max=time_max, + time_bimapping=time_bimapping, + fixed_pulse_duration=fixed_pulse_duration, + pulse_duration_min=pulse_duration_min, + pulse_duration_max=pulse_duration_max, + pulse_duration_bimapping=pulse_duration_bimapping, + fixed_pulse_intensity=fixed_pulse_intensity, + pulse_intensity_min=pulse_intensity_min, + pulse_intensity_max=pulse_intensity_max, + pulse_intensity_bimapping=pulse_intensity_bimapping, + force_tracking=force_tracking, + end_node_tracking=end_node_tracking, + custom_objective=custom_objective, + use_sx=self.use_sx, + ode_solver=self.ode_solver, + n_threads=self.n_threads, + ) + + OcpFes._sanity_check_frequency(n_stim=self.n_stim, final_time=self.final_time, frequency=frequency, round_down=round_down) + + n_stim, final_time = OcpFes._build_phase_parameter( + n_stim=self.n_stim * self.n_simultaneous_cycles, final_time=self.final_time * self.n_simultaneous_cycles, frequency=frequency, pulse_mode=pulse_mode, round_down=round_down + ) + + force_fourier_coefficient = ( + None if force_tracking is None else OcpFes._build_fourier_coefficient(force_tracking) + ) + + models = [self.model] * self.n_stim * self.n_simultaneous_cycles + + final_time_phase = OcpFes._build_phase_time( + final_time=self.final_time * self.n_simultaneous_cycles, + n_stim=self.n_stim * self.n_simultaneous_cycles, + pulse_mode=pulse_mode, + time_min=time_min, + time_max=time_max, + ) + parameters, parameters_bounds, parameters_init, parameter_objectives, constraints = OcpFes._build_parameters( + model=self.model, + n_stim=self.n_stim * self.n_simultaneous_cycles, + time_min=time_min, + time_max=time_max, + time_bimapping=time_bimapping, + fixed_pulse_duration=fixed_pulse_duration, + pulse_duration_min=pulse_duration_min, + pulse_duration_max=pulse_duration_max, + pulse_duration_bimapping=pulse_duration_bimapping, + fixed_pulse_intensity=fixed_pulse_intensity, + pulse_intensity_min=pulse_intensity_min, + pulse_intensity_max=pulse_intensity_max, + pulse_intensity_bimapping=pulse_intensity_bimapping, + use_sx=self.use_sx, + ) + + if len(constraints) == 0 and len(parameters) == 0: + raise ValueError( + "This is not an optimal control problem," + " add parameter to optimize or use the IvpFes method to build your problem" + ) + + dynamics = OcpFes._declare_dynamics(models, self.n_stim * self.n_simultaneous_cycles) + x_bounds, x_init = OcpFes._set_bounds(self.model, self.n_stim * self.n_simultaneous_cycles) + one_cycle_shooting = [self.n_shooting] * self.n_stim + objective_functions = self._set_objective( + self.n_stim, one_cycle_shooting, force_fourier_coefficient, end_node_tracking, custom_objective, time_min, time_max, self.n_simultaneous_cycles + ) + all_cycle_n_shooting = [self.n_shooting] * self.n_stim * self.n_simultaneous_cycles + self.ocp = OptimalControlProgram( + bio_model=models, + dynamics=dynamics, + n_shooting=all_cycle_n_shooting, + phase_time=final_time_phase, + objective_functions=objective_functions, + x_init=x_init, + x_bounds=x_bounds, + constraints=constraints, + parameters=parameters, + parameter_bounds=parameters_bounds, + parameter_init=parameters_init, + parameter_objectives=parameter_objectives, + control_type=ControlType.CONSTANT, + use_sx=self.use_sx, + ode_solver=self.ode_solver, + n_threads=self.n_threads, + ) + + return self.ocp + + # def update_mhe(self, previous_sol): + # sol_states = sol.decision_states(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) + # cn_results.append(list(sol_states["Cn"][0])) + # f_results.append(list(sol_states["F"][0])) + # a_results.append(list(sol_states["A"][0])) + # tau1_results.append(list(sol_states["Tau1"][0])) + # km_results.append(list(sol_states["Km"][0])) + # sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) + # sol_time = list(sol_time.reshape(sol_time.shape[0])) + # + # sol_time_stim_parameters = sol.decision_parameters()["pulse_apparition_time"] + # + # if previous_stim: + # # stim_prev = list(sol_time_stim_parameters - sol_time[-1]) + # update_previous_stim = list(np.array(previous_stim) - sol_time[-1]) + # previous_stim = update_previous_stim + stim_prev + # else: + # stim_prev = list(sol_time_stim_parameters - sol_time[-1]) + # previous_stim = stim_prev + # + # if i != 0: + # sol_time = [x + time[-1][-1] for x in sol_time] + # + # time.append(sol_time) + # keys = list(sol_states.keys()) + # + # for key in keys: + # ocp.nlp[0].x_bounds[key].max[0][0] = sol_states[key][-1][-1] + # ocp.nlp[0].x_bounds[key].min[0][0] = sol_states[key][-1][-1] + # for j in range(len(ocp.nlp)): + # ocp.nlp[j].model = DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10, + # stim_prev=previous_stim) + # for key in keys: + # ocp.nlp[j].x_init[key].init[0][0] = sol_states[key][-1][-1] + + def update_time(self, sol, index): + if index == 0: + sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) + sol_time = list(sol_time.reshape(sol_time.shape[0])) + self.time.append(sol_time) + else: + sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) + sol_time = list(sol_time.reshape(sol_time.shape[0])) + + + sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) + sol_time = list(sol_time.reshape(sol_time.shape[0])) + final_time = sol_time[-1] + self.time[-1][-1] + + + if index != 0: + sol_time = [x + self.time[-1][-1] for x in sol_time] + self.time.append(sol_time) + + def update_states(self, sol, state_keys): + sol_states = sol.decision_states(to_merge=[SolutionMerge.NODES]) + for key in state_keys: + self.ocp.nlp[0].x_bounds[key].max[0][0] = sol_states[self.n_stim//self.n_simultaneous_cycles - 1][key][0][-1] + self.ocp.nlp[0].x_bounds[key].min[0][0] = sol_states[self.n_stim//self.n_simultaneous_cycles - 1][key][0][-1] + for j in range(self.n_stim//self.n_simultaneous_cycles - 1, len(self.ocp.nlp)): + self.ocp.nlp[j].x_init[key].init[0][0] = sol_states[j][key][0][0] + return sol_states + + def update_parameters(self, sol, parameters_keys): + sol_parameters = sol.decision_parameters() + sol_parameters_dict = {key: list(sol_parameters[key][0]) for key in sol_parameters.keys()} + return + + def update_stim(self, sol): + previous_time = self.time[0] + if "pulse_apparition_time" in sol.decision_parameters(): + sol_time_stim_parameters = sol.decision_parameters()["pulse_apparition_time"] + + if self.previous_stim: + stim_prev = list(sol_time_stim_parameters - self.time[-1]) + update_previous_stim = list(np.array(self.previous_stim) - self.time[-1]) + self.previous_stim = update_previous_stim + stim_prev + # TODO: check if correct did some modification since last time + # TODO: wrong, will take the last previous_stim from n_simultaneous_cycles at each iteration + # TODO: wrong, time is pushed to many times + else: + stim_prev = list(sol_time_stim_parameters - self.time[-1]) + self.previous_stim = stim_prev + + list(self.previous_stim) + + def store_results(self, sol_states, index): + if self.cycle_to_keep == "middle": + phase_to_keep = int(math.ceil(self.n_simultaneous_cycles / 2)) + first_node_in_phase = self.n_stim * (phase_to_keep - 1) + last_node_in_phase = self.n_stim * phase_to_keep + # self.result_time = self.time[first_node_in_phase:last_node_in_phase] + for key in list(sol_states[0].keys()): + middle_states_values = sol_states[first_node_in_phase:last_node_in_phase] + middle_states_values = [list(middle_states_values[i][key][0]) for i in range(len(middle_states_values))] + middle_states_values = [j for sub in middle_states_values for j in sub] + self.result_states[key] = middle_states_values# Todo might be wrong + # self.result_parameters = self.parameters[first_node_in_phase:last_node_in_phase] + return + + def solve(self): + state_keys = list(self.ocp.nlp[0].states.keys()) + parameters_keys = list(self.ocp.nlp[0].parameters.keys()) + for i in range(self.n_total_cycles // self.n_cycle_to_advance): + sol = self.ocp.solve() + # self.update_time(sol, index=i) + sol_states = self.update_states(sol, state_keys) + # self.update_parameters(sol, parameters_keys) + # self.update_stim(sol) + self.store_results(sol_states, i) + # sol.graphs() + # return self.ocp.solve() + + @staticmethod + def _set_objective(n_stim, n_shooting, force_fourier_coefficient, end_node_tracking, custom_objective, time_min, time_max, + n_simultaneous_cycles): + # Creates the objective for our problem + objective_functions = ObjectiveList() + if custom_objective: + if len(custom_objective) != n_stim: + raise ValueError("The number of custom objective must be equal to the stimulation number of a single cycle") + for i in range(len(custom_objective)): + for j in range(n_simultaneous_cycles): + objective_functions.add(custom_objective[i + j * n_stim][0]) + + if force_fourier_coefficient is not None: + for phase in range(n_stim): + for i in range(n_shooting[phase]): + for j in range(n_simultaneous_cycles): + objective_functions.add( + CustomObjective.track_state_from_time, + custom_type=ObjectiveFcn.Mayer, + node=i, + fourier_coeff=force_fourier_coefficient, + key="F", + quadratic=True, + weight=1, + phase=phase + j * n_stim, + ) + + if end_node_tracking: + if isinstance(end_node_tracking, int | float): + for i in range(n_simultaneous_cycles): + objective_functions.add( + ObjectiveFcn.Mayer.MINIMIZE_STATE, + node=Node.END, + key="F", + quadratic=True, + weight=1, + target=end_node_tracking, + phase=n_stim - 1 + i * n_stim, + ) + + if time_min and time_max: + for i in range(n_stim): + for j in range(n_simultaneous_cycles): + objective_functions.add( + ObjectiveFcn.Mayer.MINIMIZE_TIME, + weight=0.001 / n_shooting[i], + min_bound=time_min, + max_bound=time_max, + quadratic=True, + phase=i + j * n_stim, + ) + + return objective_functions + + def _mhe_sanity_check(self): + if self.n_total_cycles is None: + raise ValueError("n_total_cycles must be set") + if self.n_simultaneous_cycles is None: + raise ValueError("n_simultaneous_cycles must be set") + if self.n_cycle_to_advance is None: + raise ValueError("n_cycle_to_advance must be set") + if self.cycle_to_keep is None: + raise ValueError("cycle_to_keep must be set") + + if self.n_total_cycles % self.n_cycle_to_advance != 0: + raise ValueError("The number of n_total_cycles must be a multiple of the number n_cycle_to_advance") + + if self.n_cycle_to_advance > self.n_simultaneous_cycles: + raise ValueError("The number of n_simultaneous_cycles must be higher than the number of n_cycle_to_advance") + + if self.cycle_to_keep not in ["first", "middle", "last"]: + raise ValueError("cycle_to_keep must be either 'first', 'middle' or 'last'") + + # if self.cycle_to_keep == "middle" and self.n_simultaneous_cycles % 2 == 0: + # raise ValueError("The number of n_total_cycles must be an odd number if cycle_to_keep is 'middle'") diff --git a/cocofest/optimization/fes_ocp_nmpc.py b/cocofest/optimization/fes_ocp_nmpc.py new file mode 100644 index 00000000..e69de29b From b7d82e376486716823db64514c95bbc6289c6d33 Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Mon, 26 Aug 2024 17:23:16 -0400 Subject: [PATCH 2/9] store time into results --- cocofest/examples/getting_started/mhe_try.py | 9 ++- cocofest/optimization/fes_ocp_mhe.py | 69 ++++++++++++++------ 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/cocofest/examples/getting_started/mhe_try.py b/cocofest/examples/getting_started/mhe_try.py index 64b1afd1..1fa08372 100644 --- a/cocofest/examples/getting_started/mhe_try.py +++ b/cocofest/examples/getting_started/mhe_try.py @@ -1,6 +1,7 @@ from bioptim import OdeSolver from cocofest import OcpFesMhe, DingModelPulseDurationFrequencyWithFatigue import numpy as np +import matplotlib.pyplot as plt time = np.linspace(0, 1, 100) @@ -12,15 +13,12 @@ n_stim=10, n_shooting=5, final_time=1, - # pulse_event={"min": 0.01, "max": 0.1, "bimapping": True}, pulse_duration={ "min": minimum_pulse_duration, "max": 0.0006, "bimapping": False, }, objective={"force_tracking": force_tracking}, - # objective={"end_node_tracking": 100}, - # , "custom": objective_functions}, n_total_cycles=6, n_simultaneous_cycles=3, n_cycle_to_advance=1, @@ -30,6 +28,11 @@ mhe.prepare_mhe() mhe.solve() +# print(mhe) +plt.plot(np.linspace(0, 6, 260), mhe.result_states["F"]) +plt.show() + + # sol = ocp.solve() # sol.graphs() diff --git a/cocofest/optimization/fes_ocp_mhe.py b/cocofest/optimization/fes_ocp_mhe.py index 3fa75935..b5158952 100644 --- a/cocofest/optimization/fes_ocp_mhe.py +++ b/cocofest/optimization/fes_ocp_mhe.py @@ -1,7 +1,7 @@ import math import numpy as np -from bioptim import SolutionMerge, ObjectiveList, ObjectiveFcn, OdeSolver, Node, OptimalControlProgram, ControlType +from bioptim import SolutionMerge, ObjectiveList, ObjectiveFcn, OdeSolver, Node, OptimalControlProgram, ControlType, TimeAlignment from cocofest import DingModelPulseDurationFrequencyWithFatigue, OcpFes, CustomObjective, FesModel # TODO relative import @@ -43,13 +43,14 @@ def __init__(self, self.n_threads = n_threads self.ocp = None self._mhe_sanity_check() - self.time = [] + # self.time = [] self.states = [] self.parameters = [] self.previous_stim = [] - self.result_states = {} - self.result_parameters = {} - self.result_time = {} + # self.result_states = {} + # self.result_parameters = {} + # self.result_time = {} + self.result = {"time": {}, "states": {}, "parameters": {}} def prepare_mhe(self): (pulse_event, pulse_duration, pulse_intensity, objective) = OcpFes._fill_dict( @@ -212,7 +213,11 @@ def update_time(self, sol, index): if index == 0: sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) sol_time = list(sol_time.reshape(sol_time.shape[0])) - self.time.append(sol_time) + sol_time = [t - sol_time[0] for t in sol_time] + + + + # self.time.append(sol_time) else: sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) sol_time = list(sol_time.reshape(sol_time.shape[0])) @@ -222,19 +227,16 @@ def update_time(self, sol, index): sol_time = list(sol_time.reshape(sol_time.shape[0])) final_time = sol_time[-1] + self.time[-1][-1] - if index != 0: sol_time = [x + self.time[-1][-1] for x in sol_time] self.time.append(sol_time) - def update_states(self, sol, state_keys): - sol_states = sol.decision_states(to_merge=[SolutionMerge.NODES]) + def update_states_bounds(self, sol_states, state_keys): for key in state_keys: self.ocp.nlp[0].x_bounds[key].max[0][0] = sol_states[self.n_stim//self.n_simultaneous_cycles - 1][key][0][-1] self.ocp.nlp[0].x_bounds[key].min[0][0] = sol_states[self.n_stim//self.n_simultaneous_cycles - 1][key][0][-1] for j in range(self.n_stim//self.n_simultaneous_cycles - 1, len(self.ocp.nlp)): self.ocp.nlp[j].x_init[key].init[0][0] = sol_states[j][key][0][0] - return sol_states def update_parameters(self, sol, parameters_keys): sol_parameters = sol.decision_parameters() @@ -259,18 +261,39 @@ def update_stim(self, sol): list(self.previous_stim) - def store_results(self, sol_states, index): + def store_results(self, sol_time, sol_states, sol_parameters, index, merge=False): if self.cycle_to_keep == "middle": + # Get the middle phase index to keep phase_to_keep = int(math.ceil(self.n_simultaneous_cycles / 2)) first_node_in_phase = self.n_stim * (phase_to_keep - 1) last_node_in_phase = self.n_stim * phase_to_keep - # self.result_time = self.time[first_node_in_phase:last_node_in_phase] - for key in list(sol_states[0].keys()): + + # Initialize the dict if it's the first iteration + if index == 0: + self.result["time"] = [None]*self.n_total_cycles + [self.result["states"].update({state_key: [None]*self.n_total_cycles}) for state_key in list(sol_states[0].keys())] + [self.result["parameters"].update({key_parameter: [None]*self.n_total_cycles}) for key_parameter in list(sol_parameters.keys())] + + # Store the results + phase_size = np.array(sol_time).shape[0] + node_size = np.array(sol_time).shape[1] + sol_time = list(np.array(sol_time).reshape(phase_size*node_size))[first_node_in_phase*node_size:last_node_in_phase*node_size] + if index == 0: + sol_time = [t - sol_time[0] for t in sol_time] + else: + sol_time = [t - sol_time[0] + self.result["time"][index-1][-1] for t in sol_time] + # time_diff = sol_time[first_node_in_phase] + # sol_time[first_node_in_phase:last_node_in_phase] = sol_time[first_node_in_phase:last_node_in_phase] - self.time[-1][-1] + self.result["time"][index] = sol_time # Todo remove last node + + for state_key in list(sol_states[0].keys()): middle_states_values = sol_states[first_node_in_phase:last_node_in_phase] - middle_states_values = [list(middle_states_values[i][key][0]) for i in range(len(middle_states_values))] + middle_states_values = [list(middle_states_values[i][state_key][0]) for i in range(len(middle_states_values))] middle_states_values = [j for sub in middle_states_values for j in sub] - self.result_states[key] = middle_states_values# Todo might be wrong - # self.result_parameters = self.parameters[first_node_in_phase:last_node_in_phase] + self.result["states"][state_key][index] = middle_states_values# Todo might be wrong + + for key_parameter in list(sol_parameters.keys()): + self.result["parameters"][key_parameter][index] = sol_parameters[key_parameter][first_node_in_phase:last_node_in_phase] return def solve(self): @@ -278,12 +301,20 @@ def solve(self): parameters_keys = list(self.ocp.nlp[0].parameters.keys()) for i in range(self.n_total_cycles // self.n_cycle_to_advance): sol = self.ocp.solve() - # self.update_time(sol, index=i) - sol_states = self.update_states(sol, state_keys) + + sol_states = sol.decision_states(to_merge=[SolutionMerge.NODES]) + self.update_states_bounds(sol_states, state_keys) + + sol_time = sol.decision_time(to_merge=SolutionMerge.NODES, time_alignment=TimeAlignment.STATES) + # sol_time = self.update_time(sol, index=i) + + sol_parameters = sol.decision_parameters() # self.update_parameters(sol, parameters_keys) # self.update_stim(sol) - self.store_results(sol_states, i) + # sol.graphs() + + self.store_results(sol_time, sol_states, sol_parameters, i) # return self.ocp.solve() @staticmethod From 39239f192c2aad55299bc23e2c734b1dfd907617 Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Tue, 27 Aug 2024 12:54:04 -0400 Subject: [PATCH 3/9] fixed time continuity and past stim --- cocofest/examples/getting_started/mhe_try.py | 18 +-- cocofest/optimization/fes_ocp_mhe.py | 146 ++++++------------- 2 files changed, 51 insertions(+), 113 deletions(-) diff --git a/cocofest/examples/getting_started/mhe_try.py b/cocofest/examples/getting_started/mhe_try.py index 1fa08372..0d9e6cfe 100644 --- a/cocofest/examples/getting_started/mhe_try.py +++ b/cocofest/examples/getting_started/mhe_try.py @@ -4,9 +4,9 @@ import matplotlib.pyplot as plt -time = np.linspace(0, 1, 100) -force = abs(np.sin(time * 5) + np.random.normal(scale=0.1, size=len(time))) * 100 -force_tracking = [time, force] +time1 = np.linspace(0, 6, 600) +force1 = abs(np.sin(time1 * 5) + np.random.normal(scale=0.1, size=len(time1))) * 100 +force_tracking = [time1, force1] minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 mhe = OcpFesMhe(model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -29,10 +29,10 @@ mhe.prepare_mhe() mhe.solve() # print(mhe) -plt.plot(np.linspace(0, 6, 260), mhe.result_states["F"]) +time = [j for sub in mhe.result["time"] for j in sub] +force = [j for sub in mhe.result["states"]["F"] for j in sub] +# fatigue = [j for sub in mhe.result["states"]["A"] for j in sub] +# plt.plot(time, fatigue) +plt.plot(time, force) +plt.plot(time1, force1) plt.show() - - -# sol = ocp.solve() -# sol.graphs() - diff --git a/cocofest/optimization/fes_ocp_mhe.py b/cocofest/optimization/fes_ocp_mhe.py index b5158952..4c745950 100644 --- a/cocofest/optimization/fes_ocp_mhe.py +++ b/cocofest/optimization/fes_ocp_mhe.py @@ -174,99 +174,46 @@ def prepare_mhe(self): return self.ocp - # def update_mhe(self, previous_sol): - # sol_states = sol.decision_states(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) - # cn_results.append(list(sol_states["Cn"][0])) - # f_results.append(list(sol_states["F"][0])) - # a_results.append(list(sol_states["A"][0])) - # tau1_results.append(list(sol_states["Tau1"][0])) - # km_results.append(list(sol_states["Km"][0])) - # sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) - # sol_time = list(sol_time.reshape(sol_time.shape[0])) - # - # sol_time_stim_parameters = sol.decision_parameters()["pulse_apparition_time"] - # - # if previous_stim: - # # stim_prev = list(sol_time_stim_parameters - sol_time[-1]) - # update_previous_stim = list(np.array(previous_stim) - sol_time[-1]) - # previous_stim = update_previous_stim + stim_prev - # else: - # stim_prev = list(sol_time_stim_parameters - sol_time[-1]) - # previous_stim = stim_prev - # - # if i != 0: - # sol_time = [x + time[-1][-1] for x in sol_time] - # - # time.append(sol_time) - # keys = list(sol_states.keys()) - # - # for key in keys: - # ocp.nlp[0].x_bounds[key].max[0][0] = sol_states[key][-1][-1] - # ocp.nlp[0].x_bounds[key].min[0][0] = sol_states[key][-1][-1] - # for j in range(len(ocp.nlp)): - # ocp.nlp[j].model = DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10, - # stim_prev=previous_stim) - # for key in keys: - # ocp.nlp[j].x_init[key].init[0][0] = sol_states[key][-1][-1] - - def update_time(self, sol, index): - if index == 0: - sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) - sol_time = list(sol_time.reshape(sol_time.shape[0])) - sol_time = [t - sol_time[0] for t in sol_time] - - - - # self.time.append(sol_time) - else: - sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) - sol_time = list(sol_time.reshape(sol_time.shape[0])) - - - sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) - sol_time = list(sol_time.reshape(sol_time.shape[0])) - final_time = sol_time[-1] + self.time[-1][-1] - - if index != 0: - sol_time = [x + self.time[-1][-1] for x in sol_time] - self.time.append(sol_time) - - def update_states_bounds(self, sol_states, state_keys): + def update_states_bounds(self, sol_states): + state_keys = list(self.ocp.nlp[0].states.keys()) + index_to_keep = 1 * self.n_stim - 1 # todo: update this when more simultaneous cycles than 3 for key in state_keys: - self.ocp.nlp[0].x_bounds[key].max[0][0] = sol_states[self.n_stim//self.n_simultaneous_cycles - 1][key][0][-1] - self.ocp.nlp[0].x_bounds[key].min[0][0] = sol_states[self.n_stim//self.n_simultaneous_cycles - 1][key][0][-1] - for j in range(self.n_stim//self.n_simultaneous_cycles - 1, len(self.ocp.nlp)): + self.ocp.nlp[0].x_bounds[key].max[0][0] = sol_states[index_to_keep][key][0][-1] + self.ocp.nlp[0].x_bounds[key].min[0][0] = sol_states[index_to_keep][key][0][-1] + for j in range(index_to_keep, len(self.ocp.nlp)): self.ocp.nlp[j].x_init[key].init[0][0] = sol_states[j][key][0][0] - def update_parameters(self, sol, parameters_keys): - sol_parameters = sol.decision_parameters() - sol_parameters_dict = {key: list(sol_parameters[key][0]) for key in sol_parameters.keys()} + # def update_parameters(self, sol, parameters_keys): + # sol_parameters = sol.decision_parameters() + # sol_parameters_dict = {key: list(sol_parameters[key][0]) for key in sol_parameters.keys()} + # return + + def update_objective(self, sol): return def update_stim(self, sol): - previous_time = self.time[0] if "pulse_apparition_time" in sol.decision_parameters(): - sol_time_stim_parameters = sol.decision_parameters()["pulse_apparition_time"] - - if self.previous_stim: - stim_prev = list(sol_time_stim_parameters - self.time[-1]) - update_previous_stim = list(np.array(self.previous_stim) - self.time[-1]) - self.previous_stim = update_previous_stim + stim_prev - # TODO: check if correct did some modification since last time - # TODO: wrong, will take the last previous_stim from n_simultaneous_cycles at each iteration - # TODO: wrong, time is pushed to many times - else: - stim_prev = list(sol_time_stim_parameters - self.time[-1]) - self.previous_stim = stim_prev + stimulation_time = sol.decision_parameters()["pulse_apparition_time"] + else: + stimulation_time = [0] + list(np.cumsum(sol.ocp.phase_time[:self.n_stim-1])) - list(self.previous_stim) + stim_prev = list(np.array(stimulation_time) - self.final_time) + if self.previous_stim: + update_previous_stim = list(np.array(self.previous_stim) - self.final_time) + self.previous_stim = update_previous_stim + stim_prev + + else: + self.previous_stim = stim_prev + + for j in range(len(sol.ocp.nlp)): + sol.ocp.nlp[j].model.set_pass_pulse_apparition_time(self.previous_stim) #TODO: Does not seem to impact the model estimation def store_results(self, sol_time, sol_states, sol_parameters, index, merge=False): if self.cycle_to_keep == "middle": # Get the middle phase index to keep phase_to_keep = int(math.ceil(self.n_simultaneous_cycles / 2)) - first_node_in_phase = self.n_stim * (phase_to_keep - 1) - last_node_in_phase = self.n_stim * phase_to_keep + self.first_node_in_phase = self.n_stim * (phase_to_keep - 1) + self.last_node_in_phase = self.n_stim * phase_to_keep # Initialize the dict if it's the first iteration if index == 0: @@ -277,45 +224,34 @@ def store_results(self, sol_time, sol_states, sol_parameters, index, merge=False # Store the results phase_size = np.array(sol_time).shape[0] node_size = np.array(sol_time).shape[1] - sol_time = list(np.array(sol_time).reshape(phase_size*node_size))[first_node_in_phase*node_size:last_node_in_phase*node_size] + sol_time = list(np.array(sol_time).reshape(phase_size*node_size))[self.first_node_in_phase*node_size:self.last_node_in_phase*node_size] + sol_time = list(dict.fromkeys(sol_time)) # Remove duplicate time if index == 0: - sol_time = [t - sol_time[0] for t in sol_time] + updated_sol_time = [t - sol_time[0] for t in sol_time] else: - sol_time = [t - sol_time[0] + self.result["time"][index-1][-1] for t in sol_time] - # time_diff = sol_time[first_node_in_phase] - # sol_time[first_node_in_phase:last_node_in_phase] = sol_time[first_node_in_phase:last_node_in_phase] - self.time[-1][-1] - self.result["time"][index] = sol_time # Todo remove last node + updated_sol_time = [t - sol_time[0] + self.temp_last_node_time for t in sol_time] + self.temp_last_node_time = updated_sol_time[-1] + self.result["time"][index] = updated_sol_time[:-1] for state_key in list(sol_states[0].keys()): - middle_states_values = sol_states[first_node_in_phase:last_node_in_phase] - middle_states_values = [list(middle_states_values[i][state_key][0]) for i in range(len(middle_states_values))] + middle_states_values = sol_states[self.first_node_in_phase:self.last_node_in_phase] + middle_states_values = [list(middle_states_values[i][state_key][0])[:-1] for i in range(len(middle_states_values))] # Remove the last node duplicate middle_states_values = [j for sub in middle_states_values for j in sub] self.result["states"][state_key][index] = middle_states_values# Todo might be wrong for key_parameter in list(sol_parameters.keys()): - self.result["parameters"][key_parameter][index] = sol_parameters[key_parameter][first_node_in_phase:last_node_in_phase] + self.result["parameters"][key_parameter][index] = sol_parameters[key_parameter][self.first_node_in_phase:self.last_node_in_phase] return def solve(self): - state_keys = list(self.ocp.nlp[0].states.keys()) - parameters_keys = list(self.ocp.nlp[0].parameters.keys()) for i in range(self.n_total_cycles // self.n_cycle_to_advance): sol = self.ocp.solve() - sol_states = sol.decision_states(to_merge=[SolutionMerge.NODES]) - self.update_states_bounds(sol_states, state_keys) - + self.update_states_bounds(sol_states) sol_time = sol.decision_time(to_merge=SolutionMerge.NODES, time_alignment=TimeAlignment.STATES) - # sol_time = self.update_time(sol, index=i) - sol_parameters = sol.decision_parameters() - # self.update_parameters(sol, parameters_keys) - # self.update_stim(sol) - - # sol.graphs() - self.store_results(sol_time, sol_states, sol_parameters, i) - # return self.ocp.solve() + self.update_stim(sol) @staticmethod def _set_objective(n_stim, n_shooting, force_fourier_coefficient, end_node_tracking, custom_objective, time_min, time_max, @@ -389,6 +325,8 @@ def _mhe_sanity_check(self): if self.cycle_to_keep not in ["first", "middle", "last"]: raise ValueError("cycle_to_keep must be either 'first', 'middle' or 'last'") + if self.cycle_to_keep != "middle": + raise NotImplementedError("Only 'middle' cycle_to_keep is implemented") - # if self.cycle_to_keep == "middle" and self.n_simultaneous_cycles % 2 == 0: - # raise ValueError("The number of n_total_cycles must be an odd number if cycle_to_keep is 'middle'") + if self.n_simultaneous_cycles != 3: + raise NotImplementedError("Only 3 simultaneous cycles are implemented yet work in progress") # todo add more simultaneous cycles From c9bfbec7be7cb4f2dca45390b6aaf788f1f7780c Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Tue, 27 Aug 2024 16:01:37 -0400 Subject: [PATCH 4/9] first example of mhe --- .../pulse_duration_optimization_mhe.py | 168 +++++++----------- cocofest/optimization/fes_ocp_mhe.py | 7 +- 2 files changed, 69 insertions(+), 106 deletions(-) diff --git a/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py b/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py index 580ad2c1..0428eda4 100644 --- a/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py +++ b/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py @@ -1,115 +1,81 @@ """ -This example will do a 10 stimulation example with Ding's 2007 pulse duration and frequency model. -This ocp was build to match a force value of 200N at the end of the last node. +This example showcases a moving time horizon simulation problem of cyclic muscle force tracking. +The FES model used here is Ding's 2007 pulse duration and frequency model with fatigue. +Only the pulse duration is optimized, frequency is fixed. +The mhe problem is composed of 3 cycles and will move forward 1 cycle at each step. +Only the middle cycle is kept in the optimization problem, the mhe problem stops once the last 6th cycle is reached. """ -import numpy as np -from bioptim import SolutionMerge, ObjectiveList, ObjectiveFcn, OdeSolver -from cocofest import DingModelPulseDurationFrequencyWithFatigue, OcpFes -# --- Build ocp --- # -# This ocp was build to match a force value of 200N at the end of the last node. -# The stimulation will be optimized between 0.01 to 0.1 seconds and are equally spaced (a fixed frequency). -# Plus the pulsation duration will be optimized between 0 and 0.0006 seconds and are not the same across the problem. -# The flag with_fatigue is set to True by default, this will include the fatigue model +import numpy as np +import matplotlib.pyplot as plt -# objective_functions = ObjectiveList() -# n_stim = 10 -# for i in range(n_stim): -# objective_functions.add(ObjectiveFcn.Lagrange.MINIMIZE_STATE, key="F", weight=1/100000, quadratic=True, phase=i) +from bioptim import OdeSolver +from cocofest import OcpFesMhe, DingModelPulseDurationFrequencyWithFatigue -# --- Building force to track ---# -time = np.linspace(0, 1, 100) -force = abs(np.sin(time * 5) + np.random.normal(scale=0.1, size=len(time))) * 100 -force_tracking = [time, force] +# --- Build target force --- # +target_time = np.linspace(0, 1, 100) +target_force = abs(np.sin(target_time*np.pi)) * 200 +force_tracking = [target_time, target_force] +# --- Build mhe --- # minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 -ocp = OcpFes().prepare_ocp( - model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), - n_stim=10, - n_shooting=5, - final_time=1, - # pulse_event={"min": 0.01, "max": 0.1, "bimapping": True}, - pulse_duration={ - "min": minimum_pulse_duration, - "max": 0.0006, - "bimapping": False, - }, - objective={"force_tracking": force_tracking}, - # objective={"end_node_tracking": 100}, - # , "custom": objective_functions}, - use_sx=True, - ode_solver=OdeSolver.COLLOCATION(), -) - -# --- Solve the program --- # -cn_results = [] -f_results = [] -a_results = [] -tau1_results = [] -km_results = [] -time = [] -previous_stim = [] -for i in range(5): - sol = ocp.solve() - # sol.graphs(show_bounds=True) - sol_states = sol.decision_states(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) - cn_results.append(list(sol_states["Cn"][0])) - f_results.append(list(sol_states["F"][0])) - a_results.append(list(sol_states["A"][0])) - tau1_results.append(list(sol_states["Tau1"][0])) - km_results.append(list(sol_states["Km"][0])) - sol_time = sol.decision_time(to_merge=[SolutionMerge.PHASES, SolutionMerge.NODES]) - sol_time = list(sol_time.reshape(sol_time.shape[0])) - - # sol_time_stim_parameters = sol.decision_parameters()["pulse_apparition_time"] - - # if previous_stim: - # # stim_prev = list(sol_time_stim_parameters - sol_time[-1]) - # update_previous_stim = list(np.array(previous_stim) - sol_time[-1]) - # previous_stim = update_previous_stim + stim_prev - # else: - # stim_prev = list(sol_time_stim_parameters - sol_time[-1]) - # previous_stim = stim_prev +mhe = OcpFesMhe(model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=30, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": minimum_pulse_duration, + "max": 0.0006, + "bimapping": False, + }, + objective={"force_tracking": force_tracking}, + n_total_cycles=8, + n_simultaneous_cycles=3, + n_cycle_to_advance=1, + cycle_to_keep="middle", + use_sx=True, + ode_solver=OdeSolver.COLLOCATION()) - if i != 0: - sol_time = [x + time[-1][-1] for x in sol_time] +mhe.prepare_mhe() +mhe.solve() - time.append(sol_time) - keys = list(sol_states.keys()) +# --- Show results --- # +time = [j for sub in mhe.result["time"] for j in sub] +fatigue = [j for sub in mhe.result["states"]["A"] for j in sub] +force = [j for sub in mhe.result["states"]["F"] for j in sub] - for key in keys: - ocp.nlp[0].x_bounds[key].max[0][0] = sol_states[key][-1][-1] - ocp.nlp[0].x_bounds[key].min[0][0] = sol_states[key][-1][-1] - for j in range(len(ocp.nlp)): - ocp.nlp[j].model = DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10, stim_prev=previous_stim) - for key in keys: - ocp.nlp[j].x_init[key].init[0][0] = sol_states[key][-1][-1] +ax1 = plt.subplot(221) +ax1.plot(time, fatigue, label='A', color='green') +ax1.set_title('Fatigue', weight='bold') +ax1.set_xlabel('Time') +ax1.set_ylabel('Force scaling factor') +plt.legend() +ax2 = plt.subplot(222) +ax2.plot(time, force, label='F', color='red', linewidth=2) +ax2.plot(target_time, target_force, label='Target', color='purple') +ax2.set_title('Force', weight='bold') +ax2.set_xlabel('Time') +ax2.set_ylabel('Force (N)') +plt.legend() -# --- Show results --- # -cn_results = [j for sub in cn_results for j in sub] -f_results = [j for sub in f_results for j in sub] -a_results = [j for sub in a_results for j in sub] -tau1_results = [j for sub in tau1_results for j in sub] -km_results = [j for sub in km_results for j in sub] -time_result = [j for sub in time for j in sub] +barWidth = 0.25 # set width of bar +cycles = mhe.result["parameters"]["pulse_duration"] # set height of bar +bar = [] # Set position of bar on X axis +for i in range(6): + if i == 0: + br = [barWidth * (x + 1) for x in range(len(cycles[i]))] + else: + br = [bar[-1][-1] + barWidth * (x + 1) for x in range(len(cycles[i]))] + bar.append(br) -import matplotlib.pyplot as plt -fig, axs = plt.subplots(5) -axs[0].plot(time_result, cn_results) -axs[0].set_ylabel("Cn") -axs[1].plot(time_result, f_results) -axs[1].set_ylabel("F") -axs[2].plot(time_result, a_results) -axs[2].set_ylabel("A") -axs[3].plot(time_result, tau1_results) -axs[3].set_ylabel("Tau1") -axs[4].plot(time_result, km_results) -axs[4].set_ylabel("Km") -plt.xlabel("Time (s)") -plt.ylabel("Force (N)") +ax3 = plt.subplot(212) +for i in range(6): + ax3.bar(bar[i], cycles[i], width = barWidth, + edgecolor ='grey', label =f'cycle n°{i+1}') +ax3.set_xticks([np.mean(r) for r in bar], ['1', '2', '3', '4', '5', '6']) +ax3.set_xlabel('Cycles') +ax3.set_ylabel('Pulse duration (s)') +plt.legend() +ax3.set_title('Pulse duration', weight='bold') plt.show() - -print(previous_stim + time_result[-1]) - -# TODO : Add the init parameters and state parameters according to the previous solution for each phase diff --git a/cocofest/optimization/fes_ocp_mhe.py b/cocofest/optimization/fes_ocp_mhe.py index 4c745950..23e2d05e 100644 --- a/cocofest/optimization/fes_ocp_mhe.py +++ b/cocofest/optimization/fes_ocp_mhe.py @@ -188,9 +188,6 @@ def update_states_bounds(self, sol_states): # sol_parameters_dict = {key: list(sol_parameters[key][0]) for key in sol_parameters.keys()} # return - def update_objective(self, sol): - return - def update_stim(self, sol): if "pulse_apparition_time" in sol.decision_parameters(): stimulation_time = sol.decision_parameters()["pulse_apparition_time"] @@ -205,8 +202,8 @@ def update_stim(self, sol): else: self.previous_stim = stim_prev - for j in range(len(sol.ocp.nlp)): - sol.ocp.nlp[j].model.set_pass_pulse_apparition_time(self.previous_stim) #TODO: Does not seem to impact the model estimation + for j in range(len(self.ocp.nlp)): + self.ocp.nlp[j].model.set_pass_pulse_apparition_time(self.previous_stim) #TODO: Does not seem to be taken into account by the next model force estimation def store_results(self, sol_time, sol_states, sol_parameters, index, merge=False): if self.cycle_to_keep == "middle": From 2da02c224ae8d57cfa2e2270e7061c887b063c3c Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Thu, 29 Aug 2024 13:29:28 -0400 Subject: [PATCH 5/9] Implementing the first nmpc cyclic example --- cocofest/__init__.py | 3 +- ...cycling_fes_driven_min_residual_torque.pkl | Bin 0 -> 106580 bytes .../cycling/plot_cycling_from_pickle.py | 2 +- .../force_and_fatigue_subplot.py | 17 +++---- cocofest/examples/getting_started/mhe_try.py | 38 --------------- ...ulse_duration_optimization_nmpc_cyclic.py} | 45 +++++++++-------- .../truncation/summation_truncation_graph.py | 30 ++++++------ cocofest/optimization/fes_ocp.py | 5 +- cocofest/optimization/fes_ocp_nmpc.py | 0 ...{fes_ocp_mhe.py => fes_ocp_nmpc_cyclic.py} | 46 ++++++++---------- 10 files changed, 69 insertions(+), 117 deletions(-) create mode 100644 cocofest/examples/dynamics/cycling/cycling_fes_driven_min_residual_torque.pkl delete mode 100644 cocofest/examples/getting_started/mhe_try.py rename cocofest/examples/getting_started/{pulse_duration_optimization_mhe.py => pulse_duration_optimization_nmpc_cyclic.py} (59%) delete mode 100644 cocofest/optimization/fes_ocp_nmpc.py rename cocofest/optimization/{fes_ocp_mhe.py => fes_ocp_nmpc_cyclic.py} (92%) diff --git a/cocofest/__init__.py b/cocofest/__init__.py index 113f4841..62a3c029 100644 --- a/cocofest/__init__.py +++ b/cocofest/__init__.py @@ -11,8 +11,7 @@ from .optimization.fes_ocp import OcpFes from .optimization.fes_identification_ocp import OcpFesId from .optimization.fes_ocp_dynamics import OcpFesMsk -# from .optimization.fes_ocp_nmpc import OcpFesNmpc -from .optimization.fes_ocp_mhe import OcpFesMhe +from .optimization.fes_ocp_nmpc_cyclic import OcpFesNmpcCyclic from .integration.ivp_fes import IvpFes from .fourier_approx import FourierSeries from .identification.ding2003_force_parameter_identification import DingModelFrequencyForceParameterIdentification diff --git a/cocofest/examples/dynamics/cycling/cycling_fes_driven_min_residual_torque.pkl b/cocofest/examples/dynamics/cycling/cycling_fes_driven_min_residual_torque.pkl new file mode 100644 index 0000000000000000000000000000000000000000..0230d94e2da3af77052d7e54a3f327155ba75437 GIT binary patch literal 106580 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 ZoqV1j$LmXu2QNH;i4wU4`(=xne*iCEfT92Z literal 0 HcmV?d00001 diff --git a/cocofest/examples/dynamics/cycling/plot_cycling_from_pickle.py b/cocofest/examples/dynamics/cycling/plot_cycling_from_pickle.py index d169a15f..ec6fd886 100644 --- a/cocofest/examples/dynamics/cycling/plot_cycling_from_pickle.py +++ b/cocofest/examples/dynamics/cycling/plot_cycling_from_pickle.py @@ -1,4 +1,4 @@ from cocofest import PlotCyclingResult # Plot the results -PlotCyclingResult("cycling_fes_driven_min_residual_torque_and_fatigue_results.pkl").plot(starting_location="E") +PlotCyclingResult("cycling_fes_driven_min_residual_torque.pkl").plot(starting_location="E") diff --git a/cocofest/examples/dynamics/reaching_task/visualization/force_and_fatigue_subplot.py b/cocofest/examples/dynamics/reaching_task/visualization/force_and_fatigue_subplot.py index cabea30b..a9574421 100644 --- a/cocofest/examples/dynamics/reaching_task/visualization/force_and_fatigue_subplot.py +++ b/cocofest/examples/dynamics/reaching_task/visualization/force_and_fatigue_subplot.py @@ -180,7 +180,7 @@ ) fig, axs = plt.subplots(1, 1, figsize=(3, (1 / 3) * 7)) -fig.suptitle("Muscle fatigue", fontsize=20, fontweight="bold", fontname="Times New Roman") +fig.suptitle("Muscle fatigue", fontsize=20, fontweight="bold") axs.set_xlim(left=0, right=1.5) plt.setp( @@ -192,8 +192,8 @@ a_force_sum_percentage = (np.array(a_force_sum_list) / a_sum_base_line) * 100 a_fatigue_sum_percentage = (np.array(a_fatigue_sum_list) / a_sum_base_line) * 100 -axs.plot(data_minimize_force["time"], a_force_sum_percentage, lw=5) -axs.plot(data_minimize_force["time"], a_fatigue_sum_percentage, lw=5) +axs.plot(data_minimize_force["time"], a_force_sum_percentage, lw=5, label="Minimize force production") +axs.plot(data_minimize_force["time"], a_fatigue_sum_percentage, lw=5, label="Maximize muscle capacity") axs.set_xlim(left=0, right=1.5) @@ -204,21 +204,16 @@ ) labels = axs.get_xticklabels() + axs.get_yticklabels() -[label.set_fontname("Times New Roman") for label in labels] -[label.set_fontsize(14) for label in labels] fig.text( 0.05, 0.5, - "Fatigue percentage (%)", + "Muscle capacity (%)", ha="center", va="center", rotation="vertical", fontsize=18, weight="bold", - font="Times New Roman", -) -axs.text(0.75, 96.3, "Time (s)", ha="center", va="center", fontsize=18, weight="bold", font="Times New Roman") -plt.legend( - ["Force", "Fatigue"], loc="upper right", ncol=1, prop={"family": "Times New Roman", "size": 14, "weight": "bold"} ) +axs.text(0.75, 96.3, "Time (s)", ha="center", va="center", fontsize=18, weight="bold") +axs.legend(title='Cost function', fontsize="medium", loc="upper right", ncol=1) plt.show() diff --git a/cocofest/examples/getting_started/mhe_try.py b/cocofest/examples/getting_started/mhe_try.py deleted file mode 100644 index 0d9e6cfe..00000000 --- a/cocofest/examples/getting_started/mhe_try.py +++ /dev/null @@ -1,38 +0,0 @@ -from bioptim import OdeSolver -from cocofest import OcpFesMhe, DingModelPulseDurationFrequencyWithFatigue -import numpy as np -import matplotlib.pyplot as plt - - -time1 = np.linspace(0, 6, 600) -force1 = abs(np.sin(time1 * 5) + np.random.normal(scale=0.1, size=len(time1))) * 100 -force_tracking = [time1, force1] - -minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 -mhe = OcpFesMhe(model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), - n_stim=10, - n_shooting=5, - final_time=1, - pulse_duration={ - "min": minimum_pulse_duration, - "max": 0.0006, - "bimapping": False, - }, - objective={"force_tracking": force_tracking}, - n_total_cycles=6, - n_simultaneous_cycles=3, - n_cycle_to_advance=1, - cycle_to_keep="middle", - use_sx=True, - ode_solver=OdeSolver.COLLOCATION()) - -mhe.prepare_mhe() -mhe.solve() -# print(mhe) -time = [j for sub in mhe.result["time"] for j in sub] -force = [j for sub in mhe.result["states"]["F"] for j in sub] -# fatigue = [j for sub in mhe.result["states"]["A"] for j in sub] -# plt.plot(time, fatigue) -plt.plot(time, force) -plt.plot(time1, force1) -plt.show() diff --git a/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py b/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py similarity index 59% rename from cocofest/examples/getting_started/pulse_duration_optimization_mhe.py rename to cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py index 0428eda4..f0504f82 100644 --- a/cocofest/examples/getting_started/pulse_duration_optimization_mhe.py +++ b/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py @@ -2,24 +2,25 @@ This example showcases a moving time horizon simulation problem of cyclic muscle force tracking. The FES model used here is Ding's 2007 pulse duration and frequency model with fatigue. Only the pulse duration is optimized, frequency is fixed. -The mhe problem is composed of 3 cycles and will move forward 1 cycle at each step. -Only the middle cycle is kept in the optimization problem, the mhe problem stops once the last 6th cycle is reached. +The nmpc cyclic problem is composed of 3 cycles and will move forward 1 cycle at each step. +Only the middle cycle is kept in the optimization problem, the nmpc cyclic problem stops once the last 6th cycle is reached. """ import numpy as np import matplotlib.pyplot as plt from bioptim import OdeSolver -from cocofest import OcpFesMhe, DingModelPulseDurationFrequencyWithFatigue +from cocofest import OcpFesNmpcCyclic, DingModelPulseDurationFrequencyWithFatigue # --- Build target force --- # target_time = np.linspace(0, 1, 100) target_force = abs(np.sin(target_time*np.pi)) * 200 force_tracking = [target_time, target_force] -# --- Build mhe --- # +# --- Build nmpc cyclic --- # +n_total_cycles = 8 minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 -mhe = OcpFesMhe(model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), +nmpc = OcpFesNmpcCyclic(model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), n_stim=30, n_shooting=5, final_time=1, @@ -29,40 +30,44 @@ "bimapping": False, }, objective={"force_tracking": force_tracking}, - n_total_cycles=8, + n_total_cycles=n_total_cycles, n_simultaneous_cycles=3, n_cycle_to_advance=1, cycle_to_keep="middle", use_sx=True, ode_solver=OdeSolver.COLLOCATION()) -mhe.prepare_mhe() -mhe.solve() +nmpc.prepare_nmpc() +nmpc.solve() # --- Show results --- # -time = [j for sub in mhe.result["time"] for j in sub] -fatigue = [j for sub in mhe.result["states"]["A"] for j in sub] -force = [j for sub in mhe.result["states"]["F"] for j in sub] +time = [j for sub in nmpc.result["time"] for j in sub] +fatigue = [j for sub in nmpc.result["states"]["A"] for j in sub] +force = [j for sub in nmpc.result["states"]["F"] for j in sub] ax1 = plt.subplot(221) ax1.plot(time, fatigue, label='A', color='green') ax1.set_title('Fatigue', weight='bold') -ax1.set_xlabel('Time') -ax1.set_ylabel('Force scaling factor') +ax1.set_xlabel('Time (s)') +ax1.set_ylabel('Force scaling factor (-)') plt.legend() ax2 = plt.subplot(222) -ax2.plot(time, force, label='F', color='red', linewidth=2) -ax2.plot(target_time, target_force, label='Target', color='purple') +ax2.plot(time, force, label='F', color='red', linewidth=4) +for i in range(n_total_cycles): + if i == 0: + ax2.plot(target_time, target_force, label='Target', color='purple') + else: + ax2.plot(target_time + i, target_force, color='purple') ax2.set_title('Force', weight='bold') -ax2.set_xlabel('Time') +ax2.set_xlabel('Time (s)') ax2.set_ylabel('Force (N)') plt.legend() barWidth = 0.25 # set width of bar -cycles = mhe.result["parameters"]["pulse_duration"] # set height of bar +cycles = nmpc.result["parameters"]["pulse_duration"] # set height of bar bar = [] # Set position of bar on X axis -for i in range(6): +for i in range(n_total_cycles): if i == 0: br = [barWidth * (x + 1) for x in range(len(cycles[i]))] else: @@ -70,10 +75,10 @@ bar.append(br) ax3 = plt.subplot(212) -for i in range(6): +for i in range(n_total_cycles): ax3.bar(bar[i], cycles[i], width = barWidth, edgecolor ='grey', label =f'cycle n°{i+1}') -ax3.set_xticks([np.mean(r) for r in bar], ['1', '2', '3', '4', '5', '6']) +ax3.set_xticks([np.mean(r) for r in bar], [str(i+1) for i in range(n_total_cycles)]) ax3.set_xlabel('Cycles') ax3.set_ylabel('Pulse duration (s)') plt.legend() diff --git a/cocofest/examples/sensitivity/truncation/summation_truncation_graph.py b/cocofest/examples/sensitivity/truncation/summation_truncation_graph.py index 28e869fd..a20e5f75 100644 --- a/cocofest/examples/sensitivity/truncation/summation_truncation_graph.py +++ b/cocofest/examples/sensitivity/truncation/summation_truncation_graph.py @@ -157,18 +157,18 @@ ticks=[1e-12, 1e-10, 1e-8, 1e-6, 1e-4, 1e-2, 1, max_error], cmap=cmap, ) -cbar1.set_label(label="Force absolute error (N)", size=25, fontname="Times New Roman") +cbar1.set_label(label="Muscle force absolute error (N)", size=25, fontname="Times New Roman") cbar1.ax.set_yticklabels( [ - "{:.0e}".format(float(1e-12)), - "{:.0e}".format(float(1e-10)), - "{:.0e}".format(float(1e-8)), - "{:.0e}".format(float(1e-6)), - "{:.0e}".format(float(1e-4)), - "{:.0e}".format(float(1e-2)), - "{:.0e}".format(float(1)), - "{:.1e}".format(float(round(max_error))), + "1e⁻¹²", + "1e⁻¹⁰", + "1e⁻⁸", + "1e⁻⁶", + "1e⁻⁴", + "1e⁻²", + "1e⁰", + "5.3e⁺¹", ], size=25, fontname="Times New Roman", @@ -184,14 +184,14 @@ y_beneath_1e_8 = [] for j in range(len((all_mode_list_error_beneath_1e_8[i]))): y_beneath_1e_8.append(parameter_list[i][all_mode_list_error_beneath_1e_8[i][j]][1]) - axs.plot(x_beneath_1e_8, y_beneath_1e_8, color="darkred", label="Calcium absolute error < 1e-8", linewidth=3) + axs.plot(x_beneath_1e_8, y_beneath_1e_8, color="darkred", label=r"Calcium absolute error < 1e⁻⁸", linewidth=3) -axs.scatter(0, 0, color="white", label="OCP (s) | 100 Integrations (s)", marker="+", s=0, lw=0) +axs.scatter(0, 0, color="white", label="Initialization (s) | 100 Integrations (s)", marker="+", s=0, lw=0) axs.scatter( 1, 1, color="blue", - label=" " + str(round(a_ocp_time, 3)) + " " + str(round(a_integration_time, 3)), + label=" " + str(round(a_ocp_time, 3)) + " " + str(round(a_integration_time, 3)), marker="^", s=200, lw=5, @@ -200,7 +200,7 @@ 100, 39, color="black", - label=" " + str(round(b_ocp_time, 3)) + " " + str(round(b_integration_time, 3)), + label=" " + str(round(b_ocp_time, 3)) + " " + str(round(b_integration_time, 3)), marker="+", s=500, lw=5, @@ -209,7 +209,7 @@ 100, 100, color="green", - label=" " + str(round(c_ocp_time, 3)) + " " + str(round(c_integration_time, 3)), + label=" " + str(round(c_ocp_time, 3)) + " " + str(round(c_integration_time, 3)), marker=",", s=200, lw=5, @@ -217,7 +217,7 @@ axs.set_xlabel("Frequency (Hz)", fontsize=25, fontname="Times New Roman") axs.xaxis.set_major_locator(MaxNLocator(integer=True)) -axs.set_ylabel("Past stimulation kept for computation (n)", fontsize=25, fontname="Times New Roman") +axs.set_ylabel("Past stimulations kept for computation (n)", fontsize=25, fontname="Times New Roman") axs.yaxis.set_major_locator(MaxNLocator(integer=True)) ticks = np.arange(1, 101, 1).tolist() diff --git a/cocofest/optimization/fes_ocp.py b/cocofest/optimization/fes_ocp.py index 4118d9c4..a36c8196 100644 --- a/cocofest/optimization/fes_ocp.py +++ b/cocofest/optimization/fes_ocp.py @@ -1,8 +1,6 @@ import numpy as np from bioptim import ( - BiMapping, - # BiMappingList, parameter mapping not yet implemented BoundsList, ConstraintList, ControlType, @@ -29,7 +27,6 @@ from ..models.ding2007 import DingModelPulseDurationFrequency from ..models.ding2007_with_fatigue import DingModelPulseDurationFrequencyWithFatigue from ..models.ding2003 import DingModelFrequency -from ..models.ding2003_with_fatigue import DingModelFrequencyWithFatigue from ..models.hmed2018 import DingModelIntensityFrequency from ..models.hmed2018_with_fatigue import DingModelIntensityFrequencyWithFatigue @@ -158,7 +155,7 @@ def prepare_ocp( force_fourier_coefficient = ( None if force_tracking is None else OcpFes._build_fourier_coefficient(force_tracking) ) - end_node_tracking = end_node_tracking + models = [model] * n_stim n_shooting = [n_shooting] * n_stim diff --git a/cocofest/optimization/fes_ocp_nmpc.py b/cocofest/optimization/fes_ocp_nmpc.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cocofest/optimization/fes_ocp_mhe.py b/cocofest/optimization/fes_ocp_nmpc_cyclic.py similarity index 92% rename from cocofest/optimization/fes_ocp_mhe.py rename to cocofest/optimization/fes_ocp_nmpc_cyclic.py index 23e2d05e..ef009420 100644 --- a/cocofest/optimization/fes_ocp_mhe.py +++ b/cocofest/optimization/fes_ocp_nmpc_cyclic.py @@ -2,11 +2,13 @@ import numpy as np from bioptim import SolutionMerge, ObjectiveList, ObjectiveFcn, OdeSolver, Node, OptimalControlProgram, ControlType, TimeAlignment -from cocofest import DingModelPulseDurationFrequencyWithFatigue, OcpFes, CustomObjective, FesModel -# TODO relative import +from .fes_ocp import OcpFes +from ..models.fes_model import FesModel +from ..custom_objectives import CustomObjective -class OcpFesMhe: + +class OcpFesNmpcCyclic: def __init__(self, model: FesModel = None, n_stim: int = None, @@ -24,8 +26,7 @@ def __init__(self, ode_solver: OdeSolver = OdeSolver.RK4(n_integration_steps=1), n_threads: int = 1, ): - - super(OcpFesMhe, self).__init__() + super(OcpFesNmpcCyclic, self).__init__() self.model = model self.n_stim = n_stim self.n_shooting = n_shooting @@ -42,17 +43,16 @@ def __init__(self, self.ode_solver = ode_solver self.n_threads = n_threads self.ocp = None - self._mhe_sanity_check() - # self.time = [] + self._nmpc_sanity_check() self.states = [] self.parameters = [] self.previous_stim = [] - # self.result_states = {} - # self.result_parameters = {} - # self.result_time = {} self.result = {"time": {}, "states": {}, "parameters": {}} + self.temp_last_node_time = 0 + self.first_node_in_phase = 0 + self.last_node_in_phase = 0 - def prepare_mhe(self): + def prepare_nmpc(self): (pulse_event, pulse_duration, pulse_intensity, objective) = OcpFes._fill_dict( self.pulse_event, self.pulse_duration, self.pulse_intensity, self.objective ) @@ -106,10 +106,6 @@ def prepare_mhe(self): OcpFes._sanity_check_frequency(n_stim=self.n_stim, final_time=self.final_time, frequency=frequency, round_down=round_down) - n_stim, final_time = OcpFes._build_phase_parameter( - n_stim=self.n_stim * self.n_simultaneous_cycles, final_time=self.final_time * self.n_simultaneous_cycles, frequency=frequency, pulse_mode=pulse_mode, round_down=round_down - ) - force_fourier_coefficient = ( None if force_tracking is None else OcpFes._build_fourier_coefficient(force_tracking) ) @@ -183,11 +179,6 @@ def update_states_bounds(self, sol_states): for j in range(index_to_keep, len(self.ocp.nlp)): self.ocp.nlp[j].x_init[key].init[0][0] = sol_states[j][key][0][0] - # def update_parameters(self, sol, parameters_keys): - # sol_parameters = sol.decision_parameters() - # sol_parameters_dict = {key: list(sol_parameters[key][0]) for key in sol_parameters.keys()} - # return - def update_stim(self, sol): if "pulse_apparition_time" in sol.decision_parameters(): stimulation_time = sol.decision_parameters()["pulse_apparition_time"] @@ -203,9 +194,10 @@ def update_stim(self, sol): self.previous_stim = stim_prev for j in range(len(self.ocp.nlp)): - self.ocp.nlp[j].model.set_pass_pulse_apparition_time(self.previous_stim) #TODO: Does not seem to be taken into account by the next model force estimation + self.ocp.nlp[j].model.set_pass_pulse_apparition_time(self.previous_stim) + # TODO: Does not seem to be taken into account by the next model force estimation - def store_results(self, sol_time, sol_states, sol_parameters, index, merge=False): + def store_results(self, sol_time, sol_states, sol_parameters, index): if self.cycle_to_keep == "middle": # Get the middle phase index to keep phase_to_keep = int(math.ceil(self.n_simultaneous_cycles / 2)) @@ -234,7 +226,7 @@ def store_results(self, sol_time, sol_states, sol_parameters, index, merge=False middle_states_values = sol_states[self.first_node_in_phase:self.last_node_in_phase] middle_states_values = [list(middle_states_values[i][state_key][0])[:-1] for i in range(len(middle_states_values))] # Remove the last node duplicate middle_states_values = [j for sub in middle_states_values for j in sub] - self.result["states"][state_key][index] = middle_states_values# Todo might be wrong + self.result["states"][state_key][index] = middle_states_values for key_parameter in list(sol_parameters.keys()): self.result["parameters"][key_parameter][index] = sol_parameters[key_parameter][self.first_node_in_phase:self.last_node_in_phase] @@ -248,7 +240,8 @@ def solve(self): sol_time = sol.decision_time(to_merge=SolutionMerge.NODES, time_alignment=TimeAlignment.STATES) sol_parameters = sol.decision_parameters() self.store_results(sol_time, sol_states, sol_parameters, i) - self.update_stim(sol) + # self.update_stim(sol) + # Todo uncomment when the model is updated to take into account the past stimulation @staticmethod def _set_objective(n_stim, n_shooting, force_fourier_coefficient, end_node_tracking, custom_objective, time_min, time_max, @@ -304,7 +297,7 @@ def _set_objective(n_stim, n_shooting, force_fourier_coefficient, end_node_track return objective_functions - def _mhe_sanity_check(self): + def _nmpc_sanity_check(self): if self.n_total_cycles is None: raise ValueError("n_total_cycles must be set") if self.n_simultaneous_cycles is None: @@ -326,4 +319,5 @@ def _mhe_sanity_check(self): raise NotImplementedError("Only 'middle' cycle_to_keep is implemented") if self.n_simultaneous_cycles != 3: - raise NotImplementedError("Only 3 simultaneous cycles are implemented yet work in progress") # todo add more simultaneous cycles + raise NotImplementedError("Only 3 simultaneous cycles are implemented yet work in progress") + # Todo add more simultaneous cycles From 6df6b3ceaeeb0ec4d86fa1c176b268fb5c3dac48 Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Thu, 29 Aug 2024 13:31:19 -0400 Subject: [PATCH 6/9] black --- .../force_and_fatigue_subplot.py | 2 +- ...pulse_duration_optimization_nmpc_cyclic.py | 67 ++++++------ cocofest/models/ding2003.py | 2 +- cocofest/models/ding2007.py | 8 +- cocofest/models/ding2007_with_fatigue.py | 6 +- cocofest/optimization/fes_ocp_nmpc_cyclic.py | 101 ++++++++++++------ 6 files changed, 119 insertions(+), 67 deletions(-) diff --git a/cocofest/examples/dynamics/reaching_task/visualization/force_and_fatigue_subplot.py b/cocofest/examples/dynamics/reaching_task/visualization/force_and_fatigue_subplot.py index a9574421..ac3bf30c 100644 --- a/cocofest/examples/dynamics/reaching_task/visualization/force_and_fatigue_subplot.py +++ b/cocofest/examples/dynamics/reaching_task/visualization/force_and_fatigue_subplot.py @@ -215,5 +215,5 @@ weight="bold", ) axs.text(0.75, 96.3, "Time (s)", ha="center", va="center", fontsize=18, weight="bold") -axs.legend(title='Cost function', fontsize="medium", loc="upper right", ncol=1) +axs.legend(title="Cost function", fontsize="medium", loc="upper right", ncol=1) plt.show() diff --git a/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py b/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py index f0504f82..d344422d 100644 --- a/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py +++ b/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py @@ -14,28 +14,30 @@ # --- Build target force --- # target_time = np.linspace(0, 1, 100) -target_force = abs(np.sin(target_time*np.pi)) * 200 +target_force = abs(np.sin(target_time * np.pi)) * 200 force_tracking = [target_time, target_force] # --- Build nmpc cyclic --- # n_total_cycles = 8 minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 -nmpc = OcpFesNmpcCyclic(model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), - n_stim=30, - n_shooting=5, - final_time=1, - pulse_duration={ - "min": minimum_pulse_duration, - "max": 0.0006, - "bimapping": False, - }, - objective={"force_tracking": force_tracking}, - n_total_cycles=n_total_cycles, - n_simultaneous_cycles=3, - n_cycle_to_advance=1, - cycle_to_keep="middle", - use_sx=True, - ode_solver=OdeSolver.COLLOCATION()) +nmpc = OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=30, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": minimum_pulse_duration, + "max": 0.0006, + "bimapping": False, + }, + objective={"force_tracking": force_tracking}, + n_total_cycles=n_total_cycles, + n_simultaneous_cycles=3, + n_cycle_to_advance=1, + cycle_to_keep="middle", + use_sx=True, + ode_solver=OdeSolver.COLLOCATION(), +) nmpc.prepare_nmpc() nmpc.solve() @@ -46,22 +48,22 @@ force = [j for sub in nmpc.result["states"]["F"] for j in sub] ax1 = plt.subplot(221) -ax1.plot(time, fatigue, label='A', color='green') -ax1.set_title('Fatigue', weight='bold') -ax1.set_xlabel('Time (s)') -ax1.set_ylabel('Force scaling factor (-)') +ax1.plot(time, fatigue, label="A", color="green") +ax1.set_title("Fatigue", weight="bold") +ax1.set_xlabel("Time (s)") +ax1.set_ylabel("Force scaling factor (-)") plt.legend() ax2 = plt.subplot(222) -ax2.plot(time, force, label='F', color='red', linewidth=4) +ax2.plot(time, force, label="F", color="red", linewidth=4) for i in range(n_total_cycles): if i == 0: - ax2.plot(target_time, target_force, label='Target', color='purple') + ax2.plot(target_time, target_force, label="Target", color="purple") else: - ax2.plot(target_time + i, target_force, color='purple') -ax2.set_title('Force', weight='bold') -ax2.set_xlabel('Time (s)') -ax2.set_ylabel('Force (N)') + ax2.plot(target_time + i, target_force, color="purple") +ax2.set_title("Force", weight="bold") +ax2.set_xlabel("Time (s)") +ax2.set_ylabel("Force (N)") plt.legend() barWidth = 0.25 # set width of bar @@ -76,11 +78,10 @@ ax3 = plt.subplot(212) for i in range(n_total_cycles): - ax3.bar(bar[i], cycles[i], width = barWidth, - edgecolor ='grey', label =f'cycle n°{i+1}') -ax3.set_xticks([np.mean(r) for r in bar], [str(i+1) for i in range(n_total_cycles)]) -ax3.set_xlabel('Cycles') -ax3.set_ylabel('Pulse duration (s)') + ax3.bar(bar[i], cycles[i], width=barWidth, edgecolor="grey", label=f"cycle n°{i+1}") +ax3.set_xticks([np.mean(r) for r in bar], [str(i + 1) for i in range(n_total_cycles)]) +ax3.set_xlabel("Cycles") +ax3.set_ylabel("Pulse duration (s)") plt.legend() -ax3.set_title('Pulse duration', weight='bold') +ax3.set_title("Pulse duration", weight="bold") plt.show() diff --git a/cocofest/models/ding2003.py b/cocofest/models/ding2003.py index f5d72d1f..c4fa23e9 100644 --- a/cocofest/models/ding2003.py +++ b/cocofest/models/ding2003.py @@ -34,7 +34,7 @@ def __init__( model_name: str = "ding2003", muscle_name: str = None, sum_stim_truncation: int = None, - stim_prev: list[float] = None + stim_prev: list[float] = None, ): super().__init__() self._model_name = model_name diff --git a/cocofest/models/ding2007.py b/cocofest/models/ding2007.py index ebe56c21..1886d66a 100644 --- a/cocofest/models/ding2007.py +++ b/cocofest/models/ding2007.py @@ -27,7 +27,13 @@ class DingModelPulseDurationFrequency(DingModelFrequency): Muscle & Nerve: Official Journal of the American Association of Electrodiagnostic Medicine, 36(2), 214-222. """ - def __init__(self, model_name: str = "ding_2007", muscle_name: str = None, sum_stim_truncation: int = None, stim_prev: list[float] = None): + def __init__( + self, + model_name: str = "ding_2007", + muscle_name: str = None, + sum_stim_truncation: int = None, + stim_prev: list[float] = None, + ): super(DingModelPulseDurationFrequency, self).__init__( model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation, stim_prev=stim_prev ) diff --git a/cocofest/models/ding2007_with_fatigue.py b/cocofest/models/ding2007_with_fatigue.py index cc52285b..ec072e49 100644 --- a/cocofest/models/ding2007_with_fatigue.py +++ b/cocofest/models/ding2007_with_fatigue.py @@ -27,7 +27,11 @@ class DingModelPulseDurationFrequencyWithFatigue(DingModelPulseDurationFrequency """ def __init__( - self, model_name: str = "ding_2007_with_fatigue", muscle_name: str = None, sum_stim_truncation: int = None, stim_prev: list[float] = None + self, + model_name: str = "ding_2007_with_fatigue", + muscle_name: str = None, + sum_stim_truncation: int = None, + stim_prev: list[float] = None, ): super(DingModelPulseDurationFrequencyWithFatigue, self).__init__( model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation, stim_prev=stim_prev diff --git a/cocofest/optimization/fes_ocp_nmpc_cyclic.py b/cocofest/optimization/fes_ocp_nmpc_cyclic.py index ef009420..60e23e4d 100644 --- a/cocofest/optimization/fes_ocp_nmpc_cyclic.py +++ b/cocofest/optimization/fes_ocp_nmpc_cyclic.py @@ -1,7 +1,16 @@ import math import numpy as np -from bioptim import SolutionMerge, ObjectiveList, ObjectiveFcn, OdeSolver, Node, OptimalControlProgram, ControlType, TimeAlignment +from bioptim import ( + SolutionMerge, + ObjectiveList, + ObjectiveFcn, + OdeSolver, + Node, + OptimalControlProgram, + ControlType, + TimeAlignment, +) from .fes_ocp import OcpFes from ..models.fes_model import FesModel @@ -9,22 +18,23 @@ class OcpFesNmpcCyclic: - def __init__(self, - model: FesModel = None, - n_stim: int = None, - n_shooting: int = None, - final_time: int | float = None, - pulse_event: dict = None, - pulse_duration: dict = None, - pulse_intensity: dict = None, - n_total_cycles: int = None, - n_simultaneous_cycles: int = None, - n_cycle_to_advance: int = None, - cycle_to_keep: str = None, - objective: dict = None, - use_sx: bool = True, - ode_solver: OdeSolver = OdeSolver.RK4(n_integration_steps=1), - n_threads: int = 1, + def __init__( + self, + model: FesModel = None, + n_stim: int = None, + n_shooting: int = None, + final_time: int | float = None, + pulse_event: dict = None, + pulse_duration: dict = None, + pulse_intensity: dict = None, + n_total_cycles: int = None, + n_simultaneous_cycles: int = None, + n_cycle_to_advance: int = None, + cycle_to_keep: str = None, + objective: dict = None, + use_sx: bool = True, + ode_solver: OdeSolver = OdeSolver.RK4(n_integration_steps=1), + n_threads: int = 1, ): super(OcpFesNmpcCyclic, self).__init__() self.model = model @@ -104,7 +114,9 @@ def prepare_nmpc(self): n_threads=self.n_threads, ) - OcpFes._sanity_check_frequency(n_stim=self.n_stim, final_time=self.final_time, frequency=frequency, round_down=round_down) + OcpFes._sanity_check_frequency( + n_stim=self.n_stim, final_time=self.final_time, frequency=frequency, round_down=round_down + ) force_fourier_coefficient = ( None if force_tracking is None else OcpFes._build_fourier_coefficient(force_tracking) @@ -146,7 +158,14 @@ def prepare_nmpc(self): x_bounds, x_init = OcpFes._set_bounds(self.model, self.n_stim * self.n_simultaneous_cycles) one_cycle_shooting = [self.n_shooting] * self.n_stim objective_functions = self._set_objective( - self.n_stim, one_cycle_shooting, force_fourier_coefficient, end_node_tracking, custom_objective, time_min, time_max, self.n_simultaneous_cycles + self.n_stim, + one_cycle_shooting, + force_fourier_coefficient, + end_node_tracking, + custom_objective, + time_min, + time_max, + self.n_simultaneous_cycles, ) all_cycle_n_shooting = [self.n_shooting] * self.n_stim * self.n_simultaneous_cycles self.ocp = OptimalControlProgram( @@ -183,7 +202,7 @@ def update_stim(self, sol): if "pulse_apparition_time" in sol.decision_parameters(): stimulation_time = sol.decision_parameters()["pulse_apparition_time"] else: - stimulation_time = [0] + list(np.cumsum(sol.ocp.phase_time[:self.n_stim-1])) + stimulation_time = [0] + list(np.cumsum(sol.ocp.phase_time[: self.n_stim - 1])) stim_prev = list(np.array(stimulation_time) - self.final_time) if self.previous_stim: @@ -206,14 +225,22 @@ def store_results(self, sol_time, sol_states, sol_parameters, index): # Initialize the dict if it's the first iteration if index == 0: - self.result["time"] = [None]*self.n_total_cycles - [self.result["states"].update({state_key: [None]*self.n_total_cycles}) for state_key in list(sol_states[0].keys())] - [self.result["parameters"].update({key_parameter: [None]*self.n_total_cycles}) for key_parameter in list(sol_parameters.keys())] + self.result["time"] = [None] * self.n_total_cycles + [ + self.result["states"].update({state_key: [None] * self.n_total_cycles}) + for state_key in list(sol_states[0].keys()) + ] + [ + self.result["parameters"].update({key_parameter: [None] * self.n_total_cycles}) + for key_parameter in list(sol_parameters.keys()) + ] # Store the results phase_size = np.array(sol_time).shape[0] node_size = np.array(sol_time).shape[1] - sol_time = list(np.array(sol_time).reshape(phase_size*node_size))[self.first_node_in_phase*node_size:self.last_node_in_phase*node_size] + sol_time = list(np.array(sol_time).reshape(phase_size * node_size))[ + self.first_node_in_phase * node_size : self.last_node_in_phase * node_size + ] sol_time = list(dict.fromkeys(sol_time)) # Remove duplicate time if index == 0: updated_sol_time = [t - sol_time[0] for t in sol_time] @@ -223,13 +250,17 @@ def store_results(self, sol_time, sol_states, sol_parameters, index): self.result["time"][index] = updated_sol_time[:-1] for state_key in list(sol_states[0].keys()): - middle_states_values = sol_states[self.first_node_in_phase:self.last_node_in_phase] - middle_states_values = [list(middle_states_values[i][state_key][0])[:-1] for i in range(len(middle_states_values))] # Remove the last node duplicate + middle_states_values = sol_states[self.first_node_in_phase : self.last_node_in_phase] + middle_states_values = [ + list(middle_states_values[i][state_key][0])[:-1] for i in range(len(middle_states_values)) + ] # Remove the last node duplicate middle_states_values = [j for sub in middle_states_values for j in sub] self.result["states"][state_key][index] = middle_states_values for key_parameter in list(sol_parameters.keys()): - self.result["parameters"][key_parameter][index] = sol_parameters[key_parameter][self.first_node_in_phase:self.last_node_in_phase] + self.result["parameters"][key_parameter][index] = sol_parameters[key_parameter][ + self.first_node_in_phase : self.last_node_in_phase + ] return def solve(self): @@ -244,13 +275,23 @@ def solve(self): # Todo uncomment when the model is updated to take into account the past stimulation @staticmethod - def _set_objective(n_stim, n_shooting, force_fourier_coefficient, end_node_tracking, custom_objective, time_min, time_max, - n_simultaneous_cycles): + def _set_objective( + n_stim, + n_shooting, + force_fourier_coefficient, + end_node_tracking, + custom_objective, + time_min, + time_max, + n_simultaneous_cycles, + ): # Creates the objective for our problem objective_functions = ObjectiveList() if custom_objective: if len(custom_objective) != n_stim: - raise ValueError("The number of custom objective must be equal to the stimulation number of a single cycle") + raise ValueError( + "The number of custom objective must be equal to the stimulation number of a single cycle" + ) for i in range(len(custom_objective)): for j in range(n_simultaneous_cycles): objective_functions.add(custom_objective[i + j * n_stim][0]) From a67d78c7b885a7091ed3a2a7251dc8c8fe9f3c81 Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Thu, 29 Aug 2024 15:13:30 -0400 Subject: [PATCH 7/9] Adding tests for nmpc cyclic --- ...pulse_duration_optimization_nmpc_cyclic.py | 4 +- cocofest/models/ding2003.py | 5 - cocofest/models/ding2007.py | 3 +- cocofest/models/ding2007_with_fatigue.py | 5 +- cocofest/optimization/fes_ocp_nmpc_cyclic.py | 22 +- tests/shard1/test_nmpc_cyclic.py | 256 ++++++++++++++++++ 6 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 tests/shard1/test_nmpc_cyclic.py diff --git a/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py b/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py index d344422d..1b122aff 100644 --- a/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py +++ b/cocofest/examples/getting_started/pulse_duration_optimization_nmpc_cyclic.py @@ -20,8 +20,10 @@ # --- Build nmpc cyclic --- # n_total_cycles = 8 minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 +fes_model = DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10) +fes_model.alpha_a = -4.0 * 10e-1 # Increasing the fatigue rate to make the fatigue more visible nmpc = OcpFesNmpcCyclic( - model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + model=fes_model, n_stim=30, n_shooting=5, final_time=1, diff --git a/cocofest/models/ding2003.py b/cocofest/models/ding2003.py index c4fa23e9..fd694434 100644 --- a/cocofest/models/ding2003.py +++ b/cocofest/models/ding2003.py @@ -34,7 +34,6 @@ def __init__( model_name: str = "ding2003", muscle_name: str = None, sum_stim_truncation: int = None, - stim_prev: list[float] = None, ): super().__init__() self._model_name = model_name @@ -52,8 +51,6 @@ def __init__( self.tau2 = 0.060 # Close value from Ding's experimentation [2] (s) self.km_rest = 0.103 # Value from Ding's experimentation [1] (unitless) - self.stim_prev = stim_prev - def set_a_rest(self, model, a_rest: MX | float): # models is required for bioptim compatibility self.a_rest = a_rest @@ -148,8 +145,6 @@ def system_dynamics( ------- The value of the derivative of each state dx/dt at the current time t """ - if self.stim_prev: - t_stim_prev = self.stim_prev + t_stim_prev r0 = self.km_rest + self.r0_km_relationship # Simplification cn_dot = self.cn_dot_fun(cn, r0, t, t_stim_prev=t_stim_prev) # Equation n°1 f_dot = self.f_dot_fun( diff --git a/cocofest/models/ding2007.py b/cocofest/models/ding2007.py index 1886d66a..4e06a2ee 100644 --- a/cocofest/models/ding2007.py +++ b/cocofest/models/ding2007.py @@ -32,10 +32,9 @@ def __init__( model_name: str = "ding_2007", muscle_name: str = None, sum_stim_truncation: int = None, - stim_prev: list[float] = None, ): super(DingModelPulseDurationFrequency, self).__init__( - model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation, stim_prev=stim_prev + model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation ) self._with_fatigue = False self.impulse_time = None diff --git a/cocofest/models/ding2007_with_fatigue.py b/cocofest/models/ding2007_with_fatigue.py index ec072e49..4a288d5c 100644 --- a/cocofest/models/ding2007_with_fatigue.py +++ b/cocofest/models/ding2007_with_fatigue.py @@ -31,10 +31,9 @@ def __init__( model_name: str = "ding_2007_with_fatigue", muscle_name: str = None, sum_stim_truncation: int = None, - stim_prev: list[float] = None, ): super(DingModelPulseDurationFrequencyWithFatigue, self).__init__( - model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation, stim_prev=stim_prev + model_name=model_name, muscle_name=muscle_name, sum_stim_truncation=sum_stim_truncation ) self._with_fatigue = True @@ -141,8 +140,6 @@ def system_dynamics( ------- The value of the derivative of each state dx/dt at the current time t """ - if self.stim_prev: - t_stim_prev = self.stim_prev + t_stim_prev r0 = km + self.r0_km_relationship # Simplification cn_dot = self.cn_dot_fun(cn, r0, t, t_stim_prev=t_stim_prev) # Equation n°1 from Ding's 2003 article a_calculated = self.a_calculation(a_scale=a, impulse_time=impulse_time) # Equation n°3 from Ding's 2007 article diff --git a/cocofest/optimization/fes_ocp_nmpc_cyclic.py b/cocofest/optimization/fes_ocp_nmpc_cyclic.py index 60e23e4d..f3502ddd 100644 --- a/cocofest/optimization/fes_ocp_nmpc_cyclic.py +++ b/cocofest/optimization/fes_ocp_nmpc_cyclic.py @@ -339,21 +339,21 @@ def _set_objective( return objective_functions def _nmpc_sanity_check(self): - if self.n_total_cycles is None: - raise ValueError("n_total_cycles must be set") - if self.n_simultaneous_cycles is None: - raise ValueError("n_simultaneous_cycles must be set") - if self.n_cycle_to_advance is None: - raise ValueError("n_cycle_to_advance must be set") - if self.cycle_to_keep is None: - raise ValueError("cycle_to_keep must be set") - - if self.n_total_cycles % self.n_cycle_to_advance != 0: - raise ValueError("The number of n_total_cycles must be a multiple of the number n_cycle_to_advance") + if not isinstance(self.n_total_cycles, int): + raise TypeError("n_total_cycles must be an integer") + if not isinstance(self.n_simultaneous_cycles, int): + raise TypeError("n_simultaneous_cycles must be an integer") + if not isinstance(self.n_cycle_to_advance, int): + raise TypeError("n_cycle_to_advance must be an integer") + if not isinstance(self.cycle_to_keep, str): + raise TypeError("cycle_to_keep must be a string") if self.n_cycle_to_advance > self.n_simultaneous_cycles: raise ValueError("The number of n_simultaneous_cycles must be higher than the number of n_cycle_to_advance") + if self.n_total_cycles % self.n_cycle_to_advance != 0: + raise ValueError("The number of n_total_cycles must be a multiple of the number n_cycle_to_advance") + if self.cycle_to_keep not in ["first", "middle", "last"]: raise ValueError("cycle_to_keep must be either 'first', 'middle' or 'last'") if self.cycle_to_keep != "middle": diff --git a/tests/shard1/test_nmpc_cyclic.py b/tests/shard1/test_nmpc_cyclic.py new file mode 100644 index 00000000..a63b3ff4 --- /dev/null +++ b/tests/shard1/test_nmpc_cyclic.py @@ -0,0 +1,256 @@ +import pytest +import re +import numpy as np + +from bioptim import OdeSolver +from cocofest import OcpFesNmpcCyclic, DingModelPulseDurationFrequencyWithFatigue + + +def test_nmpc_cyclic(): + # --- Build target force --- # + target_time = np.linspace(0, 1, 100) + target_force = abs(np.sin(target_time * np.pi)) * 50 + force_tracking = [target_time, target_force] + + # --- Build nmpc cyclic --- # + n_total_cycles = 6 + n_stim = 10 + n_shooting = 5 + + minimum_pulse_duration = DingModelPulseDurationFrequencyWithFatigue().pd0 + fes_model = DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10) + fes_model.alpha_a = -4.0 * 10e-1 # Increasing the fatigue rate to make the fatigue more visible + + nmpc = OcpFesNmpcCyclic( + model=fes_model, + n_stim=n_stim, + n_shooting=n_shooting, + final_time=1, + pulse_duration={ + "min": minimum_pulse_duration, + "max": 0.0006, + "bimapping": False, + }, + objective={"force_tracking": force_tracking}, + n_total_cycles=n_total_cycles, + n_simultaneous_cycles=3, + n_cycle_to_advance=1, + cycle_to_keep="middle", + use_sx=True, + ode_solver=OdeSolver.COLLOCATION(), + ) + + nmpc.prepare_nmpc() + nmpc.solve() + + # --- Show results --- # + time = [j for sub in nmpc.result["time"] for j in sub] + fatigue = [j for sub in nmpc.result["states"]["A"] for j in sub] + force = [j for sub in nmpc.result["states"]["F"] for j in sub] + + np.testing.assert_almost_equal(len(time), n_total_cycles*n_stim*n_shooting*(nmpc.ode_solver.polynomial_degree+1)) + np.testing.assert_almost_equal(len(fatigue), len(time)) + np.testing.assert_almost_equal(len(force), len(time)) + + np.testing.assert_almost_equal(time[0], 0.0) + np.testing.assert_almost_equal(fatigue[0], 4796.3120362970285) + np.testing.assert_almost_equal(force[0], 3.0948778396159535) + + np.testing.assert_almost_equal(time[750], 3.0000000000000013) + np.testing.assert_almost_equal(fatigue[750], 4427.259641834449) + np.testing.assert_almost_equal(force[750], 4.508999252965375) + + np.testing.assert_almost_equal(time[-1], 5.998611363115943) + np.testing.assert_almost_equal(fatigue[-1], 4063.8504572735123) + np.testing.assert_almost_equal(force[-1], 5.661514731665669) + + +def test_all_nmpc_errors(): + with pytest.raises( + TypeError, + match=re.escape( + "n_total_cycles must be an integer" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=None, + ) + + with pytest.raises( + TypeError, + match=re.escape( + "n_simultaneous_cycles must be an integer" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=5, + ) + + with pytest.raises( + TypeError, + match=re.escape( + "n_cycle_to_advance must be an integer" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=5, + n_simultaneous_cycles=3, + ) + + with pytest.raises( + TypeError, + match=re.escape( + "cycle_to_keep must be a string" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=5, + n_simultaneous_cycles=3, + n_cycle_to_advance=1, + ) + + with pytest.raises( + ValueError, + match=re.escape( + "The number of n_simultaneous_cycles must be higher than the number of n_cycle_to_advance" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=5, + n_simultaneous_cycles=3, + n_cycle_to_advance=6, + cycle_to_keep="middle", + ) + + with pytest.raises( + ValueError, + match=re.escape( + "The number of n_total_cycles must be a multiple of the number n_cycle_to_advance" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=5, + n_simultaneous_cycles=3, + n_cycle_to_advance=2, + cycle_to_keep="middle", + ) + + with pytest.raises( + ValueError, + match=re.escape( + "cycle_to_keep must be either 'first', 'middle' or 'last'" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=5, + n_simultaneous_cycles=3, + n_cycle_to_advance=1, + cycle_to_keep="between", + ) + + with pytest.raises( + NotImplementedError, + match=re.escape( + "Only 'middle' cycle_to_keep is implemented" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=5, + n_simultaneous_cycles=3, + n_cycle_to_advance=1, + cycle_to_keep="first", + ) + + with pytest.raises( + NotImplementedError, + match=re.escape( + "Only 3 simultaneous cycles are implemented yet work in progress" + ), + ): + OcpFesNmpcCyclic( + model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), + n_stim=10, + n_shooting=5, + final_time=1, + pulse_duration={ + "min": 0.0003, + "max": 0.0006, + "bimapping": False, + }, + n_total_cycles=5, + n_simultaneous_cycles=6, + n_cycle_to_advance=1, + cycle_to_keep="middle", + ) From 831e5748d52b14716cb34bb29444591e0d47a9b1 Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Thu, 29 Aug 2024 15:21:57 -0400 Subject: [PATCH 8/9] black --- tests/shard1/test_nmpc_cyclic.py | 40 ++++++++++---------------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/tests/shard1/test_nmpc_cyclic.py b/tests/shard1/test_nmpc_cyclic.py index a63b3ff4..0e8dcd9b 100644 --- a/tests/shard1/test_nmpc_cyclic.py +++ b/tests/shard1/test_nmpc_cyclic.py @@ -48,7 +48,9 @@ def test_nmpc_cyclic(): fatigue = [j for sub in nmpc.result["states"]["A"] for j in sub] force = [j for sub in nmpc.result["states"]["F"] for j in sub] - np.testing.assert_almost_equal(len(time), n_total_cycles*n_stim*n_shooting*(nmpc.ode_solver.polynomial_degree+1)) + np.testing.assert_almost_equal( + len(time), n_total_cycles * n_stim * n_shooting * (nmpc.ode_solver.polynomial_degree + 1) + ) np.testing.assert_almost_equal(len(fatigue), len(time)) np.testing.assert_almost_equal(len(force), len(time)) @@ -68,9 +70,7 @@ def test_nmpc_cyclic(): def test_all_nmpc_errors(): with pytest.raises( TypeError, - match=re.escape( - "n_total_cycles must be an integer" - ), + match=re.escape("n_total_cycles must be an integer"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -87,9 +87,7 @@ def test_all_nmpc_errors(): with pytest.raises( TypeError, - match=re.escape( - "n_simultaneous_cycles must be an integer" - ), + match=re.escape("n_simultaneous_cycles must be an integer"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -106,9 +104,7 @@ def test_all_nmpc_errors(): with pytest.raises( TypeError, - match=re.escape( - "n_cycle_to_advance must be an integer" - ), + match=re.escape("n_cycle_to_advance must be an integer"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -126,9 +122,7 @@ def test_all_nmpc_errors(): with pytest.raises( TypeError, - match=re.escape( - "cycle_to_keep must be a string" - ), + match=re.escape("cycle_to_keep must be a string"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -147,9 +141,7 @@ def test_all_nmpc_errors(): with pytest.raises( ValueError, - match=re.escape( - "The number of n_simultaneous_cycles must be higher than the number of n_cycle_to_advance" - ), + match=re.escape("The number of n_simultaneous_cycles must be higher than the number of n_cycle_to_advance"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -169,9 +161,7 @@ def test_all_nmpc_errors(): with pytest.raises( ValueError, - match=re.escape( - "The number of n_total_cycles must be a multiple of the number n_cycle_to_advance" - ), + match=re.escape("The number of n_total_cycles must be a multiple of the number n_cycle_to_advance"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -191,9 +181,7 @@ def test_all_nmpc_errors(): with pytest.raises( ValueError, - match=re.escape( - "cycle_to_keep must be either 'first', 'middle' or 'last'" - ), + match=re.escape("cycle_to_keep must be either 'first', 'middle' or 'last'"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -213,9 +201,7 @@ def test_all_nmpc_errors(): with pytest.raises( NotImplementedError, - match=re.escape( - "Only 'middle' cycle_to_keep is implemented" - ), + match=re.escape("Only 'middle' cycle_to_keep is implemented"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), @@ -235,9 +221,7 @@ def test_all_nmpc_errors(): with pytest.raises( NotImplementedError, - match=re.escape( - "Only 3 simultaneous cycles are implemented yet work in progress" - ), + match=re.escape("Only 3 simultaneous cycles are implemented yet work in progress"), ): OcpFesNmpcCyclic( model=DingModelPulseDurationFrequencyWithFatigue(sum_stim_truncation=10), From 07094e639588df338c54032db4c32b4c05ea6ea0 Mon Sep 17 00:00:00 2001 From: Kevin CO Date: Thu, 29 Aug 2024 15:34:47 -0400 Subject: [PATCH 9/9] decimal 4 --- tests/shard1/test_nmpc_cyclic.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/shard1/test_nmpc_cyclic.py b/tests/shard1/test_nmpc_cyclic.py index 0e8dcd9b..495aa8e1 100644 --- a/tests/shard1/test_nmpc_cyclic.py +++ b/tests/shard1/test_nmpc_cyclic.py @@ -54,17 +54,17 @@ def test_nmpc_cyclic(): np.testing.assert_almost_equal(len(fatigue), len(time)) np.testing.assert_almost_equal(len(force), len(time)) - np.testing.assert_almost_equal(time[0], 0.0) - np.testing.assert_almost_equal(fatigue[0], 4796.3120362970285) - np.testing.assert_almost_equal(force[0], 3.0948778396159535) + np.testing.assert_almost_equal(time[0], 0.0, decimal=4) + np.testing.assert_almost_equal(fatigue[0], 4796.3120, decimal=4) + np.testing.assert_almost_equal(force[0], 3.0948, decimal=4) - np.testing.assert_almost_equal(time[750], 3.0000000000000013) - np.testing.assert_almost_equal(fatigue[750], 4427.259641834449) - np.testing.assert_almost_equal(force[750], 4.508999252965375) + np.testing.assert_almost_equal(time[750], 3.0, decimal=4) + np.testing.assert_almost_equal(fatigue[750], 4427.2596, decimal=4) + np.testing.assert_almost_equal(force[750], 4.5089, decimal=4) - np.testing.assert_almost_equal(time[-1], 5.998611363115943) - np.testing.assert_almost_equal(fatigue[-1], 4063.8504572735123) - np.testing.assert_almost_equal(force[-1], 5.661514731665669) + np.testing.assert_almost_equal(time[-1], 5.9986, decimal=4) + np.testing.assert_almost_equal(fatigue[-1], 4063.8504, decimal=4) + np.testing.assert_almost_equal(force[-1], 5.6615, decimal=4) def test_all_nmpc_errors():