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