Repository: davidusb-geek/emhass (language: python)

Issue:
forecast-cache action to weather-forecast-cache
This is a slight modification to PR #300, adjusted HTTP action from `forecast-cache` to `weather-forecast-cache` to better match the context.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 105808f4..b4c80028 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
 # Changelog
 
+## 0.10.2 - 2024-07-06
+### Improvement
+- Weather forecast caching and Solcast method fix by @GeoDerp
+- Added a new configuration parameter to control wether we compute PV curtailment or not
+- Added hybrid inverter to data publish
+- It is now possible to pass these battery parameters at runtime: `SOCmin`, `SOCmax`, `Pd_max` and `Pc_max`
+### Fix
+- Fixed problem with negative PV forecast values in optimization.py, by @GeoDerp
+
 ## 0.10.1 - 2024-06-03
 ### Fix
 - Fixed PV curtailment maximum possible value constraint
diff --git a/README.md b/README.md
index fa608b8d..4db9b81d 100644
--- a/README.md
+++ b/README.md
@@ -144,13 +144,9 @@ docker run -it --restart always -p 5000:5000  -e TZ="Europe/Paris"  -e LOCAL_COS
 ### Method 3) Legacy method using a Python virtual environment
 
 With this method it is recommended to install on a virtual environment.
-For this you will need `virtualenv`, install it using:
+Create and activate a virtual environment:
 ```bash
-sudo apt install python3-virtualenv
-```
-Then create and activate the virtual environment:
-```bash
-virtualenv -p /usr/bin/python3 emhassenv
+python3 -m venv emhassenv
 cd emhassenv
 source bin/activate
 ```
@@ -461,7 +457,7 @@ curl -i -H 'Content-Type:application/json' -X POST -d '{"publish_prefix":"all"}'
 ```
 This action will publish the dayahead (_dh) and MPC (_mpc) optimization results from the optimizations above.
 
-### Forecast data
+### Forecast data at runtime
 
 It is possible to provide EMHASS with your own forecast data. For this just add the data as list of values to a data dictionary during the call to `emhass` using the `runtimeparams` option. 
 
@@ -484,7 +480,7 @@ The possible dictionary keys to pass data are:
 
 - `prod_price_forecast` for the PV production selling price forecast.
 
-### Passing other data
+### Passing other data at runtime
 
 It is possible to also pass other data during runtime in order to automate the energy management. For example, it could be useful to dynamically update the total number of hours for each deferrable load (`def_total_hours`) using for instance a correlation with the outdoor temperature (useful for water heater for example). 
 
@@ -500,6 +496,8 @@ Here is the list of the other additional dictionary keys that can be passed at r
 
 - `def_end_timestep` for the timestep before which each deferrable load should operate (if you don't want the deferrable load to use the whole optimization timewindow).
 
+- `def_current_state` Pass this as a list of booleans (True/False) to indicate the current deferrable load state. This is used internally to avoid incorrectly penalizing a deferrable load start if a forecast is run when that load is already running.
+
 - `treat_def_as_semi_cont` to define if we should treat each deferrable load as a semi-continuous variable.
 
 - `set_def_constant` to define if we should set each deferrable load as a constant fixed value variable with just one startup for each optimization task.
@@ -510,8 +508,16 @@ Here is the list of the other additional dictionary keys that can be passed at r
 
 - `solar_forecast_kwp` for the PV peak installed power in kW used for the solar.forecast API call. 
 
+- `SOCmin` the minimum possible SOC.
+
+- `SOCmax` the maximum possible SOC.
+
 - `SOCtarget` for the desired target value of initial and final SOC.
 
+- `Pd_max` for the maximum battery discharge power.
+
+- `Pc_max` for the maximum battery charge power.
+
 - `publish_prefix` use this key to pass a common prefix to all published data. This will add a prefix to the sensor name but also to the forecasts attributes keys within the sensor.
 
 ## A naive Model Predictive Controller
diff --git a/config_emhass.yaml b/config_emhass.yaml
index bc2ac8d9..ca3a47d7 100644
--- a/config_emhass.yaml
+++ b/config_emhass.yaml
@@ -37,6 +37,9 @@ optim_conf:
   set_def_constant: # set as a constant fixed value variable with just one startup for each 24h
   - False
   - False
+  def_start_penalty: # Set a penalty for each start up of a deferrable load
+  - 0.0
+  - 0.0
   weather_forecast_method: 'scrapper' # options are 'scrapper', 'csv', 'list', 'solcast' and 'solar.forecast'
   load_forecast_method: 'naive' # options are 'csv' to load a custom load forecast from a CSV file or 'naive' for a persistance model
   load_cost_forecast_method: 'hp_hc_periods' # options are 'hp_hc_periods' for peak and non-peak hours contracts and 'csv' to load custom cost from CSV file 
@@ -78,6 +81,7 @@ plant_conf:
   strings_per_inverter: # The number of used strings per inverter
   - 1
   inverter_is_hybrid: False # Set if it is a hybrid inverter (PV+batteries) or not
+  compute_curtailment: False # Compute a PV curtailment variable or not
   Pd_max: 1000 # If your system has a battery (set_use_battery=True), the maximum discharge power in Watts
   Pc_max: 1000 # If your system has a battery (set_use_battery=True), the maximum charge power in Watts
   eta_disch: 0.95 # If your system has a battery (set_use_battery=True), the discharge efficiency
diff --git a/docs/conf.py b/docs/conf.py
index 54ed1f74..45d7baa4 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -22,7 +22,7 @@
 author = 'David HERNANDEZ'
 
 # The full version, including alpha/beta/rc tags
-release = '0.10.1'
+release = '0.10.2'
 
 # -- General configuration ---------------------------------------------------
 
diff --git a/docs/config.md b/docs/config.md
index c2fce240..3ac0af52 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -62,6 +62,7 @@ These are the parameters needed to properly define the optimization problem.
 - `set_def_constant`: Define if we should set each deferrable load as a constant fixed value variable with just one startup for each optimization task. For example:
 	- False
 	- False
+- `def_start_penalty`: Set to a list of floats. For each deferrable load with a penalty `P`, each time the deferrable load turns on will incur an additional cost of `P * P_deferrable_nom * cost_of_electricity` at that time. 
 - `weather_forecast_method`: This will define the weather forecast method that will be used. The options are 'scrapper' for a scrapping method for weather forecast from clearoutside.com and 'csv' to load a CSV file. When loading a CSV file this will be directly considered as the PV power forecast in Watts. The default CSV file path that will be used is '/data/data_weather_forecast.csv'. Defaults to 'scrapper' method.
 - `load_forecast_method`: The load forecast method that will be used. The options are 'csv' to load a CSV file or 'naive' for a simple 1-day persistance model. The default CSV file path that will be used is '/data/data_load_forecast.csv'. Defaults to 'naive'.
 - `load_cost_forecast_method`: Define the method that will be used for load cost forecast. The options are 'hp_hc_periods' for peak and non-peak hours contracts and 'csv' to load custom cost from CSV file. The default CSV file path that will be used is '/data/data_load_cost_forecast.csv'.
@@ -106,7 +107,9 @@ Solution (2) would be to use SolCast and pass that data directly to emhass as a
 - `surface_tilt`: The tilt angle of your solar panels. Defaults to 30. This parameter can be a list of items to enable the simulation of mixed orientation systems, for example one east-facing array (azimuth=90) and one west-facing array (azimuth=270). 
 - `surface_azimuth`: The azimuth of your PV installation. Defaults to 205. This parameter can be a list of items to enable the simulation of mixed orientation systems, for example one east-facing array (azimuth=90) and one west-facing array (azimuth=270). 
 - `modules_per_string`: The number of modules per string. Defaults to 16. This parameter can be a list of items to enable the simulation of mixed orientation systems, for example one east-facing array (azimuth=90) and one west-facing array (azimuth=270). 
-- `strings_per_inverter`: The number of used strings per inverter. Defaults to 1. This parameter can be a list of items to enable the simulation of mixed orientation systems, for example one east-facing array (azimuth=90) and one west-facing array (azimuth=270). 
+- `strings_per_inverter`: The number of used strings per inverter. Defaults to 1. This parameter can be a list of items to enable the simulation of mixed orientation systems, for example one east-facing array (azimuth=90) and one west-facing array (azimuth=270).
+- `inverter_is_hybrid`: Set to True to consider that the installation inverter is hybrid for PV and batteries (Default False).
+- `compute_curtailment`: Set to True to compute a special PV curtailment variable (Default False).
 
 If your system has a battery (set_use_battery=True), then you should define the following parameters:
 
diff --git a/docs/forecasts.md b/docs/forecasts.md
index 282f16df..fde219cd 100644
--- a/docs/forecasts.md
+++ b/docs/forecasts.md
@@ -12,7 +12,7 @@ EMHASS will basically need 4 forecasts to work properly:
 
 There are methods that are generalized to the 4 forecast needed. For all there forecasts it is possible to pass the data either as a passed list of values or by reading from a CSV file. With these methods it is then possible to use data from external forecast providers.
     
-Then there are the methods that are specific to each type of forecast and that proposed forecast treated and generated internally by this EMHASS forecast class. For the weather forecast a first method (`scrapper`) uses a scrapping to the ClearOutside webpage which proposes detailed forecasts based on Lat/Lon locations. Another method (`solcast`) is using the SolCast PV production forecast service. A final method (`solar.forecast`) is using another external service: Solar.Forecast, for which just the nominal PV peak installed power should be provided. Search the forecast section on the documentation for examples on how to implement these different methods.
+Then there are the methods that are specific to each type of forecast and that proposed forecast treated and generated internally by this EMHASS forecast class. For the weather forecast a first method (`scrapper`) uses a scrapping to the ClearOutside webpage which proposes detailed forecasts based on Lat/Lon locations. Another method (`solcast`) is using the Solcast PV production forecast service. A final method (`solar.forecast`) is using another external service: Solar.Forecast, for which just the nominal PV peak installed power should be provided. Search the forecast section on the documentation for examples on how to implement these different methods.
 
 The `get_power_from_weather` method is proposed here to convert from irradiance data to electrical power. The PVLib module is used to model the PV plant. A dedicated webapp will help you search for your correct PV module and inverter: [https://emhass-pvlib-database.streamlit.app/](https://emhass-pvlib-database.streamlit.app/)
 
@@ -29,19 +29,60 @@ For the PV production selling price and Load cost forecasts the privileged metho
 
 ## PV power production forecast
 
+#### scrapper 
+
 The default method for PV power forecast is the scrapping of weather forecast data from the [https://clearoutside.com/](https://clearoutside.com/) website. This is obtained using `method=scrapper`. This site proposes detailed forecasts based on Lat/Lon locations. This method seems quite stable but as with any scrape method it will fail if any changes are made to the webpage API. The weather forecast data is then converted into PV power production using the `list_pv_module_model` and `list_pv_inverter_model` paramters defined in the configuration.
 
-A second method uses the SolCast solar forecast service. Go to [https://solcast.com/](https://solcast.com/) and configure your system. You will need to set `method=solcast` and basically use two parameters `solcast_rooftop_id` and `solcast_api_key` that should be passed as parameters at runtime. This will be limited to 10 API requests per day, the granularity will be 30 min and the forecast is updated every 6h. If needed, better performances may be obtained with paid plans: [https://solcast.com/pricing/live-and-forecast](https://solcast.com/pricing/live-and-forecast).
+#### solcast 
+
+The second method uses the Solcast solar forecast service. Go to [https://solcast.com/](https://solcast.com/) and configure your system. You will need to set `method=solcast` and use two parameters `solcast_rooftop_id` and `solcast_api_key` that should be passed as parameters at runtime or provided in the configuration/secrets. The free hobbyist account will be limited to 10 API requests per day, the granularity will be 30 minutes and the forecast is updated every 6h. If needed, better performances may be obtained with paid plans: [https://solcast.com/pricing/live-and-forecast](https://solcast.com/pricing/live-and-forecast).
 
 For example:
+```yaml
+# Set weather_forecast_method parameter to solcast in your configuration (configuration page / config_emhass.yaml)
+weather_forecast_method: 'solcast'
 ```
+```bash
+# Example of running day-ahead, passing Solcast secrets fyi runtime parameters (i.e. not set in configuration)
 curl -i -H "Content-Type:application/json" -X POST -d '{"solcast_rooftop_id":"<your_system_id>","solcast_api_key":"<your_secret_api_key>"}' http://localhost:5000/action/dayahead-optim
 ```
+</br>
 
-A third method uses the Solar.Forecast service. You will need to set `method=solar.forecast` and use just one parameter `solar_forecast_kwp` (the PV peak installed power in kW) that should be passed at runtime. This will be using the free public Solar.Forecast account with 12 API requests per hour, per IP, and 1h data resolution. As with SolCast, there are paid account services that may results in better forecasts.
+##### Caching Solcast
+For those who use the free plan and wish to use Solcast with MPC, you may like to cache the output of a Solcast weather forecast request, then reference it in your automated MPC actions:
 
-For example, for a 5 kWp installation:
+```bash
+# Run weather forecast and cache results (Recommended to run this 1-10 times a day, throughout the day)
+curl -i -H 'Content-Type:application/json' -X POST -d {} http://localhost:5000/action/weather-forecast-cache
+
+# Then run your regular MPC call (E.g. every 5 minutes)
+curl -i -H 'Content-Type:application/json' -X POST -d {} http://localhost:5000/action/naive-mpc-optim
+```
+EMHASS will see the saved Solcast cache and use it's data over pulling from Solcast.
+
+`weather_forecast_cache` can also be provided in an optimization to save the forecast results to cache:
+```bash
+# Example of running day-ahead and optimization storing the retrieved Solcast data to cache
+curl -i -H 'Content-Type:application/json' -X POST -d '{"weather_forecast_cache":true}' http://localhost:5000/action/dayahead-optim
 ```
+
+By default, if EMHASS finds a problem with the Solcast cache file, the cache will be automatically deleted. Due to the missing cache, the next optimization will run and pulling data from Solcast.
+If you wish to make sure that a certain optimization will only use the cached data, (otherwise present an error) the runtime parameter `weather_forecast_cache_only` can be used:
+```bash
+# Run the weather forecast action 1-10 times a day 
+curl -i -H 'Content-Type:application/json' -X POST -d {} http://localhost:5000/action/weather-forecast-cache
+
+# Then run your regular MPC call (E.g. every 5 minutes) and make sure it only uses the Solcast cache. (do not pull from Solcast)
+curl -i -H 'Content-Type:application/json' -X POST -d '{"weather_forecast_cache_only":true}' http://localhost:5000/action/naive-mpc-optim
+```
+
+
+#### solar.forecast 
+
+A third method uses the Solar.Forecast service. You will need to set `method=solar.forecast` and use just one parameter `solar_forecast_kwp` (the PV peak installed power in kW) that should be passed at runtime. This will be using the free public Solar.Forecast account with 12 API requests per hour, per IP, and 1h data resolution. As with Solcast, there are paid account services that may results in better forecasts.
+
+For example, for a 5 kWp installation:
+```bash
 curl -i -H "Content-Type:application/json" -X POST -d '{"solar_forecast_kwp":5}' http://localhost:5000/action/dayahead-optim
 ```
 
@@ -129,17 +170,17 @@ The possible dictionnary keys to pass data are:
 - `prod_price_forecast` for the PV production selling price forecast.
 
 For example if using the add-on or the standalone docker installation you can pass this data as list of values to the data dictionnary during the `curl` POST:
-```
+```bash
 curl -i -H "Content-Type: application/json" -X POST -d '{"pv_power_forecast":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 70, 141.22, 246.18, 513.5, 753.27, 1049.89, 1797.93, 1697.3, 3078.93, 1164.33, 1046.68, 1559.1, 2091.26, 1556.76, 1166.73, 1516.63, 1391.13, 1720.13, 820.75, 804.41, 251.63, 79.25, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}' http://localhost:5000/action/dayahead-optim
 ```
 
 You need to be careful here to send the correct amount of data on this list, the correct length. For example, if the data time step is defined to 1h and you are performing a day-ahead optimization, then this list length should be of 24 data points.
 
-### Example using: SolCast forecast + Amber prices
+### Example using: Solcast forecast + Amber prices
 
-If you're using SolCast then you can define the following sensors in your system:
+If you're using Solcast then you can define the following sensors in your system:
 
-```
+```yaml
 sensors:
 
   - platform: rest
@@ -166,11 +207,11 @@ sensors:
           {%- endfor %} {{ (values_all.all)[:48] }}
 ```
 
-With this you can now feed this SolCast forecast to EMHASS along with the mapping of the Amber prices. 
+With this you can now feed this Solcast forecast to EMHASS along with the mapping of the Amber prices. 
 
 A MPC call may look like this for 4 deferrable loads:
 
-```
+```yaml
     post_mpc_optim_solcast: "curl -i -H \"Content-Type: application/json\" -X POST -d '{\"load_cost_forecast\":{{(
           ([states('sensor.amber_general_price')|float(0)] +
           state_attr('sensor.amber_general_forecast', 'forecasts') |map(attribute='per_kwh')|list)[:48])
@@ -184,7 +225,7 @@ A MPC call may look like this for 4 deferrable loads:
 
 Thanks to [@purcell_labs](https://github.com/purcell-lab) for this example configuration.
 
-### Example combining multiple SolCast configurations
+### Example combining multiple Solcast configurations
 
 If you have multiple rooftops, for example for east-west facing solar panels, then you will need to fuze the sensors providing the different forecasts on a single one using templates in Home Assistant. Then feed that single sensor data passing the data as a list when calling the shell command.
 
@@ -192,7 +233,7 @@ Here is a sample configuration to achieve this, thanks to [@gieljnssns](https://
 
 The two sensors using rest sensors:
 
-```
+```yaml
 - platform: rest
   name: "Solcast Forecast huis"
   json_attributes:
@@ -220,7 +261,7 @@ The two sensors using rest sensors:
 
 Then two templates, one for each sensor:
 
-```
+```yaml
     solcast_24hrs_forecast_garage:
       value_template: >-
         {%- set power = state_attr('sensor.solcast_forecast_garage', 'forecasts') | map(attribute='pv_estimate') | list %}
@@ -242,7 +283,7 @@ Then two templates, one for each sensor:
 
 And the fusion of the two sensors:
 
-```
+```yaml
     solcast_24hrs_forecast:
       value_template: >-
         {% set a = states("sensor.solcast_24hrs_forecast_garage")[1:-1].split(',') | map('int') | list %}
@@ -256,7 +297,7 @@ And the fusion of the two sensors:
 
 And finally the shell command:
 
-```
+```yaml
 dayahead_optim: "curl -i -H \"Content-Type:application/json\" -X POST -d '{\"pv_power_forecast\":{{states('sensor.solcast_24hrs_forecast')}}}' http://localhost:5001/action/dayahead-optim"
 ```
 
@@ -269,7 +310,7 @@ After setup the sensors should appear in Home Assistant for raw `today` and `tom
 
 The subsequent shell command to concatenate `today` and `tomorrow` values can be for example:
 
-```
+```yaml
 shell_command:
   trigger_nordpool_forecast: "curl -i -H \"Content-Type: application/json\" -X POST -d '{\"load_cost_forecast\":{{((state_attr('sensor.nordpool', 'raw_today') | map(attribute='value') | list  + state_attr('sensor.nordpool', 'raw_tomorrow') | map(attribute='value') | list))[now().hour:][:24] }},\"prod_price_forecast\":{{((state_attr('sensor.nordpool', 'raw_today') | map(attribute='value') | list  + state_attr('sensor.nordpool', 'raw_tomorrow') | map(attribute='value') | list))[now().hour:][:24]}}}' http://localhost:5000/action/dayahead-optim"
 ```
diff --git a/docs/mlforecaster.md b/docs/mlforecaster.md
index 1de5d38c..82e52008 100644
--- a/docs/mlforecaster.md
+++ b/docs/mlforecaster.md
@@ -35,7 +35,7 @@ The minimum number of `days_to_retrieve` is hard coded to 9 by default. But it i
 - `perform_backtest`: if `True` then a back testing routine is performed to evaluate the performance of the model on the complete train set.
 
 The default values for these parameters are:
-```
+```yaml
 runtimeparams = {
     "days_to_retrieve": 9,
     "model_type": "load_forecast",
@@ -48,7 +48,7 @@ runtimeparams = {
 ```
 
 A correct `curl` call to launch a model fit can look like this:
-```
+```bash
 curl -i -H "Content-Type:application/json" -X POST -d '{}' http://localhost:5000/action/forecast-model-fit
 ```
 
@@ -90,7 +90,7 @@ To obtain a prediction using a previously trained model use the `forecast-model-
 curl -i -H "Content-Type:application/json" -X POST -d '{}' http://localhost:5000/action/forecast-model-predict
 ```
 If needed pass the correct `model_type` like this:
-```
+```bash
 curl -i -H "Content-Type:application/json" -X POST -d '{"model_type": "load_forecast"}' http://localhost:5000/action/forecast-model-predict
 ```
 The resulting forecast DataFrame is shown in the webui.
@@ -108,7 +108,7 @@ The list of parameters needed to set the data publish task is:
 - `model_predict_friendly_name`: the `friendly_name` to be used.
 
 The default values for these parameters are:
-```
+```yaml
 runtimeparams = {
     "model_predict_publish": False,
     "model_predict_entity_id": "sensor.p_load_forecast_custom_model",
@@ -122,7 +122,7 @@ runtimeparams = {
 With a previously fitted model you can use the `forecast-model-tune` end point to tune its hyperparameters. This will be using bayeasian optimization with a wrapper of `optuna` in the `skforecast` module.
 
 You can pass the same parameter you defined during the fit step, but `var_model` has to be defined at least. According to the example, the syntax will be:
-```
+```bash
 curl -i -H "Content-Type:application/json" -X POST -d '{"var_model": "sensor.power_load_no_var_loads"}' http://localhost:5000/action/forecast-model-tune
 ```
 This will launch the optimization routine and optimize the internal hyperparamters of the `scikit-learn` regressor and it will find the optimal number of lags.
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 7aedca92..ca3a8983 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -10,7 +10,7 @@ beautifulsoup4>=4.9.3
 pulp>=2.4
 pyyaml>=5.4.1
 tables==3.9.1
-skforecast==0.12.0
+skforecast==0.12.1
 markupsafe==2.1.5
 Jinja2<3.2
 plotly>=5.6.0
diff --git a/options.json b/options.json
index 7e6aa849..01f83b52 100644
--- a/options.json
+++ b/options.json
@@ -98,6 +98,14 @@
       "set_deferrable_load_single_constant": false
     }
   ],
+  "list_set_deferrable_startup_penalty": [
+    {
+      "set_deferrable_startup_penalty": 0.0
+    },
+    {
+      "set_deferrable_startup_penalty": 0.0
+    }
+  ],
   "load_peak_hours_cost": 0.1907,
   "load_offpeak_hours_cost": 0.1419,
   "production_price_forecast_method": "constant",
@@ -135,6 +143,7 @@
     }
   ],
   "inverter_is_hybrid": false,
+  "compute_curtailment": false,
   "set_use_battery": false,
   "battery_discharge_power_max": 1000,
   "battery_charge_power_max": 1000,
diff --git a/requirements.txt b/requirements.txt
index 84a36928..6034cce4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,7 +10,7 @@ h5py==3.11.0
 pulp>=2.4
 pyyaml>=5.4.1
 tables<=3.9.1
-skforecast==0.12.0
+skforecast==0.12.1
 # web server packages
 flask>=2.0.3
 waitress>=2.1.1
diff --git a/scripts/script_debug_optim.py b/scripts/script_debug_optim.py
index c4ded0f1..44900fc1 100644
--- a/scripts/script_debug_optim.py
+++ b/scripts/script_debug_optim.py
@@ -71,8 +71,8 @@
     # Set special debug cases
     optim_conf.update({'lp_solver': 'PULP_CBC_CMD'}) # set the name of the linear programming solver that will be used. Options are 'PULP_CBC_CMD', 'GLPK_CMD' and 'COIN_CMD'. 
     optim_conf.update({'lp_solver_path': 'empty'})  # set the path to the LP solver, COIN_CMD default is /usr/bin/cbc
-    optim_conf.update({'treat_def_as_semi_cont': [True, True]})
-    optim_conf.update({'set_def_constant': [False, False]})
+    optim_conf.update({'treat_def_as_semi_cont': [True, False]})
+    optim_conf.update({'set_def_constant': [True, False]})
     # optim_conf.update({'P_deferrable_nom': [[500.0, 100.0, 100.0, 500.0], 750.0]})
     
     optim_conf.update({'set_use_battery': False})
@@ -101,9 +101,11 @@
     if show_figures:
         fig_inputs_dah.show()
     
-    vars_to_plot = ['P_deferrable0', 'P_deferrable1','P_grid', 'P_PV', 'P_PV_curtailment']
+    vars_to_plot = ['P_deferrable0', 'P_deferrable1','P_grid', 'P_PV']
     if plant_conf['inverter_is_hybrid']:
         vars_to_plot = vars_to_plot + ['P_hybrid_inverter']
+    if plant_conf['compute_curtailment']:
+        vars_to_plot = vars_to_plot + ['P_PV_curtailment']
     if optim_conf['set_use_battery']:
         vars_to_plot = vars_to_plot + ['P_batt'] + ['SOC_opt']
     fig_res_dah = opt_res_dayahead[vars_to_plot].plot() # 'P_def_start_0', 'P_def_start_1', 'P_def_bin2_0', 'P_def_bin2_1'
diff --git a/setup.py b/setup.py
index bef1fbef..0f83546e 100644
--- a/setup.py
+++ b/setup.py
@@ -19,7 +19,7 @@
 
 setup(
     name='emhass',  # Required
-    version='0.10.1',  # Required
+    version='0.10.2',  # Required
     description='An Energy Management System for Home Assistant',  # Optional
     long_description=long_description,  # Optional
     long_description_content_type='text/markdown',  # Optional (see note above)
@@ -52,7 +52,7 @@
         'pulp>=2.4',
         'pyyaml>=5.4.1',
         'tables<=3.9.1',
-        'skforecast==0.12.0',
+        'skforecast==0.12.1',
         'flask>=2.0.3',
         'waitress>=2.1.1',
         'plotly>=5.6.0'
diff --git a/src/emhass/command_line.py b/src/emhass/command_line.py
index 2d47a305..ee88a4fe 100644
--- a/src/emhass/command_line.py
+++ b/src/emhass/command_line.py
@@ -97,6 +97,8 @@ def set_input_data_dict(emhass_conf: dict, costfun: str,
         # Get PV and load forecasts
         df_weather = fcst.get_weather_forecast(
             method=optim_conf["weather_forecast_method"])
+        if isinstance(df_weather, bool) and not df_weather:
+            return False
         P_PV_forecast = fcst.get_power_from_weather(df_weather)
         P_load_forecast = fcst.get_load_forecast(
             method=optim_conf['load_forecast_method'])
@@ -142,6 +144,8 @@ def set_input_data_dict(emhass_conf: dict, costfun: str,
         # Get PV and load forecasts
         df_weather = fcst.get_weather_forecast(
             method=optim_conf['weather_forecast_method'])
+        if isinstance(df_weather, bool) and not df_weather:
+            return False
         P_PV_forecast = fcst.get_power_from_weather(
             df_weather, set_mix_forecast=True, df_now=df_input_data)
         P_load_forecast = fcst.get_load_forecast(
@@ -243,6 +247,50 @@ def set_input_data_dict(emhass_conf: dict, costfun: str,
     }
     return input_data_dict
 
+def weather_forecast_cache(emhass_conf: dict, params: str, 
+                   runtimeparams: str, logger: logging.Logger) -> bool:
+    """
+    Perform a call to get forecast function, intend to save results to cache.
+
+    :param emhass_conf: Dictionary containing the needed emhass paths
+    :type emhass_conf: dict
+    :param params: Configuration parameters passed from data/options.json
+    :type params: str
+    :param runtimeparams: Runtime optimization parameters passed as a dictionary
+    :type runtimeparams: str
+    :param logger: The passed logger object
+    :type logger: logging object
+    :return: A bool for function completion
+    :rtype: bool
+
+    """
+    
+    # Parsing yaml
+    retrieve_hass_conf, optim_conf, plant_conf = utils.get_yaml_parse(
+        emhass_conf, use_secrets=True, params=params)
+    
+    # Treat runtimeparams
+    params, retrieve_hass_conf, optim_conf, plant_conf = utils.treat_runtimeparams(
+        runtimeparams, params, retrieve_hass_conf, optim_conf, plant_conf, "forecast", logger)
+    
+    # Make sure weather_forecast_cache is true
+    if (params != None) and (params != "null"):
+        params = json.loads(params)
+    else:
+        params = {}
+    params["passed_data"]["weather_forecast_cache"] = True
+    params = json.dumps(params)
+
+    # Create Forecast object
+    fcst = Forecast(retrieve_hass_conf, optim_conf, plant_conf,
+                params, emhass_conf, logger)
+
+    result = fcst.get_weather_forecast(optim_conf["weather_forecast_method"])
+    if isinstance(result, bool) and not result:
+        return False
+
+    return True
+
 
 def perfect_forecast_optim(input_data_dict: dict, logger: logging.Logger,
                            save_data_to_file: Optional[bool] = True, 
@@ -801,19 +849,35 @@ def publish_data(input_data_dict: dict, logger: logging.Logger,
     )
     cols_published = ["P_PV", "P_Load"]
     # Publish PV curtailment
-    custom_pv_curtailment_id = params["passed_data"]["custom_pv_curtailment_id"]
-    input_data_dict["rh"].post_data(
-        opt_res_latest["P_PV_curtailment"],
-        idx_closest,
-        custom_pv_curtailment_id["entity_id"],
-        custom_pv_curtailment_id["unit_of_measurement"],
-        custom_pv_curtailment_id["friendly_name"],
-        type_var="power",
-        publish_prefix=publish_prefix,
-        save_entities=entity_save,
-        dont_post=dont_post
-    )
-    cols_published = cols_published + ["P_PV_curtailment"]
+    if input_data_dict["fcst"].plant_conf['compute_curtailment']:
+        custom_pv_curtailment_id = params["passed_data"]["custom_pv_curtailment_id"]
+        input_data_dict["rh"].post_data(
+            opt_res_latest["P_PV_curtailment"],
+            idx_closest,
+            custom_pv_curtailment_id["entity_id"],
+            custom_pv_curtailment_id["unit_of_measurement"],
+            custom_pv_curtailment_id["friendly_name"],
+            type_var="power",
+            publish_prefix=publish_prefix,
+            save_entities=entity_save,
+            dont_post=dont_post
+        )
+        cols_published = cols_published + ["P_PV_curtailment"]
+    # Publish P_hybrid_inverter
+    if input_data_dict["fcst"].plant_conf['inverter_is_hybrid']:
+        custom_hybrid_inverter_id = params["passed_data"]["custom_hybrid_inverter_id"]
+        input_data_dict["rh"].post_data(
+            opt_res_latest["P_hybrid_inverter"],
+            idx_closest,
+            custom_hybrid_inverter_id["entity_id"],
+            custom_hybrid_inverter_id["unit_of_measurement"],
+            custom_hybrid_inverter_id["friendly_name"],
+            type_var="power",
+            publish_prefix=publish_prefix,
+            save_entities=entity_save,
+            dont_post=dont_post
+        )
+        cols_published = cols_published + ["P_hybrid_inverter"]
     # Publish deferrable loads
     custom_deferrable_forecast_id = params["passed_data"][
         "custom_deferrable_forecast_id"
@@ -1126,7 +1190,7 @@ def main():
         logger.error("Could not find emhass/src foulder in: " + str(root_path))
         logger.error("Try setting emhass root path with --root")
         return False
-    # Additionnal argument
+    # Additional argument
     try:
         parser.add_argument(
             "--version",
diff --git a/src/emhass/forecast.py b/src/emhass/forecast.py
index 789a1208..7058c220 100644
--- a/src/emhass/forecast.py
+++ b/src/emhass/forecast.py
@@ -182,6 +182,7 @@ def get_weather_forecast(self, method: Optional[str] = 'scrapper',
         
         """
         csv_path  = self.emhass_conf['data_path'] / csv_path
+        w_forecast_cache_path = os.path.abspath(self.emhass_conf['data_path'] / "weather_forecast_data.pkl")
 
         self.logger.info("Retrieving weather forecast data using method = "+method)
         self.weather_forecast_method = method # Saving this attribute for later use to identify csv method usage
@@ -223,40 +224,97 @@ def get_weather_forecast(self, method: Optional[str] = 'scrapper',
             data['relative_humidity'] = raw_data['Relative Humidity (%)']
             data['precipitable_water'] = pvlib.atmosphere.gueymard94_pw(
                 data['temp_air'], data['relative_humidity'])
-        elif method == 'solcast': # using solcast API
-            # Retrieve data from the solcast API
-            if 'solcast_api_key' not in self.retrieve_hass_conf:
-                self.logger.warning("The solcast_api_key parameter was not defined, using dummy values for testing")
-                self.retrieve_hass_conf['solcast_api_key'] = "123456"
-            if 'solcast_rooftop_id' not in self.retrieve_hass_conf:
-                self.logger.warning("The solcast_rooftop_id parameter was not defined, using dummy values for testing")
-                self.retrieve_hass_conf['solcast_rooftop_id'] = "123456"
-            headers = {
-                "Authorization": "Bearer " + self.retrieve_hass_conf['solcast_api_key'],
-                "content-type": "application/json",
-                }
-            days_solcast = int(len(self.forecast_dates)*self.freq.seconds/3600)
-            url = "https://api.solcast.com.au/rooftop_sites/"+self.retrieve_hass_conf['solcast_rooftop_id']+"/forecasts?hours="+str(days_solcast)
-            response = get(url, headers=headers)
-            '''import bz2 # Uncomment to save a serialized data for tests
-            import _pickle as cPickle
-            with bz2.BZ2File("data/test_response_solcast_get_method.pbz2", "w") as f: 
-                cPickle.dump(response, f)'''
-            data = response.json()
-            data_list = []
-            for elm in data['forecasts']:
-                data_list.append(elm['pv_estimate']*1000) # Converting kW to W
-            # Check if the retrieved data has the correct length
-            if len(data_list) < len(self.forecast_dates):
-                self.logger.error("Not enough data retrived from SolCast service, try increasing the time step or use MPC")
+        elif method == 'solcast': # using Solcast API
+            # Check if weather_forecast_cache is true or if forecast_data file does not exist
+            if self.params["passed_data"]["weather_forecast_cache"] or not os.path.isfile(w_forecast_cache_path):
+                # Check if weather_forecast_cache_only is true, if so produce error for not finding cache file
+                if not self.params["passed_data"]["weather_forecast_cache_only"]:
+                    # Retrieve data from the Solcast API
+                    if 'solcast_api_key' not in self.retrieve_hass_conf:
+                        self.logger.error("The solcast_api_key parameter was not defined")
+                        return False
+                    if 'solcast_rooftop_id' not in self.retrieve_hass_conf:
+                        self.logger.error("The solcast_rooftop_id parameter was not defined")
+                        return False
+                    headers = {
+                        'User-Agent': 'EMHASS',
+                        "Authorization": "Bearer " + self.retrieve_hass_conf['solcast_api_key'],
+                        "content-type": "application/json",
+                        }
+                    days_solcast = int(len(self.forecast_dates)*self.freq.seconds/3600)
+                    # If weather_forecast_cache, set request days as twice as long to avoid length issues (add a buffer) 
+                    if self.params["passed_data"]["weather_forecast_cache"]:
+                        days_solcast = min((days_solcast * 2), 336)
+                    url = "https://api.solcast.com.au/rooftop_sites/"+self.retrieve_hass_conf['solcast_rooftop_id']+"/forecasts?hours="+str(days_solcast)
+                    response = get(url, headers=headers)
+                    '''import bz2 # Uncomment to save a serialized data for tests
+                    import _pickle as cPickle
+                    with bz2.BZ2File("data/test_response_solcast_get_method.pbz2", "w") as f: 
+                        cPickle.dump(response, f)'''
+                    # Verify the request passed
+                    if int(response.status_code) == 200:
+                        data = response.json()
+                    elif int(response.status_code) == 402 or int(response.status_code) == 429:
+                        self.logger.error("Solcast error: May have exceeded your subscription limit.")
+                        return False  
+                    elif int(response.status_code) >= 400 or int(response.status_code) >= 202:
+                        self.logger.error("Solcast error: There was a issue with the solcast request, check solcast API key and rooftop ID.")
+                        self.logger.error("Solcast error: Check that your subscription is valid and your network can connect to Solcast.")
+                        return False  
+                    data_list = []
+                    for elm in data['forecasts']:
+                        data_list.append(elm['pv_estimate']*1000) # Converting kW to W
+                    # Check if the retrieved data has the correct length
+                    if len(data_list) < len(self.forecast_dates):
+                        self.logger.error("Not enough data retried from Solcast service, try increasing the time step or use MPC.")
+                    else:
+                        # If runtime weather_forecast_cache is true save forecast result to file as cache
+                        if self.params["passed_data"]["weather_forecast_cache"]:
+                            # Add x2 forecast periods for cached results. This adds a extra delta_forecast amount of days for a buffer
+                            cached_forecast_dates =  self.forecast_dates.union(pd.date_range(self.forecast_dates[-1], periods=(len(self.forecast_dates) +1), freq=self.freq)[1:])
+                            cache_data_list = data_list[0:len(cached_forecast_dates)]
+                            cache_data_dict = {'ts':cached_forecast_dates, 'yhat':cache_data_list}
+                            data_cache = pd.DataFrame.from_dict(cache_data_dict)
+                            data_cache.set_index('ts', inplace=True)
+                            with open(w_forecast_cache_path, "wb") as file:    
+                                cPickle.dump(data_cache, file)
+                            if not os.path.isfile(w_forecast_cache_path):
+                                self.logger.warning("Solcast forecast data could not be saved to file.")
+                            else:
+                                self.logger.info("Saved the Solcast results to cache, for later reference.")    
+                        # Trim request results to forecast_dates        
+                        data_list = data_list[0:len(self.forecast_dates)]
+                        data_dict = {'ts':self.forecast_dates, 'yhat':data_list}
+                        # Define DataFrame
+                        data = pd.DataFrame.from_dict(data_dict)
+                        # Define index
+                        data.set_index('ts', inplace=True)
+            # Else, notify user to update cache
+                else:
+                    self.logger.error("Unable to obtain Solcast cache file.")
+                    self.logger.error("Try running optimization again with 'weather_forecast_cache_only': false")
+                    self.logger.error("Optionally, obtain new Solcast cache with runtime parameter 'weather_forecast_cache': true in an optimization, or run the `forecast-cache` action, to pull new data from Solcast and cache.")
+                    return False
+            # Else, open stored weather_forecast_data.pkl file for previous forecast data (cached data)
             else:
-                # Ensure correct length
-                data_list = data_list[0:len(self.forecast_dates)]
-                # Define DataFrame
-                data_dict = {'ts':self.forecast_dates, 'yhat':data_list}
-                data = pd.DataFrame.from_dict(data_dict)
-                # Define index
-                data.set_index('ts', inplace=True)
+                with open(w_forecast_cache_path, "rb") as file:
+                    data = cPickle.load(file)
+                    if not isinstance(data, pd.DataFrame) or len(data) < len(self.forecast_dates):
+                        self.logger.error("There has been a error obtaining cached Solcast forecast data.")
+                        self.logger.error("Try running optimization again with 'weather_forecast_cache': true, or run action `forecast-cache`, to pull new data from Solcast and cache.")
+                        self.logger.warning("Removing old Solcast cache file. Next optimization will pull data from Solcast, unless 'weather_forecast_cache_only': true")
+                        os.remove(w_forecast_cache_path)
+                        return False
+                    # Filter cached forecast data to match current forecast_dates start-end range (reduce forecast Dataframe size to appropriate length)
+                    if self.forecast_dates[0] in data.index and self.forecast_dates[-1] in data.index:
+                        data = data.loc[self.forecast_dates[0]:self.forecast_dates[-1]]
+                        self.logger.info("Retrieved Solcast data from the previously saved cache.")
+                    else:
+                        self.logger.error("Unable to obtain cached Solcast forecast data within the requested timeframe range.")
+                        self.logger.error("Try running optimization again (not using cache). Optionally, add runtime parameter 'weather_forecast_cache': true to pull new data from Solcast and cache.")
+                        self.logger.warning("Removing old Solcast cache file. Next optimization will pull data from Solcast, unless 'weather_forecast_cache_only': true")
+                        os.remove(w_forecast_cache_path)
+                        return False    
         elif method == 'solar.forecast': # using the solar.forecast API
             # Retrieve data from the solar.forecast API
             if 'solar_forecast_kwp' not in self.retrieve_hass_conf:
@@ -423,9 +481,9 @@ def get_power_from_weather(self, df_weather: pd.DataFrame,
                 # Setting the main parameters of the PV plant
                 location = Location(latitude=self.lat, longitude=self.lon)
                 temp_params = TEMPERATURE_MODEL_PARAMETERS['sapm']['close_mount_glass_glass']
-                cec_modules = bz2.BZ2File(self.emhass_conf['root_path'] / 'data/cec_modules.pbz2', "rb")
+                cec_modules = bz2.BZ2File(self.emhass_conf['root_path'] / 'data' / 'cec_modules.pbz2', "rb")
                 cec_modules = cPickle.load(cec_modules)
-                cec_inverters = bz2.BZ2File(self.emhass_conf['root_path'] / 'data/cec_inverters.pbz2', "rb")
+                cec_inverters = bz2.BZ2File(self.emhass_conf['root_path'] / 'data' /  'cec_inverters.pbz2', "rb")
                 cec_inverters = cPickle.load(cec_inverters)
                 if type(self.plant_conf['module_model']) == list:
                     P_PV_forecast = pd.Series(0, index=df_weather.index)
@@ -838,5 +896,4 @@ def get_prod_price_forecast(self, df_final: pd.DataFrame, method: Optional[str]
         else:
             self.logger.error("Passed method is not valid")
             return False
-        return df_final
-    
+        return df_final
\ No newline at end of file
diff --git a/src/emhass/optimization.py b/src/emhass/optimization.py
index 3795710a..67b21f74 100644
--- a/src/emhass/optimization.py
+++ b/src/emhass/optimization.py
@@ -272,12 +272,20 @@ def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: n
                     rhs = 0)
                 for i in set_I}
         else:
-            constraints = {"constraint_main1_{}".format(i) :
-                plp.LpConstraint(
-                    e = P_PV[i] - P_PV_curtailment[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] + P_sto_pos[i] + P_sto_neg[i],
-                    sense = plp.LpConstraintEQ,
-                    rhs = 0)
-                for i in set_I}
+            if self.plant_conf['compute_curtailment']:
+                constraints = {"constraint_main2_{}".format(i) :
+                    plp.LpConstraint(
+                        e = P_PV[i] - P_PV_curtailment[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] + P_sto_pos[i] + P_sto_neg[i],
+                        sense = plp.LpConstraintEQ,
+                        rhs = 0)
+                    for i in set_I}
+            else:
+                constraints = {"constraint_main3_{}".format(i) :
+                    plp.LpConstraint(
+                        e = P_PV[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] + P_sto_pos[i] + P_sto_neg[i],
+                        sense = plp.LpConstraintEQ,
+                        rhs = 0)
+                    for i in set_I}
         
         # Constraint for hybrid inverter and curtailment cases
         if type(self.plant_conf['module_model']) == list:
@@ -312,12 +320,13 @@ def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: n
                     rhs = 0)
                 for i in set_I})
         else:
-            constraints.update({"constraint_curtailment_{}".format(i) :
-                plp.LpConstraint(
-                    e = P_PV_curtailment[i] - P_PV[i],
-                    sense = plp.LpConstraintLE,
-                    rhs = 0)
-                for i in set_I})
+            if self.plant_conf['compute_curtailment']:
+                constraints.update({"constraint_curtailment_{}".format(i) :
+                    plp.LpConstraint(
+                        e = P_PV_curtailment[i] - max(P_PV[i],0),
+                        sense = plp.LpConstraintLE,
+                        rhs = 0)
+                    for i in set_I})
             
         # Constraint for sequence of deferrable
         # WARNING: This is experimental, formulation seems correct but feasibility problems.
@@ -363,13 +372,13 @@ def create_matrix(input_list, n):
         # Two special constraints just for a self-consumption cost function
         if self.costfun == 'self-consumption':
             if type_self_conso == 'maxmin': # maxmin linear problem
-                constraints.update({"constraint_selfcons_PV_{}".format(i) : 
+                constraints.update({"constraint_selfcons_PV1_{}".format(i) : 
                     plp.LpConstraint(
                         e = SC[i] - P_PV[i],
                         sense = plp.LpConstraintLE,
                         rhs = 0)
                     for i in set_I})
-                constraints.update({"constraint_selfcons_PV_{}".format(i) : 
+                constraints.update({"constraint_selfcons_PV2_{}".format(i) : 
                     plp.LpConstraint(
                         e = SC[i] - P_load[i] - P_def_sum[i],
                         sense = plp.LpConstraintLE,
@@ -439,41 +448,27 @@ def create_matrix(input_list, n):
                             sense=plp.LpConstraintLE,
                             rhs=0)
                         for i in set_I})
-                # Treat the number of starts for a deferrable load
-                if self.optim_conf['set_def_constant'][k]:
-                    constraints.update({"constraint_pdef{}_start1_{}".format(k, i) : 
-                        plp.LpConstraint(
-                            e=P_deferrable[k][i] - P_def_bin2[k][i]*M,
-                            sense=plp.LpConstraintLE,
-                            rhs=0)
-                        for i in set_I})
-                    constraints.update({"constraint_pdef{}_start2_{}".format(k, i): 
-                        plp.LpConstraint(
-                            e=P_def_start[k][i] - P_def_bin2[k][i] + (P_def_bin2[k][i-1] if i-1 >= 0 else 0),
-                            sense=plp.LpConstraintGE,
-                            rhs=0)
-                        for i in set_I})
-                    constraints.update({"constraint_pdef{}_start3".format(k) :
-                    plp.LpConstraint(
-                        e = plp.lpSum(P_def_start[k][i] for i in set_I),
-                        sense = plp.LpConstraintEQ,
-                        rhs = 1)
-                    })
-                # Treat deferrable load as a semi-continuous variable
-                if self.optim_conf['treat_def_as_semi_cont'][k]:
-                    constraints.update({"constraint_pdef{}_semicont1_{}".format(k, i) : 
-                        plp.LpConstraint(
-                            e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i],
-                            sense=plp.LpConstraintGE,
-                            rhs=0)
-                        for i in set_I})
-                    constraints.update({"constraint_pdef{}_semicont2_{}".format(k, i) :
-                        plp.LpConstraint(
-                            e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i],
-                            sense=plp.LpConstraintLE,
-                            rhs=0)
-                        for i in set_I})
-                # Treat the number of starts for a deferrable load
+                # Treat the number of starts for a deferrable load (old method, kept here just in case)
+                # if self.optim_conf['set_def_constant'][k]:
+                #     constraints.update({"constraint_pdef{}_start1_{}".format(k, i) : 
+                #         plp.LpConstraint(
+                #             e=P_deferrable[k][i] - P_def_bin2[k][i]*M,
+                #             sense=plp.LpConstraintLE,
+                #             rhs=0)
+                #         for i in set_I})
+                #     constraints.update({"constraint_pdef{}_start2_{}".format(k, i): 
+                #         plp.LpConstraint(
+                #             e=P_def_start[k][i] - P_def_bin2[k][i] + (P_def_bin2[k][i-1] if i-1 >= 0 else 0),
+                #             sense=plp.LpConstraintGE,
+                #             rhs=0)
+                #         for i in set_I})
+                #     constraints.update({"constraint_pdef{}_start3".format(k) :
+                #     plp.LpConstraint(
+                #         e = plp.lpSum(P_def_start[k][i] for i in set_I),
+                #         sense = plp.LpConstraintEQ,
+                #         rhs = 1)
+                #     })
+                # Treat the number of starts for a deferrable load (new method considering current state)
                 current_state = 0
                 if ("def_current_state" in self.optim_conf and len(self.optim_conf["def_current_state"]) > k):
                     current_state = 1 if self.optim_conf["def_current_state"][k] else 0
@@ -644,7 +639,8 @@ def create_matrix(input_list, n):
             opt_tp["SOC_opt"] = SOC_opt
         if self.plant_conf['inverter_is_hybrid']:
             opt_tp["P_hybrid_inverter"] = [P_hybrid_inverter[i].varValue for i in set_I]
-        opt_tp["P_PV_curtailment"] = [P_PV_curtailment[i].varValue for i in set_I]
+        if self.plant_conf['compute_curtailment']:
+            opt_tp["P_PV_curtailment"] = [P_PV_curtailment[i].varValue for i in set_I]
         opt_tp.index = data_opt.index
 
         # Lets compute the optimal cost function
diff --git a/src/emhass/utils.py b/src/emhass/utils.py
index 723daf23..c899b090 100644
--- a/src/emhass/utils.py
+++ b/src/emhass/utils.py
@@ -11,6 +11,7 @@
 import pandas as pd
 import yaml
 import pytz
+import ast
 
 import plotly.express as px
 
@@ -166,6 +167,11 @@ def treat_runtimeparams(runtimeparams: str, params: str, retrieve_hass_conf: dic
             "unit_of_measurement": "W",
             "friendly_name": "PV Power Curtailment",
         },
+        "custom_hybrid_inverter_id": {
+            "entity_id": "sensor.p_hybrid_inverter",
+            "unit_of_measurement": "W",
+            "friendly_name": "PV Hybrid Inverter",
+        },
         "custom_batt_forecast_id": {
             "entity_id": "sensor.p_batt_forecast",
             "unit_of_measurement": "W",
@@ -247,7 +253,6 @@ def treat_runtimeparams(runtimeparams: str, params: str, retrieve_hass_conf: dic
             if "target" in runtimeparams:
                 target = runtimeparams["target"]
                 params["passed_data"]["target"] = target
-
         # Treating special data passed for MPC control case
         if set_type == "naive-mpc-optim":
             if "prediction_horizon" not in runtimeparams.keys():
@@ -303,6 +308,18 @@ def treat_runtimeparams(runtimeparams: str, params: str, retrieve_hass_conf: dic
         # Treat passed forecast data lists
         list_forecast_key = ['pv_power_forecast', 'load_power_forecast', 'load_cost_forecast', 'prod_price_forecast']
         forecast_methods = ['weather_forecast_method', 'load_forecast_method', 'load_cost_forecast_method', 'prod_price_forecast_method']
+        # Param to save forecast cache (i.e. Solcast)
+        if "weather_forecast_cache" not in runtimeparams.keys():
+            weather_forecast_cache = False
+        else:
+            weather_forecast_cache = runtimeparams["weather_forecast_cache"]
+        params["passed_data"]["weather_forecast_cache"] = weather_forecast_cache  
+        # Param to make sure optimization only uses cached data. (else produce error)
+        if "weather_forecast_cache_only" not in runtimeparams.keys():
+            weather_forecast_cache_only = False
+        else:
+            weather_forecast_cache_only = runtimeparams["weather_forecast_cache_only"]
+        params["passed_data"]["weather_forecast_cache_only"] = weather_forecast_cache_only  
         for method, forecast_key in enumerate(list_forecast_key):
             if forecast_key in runtimeparams.keys():
                 if type(runtimeparams[forecast_key]) == list and len(runtimeparams[forecast_key]) >= len(forecast_dates):
@@ -357,14 +374,12 @@ def treat_runtimeparams(runtimeparams: str, params: str, retrieve_hass_conf: dic
         if "perform_backtest" not in runtimeparams.keys():
             perform_backtest = False
         else:
-            perform_backtest = eval(str(runtimeparams["perform_backtest"]).capitalize())
+            perform_backtest = ast.literal_eval(str(runtimeparams["perform_backtest"]).capitalize())
         params["passed_data"]["perform_backtest"] = perform_backtest
         if "model_predict_publish" not in runtimeparams.keys():
             model_predict_publish = False
         else:
-            model_predict_publish = eval(
-                str(runtimeparams["model_predict_publish"]).capitalize()
-            )
+            model_predict_publish = ast.literal_eval(str(runtimeparams["model_predict_publish"]).capitalize())
         params["passed_data"]["model_predict_publish"] = model_predict_publish
         if "model_predict_entity_id" not in runtimeparams.keys():
             model_predict_entity_id = "sensor.p_load_forecast_custom_model"
@@ -421,12 +436,16 @@ def treat_runtimeparams(runtimeparams: str, params: str, retrieve_hass_conf: dic
             optim_conf["def_current_state"] = [bool(s) for s in runtimeparams["def_current_state"]]
         if "treat_def_as_semi_cont" in runtimeparams.keys():
             optim_conf["treat_def_as_semi_cont"] = [
-                eval(str(k).capitalize())
+                ast.literal_eval(str(k).capitalize())
                 for k in runtimeparams["treat_def_as_semi_cont"]
             ]
         if "set_def_constant" in runtimeparams.keys():
             optim_conf["set_def_constant"] = [
-                eval(str(k).capitalize()) for k in runtimeparams["set_def_constant"]
+                ast.literal_eval(str(k).capitalize()) for k in runtimeparams["set_def_constant"]
+            ]
+        if "def_start_penalty" in runtimeparams.keys():
+            optim_conf["def_start_penalty"] = [
+                ast.literal_eval(str(k).capitalize()) for k in runtimeparams["def_start_penalty"]
             ]
         if "solcast_api_key" in runtimeparams.keys():
             retrieve_hass_conf["solcast_api_key"] = runtimeparams["solcast_api_key"]
@@ -452,8 +471,16 @@ def treat_runtimeparams(runtimeparams: str, params: str, retrieve_hass_conf: dic
         if 'continual_publish' in runtimeparams.keys():
             retrieve_hass_conf['continual_publish'] = bool(runtimeparams['continual_publish'])  
         # Treat plant configuration parameters passed at runtime
+        if "SOCmin" in runtimeparams.keys():
+            plant_conf["SOCmin"] = runtimeparams["SOCmin"]
+        if "SOCmax" in runtimeparams.keys():
+            plant_conf["SOCmax"] = runtimeparams["SOCmax"]
         if "SOCtarget" in runtimeparams.keys():
             plant_conf["SOCtarget"] = runtimeparams["SOCtarget"]
+        if "Pd_max" in runtimeparams.keys():
+            plant_conf["Pd_max"] = runtimeparams["Pd_max"]
+        if "Pc_max" in runtimeparams.keys():
+            plant_conf["Pc_max"] = runtimeparams["Pc_max"]
         # Treat custom entities id's and friendly names for variables
         if "custom_pv_forecast_id" in runtimeparams.keys():
             params["passed_data"]["custom_pv_forecast_id"] = runtimeparams[
@@ -467,6 +494,10 @@ def treat_runtimeparams(runtimeparams: str, params: str, retrieve_hass_conf: dic
             params["passed_data"]["custom_pv_curtailment_id"] = runtimeparams[
                 "custom_pv_curtailment_id"
             ]
+        if "custom_hybrid_inverter_id" in runtimeparams.keys():
+            params["passed_data"]["custom_hybrid_inverter_id"] = runtimeparams[
+                "custom_hybrid_inverter_id"
+            ]
         if "custom_batt_forecast_id" in runtimeparams.keys():
             params["passed_data"]["custom_batt_forecast_id"] = runtimeparams[
                 "custom_batt_forecast_id"
@@ -754,9 +785,7 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int,
         params["retrieve_hass_conf"]["var_load"] = options.get("sensor_power_load_no_var_loads", params["retrieve_hass_conf"]["var_load"])
         params["retrieve_hass_conf"]["load_negative"] = options.get("load_negative", params["retrieve_hass_conf"]["load_negative"])
         params["retrieve_hass_conf"]["set_zero_min"] = options.get("set_zero_min", params["retrieve_hass_conf"]["set_zero_min"])
-        params["retrieve_hass_conf"]["var_replace_zero"] = [
-            options.get("sensor_power_photovoltaics", params["retrieve_hass_conf"]["var_replace_zero"])
-        ]
+        params["retrieve_hass_conf"]["var_replace_zero"] = [options.get("sensor_power_photovoltaics", params["retrieve_hass_conf"]["var_replace_zero"])]
         params["retrieve_hass_conf"]["var_interp"] = [
             options.get("sensor_power_photovoltaics", params["retrieve_hass_conf"]["var_PV"]),
             options.get("sensor_power_load_no_var_loads", params["retrieve_hass_conf"]["var_load"])
@@ -773,20 +802,11 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int,
         params["optim_conf"]["set_use_battery"] = options.get("set_use_battery", params["optim_conf"]["set_use_battery"])
         params["optim_conf"]["num_def_loads"] = options.get("number_of_deferrable_loads", params["optim_conf"]["num_def_loads"])
         if options.get("list_nominal_power_of_deferrable_loads", None) != None:
-            params["optim_conf"]["P_deferrable_nom"] = [
-                i["nominal_power_of_deferrable_loads"]
-                for i in options.get("list_nominal_power_of_deferrable_loads")
-            ]
+            params["optim_conf"]["P_deferrable_nom"] = [i["nominal_power_of_deferrable_loads"] for i in options.get("list_nominal_power_of_deferrable_loads")]
         if options.get("list_operating_hours_of_each_deferrable_load", None) != None:
-            params["optim_conf"]["def_total_hours"] = [
-                i["operating_hours_of_each_deferrable_load"]
-                for i in options.get("list_operating_hours_of_each_deferrable_load")
-            ]
+            params["optim_conf"]["def_total_hours"] = [i["operating_hours_of_each_deferrable_load"] for i in options.get("list_operating_hours_of_each_deferrable_load")]
         if options.get("list_treat_deferrable_load_as_semi_cont", None) != None:
-            params["optim_conf"]["treat_def_as_semi_cont"] = [
-                i["treat_deferrable_load_as_semi_cont"]
-                for i in options.get("list_treat_deferrable_load_as_semi_cont")
-            ]
+            params["optim_conf"]["treat_def_as_semi_cont"] = [i["treat_deferrable_load_as_semi_cont"] for i in options.get("list_treat_deferrable_load_as_semi_cont")]
         params["optim_conf"]["weather_forecast_method"] = options.get("weather_forecast_method", params["optim_conf"]["weather_forecast_method"])
         # Update optional param secrets
         if params["optim_conf"]["weather_forecast_method"] == "solcast":
@@ -798,19 +818,14 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int,
         params["optim_conf"]["delta_forecast"] = options.get("delta_forecast_daily", params["optim_conf"]["delta_forecast"])
         params["optim_conf"]["load_cost_forecast_method"] = options.get("load_cost_forecast_method", params["optim_conf"]["load_cost_forecast_method"])
         if options.get("list_set_deferrable_load_single_constant", None) != None:
-            params["optim_conf"]["set_def_constant"] = [
-                i["set_deferrable_load_single_constant"]
-                for i in options.get("list_set_deferrable_load_single_constant")
-            ]
+            params["optim_conf"]["set_def_constant"] = [i["set_deferrable_load_single_constant"] for i in options.get("list_set_deferrable_load_single_constant")]
+        
+        if options.get("list_set_deferrable_startup_penalty", None) != None:
+            params["optim_conf"]["def_start_penalty"] = [i["set_deferrable_startup_penalty"] for i in options.get("list_set_deferrable_startup_penalty")]
+        
         if (options.get("list_peak_hours_periods_start_hours", None) != None and options.get("list_peak_hours_periods_end_hours", None) != None):
-            start_hours_list = [
-                i["peak_hours_periods_start_hours"]
-                for i in options["list_peak_hours_periods_start_hours"]
-            ]
-            end_hours_list = [
-                i["peak_hours_periods_end_hours"]
-                for i in options["list_peak_hours_periods_end_hours"]
-            ]
+            start_hours_list = [i["peak_hours_periods_start_hours"] for i in options["list_peak_hours_periods_start_hours"]]
+            end_hours_list = [i["peak_hours_periods_end_hours"] for i in options["list_peak_hours_periods_end_hours"]]
             num_peak_hours = len(start_hours_list)
             list_hp_periods_list = [{'period_hp_'+str(i+1):[{'start':start_hours_list[i]},{'end':end_hours_list[i]}]} for i in range(num_peak_hours)]
             params['optim_conf']['list_hp_periods'] = list_hp_periods_list
@@ -848,6 +863,7 @@ def build_params(params: dict, params_secrets: dict, options: dict, addon: int,
         if options.get('list_strings_per_inverter',None) != None: 
             params['plant_conf']['strings_per_inverter'] = [i['strings_per_inverter'] for i in options.get('list_strings_per_inverter')]
         params["plant_conf"]["inverter_is_hybrid"] = options.get("inverter_is_hybrid", params["plant_conf"]["inverter_is_hybrid"])
+        params["plant_conf"]["compute_curtailment"] = options.get("compute_curtailment", params["plant_conf"]["compute_curtailment"])
         params['plant_conf']['Pd_max'] = options.get('battery_discharge_power_max', params['plant_conf']['Pd_max']) 
         params['plant_conf']['Pc_max'] = options.get('battery_charge_power_max', params['plant_conf']['Pc_max'])
         params['plant_conf']['eta_disch'] = options.get('battery_discharge_efficiency', params['plant_conf']['eta_disch'])
diff --git a/src/emhass/web_server.py b/src/emhass/web_server.py
index d8124c04..82439795 100644
--- a/src/emhass/web_server.py
+++ b/src/emhass/web_server.py
@@ -12,7 +12,7 @@
 
 from emhass.command_line import set_input_data_dict
 from emhass.command_line import perfect_forecast_optim, dayahead_forecast_optim, naive_mpc_optim
-from emhass.command_line import forecast_model_fit, forecast_model_predict, forecast_model_tune
+from emhass.command_line import forecast_model_fit, forecast_model_predict, forecast_model_tune, weather_forecast_cache
 from emhass.command_line import regressor_model_fit, regressor_model_predict
 from emhass.command_line import publish_data, continual_publish
 from emhass.utils import get_injection_dict, get_injection_dict_forecast_model_fit, \
@@ -106,6 +106,17 @@ def action_call(action_name):
     if runtimeparams is not None and runtimeparams != '{}':
         app.logger.info("Passed runtime parameters: " + str(runtimeparams))
     runtimeparams = json.dumps(runtimeparams)
+    
+    # Run action if weather_forecast_cache
+    if action_name == 'weather-forecast-cache':
+        ActionStr = " >> Performing weather forecast, try to caching result"
+        app.logger.info(ActionStr)
+        weather_forecast_cache(emhass_conf, params, runtimeparams, app.logger)
+        msg = f'EMHASS >> Weather Forecast has run and results possibly cached... \n'
+        if not checkFileLog(ActionStr):
+            return make_response(msg, 201)
+        return make_response(grabLog(ActionStr), 400)
+
     ActionStr = " >> Setting input data dict"
     app.logger.info(ActionStr)
     input_data_dict = set_input_data_dict(emhass_conf, costfun, 
@@ -459,15 +470,14 @@ def action_call(action_name):
     app.logger.addHandler(fileLogger)   
     clearFileLog() #Clear Action File logger file, ready for new instance
 
-
-    #If entity_path exists, remove any entity/metadata files 
+    # If entity_path exists, remove any entity/metadata files 
     entity_path = emhass_conf['data_path'] / "entities"
     if os.path.exists(entity_path): 
         entity_pathContents = os.listdir(entity_path)
         if len(entity_pathContents) > 0:
             for entity in entity_pathContents:
                 os.remove(entity_path / entity)
-
+        
     # Initialise continual publish thread list
     continual_publish_thread = []
     


Test patch:
diff --git a/data/test_df_final.pkl b/data/test_df_final.pkl
index 2f81e539..5c07a96b 100644
Binary files a/data/test_df_final.pkl and b/data/test_df_final.pkl differ
diff --git a/tests/test_forecast.py b/tests/test_forecast.py
index 9163c450..0b656fc3 100644
--- a/tests/test_forecast.py
+++ b/tests/test_forecast.py
@@ -2,6 +2,7 @@
 # -*- coding: utf-8 -*-
 
 import unittest
+import os
 import requests_mock
 import pandas as pd
 import pathlib, pickle, json, copy, yaml
@@ -147,6 +148,11 @@ def test_get_weather_forecast_scrapper_method_mock(self):
             self.assertEqual(len(df_weather_scrap), len(P_PV_forecast))
 
     def test_get_weather_forecast_solcast_method_mock(self):
+        self.fcst.params = {'passed_data': {'weather_forecast_cache': False, 'weather_forecast_cache_only': False}}
+        self.fcst.retrieve_hass_conf['solcast_api_key'] = "123456"
+        self.fcst.retrieve_hass_conf['solcast_rooftop_id'] =  "123456"
+        if os.path.isfile(emhass_conf['data_path'] / "weather_forecast_data.pkl"): 
+            os.rename(emhass_conf['data_path'] / "weather_forecast_data.pkl", emhass_conf['data_path'] / "temp_weather_forecast_data.pkl")
         with requests_mock.mock() as m:
             data = bz2.BZ2File(str(emhass_conf['data_path'] / 'test_response_solcast_get_method.pbz2'), "rb")
             data = cPickle.load(data)
@@ -160,6 +166,8 @@ def test_get_weather_forecast_solcast_method_mock(self):
             self.assertTrue(self.fcst.start_forecast < ts for ts in df_weather_scrap.index)
             self.assertEqual(len(df_weather_scrap), 
                             int(self.optim_conf['delta_forecast'].total_seconds()/3600/self.fcst.timeStep))
+            if os.path.isfile(emhass_conf['data_path'] / "temp_weather_forecast_data.pkl"): 
+                os.rename(emhass_conf['data_path'] / "temp_weather_forecast_data.pkl", emhass_conf['data_path'] / "weather_forecast_data.pkl")
         
     def test_get_weather_forecast_solarforecast_method_mock(self):
         with requests_mock.mock() as m:
diff --git a/tests/test_retrieve_hass.py b/tests/test_retrieve_hass.py
index 7b36899d..3526334c 100644
--- a/tests/test_retrieve_hass.py
+++ b/tests/test_retrieve_hass.py
@@ -122,7 +122,7 @@ def test_prepare_data(self):
         self.assertEqual(len(self.rh.df_final.columns), len(self.var_list))
         self.assertEqual(self.rh.df_final.index.isin(self.days_list).sum(), len(self.days_list))
         self.assertEqual(self.rh.df_final.index.freq, self.retrieve_hass_conf['freq'])
-        self.assertEqual(self.rh.df_final.index.tz, pytz.UTC)
+        self.assertEqual(self.rh.df_final.index.tz, datetime.timezone.utc)
         self.rh.prepare_data(self.retrieve_hass_conf['var_load'], 
                              load_negative = self.retrieve_hass_conf['load_negative'],
                              set_zero_min = self.retrieve_hass_conf['set_zero_min'],