Add Athletics tab from EADA data
New eada/ ingest downloads Equity in Athletics Disclosure Act files (FY2007-2025) and loads UD's institution totals and per-sport expenses. The Athletics tab shows expenses by category with the FBS transition marked, growth against university spending, cost per undergraduate, share of university expenses, and coach/AD compensation from Schedule J, with sourced context on UD's FY2025 budget messaging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X9jG4CUFcH6n4vFvgtwndY
This commit is contained in:
parent
eb489a782d
commit
726bc232f2
12 changed files with 814 additions and 2 deletions
|
|
@ -20,6 +20,7 @@ Currently in planning. Phase 1 (Data Acquisition) is planned but not yet built.
|
||||||
|--------|--------|-----------------|
|
|--------|--------|-----------------|
|
||||||
| IRS 990 bulk XML | XML (versioned schemas) | Filing financials, Part VII compensation, Schedule J detailed compensation |
|
| IRS 990 bulk XML | XML (versioned schemas) | Filing financials, Part VII compensation, Schedule J detailed compensation |
|
||||||
| IPEDS | CSV bulk downloads | Institution directory (HD), finance by function (F1A/F2), staffing (S/SAL), enrollment (EF) |
|
| IPEDS | CSV bulk downloads | Institution directory (HD), finance by function (F1A/F2), staffing (S/SAL), enrollment (EF) |
|
||||||
|
| EADA (Equity in Athletics) | Excel via ope.ed.gov/athletics API | Athletics expenses by sport, coaching salaries, participants (keyed by UNITID, FY2007+) |
|
||||||
| BLS CPI-U | Flat file or API | Consumer Price Index for inflation-adjusted compensation analysis |
|
| BLS CPI-U | Flat file or API | Consumer Price Index for inflation-adjusted compensation analysis |
|
||||||
| Admin office web pages (stretch) | HTML scraping | Staff directory headcounts |
|
| Admin office web pages (stretch) | HTML scraping | Staff directory headcounts |
|
||||||
|
|
||||||
|
|
@ -55,6 +56,7 @@ Not yet implemented. When built, the CLI will support:
|
||||||
admin-analytics ingest ipeds --year-range 2005-2024
|
admin-analytics ingest ipeds --year-range 2005-2024
|
||||||
admin-analytics ingest irs990 --year-range 2005-2024
|
admin-analytics ingest irs990 --year-range 2005-2024
|
||||||
admin-analytics ingest cpi
|
admin-analytics ingest cpi
|
||||||
|
admin-analytics ingest eada --year-range 2007-2025
|
||||||
admin-analytics ingest all
|
admin-analytics ingest all
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from typing import Annotated
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from admin_analytics.config import DEFAULT_YEAR_RANGE
|
from admin_analytics.config import DEFAULT_YEAR_RANGE, EADA_YEAR_RANGE
|
||||||
from admin_analytics.db.connection import get_connection
|
from admin_analytics.db.connection import get_connection
|
||||||
from admin_analytics.db.schema import ensure_schema
|
from admin_analytics.db.schema import ensure_schema
|
||||||
|
|
||||||
|
|
@ -130,6 +130,35 @@ def cpi(
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@ingest_app.command()
|
||||||
|
def eada(
|
||||||
|
year_range: Annotated[
|
||||||
|
str, typer.Option(help="Academic-year-end range, e.g. 2007-2025 (2025 = 2024-25)")
|
||||||
|
] = f"{EADA_YEAR_RANGE.start}-{EADA_YEAR_RANGE.stop - 1}",
|
||||||
|
force: Annotated[
|
||||||
|
bool, typer.Option("--force", help="Re-download even if files exist")
|
||||||
|
] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Ingest EADA athletics finance data for the University of Delaware."""
|
||||||
|
years = _parse_year_range(year_range)
|
||||||
|
conn = get_connection()
|
||||||
|
ensure_schema(conn)
|
||||||
|
|
||||||
|
from admin_analytics.eada.download import download_all
|
||||||
|
from admin_analytics.eada.loader import load_eada
|
||||||
|
|
||||||
|
typer.echo(f"Downloading EADA data for {years.start}-{years.stop - 1}...")
|
||||||
|
download_all(years, force=force)
|
||||||
|
|
||||||
|
typer.echo("Loading EADA data into database...")
|
||||||
|
totals = load_eada(conn, years)
|
||||||
|
typer.echo(
|
||||||
|
f"EADA ingestion complete: {totals['institution']} institution rows, "
|
||||||
|
f"{totals['sport']} sport rows."
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@ingest_app.command()
|
@ingest_app.command()
|
||||||
def scrape() -> None:
|
def scrape() -> None:
|
||||||
"""Scrape UD staff directory pages for admin headcounts."""
|
"""Scrape UD staff directory pages for admin headcounts."""
|
||||||
|
|
@ -202,4 +231,5 @@ def ingest_all(
|
||||||
ipeds(year_range=year_range, component="all", force=force)
|
ipeds(year_range=year_range, component="all", force=force)
|
||||||
irs990(year_range=year_range, force=force)
|
irs990(year_range=year_range, force=force)
|
||||||
cpi(force=force)
|
cpi(force=force)
|
||||||
|
eada(force=force)
|
||||||
scrape()
|
scrape()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ DEFAULT_YEAR_RANGE = range(2005, 2025) # 2005-2024 inclusive
|
||||||
BLS_CPI_URL = "https://download.bls.gov/pub/time.series/cu/cu.data.0.Current"
|
BLS_CPI_URL = "https://download.bls.gov/pub/time.series/cu/cu.data.0.Current"
|
||||||
BLS_DATA_DIR = DATA_DIR / "bls"
|
BLS_DATA_DIR = DATA_DIR / "bls"
|
||||||
|
|
||||||
|
# EADA (Equity in Athletics Disclosure Act), keyed by UNITID.
|
||||||
|
# Years are academic-year end (2024 = 2023-24), matching IPEDS fiscal years.
|
||||||
|
# Pre-2007 files use coded column names and are not supported.
|
||||||
|
EADA_API_URL = "https://ope.ed.gov/athletics/api/dataFiles"
|
||||||
|
EADA_DATA_DIR = DATA_DIR / "eada"
|
||||||
|
EADA_YEAR_RANGE = range(2007, 2026) # 2006-07 through 2024-25
|
||||||
|
|
||||||
# IRS 990
|
# IRS 990
|
||||||
UD_EINS = [516000297, 516017306] # UD + UD Research Foundation
|
UD_EINS = [516000297, 516017306] # UD + UD Research Foundation
|
||||||
IRS990_BASE_URL = "https://apps.irs.gov/pub/epostcard/990/xml"
|
IRS990_BASE_URL = "https://apps.irs.gov/pub/epostcard/990/xml"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from dash import dcc, html, Input, Output
|
||||||
|
|
||||||
from admin_analytics.db.connection import get_connection
|
from admin_analytics.db.connection import get_connection
|
||||||
from admin_analytics.dashboard.pages import (
|
from admin_analytics.dashboard.pages import (
|
||||||
overview, compensation, staffing, headcount, endowment, philanthropy, about,
|
overview, compensation, staffing, headcount, endowment, philanthropy, athletics, about,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -29,6 +29,7 @@ def create_app() -> dash.Dash:
|
||||||
dcc.Tab(label="Staffing & Enrollment", value="staffing"),
|
dcc.Tab(label="Staffing & Enrollment", value="staffing"),
|
||||||
dcc.Tab(label="Endowment", value="endowment"),
|
dcc.Tab(label="Endowment", value="endowment"),
|
||||||
dcc.Tab(label="Philanthropy", value="philanthropy"),
|
dcc.Tab(label="Philanthropy", value="philanthropy"),
|
||||||
|
dcc.Tab(label="Athletics", value="athletics"),
|
||||||
dcc.Tab(label="Current Headcount", value="headcount"),
|
dcc.Tab(label="Current Headcount", value="headcount"),
|
||||||
dcc.Tab(label="About", value="about"),
|
dcc.Tab(label="About", value="about"),
|
||||||
],
|
],
|
||||||
|
|
@ -52,6 +53,8 @@ def create_app() -> dash.Dash:
|
||||||
return endowment.layout(conn)
|
return endowment.layout(conn)
|
||||||
elif tab == "philanthropy":
|
elif tab == "philanthropy":
|
||||||
return philanthropy.layout(conn)
|
return philanthropy.layout(conn)
|
||||||
|
elif tab == "athletics":
|
||||||
|
return athletics.layout(conn)
|
||||||
elif tab == "headcount":
|
elif tab == "headcount":
|
||||||
return headcount.layout(conn)
|
return headcount.layout(conn)
|
||||||
elif tab == "about":
|
elif tab == "about":
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ All data is drawn from public, open-access sources:
|
||||||
|--------|-----------|-------------|----------|
|
|--------|-----------|-------------|----------|
|
||||||
| **IPEDS** | U.S. Dept. of Education, NCES | Institutional directory, expenses by function, staffing by occupation, enrollment, endowment, philanthropic gifts | 2005-2024 |
|
| **IPEDS** | U.S. Dept. of Education, NCES | Institutional directory, expenses by function, staffing by occupation, enrollment, endowment, philanthropic gifts | 2005-2024 |
|
||||||
| **IRS Form 990** | Internal Revenue Service | Executive compensation (Schedule J), filing financials for UD and UD Research Foundation | Tax years 2015-2025 |
|
| **IRS Form 990** | Internal Revenue Service | Executive compensation (Schedule J), filing financials for UD and UD Research Foundation | Tax years 2015-2025 |
|
||||||
|
| **EADA** | U.S. Dept. of Education, OPE | Athletics expenses by sport, participants, coaching salaries, full-time undergraduate enrollment | FY 2007-2025 |
|
||||||
| **BLS CPI-U** | Bureau of Labor Statistics | Consumer Price Index for inflation adjustment (series CUUR0000SA0) | Full history |
|
| **BLS CPI-U** | Bureau of Labor Statistics | Consumer Price Index for inflation adjustment (series CUUR0000SA0) | Full history |
|
||||||
| **UD Staff Directories** | University of Delaware public web pages | Administrative office headcounts (College of Engineering line management, Provost's Office) | Current snapshot |
|
| **UD Staff Directories** | University of Delaware public web pages | Administrative office headcounts (College of Engineering line management, Provost's Office) | Current snapshot |
|
||||||
|
|
||||||
|
|
@ -53,6 +54,14 @@ year. For 2005-2019, only beginning and end-of-year values are reported.
|
||||||
contracts. The compensation-vs-giving comparison indexes the President and VP
|
contracts. The compensation-vs-giving comparison indexes the President and VP
|
||||||
of Development compensation against total philanthropic revenue.
|
of Development compensation against total philanthropic revenue.
|
||||||
|
|
||||||
|
**Athletics** uses Equity in Athletics Disclosure Act (EADA) filings, keyed by
|
||||||
|
the same IPEDS UNITID. Expenses are split into football, basketball, other
|
||||||
|
sports, and spending not allocated to a single team. Per-student figures use
|
||||||
|
EADA's full-time undergraduate enrollment. Head coach and athletic director
|
||||||
|
compensation comes from Schedule J. EADA reports athletics revenue equal to
|
||||||
|
expenses, so it measures the cost of athletics, not the institutional
|
||||||
|
subsidy.
|
||||||
|
|
||||||
**Inflation Adjustment** uses the BLS CPI-U annual average (all items, U.S.
|
**Inflation Adjustment** uses the BLS CPI-U annual average (all items, U.S.
|
||||||
city average, not seasonally adjusted). CPI-adjusted values are expressed in
|
city average, not seasonally adjusted). CPI-adjusted values are expressed in
|
||||||
the most recent available year's dollars.
|
the most recent available year's dollars.
|
||||||
|
|
|
||||||
232
src/admin_analytics/dashboard/pages/athletics.py
Normal file
232
src/admin_analytics/dashboard/pages/athletics.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"""Page: Intercollegiate Athletics."""
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
from dash import html, dcc
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
from admin_analytics.dashboard.components import (
|
||||||
|
graph,
|
||||||
|
kpi_card,
|
||||||
|
kpi_row,
|
||||||
|
chart_row,
|
||||||
|
no_data,
|
||||||
|
)
|
||||||
|
from admin_analytics.dashboard.queries import (
|
||||||
|
CONSISTENT_REPORTING_YEAR,
|
||||||
|
FBS_TRANSITION_YEAR,
|
||||||
|
query_athletics_expenses,
|
||||||
|
query_athletics_vs_university,
|
||||||
|
query_athletics_comp,
|
||||||
|
)
|
||||||
|
|
||||||
|
_NO_DATA = no_data("No athletics data loaded. Run: admin-analytics ingest eada")
|
||||||
|
|
||||||
|
_ROLE_COLORS = {
|
||||||
|
"Head Football Coach": "#00539F",
|
||||||
|
"Head Basketball Coach": "#E07A5F",
|
||||||
|
"Athletic Director": "#7FB069",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CONTEXT = """
|
||||||
|
#### Context
|
||||||
|
|
||||||
|
**University finances.** In October 2024, President Assanis projected a FY2025
|
||||||
|
shortfall of $20–40 million, citing inflation, salary competition, financial
|
||||||
|
aid, and health insurance premiums. The response included a partial hiring
|
||||||
|
freeze, delayed capital projects, $11 million in payroll reductions, and $14
|
||||||
|
million saved by "reducing travel and other expenses"
|
||||||
|
([Newark Post, Oct. 4, 2024](https://www.newarkpostonline.com/news/assanis-on-uds-financial-woes-were-not-out-of-the-woods-yet/article_81015f48-8226-11ef-a864-db72de6d8f73.html)).
|
||||||
|
|
||||||
|
**Athletics.** The same fiscal year (2024–25) was football's transition to the
|
||||||
|
Football Bowl Subdivision ahead of full Conference USA membership on July 1,
|
||||||
|
2025. The Athletic Director told the Faculty Senate in December 2024 that the
|
||||||
|
department had cut institutional support by 24% over five years while growing
|
||||||
|
self-generated revenue and "gradually rais\\[ing\\] student fees," and that the $5
|
||||||
|
million FBS reclassification fee was covered by philanthropy
|
||||||
|
([UDaily](https://udel.edu/udaily/2025/february/faculty-senate-meeting-december-2024)).
|
||||||
|
|
||||||
|
#### Reading these numbers
|
||||||
|
|
||||||
|
- **EADA reports cost, not subsidy.** UD reports athletics revenue equal to
|
||||||
|
expenses every year: institutional support and student fees are counted as
|
||||||
|
revenue to balance the books. These figures show what athletics *costs*,
|
||||||
|
not how much of that cost the university or students cover. Subsidy figures
|
||||||
|
from UD's NCAA financial reports are published by the
|
||||||
|
[Knight-Newhouse College Athletics Database](https://knightnewhousedata.org/fcs/university-delaware).
|
||||||
|
- **"Not allocated by sport"** is spending not assigned to a single team:
|
||||||
|
administration, facilities, conference and reclassification fees, and
|
||||||
|
similar. It doubled in 2024–25. Some of that may be one-time transition
|
||||||
|
costs, and some may be paid by gifts.
|
||||||
|
- **2011–12 drop.** Total and unallocated expenses fall sharply between
|
||||||
|
2010–11 and 2011–12 while per-sport spending keeps rising, which suggests a
|
||||||
|
change in how UD reported shared costs rather than a real cut. The
|
||||||
|
growth comparison therefore starts in FY 2012.
|
||||||
|
- **Years** are fiscal years ending June 30 (2025 = 2024–25). University-wide
|
||||||
|
comparisons stop where IPEDS finance data ends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _transition_marker(fig: go.Figure) -> None:
|
||||||
|
fig.add_vline(
|
||||||
|
x=FBS_TRANSITION_YEAR, line_dash="dot", line_color="#999",
|
||||||
|
annotation_text="FBS transition", annotation_position="top left",
|
||||||
|
annotation_font_size=11, annotation_font_color="#666",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
|
df = query_athletics_expenses(conn)
|
||||||
|
if df.height == 0:
|
||||||
|
return _NO_DATA
|
||||||
|
|
||||||
|
exp = df.to_pandas()
|
||||||
|
latest = exp.iloc[-1]
|
||||||
|
prior = exp.iloc[-2] if len(exp) > 1 else None
|
||||||
|
year = int(latest["year"])
|
||||||
|
|
||||||
|
vs_df = query_athletics_vs_university(conn)
|
||||||
|
vs = vs_df.to_pandas() if vs_df.height > 0 else None
|
||||||
|
growth_df = query_athletics_vs_university(conn, since=CONSISTENT_REPORTING_YEAR)
|
||||||
|
growth = growth_df.to_pandas() if growth_df.height > 0 else None
|
||||||
|
|
||||||
|
# KPI cards
|
||||||
|
cards = [
|
||||||
|
kpi_card(
|
||||||
|
"Athletics Expenses",
|
||||||
|
f"${latest['total_expenses'] / 1e6:.1f}M",
|
||||||
|
f"FY {year} ({year - 1}–{str(year)[2:]})",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if prior is not None:
|
||||||
|
change = latest["total_expenses"] - prior["total_expenses"]
|
||||||
|
cards.append(kpi_card(
|
||||||
|
"One-Year Change",
|
||||||
|
f"{change / prior['total_expenses'] * 100:+.0f}%",
|
||||||
|
f"{'+' if change >= 0 else '−'}${abs(change) / 1e6:.1f}M vs FY {int(prior['year'])}",
|
||||||
|
))
|
||||||
|
cards.append(kpi_card(
|
||||||
|
"Per Full-Time Undergraduate",
|
||||||
|
f"${int(latest['per_student']):,}",
|
||||||
|
f"FY {year}",
|
||||||
|
))
|
||||||
|
if vs is not None:
|
||||||
|
last_vs = vs.iloc[-1]
|
||||||
|
cards.append(kpi_card(
|
||||||
|
"Share of University Expenses",
|
||||||
|
f"{last_vs['athletics_pct']:.1f}%",
|
||||||
|
f"FY {int(last_vs['year'])} (latest IPEDS year)",
|
||||||
|
))
|
||||||
|
kpis = kpi_row(cards)
|
||||||
|
|
||||||
|
# Expenses by category, stacked
|
||||||
|
by_cat_fig = go.Figure()
|
||||||
|
for col, label, color in [
|
||||||
|
("football", "Football", "#00539F"),
|
||||||
|
("basketball", "Basketball", "#E07A5F"),
|
||||||
|
("other_sports", "Other sports", "#7FB069"),
|
||||||
|
("unallocated_expenses", "Not allocated by sport", "#B8B8B8"),
|
||||||
|
]:
|
||||||
|
by_cat_fig.add_trace(go.Bar(
|
||||||
|
x=exp["year"], y=exp[col].astype(float) / 1e6,
|
||||||
|
name=label, marker_color=color,
|
||||||
|
))
|
||||||
|
by_cat_fig.add_trace(go.Scatter(
|
||||||
|
x=exp["year"], y=exp["total_expenses_cpi"] / 1e6,
|
||||||
|
mode="lines", name="Total, CPI-adjusted",
|
||||||
|
line={"color": "#333", "dash": "dash"},
|
||||||
|
))
|
||||||
|
_transition_marker(by_cat_fig)
|
||||||
|
by_cat_fig.update_layout(
|
||||||
|
title="Athletics Expenses by Category (Millions $)",
|
||||||
|
xaxis_title="Fiscal Year", yaxis_title="Millions $",
|
||||||
|
barmode="stack", template="plotly_white", height=440,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Growth vs university spending
|
||||||
|
growth_fig = go.Figure()
|
||||||
|
if growth is not None:
|
||||||
|
for col, label, color, dash in [
|
||||||
|
("athletics_index", "Athletics", "#00539F", None),
|
||||||
|
("university_index", "Total university", "#E07A5F", None),
|
||||||
|
("instruction_index", "Instruction", "#7FB069", None),
|
||||||
|
("cpi_index", "CPI-U", "#FFD200", "dash"),
|
||||||
|
]:
|
||||||
|
growth_fig.add_trace(go.Scatter(
|
||||||
|
x=growth["year"], y=growth[col], mode="lines+markers", name=label,
|
||||||
|
line={"color": color, "dash": dash},
|
||||||
|
))
|
||||||
|
growth_fig.add_hline(y=100, line_dash="dot", line_color="#ccc")
|
||||||
|
base_year = int(growth.iloc[0]["year"])
|
||||||
|
else:
|
||||||
|
base_year = None
|
||||||
|
growth_fig.update_layout(
|
||||||
|
title=f"Spending Growth (Indexed, FY {base_year} = 100)" if base_year else "Spending Growth",
|
||||||
|
xaxis_title="Fiscal Year", yaxis_title="Index",
|
||||||
|
template="plotly_white", height=380,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-student cost
|
||||||
|
per_student_fig = go.Figure()
|
||||||
|
per_student_fig.add_trace(go.Scatter(
|
||||||
|
x=exp["year"], y=exp["per_student"],
|
||||||
|
mode="lines+markers", name="Nominal", line={"color": "#00539F"},
|
||||||
|
))
|
||||||
|
per_student_fig.add_trace(go.Scatter(
|
||||||
|
x=exp["year"], y=exp["per_student_cpi"],
|
||||||
|
mode="lines+markers", name="CPI-adjusted", line={"color": "#FFD200", "dash": "dash"},
|
||||||
|
))
|
||||||
|
_transition_marker(per_student_fig)
|
||||||
|
per_student_fig.update_layout(
|
||||||
|
title="Athletics Expenses per Full-Time Undergraduate",
|
||||||
|
xaxis_title="Fiscal Year", yaxis_title="$ per student",
|
||||||
|
template="plotly_white", height=380,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Share of university expenses
|
||||||
|
share_fig = go.Figure()
|
||||||
|
if vs is not None:
|
||||||
|
share_fig.add_trace(go.Scatter(
|
||||||
|
x=vs["year"], y=vs["athletics_pct"],
|
||||||
|
mode="lines+markers", name="Athletics share", line={"color": "#00539F"},
|
||||||
|
))
|
||||||
|
share_fig.update_layout(
|
||||||
|
title="Athletics as % of Total University Expenses",
|
||||||
|
xaxis_title="Fiscal Year", yaxis_title="%",
|
||||||
|
template="plotly_white", height=380,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Leadership compensation (Schedule J)
|
||||||
|
comp_df = query_athletics_comp(conn)
|
||||||
|
comp_fig = go.Figure()
|
||||||
|
if comp_df.height > 0:
|
||||||
|
comp = comp_df.to_pandas()
|
||||||
|
for role, color in _ROLE_COLORS.items():
|
||||||
|
subset = comp[comp["role"] == role]
|
||||||
|
if len(subset) == 0:
|
||||||
|
continue
|
||||||
|
# Reindex to every year so missing years show as gaps, not bridges
|
||||||
|
years = range(int(comp["tax_year"].min()), int(comp["tax_year"].max()) + 1)
|
||||||
|
subset = subset.set_index("tax_year").reindex(years)
|
||||||
|
comp_fig.add_trace(go.Scatter(
|
||||||
|
x=list(years), y=subset["total_compensation"],
|
||||||
|
mode="lines+markers", name=role, line={"color": color},
|
||||||
|
customdata=subset["person_name"].fillna(""),
|
||||||
|
hovertemplate="%{customdata}<br>$%{y:,.0f}<extra>" + role + "</extra>",
|
||||||
|
))
|
||||||
|
comp_fig.update_layout(
|
||||||
|
title="Athletics Leadership Compensation (IRS 990 Schedule J)",
|
||||||
|
xaxis_title="Tax Year", yaxis_title="Total compensation ($)",
|
||||||
|
template="plotly_white", height=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
children = [
|
||||||
|
kpis,
|
||||||
|
graph(by_cat_fig),
|
||||||
|
chart_row(graph(growth_fig), graph(per_student_fig)),
|
||||||
|
]
|
||||||
|
if vs is not None:
|
||||||
|
children.append(chart_row(graph(share_fig), graph(comp_fig)))
|
||||||
|
else:
|
||||||
|
children.append(graph(comp_fig))
|
||||||
|
children.append(dcc.Markdown(_CONTEXT, className="note", link_target="_blank"))
|
||||||
|
return html.Div(children)
|
||||||
|
|
@ -553,3 +553,137 @@ def query_headcount_summary(conn: duckdb.DuckDBPyConnection) -> pl.DataFrame:
|
||||||
GROUP BY unit, category, is_overhead
|
GROUP BY unit, category, is_overhead
|
||||||
ORDER BY unit, count DESC
|
ORDER BY unit, count DESC
|
||||||
""").pl()
|
""").pl()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Athletics (EADA) ---
|
||||||
|
|
||||||
|
# Year the football program spent transitioning to FBS (2024-25); UD joined
|
||||||
|
# Conference USA as a full member on July 1, 2025 (FY2026).
|
||||||
|
FBS_TRANSITION_YEAR = 2025
|
||||||
|
|
||||||
|
# UD's unallocated athletics expenses drop by ~60% between FY2011 and FY2012
|
||||||
|
# while per-sport spending keeps rising, consistent with a change in how
|
||||||
|
# shared costs were reported. Growth comparisons start here.
|
||||||
|
CONSISTENT_REPORTING_YEAR = 2012
|
||||||
|
|
||||||
|
|
||||||
|
def query_athletics_expenses(conn: duckdb.DuckDBPyConnection) -> pl.DataFrame:
|
||||||
|
"""Athletics expenses by year, split by sport, with CPI and per-student values.
|
||||||
|
|
||||||
|
"Not allocated" is spending EADA does not assign to any single team
|
||||||
|
(administration, facilities, conference fees, etc.).
|
||||||
|
"""
|
||||||
|
return conn.execute(f"""
|
||||||
|
{_CPI_CTE},
|
||||||
|
sports AS (
|
||||||
|
SELECT year,
|
||||||
|
SUM(total_expenses) FILTER (WHERE sport = 'Football') AS football,
|
||||||
|
SUM(total_expenses) FILTER (WHERE sport = 'Basketball') AS basketball,
|
||||||
|
SUM(total_expenses) FILTER (WHERE sport NOT IN ('Football', 'Basketball'))
|
||||||
|
AS other_sports
|
||||||
|
FROM raw_eada_sport
|
||||||
|
WHERE unitid = ?
|
||||||
|
GROUP BY year
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
i.year,
|
||||||
|
i.classification,
|
||||||
|
i.total_expenses,
|
||||||
|
ROUND(i.total_expenses * (SELECT avg_cpi FROM latest_cpi) / ac.avg_cpi, 0)
|
||||||
|
AS total_expenses_cpi,
|
||||||
|
s.football,
|
||||||
|
s.basketball,
|
||||||
|
s.other_sports,
|
||||||
|
i.unallocated_expenses,
|
||||||
|
i.student_aid,
|
||||||
|
i.participants_men + i.participants_women AS participants,
|
||||||
|
i.ft_undergrad_enrollment,
|
||||||
|
ROUND(i.total_expenses * 1.0 / NULLIF(i.ft_undergrad_enrollment, 0), 0)
|
||||||
|
AS per_student,
|
||||||
|
ROUND(i.total_expenses * (SELECT avg_cpi FROM latest_cpi) / ac.avg_cpi
|
||||||
|
/ NULLIF(i.ft_undergrad_enrollment, 0), 0) AS per_student_cpi
|
||||||
|
FROM raw_eada_institution i
|
||||||
|
LEFT JOIN sports s ON s.year = i.year
|
||||||
|
LEFT JOIN annual_cpi ac ON ac.year = i.year
|
||||||
|
WHERE i.unitid = ?
|
||||||
|
ORDER BY i.year
|
||||||
|
""", [UD_UNITID, UD_UNITID]).pl()
|
||||||
|
|
||||||
|
|
||||||
|
def query_athletics_vs_university(
|
||||||
|
conn: duckdb.DuckDBPyConnection, since: int | None = None
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""Athletics spending against university-wide IPEDS spending.
|
||||||
|
|
||||||
|
Restricted to years present in both EADA and IPEDS (and >= since, if
|
||||||
|
given), indexed to the first such year = 100.
|
||||||
|
"""
|
||||||
|
return conn.execute("""
|
||||||
|
WITH annual_cpi AS (
|
||||||
|
SELECT year, AVG(value) AS avg_cpi FROM raw_cpi_u GROUP BY year
|
||||||
|
),
|
||||||
|
joined AS (
|
||||||
|
SELECT a.year,
|
||||||
|
a.total_expenses AS athletics,
|
||||||
|
f.total_expenses AS university,
|
||||||
|
f.instruction_expenses AS instruction,
|
||||||
|
f.institutional_support_expenses AS inst_support,
|
||||||
|
ac.avg_cpi
|
||||||
|
FROM raw_eada_institution a
|
||||||
|
JOIN raw_ipeds_finance f ON f.unitid = a.unitid AND f.year = a.year
|
||||||
|
LEFT JOIN annual_cpi ac ON ac.year = a.year
|
||||||
|
WHERE a.unitid = ? AND a.year >= ?
|
||||||
|
),
|
||||||
|
base AS (SELECT * FROM joined ORDER BY year LIMIT 1)
|
||||||
|
SELECT j.year, j.athletics, j.university,
|
||||||
|
ROUND(j.athletics * 100.0 / NULLIF(j.university, 0), 2) AS athletics_pct,
|
||||||
|
ROUND(j.athletics * 100.0 / NULLIF(b.athletics, 0), 1) AS athletics_index,
|
||||||
|
ROUND(j.university * 100.0 / NULLIF(b.university, 0), 1) AS university_index,
|
||||||
|
ROUND(j.instruction * 100.0 / NULLIF(b.instruction, 0), 1) AS instruction_index,
|
||||||
|
ROUND(j.inst_support * 100.0 / NULLIF(b.inst_support, 0), 1) AS inst_support_index,
|
||||||
|
ROUND(j.avg_cpi * 100.0 / NULLIF(b.avg_cpi, 0), 1) AS cpi_index
|
||||||
|
FROM joined j CROSS JOIN base b
|
||||||
|
ORDER BY j.year
|
||||||
|
""", [UD_UNITID, since or 0]).pl()
|
||||||
|
|
||||||
|
|
||||||
|
_ATHLETICS_ROLES = [
|
||||||
|
("Head Football Coach", r"FOOTBALL"),
|
||||||
|
("Head Basketball Coach", r"BASKETBALL"),
|
||||||
|
# Titles abbreviate and misspell freely: "DIR, INTERCOLLEGIATE ATHLCS",
|
||||||
|
# "DIRECTOR, INTERCOLLEGIATE ATHELTICS", "ATHLETIC DIRECTOR".
|
||||||
|
("Athletic Director", r"ATHLETIC DIRECTOR|INTERCOLLEGIATE ATH"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def query_athletics_comp(conn: duckdb.DuckDBPyConnection) -> pl.DataFrame:
|
||||||
|
"""Schedule J compensation for head coaches and the athletic director.
|
||||||
|
|
||||||
|
When a role changed hands mid-year (e.g. a departing coach and a new one),
|
||||||
|
the highest-paid person in that role is kept for the year.
|
||||||
|
"""
|
||||||
|
df = conn.execute("""
|
||||||
|
SELECT tax_year, person_name, title, total_compensation
|
||||||
|
FROM raw_990_schedule_j
|
||||||
|
WHERE regexp_matches(upper(title), 'COACH|ATHL|ATHEL')
|
||||||
|
AND NOT regexp_matches(upper(title), 'FORMER|ASSISTANT|ASSOCIATE')
|
||||||
|
""").pl()
|
||||||
|
if df.height == 0:
|
||||||
|
return pl.DataFrame(schema={
|
||||||
|
"tax_year": pl.Int32, "role": pl.Utf8,
|
||||||
|
"person_name": pl.Utf8, "total_compensation": pl.Int64,
|
||||||
|
})
|
||||||
|
role_expr = pl.lit(None, dtype=pl.Utf8)
|
||||||
|
for role, pattern in reversed(_ATHLETICS_ROLES):
|
||||||
|
role_expr = (
|
||||||
|
pl.when(pl.col("title").str.to_uppercase().str.contains(pattern))
|
||||||
|
.then(pl.lit(role)).otherwise(role_expr)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
df.with_columns(role_expr.alias("role"))
|
||||||
|
.filter(pl.col("role").is_not_null())
|
||||||
|
.sort("total_compensation", descending=True)
|
||||||
|
.unique(subset=["tax_year", "role"], keep="first")
|
||||||
|
.select("tax_year", "role", "person_name", "total_compensation")
|
||||||
|
.sort(["role", "tax_year"])
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,39 @@ TABLES = {
|
||||||
PRIMARY KEY (unitid, year)
|
PRIMARY KEY (unitid, year)
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
|
"raw_eada_institution": """
|
||||||
|
CREATE TABLE IF NOT EXISTS raw_eada_institution (
|
||||||
|
unitid INTEGER NOT NULL,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
classification VARCHAR,
|
||||||
|
total_revenue BIGINT,
|
||||||
|
total_expenses BIGINT,
|
||||||
|
unallocated_revenue BIGINT,
|
||||||
|
unallocated_expenses BIGINT,
|
||||||
|
student_aid BIGINT,
|
||||||
|
recruiting_expenses BIGINT,
|
||||||
|
head_coach_salary_men BIGINT,
|
||||||
|
head_coach_salary_women BIGINT,
|
||||||
|
asst_coach_salary_men BIGINT,
|
||||||
|
asst_coach_salary_women BIGINT,
|
||||||
|
participants_men INTEGER,
|
||||||
|
participants_women INTEGER,
|
||||||
|
ft_undergrad_enrollment INTEGER,
|
||||||
|
PRIMARY KEY (unitid, year)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"raw_eada_sport": """
|
||||||
|
CREATE TABLE IF NOT EXISTS raw_eada_sport (
|
||||||
|
unitid INTEGER NOT NULL,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
sport VARCHAR NOT NULL,
|
||||||
|
participants_men INTEGER,
|
||||||
|
participants_women INTEGER,
|
||||||
|
total_revenue BIGINT,
|
||||||
|
total_expenses BIGINT,
|
||||||
|
PRIMARY KEY (unitid, year, sport)
|
||||||
|
)
|
||||||
|
""",
|
||||||
"raw_cpi_u": """
|
"raw_cpi_u": """
|
||||||
CREATE TABLE IF NOT EXISTS raw_cpi_u (
|
CREATE TABLE IF NOT EXISTS raw_cpi_u (
|
||||||
year INTEGER NOT NULL,
|
year INTEGER NOT NULL,
|
||||||
|
|
|
||||||
0
src/admin_analytics/eada/__init__.py
Normal file
0
src/admin_analytics/eada/__init__.py
Normal file
57
src/admin_analytics/eada/download.py
Normal file
57
src/admin_analytics/eada/download.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Download EADA athletics data files from the Department of Education."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from admin_analytics import config
|
||||||
|
|
||||||
|
# Only the spreadsheets are needed; the zips also carry SAS/SPSS/Word copies.
|
||||||
|
_SPREADSHEET_SUFFIXES = (".xls", ".xlsx")
|
||||||
|
|
||||||
|
|
||||||
|
def list_data_files(client: httpx.Client) -> dict[int, str]:
|
||||||
|
"""Return {year: filename} for the per-year EADA data zips.
|
||||||
|
|
||||||
|
The API also lists "All_Data_Combined" zips with one record per
|
||||||
|
institution; those duplicate the per-year institution file and are skipped.
|
||||||
|
"""
|
||||||
|
resp = client.get(f"{config.EADA_API_URL}/fileList")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return {
|
||||||
|
int(entry["Year"]): entry["FileName"]
|
||||||
|
for entry in resp.json()
|
||||||
|
if "Combined" not in entry["FileName"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dest_dir(year: int) -> Path:
|
||||||
|
return config.EADA_DATA_DIR / str(year)
|
||||||
|
|
||||||
|
|
||||||
|
def download_year(client: httpx.Client, year: int, filename: str, force: bool = False) -> Path:
|
||||||
|
"""Download and extract the spreadsheets for one EADA year."""
|
||||||
|
dest = _dest_dir(year)
|
||||||
|
if not force and dest.exists() and any(dest.glob("*.xls*")):
|
||||||
|
return dest
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
resp = client.get(f"{config.EADA_API_URL}/file", params={"fileName": filename})
|
||||||
|
resp.raise_for_status()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.lower().endswith(_SPREADSHEET_SUFFIXES):
|
||||||
|
zf.extract(name, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def download_all(year_range: range, force: bool = False) -> None:
|
||||||
|
"""Download all available EADA years in the range."""
|
||||||
|
with httpx.Client(follow_redirects=True, timeout=600.0) as client:
|
||||||
|
available = list_data_files(client)
|
||||||
|
for year in year_range:
|
||||||
|
if year not in available:
|
||||||
|
print(f" Warning: EADA {year} not available")
|
||||||
|
continue
|
||||||
|
download_year(client, year, available[year], force=force)
|
||||||
133
src/admin_analytics/eada/loader.py
Normal file
133
src/admin_analytics/eada/loader.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""Load EADA athletics spreadsheets into raw_eada_* tables.
|
||||||
|
|
||||||
|
Each EADA year ships two spreadsheets: an institution-level file (one row per
|
||||||
|
school) and a schools file (one row per sport per school). Filenames and
|
||||||
|
column-name casing vary across years, so both are resolved loosely.
|
||||||
|
|
||||||
|
Note that EADA revenue equals expenses for most public institutions: the
|
||||||
|
institutional subsidy is reported as revenue to balance the books, so EADA
|
||||||
|
measures athletics *cost* but does not separate out the subsidy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
import pandas as pd
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from admin_analytics import config
|
||||||
|
from admin_analytics.config import UD_UNITID
|
||||||
|
|
||||||
|
# Canonical column -> candidate EADA columns, first match wins (case-insensitive).
|
||||||
|
INSTITUTION_VARIANTS = {
|
||||||
|
"unitid": ["UNITID"],
|
||||||
|
"classification": ["CLASSIFICATION_NAME", "VALUE_NAME"],
|
||||||
|
"total_revenue": ["GRND_TOTAL_REVENUE"],
|
||||||
|
"total_expenses": ["GRND_TOTAL_EXPENSE"],
|
||||||
|
"unallocated_revenue": ["TOT_REVENUE_ALL_NOTALLOC"],
|
||||||
|
"unallocated_expenses": ["TOT_EXPENSE_ALL_NOTALLOC"],
|
||||||
|
"student_aid": ["STUDENTAID_TOTAL"],
|
||||||
|
"recruiting_expenses": ["RECRUITEXP_TOTAL"],
|
||||||
|
"head_coach_salary_men": ["HDCOACH_SALARY_MEN"],
|
||||||
|
"head_coach_salary_women": ["HDCOACH_SALARY_WOMEN"],
|
||||||
|
"asst_coach_salary_men": ["ASCOACH_SALARY_MEN"],
|
||||||
|
"asst_coach_salary_women": ["ASCOACH_SALARY_WOMEN"],
|
||||||
|
"participants_men": ["UNDUP_CT_PARTIC_MEN", "IL_SUM_PARTIC_MEN"],
|
||||||
|
"participants_women": ["UNDUP_CT_PARTIC_WOMEN", "IL_SUM_PARTIC_WOMEN"],
|
||||||
|
"ft_undergrad_enrollment": ["EFTOTALCOUNT"],
|
||||||
|
}
|
||||||
|
|
||||||
|
SPORT_VARIANTS = {
|
||||||
|
"unitid": ["UNITID"],
|
||||||
|
"sport": ["SPORTS"],
|
||||||
|
"participants_men": ["SUM_PARTIC_MEN", "PARTIC_MEN"],
|
||||||
|
"participants_women": ["SUM_PARTIC_WOMEN", "PARTIC_WOMEN"],
|
||||||
|
"total_revenue": ["TOTAL_REVENUE_ALL"],
|
||||||
|
"total_expenses": ["TOTAL_EXPENSE_ALL"],
|
||||||
|
}
|
||||||
|
|
||||||
|
INSTITUTION_COLUMNS = ["unitid", "year"] + [c for c in INSTITUTION_VARIANTS if c != "unitid"]
|
||||||
|
SPORT_COLUMNS = ["unitid", "year"] + [c for c in SPORT_VARIANTS if c != "unitid"]
|
||||||
|
_TEXT_COLUMNS = {"classification", "sport"}
|
||||||
|
_INT32_COLUMNS = {"participants_men", "participants_women", "ft_undergrad_enrollment"}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_file(year_dir: Path, kind: str) -> Path | None:
|
||||||
|
"""Find the institution ("inst") or per-sport ("school") spreadsheet."""
|
||||||
|
for path in sorted(year_dir.glob("*.xls*")):
|
||||||
|
if kind in path.name.lower():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path, variants: dict, unitid_filter: int | None) -> pl.DataFrame | None:
|
||||||
|
df = pd.read_excel(path)
|
||||||
|
upper_cols = {str(c).strip().upper(): c for c in df.columns}
|
||||||
|
col_map = {}
|
||||||
|
for canonical, candidates in variants.items():
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in upper_cols:
|
||||||
|
col_map[canonical] = upper_cols[candidate]
|
||||||
|
break
|
||||||
|
if "unitid" not in col_map:
|
||||||
|
return None
|
||||||
|
|
||||||
|
df = df[list(col_map.values())].rename(columns={v: k for k, v in col_map.items()})
|
||||||
|
if unitid_filter is not None:
|
||||||
|
df = df[df["unitid"] == unitid_filter]
|
||||||
|
return pl.from_pandas(df)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(df: pl.DataFrame, year: int, columns: list[str]) -> pl.DataFrame:
|
||||||
|
df = df.with_columns(pl.lit(year).alias("year"))
|
||||||
|
for col in columns:
|
||||||
|
if col not in df.columns:
|
||||||
|
df = df.with_columns(pl.lit(None).alias(col))
|
||||||
|
if col in _TEXT_COLUMNS:
|
||||||
|
df = df.with_columns(pl.col(col).cast(pl.Utf8).str.strip_chars())
|
||||||
|
elif col in _INT32_COLUMNS or col in ("unitid", "year"):
|
||||||
|
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False).cast(pl.Int32, strict=False))
|
||||||
|
else:
|
||||||
|
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False).round(0).cast(pl.Int64, strict=False))
|
||||||
|
return df.select(columns)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(conn: duckdb.DuckDBPyConnection, table: str, df: pl.DataFrame) -> int:
|
||||||
|
if df.height == 0:
|
||||||
|
return 0
|
||||||
|
conn.register("_tmp_eada", df.to_arrow())
|
||||||
|
conn.execute(f"INSERT INTO {table} SELECT * FROM _tmp_eada")
|
||||||
|
conn.unregister("_tmp_eada")
|
||||||
|
return df.height
|
||||||
|
|
||||||
|
|
||||||
|
def load_eada(
|
||||||
|
conn: duckdb.DuckDBPyConnection,
|
||||||
|
year_range: range,
|
||||||
|
unitid_filter: int | None = UD_UNITID,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Load EADA institution and per-sport data for each year in the range."""
|
||||||
|
totals = {"institution": 0, "sport": 0}
|
||||||
|
for year in year_range:
|
||||||
|
year_dir = config.EADA_DATA_DIR / str(year)
|
||||||
|
conn.execute("DELETE FROM raw_eada_institution WHERE year = ?", [year])
|
||||||
|
conn.execute("DELETE FROM raw_eada_sport WHERE year = ?", [year])
|
||||||
|
|
||||||
|
inst_path = _find_file(year_dir, "inst")
|
||||||
|
if inst_path is None:
|
||||||
|
print(f" No EADA institution file for {year}, skipping")
|
||||||
|
continue
|
||||||
|
inst = _read(inst_path, INSTITUTION_VARIANTS, unitid_filter)
|
||||||
|
if inst is not None:
|
||||||
|
totals["institution"] += _insert(
|
||||||
|
conn, "raw_eada_institution", _normalize(inst, year, INSTITUTION_COLUMNS)
|
||||||
|
)
|
||||||
|
|
||||||
|
sport_path = _find_file(year_dir, "school")
|
||||||
|
if sport_path is not None:
|
||||||
|
sports = _read(sport_path, SPORT_VARIANTS, unitid_filter)
|
||||||
|
if sports is not None:
|
||||||
|
sports = _normalize(sports, year, SPORT_COLUMNS).filter(pl.col("sport").is_not_null())
|
||||||
|
totals["sport"] += _insert(conn, "raw_eada_sport", sports)
|
||||||
|
|
||||||
|
return totals
|
||||||
172
tests/test_eada.py
Normal file
172
tests/test_eada.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""Tests for EADA athletics download, loader, and dashboard queries."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pandas as pd
|
||||||
|
import respx
|
||||||
|
|
||||||
|
import admin_analytics.config as config
|
||||||
|
from admin_analytics.config import UD_UNITID
|
||||||
|
from admin_analytics.dashboard.queries import (
|
||||||
|
query_athletics_comp,
|
||||||
|
query_athletics_expenses,
|
||||||
|
query_athletics_vs_university,
|
||||||
|
)
|
||||||
|
from admin_analytics.eada.download import download_all
|
||||||
|
from admin_analytics.eada.loader import load_eada
|
||||||
|
|
||||||
|
OTHER_UNITID = 999999
|
||||||
|
|
||||||
|
|
||||||
|
def _write_year(year_dir, inst_name="instLevel.xlsx", school_name="Schools.xlsx", lower=False):
|
||||||
|
"""Write minimal EADA institution and per-sport spreadsheets."""
|
||||||
|
year_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
inst = pd.DataFrame({
|
||||||
|
"unitid": [UD_UNITID, OTHER_UNITID],
|
||||||
|
"classification_name": ["NCAA Division I-FCS", "NCAA Division II"],
|
||||||
|
"EFTotalCount": [18000, 5000],
|
||||||
|
"STUDENTAID_TOTAL": [15_000_000, 1_000_000],
|
||||||
|
"HDCOACH_SALARY_MEN": [240_000.0, 80_000.0],
|
||||||
|
"UNDUP_CT_PARTIC_MEN": [300, 100],
|
||||||
|
"UNDUP_CT_PARTIC_WOMEN": [320, 110],
|
||||||
|
"TOT_EXPENSE_ALL_NOTALLOC": [14_000_000, 2_000_000],
|
||||||
|
"GRND_TOTAL_REVENUE": [48_000_000, 9_000_000],
|
||||||
|
"GRND_TOTAL_EXPENSE": [48_000_000, 9_000_000],
|
||||||
|
})
|
||||||
|
if lower:
|
||||||
|
inst.columns = [c.lower() for c in inst.columns]
|
||||||
|
inst.to_excel(year_dir / inst_name, index=False)
|
||||||
|
pd.DataFrame({
|
||||||
|
"unitid": [UD_UNITID, UD_UNITID, UD_UNITID, OTHER_UNITID],
|
||||||
|
"SUM_PARTIC_MEN": [110, 15, 30, 50],
|
||||||
|
"SUM_PARTIC_WOMEN": [None, 16, 28, 40],
|
||||||
|
"TOTAL_REVENUE_ALL": [9_000_000, 6_000_000, 2_000_000, 1_000_000],
|
||||||
|
"TOTAL_EXPENSE_ALL": [9_000_000, 6_000_000, 2_000_000, 1_000_000],
|
||||||
|
"Sports": ["Football", "Basketball", "Soccer", "Football"],
|
||||||
|
}).to_excel(year_dir / school_name, index=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoader:
|
||||||
|
def test_loads_ud_only(self, db_conn, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||||
|
_write_year(tmp_path / "2024")
|
||||||
|
totals = load_eada(db_conn, range(2024, 2025))
|
||||||
|
assert totals == {"institution": 1, "sport": 3}
|
||||||
|
row = db_conn.execute(
|
||||||
|
"SELECT total_expenses, unallocated_expenses, participants_men, "
|
||||||
|
"ft_undergrad_enrollment, classification FROM raw_eada_institution"
|
||||||
|
).fetchone()
|
||||||
|
assert row == (48_000_000, 14_000_000, 300, 18000, "NCAA Division I-FCS")
|
||||||
|
|
||||||
|
def test_resolves_column_and_filename_variants(self, db_conn, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||||
|
_write_year(tmp_path / "2010", inst_name="instLevelData.xlsx",
|
||||||
|
school_name="schools.xlsx", lower=True)
|
||||||
|
totals = load_eada(db_conn, range(2010, 2011))
|
||||||
|
assert totals["institution"] == 1
|
||||||
|
assert db_conn.execute(
|
||||||
|
"SELECT total_expenses FROM raw_eada_institution WHERE year = 2010"
|
||||||
|
).fetchone()[0] == 48_000_000
|
||||||
|
|
||||||
|
def test_reload_replaces_year(self, db_conn, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||||
|
_write_year(tmp_path / "2024")
|
||||||
|
load_eada(db_conn, range(2024, 2025))
|
||||||
|
load_eada(db_conn, range(2024, 2025))
|
||||||
|
assert db_conn.execute("SELECT COUNT(*) FROM raw_eada_sport").fetchone()[0] == 3
|
||||||
|
|
||||||
|
def test_missing_year_skipped(self, db_conn, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||||
|
assert load_eada(db_conn, range(2024, 2025)) == {"institution": 0, "sport": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownload:
|
||||||
|
@respx.mock
|
||||||
|
def test_downloads_spreadsheets_only(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||||
|
respx.get(f"{config.EADA_API_URL}/fileList").mock(return_value=httpx.Response(200, json=[
|
||||||
|
{"Year": 2024, "FileName": "EADA_2023-2024.zip"},
|
||||||
|
{"Year": 2024, "FileName": "EADA_All_Data_Combined_2023-2024_SAS_SPSS_EXCEL.zip"},
|
||||||
|
]))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
zf.writestr("instLevel.xlsx", b"x")
|
||||||
|
zf.writestr("schools.sav", b"x")
|
||||||
|
route = respx.get(f"{config.EADA_API_URL}/file").mock(
|
||||||
|
return_value=httpx.Response(200, content=buf.getvalue())
|
||||||
|
)
|
||||||
|
download_all(range(2024, 2025))
|
||||||
|
assert route.calls.last.request.url.params["fileName"] == "EADA_2023-2024.zip"
|
||||||
|
assert sorted(p.name for p in (tmp_path / "2024").iterdir()) == ["instLevel.xlsx"]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(conn):
|
||||||
|
for year, total, unalloc in [(2023, 40_000_000, 10_000_000), (2024, 50_000_000, 20_000_000)]:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO raw_eada_institution (unitid, year, total_expenses, unallocated_expenses, "
|
||||||
|
"participants_men, participants_women, ft_undergrad_enrollment) VALUES (?, ?, ?, ?, 300, 300, 20000)",
|
||||||
|
[UD_UNITID, year, total, unalloc],
|
||||||
|
)
|
||||||
|
for sport, amount in [("Football", 10_000_000), ("Basketball", 5_000_000), ("Soccer", 3_000_000)]:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO raw_eada_sport (unitid, year, sport, total_expenses) VALUES (?, ?, ?, ?)",
|
||||||
|
[UD_UNITID, year, sport, amount],
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO raw_ipeds_finance (unitid, year, total_expenses, instruction_expenses, "
|
||||||
|
"institutional_support_expenses) VALUES (?, 2023, 1000000000, 400000000, 100000000)",
|
||||||
|
[UD_UNITID],
|
||||||
|
)
|
||||||
|
for year, value in [(2023, 300.0), (2024, 310.0)]:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO raw_cpi_u (year, month, value, series_id) VALUES (?, 1, ?, 'CUUR0000SA0')",
|
||||||
|
[year, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueries:
|
||||||
|
def test_expenses_split_and_per_student(self, db_conn):
|
||||||
|
_seed(db_conn)
|
||||||
|
df = query_athletics_expenses(db_conn)
|
||||||
|
assert df["year"].to_list() == [2023, 2024]
|
||||||
|
latest = df.row(1, named=True)
|
||||||
|
assert latest["football"] == 10_000_000
|
||||||
|
assert latest["other_sports"] == 3_000_000
|
||||||
|
assert latest["per_student"] == 2500
|
||||||
|
# Latest year is the CPI base, so adjusted equals nominal
|
||||||
|
assert latest["total_expenses_cpi"] == 50_000_000
|
||||||
|
|
||||||
|
def test_vs_university_only_overlapping_years(self, db_conn):
|
||||||
|
_seed(db_conn)
|
||||||
|
df = query_athletics_vs_university(db_conn)
|
||||||
|
assert df["year"].to_list() == [2023]
|
||||||
|
assert df["athletics_pct"][0] == 4.0
|
||||||
|
assert df["athletics_index"][0] == 100.0
|
||||||
|
assert query_athletics_vs_university(db_conn, since=2024).height == 0
|
||||||
|
|
||||||
|
def test_comp_matches_abbreviated_titles(self, db_conn):
|
||||||
|
for year, name, title, comp in [
|
||||||
|
(2022, "A", "DIR, INTERCOLLEGIATE ATHLC & REC SR", 700_000),
|
||||||
|
(2023, "A", "DIRECTOR, INTERCOLLEGIATE ATHELTICS & RECREATION", 710_000),
|
||||||
|
(2023, "B", "HEAD FOOTBALL COACH", 680_000),
|
||||||
|
(2023, "C", "HEAD FOOTBALL COACH TO 1/1/23", 500_000),
|
||||||
|
(2023, "D", "FORMER ATHLETIC DIRECTOR", 400_000),
|
||||||
|
(2023, "E", "PROVOST", 800_000),
|
||||||
|
]:
|
||||||
|
db_conn.execute(
|
||||||
|
"INSERT INTO raw_990_schedule_j (tax_year, person_name, title, total_compensation) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
[year, name, title, comp],
|
||||||
|
)
|
||||||
|
df = query_athletics_comp(db_conn)
|
||||||
|
assert set(zip(df["tax_year"], df["role"], df["person_name"])) == {
|
||||||
|
(2022, "Athletic Director", "A"),
|
||||||
|
(2023, "Athletic Director", "A"),
|
||||||
|
(2023, "Head Football Coach", "B"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_empty(self, db_conn):
|
||||||
|
assert query_athletics_expenses(db_conn).height == 0
|
||||||
|
assert query_athletics_comp(db_conn).height == 0
|
||||||
Loading…
Add table
Add a link
Reference in a new issue