Package: powerplantsim
PowerPlantSim: A Power Plant SimulatorPowerPlantSimis a library for modelling power plants and running simulations on top of them.1. Create a PlantThe main class ofpowerplantsimisPlant, which defines a new power plant to simulate.
Its only parameter is the time horizon of the simulation; in this case, since we are passing an integer, the result will
be an empty power plant ready to be run forhorizontime steps.frompowerplantsimimportPlantplant=Plant(horizon=7)Rather than an integer value, it is also possible to pass a custom index. The constructor method acceptslists,numpy
arrays, andpandas series.
Additionally, it is possible to provide a customnamefor the plant, and aseedvalue which is used to build an
internal random number generator.plant_list=Plant(horizon=[0,1,2,3,4,5,6],name='List',seed=1)plant_array=Plant(horizon=np.arange(7),name='Array',seed=2)plant_series=Plant(horizon=pd.Series(range(7)),name='Series',seed=3)2. Add NodesAPlantinstance contains three main kind of nodes:Extremities, which can either supply or accept a single commodity;Machines, which can transform a single input commodity into one or more output commodities;Storages, which can store a single commodity.The way to add a new node is via theadd_<type>method, i.e.,add_extremity,add_machine, oradd_storage.2.1. SuppliersASuppliernode is an extremity node that can supply a single commodity.
In order to add a newSupplier, you need to use theadd_extremitymethod with parameterkind='supplier'.
The rest of the parameters are:name, i.e., a unique identifier for the node in the plant;commodity, i.e., the name of the commodity which is supplied by the node;predictions, i.e., the vector of predicted prices for that commodity throughout the simulation;Note: since here we are passing a single float value as predicted vector, this will be converted in the
array[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]-- the length of the array is7, as many as are the time steps of our
simulation.plant.add_extremity(kind='supplier',name='gas',commodity='gas',predictions=1.0)Together with the predicted prices vector -- or constant --, it is possible to pass a variance model.The variance $\Sigma$ is a custom distribution defining the difference between the predicted price $p_t$ and the actual
value $v_t$ used in the simulation at time step $t$. This shift between prediction and value allows to model the
intrinsic uncertainty of the problem, which usually relies on historical data to optimize the energetic plan of the
power plant.More specifically, the actual value $v_t$ is computed as:
$$v_t = p_t + \sigma_t, \sigma_t \sim \Sigma \mid v_{{1:t-1}}$$
namely, the distribution $\Sigma$ can be conditioned on the actual values computed in previous time steps.Note: from a programming perspective, the variance model is a functionf(rng, values), of which the actual values
are the second parameter. The first parameter, instead, is a reference to the random number generator which is stored
in
the plant itself. It is advisable to use that random number generator instance to perform any random operation, in
order
to have completely reproducible results.plant.add_extremity(kind='supplier',name='elec',commodity='elec',predictions=[1.4,0.8,2.3,3.2,1.8,2.1,1.5],variance=lambdarng,values:rng.normal(loc=0.0,scale=0.2))Every time a new node is added in the plant, this is not only returned by theadd_<type>method, but also stored
automatically within the plant itself.2.2. MachinesAMachinenode consumes a single commodity as input and produces one or more commodities as output.
Other than the usualname, a machine node needs:either a single or a list ofparentsthat can provide its input commodity;the name of the inputcommodity;asetpoint, which defines the operative range of the machine;theinputs, i.e., a list of input commodity flows paired with the setpoint;theoutputs, i.e., a list of flows paired with the setpoint and indexed by the name of the output commodity;plant.add_machine(name='boiler',parents='gas',commodity='gas',setpoint=[0,1],inputs=[0,100],outputs={'heat':[0,90]})Differently fromSuppliernodes that are sources in the plant topology,Machinenodes must have parents.
Indeed, an edge between the machine and its parent is automatically included as well.Additionally, machines can be built with some additional parameters:discrete, i.e., whether the setpoint of the machine is discrete or continuous;cost, i.e., the cost of operating the machine during a time step;max_starting, i.e., a tuple ($N$, $T$) specifying that the machine can be started at most $N$ times in the past $T$
time steps.plant.add_machine(name='cogen',parents='gas',commodity='gas',setpoint=[0.5,1],inputs=[67.5,135],outputs={'heat':[27,50],'elec':[23,50]},discrete=True,cost=2.0,max_starting=(1,3))And, eventually, bothMachineandStoragenodes can havemin_flowandmax_flowparameters to define bounds for
the minimum and maximum in all the edges coming from the parents.plant.add_machine(name='chiller',parents=['elec','cogen'],commodity='elec',setpoint=[0,1],inputs=[0,0.7],outputs={'cool':[0,2.0]},min_flow=0.0,max_flow=1.0)2.3. StoragesAStoragenode can store a single commodity, and it can be either charged or discharged in a single time step -- but
not both.
Additionally, storage nodes have:acapacity, i.e., the maximal amount of commodity units that can be stored;adissipation, i.e., a parameter defining the amount of commodity units that gets dissipated every time step.
Namely, given a storage amount $s_t$ and a dissipation $\delta$, the amount in the following time step will be $s_{t +
1} = (1 - \delta) * s_t$;two optional floats ofcharge_rateanddischarge_ratewhich defines the maximal charge and discharge rate in a
single time step.plant.add_storage(name='storage',parents=['cogen','boiler'],commodity='heat',capacity=45,dissipation=0.02,charge_rate=10.0,discharge_rate=5.0)2.4. ClientsAClientnode is another type of extremity node that accepts a single commodity.
The parameters of aClientnode are the same as for aSuppliernode, although for clients theparentsneed to be
passed.plant.add_extremity(kind='customer',name='heat',commodity='heat',parents=['cogen','boiler','storage'],predictions=[50.0,54.0,58.2,48.7,53.0,49.6,51.8])Moreover, aClientnode can be of two different kinds:customerorpurchaser.Their difference is that aCustomernodesdemandsa commodity while aPurchasernode can buy it.
More specifically, the vector ofpredictionsthat are passed represent the node demands or the node buying prices,
respectively.Note: a demand automatically poses an upper bound in the total input flow for aCustomernode, meaning that it is
not possible to send more commodity than expected to that node.plant.add_extremity(kind='customer',name='cool',commodity='cool',parents='chiller',predictions=0.8,variance=lambdarng,series:rng.normal(loc=0.0,scale=0.2))plant.add_extremity(kind='purchaser',name='grid',commodity='elec',parents='cogen',predictions=2.0,variance=lambdarng,series:rng.normal(loc=0.0,scale=0.5))3. Inspect the PlantIt is possible to access the nodes and edges of aPlantobject at any time using the two methods with the same name.
Additionally, the plant topology can be drawn using thedrawmethod.forname,nodeinplant.nodes().items():print(f'{name}:{node}')for(source,destination),edgeinplant.edges().items():print(f'{source}->{destination}:{edge}')plant.draw()4. Run the SimulationAs a final step, it is possible to run the simulation using therunmethod.This method accepts two input parameters:plan, i.e., a dictionary of type {machine|edge:states|flows} which associates to each a vector of
states for each machine and a vector of flows for each edge in the plant.action, i.e., aRecourseActionobject that defines the strategy to update the states and the flows throughout the
simulation. In this case, we are using the default implementation of the recourse action, which computes the new flows
using a greedy strategy that minimizes at each time step the distance from the given plan.
A callback mechanism is also implemented, and allows to keep track of the internal status of the simulation by passing
a customCallbackobject to thecallbacksmethod.Note: as for the vector of predictions, again passing a single float value will be translated in a constant vector.
Also, the valuenp.nanindicates that the machine is off in that specific time step.frompowerplantsim.plantimportDefaultRecourseActionoutput=plant.run(plan={'boiler':[0.1,0.4,0.2,0.4,0.6,0.6,0.5],'cogen':[np.nan,0.5,np.nan,np.nan,np.nan,1.0,np.nan],'chiller':0.6,('gas','boiler'):[10,40,20,40,60,60,50],('gas','cogen'):[0,67.5,0,0,0,135,0],('elec','chiller'):0.42,('boiler','storage'):0.0,('boiler','heat'):[9,36,18,36,54,54,45],('cogen','storage'):0.0,('cogen','chiller'):0.0,('cogen','heat'):[0,27,0,0,0,50,0],('cogen','grid'):[0,23,0,0,0,50,0],('storage','heat'):0.0,('chiller','cool'):1.2},action=DefaultRecourseAction(solver='gurobi'),callbacks=None)Therunmethod return aSimulationOutputobject, which contains all the simulation details such as the actual
machine states that were used, the actual flows that passed in all the edges, and the actual prices, demands, and
storage values.