Skip to content

[Bug] Unwanted side effect when using obs.simulate and lightsim2grid backend #128

@RalfCortes

Description

@RalfCortes

Environment

  • grid2op 1.12.3
  • lightsim2grid 0.12.2
  • Oracle Linux Server 8.10
uv venv --python 3.12.9
uv pip install grid2op==1.12.3 lightsim2grid==0.12.2

Bug description

When using lightsim2grid, it seems that obs.simulate can have side effects and 'break' the initial observation we are working on.
This is not the case when using pypowsybl2grid or pandapower.

In the example bellow, we intentionally create a 'bad' forecast timeserie, that diverges at timestep=9. Once this is done, we can not go back in time anymore.

How to reproduce

Code snippet

import grid2op
import numpy as np
from datetime import timedelta
from lightsim2grid import LightSimBackend
from grid2op.Chronics import FromNPY
from grid2op.Exceptions import ChronicsError
from typing import Optional


class FromNPYMultiHorizon(FromNPY):
    """
    Extension of FromNPY supporting multi-horizon forecasts.

    Forecast arrays can be:
        (T, N)         -> automatically converted to (T, 1, N)
        (T, H, N)      -> used directly

    Where:
        T = number of timesteps
        H = number of forecast horizons
        N = number of loads / generators
    """

    MULTI_CHRONICS = False

    def __init__(
        self,
        *args,
        load_p_forecast: Optional[np.ndarray] = None,
        load_q_forecast: Optional[np.ndarray] = None,
        prod_p_forecast: Optional[np.ndarray] = None,
        prod_v_forecast: Optional[np.ndarray] = None,
        **kwargs,
    ):
        # Prevent parent from building recursive forecast object
        super().__init__(
            *args,
            load_p_forecast=None,
            load_q_forecast=None,
            prod_p_forecast=None,
            prod_v_forecast=None,
            **kwargs,
        )

        # Format forecast arrays
        self._load_p_forecast = self._format_forecast_array(load_p_forecast)
        self._load_q_forecast = self._format_forecast_array(load_q_forecast)
        self._prod_p_forecast = self._format_forecast_array(prod_p_forecast)
        self._prod_v_forecast = self._format_forecast_array(prod_v_forecast)

        # Determine number of horizons
        if self._load_p_forecast is not None:
            self.n_forecast_horizons = self._load_p_forecast.shape[1]
        else:
            self.n_forecast_horizons = 0

    @staticmethod
    def _format_forecast_array(arr: Optional[np.ndarray]) -> Optional[np.ndarray]:
        """
        Convert forecast array to shape (T, H, N)

        Accepts:
            (T, N)
            (T, H, N)
        """
        if arr is None:
            return None

        if arr.ndim == 2:
            # (T, N) → (T, 1, N)
            return arr[:, np.newaxis, :]

        if arr.ndim == 3:
            return arr

        raise ChronicsError("Forecast arrays must have shape (T, N) or (T, H, N)")

    def forecasts(self):
        """
        Return list of (datetime, dict) for each horizon.
        """
        if self._load_p_forecast is None:
            return []

        t = self.current_index

        if t >= self._load_p_forecast.shape[0]:
            return []

        results = []

        for h in range(self.n_forecast_horizons):
            dict_ = {}

            if self._load_p_forecast is not None:
                dict_["load_p"] = 1.0 * self._load_p_forecast[t, h, :]

            if self._load_q_forecast is not None:
                dict_["load_q"] = 1.0 * self._load_q_forecast[t, h, :]

            if self._prod_p_forecast is not None:
                dict_["prod_p"] = 1.0 * self._prod_p_forecast[t, h, :]

            if self._prod_v_forecast is not None:
                dict_["prod_v"] = 1.0 * self._prod_v_forecast[t, h, :]

            forecast_datetime = self.current_datetime + (h + 1) * self.time_interval

            results.append((forecast_datetime, {"injection": dict_}))

        return results

    def change_forecasts(
        self,
        new_load_p: Optional[np.ndarray] = None,
        new_load_q: Optional[np.ndarray] = None,
        new_prod_p: Optional[np.ndarray] = None,
        new_prod_v: Optional[np.ndarray] = None,
    ):
        """
        Update forecast arrays (effective after env.reset()).
        """
        if new_load_p is not None:
            self._load_p_forecast = self._format_forecast_array(new_load_p)

        if new_load_q is not None:
            self._load_q_forecast = self._format_forecast_array(new_load_q)

        if new_prod_p is not None:
            self._prod_p_forecast = self._format_forecast_array(new_prod_p)

        if new_prod_v is not None:
            self._prod_v_forecast = self._format_forecast_array(new_prod_v)

        if self._load_p_forecast is not None:
            self.n_forecast_horizons = self._load_p_forecast.shape[1]
        else:
            self.n_forecast_horizons = 0

    def check_validity(self, backend=None):
        super().check_validity(backend)

        if self._load_p_forecast is not None:
            assert self._load_p_forecast.shape[0] == self._load_p.shape[0]
            assert self._load_p_forecast.shape[2] == self.n_load

        if self._prod_p_forecast is not None:
            assert self._prod_p_forecast.shape[0] == self._prod_p.shape[0]
            assert self._prod_p_forecast.shape[2] == self.n_gen


env = grid2op.make(
    "l2rpn_case14_sandbox",
    backend=LightSimBackend(),
)

init_obs = env.reset()

# Artificially create a timeseries :
load_p, load_q, prod_p, prod_v = (
    init_obs.load_p,
    init_obs.load_q,
    init_obs.prod_p,
    init_obs.prod_v,
)

load_p = np.concatenate([[load_p] * 48], axis=0)
load_q = np.concatenate([[load_q] * 48], axis=0)
prod_p = np.concatenate([[prod_p] * 48], axis=0)
prod_v = np.concatenate([[prod_v] * 48], axis=0)

load_p_fc = load_p
load_q_fc = load_q
prod_p_fc = prod_p
prod_v_fc = prod_v

# create a fake moving timeserie that will create a DC divergence (too high load)

load_p_fake = (load_p + (np.linspace(0, 1, len(load_p)) * 100).reshape(-1, 1)).round(1)

T, N = load_p_fake.shape
H = 48

indices = np.arange(T)[:, None] + np.arange(1, H + 1)[None, :]
indices = np.clip(indices, 0, T - 1)

load_p_fc = load_p_fake[indices]
load_q_fc = load_q[indices]
prod_p_fc = prod_p[indices]
prod_v_fc = prod_v[indices]

load_p_fc[1:, :, :] = (
    0  # Forecast available at t =0. We only need the first row for each forecast
)

env = grid2op.make(
    "l2rpn_case14_sandbox",
    backend=LightSimBackend(),
    allow_detachment=True,
    n_busbar=6,
    chronics_class=FromNPYMultiHorizon,
    data_feeding_kwargs={
        "i_start": 0,
        "i_end": 48,
        "load_p": load_p_fake,
        "load_q": load_q,
        "prod_p": prod_p,
        "prod_v": prod_v,
        "load_p_forecast": load_p_fc,
        "load_q_forecast": load_q_fc,
        "prod_p_forecast": prod_p_fc,
        "prod_v_forecast": prod_v_fc,
        "h_forecast": [i * 30 for i in range(1, 48)],
        "time_interval": timedelta(minutes=30),
    },
)


env.chronics_handler.change_chronics(
    new_load_p=load_p_fake,
    new_load_q=load_q,
    new_prod_p=prod_p,
    new_prod_v=prod_v,
)
env.chronics_handler.change_forecasts(
    new_load_p=load_p_fc,
    new_load_q=load_q_fc,
    new_prod_p=prod_p_fc,
    new_prod_v=prod_v_fc,
)

##

if __name__ == "__main__":
    obs_init = env.reset()
    print(obs_init.get_time_stamp())
    print(obs_init.load_p)

    # OK

    obs_step, *_ = env.step(env.action_space())
    print(obs_step.get_time_stamp())
    print(obs_step.load_p)

    # OK

    obs_simulate, *_ = obs_init.simulate(env.action_space(), time_step=8) # t = 0 ... 8 is Okay !

    print(obs_simulate.get_time_stamp())
    print(obs_simulate.load_p)
    print(obs_simulate.rho)

    # OK

    obs_simulate, *_ = obs_init.simulate(env.action_space(), time_step=5) # t = 0 ... 8 is Okay !

    print(obs_simulate.get_time_stamp())
    print(obs_simulate.load_p)
    print(obs_simulate.rho)

    # After this NOK

    obs_simulate, *_ = obs_init.simulate(env.action_space(), time_step=9)

    print(obs_simulate.get_time_stamp())
    print(obs_simulate.load_p)
    print(obs_simulate.rho)

    obs_simulate, *_ = obs_init.simulate(env.action_space(), time_step=5)

    print(obs_simulate.get_time_stamp())
    print(obs_simulate.load_p)
    print(obs_simulate.rho)

Current output

2019-01-01 00:30:00
[21.9 85.8 44.3  6.9 11.9 28.5  8.8  3.5  5.4 12.6 14.4]
2019-01-01 01:00:00
[24.  87.9 46.4  9.  14.  30.6 10.9  5.6  7.5 14.7 16.5]
2019-01-01 04:30:00
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
2019-01-01 03:00:00
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
2019-01-01 05:00:00
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
2019-01-01 03:00:00
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]

Expected output

This output was created using Pandapower backend:


2019-01-01 00:30:00
[21.9 85.8 44.3  6.9 11.9 28.5  8.8  3.5  5.4 12.6 14.4]
2019-01-01 01:00:00
[24.  87.9 46.4  9.  14.  30.6 10.9  5.6  7.5 14.7 16.5]
2019-01-01 04:30:00
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
2019-01-01 03:00:00
[32.5 96.4 54.9 17.5 22.5 39.1 19.4 14.1 16.  23.2 25. ]
[1.0019906  0.7419449  0.4845092  0.44415575 1.4045746  0.420179
 0.50475854 0.80372405 0.9490499  1.1287203  1.2295688  0.7897864
 0.34381983 0.4001435  0.4910553  0.9341112  0.99194753 1.8852744
 0.57754666 0.86008847]
2019-01-01 05:00:00
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
2019-01-01 03:00:00
[32.5 96.4 54.9 17.5 22.5 39.1 19.4 14.1 16.  23.2 25. ]
[1.0019906  0.7419449  0.4845092  0.44415575 1.4045746  0.420179
 0.50475854 0.80372405 0.9490499  1.1287203  1.2295688  0.7897864
 0.34381983 0.4001435  0.4910553  0.9341112  0.99194753 1.8852744
 0.57754666 0.86008847]

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions