Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Software architecture design with modern Python #2

Closed
diegoferigo opened this issue Mar 2, 2023 · 5 comments
Closed

Software architecture design with modern Python #2

diegoferigo opened this issue Mar 2, 2023 · 5 comments

Comments

@diegoferigo
Copy link
Member

diegoferigo commented Mar 2, 2023

This issue provides some architectural guidelines on how the code could exploit at best modern Python. I'm not going through the desiderata discussed f2f, but I'll provide hints on how I would structure the code given my Python experience. Not necessarily things have to be implemented in this way, however the following could be a good starting point.

Generally speaking, I'd suggest:

  • Use extensively dataclasses. We need containers of optimization Variables and optimization Parameters, and dataclasses are the most expressive default containers (very similar to plain C/C++ structs) whose fields can also be associated to metadata for further introspection.
  • Start supporting Python ≥ 3.10 since this version improves significantly the inheritance properties of dataclasses.
  • Expose only NumPy objects to the downstream user and perform conversion to CasADi types internally. Then, for advanced usage, the CasADi resources could be public.

This library aims at simplifying the development of Trajectory Optimization (TO) problems for robotics. Under-the-hood, it will rely on CasADi, that already provides generic resources for numerical optimization. One of this project's goals is providing variables and constraints resources that are shared among different optimization problems (therefore reducing code duplication) and providing resources to read model descriptions to load kinematic and dynamic properties.

We assume downstream users to start with the following information:

  1. Knowledge of the ODE associated to the Optimal Control problem.
  2. Knowledge of all the optimization variables and parameters.
  3. Knowledge of all the equality/inequality constraints applied to the variables.
  4. Knowledge of the computation of the cost function.

It would be ideal to structure the code with the following (proposed) final usage in mind:

@dataclasses.dataclass
class MyParameters(ParametersABC):

    parameter1: cs.MX | npt.NDArray
    parameter2: cs.MX | npt.NDArray
    # Note: npt.NDArray typing is for user consumption, cs.MX for internal consumption


@dataclasses.dataclass
class MyVariables(
    # Inherit from existing variable classes (e.g. joint positions, base position, etc)
    PredefinedVariables1,
    PredefinedVariables2,
):

    # Allow to specify additional variables not part of any predefined class
    variable1: cs.MX | npt.NDArray
    variable2: cs.MX | npt.NDArray
    variable3: cs.MX | npt.NDArray

    # Dataclasses allow specifying initialization parameters only used for construction
    init_param1: dataclasses.InitVar[Any]
    init_param2: dataclasses.InitVar[Any]

    def __post_init(init_param1, init_param2) -> None:

        # Should build the zero/initial variables already with the right size.
        # All attributes are numpy arrays (also scalars) so that we can treat them homogeneous.  
        # [...]


@dataclasses.dataclass
class MyODE(
    ODEABC,
    # Note: the integrator could be also specified with composition instead of inheritance:
    ForwardEuler,
):

    variables: MyVariables
    parameters: MyParameters

    def variables_ode_state(self) -> cs.MX:
       # If n is the dimension of the state vector x(t), and H+1 the number of optimized steps,
       # return the (n×H+1) matrix of all the optimized state X

    def variables_ode_input(self) -> cs.MX:
        # If m is the dimension of the input vector u(t), and H+1 the number of optimized steps,
        # return the (m×H+1) matrix of all the optimized state U

    def variables_ode_state_derivative(self) -> cs.MX:
        # Returns the variables corresponding to ẋ of the entire horizon (the derivative of X)

    def time(self, k) -> float:
        # Return the time of the k-th optimized step, considering k∈[0, H].
        # This is used by the integrator to compute its Δt.
        # Note that the time could either be hardcoded number or an optimization variable.

    def state_derivative_fn(self) -> cs.Function:
        # Returns a CasADi function to compute the right-hand side of ẋ = f(x, u), that is used internally by the 
        # active integrator, possibly also over intermediate steps that are not optimized (between k and k+1).
        # To simplify the interoperability with the integrators, this function should work with x∈ℝⁿ and u∈ℝᵐ.

        # [...]
        # Create a function to evaluate the ODE
        f = cs.Function("f", dict(x=x, u=u, f=f_xu), ["x", "u"], ["f"])
        return f.expand()

@dataclasses.dataclass
def MyBuilder(
    # Parent class providing resources to build a cs.Opti object
    OptiProblemBuilder,
    # Classes providing methods specifying problem-specific constraint that operate on the composed variable class
    ConstraintClassOne,
    ConstraintClassTwo,
    ConstraintClassThree,
):

    custom_user_parameter: Any

    def build(self) -> OptimizationProblem:
        # Get the cs.Opti object and implement the desired formulation of the optimization problem.
        # This method defines both the constraints (including the ODE) and the cost function to minimize.

        # Get the Opti object and convert the data stored in the user-defined MyVariable/MyParameters objects
        # from NumPy to cs.MX symbols
        opti = self.opti(...)
        vars = self.vars(...)
        params = self.params(...)

        # Add the constraints, and think how to simplify the switch between hard constraint and soft constraint.
        # The expression of the constraint could be provided by the ConstraintClass{One|Two|Three} classes.
        self.add_equality_constraint(...)
        self.add_inequality_constraint(...)
        # [...]

        # Populate the cost function with different tasks.
        # Each task has a name for possible introspection capability.
        self.add_task(name="task1", ...)
        # [...]

        # Build the ODE (there could be more than one ODE).
        # Each ODE operates on variables with the same horizon length and uses a shared integrator.
        ode1 = MyODE(variables=vars, parameters=params)

        # Integrate the variables of the ODE
        for k in ode1:
            ode1.integrate(...)

       # Store the resources in a problem object.
       # This might also be used to convert the whole problem into a CasADi function
        # for model exchange (also cross-language).
        problem = OptimizationProblem(opti, vars, params)

        return problem


if __name__ == "__main__":

    # Number of optimization steps is H+1
    H = 99

    # Initialize the parameters
    parameters = MyParameters(...)

    # Create the initial variables
    initial_variables = MyVariables(
        # Initialization parameters
        init_param1="bar",
        init_param2=42,
        # Optional non-default initial variables (e.g. from a previous run or custom initialization logic)
        variable2=np.ones(shape=(3, H + 1)),
    )

    # Create the builder
    builder = MyBuilder(
        variables=initial_variables,
        parameters=parameters,
        custom_user_parameter=H,
    )

    # Build the optimization problem
    problem = builder.build()

    # Solve the problem.
    # Optimized variables are converted to NumPy before returning.
    # The idea is to keep using the same MyVariables class also here so that
    # we can warm-start a following run with it, store to file, visualize/plot the trajectories, etc. 
    optimized_variables = problem.solve(...)

At first look, the snippet above could represent an initial configuration that from the one hand allow providing shared variables/constraint/integrators, and from the other one gives a wide room of extensibility to the downstream users.

Right now, I'd suggest to focus on purely offline problems that are simpler since they are one-shot. Then, maybe we can move to receding horizon problems like MPC.

cc @S-Dafarra, @traversaro, @GiulioRomualdi, @rob-mau, @LoreMoretti

@GiulioRomualdi
Copy link
Member

Start supporting Python ≥ 3.10 since this version improves significantly the inheritance properties of dataclasses.

As far I know the default version of python installed via apt on ubuntu 20.04 is 3.8

@diegoferigo
Copy link
Member Author

Start supporting Python ≥ 3.10 since this version improves significantly the inheritance properties of dataclasses.

As far I know the default version of python installed via apt on ubuntu 20.04 is 3.8

Shouldn't we all be based on 22.04 LTS at this point? 20.04 is three years old. Sadly, dataclasses shine at best only with Python ≥ 3.10, and a similar design without dataclasses would affect significantly the public user interface.

I also checked the EOL of Python 3.8, and it's not too distant (October 2024).

@GiulioRomualdi
Copy link
Member

GiulioRomualdi commented Mar 2, 2023

Shouldn't we all be based on 22.04 LTS at this point?

I don't think so. For instance, the setup of iCubGenova09 is still ubuntu 20.04. And given this issue, we still support ubuntu 18.04 robotology/robotology-superbuild#481

@diegoferigo
Copy link
Member Author

Shouldn't we all be based on 22.04 LTS at this point?

I don't think so. For instance, the setup of iCubGenova09 is still ubuntu 20.04. And given this issue, we still support ubuntu 18.04 robotology/robotology-superbuild#481

Oh ok you mean also on the robot setups, I was considering our personal machines. Let's keep discussing this topic elsewhere and keep the issue for the architecture. With Python < 3.10, none of what written here holds.

@S-Dafarra
Copy link
Member

Given the first round of PRs, I would close this. Thanks everybody for the suggestions!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants