Design of callback for `scipy.integrate.solve_ivp`

I have opened PR #21741 to add callbacks in scipy.integrate.solve_ivp for use cases such as progress tracking and dynamic max step size control. This callback is not currently intended to directly control the state or RHS of the integration as currently solve_ivp is not equipped to handle this.

The current discussion has aligned on passing the callback an instance of class IntermediateOdeResult(current naming) inheriting from _RichResult It would contain attributes that have information about the integration, e.g. time, state, nsteps, etc.

In review, there was a desire to open a discussion on when the callback should be called and what information should be supplied to the callback in those cases. I am trying to distill a few different pieces of the conversation down, and the list is meant as a starting point for discussion.

First, some definitions used in the options related to timing/type of callback:

  • after-step callback: callback that is called after every (successful) integration step of the algorithm.
  • upon-event callback: callback that is called when any event occurs
  • upon-event-type callback: callback that is called only when a specific event occurs

More definitions:

  • t_current: current integration time after step
  • y_current current integration state after step
  • t: history of time for all steps including last step
  • y: history of state for all steps including last step
  • t_events_current: times of events that occured at last step
  • y_events_current: states of system at times in above line
  • t_events history of times of events for all steps including last step
  • y_events history of states of system at times in above line
  • event_id index of a specific event that occured over last step
  • event_indices indices of all events that occured over last step
  • context what triggered the callback

Options:

  1. Only use a single after-step callback with history: include t, y, t_events, y_events, event_indices. The current values could also be passed for convenience.

  2. Only use a single after-step callback without history: include t_current, y_current, t_events_current, y_events_current, event_indices.

  3. Use a single callback, and call it after-step and upon-event without history.

    • Using context to distinguish what triggered callback.
  4. Use a single callback, and call it after-step and upon-event-type without history.

    • Similar to 3, but it would be called per step type. context could be the same as event_id in this case.
  5. Use different callbacks for after-step and upon-event without history.

    • No need for context.
  6. Use different callbacks for after-step and upon-event-type without history.

    • Need event_id and could be same as context.
    • 1 callback per event type could also be considered. event_id/context is possibly unneeded, but could be useful.

I chose to include with history for option 1, but it could potentially be considered for any option 3-6, particularly for the after-step context.

For options 3-6 we could choose to limit the information available to the different types of callback calls, e.g. only after step information in after-step context and only the state at the time of event in the upon-event context. Or we could chose to provide similar types of information to all contexts.

My preference/proposal is to implement option 2. And if desired, option 1 and /or option 5 or 6 could be implemented later. Option 2 is the minimal viable product for all other options except Options 3-4. Implementing Options 3-4 after Option 2 would result in the changing the number of times the callback is called.

One problem of passing the history of solution, Option 1, in the intermediate result to the callback is that it is a different shape and type than the final result in solve_ivp. The intermediate result y is a list that contains the states of the solution at each step, i.e. the shape if converted to an ndarray is (nsteps, n) where n is the number of state variables being integrated (n=len(y0)). The return from solve_ivp is an ndarray that has shape (n, nsteps). This will make it confusing to users. It could be corrected by transposing the result after every step, however this might have performance/memory issues. Additionally, if users need the history, they can construct it themselves with the callback.

For Options 3-6, they are only for convenience as all functionality can be achieved in the after-step callback context if the event information is passed. In solve_ivp the events are found after the step is completed, and the state of the solver will be the same when the callback is called in both after-step and upon-event(-type) contexts. Using Options 3-6 adds extra overhead to maintain and also describe in the documentation.

Using a single callback that is called after-step and again upon-event(-type), Options 3 and 4, adds the most complexity to the usage IMO. A single call back that is called multiple times with contexts adds more complexity to the user code if they want to do something different for after-step vs. upon-event compared to Options 5-6.

I like the idea of Options 5 and/or 6 as it reduces the complexity of the users code if they want to different things during after-step vs. upon-event(-type). However, they also increase the # of parameter in solve_ivp and thus the complexity of documentation. Since these can be done after Option 2 (or 1), it is best to wait until feedback is gained from the community before implementing it.