diff --git a/backend/app.py b/backend/app.py index 6211fd1..1983754 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,6 +1,6 @@ """ SchoolCompare.co.uk API -Serves primary school (KS2) performance data for comparing schools. +Serves primary and secondary school performance data for comparing schools. Uses real data from UK Government Compare School Performance downloads. """ @@ -151,7 +151,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="SchoolCompare API", - description="API for comparing primary school (KS2) performance data - schoolcompare.co.uk", + description="API for comparing primary and secondary school performance data - schoolcompare.co.uk", version="2.0.0", lifespan=lifespan, # Disable docs in production for security @@ -213,21 +213,23 @@ async def get_schools( None, description="Filter by local authority", max_length=100 ), school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100), + phase: Optional[str] = Query(None, description="Filter by phase: primary, secondary, all-through", max_length=50), postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10), radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"), page: int = Query(1, ge=1, le=1000, description="Page number"), page_size: int = Query(None, ge=1, le=100, description="Results per page"), ): """ - Get list of unique primary schools with pagination. + Get list of schools with pagination. Returns paginated results with total count for efficient loading. - Supports location-based search using postcode. + Supports location-based search using postcode and phase filtering. """ # Sanitize inputs search = sanitize_search_input(search) local_authority = sanitize_search_input(local_authority) school_type = sanitize_search_input(school_type) + phase = sanitize_search_input(phase) postcode = validate_postcode(postcode) df = load_school_data() @@ -253,9 +255,25 @@ async def get_schools( ) df_latest = df_latest.merge(prev_rwm, on="urn", how="left") + # Phase filter + if phase: + phase_lower = phase.lower() + if phase_lower in ("primary", "secondary", "all-through", "all_through"): + # Map param values to GIAS phase strings (partial match) + phase_map = { + "primary": "primary", + "secondary": "secondary", + "all-through": "all-through", + "all_through": "all-through", + } + phase_substr = phase_map[phase_lower] + schools_df_phase_mask = df_latest["phase"].str.lower().str.contains(phase_substr, na=False) + df_latest = df_latest[schools_df_phase_mask] + # Include key result metrics for display on cards location_cols = ["latitude", "longitude"] result_cols = [ + "phase", "year", "rwm_expected_pct", "rwm_high_pct", @@ -264,6 +282,8 @@ async def get_schools( "writing_expected_pct", "maths_expected_pct", "total_pupils", + "attainment_8_score", + "english_maths_standard_pass_pct", ] available_cols = [ c @@ -364,7 +384,7 @@ async def get_schools( @app.get("/api/schools/{urn}") @limiter.limit(f"{settings.rate_limit_per_minute}/minute") async def get_school_details(request: Request, urn: int): - """Get detailed KS2 data for a specific primary school across all years.""" + """Get detailed performance data for a specific school across all years.""" # Validate URN range (UK school URNs are 6 digits) if not (100000 <= urn <= 999999): raise HTTPException(status_code=400, detail="Invalid URN format") @@ -406,7 +426,7 @@ async def get_school_details(request: Request, urn: int): "age_range": latest.get("age_range", ""), "latitude": latest.get("latitude"), "longitude": latest.get("longitude"), - "phase": "Primary", + "phase": latest.get("phase"), # GIAS fields "website": latest.get("website"), "headteacher_name": latest.get("headteacher_name"), @@ -433,7 +453,7 @@ async def compare_schools( request: Request, urns: str = Query(..., description="Comma-separated URNs", max_length=100) ): - """Compare multiple primary schools side by side.""" + """Compare multiple schools side by side.""" df = load_school_data() if df.empty: @@ -487,10 +507,66 @@ async def get_filter_options(request: Request): "years": [], } + # Phases: return values from data, ordered sensibly + phases = sorted(df["phase"].dropna().unique().tolist()) if "phase" in df.columns else [] + 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()), + "phases": phases, + } + + +@app.get("/api/national-averages") +@limiter.limit(f"{settings.rate_limit_per_minute}/minute") +async def get_national_averages(request: Request): + """ + Compute national average for each metric from the latest data year. + Returns separate averages for primary (KS2) and secondary (KS4) schools. + Values are derived from the loaded DataFrame so they automatically + stay current when new data is loaded. + """ + df = load_school_data() + if df.empty: + return {"primary": {}, "secondary": {}} + + latest_year = int(df["year"].max()) + df_latest = df[df["year"] == latest_year] + + ks2_metrics = [ + "rwm_expected_pct", "rwm_high_pct", + "reading_expected_pct", "writing_expected_pct", "maths_expected_pct", + "reading_avg_score", "maths_avg_score", "gps_avg_score", + "reading_progress", "writing_progress", "maths_progress", + "overall_absence_pct", "persistent_absence_pct", + "disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", + ] + ks4_metrics = [ + "attainment_8_score", "progress_8_score", + "english_maths_standard_pass_pct", "english_maths_strong_pass_pct", + "ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct", + "ebacc_avg_score", "gcse_grade_91_pct", + ] + + def _means(sub_df, metric_list): + out = {} + for col in metric_list: + if col in sub_df.columns: + val = sub_df[col].dropna() + if len(val) > 0: + out[col] = round(float(val.mean()), 2) + return out + + # Primary: schools where KS2 data is non-null + primary_df = df_latest[df_latest["rwm_expected_pct"].notna()] + # Secondary: schools where KS4 data is non-null + secondary_df = df_latest[df_latest["attainment_8_score"].notna()] + + return { + "year": latest_year, + "primary": _means(primary_df, ks2_metrics), + "secondary": _means(secondary_df, ks4_metrics), } @@ -498,7 +574,7 @@ async def get_filter_options(request: Request): @limiter.limit(f"{settings.rate_limit_per_minute}/minute") async def get_available_metrics(request: Request): """ - Get list of available KS2 performance metrics for primary schools. + Get list of available performance metrics for schools. This is the single source of truth for metric definitions. Frontend should consume this to avoid duplication. @@ -517,7 +593,7 @@ async def get_available_metrics(request: Request): @limiter.limit(f"{settings.rate_limit_per_minute}/minute") async def get_rankings( request: Request, - metric: str = Query("rwm_expected_pct", description="KS2 metric to rank by", max_length=50), + metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50), year: Optional[int] = Query( None, description="Specific year (defaults to most recent)", ge=2000, le=2100 ), @@ -526,7 +602,7 @@ async def get_rankings( None, description="Filter by local authority", max_length=100 ), ): - """Get primary school rankings by a specific KS2 metric.""" + """Get school rankings by a specific metric.""" # Sanitize local authority input local_authority = sanitize_search_input(local_authority) diff --git a/backend/data_loader.py b/backend/data_loader.py index 26aa79a..95e1dd4 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -113,6 +113,7 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl # ============================================================================= _MAIN_QUERY = text(""" + -- Branch 1: Primary schools (KS2 data; KS4 columns NULL) SELECT s.urn, s.school_name, @@ -137,11 +138,11 @@ _MAIN_QUERY = text(""" l.postcode, l.latitude, l.longitude, - -- KS2 performance k.year, k.source_urn, k.total_pupils, k.eligible_pupils, + -- KS2 columns k.rwm_expected_pct, k.rwm_high_pct, k.reading_expected_pct, @@ -175,11 +176,116 @@ _MAIN_QUERY = text(""" k.eal_pct, k.sen_support_pct, k.sen_ehcp_pct, - k.stability_pct + k.stability_pct, + -- KS4 columns (NULL for primary) + NULL::numeric AS attainment_8_score, + NULL::numeric AS progress_8_score, + NULL::numeric AS progress_8_lower_ci, + NULL::numeric AS progress_8_upper_ci, + NULL::numeric AS progress_8_english, + NULL::numeric AS progress_8_maths, + NULL::numeric AS progress_8_ebacc, + NULL::numeric AS progress_8_open, + NULL::numeric AS english_maths_strong_pass_pct, + NULL::numeric AS english_maths_standard_pass_pct, + NULL::numeric AS ebacc_entry_pct, + NULL::numeric AS ebacc_strong_pass_pct, + NULL::numeric AS ebacc_standard_pass_pct, + NULL::numeric AS ebacc_avg_score, + NULL::numeric AS gcse_grade_91_pct, + NULL::numeric AS prior_attainment_avg FROM marts.dim_school s JOIN marts.dim_location l ON s.urn = l.urn JOIN marts.fact_ks2_performance k ON s.urn = k.urn - ORDER BY s.school_name, k.year + + UNION ALL + + -- Branch 2: Secondary schools (KS4 data; KS2 columns NULL) + SELECT + s.urn, + s.school_name, + s.phase, + s.school_type, + s.academy_trust_name AS trust_name, + s.academy_trust_uid AS trust_uid, + s.religious_character AS religious_denomination, + s.gender, + s.age_range, + s.capacity, + s.headteacher_name, + s.website, + s.ofsted_grade, + s.ofsted_date, + s.ofsted_framework, + l.local_authority_name AS local_authority, + l.local_authority_code, + l.address_line1 AS address1, + l.address_line2 AS address2, + l.town, + l.postcode, + l.latitude, + l.longitude, + k4.year, + k4.source_urn, + k4.total_pupils, + k4.eligible_pupils, + -- KS2 columns (NULL for secondary) + NULL::numeric AS rwm_expected_pct, + NULL::numeric AS rwm_high_pct, + NULL::numeric AS reading_expected_pct, + NULL::numeric AS reading_high_pct, + NULL::numeric AS reading_avg_score, + NULL::numeric AS reading_progress, + NULL::numeric AS writing_expected_pct, + NULL::numeric AS writing_high_pct, + NULL::numeric AS writing_progress, + NULL::numeric AS maths_expected_pct, + NULL::numeric AS maths_high_pct, + NULL::numeric AS maths_avg_score, + NULL::numeric AS maths_progress, + NULL::numeric AS gps_expected_pct, + NULL::numeric AS gps_high_pct, + NULL::numeric AS gps_avg_score, + NULL::numeric AS science_expected_pct, + NULL::numeric AS reading_absence_pct, + NULL::numeric AS writing_absence_pct, + NULL::numeric AS maths_absence_pct, + NULL::numeric AS gps_absence_pct, + NULL::numeric AS science_absence_pct, + NULL::numeric AS rwm_expected_boys_pct, + NULL::numeric AS rwm_high_boys_pct, + NULL::numeric AS rwm_expected_girls_pct, + NULL::numeric AS rwm_high_girls_pct, + NULL::numeric AS rwm_expected_disadvantaged_pct, + NULL::numeric AS rwm_expected_non_disadvantaged_pct, + NULL::numeric AS disadvantaged_gap, + NULL::numeric AS disadvantaged_pct, + NULL::numeric AS eal_pct, + k4.sen_support_pct, + k4.sen_ehcp_pct, + NULL::numeric AS stability_pct, + -- KS4 columns + k4.attainment_8_score, + k4.progress_8_score, + k4.progress_8_lower_ci, + k4.progress_8_upper_ci, + k4.progress_8_english, + k4.progress_8_maths, + k4.progress_8_ebacc, + k4.progress_8_open, + k4.english_maths_strong_pass_pct, + k4.english_maths_standard_pass_pct, + k4.ebacc_entry_pct, + k4.ebacc_strong_pass_pct, + k4.ebacc_standard_pass_pct, + k4.ebacc_avg_score, + k4.gcse_grade_91_pct, + k4.prior_attainment_avg + FROM marts.dim_school s + JOIN marts.dim_location l ON s.urn = l.urn + JOIN marts.fact_ks4_performance k4 ON s.urn = k4.urn + + ORDER BY school_name, year """) diff --git a/backend/schemas.py b/backend/schemas.py index fbbdae0..b3e0f0d 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -401,6 +401,70 @@ METRIC_DEFINITIONS = { "type": "score", "category": "trends", }, + # ── GCSE Performance (KS4) ──────────────────────────────────────────── + "attainment_8_score": { + "name": "Attainment 8", + "short_name": "Att 8", + "description": "Average grade across a pupil's best 8 GCSEs including English and Maths", + "type": "score", + "category": "gcse", + }, + "progress_8_score": { + "name": "Progress 8", + "short_name": "P8", + "description": "Progress from KS2 baseline to GCSE relative to similar pupils nationally (0 = national average)", + "type": "score", + "category": "gcse", + }, + "english_maths_standard_pass_pct": { + "name": "English & Maths Grade 4+", + "short_name": "E&M 4+", + "description": "% of pupils achieving grade 4 (standard pass) or above in both English and Maths", + "type": "percentage", + "category": "gcse", + }, + "english_maths_strong_pass_pct": { + "name": "English & Maths Grade 5+", + "short_name": "E&M 5+", + "description": "% of pupils achieving grade 5 (strong pass) or above in both English and Maths", + "type": "percentage", + "category": "gcse", + }, + "ebacc_entry_pct": { + "name": "EBacc Entry %", + "short_name": "EBacc Entry", + "description": "% of pupils entered for the English Baccalaureate (English, Maths, Sciences, Languages, Humanities)", + "type": "percentage", + "category": "gcse", + }, + "ebacc_standard_pass_pct": { + "name": "EBacc Grade 4+", + "short_name": "EBacc 4+", + "description": "% of pupils achieving grade 4+ across all EBacc subjects", + "type": "percentage", + "category": "gcse", + }, + "ebacc_strong_pass_pct": { + "name": "EBacc Grade 5+", + "short_name": "EBacc 5+", + "description": "% of pupils achieving grade 5+ across all EBacc subjects", + "type": "percentage", + "category": "gcse", + }, + "ebacc_avg_score": { + "name": "EBacc Average Score", + "short_name": "EBacc Avg", + "description": "Average points score across EBacc subjects", + "type": "score", + "category": "gcse", + }, + "gcse_grade_91_pct": { + "name": "GCSE Grade 9–1 %", + "short_name": "GCSE 9–1", + "description": "% of GCSE entries achieving a grade 9 to 1", + "type": "percentage", + "category": "gcse", + }, } # Ranking columns to include in rankings response @@ -456,6 +520,16 @@ RANKING_COLUMNS = [ "rwm_expected_3yr_pct", "reading_avg_3yr", "maths_avg_3yr", + # GCSE (KS4) + "attainment_8_score", + "progress_8_score", + "english_maths_standard_pass_pct", + "english_maths_strong_pass_pct", + "ebacc_entry_pct", + "ebacc_standard_pass_pct", + "ebacc_strong_pass_pct", + "ebacc_avg_score", + "gcse_grade_91_pct", ] # School listing columns diff --git a/nextjs-app/app/layout.tsx b/nextjs-app/app/layout.tsx index 7ba5770..f43e8d3 100644 --- a/nextjs-app/app/layout.tsx +++ b/nextjs-app/app/layout.tsx @@ -23,11 +23,11 @@ const playfairDisplay = Playfair_Display({ export const metadata: Metadata = { title: { - default: 'SchoolCompare | Compare Primary School Performance', + default: 'SchoolCompare | Compare School Performance', template: '%s | SchoolCompare', }, - description: 'Compare primary school KS2 performance across England', - keywords: 'school comparison, KS2 results, primary school performance, England schools, SATs results', + description: 'Compare primary and secondary school SATs and GCSE performance across England', + keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results', authors: [{ name: 'SchoolCompare' }], manifest: '/manifest.json', icons: { @@ -37,15 +37,15 @@ export const metadata: Metadata = { }, openGraph: { type: 'website', - title: 'SchoolCompare | Compare Primary School Performance', - description: 'Compare primary school KS2 performance across England', + title: 'SchoolCompare | Compare School Performance', + description: 'Compare primary and secondary school SATs and GCSE performance across England', url: 'https://schoolcompare.co.uk', siteName: 'SchoolCompare', }, twitter: { card: 'summary', - title: 'SchoolCompare | Compare Primary School Performance', - description: 'Compare primary school KS2 performance across England', + title: 'SchoolCompare | Compare School Performance', + description: 'Compare primary and secondary school SATs and GCSE performance across England', }, }; diff --git a/nextjs-app/app/page.tsx b/nextjs-app/app/page.tsx index 0f6d083..56d3206 100644 --- a/nextjs-app/app/page.tsx +++ b/nextjs-app/app/page.tsx @@ -11,6 +11,7 @@ interface HomePageProps { search?: string; local_authority?: string; school_type?: string; + phase?: string; page?: string; postcode?: string; radius?: string; @@ -19,7 +20,7 @@ interface HomePageProps { export const metadata = { title: 'Home', - description: 'Search and compare primary school KS2 performance across England', + description: 'Search and compare school performance across England', }; // Force dynamic rendering (no static generation at build time) @@ -38,6 +39,7 @@ export default async function HomePage({ searchParams }: HomePageProps) { params.search || params.local_authority || params.school_type || + params.phase || params.postcode ); @@ -52,6 +54,7 @@ export default async function HomePage({ searchParams }: HomePageProps) { search: params.search, local_authority: params.local_authority, school_type: params.school_type, + phase: params.phase, postcode: params.postcode, radius, page, @@ -65,7 +68,7 @@ export default async function HomePage({ searchParams }: HomePageProps) { return ( ); @@ -76,7 +79,7 @@ export default async function HomePage({ searchParams }: HomePageProps) { return ( ); diff --git a/nextjs-app/app/rankings/page.tsx b/nextjs-app/app/rankings/page.tsx index 57a547f..1379735 100644 --- a/nextjs-app/app/rankings/page.tsx +++ b/nextjs-app/app/rankings/page.tsx @@ -17,8 +17,8 @@ interface RankingsPageProps { export const metadata: Metadata = { title: 'School Rankings', - description: 'Top-ranked primary schools by KS2 performance across England', - keywords: 'school rankings, top schools, best schools, KS2 rankings, school league tables', + description: 'Top-ranked schools by SATs and GCSE performance across England', + keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables', }; // Force dynamic rendering diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index 401525e..9dc25b5 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -27,6 +27,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) { const currentLA = searchParams.get('local_authority') || ''; const currentType = searchParams.get('school_type') || ''; + const currentPhase = searchParams.get('phase') || ''; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -86,7 +87,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) { }); }; - const hasActiveFilters = currentSearch || currentLA || currentType || currentPostcode; + const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode; return (
@@ -152,6 +153,22 @@ export function FilterBar({ filters, isHero }: FilterBarProps) { ))} + {filters.phases && filters.phases.length > 0 && ( + + )} + {hasActiveFilters && (