frequenz-floss / frequenz-sdk-python

Frequenz Python Software Development Kit (SDK)
https://frequenz-floss.github.io/frequenz-sdk-python/
MIT License
13 stars 17 forks source link

PowerManager #161

Closed ela-kotulska-frequenz closed 1 year ago

ela-kotulska-frequenz commented 1 year ago

What's needed?

PowerManager is a tool for controlling requests to the microgrid from the sdk side.

Requests:

Problem

Proposed solution

We called it matrjoschka algorithm. The name comes from Russian nested dolls that construction makes easier to understand the algorithm.

For simplicity lets consider PowerManager for controlling batteries only. Actors can send requests to charge or discharge batteries.

Each actor should have assigned priority. Priority tells how important is request from that actor. Priority should be configurable dynamically in the UI.

Request would look like that:

class BatteryPowerRequest:
    priority: float # Actors priority
    batteries: Set[int] # What batteries should be charged/discharged
    power: float # Total power recommended by actor to be distributed between given batteries. Negative value would discharge batteries, positive value would charge them.This value should be between min_power and max_power.
    min_power: float # min power bound (described below)
    max_power: float # max power bound (described below)
    duration_sec: float # how long the request should stay.

So each actor sends power and the power tolerance: min_power and max_power. That means: I suggest to set power but all values between min_power and max_power are also ok.

PowerManager would receive requests and set the bounds in the way described below:

Let internal bound be bound created based on the requests from the actor.

PowerManager will stream every change of internal bound to the subscribed users using channels.

Beyond the internal bound, microgrid have it own bounds that are streamed by components. Microgrid Bounds has the highest priority. That priority can't be overwrite.

If actor needs an exclusive lock, then he should send request with both power, min_power, max_power set to the same level. Exclusive lock will be ignored if the min_power or max_power are outside the internal bound. Exclusive lock can be interrupted if actor with higher priority sends request that changes bounds in a way that the exclusive lock bounds don't apply.

Examples

Lets assume we have 2 actors: Actor_1, Actor_2. Actor1 has higher priority then Actor2.

sequenceDiagram
    actor Actor1
    actor Actor2
    actor PowerManager
    actor Microgrid

    Microgrid->>PowerManager: charge_bounds: [0, 1000]
    Microgrid->>PowerManager: discharge_bounds: [-1000, 0]
    activate PowerManager
    Note right of PowerManager: internal discharge bounds = [-1000, 0]
    Note right of PowerManager: internal charge bounds = [0, 1000]
    deactivate PowerManager

    Actor1-)PowerManager: Request(priority=1, power=500, min_power=200, max_power=800)
    PowerManager--)Actor1: Request Succeed: <br/> Because bounds were within internal bounds.
    activate PowerManager
    Note right of PowerManager: internal discharge bounds = [0, 0]
    Note right of PowerManager: internal charge bounds = [200, 800]
    deactivate PowerManager
    PowerManager-) Microgrid: set_power(500)

    Actor2-)PowerManager: Request(priority=2, power=100, min_power=0, max_power=100)
    PowerManager--)Actor2: Request Failed: Because requested bounds were outside the internal bounds. <br/> Actor 2 has lower priority then Actor1 so he can't increase the bounds range. <br/> He can only limit them (as showed in the next request).

    Actor2-)PowerManager: Request(priority=2, power=400, min_power=300, max_power=600)
    PowerManager--)Actor2: Request Succeed:<br/>Because requested bounds just limit the range that actors with higher priority set.
    activate PowerManager
    Note right of PowerManager: internal discharge bounds = [0, 0]
    Note right of PowerManager: internal charge bounds = [300, 600]
    deactivate PowerManager
    PowerManager-) Microgrid: set_power(400)

    Actor2-)PowerManager: Request(priority=2, power=210, min_power=210, max_power=210)
    PowerManager--)Actor2: Request Succeed:<br/> Because requested bounds are in range that actors with higher priority set
    activate PowerManager
    Note right of PowerManager: internal discharge bounds = [0, 0]
    Note right of PowerManager: internal charge bounds = [210, 210]
    Note over PowerManager,Actor2: Actor 2 has exclusive lock, actors with lower priority can't change bounds.
    deactivate PowerManager
    PowerManager-) Microgrid: set_power(210)

    Actor1-)PowerManager: Request(priority=1, power=-500, min_power=-800, max_power=0)
    PowerManager--)Actor1: Request Succeed. Because actor1 has higher priority so his request can override the bounds set by actors with lower priority.
    activate PowerManager
    Note right of PowerManager: internal discharge bounds = [-800, 0]
    Note right of PowerManager: internal charge bounds = [0, 0]
    deactivate PowerManager
    PowerManager-) Microgrid: set_power(-500)
matthias-wende-frequenz commented 1 year ago

class BatteryPowerRequest: priority: float # Actors priority batteries: Set[int] # What batteries should be charged/discharged power: float # Total power recommended by actor to be distributed between given batteries. Negative value would discharge batteries, positive value would charge them.This value should be between min_power and max_power. min_power: float # min power bound (described below) max_power: float # max power bound (described below) duration_sec: float # how long the request should stay.

I suggest to use the type datetime.timedelta for duration_sec and rename this field to duration as Marenz did in the ringbuffer.

thomas-nicolai-frequenz commented 1 year ago

I suggest to design something that allows plugging in different consensus building algorithms. It would be great if we could abstract it in a way that it would allow to replace the matrjoschka / russian doll algorithm with something else like a voting-based mechanism as @andrew-stevenson-frequenz suggested.

thomas-nicolai-frequenz commented 1 year ago

In this implementation, each actor is prompted to provide a minimum and maximum charging or discharging power they are willing to accept. The average rating for each option is calculated only for ratings that fall within each actor's acceptable range, using the same weighting system as before. Finally, the option with the highest average rating is chosen as the winner.

Down below is an example of what a voting based-approach could look like. In this example multiple actor negotiate about the (dis)charging power of a battery.

charging_power_options = ['-100kW', '-50kW', '0kW', '50kW', '100kW']
num_actors = 3

# Initialize a dictionary to store the ratings for each option
ratings = {option: [] for option in charging_power_options}

# Assign weights to each actor
weights = {1: 1, 2: 2, 3: 1}

# Prompt each actor to rate each option
for i in range(num_actors):
    print("Actor", i+1, "please rate each option for charging power (-100kW to 100kW):")
    for option in charging_power_options:
        rating = int(input(option + ": "))
        # Multiply the rating by the actor's weight
        rating *= weights[i+1]
        if i == 2: # if actor is discharging, then invert the rating
            rating *= -1
        ratings[option].append(rating)

# Prompt each actor to provide their acceptable range for charging or discharging power
acceptable_ranges = {}
for i in range(num_actors):
    print("Actor", i+1, "please provide your acceptable range for charging or discharging power:")
    min_power = int(input("Minimum power: "))
    max_power = int(input("Maximum power: "))
    acceptable_ranges[i+1] = (min_power, max_power)

# Calculate the average rating for each option that falls within each actor's acceptable range
total_weight = sum(weights.values())
averages = {}
for option in charging_power_options:
    # Compute the weighted average of the ratings for each option
    rating_sum = 0
    weight_sum = 0
    for i in range(num_actors):
        rating = ratings[option][i]
        weight = weights[i+1]
        if i == 2: # if actor is discharging, then invert the rating
            rating *= -1
        # Check if the rating falls within the actor's acceptable range
        min_power, max_power = acceptable_ranges[i+1]
        if min_power <= rating <= max_power:
            rating_sum += rating * weight
            weight_sum += weight
    rating_avg = rating_sum / weight_sum if weight_sum > 0 else 0
    averages[option] = rating_avg

# Find the option with the highest average rating
winner = max(averages, key=averages.get)

print("The chosen option is:", winner)
thomas-nicolai-frequenz commented 1 year ago

What I would like to achieve is to create an interface for the PowerManager that allows both approaches to work; the russian doll priority-based approach as well as the voting based one.

llucax commented 1 year ago

Related issue: