#PV analysis updated code
# PV analysis (clean + reusable)
# --------------------------------
# Reads hourly electricity load + PV generation profile from Excel,
# computes self-consumption, grid import/export, and key economic KPIs.
#
# Notes:
# - Assumes PV profile column is in Wh/m² per hour and load is in kWh per hour.
# - "payback_years" is a simple payback time (not ROI in %).
from __future__ import annotations
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ----------------------------
# Configuration / Inputs
# ----------------------------
PATH = "Desktop/PV_exercise.xlsx"
SHEET = "el_and_pv_data"
PV_AREA_M2 = 20
ELECTRICITY_PRICE_GRID_RP_PER_KWH = 27
ELECTRICITY_FEED_IN_TARIFF_RP_PER_KWH = 6
PV_SPECIFIC_INVESTMENT_COST_CHF_PER_M2 = 500 # https://solar-ratgeber.ch/photovoltaik/kosten-preise/
PV_SUBSIDY_FRACTION_OF_COST = 0.2 # https://www.stadtluzern.ch/dienstleistungeninformation/6253...
# Column names in your Excel file
COL_TIME = "Time"
COL_LOAD = "Electricity_Profile_MFH [kWh]"
COL_PV_PROFILE = "PV el generation profil [Wh/m2]"
# ----------------------------
# Helper functions
# ----------------------------
def read_input_dataframe(path: str, sheet: str) -> pd.DataFrame:
"""Read required columns, parse time, and return a clean DataFrame."""
df = pd.read_excel(
path,
sheet_name=sheet,
usecols=[COL_TIME, COL_LOAD, COL_PV_PROFILE],
).copy()
# Parse and index by time (recommended for time series work)
df[COL_TIME] = pd.to_datetime(df[COL_TIME], errors="coerce")
if df[COL_TIME].isna().any():
bad = df[df[COL_TIME].isna()]
raise ValueError(f"Some time values could not be parsed. Example rows:\n{bad.head()}")
df = df.sort_values(COL_TIME).set_index(COL_TIME)
# Standardize column names
df = df.rename(
columns={
COL_LOAD: "load_kWh",
COL_PV_PROFILE: "pv_Wh_m2",
}
)
return df
def compute_energy_flows(df: pd.DataFrame, pv_area_m2: float) -> pd.DataFrame:
"""Add PV generation (kWh), PV used, grid import, and grid export to df."""
out = df.copy()
# Total PV generation in kWh (Wh/m² * m² / 1000 = kWh)
out["pv_kWh"] = out["pv_Wh_m2"] * pv_area_m2 / 1000.0
# PV used on-site (self-consumption)
out["pv_used_kWh"] = np.minimum(out["pv_kWh"], out["load_kWh"])
# Grid import = remaining load
out["grid_import_kWh"] = out["load_kWh"] - out["pv_used_kWh"]
# Grid export = PV surplus (clip to avoid -0 from floating arithmetic)
out["grid_export_kWh"] = (out["pv_kWh"] - out["pv_used_kWh"]).clip(lower=0)
# Energy balance checks (optional but helpful)
# PV split: pv_used + export == pv
if not np.allclose((out["pv_used_kWh"] + out["grid_export_kWh"]).to_numpy(), out["pv_kWh"].to_numpy(), rtol=1e-9, atol=1e-9):
raise AssertionError("PV energy balance check failed (pv_used + export != pv).")
# Load split: pv_used + import == load
if not np.allclose((out["pv_used_kWh"] + out["grid_import_kWh"]).to_numpy(), out["load_kWh"].to_numpy(), rtol=1e-9, atol=1e-9):
raise AssertionError("Load energy balance check failed (pv_used + import != load).")
return out
def economical_values(
df: pd.DataFrame,
electricity_price_grid_rp_per_kwh: float,
feed_in_tariff_rp_per_kwh: float,
pv_specific_investment_cost_chf_per_m2: float,
pv_subsidy_fraction: float,
pv_area_m2: float,
) -> dict:
"""Compute annual totals and economic KPIs."""
rp_to_chf = 1 / 100.0
price_grid_chf = electricity_price_grid_rp_per_kwh * rp_to_chf
feed_in_chf = feed_in_tariff_rp_per_kwh * rp_to_chf
pv_gen_kwh = float(df["pv_kWh"].sum())
pv_used_kwh = float(df["pv_used_kWh"].sum())
grid_import_kwh = float(df["grid_import_kWh"].sum())
grid_export_kwh = float(df["grid_export_kWh"].sum())
load_kwh = float(df["load_kWh"].sum())
investment_cost_user_chf = pv_specific_investment_cost_chf_per_m2 * pv_area_m2 * (1 - pv_subsidy_fraction)
# Self-consumption share of PV production (%)
pv_self_consumption_share = (pv_used_kwh / pv_gen_kwh * 100.0) if pv_gen_kwh > 0 else 0.0
bill_without_pv_chf_per_year = load_kwh * price_grid_chf
bill_with_pv_chf_per_year = grid_import_kwh * price_grid_chf
revenue_feed_in_chf_per_year = grid_export_kwh * feed_in_chf
annual_benefit_chf = (bill_without_pv_chf_per_year - bill_with_pv_chf_per_year) + revenue_feed_in_chf_per_year
payback_years = (investment_cost_user_chf / annual_benefit_chf) if annual_benefit_chf > 0 else np.inf
return {
"pv_generation_kWh": pv_gen_kwh,
"pv_used_on_site_kWh": pv_used_kwh,
"grid_import_kWh": grid_import_kwh,
"grid_export_kWh": grid_export_kwh,
"investment_cost_user_CHF": investment_cost_user_chf,
"pv_self_consumption_share_%": pv_self_consumption_share,
"bill_without_pv_CHF_per_year": bill_without_pv_chf_per_year,
"bill_with_pv_CHF_per_year": bill_with_pv_chf_per_year,
"revenue_feed_in_CHF_per_year": revenue_feed_in_chf_per_year,
"annual_benefit_CHF_per_year": annual_benefit_chf,
"payback_years": payback_years,
}
def run_sensitivity_pv_area(
df_base: pd.DataFrame,
areas_m2: list[float],
) -> pd.DataFrame:
"""Compute KPIs for multiple PV areas (part g)."""
rows = []
for area in areas_m2:
df_flows = compute_energy_flows(df_base, pv_area_m2=area)
kpis = economical_values(
df_flows,
electricity_price_grid_rp_per_kwh=ELECTRICITY_PRICE_GRID_RP_PER_KWH,
feed_in_tariff_rp_per_kwh=ELECTRICITY_FEED_IN_TARIFF_RP_PER_KWH,
pv_specific_investment_cost_chf_per_m2=PV_SPECIFIC_INVESTMENT_COST_CHF_PER_M2,
pv_subsidy_fraction=PV_SUBSIDY_FRACTION_OF_COST,
pv_area_m2=area,
)
kpis["pv_area_m2"] = area
rows.append(kpis)
return pd.DataFrame(rows).set_index("pv_area_m2")
# ----------------------------
# Main
# ----------------------------
def main() -> None:
df_base = read_input_dataframe(PATH, SHEET)
# Compute flows for the chosen PV area
df = compute_energy_flows(df_base, pv_area_m2=PV_AREA_M2)
# Compute KPIs
economics = economical_values(
df,
electricity_price_grid_rp_per_kwh=ELECTRICITY_PRICE_GRID_RP_PER_KWH,
feed_in_tariff_rp_per_kwh=ELECTRICITY_FEED_IN_TARIFF_RP_PER_KWH,
pv_specific_investment_cost_chf_per_m2=PV_SPECIFIC_INVESTMENT_COST_CHF_PER_M2,
pv_subsidy_fraction=PV_SUBSIDY_FRACTION_OF_COST,
pv_area_m2=PV_AREA_M2,
)
# Print results (similar to your script, but with clear labels + units)
print(df)
print(f"PV electricity generation [kWh]: {economics['pv_generation_kWh']:.2f}")
print(f"Electricity covered by PV [kWh]: {economics['pv_used_on_site_kWh']:.2f}")
print(f"Electricity bought from the grid [kWh]: {economics['grid_import_kWh']:.2f}")
print(f"Electricity sold to the grid [kWh]: {economics['grid_export_kWh']:.2f}")
print(f"Investment cost for the user [CHF]: {economics['investment_cost_user_CHF']:.2f}")
print(f"Percentage of PV electricity directly used [%]: {economics['pv_self_consumption_share_%']:.2f}")
print(f"Electricity bill without PV [CHF/year]: {economics['bill_without_pv_CHF_per_year']:.2f}")
print(f"Electricity bill with PV [CHF/year]: {economics['bill_with_pv_CHF_per_year']:.2f}")
print(f"Revenue from selling PV to grid [CHF/year]: {economics['revenue_feed_in_CHF_per_year']:.2f}")
print(f"Annual benefit [CHF/year]: {economics['annual_benefit_CHF_per_year']:.2f}")
print(f"Payback time [years]: {economics['payback_years']:.2f}")
#sensitivity analysis for PV area (part g)
areas = list(range(5, 30, 1)) # 0..100 m² in 1 m² steps
df_sensitivity = run_sensitivity_pv_area(df_base, areas_m2=areas)
kpis_to_plot = [
"payback_years",
"annual_benefit_CHF_per_year",
"bill_with_pv_CHF_per_year",
"revenue_feed_in_CHF_per_year",
"pv_self_consumption_share_%",
]
for kpi in kpis_to_plot:
plt.figure()
df_sensitivity[kpi].plot()
plt.xlabel("PV area [m²]")
plt.ylabel(kpi.replace("_", " "))
plt.title(f"{kpi} vs PV area")
plt.grid(True)
plt.tight_layout()
plt.show()
if __name__ == "__main__":
main()