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

EPC results in South London

How important the Energy Performance Certificate (EPC) really is in 2025

Home » South London Property Guides & Resources » Guides » How important the Energy Performance Certificate (EPC) really is in 2025

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.

RatingBromleyCroydonLambethSutton
A0.2%0.2%0.1%0.3%
B7.9%12.6%12.5%9.6%
C30.8%29.7%37.5%33.5%
D40.4%38.6%36.4%36.9%
E16.8%15.3%11.2%15.6%
F3.1%2.9%1.8%3.3%
G0.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()

Consider buying or selling in South London? Go with Jukes!

  • Trusted estate agent
  • More than 25 years of experience combined
  • We help you focus on your objective (investment, 1st time buy)