-
Notifications
You must be signed in to change notification settings - Fork 1
Code: sandbox
Page with all code information, that will be split later into 3 pages
Under construction
- Before every method name, the
_prefix is used whenever the method is not intended to be used outside of the body of the class. -
U, P(capital) refer to the full fields$U(x,t), P(x,t)$ , whileu, p(small) refer to the perturbation fields$u'(x,t), p'(x,t)$ (see Numerical details). For boundary conditions,BC, bcfollow the same convention, and more generally, all names refering to flow fields is following the convention.
The simulation revolves around the abstract class FlowSolver that implements core features such as loading the mesh, defining the function spaces & trial/test functions, variational formulations, numerical schemes and solvers, handling the time-stepping and exporting fields and timeseries. The class is abstract as it does not implement a simulation case per se, but only provides utility for doing so. It features two abstract methods, that are redefined for each use-case:
-
_make_boundariesprovides a definition and naming of the boundaries of the mesh in a pandas DataFrame.
@abstractmethod
def _make_boundaries(self) -> pd.DataFrame:
passThe expected DataFrame has the following simple structure:
boundaries_as_df = pandas.DataFrame(
index=boundaries_names_as_list: list[str],
data={"subdomain": subdomains_as_list: list[dolfin.SubDomain]}
)xdmf format (see Third-party tools), that is compatible with their definition of boundaries.
-
_make_bcsprovides a description of the boundary conditions on the boundaries defined above, in a dedicated classBoundaryConditionscontaining two lists.
@abstractmethod
def _make_bcs(self) -> BoundaryConditions:
passBoundaryConditions is a utility class that contains two list fields: bcu (velocity boundary conditions for the perturbation field) and bcp (pressure boundary conditions for the perturbation field). See below:
@dataclass
class BoundaryConditions:
bcu: list[dolfin.DirichletBC]
bcp: list[dolfin.DirichletBC]We give two examples with the code (the flow past a cylinder, and the flow over an open cavity) that inherit from FlowSolver: they are respectively CylinderFlowSolver and CavityFlowSolver.
In order to perform sensing and actuation (with the objective to close the loop), two dedicated abstract classes are proposed: Sensor and Actuator. Both these classes implement behaviors common to all sensors or actuators. They are not aimed at being instantiated directly, they need to be inherited before.
The sensors and actuators are attached to a FlowSolver object as lists, through the ParamControl dataclass (as ParamControl.sensor_list, ParamControl.actuator_list). By attaching several sensors or actuators, it is possible to generate Multiple-Input, Multiple-Output configurations for control. The call to Sensors and Actuators is made automatically by FlowSolver.
For the cylinder case, we give an example below. We create two actuators forcing boundary conditions (on the top and bottom poles of the cylinder, respectively), and three point probes at different locations in the wake. They are gathered in a ParamControl object, which is passed as an argument to initialize a CylinderFlowSolver.
# Actuators
actuator_bc_1 = ActuatorBCParabolicV(angular_size_deg=10)
actuator_bc_2 = ActuatorBCParabolicV(angular_size_deg=10)
# Sensors
sensor_feedback = SensorPoint(sensor_type=SENSOR_TYPE.V, position=np.array([3, 0]))
sensor_perf_1 = SensorPoint(sensor_type=SENSOR_TYPE.V, position=np.array([3.1, 1]))
sensor_perf_2 = SensorPoint(sensor_type=SENSOR_TYPE.V, position=np.array([3.1, -1]))
# Gather actuators and sensors in ParamControl object
params_control = flowsolverparameters.ParamControl(
sensor_list=[sensor_feedback, sensor_perf_1, sensor_perf_2],
actuator_list=[actuator_bc_1, actuator_bc_2],
)Once a use-case is defined by implementing the corresponding class inheriting FlowSolver, the basic feedback syntax has the following philosophy:
- The
FlowSolversubclass is instantiated with user-defined parameters - The base flow (stationary solution) is computed first
- The object is prepared for time-stepping (e.g. we define operators, solvers, numerical schemes)
- (Optional) A
Controlleris synthesized or read from a file - Time loop: iterate the
FlowSolver.step(u)method, providing the 1D vector inputu(open-loop or closed-loop using theControlleroutput)
A draft is given below. See the folder examples for more exhaustive code.
# Instantiate and initialize FlowSolver object
fs = CylinderFlowSolver(...)
fs.compute_steady_state(...)
fs.initialize_time_stepping(...)
# Instantiate Controller (e.g. load from .mat file)
Kss = Controller.from_file(...)
# Time loop
y_meas = fs.y_meas
for _ in range(fs.params_time.num_steps):
u_ctrl = Kss.step(y=-y_meas[0], dt=fs.params_time.dt)
y_meas = fs.step(u_ctrl=u_ctrl)The simulation should run seamlessly while providing information on the computed fields and potentially exporting information (as xdmf and csv).
A handful of parameters are embedded in dataclasses prefixed with Param*, defined in the file flowsolverparameters.py:
ParamFlow
ParamMesh
ParamControl
ParamTime
ParamRestart
ParamSave
ParamSolver
ParamICAll these dataclasses contain parameters used natively by FlowSolver: flow parameters (
In addition, they inherit from the base class ParamFlowSolver which embeds a dictionary of user_data. This dictionary is intended to be used for data unknown to FlowSolver (the latter never calls this dictionary), but by its subclasses. In other words, only the user is supposed to call the user_data field when inheriting from FlowSolver. For example, ParamMesh.user_data could contain the mesh extent or specific locations used to define boundaries/boundary conditions.
A mesh should be provided by the user in xdmf format. It should be compatible with the definition of boundaries and boundary conditions in the _make_boundaries, _make_bcs methods overriden by the user. The path to the mesh is embedded in the dataclass ParamMesh as a ParamMesh.meshpath: pathlib.Path.
In order to instantiate a FlowSolver, all parameters classes should be instantiated (see above) and passed as parameters.
When instantiating a FlowSolver, the timeline of internal methods called is the following:
self.paths = self._define_paths()
self.mesh = self._make_mesh()
self.V, self.P, self.W = self._make_function_spaces()
self.boundaries = self._make_boundaries() # @abstract
self._mark_boundaries()
self._load_actuators()
self._load_sensors()
self.bc = self._make_bcs() # @abstract
self.BC = self._make_BCs()Before launching a time simulation with a time loop, some operations are performed onto a FlowSolver object by calling FlowSolver.initialize_time_stepping(Tstart, ic). This method can work in two distinct ways:
- If
Tstart=0, then the parametericis taken into account. It corresponds to the initial condition of the perturbation field on the base flow. The default perturbation field is defined in the following method:
_default_initial_perturbation(self, xloc: float = 0.0, yloc: float = 0.0, radius: float = 1.0) -> dolfin.FunctionIts parameters may be tweaked from outside with the ParamIC dataclass, as follows:
params_ic = flowsolverparameters.ParamIC(
xloc=2.0, yloc=0.0, radius=0.5, amplitude=1.0
)- If
Tstart!=0, then the code will try to restart a simulation from a saved file corresponding to the prescribedTstart. In this case, it is important that the parameterParamRestartis set correctly, in order to find the snapshot corresponding toTstart(because FEniCS is working with index-based snapshots instead of time-based snapshots, which means a computation is required to retrieve the index from the time instant). More details on the saving system and the restarting procedure are given below, in a dedicated section.
Good practice is to do Picard iterations, then Newton
Initial guess for Picard only:
_default_steady_state_initial_guess(self) -> dolfin.UserExpressionBy default, the inlet flow profile is uniform, with velocity ParamFlow.uinf. This default profile may be modified in the method make_BCs with a dolfin.Expression.
The perturbation velocity boundary condition on this boundary is always
Some helper classes were defined to embed flow fields more easily, especially because FEniCS might sometimes need the velocity and pressure fields separately, or merged into a single object. The dataclass FlowField provides this utility: it contains a velocity field u, a pressure field p and the merged field up, all as dolfin.Functions.
In order to split a up field into the corresponding u, p fields, the method dolfin.Function.split may be used. In order to reverse the operation and merge u, p into a single field up, the method FlowSolver.merge is advised.
In addition, an object FlowSolver holds a FlowFieldCollection dataclass to gather all fields into a single structure for easier access. FlowFieldCollection contains lots of types of fields, among which the base flow, the initial perturbation, the current and previous perturbations... By default, the collection can be accessed through the attribute FlowField.fields.
The FlowSolver class is intended to be seen, among other things, as capable of performing time-stepping using a series of inputs u_ctrl, providing a series of outputs y_meas. The time simulation is done one time step dt at a time, by using the FlowSolver.step(u_ctrl) method. The step method may be iterated in a loop in order to perform long simulations.
The input u_ctrl may be defined by hand or stem from a controller in closed-loop (see below), some examples are found in the examples/ folder.
- Principle
Actuatoris an abstract class that encapsulates adolfin.Expressionand other parameters. An actuator are passed as a parameter to aFlowSolverfor instantiation, through anactuator_listin theParamControlobject.
Actuator have an assigned type, defined as an integer enumeration: ACTUATOR_TYPE(IntEnum). It may be one of the following:
-
ACTUATOR_TYPE.FORCE: the actuator provides a volumic forcing. Its expression is automatically included in the momentum equation (in variational form). -
ACTUATOR_TYPE.BC: the actuator modifies the boundary conditions dynamically. It should be reflected by the user when overriding_make_boundaries(), _make_bcs(). An example can be found inexamples/cylinder/cylinderflowsolver.py:
def _make_bcs(self):
...
bcu_actuation_up = dolfin.DirichletBC(
self.W.sub(0),
self.params_control.actuator_list[0].expression,
self.get_subdomain["actuator_up"],
)
bcu_actuation_lo = dolfin.DirichletBC(
self.W.sub(0),
self.params_control.actuator_list[1].expression,
self.get_subdomain["actuator_lo"],
)
...
return BoundaryConditions(bcu=bcu, bcp=[])The expression of each actuator needs to be loaded after the FlowSolver is instantiated (the analytic dolfin.Expression is projected onto the FEM function spaces), which is handled automatically by the code.
- Examples of actuators
-
ActuatorBCParabolicV: boundary condition actuator, 2nd component on velocity has parabolic profile
Mathematical expression:
FEniCS syntax:
def load_expression(self, flowsolver):
L = (
1
/ 2
* flowsolver.params_flow.user_data["D"]
* np.sin(1 / 2 * self.angular_size_deg * dolfin.pi / 180)
)
expression = dolfin.Expression(
[
"0",
"(x[0]>=L || x[0] <=-L) ? 0 : u_ctrl * -1*(x[0]+L)*(x[0]-L) / (L*L)",
],
element=flowsolver.V.ufl_element(),
L=L,
u_ctrl=0.0,
)
self.expression = expression-
ActuatorForceGaussianV: force actuator, gaussian-shaped on the 2nd component of velocity
Mathematical expression:
FEniCS syntax:
def load_expression(self, flowsolver):
expression = dolfin.Expression(
[
"0",
"u_ctrl * eta*exp(-0.5*((x[0]-x10)*(x[0]-x10)+(x[1]-x20)*(x[1]-x20))/(sig*sig))",
],
element=flowsolver.V.ufl_element(),
eta=1,
sig=self.sigma,
x10=self.position[0],
x20=self.position[1],
u_ctrl=1.0,
)
BtB = dolfin.norm(expression, mesh=flowsolver.mesh)
expression.eta = 1 / BtB
expression.u_ctrl = 0.0
self.expression = expression- Define new actuators
One can readily define a new actuator by inheriting the base class
Actuatorand providing a dedicated expression through theload_expression(self, flowsolver)method.
- Include a
u_ctrlfield in thedolfin.Expression. Its value may be changed in the body of the method (seeActuatorForceGaussianV), but it should be 0.0 when the methodload_expression(self, flowsolver)exits. - Assign
self.expression = expressionat the end ofdef load_expression(self, flowsolver).
- Principle
Sensor is an abstract class that gathers a behavior common to all sensors: it exhibits an abstract method eval(self, up: dolfin.Function) -> float to evaluate the measurement on a mixed-field (u,p).
The Sensor abstract class is expecte to be inherited by specific kinds of sensors. For example, the classes SensorPoint (point probe) and SensorHorizontalWallShear (integration on a subdomain) are subclasses that implement the Sensor.eval() abstract method.
The evaluation of sensors is handled automatically by the FlowSolver in the step() method.
Some sensors, for example those inheriting from SensorIntegral (e.g. SensorHorizontalWallShear) need to be loaded in some way (e.g. to define a subdomain of integration). They implement a load() method that is called by FlowSolver if the boolean Sensor.require_loading is set to True.
Just like actuators, sensors hold a SENSOR_TYPE(IntEnum), but it serves a different purpose. The SENSOR_TYPE.U, SENSOR_TYPE.V, SENSOR_TYPE.P, SENSOR_TYPE.OTHER is merely a shortcut to evaluate point probes on the right component of the field.
- Examples of sensors
- A simple
SensorPoint(Sensor)has a straightforward definition of itseval()method: it evaluates the field at the given position (self.position) and on the given component (self.sensor_type):
Mathematical expression:
FEniCS syntax:
def eval(self, up):
return up(self.position[0], self.position[1])[self.sensor_type]- For a
SensorHorizontalWallShear(SensorIntegral)(whereSensorIntegralinherits directly fromSensor), the definition of theeval()method is more complex. First of all, theload()function defines a subdomain of integration with a givenindex: intand an associatedds: dolfin.Measure. Theeval()method integrates (assemble) on the sensor subdomain (self.ds(int(self.sensor_index))) the quantity$\frac{\partial u_1}{\partial x_2}$ .
Mathematical expression:
FEniCS syntax:
def eval(self, up):
return dolfin.assemble(up.dx(1)[0] * self.ds(int(self.sensor_index)))- Define new sensors
New sensors can be defined by inheriting existing classes.
Sensor.eval()code with parallel execution (MPI) of the code.
The class Controller aims at implementing a LTI system used as a controller in a closed-loop. It inherits from control.StateSpace (LTI system) while encapsulating two additional attributes:
- The current plant state
x, notably for performing the time simulation of the controller, - (Optional) A
filefrom which the controller was read (e.g. if it was synthesized in Matlab and imported in Python).
As such, it overrides methods from control.StateSpace for LTI systems: addition, multiplication, concatenation, etc., as well as inversion.
Additionally, the class Controller implements a Controller.step(y, dt) method, which advances the time simulation with a step dt using the input y from the current internal state self.x. The method itself is merely a wrapper around control.StateSpace.forced_response with dimension manipulations.
The toolbox offers two ways of starting a simulation. A simulation can be:
- starting from a given initial condition (IC) from the time instant
$t=0$ (by default). The IC is usually provided as a perturbation field over the base flow. - restarting from a given time instant that needs to correspond to a previously saved file.
Below, we describe both methods, as well as the way FEniCS saves files.
All files are saved in the folder specified in the ParamSave.path_out. It is assumed to exist. In general, it is suggested to set:
cwd = Path(__file__).parent
params_save = flowsolverparameters.ParamSave(
save_every=5, path_out=cwd / "data_output"
)FEniCS is able to save fields in the xdmf format], a usual choice for high-performance computing. This format differentiates two types of data: meta-data, stored using XML in the .xdmf file, and value data, stored using HDF5 in the .h5 file. In other words, a single field being saved produces two files.
In the developed toolbox, when performing a simulation, fields can be saved at a given frequence: ParamSave.save_every parameter. As its name suggests, it saves the current field only at every ParamSave.save_every time step of the simulation.
When saving several time steps, a single xdmf file (and its h5 counterpart) is used. All time steps are referenced in the xdmf file with their corresponding time instant, and the field is saved in the h5 file. However, when reading a field from the xdmf/h5 files, it is not possible to reference it with its time instant: it must be referenced to by its index. Below, in the Restarting section, it is explained how it is used for restarting from a particular time instant.
Several full fields fields are saved simultaneously: U), Uprev), P). Note that those fields are full fields, and not perturbation fields. When computed, the base flow is automatically saved in the subfolder steady of the path ParamSave.data_output.
At every simulated time step (starting from ParamTime.Tstart=0), we evaluate whether the step is a multiple of ParamSave.save_every. If it is, the current field is saved as a xdmf/h5 file. The file structure is summarized below.

It is important to note that fields are referenced in two different ways in the xdmf and h5 files.
- In the
xdmffile, they are referenced with the corresponding time instant. A field has the following structure:
<Grid Name="U_0" GridType="Uniform">
<Topology NumberOfElements="12284" TopologyType="Triangle" NodesPerElement="3">
...
</Topology>
<Geometry GeometryType="XY">
...
</Geometry>
<Time Value="0.000000" />
<Attribute ItemType="FiniteElementFunction" ElementFamily="CG" ElementDegree="2" ElementCell="triangle" Name="U" Center="Other" AttributeType="Vector">
...
</Attribute>
</Grid>- In the
h5file, they are referenced with their corresponding index (i.e. an integer). There is, however, a clear correspondence between the time instant and the index.
This is particularly important to explain the next section.
In order to restart a simulation from an arbitrary time instant, the process is a bit tricky due to FEniCS' way of saving and retrieving fields from the xdmf/h5 files.
- Restart (show graph)
- Use it as backend in an optimization tool (as in Jussiau, W., Demourant, F., Leclercq, C., & Apkarian, P. (2025). Control of a Class of High-Dimensional Nonlinear Oscillators: Application to Flow Stabilization. IEEE Transactions on Control Systems Technology.).
- Operator computation
- Frequency response computation
- Export utils (spy, save)
- Debug utils (export subdomains)
- Mpi utils (= shortcuts)
Powered by GitHub