initial commit
This commit is contained in:
44
.dockerignore
Normal file
44
.dockerignore
Normal file
@@ -0,0 +1,44 @@
|
||||
# Virtual environment
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.cursor/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Scripts (not needed in container)
|
||||
scripts/
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
venv
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
COPY data/ ./data/
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 80
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "80"]
|
||||
|
||||
189
README.md
Normal file
189
README.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Primary School Compass 🧒📚
|
||||
|
||||
A modern web application for comparing **primary school (KS2)** performance data in **Wandsworth and Merton** over the last 5 years. Built with FastAPI and vanilla JavaScript with Chart.js visualizations.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 📊 **Interactive Charts** - Visualize KS2 performance trends over time
|
||||
- 🔍 **Smart Search** - Find primary schools by name in Wandsworth & Merton
|
||||
- ⚖️ **Side-by-Side Comparison** - Compare up to 5 schools simultaneously
|
||||
- 🏆 **Rankings** - View top-performing primary schools by various KS2 metrics
|
||||
- 📱 **Responsive Design** - Works beautifully on desktop and mobile
|
||||
|
||||
## Key Metrics (KS2)
|
||||
|
||||
The application tracks these Key Stage 2 performance indicators:
|
||||
|
||||
| Metric | Description |
|
||||
|--------|-------------|
|
||||
| **Reading Progress** | Progress in reading from KS1 to KS2 |
|
||||
| **Writing Progress** | Progress in writing from KS1 to KS2 |
|
||||
| **Maths Progress** | Progress in maths from KS1 to KS2 |
|
||||
| **Reading Expected %** | Percentage meeting expected standard in reading |
|
||||
| **Writing Expected %** | Percentage meeting expected standard in writing |
|
||||
| **Maths Expected %** | Percentage meeting expected standard in maths |
|
||||
| **RWM Combined %** | Percentage meeting expected standard in all three subjects |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and Setup
|
||||
|
||||
```bash
|
||||
cd school_results
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Run the Application
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
python -m uvicorn backend.app:app --reload --port 8000
|
||||
```
|
||||
|
||||
Then open http://localhost:8000 in your browser.
|
||||
|
||||
The app will run with **sample data** by default, showing **110 primary schools** (66 in Wandsworth, 44 in Merton) with 5 years of KS2 performance data.
|
||||
|
||||
### 3. (Optional) Use Real Data
|
||||
|
||||
To use real UK school performance data:
|
||||
|
||||
1. Visit [Compare School Performance - Download Data](https://www.compare-school-performance.service.gov.uk/download-data)
|
||||
|
||||
2. Download **Key Stage 2** data for the years you want (2019-2024)
|
||||
- Select "Key Stage 2" as the data type
|
||||
|
||||
3. Place the CSV files in the `data/` folder
|
||||
|
||||
4. Restart the server - it will automatically load and filter to Wandsworth & Merton schools
|
||||
|
||||
**Note:** The app only displays schools in Wandsworth and Merton. Data from other areas will be filtered out.
|
||||
|
||||
See the helper script for more details:
|
||||
```bash
|
||||
python scripts/download_data.py
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
school_results/
|
||||
├── backend/
|
||||
│ └── app.py # FastAPI application with all API endpoints
|
||||
├── frontend/
|
||||
│ ├── index.html # Main HTML page
|
||||
│ ├── styles.css # Styling (warm, editorial design)
|
||||
│ └── app.js # Frontend JavaScript
|
||||
├── data/
|
||||
│ └── .gitkeep # Place CSV data files here
|
||||
├── scripts/
|
||||
│ └── download_data.py # Helper for downloading/processing data
|
||||
├── requirements.txt # Python dependencies
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/schools` | List schools with optional search/filter |
|
||||
| `GET /api/schools/{urn}` | Get detailed data for a specific school |
|
||||
| `GET /api/compare?urns=...` | Compare multiple schools |
|
||||
| `GET /api/rankings` | Get school rankings by metric |
|
||||
| `GET /api/filters` | Get available filter options |
|
||||
| `GET /api/metrics` | Get available performance metrics |
|
||||
|
||||
### Example API Usage
|
||||
|
||||
```bash
|
||||
# Search for schools
|
||||
curl "http://localhost:8000/api/schools?search=academy"
|
||||
|
||||
# Get school details
|
||||
curl "http://localhost:8000/api/schools/100001"
|
||||
|
||||
# Compare schools
|
||||
curl "http://localhost:8000/api/compare?urns=100001,100002,100003"
|
||||
|
||||
# Get rankings
|
||||
curl "http://localhost:8000/api/rankings?metric=rwm_expected_pct&year=2024"
|
||||
```
|
||||
|
||||
## Data Format
|
||||
|
||||
If using your own CSV data, ensure it includes these columns (or similar):
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| URN | Integer | Unique Reference Number |
|
||||
| SCHNAME | String | School name |
|
||||
| LA | String | Local Authority (must be Wandsworth or Merton) |
|
||||
| READPROG | Float | Reading progress score |
|
||||
| WRITPROG | Float | Writing progress score |
|
||||
| MATPROG | Float | Maths progress score |
|
||||
| PTRWM_EXP | Float | % meeting expected standard in RWM |
|
||||
| PTREAD_EXP | Float | % meeting expected standard in reading |
|
||||
| PTWRIT_EXP | Float | % meeting expected standard in writing |
|
||||
| PTMAT_EXP | Float | % meeting expected standard in maths |
|
||||
|
||||
The application normalizes column names automatically and filters to only show Wandsworth and Merton schools.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: FastAPI (Python) - High-performance async API framework
|
||||
- **Frontend**: Vanilla JavaScript with Chart.js
|
||||
- **Styling**: Custom CSS with CSS variables for theming
|
||||
- **Data**: Pandas for CSV processing
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
The UI features a warm, editorial design inspired by quality publications:
|
||||
- **Typography**: DM Sans for body text, Playfair Display for headings
|
||||
- **Color Palette**: Warm cream background with coral and teal accents
|
||||
- **Interactions**: Smooth animations and hover effects
|
||||
- **Charts**: Clean, readable data visualizations
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run with auto-reload
|
||||
python -m uvicorn backend.app:app --reload --port 8000
|
||||
|
||||
# Or run directly
|
||||
python backend/app.py
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
This application is specifically designed for:
|
||||
|
||||
- **School Phase**: Primary schools only (Key Stage 2)
|
||||
- **Geographic Area**: Wandsworth and Merton (London boroughs)
|
||||
- **Time Period**: Last 5 years of data (2020-2024)
|
||||
|
||||
Note: 2021 data shows as unavailable because SATs were cancelled due to COVID-19.
|
||||
|
||||
## Data Source
|
||||
|
||||
Data is sourced from the UK Government's [Compare School Performance](https://www.compare-school-performance.service.gov.uk/) service, which provides official school performance data for England.
|
||||
|
||||
**Important**: When using real data, please comply with the [terms of use](https://www.compare-school-performance.service.gov.uk/download-data) and data protection regulations.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - feel free to use this project for educational purposes.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ for Wandsworth & Merton families
|
||||
|
||||
549
backend/app.py
Normal file
549
backend/app.py
Normal file
@@ -0,0 +1,549 @@
|
||||
"""
|
||||
Primary School Performance Comparison API
|
||||
Serves primary school (KS2) performance data for Wandsworth and Merton.
|
||||
Uses real data from UK Government Compare School Performance downloads.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import os
|
||||
import re
|
||||
|
||||
# Local Authority codes for Wandsworth and Merton
|
||||
LA_CODES = {
|
||||
212: "Wandsworth",
|
||||
315: "Merton"
|
||||
}
|
||||
ALLOWED_LA_CODES = list(LA_CODES.keys())
|
||||
|
||||
app = FastAPI(
|
||||
title="Primary School Performance API - Wandsworth & Merton",
|
||||
description="API for comparing primary school (KS2) performance data in Wandsworth and Merton",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS middleware for development
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Data directory
|
||||
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||
|
||||
# Cache for loaded data - cleared on reload (updated for 2016-2017 data)
|
||||
_data_cache: Optional[pd.DataFrame] = None
|
||||
|
||||
|
||||
def convert_to_native(value):
|
||||
"""Convert numpy types to native Python types for JSON serialization."""
|
||||
if pd.isna(value):
|
||||
return None
|
||||
if isinstance(value, (np.integer,)):
|
||||
return int(value)
|
||||
if isinstance(value, (np.floating,)):
|
||||
if np.isnan(value) or np.isinf(value):
|
||||
return None
|
||||
return float(value)
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.tolist()
|
||||
if value == "SUPP" or value == "NE" or value == "NA" or value == "NP":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def clean_for_json(df: pd.DataFrame) -> list:
|
||||
"""Convert DataFrame to list of dicts, replacing NaN/inf with None for JSON serialization."""
|
||||
records = df.to_dict(orient="records")
|
||||
cleaned = []
|
||||
for record in records:
|
||||
clean_record = {}
|
||||
for key, value in record.items():
|
||||
clean_record[key] = convert_to_native(value)
|
||||
cleaned.append(clean_record)
|
||||
return cleaned
|
||||
|
||||
|
||||
def parse_numeric(value):
|
||||
"""Parse a value to numeric, handling SUPP, NE, NA, %, etc."""
|
||||
if pd.isna(value):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
if np.isnan(value) or np.isinf(value):
|
||||
return None
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if value in ["SUPP", "NE", "NA", "NP", "NEW", "LOW", ""]:
|
||||
return None
|
||||
# Remove % sign if present
|
||||
if value.endswith('%'):
|
||||
value = value[:-1]
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def extract_year_from_folder(folder_name: str) -> Optional[int]:
|
||||
"""Extract the end year from folder name like '2023-2024' -> 2024."""
|
||||
match = re.search(r'(\d{4})-(\d{4})', folder_name)
|
||||
if match:
|
||||
return int(match.group(2))
|
||||
return None
|
||||
|
||||
|
||||
def load_school_data() -> pd.DataFrame:
|
||||
"""Load and combine all school data from CSV files in year folders."""
|
||||
global _data_cache
|
||||
|
||||
if _data_cache is not None:
|
||||
return _data_cache
|
||||
|
||||
all_data = []
|
||||
|
||||
# Look for year folders in data directory
|
||||
if DATA_DIR.exists():
|
||||
for year_folder in DATA_DIR.iterdir():
|
||||
if year_folder.is_dir() and re.match(r'\d{4}-\d{4}', year_folder.name):
|
||||
year = extract_year_from_folder(year_folder.name)
|
||||
if year is None:
|
||||
continue
|
||||
|
||||
# Look for KS2 data file
|
||||
ks2_file = year_folder / "england_ks2final.csv"
|
||||
if ks2_file.exists():
|
||||
try:
|
||||
print(f"Loading data from {ks2_file}")
|
||||
df = pd.read_csv(ks2_file, low_memory=False)
|
||||
|
||||
# Filter to Wandsworth (212) and Merton (315)
|
||||
# Handle both string and integer columns
|
||||
if df['LEA'].dtype == 'object':
|
||||
df['LEA'] = pd.to_numeric(df['LEA'], errors='coerce')
|
||||
if df['URN'].dtype == 'object':
|
||||
df['URN'] = pd.to_numeric(df['URN'], errors='coerce')
|
||||
df = df[df['LEA'].isin(ALLOWED_LA_CODES)]
|
||||
|
||||
# Filter to schools only (RECTYPE == 1 means school level data)
|
||||
if 'RECTYPE' in df.columns:
|
||||
df = df[df['RECTYPE'] == 1]
|
||||
|
||||
# Add year and local authority name
|
||||
df['year'] = year
|
||||
df['local_authority'] = df['LEA'].map(LA_CODES)
|
||||
|
||||
# Standardize column names for our API
|
||||
df = df.rename(columns={
|
||||
'URN': 'urn',
|
||||
'SCHNAME': 'school_name',
|
||||
'ADDRESS1': 'address1',
|
||||
'ADDRESS2': 'address2',
|
||||
'TOWN': 'town',
|
||||
'PCODE': 'postcode',
|
||||
'NFTYPE': 'school_type_code',
|
||||
'RELDENOM': 'religious_denomination',
|
||||
'AGERANGE': 'age_range',
|
||||
'TOTPUPS': 'total_pupils',
|
||||
'TELIG': 'eligible_pupils',
|
||||
# Core KS2 metrics
|
||||
'PTRWM_EXP': 'rwm_expected_pct',
|
||||
'PTRWM_HIGH': 'rwm_high_pct',
|
||||
'READPROG': 'reading_progress',
|
||||
'WRITPROG': 'writing_progress',
|
||||
'MATPROG': 'maths_progress',
|
||||
'PTREAD_EXP': 'reading_expected_pct',
|
||||
'PTWRITTA_EXP': 'writing_expected_pct',
|
||||
'PTMAT_EXP': 'maths_expected_pct',
|
||||
'READ_AVERAGE': 'reading_avg_score',
|
||||
'MAT_AVERAGE': 'maths_avg_score',
|
||||
'PTREAD_HIGH': 'reading_high_pct',
|
||||
'PTWRITTA_HIGH': 'writing_high_pct',
|
||||
'PTMAT_HIGH': 'maths_high_pct',
|
||||
# GPS (Grammar, Punctuation & Spelling)
|
||||
'PTGPS_EXP': 'gps_expected_pct',
|
||||
'PTGPS_HIGH': 'gps_high_pct',
|
||||
'GPS_AVERAGE': 'gps_avg_score',
|
||||
# Science
|
||||
'PTSCITA_EXP': 'science_expected_pct',
|
||||
# School context
|
||||
'PTFSM6CLA1A': 'disadvantaged_pct',
|
||||
'PTEALGRP2': 'eal_pct',
|
||||
'PSENELK': 'sen_support_pct',
|
||||
'PSENELE': 'sen_ehcp_pct',
|
||||
'PTMOBN': 'stability_pct',
|
||||
# Gender breakdown
|
||||
'PTRWM_EXP_B': 'rwm_expected_boys_pct',
|
||||
'PTRWM_EXP_G': 'rwm_expected_girls_pct',
|
||||
'PTRWM_HIGH_B': 'rwm_high_boys_pct',
|
||||
'PTRWM_HIGH_G': 'rwm_high_girls_pct',
|
||||
# Disadvantaged performance
|
||||
'PTRWM_EXP_FSM6CLA1A': 'rwm_expected_disadvantaged_pct',
|
||||
'PTRWM_EXP_NotFSM6CLA1A': 'rwm_expected_non_disadvantaged_pct',
|
||||
'DIFFN_RWM_EXP': 'disadvantaged_gap',
|
||||
# 3-year averages
|
||||
'PTRWM_EXP_3YR': 'rwm_expected_3yr_pct',
|
||||
'READ_AVERAGE_3YR': 'reading_avg_3yr',
|
||||
'MAT_AVERAGE_3YR': 'maths_avg_3yr',
|
||||
})
|
||||
|
||||
# Create address field
|
||||
def make_address(row):
|
||||
parts = []
|
||||
if pd.notna(row.get('address1')) and row.get('address1'):
|
||||
parts.append(str(row['address1']))
|
||||
if pd.notna(row.get('town')) and row.get('town'):
|
||||
parts.append(str(row['town']))
|
||||
if pd.notna(row.get('postcode')) and row.get('postcode'):
|
||||
parts.append(str(row['postcode']))
|
||||
return ', '.join(parts) if parts else ''
|
||||
|
||||
df['address'] = df.apply(make_address, axis=1)
|
||||
|
||||
# Map school type codes to names
|
||||
school_type_map = {
|
||||
'AC': 'Academy', 'ACC': 'Academy Converter', 'ACS': 'Academy Sponsor Led',
|
||||
'CY': 'Community School', 'VA': 'Voluntary Aided', 'VC': 'Voluntary Controlled',
|
||||
'FD': 'Foundation', 'F': 'Foundation', 'FS': 'Free School',
|
||||
}
|
||||
df['school_type'] = df['school_type_code'].map(school_type_map).fillna('Other')
|
||||
|
||||
# Parse numeric columns
|
||||
numeric_cols = [
|
||||
# Core metrics
|
||||
'rwm_expected_pct', 'rwm_high_pct', 'reading_progress',
|
||||
'writing_progress', 'maths_progress', 'reading_expected_pct',
|
||||
'writing_expected_pct', 'maths_expected_pct', 'reading_avg_score',
|
||||
'maths_avg_score', 'reading_high_pct', 'writing_high_pct', 'maths_high_pct',
|
||||
# GPS & Science
|
||||
'gps_expected_pct', 'gps_high_pct', 'gps_avg_score', 'science_expected_pct',
|
||||
# School context
|
||||
'total_pupils', 'eligible_pupils', 'disadvantaged_pct', 'eal_pct',
|
||||
'sen_support_pct', 'sen_ehcp_pct', 'stability_pct',
|
||||
# Gender breakdown
|
||||
'rwm_expected_boys_pct', 'rwm_expected_girls_pct',
|
||||
'rwm_high_boys_pct', 'rwm_high_girls_pct',
|
||||
# Disadvantaged performance
|
||||
'rwm_expected_disadvantaged_pct', 'rwm_expected_non_disadvantaged_pct', 'disadvantaged_gap',
|
||||
# 3-year averages
|
||||
'rwm_expected_3yr_pct', 'reading_avg_3yr', 'maths_avg_3yr',
|
||||
]
|
||||
|
||||
for col in numeric_cols:
|
||||
if col in df.columns:
|
||||
df[col] = df[col].apply(parse_numeric)
|
||||
|
||||
all_data.append(df)
|
||||
print(f" Loaded {len(df)} schools for year {year}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading {ks2_file}: {e}")
|
||||
|
||||
if all_data:
|
||||
_data_cache = pd.concat(all_data, ignore_index=True)
|
||||
print(f"\nTotal records loaded: {len(_data_cache)}")
|
||||
print(f"Unique schools: {_data_cache['urn'].nunique()}")
|
||||
print(f"Years: {sorted(_data_cache['year'].unique())}")
|
||||
else:
|
||||
print("No data files found. Creating empty DataFrame.")
|
||||
_data_cache = pd.DataFrame()
|
||||
|
||||
return _data_cache
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Serve the frontend."""
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/api/schools")
|
||||
async def get_schools(
|
||||
search: Optional[str] = Query(None, description="Search by school name"),
|
||||
local_authority: Optional[str] = Query(None, description="Filter by local authority (Wandsworth or Merton)"),
|
||||
school_type: Optional[str] = Query(None, description="Filter by school type"),
|
||||
):
|
||||
"""Get list of unique primary schools in Wandsworth and Merton."""
|
||||
df = load_school_data()
|
||||
|
||||
if df.empty:
|
||||
return {"schools": []}
|
||||
|
||||
# Get unique schools (latest year data for each)
|
||||
latest_year = df.groupby('urn')['year'].max().reset_index()
|
||||
df_latest = df.merge(latest_year, on=['urn', 'year'])
|
||||
|
||||
school_cols = ["urn", "school_name", "local_authority", "school_type", "address", "town", "postcode"]
|
||||
available_cols = [c for c in school_cols if c in df_latest.columns]
|
||||
schools_df = df_latest[available_cols].drop_duplicates(subset=['urn'])
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
mask = schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
|
||||
if "address" in schools_df.columns:
|
||||
mask = mask | schools_df["address"].str.lower().str.contains(search_lower, na=False)
|
||||
schools_df = schools_df[mask]
|
||||
|
||||
if local_authority:
|
||||
schools_df = schools_df[schools_df["local_authority"].str.lower() == local_authority.lower()]
|
||||
|
||||
if school_type:
|
||||
schools_df = schools_df[schools_df["school_type"].str.lower() == school_type.lower()]
|
||||
|
||||
return {"schools": clean_for_json(schools_df)}
|
||||
|
||||
|
||||
@app.get("/api/schools/{urn}")
|
||||
async def get_school_details(urn: int):
|
||||
"""Get detailed KS2 data for a specific primary school across all years."""
|
||||
df = load_school_data()
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=404, detail="No data available")
|
||||
|
||||
school_data = df[df["urn"] == urn]
|
||||
|
||||
if school_data.empty:
|
||||
raise HTTPException(status_code=404, detail="School not found")
|
||||
|
||||
# Sort by year
|
||||
school_data = school_data.sort_values("year")
|
||||
|
||||
# Get latest info for the school
|
||||
latest = school_data.iloc[-1]
|
||||
|
||||
return {
|
||||
"school_info": {
|
||||
"urn": urn,
|
||||
"school_name": latest.get("school_name", ""),
|
||||
"local_authority": latest.get("local_authority", ""),
|
||||
"school_type": latest.get("school_type", ""),
|
||||
"address": latest.get("address", ""),
|
||||
"phase": "Primary",
|
||||
},
|
||||
"yearly_data": clean_for_json(school_data)
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/compare")
|
||||
async def compare_schools(urns: str = Query(..., description="Comma-separated URNs")):
|
||||
"""Compare multiple primary schools side by side."""
|
||||
df = load_school_data()
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=404, detail="No data available")
|
||||
|
||||
try:
|
||||
urn_list = [int(u.strip()) for u in urns.split(",")]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid URN format")
|
||||
|
||||
comparison_data = df[df["urn"].isin(urn_list)]
|
||||
|
||||
if comparison_data.empty:
|
||||
raise HTTPException(status_code=404, detail="No schools found")
|
||||
|
||||
result = {}
|
||||
for urn in urn_list:
|
||||
school_data = comparison_data[comparison_data["urn"] == urn].sort_values("year")
|
||||
if not school_data.empty:
|
||||
latest = school_data.iloc[-1]
|
||||
result[str(urn)] = {
|
||||
"school_info": {
|
||||
"urn": urn,
|
||||
"school_name": latest.get("school_name", ""),
|
||||
"local_authority": latest.get("local_authority", ""),
|
||||
"address": latest.get("address", ""),
|
||||
},
|
||||
"yearly_data": clean_for_json(school_data)
|
||||
}
|
||||
|
||||
return {"comparison": result}
|
||||
|
||||
|
||||
@app.get("/api/filters")
|
||||
async def get_filter_options():
|
||||
"""Get available filter options (local authorities, school types, years)."""
|
||||
df = load_school_data()
|
||||
|
||||
if df.empty:
|
||||
return {
|
||||
"local_authorities": ["Wandsworth", "Merton"],
|
||||
"school_types": [],
|
||||
"years": [],
|
||||
}
|
||||
|
||||
return {
|
||||
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
|
||||
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
|
||||
"years": sorted(df["year"].dropna().unique().tolist()),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/metrics")
|
||||
async def get_available_metrics():
|
||||
"""Get list of available KS2 performance metrics for primary schools."""
|
||||
df = load_school_data()
|
||||
|
||||
# Define KS2 metric metadata organized by category
|
||||
metric_info = {
|
||||
# Expected Standard
|
||||
"rwm_expected_pct": {"name": "RWM Combined %", "description": "% meeting expected standard in reading, writing and maths", "type": "percentage", "category": "expected"},
|
||||
"reading_expected_pct": {"name": "Reading Expected %", "description": "% meeting expected standard in reading", "type": "percentage", "category": "expected"},
|
||||
"writing_expected_pct": {"name": "Writing Expected %", "description": "% meeting expected standard in writing", "type": "percentage", "category": "expected"},
|
||||
"maths_expected_pct": {"name": "Maths Expected %", "description": "% meeting expected standard in maths", "type": "percentage", "category": "expected"},
|
||||
"gps_expected_pct": {"name": "GPS Expected %", "description": "% meeting expected standard in grammar, punctuation & spelling", "type": "percentage", "category": "expected"},
|
||||
"science_expected_pct": {"name": "Science Expected %", "description": "% meeting expected standard in science", "type": "percentage", "category": "expected"},
|
||||
# Higher Standard
|
||||
"rwm_high_pct": {"name": "RWM Combined Higher %", "description": "% achieving higher standard in RWM combined", "type": "percentage", "category": "higher"},
|
||||
"reading_high_pct": {"name": "Reading Higher %", "description": "% achieving higher standard in reading", "type": "percentage", "category": "higher"},
|
||||
"writing_high_pct": {"name": "Writing Higher %", "description": "% achieving greater depth in writing", "type": "percentage", "category": "higher"},
|
||||
"maths_high_pct": {"name": "Maths Higher %", "description": "% achieving higher standard in maths", "type": "percentage", "category": "higher"},
|
||||
"gps_high_pct": {"name": "GPS Higher %", "description": "% achieving higher standard in GPS", "type": "percentage", "category": "higher"},
|
||||
# Progress Scores
|
||||
"reading_progress": {"name": "Reading Progress", "description": "Progress in reading from KS1 to KS2", "type": "score", "category": "progress"},
|
||||
"writing_progress": {"name": "Writing Progress", "description": "Progress in writing from KS1 to KS2", "type": "score", "category": "progress"},
|
||||
"maths_progress": {"name": "Maths Progress", "description": "Progress in maths from KS1 to KS2", "type": "score", "category": "progress"},
|
||||
# Average Scores
|
||||
"reading_avg_score": {"name": "Reading Avg Score", "description": "Average scaled score in reading", "type": "score", "category": "average"},
|
||||
"maths_avg_score": {"name": "Maths Avg Score", "description": "Average scaled score in maths", "type": "score", "category": "average"},
|
||||
"gps_avg_score": {"name": "GPS Avg Score", "description": "Average scaled score in GPS", "type": "score", "category": "average"},
|
||||
# Gender Performance
|
||||
"rwm_expected_boys_pct": {"name": "RWM Expected % (Boys)", "description": "% of boys meeting expected standard", "type": "percentage", "category": "gender"},
|
||||
"rwm_expected_girls_pct": {"name": "RWM Expected % (Girls)", "description": "% of girls meeting expected standard", "type": "percentage", "category": "gender"},
|
||||
"rwm_high_boys_pct": {"name": "RWM Higher % (Boys)", "description": "% of boys at higher standard", "type": "percentage", "category": "gender"},
|
||||
"rwm_high_girls_pct": {"name": "RWM Higher % (Girls)", "description": "% of girls at higher standard", "type": "percentage", "category": "gender"},
|
||||
# Disadvantaged Performance
|
||||
"rwm_expected_disadvantaged_pct": {"name": "RWM Expected % (Disadvantaged)", "description": "% of disadvantaged pupils meeting expected", "type": "percentage", "category": "equity"},
|
||||
"rwm_expected_non_disadvantaged_pct": {"name": "RWM Expected % (Non-Disadvantaged)", "description": "% of non-disadvantaged pupils meeting expected", "type": "percentage", "category": "equity"},
|
||||
"disadvantaged_gap": {"name": "Disadvantaged Gap", "description": "Gap between disadvantaged and national non-disadvantaged", "type": "score", "category": "equity"},
|
||||
# School Context
|
||||
"disadvantaged_pct": {"name": "% Disadvantaged Pupils", "description": "% of pupils eligible for free school meals or looked after", "type": "percentage", "category": "context"},
|
||||
"eal_pct": {"name": "% EAL Pupils", "description": "% of pupils with English as additional language", "type": "percentage", "category": "context"},
|
||||
"sen_support_pct": {"name": "% SEN Support", "description": "% of pupils with SEN support", "type": "percentage", "category": "context"},
|
||||
"stability_pct": {"name": "% Pupil Stability", "description": "% of non-mobile pupils (stayed at school)", "type": "percentage", "category": "context"},
|
||||
# 3-Year Averages
|
||||
"rwm_expected_3yr_pct": {"name": "RWM Expected % (3-Year Avg)", "description": "3-year average % meeting expected", "type": "percentage", "category": "trends"},
|
||||
"reading_avg_3yr": {"name": "Reading Score (3-Year Avg)", "description": "3-year average reading score", "type": "score", "category": "trends"},
|
||||
"maths_avg_3yr": {"name": "Maths Score (3-Year Avg)", "description": "3-year average maths score", "type": "score", "category": "trends"},
|
||||
}
|
||||
|
||||
available = []
|
||||
for col, info in metric_info.items():
|
||||
if df.empty or col in df.columns:
|
||||
available.append({"key": col, **info})
|
||||
|
||||
return {"metrics": available}
|
||||
|
||||
|
||||
@app.get("/api/rankings")
|
||||
async def get_rankings(
|
||||
metric: str = Query("rwm_expected_pct", description="KS2 metric to rank by"),
|
||||
year: Optional[int] = Query(None, description="Specific year (defaults to most recent)"),
|
||||
limit: int = Query(20, description="Number of schools to return"),
|
||||
):
|
||||
"""Get primary school rankings by a specific KS2 metric."""
|
||||
df = load_school_data()
|
||||
|
||||
if df.empty:
|
||||
return {"metric": metric, "year": None, "rankings": []}
|
||||
|
||||
if metric not in df.columns:
|
||||
raise HTTPException(status_code=400, detail=f"Metric '{metric}' not available")
|
||||
|
||||
# Filter by year
|
||||
if year:
|
||||
df = df[df["year"] == year]
|
||||
else:
|
||||
# Use most recent year
|
||||
max_year = df["year"].max()
|
||||
df = df[df["year"] == max_year]
|
||||
|
||||
# Sort and rank (exclude rows with no data for this metric)
|
||||
df = df.dropna(subset=[metric])
|
||||
|
||||
# For progress scores, higher is better. For percentages, higher is also better.
|
||||
df = df.sort_values(metric, ascending=False).head(limit)
|
||||
|
||||
# Return only relevant fields for rankings
|
||||
ranking_cols = [
|
||||
"urn", "school_name", "local_authority", "school_type", "address", "year", "total_pupils",
|
||||
# Core expected
|
||||
"rwm_expected_pct", "reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
||||
"gps_expected_pct", "science_expected_pct",
|
||||
# Core higher
|
||||
"rwm_high_pct", "reading_high_pct", "writing_high_pct", "maths_high_pct", "gps_high_pct",
|
||||
# Progress & averages
|
||||
"reading_progress", "writing_progress", "maths_progress",
|
||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||
# Gender
|
||||
"rwm_expected_boys_pct", "rwm_expected_girls_pct", "rwm_high_boys_pct", "rwm_high_girls_pct",
|
||||
# Equity
|
||||
"rwm_expected_disadvantaged_pct", "rwm_expected_non_disadvantaged_pct", "disadvantaged_gap",
|
||||
# Context
|
||||
"disadvantaged_pct", "eal_pct", "sen_support_pct", "stability_pct",
|
||||
# 3-year
|
||||
"rwm_expected_3yr_pct", "reading_avg_3yr", "maths_avg_3yr",
|
||||
]
|
||||
available_cols = [c for c in ranking_cols if c in df.columns]
|
||||
df = df[available_cols]
|
||||
|
||||
return {
|
||||
"metric": metric,
|
||||
"year": int(df["year"].iloc[0]) if not df.empty else None,
|
||||
"rankings": clean_for_json(df)
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/data-info")
|
||||
async def get_data_info():
|
||||
"""Get information about loaded data."""
|
||||
df = load_school_data()
|
||||
|
||||
if df.empty:
|
||||
return {
|
||||
"status": "no_data",
|
||||
"message": "No data files found in data folder. Please download KS2 data from the government website.",
|
||||
"data_folder": str(DATA_DIR),
|
||||
}
|
||||
|
||||
years = [int(y) for y in sorted(df["year"].unique())]
|
||||
schools_per_year = {str(int(k)): int(v) for k, v in df.groupby("year")["urn"].nunique().to_dict().items()}
|
||||
la_counts = {str(k): int(v) for k, v in df["local_authority"].value_counts().to_dict().items()}
|
||||
|
||||
return {
|
||||
"status": "loaded",
|
||||
"total_records": int(len(df)),
|
||||
"unique_schools": int(df["urn"].nunique()),
|
||||
"years_available": years,
|
||||
"schools_per_year": schools_per_year,
|
||||
"local_authorities": la_counts,
|
||||
}
|
||||
|
||||
|
||||
# Mount static files
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
"""Setup static file serving and load data on startup."""
|
||||
if FRONTEND_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
|
||||
# Pre-load data
|
||||
load_school_data()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
BIN
data/.DS_Store
vendored
Normal file
BIN
data/.DS_Store
vendored
Normal file
Binary file not shown.
3
data/.gitkeep
Normal file
3
data/.gitkeep
Normal file
@@ -0,0 +1,3 @@
|
||||
# Place your CSV data files here
|
||||
# Download from: https://www.compare-school-performance.service.gov.uk/download-data
|
||||
|
||||
23511
data/2016-2017/england_census.csv
Normal file
23511
data/2016-2017/england_census.csv
Normal file
File diff suppressed because it is too large
Load Diff
16468
data/2016-2017/england_ks2final.csv
Normal file
16468
data/2016-2017/england_ks2final.csv
Normal file
File diff suppressed because it is too large
Load Diff
26329
data/2016-2017/england_spine.csv
Normal file
26329
data/2016-2017/england_spine.csv
Normal file
File diff suppressed because it is too large
Load Diff
23557
data/2017-2018/england_census.csv
Normal file
23557
data/2017-2018/england_census.csv
Normal file
File diff suppressed because it is too large
Load Diff
16519
data/2017-2018/england_ks2final.csv
Normal file
16519
data/2017-2018/england_ks2final.csv
Normal file
File diff suppressed because it is too large
Load Diff
26079
data/2017-2018/england_spine.csv
Normal file
26079
data/2017-2018/england_spine.csv
Normal file
File diff suppressed because it is too large
Load Diff
23943
data/2018-2019/england_census.csv
Normal file
23943
data/2018-2019/england_census.csv
Normal file
File diff suppressed because it is too large
Load Diff
16509
data/2018-2019/england_ks2final.csv
Normal file
16509
data/2018-2019/england_ks2final.csv
Normal file
File diff suppressed because it is too large
Load Diff
26167
data/2018-2019/england_school_information.csv
Normal file
26167
data/2018-2019/england_school_information.csv
Normal file
File diff suppressed because it is too large
Load Diff
24437
data/2022-2023/england_census.csv
Normal file
24437
data/2022-2023/england_census.csv
Normal file
File diff suppressed because it is too large
Load Diff
16540
data/2022-2023/england_ks2final.csv
Normal file
16540
data/2022-2023/england_ks2final.csv
Normal file
File diff suppressed because it is too large
Load Diff
25387
data/2022-2023/england_school_information.csv
Normal file
25387
data/2022-2023/england_school_information.csv
Normal file
File diff suppressed because it is too large
Load Diff
24448
data/2023-2024/england_census.csv
Normal file
24448
data/2023-2024/england_census.csv
Normal file
File diff suppressed because it is too large
Load Diff
16638
data/2023-2024/england_ks2final.csv
Normal file
16638
data/2023-2024/england_ks2final.csv
Normal file
File diff suppressed because it is too large
Load Diff
25548
data/2023-2024/england_school_information.csv
Normal file
25548
data/2023-2024/england_school_information.csv
Normal file
File diff suppressed because it is too large
Load Diff
24474
data/2024-2025/england_census.csv
Normal file
24474
data/2024-2025/england_census.csv
Normal file
File diff suppressed because it is too large
Load Diff
16564
data/2024-2025/england_ks2final.csv
Normal file
16564
data/2024-2025/england_ks2final.csv
Normal file
File diff suppressed because it is too large
Load Diff
25633
data/2024-2025/england_school_information.csv
Normal file
25633
data/2024-2025/england_school_information.csv
Normal file
File diff suppressed because it is too large
Load Diff
BIN
data/meta/abbreviations.xlsx
Normal file
BIN
data/meta/abbreviations.xlsx
Normal file
Binary file not shown.
24
data/meta/census_meta.csv
Normal file
24
data/meta/census_meta.csv
Normal file
@@ -0,0 +1,24 @@
|
||||
Field Number,Field Reference,Field Name,Values,Data Format,LA level field?,National level field?
|
||||
1,URN,School Unique Reference Number,999999,I6,No,No
|
||||
2,LA,LA number,999,I3,Yes,No
|
||||
3,ESTAB,ESTAB number,9999,I4,No,No
|
||||
4,SCHOOLTYPE,Type of school,String,,No,No
|
||||
5,NOR,Total number of pupils on roll,9999 or NA,,Yes,Yes
|
||||
6,NORG,Number of girls on roll,9999 or NA,,Yes,Yes
|
||||
7,NORB,Number of boys on roll,9999 or NA,,Yes,Yes
|
||||
8,PNORG,Percentage of girls on roll,99.9 or NA,,Yes,Yes
|
||||
9,PNORB,Percentage of boys on roll,99.9 or NA,,Yes,Yes
|
||||
10,TSENELSE,Number of eligible pupils with an EHC plan,9999 or NA,A4,Yes,Yes
|
||||
11,PSENELSE,Percentage of eligible pupils with an EHC plan,99.9 or NA,A4,Yes,Yes
|
||||
12,TSENELK,Number of eligible pupils with SEN support,9999 or NA,A4,Yes,Yes
|
||||
13,PSENELK,Percentage of eligible pupils with SEN support,99.9 or NA,A4,Yes,Yes
|
||||
14,NUMEAL,No. pupils where English not first language,9999 or NA,A4,Yes,Yes
|
||||
15,NUMENGFL,No. pupils with English first language,9999 or NA,A4,Yes,Yes
|
||||
16,NUMUNCFL,No. pupils where first language is unclassified,9999 or NA,A4,Yes,Yes
|
||||
17,PNUMEAL,% pupils where English not first language,99.9 or NA,A4,Yes,Yes
|
||||
18,PNUMENGFL,% pupils with English first language,99.9 or NA,A4,Yes,Yes
|
||||
19,PNUMUNCFL,% pupils where first language is unclassified,99.9 or NA,A4,Yes,Yes
|
||||
20,NUMFSM,No. pupils eligible for free school meals,9999 or NA,A4,Yes,Yes
|
||||
21,NUMFSMEVER,Number of pupils eligible for FSM at any time during the past 6 years,9999 or NA,A6,Yes,Yes
|
||||
22,NORFSMEVER,Total pupils for FSMEver,9999 or NA,,Yes,Yes
|
||||
23,PNUMFSMEVER,Percentage of pupils eligible for FSM at any time during the past 6 years,99.9 or NA,A4,Yes,Yes
|
||||
|
312
data/meta/ks2_meta.csv
Normal file
312
data/meta/ks2_meta.csv
Normal file
@@ -0,0 +1,312 @@
|
||||
Column,Field Name,Label/Description
|
||||
1,RECTYPE,Record type
|
||||
2,AlphaIND,Alphabetic index
|
||||
3,LEA,Local authority number
|
||||
4,ESTAB,Establishment number
|
||||
5,URN,School unique reference number
|
||||
6,SCHNAME,School/Local authority name
|
||||
7,ADDRESS1,School address (1)
|
||||
8,ADDRESS2,School address (2)
|
||||
9,ADDRESS3,School address (3)
|
||||
10,TOWN,School town
|
||||
11,PCODE,School postcode
|
||||
12,TELNUM,School telephone number
|
||||
13,PCON_CODE,School parliamentary constituency code
|
||||
14,PCON_NAME,School parliamentary constituency name
|
||||
15,URN_AC,Converter academy: URN
|
||||
16,SCHNAME_AC,Converter academy: name
|
||||
17,OPEN_AC,Converter academy: open date
|
||||
18,NFTYPE,School type
|
||||
19,ICLOSE,Closed Flag
|
||||
20,RELDENOM,Religious denomination
|
||||
21,AGERANGE,Age range
|
||||
22,TAB15,School published in secondary school (key stage 4) performance tables
|
||||
23,TAB1618,School published in school and college (key stage 5) performance tables
|
||||
24,TOTPUPS,Total number of pupils (including part-time pupils)
|
||||
25,TPUPYEAR,Number of pupils aged 11
|
||||
26,TELIG,Published eligible pupil number
|
||||
27,BELIG,Eligible boys on school roll at time of tests
|
||||
28,GELIG,Eligible girls on school roll at time of tests
|
||||
29,PBELIG,Percentage of eligible boys on school roll at time of tests
|
||||
30,PGELIG,Percentage of eligible girls on school roll at time of tests
|
||||
31,TKS1AVERAGE,Cohort level key stage 1 average points score [not populated in 2025]
|
||||
32,TKS1GROUP_L,Number of pupils in cohort with low KS1 attainment [not populated in 2025]
|
||||
33,PTKS1GROUP_L,Percentage of pupils in cohort with low KS1 attainment [not populated in 2025]
|
||||
34,TKS1GROUP_M,Number of pupils in cohort with medium KS1 attainment [not populated in 2025]
|
||||
35,PTKS1GROUP_M,Percentage of pupils in cohort with medium KS1 attainment [not populated in 2025]
|
||||
36,TKS1GROUP_H,Number of pupils in cohort high KS1 attainment [not populated in 2025]
|
||||
37,PTKS1GROUP_H,Percentage of pupils in cohort with high KS1 attainment [not populated in 2025]
|
||||
38,TKS1GROUP_NA,No. of pupils in KS1 group not calculable [not populated in 2025]
|
||||
39,PTKS1GROUP_NA,Percentage of pupils in KS1group not calculable [not populated in 2025]
|
||||
40,TFSM6CLA1A,Number of key stage 2 disadvantaged pupils (those who were eligible for free school meals in last 6 years or are looked after by the LA for a day or more or who have been adopted from care)
|
||||
41,PTFSM6CLA1A,Percentage of key stage 2 disadvantaged pupils
|
||||
42,TNotFSM6CLA1A,Number of key stage 2 pupils who are not disadvantaged
|
||||
43,PTNotFSM6CLA1A,Percentage of key stage 2 pupils who are not disadvantaged
|
||||
44,TEALGRP2,Number of eligible pupils with English as additional language (EAL)
|
||||
45,PTEALGRP2,Percentage of eligible pupils with English as additional language (EAL)
|
||||
46,TMOBN,Number of eligible pupils classified as non-mobile
|
||||
47,PTMOBN,Percentage of eligible pupils classified as non-mobile
|
||||
48,PTRWM_EXP,"Percentage of pupils reaching the expected standard in reading, writing and maths"
|
||||
49,PTRWM_HIGH,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
50,READPROG,Reading progress measure [not populated in 2025]
|
||||
51,READPROG_LOWER,Reading progress measure - lower confidence limit [not populated in 2025]
|
||||
52,READPROG_UPPER,Reading progress measure - upper confidence limit [not populated in 2025]
|
||||
53,READCOV,Reading progress measure - coverage [not populated in 2025]
|
||||
54,WRITPROG,Writing progress measure [not populated in 2025]
|
||||
55,WRITPROG_LOWER,Writing progress measure - lower confidence limit [not populated in 2025]
|
||||
56,WRITPROG_UPPER,Writing progress measure - upper confidence limit [not populated in 2025]
|
||||
57,WRITCOV,Writing progress measure - coverage [not populated in 2025]
|
||||
58,MATPROG,Maths progress measure [not populated in 2025]
|
||||
59,MATPROG_LOWER,Maths progress measure - lower confidence limit [not populated in 2025]
|
||||
60,MATPROG_UPPER,Maths progress measure - upper confidence limit [not populated in 2025]
|
||||
61,MATCOV,Maths progress measure - coverage [not populated in 2025]
|
||||
62,PTREAD_EXP,Percentage of pupils reaching the expected standard in reading
|
||||
63,PTREAD_HIGH,Percentage of pupils achieving a high score in reading
|
||||
64,PTREAD_AT,Percentage of pupils absent from or not able to access the test in reading
|
||||
65,READ_AVERAGE,Average scaled score in reading
|
||||
66,PTGPS_EXP,"Percentage of pupils reaching the expected standard in grammar, punctuation and spelling"
|
||||
67,PTGPS_HIGH,"Percentage of pupils achieving a high score in grammar, punctuation and spelling"
|
||||
68,PTGPS_AT,"Percentage of pupils absent from or not able to access the test in grammar, punctuation and spelling"
|
||||
69,GPS_AVERAGE,"Average scaled score in grammar, punctuation and spelling"
|
||||
70,PTMAT_EXP,Percentage of pupils reaching the expected standard in maths
|
||||
71,PTMAT_HIGH,Percentage of pupils achieving a high score in maths
|
||||
72,PTMAT_AT,Percentage of pupils absent from or not able to access the test in maths
|
||||
73,MAT_AVERAGE,Average scaled score in maths
|
||||
74,PTWRITTA_EXP,Percentage of pupils reaching the expected standard in writing
|
||||
75,PTWRITTA_HIGH,Percentage of pupils working at greater depth within the expected standard in writing
|
||||
76,PTWRITTA_WTS,Percentage of pupils working towards the expected standard in writing
|
||||
77,PTWRITTA_AD,Percentage of pupils absent or disapplied in writing TA
|
||||
78,PTSCITA_EXP,Percentage of pupils reaching the expected standard in science TA
|
||||
79,PTSCITA_AD,Percentage of pupils absent or disapplied in science TA
|
||||
80,PTRWM_EXP_B,"Percentage of boys reaching the expected standard in reading, writing and maths"
|
||||
81,PTRWM_EXP_G,"Percentage of girls reaching the expected standard in reading, writing and maths"
|
||||
82,PTRWM_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
||||
83,PTRWM_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
||||
84,PTRWM_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
||||
85,PTRWM_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths"
|
||||
86,PTRWM_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths"
|
||||
87,DIFFN_RWM_EXP,"Difference between school percentage of disavantaged pupils and national percentage of other pupils reaching the expected standard in reading, writing and maths "
|
||||
88,PTRWM_EXP_EAL,"Percentage of EAL pupils reaching the expected standard in reading, writing and maths"
|
||||
89,PTRWM_EXP_MOBN,"Percentage of non-mobile pupils reaching the expected standard in reading, writing and maths"
|
||||
90,PTRWM_HIGH_B,Percentage of boys achieving a high score in reading and maths and working at greater depth in writing
|
||||
91,PTRWM_HIGH_G,"Percentage of girls reaching the HIGHected standard in reading, writing and maths"
|
||||
92,PTRWM_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
||||
93,PTRWM_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
||||
94,PTRWM_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
||||
95,PTRWM_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
96,PTRWM_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
97,DIFFN_RWM_HIGH,"Difference between school percentage of disavantaged pupils and national percentage of other pupils achieving a high score in reading, writing and maths "
|
||||
98,PTRWM_HIGH_EAL,Percentage of EAL pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
99,PTRWM_HIGH_MOBN,Percentage of non-mobile pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
100,READPROG_B,Reading progress measure for boys [not populated in 2025]
|
||||
101,READPROG_B_LOWER,Reading progress measure for boys - lower confidence limit [not populated in 2025]
|
||||
102,READPROG_B_UPPER,Reading progress measure for boys - upper confidence limit [not populated in 2025]
|
||||
103,READPROG_G,Reading progress measure for girls [not populated in 2025]
|
||||
104,READPROG_G_LOWER,Reading progress measure for girls - lower confidence limit [not populated in 2025]
|
||||
105,READPROG_G_UPPER,Reading progress measure for girls - upper confidence limit [not populated in 2025]
|
||||
106,READPROG_L,Reading progress measure for pupils with low prior attainment [not populated in 2025]
|
||||
107,READPROG_L_LOWER,Reading progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
||||
108,READPROG_L_UPPER,Reading progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
||||
109,READPROG_M,Reading progress measure for pupils with medium prior attainment [not populated in 2025]
|
||||
110,READPROG_M_LOWER,Reading progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
||||
111,READPROG_M_UPPER,Reading progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
||||
112,READPROG_H,Reading progress measure for pupils with high prior attainment [not populated in 2025]
|
||||
113,READPROG_H_LOWER,Reading progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
||||
114,READPROG_H_UPPER,Reading progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
||||
115,READPROG_FSM6CLA1A,Reading progress measure for disadvantaged pupils [not populated in 2025]
|
||||
116,READPROG_FSM6CLA1A_LOWER,Reading progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
117,READPROG_FSM6CLA1A_UPPER,Reading progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
118,READPROG_NotFSM6CLA1A,Reading progress measure for non-disadvantaged pupils [not populated in 2025]
|
||||
119,READPROG_NotFSM6CLA1A_LOWER,Reading progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
120,READPROG_NotFSM6CLA1A_UPPER,Reading progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
121,DIFFN_READPROG,Difference between reading progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
||||
122,READPROG_EAL,Reading progress measure for EAL pupils [not populated in 2025]
|
||||
123,READPROG_EAL_LOWER,Reading progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
||||
124,READPROG_EAL_UPPER,Reading progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
||||
125,READPROG_MOBN,Reading progress measure for non-mobile pupils [not populated in 2025]
|
||||
126,READPROG_MOBN_LOWER,Reading progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
||||
127,READPROG_MOBN_UPPER,Reading progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
||||
128,WRITPROG_B,Writing progress measure for boys [not populated in 2025]
|
||||
129,WRITPROG_B_LOWER,Writing progress measure for boys - lower confidence limit [not populated in 2025]
|
||||
130,WRITPROG_B_UPPER,Writing progress measure for boys - upper confidence limit [not populated in 2025]
|
||||
131,WRITPROG_G,Writing progress measure for girls [not populated in 2025]
|
||||
132,WRITPROG_G_LOWER,Writing progress measure for girls - lower confidence limit [not populated in 2025]
|
||||
133,WRITPROG_G_UPPER,Writing progress measure for girls - upper confidence limit [not populated in 2025]
|
||||
134,WRITPROG_L,Writing progress measure for pupils with low prior attainment [not populated in 2025]
|
||||
135,WRITPROG_L_LOWER,Writing progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
||||
136,WRITPROG_L_UPPER,Writing progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
||||
137,WRITPROG_M,Writing progress measure for pupils with medium prior attainment [not populated in 2025]
|
||||
138,WRITPROG_M_LOWER,Writing progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
||||
139,WRITPROG_M_UPPER,Writing progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
||||
140,WRITPROG_H,Writing progress measure for pupils with high prior attainment [not populated in 2025]
|
||||
141,WRITPROG_H_LOWER,Writing progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
||||
142,WRITPROG_H_UPPER,Writing progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
||||
143,WRITPROG_FSM6CLA1A,Writing progress measure for disadvantaged pupils [not populated in 2025]
|
||||
144,WRITPROG_FSM6CLA1A_LOWER,Writing progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
145,WRITPROG_FSM6CLA1A_UPPER,Writing progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
146,WRITPROG_NotFSM6CLA1A,Writing progress measure for non-disadvantaged pupils [not populated in 2025]
|
||||
147,WRITPROG_NotFSM6CLA1A_LOWER,Writing progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
148,WRITPROG_NotFSM6CLA1A_UPPER,Writing progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
149,DIFFN_WRITPROG,Difference between writing progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
||||
150,WRITPROG_EAL,Writing progress measure for EAL pupils [not populated in 2025]
|
||||
151,WRITPROG_EAL_LOWER,Writing progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
||||
152,WRITPROG_EAL_UPPER,Writing progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
||||
153,WRITPROG_MOBN,Writing progress measure for non-mobile pupils [not populated in 2025]
|
||||
154,WRITPROG_MOBN_LOWER,Writing progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
||||
155,WRITPROG_MOBN_UPPER,Writing progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
||||
156,MATPROG_B,Maths progress measure for boys [not populated in 2025]
|
||||
157,MATPROG_B_LOWER,Maths progress measure for boys - lower confidence limit [not populated in 2025]
|
||||
158,MATPROG_B_UPPER,Maths progress measure for boys - upper confidence limit [not populated in 2025]
|
||||
159,MATPROG_G,Maths progress measure for girls [not populated in 2025]
|
||||
160,MATPROG_G_LOWER,Maths progress measure for girls - lower confidence limit [not populated in 2025]
|
||||
161,MATPROG_G_UPPER,Maths progress measure for girls - upper confidence limit [not populated in 2025]
|
||||
162,MATPROG_L,Maths progress measure for pupils with low prior attainment [not populated in 2025]
|
||||
163,MATPROG_L_LOWER,Maths progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
||||
164,MATPROG_L_UPPER,Maths progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
||||
165,MATPROG_M,Maths progress measure for pupils with medium prior attainment [not populated in 2025]
|
||||
166,MATPROG_M_LOWER,Maths progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
||||
167,MATPROG_M_UPPER,Maths progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
||||
168,MATPROG_H,Maths progress measure for pupils with high prior attainment [not populated in 2025]
|
||||
169,MATPROG_H_LOWER,Maths progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
||||
170,MATPROG_H_UPPER,Maths progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
||||
171,MATPROG_FSM6CLA1A,Maths progress measure for disadvantaged pupils [not populated in 2025]
|
||||
172,MATPROG_FSM6CLA1A_LOWER,Maths progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
173,MATPROG_FSM6CLA1A_UPPER,Maths progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
174,MATPROG_NotFSM6CLA1A,Maths progress measure for non-disadvantaged pupils [not populated in 2025]
|
||||
175,MATPROG_NotFSM6CLA1A_LOWER,Maths progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
176,MATPROG_NotFSM6CLA1A_UPPER,Maths progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
177,DIFFN_MATPROG,Difference between maths progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
||||
178,MATPROG_EAL,Maths progress measure for EAL pupils [not populated in 2025]
|
||||
179,MATPROG_EAL_LOWER,Maths progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
||||
180,MATPROG_EAL_UPPER,Maths progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
||||
181,MATPROG_MOBN,Maths progress measure for non-mobile pupils [not populated in 2025]
|
||||
182,MATPROG_MOBN_LOWER,Maths progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
||||
183,MATPROG_MOBN_UPPER,Maths progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
||||
184,READ_AVERAGE_B,Average scaled score in reading for boys
|
||||
185,READ_AVERAGE_G,Average scaled score in reading for girls
|
||||
186,READ_AVERAGE_L,Average scaled score in reading for pupils with low prior attainment [not populated in 2025]
|
||||
187,READ_AVERAGE_M,Average scaled score in reading for pupils with medium prior attainment [not populated in 2025]
|
||||
188,READ_AVERAGE_H,Average scaled score in reading for pupils with high prior attainment [not populated in 2025]
|
||||
189,READ_AVERAGE_FSM6CLA1A,Average scaled score in reading for disadvantaged pupils
|
||||
190,READ_AVERAGE_NotFSM6CLA1A,Average scaled score in reading for non-disadvantaged pupils
|
||||
191,READ_AVERAGE_EAL,Average scaled score in reading for EAL pupils
|
||||
192,READ_AVERAGE_MOBN,Average scaled score in reading for MOBN pupils
|
||||
193,MAT_AVERAGE_B,Average scaled score in maths for boys
|
||||
194,MAT_AVERAGE_G,Average scaled score in maths for girls
|
||||
195,MAT_AVERAGE_L,Average scaled score in maths for pupils with low prior attainment [not populated in 2025]
|
||||
196,MAT_AVERAGE_M,Average scaled score in maths for pupils with medium prior attainment [not populated in 2025]
|
||||
197,MAT_AVERAGE_H,Average scaled score in maths for pupils with high prior attainment [not populated in 2025]
|
||||
198,MAT_AVERAGE_FSM6CLA1A,Average scaled score in maths for disadvantaged pupils
|
||||
199,MAT_AVERAGE_NotFSM6CLA1A,Average scaled score in maths for non-disadvantaged pupils
|
||||
200,MAT_AVERAGE_EAL,Average scaled score in maths for EAL pupils
|
||||
201,MAT_AVERAGE_MOBN,Average scaled score in maths for MOBN pupils
|
||||
202,GPS_AVERAGE_B,Average scaled score in GPS for boys
|
||||
203,GPS_AVERAGE_G,Average scaled score in GPS for girls
|
||||
204,GPS_AVERAGE_L,Average scaled score in GPS for pupils with low prior attainment [not populated in 2025]
|
||||
205,GPS_AVERAGE_M,Average scaled score in GPS for pupils with medium prior attainment [not populated in 2025]
|
||||
206,GPS_AVERAGE_H,Average scaled score in GPS for pupils with high prior attainment [not populated in 2025]
|
||||
207,GPS_AVERAGE_FSM6CLA1A,Average scaled score in GPS for disadvantaged pupils
|
||||
208,GPS_AVERAGE_NotFSM6CLA1A,Average scaled score in GPS for non-disadvantaged pupils
|
||||
209,GPS_AVERAGE_EAL,Average scaled score in GPS for EAL pupils
|
||||
210,GPS_AVERAGE_MOBN,Average scaled score in GPS for MOBN pupils
|
||||
211,PTREAD_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in reading [not populated in 2025]
|
||||
212,PTREAD_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in reading [not populated in 2025]
|
||||
213,PTREAD_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in reading [not populated in 2025]
|
||||
214,PTREAD_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in reading
|
||||
215,PTREAD_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in reading
|
||||
216,PTGPS_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
||||
217,PTGPS_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
||||
218,PTGPS_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
||||
219,PTGPS_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
|
||||
220,PTGPS_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
|
||||
221,PTMAT_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in maths [not populated in 2025]
|
||||
222,PTMAT_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in maths [not populated in 2025]
|
||||
223,PTMAT_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in maths [not populated in 2025]
|
||||
224,PTMAT_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in maths
|
||||
225,PTMAT_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in maths
|
||||
226,PTWRITTA_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in writing [not populated in 2025]
|
||||
227,PTWRITTA_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in writing [not populated in 2025]
|
||||
228,PTWRITTA_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in writing [not populated in 2025]
|
||||
229,PTWRITTA_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in writing
|
||||
230,PTWRITTA_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in writing
|
||||
231,PTREAD_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading [not populated in 2025]
|
||||
232,PTREAD_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading [not populated in 2025]
|
||||
233,PTREAD_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading [not populated in 2025]
|
||||
234,PTREAD_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading
|
||||
235,PTREAD_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading
|
||||
236,PTGPS_HIGH_L,"Percentage of pupils with low prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
||||
237,PTGPS_HIGH_M,"Percentage of pupils with medium prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
||||
238,PTGPS_HIGH_H,"Percentage of pupils with high prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
||||
239,PTGPS_HIGH_FSM6CLA1A,"Percentage of disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
|
||||
240,PTGPS_HIGH_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
|
||||
241,PTMAT_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in maths [not populated in 2025]
|
||||
242,PTMAT_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in maths [not populated in 2025]
|
||||
243,PTMAT_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in maths [not populated in 2025]
|
||||
244,PTMAT_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in maths
|
||||
245,PTMAT_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in maths
|
||||
246,PTWRITTA_HIGH_L,Percentage of pupils with low prior attainment working at greater depth in writing [not populated in 2025]
|
||||
247,PTWRITTA_HIGH_M,Percentage of pupils with medium prior attainment working at greater depth in writing [not populated in 2025]
|
||||
248,PTWRITTA_HIGH_H,Percentage of pupils with high prior attainment working at greater depth in writing [not populated in 2025]
|
||||
249,PTWRITTA_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils working at greater depth in writing
|
||||
250,PTWRITTA_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils working at greater depth in writing
|
||||
251,TEALGRP1,Number of eligible pupils with English as first language
|
||||
252,PTEALGRP1,Percentage of eligible pupils with English as first language
|
||||
253,TEALGRP3,Number of eligible pupils with unclassified language
|
||||
254,PTEALGRP3,Percentage of eligible pupils with unclassified language
|
||||
255,TSENELE,Number of eligible pupils with EHC plan
|
||||
256,PSENELE,Percentage of eligible pupils with EHC plan
|
||||
257,TSENELK,Number of eligible pupils with SEN support
|
||||
258,PSENELK,Percentage of eligible pupils with SEN support
|
||||
259,TSENELEK,Number of eligible pupils with SEN (EHC plan or SEN support)
|
||||
260,PSENELEK,Percentage of eligible pupils with SEN (EHC plan or SEN support)
|
||||
261,TELIG_24,Number of eligible pupils 2024
|
||||
262,PTFSM6CLA1A_24,Percentage of key stage 2 disadvantaged pupils one year prior
|
||||
263,PTNOTFSM6CLA1A_24,Percentage of key stage 2 pupils who are not disadvantaged one year prior
|
||||
264,PTRWM_EXP_24,"Percentage of pupils reaching the expected standard in reading, writing and maths one year prior"
|
||||
265,PTRWM_HIGH_24,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
||||
266,PTRWM_EXP_FSM6CLA1A_24,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
|
||||
267,PTRWM_HIGH_FSM6CLA1A_24,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
||||
268,PTRWM_EXP_NotFSM6CLA1A_24,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
|
||||
269,PTRWM_HIGH_NotFSM6CLA1A_24,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
||||
270,READPROG_24,Reading progress measure - one year prior [not populated in 2025]
|
||||
271,READPROG_LOWER_24,Reading progress measure - lower confidence limit - one year prior [not populated in 2025]
|
||||
272,READPROG_UPPER_24,Reading progress measure - upper confidence limit - one year prior [not populated in 2025]
|
||||
273,WRITPROG_24,Writing progress measure - one year prior [not populated in 2025]
|
||||
274,WRITPROG_LOWER_24,Writing progress measure - lower confidence limit - one year prior [not populated in 2025]
|
||||
275,WRITPROG_UPPER_24,Writing progress measure - upper confidence limit - one year prior [not populated in 2025]
|
||||
276,MATPROG_24,Maths progress measure - one year prior [not populated in 2025]
|
||||
277,MATPROG_LOWER_24,Maths progress measure - lower confidence limit - one year prior [not populated in 2025]
|
||||
278,MATPROG_UPPER_24,Maths progress measure - upper confidence limit - one year prior [not populated in 2025]
|
||||
279,READ_AVERAGE_24,Average scaled score in reading - one year prior
|
||||
280,MAT_AVERAGE_24,Average scaled score in maths - one year prior
|
||||
281,TELIG_23,Number of eligible pupils 2023
|
||||
282,PTFSM6CLA1A_23,Percentage of key stage 2 disadvantaged pupils - two years prior
|
||||
283,PTNOTFSM6CLA1A_23,Percentage of key stage 2 pupils who are not disadvantaged - two years prior
|
||||
284,PTRWM_EXP_23,"Percentage of pupils reaching the expected standard in reading, writing and maths - two years prior"
|
||||
285,PTRWM_HIGH_23,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
||||
286,PTRWM_EXP_FSM6CLA1A_23,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
|
||||
287,PTRWM_HIGH_FSM6CLA1A_23,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
||||
288,PTRWM_EXP_NotFSM6CLA1A_23,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
|
||||
289,PTRWM_HIGH_NotFSM6CLA1A_23,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
||||
290,READPROG_23,Reading progress measure - two years prior
|
||||
291,READPROG_LOWER_23,Reading progress measure - lower confidence limit - two years prior
|
||||
292,READPROG_UPPER_23,Reading progress measure - upper confidence limit - two years prior
|
||||
293,WRITPROG_23,Writing progress measure - two years prior
|
||||
294,WRITPROG_LOWER_23,Writing progress measure - lower confidence limit - two years prior
|
||||
295,WRITPROG_UPPER_23,Writing progress measure - upper confidence limit - two years prior
|
||||
296,MATPROG_23,Maths progress measure - two years prior
|
||||
297,MATPROG_LOWER_23,Maths progress measure - lower confidence limit - two years prior
|
||||
298,MATPROG_UPPER_23,Maths progress measure - upper confidence limit - two years prior
|
||||
299,READ_AVERAGE_23,Average scaled score in reading - two years prior
|
||||
300,MAT_AVERAGE_23,Average scaled score in maths - two years prior
|
||||
301,TELIG_3YR,Total number of pupils at the end of Key Stage 2 over the past three years
|
||||
302,PTRWM_EXP_3YR,"Percentage of pupils reaching the expected standard in reading, writing and maths - 3 year total"
|
||||
303,PTRWM_HIGH_3YR,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - 3 year total
|
||||
304,READ_AVERAGE_3YR,Average scaled score in reading - 3 year average
|
||||
305,MAT_AVERAGE_3YR,Average scaled score in maths - 3 year average
|
||||
306,READPROG_UNADJUSTED,Unadjusted reading progress measure [not populated in 2025]
|
||||
307,WRITPROG_UNADJUSTED,Unadjusted writing progress measure [not populated in 2025]
|
||||
308,MATPROG_UNADJUSTED,Unadjusted maths progress measure [not populated in 2025]
|
||||
309,READPROG_DESCR,Reading progress measure 'description' [not populated in 2025]
|
||||
310,WRITPROG_DESCR,Writing progress measure 'description' [not populated in 2025]
|
||||
311,MATPROG_DESCR,Maths progress measure 'description' [not populated in 2025]
|
||||
|
154
data/meta/la_and_region_codes_meta.csv
Normal file
154
data/meta/la_and_region_codes_meta.csv
Normal file
@@ -0,0 +1,154 @@
|
||||
LEA,LA Name,REGION,REGION NAME
|
||||
841,Darlington,1,North East A
|
||||
840,County Durham,1,North East A
|
||||
805,Hartlepool,1,North East A
|
||||
806,Middlesbrough,1,North East A
|
||||
807,Redcar and Cleveland,1,North East A
|
||||
808,Stockton-on-Tees,1,North East A
|
||||
390,Gateshead,3,North East B
|
||||
391,Newcastle upon Tyne,3,North East B
|
||||
392,North Tyneside,3,North East B
|
||||
929,Northumberland,3,North East B
|
||||
393,South Tyneside,3,North East B
|
||||
394,Sunderland,3,North East B
|
||||
889,Blackburn with Darwen,6,North West A
|
||||
890,Blackpool,6,North West A
|
||||
942,Cumberland,6,North West A
|
||||
943,Westmorland and Furness ,6,North West A
|
||||
888,Lancashire,6,North West A
|
||||
350,Bolton,7,North West B
|
||||
351,Bury,7,North West B
|
||||
352,Manchester,7,North West B
|
||||
353,Oldham,7,North West B
|
||||
354,Rochdale,7,North West B
|
||||
355,Salford,7,North West B
|
||||
356,Stockport,7,North West B
|
||||
357,Tameside,7,North West B
|
||||
358,Trafford,7,North West B
|
||||
359,Wigan,7,North West B
|
||||
895,Cheshire East,9,North West C
|
||||
896,Cheshire West and Chester,9,North West C
|
||||
876,Halton,9,North West C
|
||||
340,Knowsley,9,North West C
|
||||
341,Liverpool,9,North West C
|
||||
343,Sefton,9,North West C
|
||||
342,St. Helens,9,North West C
|
||||
877,Warrington,9,North West C
|
||||
344,Wirral,9,North West C
|
||||
811,East Riding of Yorkshire,10,North Yorkshire and The Humber
|
||||
810,"Kingston Upon Hull, City of",10,North Yorkshire and The Humber
|
||||
812,North East Lincolnshire,10,North Yorkshire and The Humber
|
||||
813,North Lincolnshire,10,North Yorkshire and The Humber
|
||||
815,North Yorkshire,10,North Yorkshire and The Humber
|
||||
816,York,10,North Yorkshire and The Humber
|
||||
370,Barnsley,12,South and West Yorkshire
|
||||
380,Bradford,12,South and West Yorkshire
|
||||
381,Calderdale,12,South and West Yorkshire
|
||||
371,Doncaster,12,South and West Yorkshire
|
||||
382,Kirklees,12,South and West Yorkshire
|
||||
383,Leeds,12,South and West Yorkshire
|
||||
372,Rotherham,12,South and West Yorkshire
|
||||
373,Sheffield,12,South and West Yorkshire
|
||||
384,Wakefield,12,South and West Yorkshire
|
||||
831,Derby,14,East Midlands A
|
||||
830,Derbyshire,14,East Midlands A
|
||||
892,Nottingham,14,East Midlands A
|
||||
891,Nottinghamshire,14,East Midlands A
|
||||
856,Leicester,16,East Midlands B
|
||||
855,Leicestershire,16,East Midlands B
|
||||
925,Lincolnshire,16,East Midlands B
|
||||
940,North Northamptonshire,16,East Midlands B
|
||||
941,West Northamptonshire,16,East Midlands B
|
||||
857,Rutland,16,East Midlands B
|
||||
893,Shropshire,20,West Midlands A
|
||||
860,Staffordshire,20,West Midlands A
|
||||
861,Stoke-on-Trent,20,West Midlands A
|
||||
894,Telford and Wrekin,20,West Midlands A
|
||||
884,"Herefordshire, County of",22,West Midlands B
|
||||
885,Worcestershire,22,West Midlands B
|
||||
330,Birmingham,24,West Midlands C
|
||||
331,Coventry,24,West Midlands C
|
||||
332,Dudley,24,West Midlands C
|
||||
333,Sandwell,24,West Midlands C
|
||||
334,Solihull,24,West Midlands C
|
||||
335,Walsall,24,West Midlands C
|
||||
937,Warwickshire,24,West Midlands C
|
||||
336,Wolverhampton,24,West Midlands C
|
||||
822,Bedford,25,East of England A
|
||||
873,Cambridgeshire,25,East of England A
|
||||
823,Central Bedfordshire,25,East of England A
|
||||
919,Hertfordshire,25,East of England A
|
||||
821,Luton,25,East of England A
|
||||
874,Peterborough,25,East of England A
|
||||
881,Essex,27,East of England B
|
||||
926,Norfolk,27,East of England B
|
||||
882,Southend-on-Sea,27,East of England B
|
||||
935,Suffolk,27,East of England B
|
||||
883,Thurrock,27,East of England B
|
||||
202,Camden,31,London Central
|
||||
206,Islington,31,London Central
|
||||
207,Kensington and Chelsea,31,London Central
|
||||
208,Lambeth,31,London Central
|
||||
210,Southwark,31,London Central
|
||||
212,Wandsworth,31,London Central
|
||||
213,Westminster,31,London Central
|
||||
301,Barking and Dagenham,32,London East
|
||||
303,Bexley,32,London East
|
||||
201,City of London,32,London East
|
||||
203,Greenwich,32,London East
|
||||
204,Hackney,32,London East
|
||||
311,Havering,32,London East
|
||||
209,Lewisham,32,London East
|
||||
316,Newham,32,London East
|
||||
317,Redbridge,32,London East
|
||||
211,Tower Hamlets,32,London East
|
||||
302,Barnet,33,London North
|
||||
308,Enfield,33,London North
|
||||
309,Haringey,33,London North
|
||||
320,Waltham Forest,33,London North
|
||||
305,Bromley,34,London South
|
||||
306,Croydon,34,London South
|
||||
314,Kingston upon Thames,34,London South
|
||||
315,Merton,34,London South
|
||||
318,Richmond upon Thames,34,London South
|
||||
319,Sutton,34,London South
|
||||
304,Brent,35,London West
|
||||
307,Ealing,35,London West
|
||||
205,Hammersmith and Fulham,35,London West
|
||||
310,Harrow,35,London West
|
||||
312,Hillingdon,35,London West
|
||||
313,Hounslow,35,London West
|
||||
867,Bracknell Forest,36,South East A
|
||||
825,Buckinghamshire,36,South East A
|
||||
826,Milton Keynes,36,South East A
|
||||
931,Oxfordshire,36,South East A
|
||||
870,Reading,36,South East A
|
||||
871,Slough,36,South East A
|
||||
869,West Berkshire,36,South East A
|
||||
868,Windsor and Maidenhead,36,South East A
|
||||
872,Wokingham,36,South East A
|
||||
850,Hampshire,37,South East B
|
||||
921,Isle of Wight,37,South East B
|
||||
851,Portsmouth,37,South East B
|
||||
852,Southampton,37,South East B
|
||||
936,Surrey,38,South East C
|
||||
938,West Sussex,38,South East C
|
||||
846,Brighton and Hove,39,South East D
|
||||
845,East Sussex,39,South East D
|
||||
886,Kent,39,South East D
|
||||
887,Medway,39,South East D
|
||||
839,"Bournemouth, Christchurch and Poole",43,South West A
|
||||
908,Cornwall,43,South West A
|
||||
878,Devon,43,South West A
|
||||
838,Dorset,43,South West A
|
||||
420,Isles of Scilly,43,South West A
|
||||
879,Plymouth,43,South West A
|
||||
933,Somerset,43,South West A
|
||||
880,Torbay,43,South West A
|
||||
800,Bath and North East Somerset,45,South West B
|
||||
801,"Bristol, City of",45,South West B
|
||||
916,Gloucestershire,45,South West B
|
||||
802,North Somerset,45,South West B
|
||||
803,South Gloucestershire,45,South West B
|
||||
866,Swindon,45,South West B
|
||||
865,Wiltshire,45,South West B
|
||||
|
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
# Mount data directory for easy updates without rebuilding
|
||||
- ./data:/app/data:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80/api/data-info"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
831
frontend/app.js
Normal file
831
frontend/app.js
Normal file
@@ -0,0 +1,831 @@
|
||||
/**
|
||||
* School Performance Compass - Frontend Application
|
||||
* Interactive UK School Data Visualization
|
||||
*/
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
// State
|
||||
let allSchools = [];
|
||||
let selectedSchools = [];
|
||||
let comparisonChart = null;
|
||||
let schoolDetailChart = null;
|
||||
let currentSchoolData = null;
|
||||
|
||||
// Chart colors
|
||||
const CHART_COLORS = [
|
||||
'#e07256', // coral
|
||||
'#2d7d7d', // teal
|
||||
'#c9a227', // gold
|
||||
'#7b68a6', // purple
|
||||
'#3498db', // blue
|
||||
'#27ae60', // green
|
||||
'#e74c3c', // red
|
||||
'#9b59b6', // violet
|
||||
];
|
||||
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
schoolSearch: document.getElementById('school-search'),
|
||||
localAuthorityFilter: document.getElementById('local-authority-filter'),
|
||||
typeFilter: document.getElementById('type-filter'),
|
||||
schoolsGrid: document.getElementById('schools-grid'),
|
||||
compareSearch: document.getElementById('compare-search'),
|
||||
compareResults: document.getElementById('compare-results'),
|
||||
selectedSchools: document.getElementById('selected-schools'),
|
||||
chartsSection: document.getElementById('charts-section'),
|
||||
metricSelect: document.getElementById('metric-select'),
|
||||
comparisonChart: document.getElementById('comparison-chart'),
|
||||
comparisonTable: document.getElementById('comparison-table'),
|
||||
tableHeader: document.getElementById('table-header'),
|
||||
tableBody: document.getElementById('table-body'),
|
||||
rankingMetric: document.getElementById('ranking-metric'),
|
||||
rankingYear: document.getElementById('ranking-year'),
|
||||
rankingsList: document.getElementById('rankings-list'),
|
||||
modal: document.getElementById('school-modal'),
|
||||
modalClose: document.getElementById('modal-close'),
|
||||
modalSchoolName: document.getElementById('modal-school-name'),
|
||||
modalMeta: document.getElementById('modal-meta'),
|
||||
modalStats: document.getElementById('modal-stats'),
|
||||
schoolDetailChart: document.getElementById('school-detail-chart'),
|
||||
addToCompare: document.getElementById('add-to-compare'),
|
||||
};
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
async function init() {
|
||||
await loadFilters();
|
||||
await loadSchools();
|
||||
await loadRankingYears();
|
||||
await loadRankings();
|
||||
setupEventListeners();
|
||||
}
|
||||
|
||||
// API Functions
|
||||
async function fetchAPI(endpoint) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilters() {
|
||||
const data = await fetchAPI('/api/filters');
|
||||
if (!data) return;
|
||||
|
||||
// Populate school type filter
|
||||
data.school_types.forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type;
|
||||
option.textContent = type;
|
||||
elements.typeFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSchools() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const search = elements.schoolSearch.value.trim();
|
||||
if (search) params.append('search', search);
|
||||
|
||||
const localAuthority = elements.localAuthorityFilter.value;
|
||||
if (localAuthority) params.append('local_authority', localAuthority);
|
||||
|
||||
const type = elements.typeFilter.value;
|
||||
if (type) params.append('school_type', type);
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/api/schools${queryString ? '?' + queryString : ''}`;
|
||||
|
||||
const data = await fetchAPI(endpoint);
|
||||
if (!data) {
|
||||
showEmptyState(elements.schoolsGrid, 'Unable to load schools');
|
||||
return;
|
||||
}
|
||||
|
||||
allSchools = data.schools;
|
||||
renderSchools(allSchools);
|
||||
}
|
||||
|
||||
async function loadSchoolDetails(urn) {
|
||||
const data = await fetchAPI(`/api/schools/${urn}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadComparison() {
|
||||
if (selectedSchools.length === 0) return null;
|
||||
|
||||
const urns = selectedSchools.map(s => s.urn).join(',');
|
||||
const data = await fetchAPI(`/api/compare?urns=${urns}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadRankingYears() {
|
||||
const data = await fetchAPI('/api/filters');
|
||||
if (!data) return;
|
||||
|
||||
elements.rankingYear.innerHTML = '';
|
||||
data.years.sort((a, b) => b - a).forEach(year => {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}`;
|
||||
elements.rankingYear.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRankings() {
|
||||
const metric = elements.rankingMetric.value;
|
||||
const year = elements.rankingYear.value;
|
||||
|
||||
let endpoint = `/api/rankings?metric=${metric}&limit=20`;
|
||||
if (year) endpoint += `&year=${year}`;
|
||||
|
||||
const data = await fetchAPI(endpoint);
|
||||
if (!data) {
|
||||
showEmptyState(elements.rankingsList, 'Unable to load rankings');
|
||||
return;
|
||||
}
|
||||
|
||||
renderRankings(data.rankings, metric);
|
||||
}
|
||||
|
||||
// Render Functions
|
||||
function renderSchools(schools) {
|
||||
if (schools.length === 0) {
|
||||
showEmptyState(elements.schoolsGrid, 'No primary schools found matching your criteria');
|
||||
return;
|
||||
}
|
||||
|
||||
elements.schoolsGrid.innerHTML = schools.map(school => `
|
||||
<div class="school-card" data-urn="${school.urn}">
|
||||
<h3 class="school-name">${escapeHtml(school.school_name)}</h3>
|
||||
<div class="school-meta">
|
||||
<span class="school-tag">${escapeHtml(school.local_authority || '')}</span>
|
||||
<span class="school-tag type">${escapeHtml(school.school_type || '')}</span>
|
||||
</div>
|
||||
<div class="school-address">${escapeHtml(school.address || '')}</div>
|
||||
<div class="school-stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value">Primary</div>
|
||||
<div class="stat-label">Phase</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">KS2</div>
|
||||
<div class="stat-label">Data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const urn = parseInt(card.dataset.urn);
|
||||
openSchoolModal(urn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRankings(rankings, metric) {
|
||||
if (rankings.length === 0) {
|
||||
showEmptyState(elements.rankingsList, 'No ranking data available for this year/metric');
|
||||
return;
|
||||
}
|
||||
|
||||
const metricLabels = {
|
||||
// Expected
|
||||
rwm_expected_pct: 'RWM %',
|
||||
reading_expected_pct: 'Reading %',
|
||||
writing_expected_pct: 'Writing %',
|
||||
maths_expected_pct: 'Maths %',
|
||||
gps_expected_pct: 'GPS %',
|
||||
science_expected_pct: 'Science %',
|
||||
// Higher
|
||||
rwm_high_pct: 'RWM Higher %',
|
||||
reading_high_pct: 'Reading Higher %',
|
||||
writing_high_pct: 'Writing Higher %',
|
||||
maths_high_pct: 'Maths Higher %',
|
||||
gps_high_pct: 'GPS Higher %',
|
||||
// Progress
|
||||
reading_progress: 'Reading Progress',
|
||||
writing_progress: 'Writing Progress',
|
||||
maths_progress: 'Maths Progress',
|
||||
// Averages
|
||||
reading_avg_score: 'Reading Avg',
|
||||
maths_avg_score: 'Maths Avg',
|
||||
gps_avg_score: 'GPS Avg',
|
||||
// Gender
|
||||
rwm_expected_boys_pct: 'Boys RWM %',
|
||||
rwm_expected_girls_pct: 'Girls RWM %',
|
||||
rwm_high_boys_pct: 'Boys Higher %',
|
||||
rwm_high_girls_pct: 'Girls Higher %',
|
||||
// Equity
|
||||
rwm_expected_disadvantaged_pct: 'Disadvantaged %',
|
||||
rwm_expected_non_disadvantaged_pct: 'Non-Disadv %',
|
||||
disadvantaged_gap: 'Disadv Gap',
|
||||
// Context
|
||||
disadvantaged_pct: '% Disadvantaged',
|
||||
eal_pct: '% EAL',
|
||||
sen_support_pct: '% SEN',
|
||||
stability_pct: '% Stable',
|
||||
// 3-Year
|
||||
rwm_expected_3yr_pct: 'RWM 3yr %',
|
||||
reading_avg_3yr: 'Reading 3yr',
|
||||
maths_avg_3yr: 'Maths 3yr',
|
||||
};
|
||||
|
||||
elements.rankingsList.innerHTML = rankings.map((school, index) => {
|
||||
const value = school[metric];
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
const isProgress = metric.includes('progress');
|
||||
const isScore = metric.includes('_avg_');
|
||||
let displayValue;
|
||||
if (isProgress) {
|
||||
displayValue = (value >= 0 ? '+' : '') + value.toFixed(1);
|
||||
} else if (isScore) {
|
||||
displayValue = value.toFixed(0);
|
||||
} else {
|
||||
displayValue = value.toFixed(0) + '%';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="ranking-item" data-urn="${school.urn}">
|
||||
<div class="ranking-position ${index < 3 ? 'top-3' : ''}">${index + 1}</div>
|
||||
<div class="ranking-info">
|
||||
<div class="ranking-name">${escapeHtml(school.school_name)}</div>
|
||||
<div class="ranking-location">${escapeHtml(school.local_authority || '')}</div>
|
||||
</div>
|
||||
<div class="ranking-score">
|
||||
<div class="ranking-score-value">${displayValue}</div>
|
||||
<div class="ranking-score-label">${metricLabels[metric] || metric}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).filter(Boolean).join('');
|
||||
|
||||
// Add click handlers
|
||||
elements.rankingsList.querySelectorAll('.ranking-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const urn = parseInt(item.dataset.urn);
|
||||
openSchoolModal(urn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedSchools() {
|
||||
if (selectedSchools.length === 0) {
|
||||
elements.selectedSchools.innerHTML = `
|
||||
<div class="empty-selection">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="6" y="10" width="36" height="28" rx="2"/>
|
||||
<path d="M6 18h36"/>
|
||||
<circle cx="14" cy="14" r="2" fill="currentColor"/>
|
||||
<circle cx="22" cy="14" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Search and add schools to compare</p>
|
||||
</div>
|
||||
`;
|
||||
elements.chartsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.selectedSchools.innerHTML = selectedSchools.map((school, index) => `
|
||||
<div class="selected-school-tag" style="border-left: 3px solid ${CHART_COLORS[index % CHART_COLORS.length]}">
|
||||
<span>${escapeHtml(school.school_name)}</span>
|
||||
<button class="remove" data-urn="${school.urn}" title="Remove">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 4L4 12M4 4l8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add remove handlers
|
||||
elements.selectedSchools.querySelectorAll('.remove').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const urn = parseInt(btn.dataset.urn);
|
||||
removeFromComparison(urn);
|
||||
});
|
||||
});
|
||||
|
||||
elements.chartsSection.style.display = 'block';
|
||||
updateComparisonChart();
|
||||
}
|
||||
|
||||
async function updateComparisonChart() {
|
||||
if (selectedSchools.length === 0) return;
|
||||
|
||||
const data = await loadComparison();
|
||||
if (!data) return;
|
||||
|
||||
const metric = elements.metricSelect.value;
|
||||
const metricLabels = {
|
||||
// Expected Standard
|
||||
rwm_expected_pct: 'Reading, Writing & Maths Combined (%)',
|
||||
reading_expected_pct: 'Reading Expected Standard (%)',
|
||||
writing_expected_pct: 'Writing Expected Standard (%)',
|
||||
maths_expected_pct: 'Maths Expected Standard (%)',
|
||||
gps_expected_pct: 'GPS Expected Standard (%)',
|
||||
science_expected_pct: 'Science Expected Standard (%)',
|
||||
// Higher Standard
|
||||
rwm_high_pct: 'RWM Combined Higher Standard (%)',
|
||||
reading_high_pct: 'Reading Higher Standard (%)',
|
||||
writing_high_pct: 'Writing Greater Depth (%)',
|
||||
maths_high_pct: 'Maths Higher Standard (%)',
|
||||
gps_high_pct: 'GPS Higher Standard (%)',
|
||||
// Progress
|
||||
reading_progress: 'Reading Progress Score',
|
||||
writing_progress: 'Writing Progress Score',
|
||||
maths_progress: 'Maths Progress Score',
|
||||
// Averages
|
||||
reading_avg_score: 'Reading Average Scaled Score',
|
||||
maths_avg_score: 'Maths Average Scaled Score',
|
||||
gps_avg_score: 'GPS Average Scaled Score',
|
||||
// Gender
|
||||
rwm_expected_boys_pct: 'RWM Expected % (Boys)',
|
||||
rwm_expected_girls_pct: 'RWM Expected % (Girls)',
|
||||
rwm_high_boys_pct: 'RWM Higher % (Boys)',
|
||||
rwm_high_girls_pct: 'RWM Higher % (Girls)',
|
||||
// Equity
|
||||
rwm_expected_disadvantaged_pct: 'RWM Expected % (Disadvantaged)',
|
||||
rwm_expected_non_disadvantaged_pct: 'RWM Expected % (Non-Disadvantaged)',
|
||||
disadvantaged_gap: 'Disadvantaged Gap vs National',
|
||||
// Context
|
||||
disadvantaged_pct: '% Disadvantaged Pupils',
|
||||
eal_pct: '% EAL Pupils',
|
||||
sen_support_pct: '% SEN Support',
|
||||
stability_pct: '% Pupil Stability',
|
||||
// 3-Year
|
||||
rwm_expected_3yr_pct: 'RWM Expected % (3-Year Avg)',
|
||||
reading_avg_3yr: 'Reading Score (3-Year Avg)',
|
||||
maths_avg_3yr: 'Maths Score (3-Year Avg)',
|
||||
};
|
||||
|
||||
// Prepare chart data - iterate in same order as selectedSchools for color consistency
|
||||
const datasets = [];
|
||||
const allYears = new Set();
|
||||
|
||||
selectedSchools.forEach((school, index) => {
|
||||
const schoolData = data.comparison[school.urn];
|
||||
if (!schoolData) return;
|
||||
|
||||
const yearlyData = schoolData.yearly_data;
|
||||
yearlyData.forEach(d => allYears.add(d.year));
|
||||
|
||||
const sortedData = yearlyData.sort((a, b) => a.year - b.year);
|
||||
|
||||
datasets.push({
|
||||
label: schoolData.school_info.school_name,
|
||||
data: sortedData.map(d => ({ x: d.year, y: d[metric] })),
|
||||
borderColor: CHART_COLORS[index % CHART_COLORS.length],
|
||||
backgroundColor: CHART_COLORS[index % CHART_COLORS.length] + '20',
|
||||
borderWidth: 3,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
});
|
||||
});
|
||||
|
||||
const years = Array.from(allYears).sort();
|
||||
|
||||
// Destroy existing chart
|
||||
if (comparisonChart) {
|
||||
comparisonChart.destroy();
|
||||
}
|
||||
|
||||
// Create new chart
|
||||
const ctx = elements.comparisonChart.getContext('2d');
|
||||
comparisonChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: years,
|
||||
datasets: datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 2,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
font: { family: "'DM Sans', sans-serif", size: 12 },
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: metricLabels[metric],
|
||||
font: { family: "'Playfair Display', serif", size: 18, weight: 600 },
|
||||
padding: { bottom: 20 },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1a1612',
|
||||
titleFont: { family: "'DM Sans', sans-serif" },
|
||||
bodyFont: { family: "'DM Sans', sans-serif" },
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Academic Year',
|
||||
font: { family: "'DM Sans', sans-serif", weight: 500 },
|
||||
},
|
||||
grid: { display: false },
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: metricLabels[metric],
|
||||
font: { family: "'DM Sans', sans-serif", weight: 500 },
|
||||
},
|
||||
grid: { color: '#e5dfd5' },
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update comparison table
|
||||
updateComparisonTable(data.comparison, metric, years);
|
||||
}
|
||||
|
||||
function updateComparisonTable(comparison, metric, years) {
|
||||
// Build header
|
||||
let headerHtml = '<th>School</th>';
|
||||
years.forEach(year => {
|
||||
headerHtml += `<th>${year}</th>`;
|
||||
});
|
||||
headerHtml += '<th>Change</th>';
|
||||
elements.tableHeader.innerHTML = headerHtml;
|
||||
|
||||
// Build body - iterate in same order as selectedSchools for color consistency
|
||||
let bodyHtml = '';
|
||||
selectedSchools.forEach((school, index) => {
|
||||
const schoolData = comparison[school.urn];
|
||||
if (!schoolData) return;
|
||||
|
||||
const yearlyMap = {};
|
||||
schoolData.yearly_data.forEach(d => {
|
||||
yearlyMap[d.year] = d[metric];
|
||||
});
|
||||
|
||||
const firstValue = yearlyMap[years[0]];
|
||||
const lastValue = yearlyMap[years[years.length - 1]];
|
||||
const change = firstValue && lastValue ? (lastValue - firstValue).toFixed(2) : 'N/A';
|
||||
const changeClass = parseFloat(change) >= 0 ? 'positive' : 'negative';
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
|
||||
bodyHtml += `<tr>`;
|
||||
bodyHtml += `<td><strong style="border-left: 3px solid ${color}; padding-left: 8px;">${escapeHtml(schoolData.school_info.school_name)}</strong></td>`;
|
||||
years.forEach(year => {
|
||||
const value = yearlyMap[year];
|
||||
bodyHtml += `<td>${value !== undefined ? formatMetricValue(value, metric) : '-'}</td>`;
|
||||
});
|
||||
bodyHtml += `<td class="${changeClass}">${change !== 'N/A' ? (parseFloat(change) >= 0 ? '+' : '') + change : change}</td>`;
|
||||
bodyHtml += `</tr>`;
|
||||
});
|
||||
elements.tableBody.innerHTML = bodyHtml;
|
||||
}
|
||||
|
||||
function formatMetricValue(value, metric) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (metric.includes('progress')) {
|
||||
return (value >= 0 ? '+' : '') + value.toFixed(1);
|
||||
}
|
||||
if (metric.includes('pct')) {
|
||||
return value.toFixed(0) + '%';
|
||||
}
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
async function openSchoolModal(urn) {
|
||||
const data = await loadSchoolDetails(urn);
|
||||
if (!data) return;
|
||||
|
||||
currentSchoolData = data;
|
||||
|
||||
elements.modalSchoolName.textContent = data.school_info.school_name;
|
||||
elements.modalMeta.innerHTML = `
|
||||
<span class="school-tag">${escapeHtml(data.school_info.local_authority || '')}</span>
|
||||
<span class="school-tag type">${escapeHtml(data.school_info.school_type || '')}</span>
|
||||
<span class="school-tag">Primary</span>
|
||||
`;
|
||||
|
||||
// Get latest year data with actual results (skip 2021 - no SATs)
|
||||
const sortedData = data.yearly_data.sort((a, b) => b.year - a.year);
|
||||
const latest = sortedData.find(d => d.rwm_expected_pct !== null) || sortedData[0];
|
||||
|
||||
elements.modalStats.innerHTML = `
|
||||
<div class="modal-stats-section">
|
||||
<h4>KS2 Results (${latest.year})</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.rwm_expected_pct, 'rwm_expected_pct')}</div>
|
||||
<div class="modal-stat-label">RWM Expected</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.rwm_high_pct, 'rwm_high_pct')}</div>
|
||||
<div class="modal-stat-label">RWM Higher</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.gps_expected_pct, 'gps_expected_pct')}</div>
|
||||
<div class="modal-stat-label">GPS Expected</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.science_expected_pct, 'science_expected_pct')}</div>
|
||||
<div class="modal-stat-label">Science Expected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-stats-section">
|
||||
<h4>Progress Scores</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.reading_progress)}">${formatMetricValue(latest.reading_progress, 'reading_progress')}</div>
|
||||
<div class="modal-stat-label">Reading</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.writing_progress)}">${formatMetricValue(latest.writing_progress, 'writing_progress')}</div>
|
||||
<div class="modal-stat-label">Writing</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.maths_progress)}">${formatMetricValue(latest.maths_progress, 'maths_progress')}</div>
|
||||
<div class="modal-stat-label">Maths</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-stats-section">
|
||||
<h4>School Context</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${latest.total_pupils || '-'}</div>
|
||||
<div class="modal-stat-label">Total Pupils</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.disadvantaged_pct, 'disadvantaged_pct')}</div>
|
||||
<div class="modal-stat-label">% Disadvantaged</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.eal_pct, 'eal_pct')}</div>
|
||||
<div class="modal-stat-label">% EAL</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.sen_support_pct, 'sen_support_pct')}</div>
|
||||
<div class="modal-stat-label">% SEN Support</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function getProgressClass(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return value >= 0 ? 'positive' : 'negative';
|
||||
}
|
||||
|
||||
// Create chart - filter out years with no data (2021)
|
||||
if (schoolDetailChart) {
|
||||
schoolDetailChart.destroy();
|
||||
}
|
||||
|
||||
const validData = sortedData.filter(d => d.rwm_expected_pct !== null).reverse();
|
||||
const years = validData.map(d => d.year);
|
||||
const ctx = elements.schoolDetailChart.getContext('2d');
|
||||
|
||||
schoolDetailChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: years,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Reading %',
|
||||
data: validData.map(d => d.reading_expected_pct),
|
||||
backgroundColor: '#2d7d7d',
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: 'Writing %',
|
||||
data: validData.map(d => d.writing_expected_pct),
|
||||
backgroundColor: '#c9a227',
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: 'Maths %',
|
||||
data: validData.map(d => d.maths_expected_pct),
|
||||
backgroundColor: '#e07256',
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 2,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
font: { family: "'DM Sans', sans-serif" },
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'KS2 Attainment Over Time (% meeting expected standard)',
|
||||
font: { family: "'Playfair Display', serif", size: 16, weight: 600 },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
grid: { color: '#e5dfd5' },
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update add to compare button
|
||||
const isSelected = selectedSchools.some(s => s.urn === data.school_info.urn);
|
||||
elements.addToCompare.textContent = isSelected ? 'Remove from Compare' : 'Add to Compare';
|
||||
elements.addToCompare.dataset.urn = data.school_info.urn;
|
||||
|
||||
elements.modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
elements.modal.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
currentSchoolData = null;
|
||||
}
|
||||
|
||||
function addToComparison(school) {
|
||||
if (selectedSchools.some(s => s.urn === school.urn)) return;
|
||||
if (selectedSchools.length >= 5) {
|
||||
alert('Maximum 5 schools can be compared at once');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedSchools.push(school);
|
||||
renderSelectedSchools();
|
||||
}
|
||||
|
||||
function removeFromComparison(urn) {
|
||||
selectedSchools = selectedSchools.filter(s => s.urn !== urn);
|
||||
renderSelectedSchools();
|
||||
}
|
||||
|
||||
function showEmptyState(container, message) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="24" cy="24" r="20"/>
|
||||
<path d="M16 20h16M16 28h10"/>
|
||||
</svg>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
function setupEventListeners() {
|
||||
// Navigation
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const view = link.dataset.view;
|
||||
|
||||
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
document.getElementById(`${view}-view`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Search and filters
|
||||
let searchTimeout;
|
||||
elements.schoolSearch.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(loadSchools, 300);
|
||||
});
|
||||
|
||||
elements.localAuthorityFilter.addEventListener('change', loadSchools);
|
||||
elements.typeFilter.addEventListener('change', loadSchools);
|
||||
|
||||
// Compare search
|
||||
let compareSearchTimeout;
|
||||
elements.compareSearch.addEventListener('input', async () => {
|
||||
clearTimeout(compareSearchTimeout);
|
||||
const query = elements.compareSearch.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
elements.compareResults.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
compareSearchTimeout = setTimeout(async () => {
|
||||
const data = await fetchAPI(`/api/schools?search=${encodeURIComponent(query)}`);
|
||||
if (!data) return;
|
||||
|
||||
const results = data.schools.filter(s => !selectedSchools.some(sel => sel.urn === s.urn));
|
||||
|
||||
if (results.length === 0) {
|
||||
elements.compareResults.innerHTML = '<div class="compare-result-item"><span class="name">No schools found</span></div>';
|
||||
} else {
|
||||
elements.compareResults.innerHTML = results.slice(0, 10).map(school => `
|
||||
<div class="compare-result-item" data-urn="${school.urn}" data-name="${escapeHtml(school.school_name)}">
|
||||
<div class="name">${escapeHtml(school.school_name)}</div>
|
||||
<div class="location">${escapeHtml(school.local_authority || '')}${school.postcode ? ' • ' + escapeHtml(school.postcode) : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
elements.compareResults.querySelectorAll('.compare-result-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const urn = parseInt(item.dataset.urn);
|
||||
const school = data.schools.find(s => s.urn === urn);
|
||||
if (school) {
|
||||
addToComparison(school);
|
||||
elements.compareSearch.value = '';
|
||||
elements.compareResults.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
elements.compareResults.classList.add('active');
|
||||
}, 300);
|
||||
});
|
||||
|
||||
elements.compareSearch.addEventListener('blur', () => {
|
||||
setTimeout(() => elements.compareResults.classList.remove('active'), 200);
|
||||
});
|
||||
|
||||
elements.compareSearch.addEventListener('focus', () => {
|
||||
if (elements.compareSearch.value.trim().length >= 2) {
|
||||
elements.compareResults.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Metric selector
|
||||
elements.metricSelect.addEventListener('change', updateComparisonChart);
|
||||
|
||||
// Rankings
|
||||
elements.rankingMetric.addEventListener('change', loadRankings);
|
||||
elements.rankingYear.addEventListener('change', loadRankings);
|
||||
|
||||
// Modal
|
||||
elements.modalClose.addEventListener('click', closeModal);
|
||||
elements.modal.querySelector('.modal-backdrop').addEventListener('click', closeModal);
|
||||
|
||||
elements.addToCompare.addEventListener('click', () => {
|
||||
if (!currentSchoolData) return;
|
||||
|
||||
const urn = currentSchoolData.school_info.urn;
|
||||
const isSelected = selectedSchools.some(s => s.urn === urn);
|
||||
|
||||
if (isSelected) {
|
||||
removeFromComparison(urn);
|
||||
elements.addToCompare.textContent = 'Add to Compare';
|
||||
} else {
|
||||
addToComparison(currentSchoolData.school_info);
|
||||
elements.addToCompare.textContent = 'Remove from Compare';
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
261
frontend/index.html
Normal file
261
frontend/index.html
Normal file
@@ -0,0 +1,261 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Primary School Compass | Wandsworth & Merton</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="noise-overlay"></div>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="20" cy="20" r="18" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="20" r="4" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-title">Primary School Compass</span>
|
||||
<span class="logo-subtitle">Wandsworth & Merton</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a href="#" class="nav-link active" data-view="dashboard">Dashboard</a>
|
||||
<a href="#" class="nav-link" data-view="compare">Compare</a>
|
||||
<a href="#" class="nav-link" data-view="rankings">Rankings</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<!-- Dashboard View -->
|
||||
<section id="dashboard-view" class="view active">
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">Primary Schools in Wandsworth & Merton</h1>
|
||||
<p class="hero-subtitle">Compare KS2 performance data from the last 5 years across local primary schools</p>
|
||||
</div>
|
||||
|
||||
<div class="search-section">
|
||||
<div class="search-container">
|
||||
<input type="text" id="school-search" class="search-input" placeholder="Search primary schools by name...">
|
||||
<div class="search-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="local-authority-filter" class="filter-select">
|
||||
<option value="">All Areas</option>
|
||||
<option value="Wandsworth">Wandsworth</option>
|
||||
<option value="Merton">Merton</option>
|
||||
</select>
|
||||
<select id="type-filter" class="filter-select">
|
||||
<option value="">All School Types</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schools-grid" id="schools-grid">
|
||||
<!-- School cards populated by JS -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Compare View -->
|
||||
<section id="compare-view" class="view">
|
||||
<div class="compare-header">
|
||||
<h2 class="section-title">Compare Primary Schools</h2>
|
||||
<p class="section-subtitle">Select schools to compare their KS2 performance over time</p>
|
||||
</div>
|
||||
|
||||
<div class="selected-schools" id="selected-schools">
|
||||
<div class="empty-selection">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="6" y="10" width="36" height="28" rx="2"/>
|
||||
<path d="M6 18h36"/>
|
||||
<circle cx="14" cy="14" r="2" fill="currentColor"/>
|
||||
<circle cx="22" cy="14" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Search and add schools to compare</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compare-search-section">
|
||||
<input type="text" id="compare-search" class="search-input" placeholder="Add a school to compare...">
|
||||
<div id="compare-results" class="compare-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="charts-section" id="charts-section" style="display: none;">
|
||||
<div class="metric-selector">
|
||||
<label>Select KS2 Metric:</label>
|
||||
<select id="metric-select" class="filter-select">
|
||||
<optgroup label="Expected Standard">
|
||||
<option value="rwm_expected_pct">Reading, Writing & Maths Combined %</option>
|
||||
<option value="reading_expected_pct">Reading Expected %</option>
|
||||
<option value="writing_expected_pct">Writing Expected %</option>
|
||||
<option value="maths_expected_pct">Maths Expected %</option>
|
||||
<option value="gps_expected_pct">GPS Expected %</option>
|
||||
<option value="science_expected_pct">Science Expected %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
<option value="rwm_high_pct">RWM Combined Higher %</option>
|
||||
<option value="reading_high_pct">Reading Higher %</option>
|
||||
<option value="writing_high_pct">Writing Higher %</option>
|
||||
<option value="maths_high_pct">Maths Higher %</option>
|
||||
<option value="gps_high_pct">GPS Higher %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
<option value="reading_progress">Reading Progress</option>
|
||||
<option value="writing_progress">Writing Progress</option>
|
||||
<option value="maths_progress">Maths Progress</option>
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
<option value="reading_avg_score">Reading Avg Score</option>
|
||||
<option value="maths_avg_score">Maths Avg Score</option>
|
||||
<option value="gps_avg_score">GPS Avg Score</option>
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
<option value="rwm_expected_boys_pct">RWM Expected % (Boys)</option>
|
||||
<option value="rwm_expected_girls_pct">RWM Expected % (Girls)</option>
|
||||
<option value="rwm_high_boys_pct">RWM Higher % (Boys)</option>
|
||||
<option value="rwm_high_girls_pct">RWM Higher % (Girls)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
<option value="rwm_expected_disadvantaged_pct">RWM Expected % (Disadvantaged)</option>
|
||||
<option value="rwm_expected_non_disadvantaged_pct">RWM Expected % (Non-Disadvantaged)</option>
|
||||
<option value="disadvantaged_gap">Disadvantaged Gap vs National</option>
|
||||
</optgroup>
|
||||
<optgroup label="School Context">
|
||||
<option value="disadvantaged_pct">% Disadvantaged Pupils</option>
|
||||
<option value="eal_pct">% EAL Pupils</option>
|
||||
<option value="sen_support_pct">% SEN Support</option>
|
||||
<option value="stability_pct">% Pupil Stability</option>
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
<option value="rwm_expected_3yr_pct">RWM Expected % (3-Year Avg)</option>
|
||||
<option value="reading_avg_3yr">Reading Score (3-Year Avg)</option>
|
||||
<option value="maths_avg_3yr">Maths Score (3-Year Avg)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<canvas id="comparison-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="data-table-container">
|
||||
<table class="data-table" id="comparison-table">
|
||||
<thead>
|
||||
<tr id="table-header"></tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rankings View -->
|
||||
<section id="rankings-view" class="view">
|
||||
<div class="rankings-header">
|
||||
<h2 class="section-title">Primary School Rankings</h2>
|
||||
<p class="section-subtitle">Top performing schools in Wandsworth & Merton by KS2 metric</p>
|
||||
</div>
|
||||
|
||||
<div class="rankings-controls">
|
||||
<select id="ranking-metric" class="filter-select">
|
||||
<optgroup label="Expected Standard">
|
||||
<option value="rwm_expected_pct">Reading, Writing & Maths Combined %</option>
|
||||
<option value="reading_expected_pct">Reading Expected %</option>
|
||||
<option value="writing_expected_pct">Writing Expected %</option>
|
||||
<option value="maths_expected_pct">Maths Expected %</option>
|
||||
<option value="gps_expected_pct">GPS Expected %</option>
|
||||
<option value="science_expected_pct">Science Expected %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
<option value="rwm_high_pct">RWM Combined Higher %</option>
|
||||
<option value="reading_high_pct">Reading Higher %</option>
|
||||
<option value="writing_high_pct">Writing Higher %</option>
|
||||
<option value="maths_high_pct">Maths Higher %</option>
|
||||
<option value="gps_high_pct">GPS Higher %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
<option value="reading_progress">Reading Progress</option>
|
||||
<option value="writing_progress">Writing Progress</option>
|
||||
<option value="maths_progress">Maths Progress</option>
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
<option value="reading_avg_score">Reading Avg Score</option>
|
||||
<option value="maths_avg_score">Maths Avg Score</option>
|
||||
<option value="gps_avg_score">GPS Avg Score</option>
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
<option value="rwm_expected_boys_pct">RWM Expected % (Boys)</option>
|
||||
<option value="rwm_expected_girls_pct">RWM Expected % (Girls)</option>
|
||||
<option value="rwm_high_boys_pct">RWM Higher % (Boys)</option>
|
||||
<option value="rwm_high_girls_pct">RWM Higher % (Girls)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
<option value="rwm_expected_disadvantaged_pct">RWM Expected % (Disadvantaged)</option>
|
||||
<option value="rwm_expected_non_disadvantaged_pct">RWM Expected % (Non-Disadvantaged)</option>
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
<option value="rwm_expected_3yr_pct">RWM Expected % (3-Year Avg)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<select id="ranking-year" class="filter-select">
|
||||
<!-- Populated by JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="rankings-list" id="rankings-list">
|
||||
<!-- Rankings populated by JS -->
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- School Detail Modal -->
|
||||
<div class="modal" id="school-modal">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" id="modal-close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-school-name"></h2>
|
||||
<div class="modal-meta" id="modal-meta"></div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-chart-container">
|
||||
<canvas id="school-detail-chart"></canvas>
|
||||
</div>
|
||||
<div class="modal-stats" id="modal-stats"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" id="add-to-compare">Add to Compare</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<p>Data source: <a href="https://www.compare-school-performance.service.gov.uk/download-data" target="_blank">UK Government - Compare School Performance</a></p>
|
||||
<p class="footer-note">Primary school (KS2) data for Wandsworth and Merton. Data from 2019-2020, 2020-2021, 2021-2022 unavailable due to COVID-19 disruption.</p>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
931
frontend/styles.css
Normal file
931
frontend/styles.css
Normal file
@@ -0,0 +1,931 @@
|
||||
/*
|
||||
* School Performance Compass
|
||||
* A warm, editorial design inspired by quality publications
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Warm, sophisticated palette */
|
||||
--bg-primary: #faf7f2;
|
||||
--bg-secondary: #f3ede4;
|
||||
--bg-card: #ffffff;
|
||||
--bg-accent: #1a1612;
|
||||
|
||||
--text-primary: #1a1612;
|
||||
--text-secondary: #5c564d;
|
||||
--text-muted: #8a847a;
|
||||
--text-inverse: #faf7f2;
|
||||
|
||||
--accent-coral: #e07256;
|
||||
--accent-coral-dark: #c45a3f;
|
||||
--accent-teal: #2d7d7d;
|
||||
--accent-teal-light: #3a9e9e;
|
||||
--accent-gold: #c9a227;
|
||||
--accent-navy: #2c3e50;
|
||||
|
||||
/* Chart colors */
|
||||
--chart-1: #e07256;
|
||||
--chart-2: #2d7d7d;
|
||||
--chart-3: #c9a227;
|
||||
--chart-4: #7b68a6;
|
||||
--chart-5: #3498db;
|
||||
|
||||
--border-color: #e5dfd5;
|
||||
--shadow-soft: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
--shadow-medium: 0 4px 20px rgba(26, 22, 18, 0.1);
|
||||
--shadow-strong: 0 8px 40px rgba(26, 22, 18, 0.15);
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
--transition: 0.2s ease;
|
||||
--transition-slow: 0.4s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Subtle noise texture overlay */
|
||||
.noise-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
z-index: 1000;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--accent-coral);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.6rem 1.2rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--bg-accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.search-section {
|
||||
max-width: 700px;
|
||||
margin: 2rem auto 3rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem 1rem 3.5rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral);
|
||||
box-shadow: 0 0 0 4px rgba(224, 114, 86, 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.6rem 2rem 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%235c564d' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
|
||||
/* Schools Grid */
|
||||
.schools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.school-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.school-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: var(--accent-coral);
|
||||
transform: scaleY(0);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.school-card:hover {
|
||||
border-color: var(--accent-coral);
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.school-card:hover::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.school-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.school-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.school-tag.type {
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.school-address {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.school-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-value.positive {
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.stat-value.negative {
|
||||
color: var(--accent-coral);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Section Titles */
|
||||
.section-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Compare View */
|
||||
.compare-header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.selected-schools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
min-height: 100px;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-selection {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selected-school-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.selected-school-tag .remove {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.selected-school-tag .remove:hover {
|
||||
background: var(--accent-coral);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.compare-search-section {
|
||||
max-width: 500px;
|
||||
margin: 0 auto 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compare-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-medium);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compare-results.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.compare-result-item {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.compare-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.compare-result-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.compare-result-item .name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.compare-result-item .location {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Charts Section */
|
||||
.charts-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.metric-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-selector label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Data Table */
|
||||
.data-table-container {
|
||||
overflow-x: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: rgba(224, 114, 86, 0.03);
|
||||
}
|
||||
|
||||
/* Rankings View */
|
||||
.rankings-header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.rankings-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.rankings-list {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 0.75rem;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ranking-item:hover {
|
||||
border-color: var(--accent-coral);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.ranking-position {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ranking-position.top-3 {
|
||||
background: linear-gradient(135deg, var(--accent-gold), #d4af37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ranking-position:not(.top-3) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ranking-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ranking-name {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ranking-location {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ranking-score {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ranking-score-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.ranking-score-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 200;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(26, 22, 18, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xl);
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-strong);
|
||||
animation: modalIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--accent-coral);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-close svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 2rem 2rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.modal-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-chart-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.modal-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-stats-section {
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-stats-section h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.modal-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-stat {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-coral);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-coral-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
margin-top: 3rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent-teal);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent-coral);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schools-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.rankings-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pandas==2.1.4
|
||||
python-multipart==0.0.6
|
||||
aiofiles==23.2.1
|
||||
|
||||
181
scripts/download_data.py
Normal file
181
scripts/download_data.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Data Download Helper Script
|
||||
|
||||
This script provides instructions and utilities for downloading
|
||||
UK school performance data from the official government source.
|
||||
|
||||
Data Source: https://www.compare-school-performance.service.gov.uk/download-data
|
||||
|
||||
Note: The actual CSV downloads require manual selection on the website
|
||||
as they use dynamic form submissions. This script helps prepare and
|
||||
organize the downloaded data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||
|
||||
|
||||
def print_instructions():
|
||||
"""Print instructions for downloading the data."""
|
||||
print("""
|
||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ UK School Performance Data Download Instructions ║
|
||||
╠══════════════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ 1. Visit: https://www.compare-school-performance.service.gov.uk/download-data║
|
||||
║ ║
|
||||
║ 2. For each year (2019-2020 through 2023-2024), select: ║
|
||||
║ • Year: Select the academic year ║
|
||||
║ • Data type: "Key Stage 4" (for secondary school GCSE data) ║
|
||||
║ • File type: "All data" or specific metrics you need ║
|
||||
║ ║
|
||||
║ 3. Key metrics available: ║
|
||||
║ • Progress 8 - measures pupil progress from KS2 to KS4 ║
|
||||
║ • Attainment 8 - average attainment across 8 qualifications ║
|
||||
║ • English & Maths Grade 5+ percentage ║
|
||||
║ • EBacc entry and achievement percentages ║
|
||||
║ ║
|
||||
║ 4. Download the CSV files and place them in the 'data' folder ║
|
||||
║ ║
|
||||
║ 5. Rename files with the year for clarity, e.g.: ║
|
||||
║ • ks4_2020.csv ║
|
||||
║ • ks4_2021.csv ║
|
||||
║ • ks4_2022.csv ║
|
||||
║ • ks4_2023.csv ║
|
||||
║ • ks4_2024.csv ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
|
||||
def check_data_files():
|
||||
"""Check what data files are present in the data directory."""
|
||||
if not DATA_DIR.exists():
|
||||
print(f"Data directory not found: {DATA_DIR}")
|
||||
return []
|
||||
|
||||
csv_files = list(DATA_DIR.glob("*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No CSV files found in the data directory.")
|
||||
print(f"Please place your downloaded CSV files in: {DATA_DIR}")
|
||||
return []
|
||||
|
||||
print(f"\nFound {len(csv_files)} CSV file(s):")
|
||||
for f in csv_files:
|
||||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
print(f" • {f.name} ({size_mb:.2f} MB)")
|
||||
|
||||
return csv_files
|
||||
|
||||
|
||||
def preview_data(file_path: Path, rows: int = 5):
|
||||
"""Preview a CSV file."""
|
||||
try:
|
||||
df = pd.read_csv(file_path, nrows=rows)
|
||||
print(f"\n--- Preview of {file_path.name} ---")
|
||||
print(f"Columns ({len(df.columns)}):")
|
||||
for col in df.columns[:20]:
|
||||
print(f" • {col}")
|
||||
if len(df.columns) > 20:
|
||||
print(f" ... and {len(df.columns) - 20} more columns")
|
||||
print(f"\nFirst {rows} rows:")
|
||||
print(df.to_string())
|
||||
except Exception as e:
|
||||
print(f"Error reading {file_path}: {e}")
|
||||
|
||||
|
||||
def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Standardize column names for consistency."""
|
||||
# Common column mappings from the official data
|
||||
column_mappings = {
|
||||
'URN': 'urn',
|
||||
'SCHNAME': 'school_name',
|
||||
'TOWN': 'town',
|
||||
'REGION': 'region',
|
||||
'RELDENOM': 'school_type',
|
||||
'P8MEA': 'progress_8',
|
||||
'ATT8SCR': 'attainment_8',
|
||||
'PTAC5EM': 'grade_5_eng_maths_pct',
|
||||
'PTEBACCEG': 'ebacc_entry_pct',
|
||||
'TPUP': 'pupils',
|
||||
}
|
||||
|
||||
# Normalize column names
|
||||
df.columns = df.columns.str.strip().str.upper()
|
||||
|
||||
# Apply mappings
|
||||
df = df.rename(columns={k.upper(): v for k, v in column_mappings.items()})
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def process_and_combine_data():
|
||||
"""Process and combine all CSV files into a single dataset."""
|
||||
csv_files = check_data_files()
|
||||
if not csv_files:
|
||||
return None
|
||||
|
||||
all_data = []
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"\nProcessing: {csv_file.name}")
|
||||
try:
|
||||
df = pd.read_csv(csv_file, low_memory=False)
|
||||
df = standardize_columns(df)
|
||||
|
||||
# Try to extract year from filename
|
||||
import re
|
||||
year_match = re.search(r'20\d{2}', csv_file.stem)
|
||||
if year_match:
|
||||
df['year'] = int(year_match.group())
|
||||
|
||||
all_data.append(df)
|
||||
print(f" Loaded {len(df)} rows")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
if all_data:
|
||||
combined = pd.concat(all_data, ignore_index=True)
|
||||
output_path = DATA_DIR / "combined_data.csv"
|
||||
combined.to_csv(output_path, index=False)
|
||||
print(f"\nCombined data saved to: {output_path}")
|
||||
print(f"Total rows: {len(combined)}")
|
||||
return combined
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) > 1:
|
||||
command = sys.argv[1].lower()
|
||||
|
||||
if command == "check":
|
||||
check_data_files()
|
||||
elif command == "preview" and len(sys.argv) > 2:
|
||||
file_path = DATA_DIR / sys.argv[2]
|
||||
if file_path.exists():
|
||||
preview_data(file_path)
|
||||
else:
|
||||
print(f"File not found: {file_path}")
|
||||
elif command == "combine":
|
||||
process_and_combine_data()
|
||||
else:
|
||||
print_instructions()
|
||||
else:
|
||||
print_instructions()
|
||||
print("\nAvailable commands:")
|
||||
print(" python download_data.py check - Check for existing data files")
|
||||
print(" python download_data.py preview <filename> - Preview a CSV file")
|
||||
print(" python download_data.py combine - Combine all CSV files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
253
scripts/fetch_real_data.py
Normal file
253
scripts/fetch_real_data.py
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch real school performance data from UK Government sources.
|
||||
|
||||
This script downloads KS2 (Key Stage 2) primary school data from:
|
||||
- Compare School Performance service
|
||||
- Get Information about Schools (GIAS)
|
||||
|
||||
Data is filtered to only include schools in Wandsworth and Merton.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from io import StringIO
|
||||
|
||||
# Output directory
|
||||
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||
|
||||
# Local Authority codes for Wandsworth and Merton
|
||||
LA_CODES = {
|
||||
"Wandsworth": "212",
|
||||
"Merton": "315"
|
||||
}
|
||||
|
||||
# Academic years to fetch (last 5 years available)
|
||||
YEARS = ["2023-2024", "2022-2023", "2021-2022", "2019-2020", "2018-2019"]
|
||||
# Note: 2020-2021 had no SATs due to COVID
|
||||
|
||||
|
||||
def fetch_gias_data():
|
||||
"""
|
||||
Fetch school establishment data from Get Information About Schools.
|
||||
This gives us the list of schools with URN, name, address, type, etc.
|
||||
"""
|
||||
print("Fetching school establishment data from GIAS...")
|
||||
|
||||
# GIAS provides downloadable extracts
|
||||
# Main extract URL (this may need to be updated periodically)
|
||||
gias_url = "https://ea-edubase-api-prod.azurewebsites.net/edubase/downloads/public/edubasealldata.csv"
|
||||
|
||||
try:
|
||||
response = requests.get(gias_url, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse CSV
|
||||
df = pd.read_csv(StringIO(response.text), encoding='utf-8-sig', low_memory=False)
|
||||
|
||||
# Filter to primary schools in Wandsworth and Merton
|
||||
# Phase of education: Primary, Middle deemed primary
|
||||
# LA codes: 212 (Wandsworth), 315 (Merton)
|
||||
df = df[
|
||||
(df['LA (code)'].astype(str).isin(LA_CODES.values())) &
|
||||
(df['PhaseOfEducation (name)'].str.contains('Primary', na=False))
|
||||
]
|
||||
|
||||
# Select relevant columns
|
||||
columns_to_keep = [
|
||||
'URN', 'EstablishmentName', 'LA (name)', 'TypeOfEstablishment (name)',
|
||||
'Street', 'Locality', 'Town', 'Postcode',
|
||||
'SchoolCapacity', 'NumberOfPupils', 'OfstedRating (name)'
|
||||
]
|
||||
available_cols = [c for c in columns_to_keep if c in df.columns]
|
||||
df = df[available_cols]
|
||||
|
||||
# Rename columns
|
||||
df = df.rename(columns={
|
||||
'URN': 'urn',
|
||||
'EstablishmentName': 'school_name',
|
||||
'LA (name)': 'local_authority',
|
||||
'TypeOfEstablishment (name)': 'school_type',
|
||||
'Street': 'street',
|
||||
'Town': 'town',
|
||||
'Postcode': 'postcode',
|
||||
'NumberOfPupils': 'pupils',
|
||||
'OfstedRating (name)': 'ofsted_rating'
|
||||
})
|
||||
|
||||
# Create address field
|
||||
df['address'] = df.apply(
|
||||
lambda row: f"{row.get('street', '')}, {row.get('postcode', '')}".strip(', '),
|
||||
axis=1
|
||||
)
|
||||
|
||||
print(f"Found {len(df)} primary schools in Wandsworth and Merton")
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching GIAS data: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_ks2_performance_data():
|
||||
"""
|
||||
Fetch KS2 performance data from Compare School Performance.
|
||||
|
||||
Note: The official download page requires form submission.
|
||||
We'll try to access the underlying data files directly.
|
||||
"""
|
||||
print("\nFetching KS2 performance data...")
|
||||
|
||||
# The performance data is available at gov.uk statistics pages
|
||||
# KS2 data URLs follow a pattern
|
||||
base_urls = {
|
||||
"2023-2024": "https://content.explore-education-statistics.service.gov.uk/api/releases/",
|
||||
"2022-2023": "https://content.explore-education-statistics.service.gov.uk/api/releases/",
|
||||
}
|
||||
|
||||
# Alternative: Direct download links from gov.uk (when available)
|
||||
# These URLs may need to be updated when new data is released
|
||||
data_urls = {
|
||||
# 2024 KS2 results (provisional)
|
||||
"2024": "https://content.explore-education-statistics.service.gov.uk/api/releases/b4cb82e3-6dca-4c98-a3b0-ba7d1d3ef555/files",
|
||||
}
|
||||
|
||||
print("Note: For the most accurate data, please download manually from:")
|
||||
print("https://www.compare-school-performance.service.gov.uk/download-data")
|
||||
print("\nSteps:")
|
||||
print("1. Select 'Key Stage 2' for Data type")
|
||||
print("2. Select 'All data' for File type")
|
||||
print("3. Select desired academic year")
|
||||
print("4. Download and place CSV files in the 'data' folder")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def download_from_explore_education_statistics():
|
||||
"""
|
||||
Try to fetch data from the Explore Education Statistics API.
|
||||
API docs: https://dfe-analytical-services.github.io/explore-education-statistics-api-docs/
|
||||
"""
|
||||
print("\nAttempting to fetch from Explore Education Statistics API...")
|
||||
|
||||
api_base = "https://explore-education-statistics.service.gov.uk/api/v1"
|
||||
|
||||
# First, list available publications
|
||||
try:
|
||||
# Get KS2 publication
|
||||
publications_url = f"{api_base}/publications"
|
||||
response = requests.get(publications_url, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
publications = response.json()
|
||||
|
||||
# Find KS2 related publication
|
||||
ks2_pubs = [p for p in publications.get('results', [])
|
||||
if 'key stage 2' in p.get('title', '').lower()
|
||||
or 'ks2' in p.get('title', '').lower()]
|
||||
|
||||
if ks2_pubs:
|
||||
print(f"Found KS2 publications: {[p['title'] for p in ks2_pubs]}")
|
||||
|
||||
# Get the latest release
|
||||
for pub in ks2_pubs:
|
||||
pub_id = pub.get('id')
|
||||
if pub_id:
|
||||
release_url = f"{api_base}/publications/{pub_id}/releases/latest"
|
||||
release_response = requests.get(release_url, timeout=30)
|
||||
|
||||
if release_response.status_code == 200:
|
||||
release = release_response.json()
|
||||
print(f"Latest release: {release.get('title')}")
|
||||
|
||||
# Get data files
|
||||
data_sets = release.get('dataSets', [])
|
||||
for ds in data_sets:
|
||||
print(f" - Dataset: {ds.get('name')}")
|
||||
else:
|
||||
print("No KS2 publications found via API")
|
||||
else:
|
||||
print(f"API returned status {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error accessing API: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_combined_dataset(schools_df, performance_data=None):
|
||||
"""
|
||||
Combine school information with performance data.
|
||||
If no performance data is available, returns school info only.
|
||||
"""
|
||||
if schools_df is None:
|
||||
return None
|
||||
|
||||
# Add year column for compatibility
|
||||
schools_df['year'] = 2024
|
||||
|
||||
# Add placeholder performance columns if no real data
|
||||
if performance_data is None:
|
||||
print("\nNo performance data available - school list saved without metrics")
|
||||
print("Download KS2 data manually and re-run to add performance metrics")
|
||||
|
||||
return schools_df
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
print("=" * 60)
|
||||
print("Fetching Real School Data for Wandsworth & Merton")
|
||||
print("=" * 60)
|
||||
|
||||
# Create data directory
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Fetch school establishment data
|
||||
schools_df = fetch_gias_data()
|
||||
|
||||
# Try to fetch performance data
|
||||
fetch_ks2_performance_data()
|
||||
download_from_explore_education_statistics()
|
||||
|
||||
# Save school data
|
||||
if schools_df is not None:
|
||||
output_file = DATA_DIR / "schools_wandsworth_merton.csv"
|
||||
schools_df.to_csv(output_file, index=False)
|
||||
print(f"\nSchool data saved to: {output_file}")
|
||||
print(f"Total schools: {len(schools_df)}")
|
||||
|
||||
# Show breakdown
|
||||
print("\nBreakdown by Local Authority:")
|
||||
print(schools_df['local_authority'].value_counts())
|
||||
else:
|
||||
print("\nFailed to fetch school data")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("NEXT STEPS:")
|
||||
print("=" * 60)
|
||||
print("""
|
||||
To get complete performance data:
|
||||
|
||||
1. Go to: https://www.compare-school-performance.service.gov.uk/download-data
|
||||
|
||||
2. Download KS2 data for each year (2019-2024):
|
||||
- Select: Key Stage 2
|
||||
- Select: All data (or specific metrics)
|
||||
- Select: Academic year
|
||||
- Click: Download data
|
||||
|
||||
3. Place downloaded CSV files in the 'data' folder
|
||||
|
||||
4. Restart the application - it will automatically load the real data
|
||||
|
||||
The app will merge school info with performance metrics.
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user