As property professionals we want to understand what determines the value of a property, what costs a homeowner or a landlord may occur when renting or buying a property and many more.
So we started this project by asking a simple question: since the implementation of the mandatory Energy Performance Certificate, what has changed?
Free EPC check
Check the EPC rating of any property
Search the official government EPC register and get a free report showing the rating, running costs and the improvements that would lift it.
- Official government register data
- Free, in under a minute
- Downloadable report
Methodology & Index
Our methodology
To explore this we decided to conduct an analysis of the EPC data for the 4 main boroughs of South London:
- Lambeth
- Croydon
- Bromley
- Sutton
In total we analysed the energy performance of more than 590,000 properties all sharing different features, built type and built date.
We did this to have a better understanding of:
- How energy efficient are the properties in South London?
- How the energy efficiency has improved over time?
- What are the gains brought by energy efficiency improvement?
- and more
Index
- We analysed the energy performance of 590,159 properties. Of which, 24% are located in Bromley, 29% in Croydon, 32% in Lambeth and 15% in Sutton.
- Our analysis covers 2008 to June 2025
- 38.1% of the properties have an energy rating of D, leading to a wapping £137.3M lost in energy costs
- Energy efficiency has increased by 29.1% when comparing properties built between 1950-1966 vs properties built after 2012 showing a net improvements driven by insulation, newer materials
- Lambeth has the highest energy efficiency ratings and Bromley the lowest with 67 on average for Lambeth vs 63.7 for Bromley
- Flats are on average 15% more energy efficient than houses
- You should reach out to your agent to get an EPC in December as it is the moment where engineers have the lowest demand (and thus will more likely be available)
What is the current situation in South London?
The first step to take when analysing such a large dataset is to understand what is the overall current situation, but also to have a look at the discrepancies within each borough
Lambeth is the most energy efficient borough
With an average energy efficiency rating of 67, Lambeth takes the lead.

Although, it is important to note that all 4 boroughs are very close to each other when it comes to the energy efficiency of their properties. Only 3.3 points difference between them
What is happening within each borough?
The most common energy rating across all boroughs is D (ie within the 55-68% score range) but there are some disparities when we look at the repartition of the rating within each borough. This gives us a view on how local policies may or may not impact the energy efficiencies of the properties

Although Lambeth takes the lead with its average energy efficiency rating of 67, it is the last when we look at the most efficient properties. Indeed, only 0.1% of the properties have a rating of A (ie above 80). Sutton then takes the lead with 0.3% of its properties with the precious A rating.
| Rating | Bromley | Croydon | Lambeth | Sutton |
| A | 0.2% | 0.2% | 0.1% | 0.3% |
| B | 7.9% | 12.6% | 12.5% | 9.6% |
| C | 30.8% | 29.7% | 37.5% | 33.5% |
| D | 40.4% | 38.6% | 36.4% | 36.9% |
| E | 16.8% | 15.3% | 11.2% | 15.6% |
| F | 3.1% | 2.9% | 1.8% | 3.3% |
| G | 0.8% | 0.8% | 0.6% | 0.7% |
How energy performance has improved over time?
Through the effort of successive government regulations, properties have become more and more efficient when it comes to the ways they consume energy.
Free EPC check
Check the EPC rating of any property
Search the official government EPC register and get a free report showing the rating, running costs and the improvements that would lift it.
- Official government register data
- Free, in under a minute
- Downloadable report
Energy efficiency is increasing
This is clearly shown by how the energy efficiency has increased based on the year the property was built in. This energy efficiency going from a mere 58.5 on average for buildings built before 1900 to 81.5 for properties built after 2012.

This increase can be explained by the different actions taken by the successive government to help landlords and homeowner improve their energy efficiency.
Introduced in 2007 with a dedicated legislation, the EPC and the obligation to present it when renting or selling has had a sharp impact on the overall property market. Pushing landlord and homeowners to take actions.
What are the costs & potential savings associated with your energy efficiency rating?
With global warming impacting our daily lives, energy efficiency has become an important aspect of housing policies. But taking aside this environmental element, improving the energy efficiency of one’s home has an important impact on one’s finances.
The cost of a low energy efficiency rating
When we look at the current costs for a property versus the potential cost, we then land on a £233 savings per year on average, with the major gains made on heating. This represents a 27% decrease in yearly costs and a 29% decrease in heating related expenses.

Now when we look at the types of properties that can benefit the most from an improvement in their energy efficiency, it is clear that houses are the ones that would benefit the most. It is well know now that using efficient insulation on walls, roofs and glazing has a tremendous impact on how much heat gets in or out of your property.
The potential savings based on the type of property
Houses could save up to £364 per year on average by increasing their energy efficiency. Flats are on the opposite end of this spectrum as building regulation have changed in the past years and block managers have taken the steps to improve the overall efficiency of the buildings they manage.

Other factors impacting the energy efficiency
How glazing impacts energy efficiency?
Well based on our analysis, glazing and especially triple glazing has a major impact on how efficient your property will be.
Triple glazed properties are 27% more efficient than single glazed ones with an average energy efficiency rating of 73.1.

Floor area has a negative impact on energy ratings
When it comes to the size of properties, there is a clear correlation between the size of a property and its energy rating. Bigger properties have a tendency to let heat, water and more broadly energy flow outwards.
This is supported by the data as we can see the distribution of property per size comped to the energy efficiency rating show a negative correlation (ie, big house, low energy rating).

So what are the good news?
The good news is that most of the properties in our analysis can improve their energy ratings according to the professionals who assessed these properties.
Indeed, 36.3% of the properties could technically gain at least 1 band (ie go from D to C or B to A etc.), 18.4% of them could even jump 2 bands. This represents 322,469 properties in South London and this is huge.

What this chart shows, is that the future holds great opportunities for homeowners and landlords.
Considering that with the rising awareness of first time buyers and renters regarding the environment but also the rising costs of living especially is a big city like London, make the energy efficiency rating very relevant.
It is a signal to the market that your property is: desirable, affordable and environmentally conscious. All of these making a great argument to either start renovations in order to achieve higher prices.
Fun facts
While running the analysis we realised that most of the EPC are being made in April, and the most quiet month is December. Our recommendation then: get an appointment in December to be sure to avoid any rush period.

The technicalities
We decided to disclose the code we used to conduct this analysis. We recommend that you verify our statements running this code on your own:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from datetime import datetime, timedelta
import warnings
warnings.filterwarnings('ignore')
# Set plotting style
plt.style.use('seaborn-v0_8-darkgrid')
sns.set_palette("husl")
plt.rcParams['figure.figsize'] = (12, 8)
# [markdown]
# ## Data Loading and Preprocessing
#
# UPDATE THIS PATH TO YOUR EPC DATA FILE
FILE_PATH = '/content/combined-epc.csv'
def clean_construction_age_band(age_band):
"""Clean and standardize construction age band data"""
if pd.isna(age_band) or age_band in ['INVALID!', 'NO DATA!']:
return None
# Remove "England and Wales: " prefix
if str(age_band).startswith('England and Wales: '):
age_band = str(age_band).replace('England and Wales: ', '')
# Handle individual years - map to appropriate bands
try:
year = int(age_band)
if year < 1900:
return 'before 1900'
elif 1900 <= year <= 1929:
return '1900-1929'
elif 1930 <= year <= 1949:
return '1930-1949'
elif 1950 <= year <= 1966:
return '1950-1966'
elif 1967 <= year <= 1975:
return '1967-1975'
elif 1976 <= year <= 1982:
return '1976-1982'
elif 1983 <= year <= 1990:
return '1983-1990'
elif 1991 <= year <= 1995:
return '1991-1995'
elif 1996 <= year <= 2002:
return '1996-2002'
elif 2003 <= year <= 2006:
return '2003-2006'
elif 2007 <= year <= 2011:
return '2007-2011'
elif year >= 2012:
return '2012 onwards'
except ValueError:
pass
# Handle existing ranges and special cases
age_band = str(age_band).strip()
# Standardize existing ranges
if age_band in ['2007 onwards', '2007-2011']:
return '2007-2011'
elif age_band == '2012 onwards':
return '2012 onwards'
elif age_band == 'before 1900':
return 'before 1900'
return age_band
def clean_glazing_type(glazing):
"""Group glazing types into clearer categories"""
if pd.isna(glazing) or glazing in ['INVALID!', 'NO DATA!']:
return 'Unknown'
glazing = str(glazing).lower().strip()
if 'single' in glazing:
return 'Single Glazed'
elif 'double' in glazing:
return 'Double Glazed'
elif 'triple' in glazing:
return 'Triple Glazed'
elif 'secondary' in glazing:
return 'Secondary Glazing'
elif 'partial' in glazing:
return 'Partially Glazed'
else:
return 'Other/Mixed'
def clean_main_fuel(fuel):
"""Group main fuel types into clearer categories"""
if pd.isna(fuel) or fuel in ['INVALID!', 'NO DATA!']:
return 'Unknown'
fuel = str(fuel).lower().strip()
# Remove backwards compatibility notes
if 'backwards compatibility' in fuel:
fuel = fuel.split(' - ')[0].strip()
if 'mains gas' in fuel or fuel == 'gas':
return 'Mains Gas'
elif 'electricity' in fuel:
return 'Electricity'
elif 'oil' in fuel:
return 'Oil'
elif 'lpg' in fuel:
return 'LPG'
elif 'solid fuel' in fuel or 'coal' in fuel or 'wood' in fuel:
return 'Solid Fuel'
elif 'heat pump' in fuel or 'ground source' in fuel or 'air source' in fuel:
return 'Heat Pump'
elif 'district' in fuel or 'community' in fuel or 'communal' in fuel:
return 'District/Communal'
elif 'renewable' in fuel or 'solar' in fuel:
return 'Renewable'
elif 'biomass' in fuel:
return 'Biomass'
else:
return 'Other'
def load_epc_data(file_path):
"""Load and preprocess EPC data with improved cleaning"""
print("Loading EPC data...")
# Load the data
df = pd.read_csv(file_path, low_memory=False)
print(f"Initial data shape: {df.shape}")
# Convert date columns
date_columns = ['LODGEMENT_DATE', 'INSPECTION_DATE']
for col in date_columns:
if col in df.columns:
df[col] = pd.to_datetime(df[col], errors='coerce')
if 'LODGEMENT_DATETIME' in df.columns:
df['LODGEMENT_DATETIME'] = pd.to_datetime(df['LODGEMENT_DATETIME'], errors='coerce')
# Filter for Southern London boroughs
southern_london_authorities = [
'Croydon', 'Sutton', 'Merton', 'Wandsworth', 'Lambeth', 'Southwark',
'Lewisham', 'Greenwich', 'Bromley', 'Kingston upon Thames', 'Richmond upon Thames'
]
if 'LOCAL_AUTHORITY_LABEL' in df.columns:
df = df[df['LOCAL_AUTHORITY_LABEL'].isin(southern_london_authorities)]
print(f"Filtered for Southern London: {df.shape}")
else:
print("LOCAL_AUTHORITY_LABEL not found. Using all data.")
# Clean energy rating columns
if 'CURRENT_ENERGY_RATING' in df.columns:
df['CURRENT_ENERGY_RATING'] = df['CURRENT_ENERGY_RATING'].str.upper()
# Clean construction age band
if 'CONSTRUCTION_AGE_BAND' in df.columns:
print("Cleaning construction age band data...")
df['CONSTRUCTION_AGE_BAND_CLEAN'] = df['CONSTRUCTION_AGE_BAND'].apply(clean_construction_age_band)
# Show before/after counts
print("Construction Age Band cleaning results:")
print("Before cleaning:")
print(df['CONSTRUCTION_AGE_BAND'].value_counts().head(10))
print("\nAfter cleaning:")
print(df['CONSTRUCTION_AGE_BAND_CLEAN'].value_counts())
# Clean glazing type
if 'GLAZED_TYPE' in df.columns:
print("Cleaning glazing type data...")
df['GLAZED_TYPE_CLEAN'] = df['GLAZED_TYPE'].apply(clean_glazing_type)
print("Glazing Type cleaning results:")
print("After cleaning:")
print(df['GLAZED_TYPE_CLEAN'].value_counts())
# Clean main fuel type
if 'MAIN_FUEL' in df.columns:
print("Cleaning main fuel type data...")
df['MAIN_FUEL_CLEAN'] = df['MAIN_FUEL'].apply(clean_main_fuel)
print("Main Fuel cleaning results:")
print("After cleaning:")
print(df['MAIN_FUEL_CLEAN'].value_counts())
# Add derived columns
if 'LODGEMENT_DATE' in df.columns:
df['YEAR'] = df['LODGEMENT_DATE'].dt.year
df['QUARTER'] = df['LODGEMENT_DATE'].dt.quarter
df['MONTH'] = df['LODGEMENT_DATE'].dt.month
# Calculate potential savings
cost_columns = ['HEATING_COST_CURRENT', 'HEATING_COST_POTENTIAL',
'HOT_WATER_COST_CURRENT', 'HOT_WATER_COST_POTENTIAL',
'LIGHTING_COST_CURRENT', 'LIGHTING_COST_POTENTIAL']
available_cost_cols = [col for col in cost_columns if col in df.columns]
if len(available_cost_cols) >= 2:
# Calculate totals
current_cols = ['HEATING_COST_CURRENT', 'HOT_WATER_COST_CURRENT', 'LIGHTING_COST_CURRENT']
potential_cols = ['HEATING_COST_POTENTIAL', 'HOT_WATER_COST_POTENTIAL', 'LIGHTING_COST_POTENTIAL']
current_available = [col for col in current_cols if col in df.columns]
potential_available = [col for col in potential_cols if col in df.columns]
if current_available:
df['TOTAL_CURRENT_COST'] = df[current_available].sum(axis=1, skipna=True)
if potential_available:
df['TOTAL_POTENTIAL_COST'] = df[potential_available].sum(axis=1, skipna=True)
if 'TOTAL_CURRENT_COST' in df.columns and 'TOTAL_POTENTIAL_COST' in df.columns:
df['POTENTIAL_SAVINGS'] = df['TOTAL_CURRENT_COST'] - df['TOTAL_POTENTIAL_COST']
return df
# Load the data (update FILE_PATH above)
try:
df = load_epc_data(FILE_PATH)
print(f"\n📊 Dataset Overview:")
print(f" Total properties: {len(df):,}")
if 'LODGEMENT_DATE' in df.columns:
print(f" Date range: {df['LODGEMENT_DATE'].min()} to {df['LODGEMENT_DATE'].max()}")
if 'LOCAL_AUTHORITY_LABEL' in df.columns:
print(f" Boroughs covered: {df['LOCAL_AUTHORITY_LABEL'].nunique()}")
print(f" Borough list: {', '.join(df['LOCAL_AUTHORITY_LABEL'].unique()[:5])}...")
except FileNotFoundError:
print("❌ File not found. Please update FILE_PATH variable above with your EPC data file path.")
print("Example: FILE_PATH = 'path/to/your/epc_data.csv'")
result = df.groupby('LOCAL_AUTHORITY_LABEL')['LMK_KEY'].count()
print(result)
# [markdown]
# # 1. TEMPORAL ANALYSIS - Energy Performance Evolution
# [markdown]
# ## 1.1 Energy Rating Distribution Over Time
# %%
# Chart 1: Energy Rating Distribution Over Time
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 8))
# Energy rating trends by year
if 'CURRENT_ENERGY_RATING' in df.columns and 'YEAR' in df.columns:
rating_by_year = df.groupby(['YEAR', 'CURRENT_ENERGY_RATING']).size().unstack(fill_value=0)
rating_by_year_pct = rating_by_year.div(rating_by_year.sum(axis=1), axis=0) * 100
# Stacked area chart
rating_by_year_pct.plot(kind='area', stacked=True, ax=ax1, alpha=0.7)
ax1.set_title('Energy Rating Distribution Over Time\n(Percentage by Year)', fontsize=14, fontweight='bold')
ax1.set_xlabel('Year')
ax1.set_ylabel('Percentage of Properties (%)')
ax1.legend(title='Energy Rating', bbox_to_anchor=(1.05, 1), loc='upper left')
ax1.grid(True, alpha=0.3)
else:
ax1.text(0.5, 0.5, 'Energy rating data not available', ha='center', va='center', transform=ax1.transAxes)
# Average Energy Efficiency Score Trends
if 'CURRENT_ENERGY_EFFICIENCY' in df.columns and 'YEAR' in df.columns:
efficiency_trends = df.groupby('YEAR')['CURRENT_ENERGY_EFFICIENCY'].agg(['mean', 'median']).reset_index()
ax2.plot(efficiency_trends['YEAR'], efficiency_trends['mean'],
marker='o', linewidth=3, markersize=8, label='Mean Energy Efficiency')
ax2.plot(efficiency_trends['YEAR'], efficiency_trends['median'],
marker='s', linewidth=3, markersize=8, label='Median Energy Efficiency')
ax2.set_title('Energy Efficiency Trends Over Time', fontsize=14, fontweight='bold')
ax2.set_xlabel('Year')
ax2.set_ylabel('Energy Efficiency Score')
ax2.legend()
ax2.grid(True, alpha=0.3)
else:
ax2.text(0.5, 0.5, 'Energy efficiency data not available', ha='center', va='center', transform=ax2.transAxes)
plt.tight_layout()
plt.show()
# Summary statistics
if 'CURRENT_ENERGY_RATING' in df.columns:
print("Energy Rating Distribution (Overall):")
rating_dist = df['CURRENT_ENERGY_RATING'].value_counts().sort_index()
for rating, count in rating_dist.items():
pct = (count / len(df)) * 100
print(f" {rating}: {count:,} properties ({pct:.1f}%)")
# [markdown]
# ## 1.2 Seasonal Patterns Analysis
# %%
# Seasonal Patterns Analysis
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 8))
# EPC Assessments by Month
if 'MONTH' in df.columns:
monthly_counts = df['MONTH'].value_counts().sort_index()
month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
bars = ax1.bar(range(1, 13), monthly_counts.values, color='skyblue', alpha=0.8)
ax1.set_title('EPC Assessments by Month\n(Seasonal Patterns)', fontsize=14, fontweight='bold')
ax1.set_xlabel('Month')
ax1.set_ylabel('Number of Assessments')
ax1.set_xticks(range(1, 13))
ax1.set_xticklabels(month_names)
ax1.grid(True, alpha=0.3, axis='y')
# Add value labels on bars
for bar in bars:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2., height + 50,
f'{int(height):,}', ha='center', va='bottom', fontsize=10)
else:
ax1.text(0.5, 0.5, 'Monthly data not available', ha='center', va='center', transform=ax1.transAxes)
# Average Energy Efficiency by Quarter
if 'QUARTER' in df.columns and 'CURRENT_ENERGY_EFFICIENCY' in df.columns:
quarterly_efficiency = df.groupby('QUARTER')['CURRENT_ENERGY_EFFICIENCY'].mean()
bars = ax2.bar(quarterly_efficiency.index, quarterly_efficiency.values,
color=['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4'], alpha=0.8)
ax2.set_title('Average Energy Efficiency by Quarter', fontsize=14, fontweight='bold')
ax2.set_xlabel('Quarter')
ax2.set_ylabel('Average Energy Efficiency')
ax2.set_xticks(range(1, 5))
ax2.set_xticklabels(['Q1', 'Q2', 'Q3', 'Q4'])
ax2.grid(True, alpha=0.3, axis='y')
# Add value labels on bars
for bar in bars:
height = bar.get_height()
ax2.text(bar.get_x() + bar.get_width()/2., height + 1,
f'{height:.1f}', ha='center', va='bottom', fontsize=11, fontweight='bold')
else:
ax2.text(0.5, 0.5, 'Quarterly efficiency data not available', ha='center', va='center', transform=ax2.transAxes)
plt.tight_layout()
plt.show()
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 8))
# Average Energy Efficiency Score based on construction year
if 'CURRENT_ENERGY_EFFICIENCY' in df.columns and 'CONSTRUCTION_AGE_BAND_CLEAN' in df.columns:
efficiency_trends = df.groupby('CONSTRUCTION_AGE_BAND_CLEAN')['CURRENT_ENERGY_EFFICIENCY'].agg(['mean', 'median']).reset_index()
ax2.plot(efficiency_trends['CONSTRUCTION_AGE_BAND_CLEAN'], efficiency_trends['mean'],
marker='o', linewidth=3, markersize=8, label='Mean Energy Efficiency')
ax2.plot(efficiency_trends['CONSTRUCTION_AGE_BAND_CLEAN'], efficiency_trends['median'],
marker='s', linewidth=3, markersize=8, label='Median Energy Efficiency')
ax2.set_title('Energy Efficiency Trends Over Time', fontsize=14, fontweight='bold')
ax2.set_xlabel('Construction year group')
ax2.set_ylabel('Energy Efficiency Score')
ax2.legend()
ax2.grid(True, alpha=0.3)
else:
ax2.text(0.5, 0.5, 'Energy efficiency data not available', ha='center', va='center', transform=ax2.transAxes)
plt.tight_layout()
plt.show()
# [markdown]
# # 2. GEOGRAPHIC ANALYSIS - Southern London Focus
# [markdown]
# ## 2.1 Performance by Borough
# %%
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(16, 16))
# Average Energy Efficiency by Borough
if 'LOCAL_AUTHORITY_LABEL' in df.columns and 'CURRENT_ENERGY_EFFICIENCY' in df.columns:
borough_efficiency = df.groupby('LOCAL_AUTHORITY_LABEL')['CURRENT_ENERGY_EFFICIENCY'].agg(['mean', 'count']).reset_index()
borough_efficiency = borough_efficiency[borough_efficiency['count'] >= 100] # Filter boroughs with sufficient data
borough_efficiency = borough_efficiency.sort_values('mean')
bars = ax1.barh(borough_efficiency['LOCAL_AUTHORITY_LABEL'], borough_efficiency['mean'],
color='lightcoral', alpha=0.8)
ax1.set_title('Average Energy Efficiency by Southern London Borough\n(£/m²/year)',
fontsize=14, fontweight='bold')
ax1.set_xlabel('Average Energy Efficiency (£/m²/year)')
ax1.grid(True, alpha=0.3, axis='x')
# Add value labels
for i, bar in enumerate(bars):
width = bar.get_width()
ax1.text(width + 1, bar.get_y() + bar.get_height()/2.,
f'{width:.1f}', ha='left', va='center', fontsize=10, fontweight='bold')
else:
ax1.text(0.5, 0.5, 'Borough efficiency data not available', ha='center', va='center', transform=ax1.transAxes)
# Energy Rating Distribution by Borough (Top boroughs)
if 'LOCAL_AUTHORITY_LABEL' in df.columns and 'CURRENT_ENERGY_RATING' in df.columns:
# Get top 6 boroughs by property count
top_boroughs = df['LOCAL_AUTHORITY_LABEL'].value_counts().head(6).index
df_top = df[df['LOCAL_AUTHORITY_LABEL'].isin(top_boroughs)]
rating_by_borough = df_top.groupby(['LOCAL_AUTHORITY_LABEL', 'CURRENT_ENERGY_RATING']).size().unstack(fill_value=0)
rating_by_borough_pct = rating_by_borough.div(rating_by_borough.sum(axis=1), axis=0) * 100
rating_by_borough_pct.plot(kind='bar', stacked=True, ax=ax2, width=0.8)
ax2.set_title('Energy Rating Distribution by Borough\n(Top 6 Boroughs by Property Count)',
fontsize=14, fontweight='bold')
ax2.set_xlabel('Borough')
ax2.set_ylabel('Percentage of Properties (%)')
ax2.legend(title='Energy Rating', bbox_to_anchor=(1.05, 1), loc='upper left')
ax2.tick_params(axis='x', rotation=45)
ax2.grid(True, alpha=0.3, axis='y')
# Add percentage labels to stacked bars
for i, borough in enumerate(rating_by_borough_pct.index):
y_offset = 0
for rating in rating_by_borough_pct.columns:
percentage = rating_by_borough_pct.loc[borough, rating]
if percentage > 3: # Only show labels for segments > 3% to avoid clutter
label_y = y_offset + percentage/2
ax2.text(i, label_y, f'{percentage:.1f}%',
ha='center', va='center', fontsize=9,
fontweight='bold', color='white',
bbox=dict(boxstyle='round,pad=0.2', facecolor='black', alpha=0.7))
y_offset += percentage
else:
ax2.text(0.5, 0.5, 'Borough rating data not available', ha='center', va='center', transform=ax2.transAxes)
plt.tight_layout()
plt.show()
# Print summary statistics
if 'LOCAL_AUTHORITY_LABEL' in df.columns:
print("Property Count by Borough:")
borough_counts = df['LOCAL_AUTHORITY_LABEL'].value_counts()
for borough, count in borough_counts.items():
print(f" {borough}: {count:,} properties")
# Energy Rating Distribution by Borough (Top boroughs) - TABLE FORMAT
if 'LOCAL_AUTHORITY_LABEL' in df.columns and 'CURRENT_ENERGY_RATING' in df.columns:
# Get top 6 boroughs by property count
top_boroughs = df['LOCAL_AUTHORITY_LABEL'].value_counts().head(6).index
df_top = df[df['LOCAL_AUTHORITY_LABEL'].isin(top_boroughs)]
rating_by_borough = df_top.groupby(['LOCAL_AUTHORITY_LABEL', 'CURRENT_ENERGY_RATING']).size().unstack(fill_value=0)
rating_by_borough_pct = rating_by_borough.div(rating_by_borough.sum(axis=1), axis=0) * 100
# Transpose to have ratings as rows and boroughs as columns
rating_table = rating_by_borough_pct.T
# Ensure all ratings A-G are present (fill missing with 0)
all_ratings = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
rating_table = rating_table.reindex(all_ratings, fill_value=0)
print("\n" + "="*100)
print("📊 ENERGY RATING DISTRIBUTION BY BOROUGH (Top 6 Boroughs)")
print("="*100)
print("Values show percentage of properties in each rating category")
print("-" * 100)
# Create table header
header = f"{'Rating':<8}"
for borough in rating_table.columns:
header += f"{borough:<15}"
print(header)
print("-" * 100)
# Print each row (rating)
for rating in rating_table.index:
row = f"{rating:<8}"
for borough in rating_table.columns:
percentage = rating_table.loc[rating, borough]
row += f"{percentage:>13.1f}%"
print(row)
# Add summary statistics
print("-" * 100)
print("SUMMARY STATISTICS:")
print("-" * 100)
# Total properties per borough
borough_totals = df_top['LOCAL_AUTHORITY_LABEL'].value_counts()
total_row = f"{'Total Props':<8}"
for borough in rating_table.columns:
total_props = borough_totals[borough]
total_row += f"{total_props:>13,} "
print(total_row)
# Most common rating per borough
most_common_row = f"{'Top Rating':<8}"
for borough in rating_table.columns:
top_rating = rating_table[borough].idxmax()
top_pct = rating_table.loc[top_rating, borough]
most_common_row += f"{top_rating}({top_pct:.1f}%){'':<6}"
print(most_common_row)
# Average efficiency rating (A=7, B=6, etc.)
rating_scores = {'A': 7, 'B': 6, 'C': 5, 'D': 4, 'E': 3, 'F': 2, 'G': 1}
avg_score_row = f"{'Avg Score':<8}"
for borough in rating_table.columns:
weighted_score = sum(rating_table.loc[rating, borough] * rating_scores[rating]
for rating in rating_table.index) / 100
avg_score_row += f"{weighted_score:>13.2f} "
print(avg_score_row)
print("-" * 100)
# Highlight best and worst performing boroughs
borough_avg_scores = {}
for borough in rating_table.columns:
weighted_score = sum(rating_table.loc[rating, borough] * rating_scores[rating]
for rating in rating_table.index) / 100
borough_avg_scores[borough] = weighted_score
best_borough = max(borough_avg_scores, key=borough_avg_scores.get)
worst_borough = min(borough_avg_scores, key=borough_avg_scores.get)
print(f"🏅 BEST performing borough: {best_borough} (avg score: {borough_avg_scores[best_borough]:.2f})")
print(f"🚨 WORST performing borough: {worst_borough} (avg score: {borough_avg_scores[worst_borough]:.2f})")
else:
print("Borough rating data not available")
# [markdown]
# # 3. PROPERTY CHARACTERISTICS Impact Analysis (IMPROVED WITH DATA CLEANING)
# [markdown]
# ## 3.1 Property Type and Age Analysis (CLEANED DATA)
# %%
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(20, 16))
# Energy Performance by Property Type
if 'PROPERTY_TYPE' in df.columns and 'CURRENT_ENERGY_EFFICIENCY' in df.columns:
prop_type_efficiency = df.groupby('PROPERTY_TYPE')['CURRENT_ENERGY_EFFICIENCY'].agg(['mean', 'count']).reset_index()
prop_type_efficiency = prop_type_efficiency[prop_type_efficiency['count'] >= 50]
prop_type_efficiency = prop_type_efficiency.sort_values('mean')
bars = ax1.bar(prop_type_efficiency['PROPERTY_TYPE'], prop_type_efficiency['mean'],
color='lightblue', alpha=0.8)
ax1.set_title('Average Energy Efficiency by Property Type\n(£/m²/year)',
fontsize=12, fontweight='bold')
ax1.set_ylabel('Average Energy Efficiency (£/m²/year)')
ax1.tick_params(axis='x', rotation=45)
ax1.grid(True, alpha=0.3, axis='y')
# Add value labels
for bar in bars:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2., height + 1,
f'{height:.1f}', ha='center', va='bottom', fontsize=10)
else:
ax1.text(0.5, 0.5, 'Property type data not available', ha='center', va='center', transform=ax1.transAxes)
# Energy Performance by Construction Age Band (CLEANED)
if 'CONSTRUCTION_AGE_BAND_CLEAN' in df.columns and 'CURRENT_ENERGY_EFFICIENCY' in df.columns:
# Remove null values
df_age_clean = df.dropna(subset=['CONSTRUCTION_AGE_BAND_CLEAN'])
age_efficiency = df_age_clean.groupby('CONSTRUCTION_AGE_BAND_CLEAN')['CURRENT_ENERGY_EFFICIENCY'].agg(['mean', 'count']).reset_index()
age_efficiency = age_efficiency[age_efficiency['count'] >= 50]
# Sort by chronological order
age_order = ['before 1900', '1900-1929', '1930-1949', '1950-1966', '1967-1975',
'1976-1982', '1983-1990', '1991-1995', '1996-2002', '2003-2006',
'2007-2011', '2012 onwards']
age_efficiency['sort_order'] = age_efficiency['CONSTRUCTION_AGE_BAND_CLEAN'].apply(
lambda x: age_order.index(x) if x in age_order else 999)
age_efficiency = age_efficiency.sort_values('sort_order')
ax2.plot(range(len(age_efficiency)), age_efficiency['mean'],
marker='o', linewidth=3, markersize=8, color='green')
ax2.set_title('Energy Efficiency by Construction Age (CLEANED)\n(£/m²/year)',
fontsize=12, fontweight='bold')
ax2.set_ylabel('Average Energy Efficiency (£/m²/year)')
ax2.set_xticks(range(len(age_efficiency)))
ax2.set_xticklabels(age_efficiency['CONSTRUCTION_AGE_BAND_CLEAN'], rotation=45, ha='right')
ax2.grid(True, alpha=0.3)
# Add data counts
for i, (idx, row) in enumerate(age_efficiency.iterrows()):
ax2.text(i, row['mean'] + 2, f"n={row['count']}", ha='center', va='bottom',
fontsize=8, rotation=90)
else:
ax2.text(0.5, 0.5, 'Cleaned construction age data not available', ha='center', va='center', transform=ax2.transAxes)
# Energy Rating Distribution by Built Form
if 'BUILT_FORM' in df.columns and 'CURRENT_ENERGY_RATING' in df.columns:
built_form_counts = df['BUILT_FORM'].value_counts()
top_built_forms = built_form_counts.head(5).index
df_built = df[df['BUILT_FORM'].isin(top_built_forms)]
rating_by_built = df_built.groupby(['BUILT_FORM', 'CURRENT_ENERGY_RATING']).size().unstack(fill_value=0)
rating_by_built_pct = rating_by_built.div(rating_by_built.sum(axis=1), axis=0) * 100
rating_by_built_pct.plot(kind='bar', stacked=True, ax=ax3, width=0.8)
ax3.set_title('Energy Rating Distribution by Built Form\n(Top 5 Built Forms)',
fontsize=12, fontweight='bold')
ax3.set_ylabel('Percentage of Properties (%)')
ax3.tick_params(axis='x', rotation=45)
ax3.legend(title='Energy Rating', bbox_to_anchor=(1.05, 1), loc='upper left')
ax3.grid(True, alpha=0.3, axis='y')
else:
ax3.text(0.5, 0.5, 'Built form data not available', ha='center', va='center', transform=ax3.transAxes)
# Floor Area vs Energy Efficiency Scatter
if 'TOTAL_FLOOR_AREA' in df.columns and 'CURRENT_ENERGY_EFFICIENCY' in df.columns:
# Sample data for performance (take every 10th row if dataset is large)
df_sample = df.dropna(subset=['TOTAL_FLOOR_AREA', 'CURRENT_ENERGY_EFFICIENCY'])
if len(df_sample) > 5000:
df_sample = df_sample.iloc[::len(df_sample)//5000]
ax4.scatter(df_sample['TOTAL_FLOOR_AREA'], df_sample['CURRENT_ENERGY_EFFICIENCY'],
alpha=0.5, s=20, color='purple')
ax4.set_title('Floor Area vs Energy Efficiency\n(Sample of Properties)',
fontsize=12, fontweight='bold')
ax4.set_xlabel('Total Floor Area (m²)')
ax4.set_ylabel('Energy Efficiency (£/m²/year)')
ax4.grid(True, alpha=0.3)
# Add trend line
z = np.polyfit(df_sample['TOTAL_FLOOR_AREA'], df_sample['CURRENT_ENERGY_EFFICIENCY'], 1)
p = np.poly1d(z)
ax4.plot(df_sample['TOTAL_FLOOR_AREA'], p(df_sample['TOTAL_FLOOR_AREA']),
"r--", alpha=0.8, linewidth=2)
else:
ax4.text(0.5, 0.5, 'Floor area vs efficiency data not available', ha='center', va='center', transform=ax4.transAxes)
plt.tight_layout()
plt.show()
# Display detailed table of energy efficiency by construction age
print("\n" + "="*70)
print("📊 ENERGY EFFICIENCY BY CONSTRUCTION AGE - DETAILED TABLE")
print("="*70)
# Calculate additional statistics for the table
age_stats = df_age_clean.groupby('CONSTRUCTION_AGE_BAND_CLEAN')['CURRENT_ENERGY_EFFICIENCY'].agg([
'mean', 'median', 'std', 'min', 'max', 'count'
]).reset_index()
age_stats = age_stats[age_stats['count'] >= 50]
# Sort by chronological order
age_stats['sort_order'] = age_stats['CONSTRUCTION_AGE_BAND_CLEAN'].apply(
lambda x: age_order.index(x) if x in age_order else 999)
age_stats = age_stats.sort_values('sort_order')
# Format and display the table
print(f"{'Construction Period':<20} {'Count':<8} {'Mean':<8} {'Median':<8} {'Std Dev':<8} {'Min':<8} {'Max':<8}")
print("-" * 70)
for _, row in age_stats.iterrows():
period = row['CONSTRUCTION_AGE_BAND_CLEAN']
count = int(row['count'])
mean_val = row['mean']
median_val = row['median']
std_val = row['std']
min_val = row['min']
max_val = row['max']
print(f"{period:<20} {count:<8,} {mean_val:<8.1f} {median_val:<8.1f} {std_val:<8.1f} {min_val:<8.1f} {max_val:<8.1f}")
# Calculate percentage distribution
total_properties = age_stats['count'].sum()
print(f"\nTotal properties analyzed: {total_properties:,}")
print(f"\nPercentage distribution by construction age:")
print("-" * 40)
for _, row in age_stats.iterrows():
period = row['CONSTRUCTION_AGE_BAND_CLEAN']
count = int(row['count'])
percentage = (count / total_properties) * 100
print(f"{period:<20} {percentage:>6.1f}%")
# Identify best and worst performing periods
best_period = age_stats.loc[age_stats['mean'].idxmin()]
worst_period = age_stats.loc[age_stats['mean'].idxmax()]
print(f"\n🏅 BEST performing period: {best_period['CONSTRUCTION_AGE_BAND_CLEAN']}")
print(f" Average efficiency: £{best_period['mean']:.1f}/m²/year ({best_period['count']:,} properties)")
print(f"\n🚨 WORST performing period: {worst_period['CONSTRUCTION_AGE_BAND_CLEAN']}")
print(f" Average efficiency: £{worst_period['mean']:.1f}/m²/year ({worst_period['count']:,} properties)")
efficiency_difference = worst_period['mean'] - best_period['mean']
print(f"\n💰 Efficiency gap: £{efficiency_difference:.1f}/m²/year difference between best and worst")
# [markdown]
# ## 3.2 Building Features Impact (IMPROVED WITH GROUPED DATA)
# %%
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 8))
# Energy Efficiency by Glazing Type (CLEANED)
if 'GLAZED_TYPE_CLEAN' in df.columns and 'CURRENT_ENERGY_EFFICIENCY' in df.columns:
glazing_efficiency = df.groupby('GLAZED_TYPE_CLEAN')['CURRENT_ENERGY_EFFICIENCY'].agg(['mean', 'count']).reset_index()
glazing_efficiency = glazing_efficiency[glazing_efficiency['count'] >= 50]
glazing_efficiency = glazing_efficiency.sort_values('mean')
# Use more colors for the cleaned categories
colors = plt.cm.Set3(np.linspace(0, 1, len(glazing_efficiency)))
bars = ax1.bar(glazing_efficiency['GLAZED_TYPE_CLEAN'], glazing_efficiency['mean'],
color=colors, alpha=0.8)
ax1.set_title('Energy Efficiency by Glazing Type (GROUPED)\n(£/m²/year)',
fontsize=14, fontweight='bold')
ax1.set_ylabel('Average Energy Efficiency (£/m²/year)')
ax1.tick_params(axis='x', rotation=45)
ax1.grid(True, alpha=0.3, axis='y')
# Add value labels and counts
for i, bar in enumerate(bars):
height = bar.get_height()
count = glazing_efficiency.iloc[i]['count']
ax1.text(bar.get_x() + bar.get_width()/2., height + 2,
f'{height:.1f}\n(n={count:,})', ha='center', va='bottom',
fontsize=10, fontweight='bold')
else:
ax1.text(0.5, 0.5, 'Cleaned glazing type data not available', ha='center', va='center', transform=ax1.transAxes)
# Main Fuel Type Performance (CLEANED)
if 'MAIN_FUEL_CLEAN' in df.columns and 'CURRENT_ENERGY_EFFICIENCY' in df.columns:
fuel_efficiency = df.groupby('MAIN_FUEL_CLEAN')['CURRENT_ENERGY_EFFICIENCY'].agg(['mean', 'count']).reset_index()
fuel_efficiency = fuel_efficiency[fuel_efficiency['count'] >= 100]
fuel_efficiency = fuel_efficiency.sort_values('mean')
colors = plt.cm.viridis(np.linspace(0, 1, len(fuel_efficiency)))
bars = ax2.barh(fuel_efficiency['MAIN_FUEL_CLEAN'], fuel_efficiency['mean'],
color=colors, alpha=0.8)
ax2.set_title('Energy Efficiency by Main Fuel Type (GROUPED)\n(£/m²/year)',
fontsize=14, fontweight='bold')
ax2.set_xlabel('Average Energy Efficiency (£/m²/year)')
ax2.grid(True, alpha=0.3, axis='x')
# Add value labels
for i, bar in enumerate(bars):
width = bar.get_width()
count = fuel_efficiency.iloc[i]['count']
ax2.text(width + 2, bar.get_y() + bar.get_height()/2.,
f'{width:.1f} (n={count:,})', ha='left', va='center',
fontsize=10, fontweight='bold')
else:
ax2.text(0.5, 0.5, 'Cleaned main fuel data not available', ha='center', va='center', transform=ax2.transAxes)
plt.tight_layout()
plt.show()
# Print data cleaning summary
print("\n" + "="*60)
print("🧹 DATA CLEANING SUMMARY")
print("="*60)
if 'CONSTRUCTION_AGE_BAND_CLEAN' in df.columns:
print("Construction Age Band Cleaning:")
original_nulls = df['CONSTRUCTION_AGE_BAND'].isin(['INVALID!', 'NO DATA!']).sum()
cleaned_nulls = df['CONSTRUCTION_AGE_BAND_CLEAN'].isna().sum()
print(f" Original invalid/no data entries: {original_nulls:,}")
print(f" After cleaning (null values): {cleaned_nulls:,}")
print(f" Valid entries for analysis: {(~df['CONSTRUCTION_AGE_BAND_CLEAN'].isna()).sum():,}")
if 'GLAZED_TYPE_CLEAN' in df.columns:
print("\nGlazing Type Grouping:")
print(" Categories after grouping:")
for category, count in df['GLAZED_TYPE_CLEAN'].value_counts().items():
print(f" {category}: {count:,} properties")
if 'MAIN_FUEL_CLEAN' in df.columns:
print("\nMain Fuel Type Grouping:")
print(" Categories after grouping:")
for category, count in df['MAIN_FUEL_CLEAN'].value_counts().items():
print(f" {category}: {count:,} properties")
# % [markdown]
# # 4. ECONOMIC ANALYSIS - Energy Costs and Savings
# % [markdown]
# ## 4.1 Cost Breakdown and Savings Analysis
# %%
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(20, 16))
# Current vs Potential Energy Costs Breakdown
if all(col in df.columns for col in ['HEATING_COST_CURRENT', 'HOT_WATER_COST_CURRENT', 'LIGHTING_COST_CURRENT']):
current_costs = {
'Heating': df['HEATING_COST_CURRENT'].mean(),
'Hot Water': df['HOT_WATER_COST_CURRENT'].mean(),
'Lighting': df['LIGHTING_COST_CURRENT'].mean()
}
potential_costs = {
'Heating': df['HEATING_COST_POTENTIAL'].mean(),
'Hot Water': df['HOT_WATER_COST_POTENTIAL'].mean(),
'Lighting': df['LIGHTING_COST_POTENTIAL'].mean()
}
components = list(current_costs.keys())
current_values = list(current_costs.values())
potential_values = list(potential_costs.values())
x = np.arange(len(components))
width = 0.35
bars1 = ax1.bar(x - width/2, current_values, width, label='Current Costs', color='lightcoral', alpha=0.8)
bars2 = ax1.bar(x + width/2, potential_values, width, label='Potential Costs', color='lightblue', alpha=0.8)
ax1.set_title('Average Energy Costs: Current vs Potential\n(£/year)',
fontsize=14, fontweight='bold')
ax1.set_ylabel('Average Annual Cost (£)')
ax1.set_xticks(x)
ax1.set_xticklabels(components)
ax1.legend()
ax1.grid(True, alpha=0.3, axis='y')
# Add value labels
for bars in [bars1, bars2]:
for bar in bars:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2., height + 5,
f'£{height:.0f}', ha='center', va='bottom', fontsize=10)
else:
ax1.text(0.5, 0.5, 'Cost breakdown data not available', ha='center', va='center', transform=ax1.transAxes)
# Potential Savings Distribution
if 'POTENTIAL_SAVINGS' in df.columns:
savings_data = df['POTENTIAL_SAVINGS'].dropna()
savings_data = savings_data[savings_data > 0] # Only positive savings
ax2.hist(savings_data, bins=50, color='gold', alpha=0.7, edgecolor='black')
ax2.axvline(savings_data.mean(), color='red', linestyle='--', linewidth=2,
label=f'Mean: £{savings_data.mean():.0f}')
ax2.axvline(savings_data.median(), color='blue', linestyle='--', linewidth=2,
label=f'Median: £{savings_data.median():.0f}')
ax2.set_title('Distribution of Potential Annual Savings\n(Properties with Positive Savings)',
fontsize=14, fontweight='bold')
ax2.set_xlabel('Potential Annual Savings (£)')
ax2.set_ylabel('Number of Properties')
ax2.legend()
ax2.grid(True, alpha=0.3, axis='y')
else:
ax2.text(0.5, 0.5, 'Savings data not available', ha='center', va='center', transform=ax2.transAxes)
# Savings Potential by Property Type
if all(col in df.columns for col in ['PROPERTY_TYPE', 'POTENTIAL_SAVINGS']):
prop_savings = df.groupby('PROPERTY_TYPE')['POTENTIAL_SAVINGS'].agg(['mean', 'median', 'count']).reset_index()
prop_savings = prop_savings[prop_savings['count'] >= 50]
prop_savings = prop_savings.sort_values('mean', ascending=False)
x = np.arange(len(prop_savings))
width = 0.35
bars1 = ax3.bar(x - width/2, prop_savings['mean'], width, label='Mean Savings',
color='darkgreen', alpha=0.8)
bars2 = ax3.bar(x + width/2, prop_savings['median'], width, label='Median Savings',
color='lightgreen', alpha=0.8)
ax3.set_title('Potential Savings by Property Type\n(£/year)',
fontsize=14, fontweight='bold')
ax3.set_ylabel('Potential Annual Savings (£)')
ax3.set_xticks(x)
ax3.set_xticklabels(prop_savings['PROPERTY_TYPE'], rotation=45, ha='right')
ax3.legend()
ax3.grid(True, alpha=0.3, axis='y')
# Add value labels
for bars in [bars1, bars2]:
for bar in bars:
height = bar.get_height()
if height > 0:
ax3.text(bar.get_x() + bar.get_width()/2., height + 10,
f'£{height:.0f}', ha='center', va='bottom', fontsize=9)
else:
ax3.text(0.5, 0.5, 'Property savings data not available', ha='center', va='center', transform=ax3.transAxes)
# Cost vs Floor Area Analysis
if all(col in df.columns for col in ['TOTAL_FLOOR_AREA', 'TOTAL_CURRENT_COST']):
# Create floor area bins
df_cost = df.dropna(subset=['TOTAL_FLOOR_AREA', 'TOTAL_CURRENT_COST'])
df_cost = df_cost[df_cost['TOTAL_FLOOR_AREA'] <= 300] # Remove outliers
df_cost['FLOOR_AREA_BIN'] = pd.cut(df_cost['TOTAL_FLOOR_AREA'],
bins=[0, 50, 75, 100, 150, 200, 300],
labels=['<50m²', '50-75m²', '75-100m²',
'100-150m²', '150-200m²', '200m²+'])
cost_by_area = df_cost.groupby('FLOOR_AREA_BIN')['TOTAL_CURRENT_COST'].agg(['mean', 'count']).reset_index()
cost_by_area = cost_by_area[cost_by_area['count'] >= 20]
bars = ax4.bar(range(len(cost_by_area)), cost_by_area['mean'],
color='orange', alpha=0.8)
ax4.set_title('Average Total Energy Cost by Floor Area\n(£/year)',
fontsize=14, fontweight='bold')
ax4.set_ylabel('Average Annual Cost (£)')
ax4.set_xticks(range(len(cost_by_area)))
ax4.set_xticklabels(cost_by_area['FLOOR_AREA_BIN'])
ax4.grid(True, alpha=0.3, axis='y')
# Add value labels
for i, bar in enumerate(bars):
height = bar.get_height()
count = cost_by_area.iloc[i]['count']
ax4.text(bar.get_x() + bar.get_width()/2., height + 20,
f'£{height:.0f}\n(n={count:,})', ha='center', va='bottom',
fontsize=10, fontweight='bold')
else:
ax4.text(0.5, 0.5, 'Cost vs area data not available', ha='center', va='center', transform=ax4.transAxes)
plt.tight_layout()
plt.show()
# [markdown]
# ## 4.2 Investment Potential Analysis
# %%
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 8))
# Energy Rating Improvement Potential
if all(col in df.columns for col in ['CURRENT_ENERGY_RATING', 'POTENTIAL_ENERGY_RATING']):
# Calculate improvement potential
rating_map = {'A': 7, 'B': 6, 'C': 5, 'D': 4, 'E': 3, 'F': 2, 'G': 1}
df_improvement = df.dropna(subset=['CURRENT_ENERGY_RATING', 'POTENTIAL_ENERGY_RATING'])
df_improvement['CURRENT_SCORE'] = df_improvement['CURRENT_ENERGY_RATING'].map(rating_map)
df_improvement['POTENTIAL_SCORE'] = df_improvement['POTENTIAL_ENERGY_RATING'].map(rating_map)
df_improvement['IMPROVEMENT_POTENTIAL'] = df_improvement['POTENTIAL_SCORE'] - df_improvement['CURRENT_SCORE']
improvement_dist = df_improvement['IMPROVEMENT_POTENTIAL'].value_counts().sort_index()
bars = ax1.bar(improvement_dist.index, improvement_dist.values,
color='steelblue', alpha=0.8)
ax1.set_title('Energy Rating Improvement Potential\n(Number of Rating Bands)',
fontsize=14, fontweight='bold')
ax1.set_xlabel('Potential Improvement (Rating Bands)')
ax1.set_ylabel('Number of Properties')
ax1.grid(True, alpha=0.3, axis='y')
# Add value labels
for bar in bars:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2., height + 100,
f'{int(height):,}', ha='center', va='bottom', fontsize=10)
# Add percentage labels
total_props = improvement_dist.sum()
for i, bar in enumerate(bars):
height = bar.get_height()
pct = (height / total_props) * 100
ax1.text(bar.get_x() + bar.get_width()/2., height/2,
f'{pct:.1f}%', ha='center', va='center',
fontsize=9, fontweight='bold', color='white')
else:
ax1.text(0.5, 0.5, 'Improvement potential data not available', ha='center', va='center', transform=ax1.transAxes)
# Payback Period Analysis (if indicative cost available)
if all(col in df.columns for col in ['POTENTIAL_SAVINGS', 'INDICATIVE_COST']):
df_payback = df.dropna(subset=['POTENTIAL_SAVINGS', 'INDICATIVE_COST'])
df_payback = df_payback[(df_payback['POTENTIAL_SAVINGS'] > 0) &
(df_payback['INDICATIVE_COST'] > 0)]
df_payback['PAYBACK_YEARS'] = df_payback['INDICATIVE_COST'] / df_payback['POTENTIAL_SAVINGS']
df_payback = df_payback[df_payback['PAYBACK_YEARS'] <= 50] # Remove extreme outliers
# Create payback period bins
df_payback['PAYBACK_BIN'] = pd.cut(df_payback['PAYBACK_YEARS'],
bins=[0, 5, 10, 15, 20, 25, 50],
labels=['0-5 years', '5-10 years', '10-15 years',
'15-20 years', '20-25 years', '25+ years'])
payback_dist = df_payback['PAYBACK_BIN'].value_counts()
colors = ['darkgreen', 'green', 'yellowgreen', 'orange', 'orangered', 'red']
bars = ax2.bar(range(len(payback_dist)), payback_dist.values,
color=colors[:len(payback_dist)], alpha=0.8)
ax2.set_title('Investment Payback Period Distribution\n(Based on Potential Savings)',
fontsize=14, fontweight='bold')
ax2.set_ylabel('Number of Properties')
ax2.set_xticks(range(len(payback_dist)))
ax2.set_xticklabels(payback_dist.index, rotation=45)
ax2.grid(True, alpha=0.3, axis='y')
# Add value labels
for bar in bars:
height = bar.get_height()
ax2.text(bar.get_x() + bar.get_width()/2., height + 50,
f'{int(height):,}', ha='center', va='bottom', fontsize=10)
# Add percentage labels
total_props = payback_dist.sum()
for i, bar in enumerate(bars):
height = bar.get_height()
pct = (height / total_props) * 100
ax2.text(bar.get_x() + bar.get_width()/2., height/2,
f'{pct:.1f}%', ha='center', va='center',
fontsize=9, fontweight='bold', color='white')
else:
ax2.text(0.5, 0.5, 'Payback period data not available', ha='center', va='center', transform=ax2.transAxes)
plt.tight_layout()
plt.show()

