{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "cae65a79",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Loaded 635,281 rows across 57 rides\n",
      "Time range: 2024-05-15 12:23:34.954315-04:00 → 2026-05-26 16:35:16.363800-04:00\n"
     ]
    }
   ],
   "source": [
    "import os\n",
    "from pathlib import Path\n",
    "\n",
    "import pandas as pd\n",
    "from dotenv import load_dotenv\n",
    "from sqlalchemy import create_engine, text\n",
    "\n",
    "load_dotenv(Path.cwd() / \".env\")\n",
    "\n",
    "engine = create_engine(\n",
    "    \"postgresql+psycopg2://{user}:{pw}@{host}:{port}/{db}\".format(\n",
    "        user=os.environ[\"PG_USER\"],\n",
    "        pw=os.environ[\"PG_PASSWORD\"],\n",
    "        host=os.environ[\"PG_HOST\"],\n",
    "        port=os.environ[\"PG_PORT\"],\n",
    "        db=os.environ[\"PG_DB\"],\n",
    "    )\n",
    ")\n",
    "\n",
    "# LZ_attractions_io carries one row per (timestamp, ride) and uses the\n",
    "# Item _id that matches both ride_master and LZ_attractions_io_poi.\n",
    "# (LZ_attractions_io_queuetimes is a same-data duplicate keyed by QueueLine\n",
    "# id, which doesn't join to anything — don't use it.)\n",
    "QUERY = text(\n",
    "    \"\"\"\n",
    "    SELECT a.time_stamp,\n",
    "           a._id            AS ride_id,\n",
    "           r.ride           AS ride_name,\n",
    "           r.capacity,\n",
    "           r.duration,\n",
    "           a.queue_time     AS queue_time_sec\n",
    "      FROM knoebels.\"LZ_attractions_io\" a\n",
    "      JOIN knoebels.ride_master r\n",
    "        ON r.attractions_dot_io_id = a._id\n",
    "     WHERE a.queue_time IS NOT NULL\n",
    "     ORDER BY a.time_stamp\n",
    "    \"\"\"\n",
    ")\n",
    "\n",
    "df = pd.read_sql(QUERY, engine)\n",
    "df[\"queue_time_min\"] = df[\"queue_time_sec\"] / 60.0\n",
    "\n",
    "# Scraper writes naive timestamps that represent America/New_York wall clock.\n",
    "# Localize so downstream time-of-day / day-of-week features are unambiguous.\n",
    "df[\"time_stamp\"] = (\n",
    "    pd.to_datetime(df[\"time_stamp\"])\n",
    "    .dt.tz_localize(\"America/New_York\", ambiguous=\"NaT\", nonexistent=\"NaT\")\n",
    ")\n",
    "df = df.dropna(subset=[\"time_stamp\"]).reset_index(drop=True)\n",
    "\n",
    "\n",
    "def get_ride_time_matrix(df: pd.DataFrame, freq: str = \"5min\") -> pd.DataFrame:\n",
    "    \"\"\"Pivot the long df into a (timestamp x ride_name) matrix of queue times.\n",
    "\n",
    "    Rows are snapped to `freq` bins so all rides share a common index.\n",
    "    Values are mean queue time in minutes within each bin.\n",
    "    \"\"\"\n",
    "    snapped = df.assign(ts=df[\"time_stamp\"].dt.floor(freq))\n",
    "    return (\n",
    "        snapped.pivot_table(\n",
    "            index=\"ts\",\n",
    "            columns=\"ride_name\",\n",
    "            values=\"queue_time_min\",\n",
    "            aggfunc=\"mean\",\n",
    "        )\n",
    "        .sort_index().ffill().fillna(0) # Impute missing values: forward fill first (assume line stayed the same), then fill remaining NaNs (like morning startup) with 0\n",
    "    )\n",
    "\n",
    "\n",
    "print(f\"Loaded {len(df):,} rows across {df['ride_name'].nunique()} rides\")\n",
    "print(f\"Time range: {df['time_stamp'].min()} → {df['time_stamp'].max()}\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "261c73fd",
   "metadata": {},
   "outputs": [],
   "source": "'''\nif we were to run SVD on raw minutes, the algorithm will just chase the rides\nwith the biggest raw numbers.\n\nFrist, need to standardize the data so every ride has a mean of 0 and a variance of 1.\nscikit-learn makes this trivial\n'''\nfrom sklearn.preprocessing import StandardScaler\n\n# Rides to exclude from the PCA:\n#   powersurge — torn down years ago, ~90% of its rows are imputed zeros and\n#   its loadings are pure artifact.\nEXCLUDED_RIDES = [\"powersurge\"]\n\ndf_matrix = get_ride_time_matrix(df).drop(columns=EXCLUDED_RIDES, errors=\"ignore\")\n\n# Standardize the columns (rides)\nscaler = StandardScaler()\nscaled_matrix = scaler.fit_transform(df_matrix)\n\npd.DataFrame(scaled_matrix, index=df_matrix.index, columns=df_matrix.columns).head()\n"
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "ac1f62a3",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Variance explained by PC1 (Park Mood): 40.0%\n",
      "Variance explained by PC2: 12.9%\n",
      "Variance explained by PC3: 7.0%\n"
     ]
    }
   ],
   "source": [
    "from sklearn.decomposition import PCA\n",
    "\n",
    "# Let's extract the top 3 components (\"Park Mood\", \"Capacity\", etc.)\n",
    "pca = PCA(n_components=3)\n",
    "principal_components = pca.fit_transform(scaled_matrix)\n",
    "\n",
    "# Look at how much variance (signal) each component explains\n",
    "print(f\"Variance explained by PC1 (Park Mood): {pca.explained_variance_ratio_[0]:.1%}\")\n",
    "print(f\"Variance explained by PC2: {pca.explained_variance_ratio_[1]:.1%}\")\n",
    "print(f\"Variance explained by PC3: {pca.explained_variance_ratio_[2]:.1%}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "4663779d",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "=== PC1 — top 8 rides by |loading| ===\n",
      "ride_name\n",
      "stratosfear        0.179701\n",
      "merry_mixer        0.173788\n",
      "flyer              0.168306\n",
      "s&g_carousel       0.168079\n",
      "tornado            0.166412\n",
      "kiddie_whip        0.166132\n",
      "spanish_bambini    0.165741\n",
      "red_baron          0.165697\n",
      "\n",
      "=== PC2 — top 8 rides by |loading| ===\n",
      "ride_name\n",
      "bumper_cars        0.230985\n",
      "giant_wheel        0.209181\n",
      "kiddie_boats      -0.196950\n",
      "pete's_fleet      -0.195781\n",
      "spanish_bambini   -0.195189\n",
      "s&g_carousel      -0.195019\n",
      "red_baron         -0.190284\n",
      "helicopters       -0.189507\n",
      "\n",
      "=== PC3 — top 8 rides by |loading| ===\n",
      "ride_name\n",
      "paratrooper           0.335514\n",
      "kiddie_bumper_cars   -0.327763\n",
      "motor_boats          -0.244509\n",
      "powersurge            0.232955\n",
      "tumbling_timbers     -0.207141\n",
      "roto_jets             0.199833\n",
      "stratosfear           0.184504\n",
      "tornado               0.180223\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "array([<Axes: title={'center': 'PC1'}, xlabel='ts'>,\n",
       "       <Axes: title={'center': 'PC2'}, xlabel='ts'>,\n",
       "       <Axes: title={'center': 'PC3'}, xlabel='ts'>], dtype=object)"
      ]
     },
     "execution_count": 14,
     "metadata": {},
     "output_type": "execute_result"
    },
    {
     "data": {
      "image/png": "[encoded data, 73444 characters]",
      "text/plain": [
       "<Figure size 1200x600 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Interpret each PC by looking at (1) which rides load onto it and (2) when its score is high/low.\n",
    "\n",
    "loadings = pd.DataFrame(\n",
    "    pca.components_.T,\n",
    "    index=df_matrix.columns,\n",
    "    columns=[\"PC1\", \"PC2\", \"PC3\"],\n",
    ")\n",
    "\n",
    "scores = pd.DataFrame(\n",
    "    principal_components,\n",
    "    index=df_matrix.index,\n",
    "    columns=[\"PC1\", \"PC2\", \"PC3\"],\n",
    ")\n",
    "\n",
    "# Top-magnitude rides per PC — these are the ones \"shaping\" the axis.\n",
    "for pc in loadings.columns:\n",
    "    print(f\"\\n=== {pc} — top 8 rides by |loading| ===\")\n",
    "    top = loadings[pc].reindex(loadings[pc].abs().sort_values(ascending=False).index).head(8)\n",
    "    print(top.to_string())\n",
    "\n",
    "# Daily-mean PC scores over time — reveals seasonality each PC captures.\n",
    "scores.resample(\"D\").mean().plot(subplots=True, figsize=(12, 6), title=[\"PC1\", \"PC2\", \"PC3\"])\n"
   ]
  },
  {
   "cell_type": "code",
   "id": "95277639",
   "source": "# Join ride metadata (area + category) to the loadings so the labels write themselves.\n# NOTE: attractions.io category names carry a trailing space (\"Kiddie \", \"Family \",\n# \"Thrill \"), so trim() here — otherwise downstream == / .isin() / dict lookups on\n# the bare names silently miss (e.g. the PC2×PC3 scatter colors all fall through).\n\nmetadata = pd.read_sql(\n    text(\n        \"\"\"\n        SELECT r.ride               AS ride_name,\n               trim(c.name)         AS category,\n               p.parent             AS area\n          FROM knoebels.ride_master r\n          JOIN knoebels.\"LZ_attractions_io_poi\" p\n            ON p._id = r.attractions_dot_io_id\n          LEFT JOIN knoebels.\"LZ_attractions_io_categories\" c\n            ON c._id = p.category\n        \"\"\"\n    ),\n    engine,\n).set_index(\"ride_name\")\n\nannotated = loadings.join(metadata)\n\nprint(\"=== Mean loading by area ===\")\nprint(annotated.groupby(\"area\")[[\"PC1\", \"PC2\", \"PC3\"]].mean().round(3))\n\nprint(\"\\n=== Mean loading by category ===\")\nprint(annotated.groupby(\"category\")[[\"PC1\", \"PC2\", \"PC3\"]].mean().round(3))\n",
   "metadata": {},
   "execution_count": null,
   "outputs": []
  },
  {
   "cell_type": "code",
   "id": "94e2c1be",
   "source": "# Validate PC interpretations from the time side.\n# PC1 (busyness) should peak weekend afternoons.\n# PC2 (Kiddieland vs main midway) should fade in the evening as the kids leave.\n# PC3 (daypart) should run positive midday — school/daycare group rates flood\n# the daytime family rides — and go negative after dark, when the evening\n# coaster/bumper-car/Ferris-wheel crowd takes over.\nimport matplotlib.pyplot as plt\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 4))\n\nscores.groupby(scores.index.hour).mean().plot(ax=axes[0])\naxes[0].set_title(\"Mean PC score by hour of day\")\naxes[0].set_xlabel(\"Hour (local)\")\naxes[0].axhline(0, color=\"grey\", linewidth=0.5)\n\nscores.groupby(scores.index.dayofweek).mean().plot(ax=axes[1])\naxes[1].set_title(\"Mean PC score by day of week (0=Mon, 6=Sun)\")\naxes[1].set_xlabel(\"Day of week\")\naxes[1].axhline(0, color=\"grey\", linewidth=0.5)\n\nplt.tight_layout()\n",
   "metadata": {},
   "execution_count": null,
   "outputs": []
  },
  {
   "cell_type": "code",
   "id": "8663040b",
   "source": "# Low-rank reconstruction: how much of any one ride's queue is explained by the top 3 PCs?\n# pca.inverse_transform takes us back to standardized space; scaler.inverse_transform back to minutes.\nreconstructed = pd.DataFrame(\n    scaler.inverse_transform(pca.inverse_transform(principal_components)),\n    index=df_matrix.index,\n    columns=df_matrix.columns,\n)\n\n# Per-ride R^2 of the rank-3 model: 1 - SS_residual / SS_total.\nss_resid = ((df_matrix - reconstructed) ** 2).sum()\nss_total = ((df_matrix - df_matrix.mean()) ** 2).sum()\nride_r2 = (1 - ss_resid / ss_total).rename(\"rank3_R2\")\n\nprint(\"=== Best explained by top 3 PCs (move with the park) ===\")\nprint(ride_r2.sort_values(ascending=False).head(10).round(3).to_string())\nprint(\"\\n=== Worst explained (ride-specific dynamics) ===\")\nprint(ride_r2.sort_values().head(10).round(3).to_string())\n\n# Visualize one well-explained ride vs the reconstruction.\nRIDE = ride_r2.sort_values(ascending=False).index[0]\nfig, ax = plt.subplots(figsize=(14, 4))\ndf_matrix[RIDE].resample(\"D\").mean().plot(ax=ax, label=\"actual\", alpha=0.6)\nreconstructed[RIDE].resample(\"D\").mean().plot(ax=ax, label=\"top-3 PC reconstruction\", alpha=0.9)\nax.set_title(f\"{RIDE}: actual vs 3-PC reconstruction (daily-mean queue, min)\")\nax.set_ylabel(\"Queue (min)\")\nax.legend()\n",
   "metadata": {},
   "execution_count": null,
   "outputs": []
  },
  {
   "cell_type": "code",
   "id": "701ce1d6",
   "source": "# Blog-ready scatter: rides positioned in PC2 x PC3 space, colored by category.\n# PC2 = Kiddieland corner vs main midway (spatial; see GPS-cluster validation)\n# PC3 = evening crowd (−) vs daytime / school-group daypart (+); NOT throughput.\nfig, ax = plt.subplots(figsize=(10, 8))\n\ncategory_colors = {\"Family\": \"tab:blue\", \"Kiddie\": \"tab:orange\", \"Thrill\": \"tab:red\"}\nfor category, group in annotated.dropna(subset=[\"category\"]).groupby(\"category\"):\n    ax.scatter(\n        group[\"PC2\"], group[\"PC3\"],\n        label=category,\n        color=category_colors.get(category, \"grey\"),\n        s=80, alpha=0.7, edgecolor=\"white\",\n    )\n\n# Label only the rides farthest from the origin so the plot stays readable.\ndistance = (annotated[\"PC2\"] ** 2 + annotated[\"PC3\"] ** 2) ** 0.5\nto_label = distance.sort_values(ascending=False).head(15).index\nfor ride in to_label:\n    row = annotated.loc[ride]\n    ax.annotate(ride, (row[\"PC2\"], row[\"PC3\"]), fontsize=8, alpha=0.85,\n                xytext=(4, 2), textcoords=\"offset points\")\n\nax.axhline(0, color=\"grey\", linewidth=0.5)\nax.axvline(0, color=\"grey\", linewidth=0.5)\nax.set_xlabel(\"PC2     Kiddieland corner  ←      →  main midway\")\nax.set_ylabel(\"PC3     evening crowd  ←      →  daytime / school-group daypart\")\nax.set_title(\"Knoebels rides in PC2 × PC3 loading space\")\nax.legend(title=\"Category\")\nplt.tight_layout()\n",
   "metadata": {},
   "execution_count": null,
   "outputs": []
  },
  {
   "cell_type": "code",
   "id": "6c2f885f",
   "source": "# Derive park areas from GPS instead of a missing `parent` column.\n# Each POI has a (lat, lon) point; kiddie rides are physically clustered in\n# Kiddieland. KMeans on coords gives us an area label derived from geometry\n# alone, which we can then cross-check against the PCA loadings.\n#\n# k=5 carves out a tight 19-ride Kiddieland. k=3 was too coarse — it swept\n# main-midway rides near the park entrance (giant_wheel, grand_carousel,\n# bumper_cars) into the kiddie blob. IMPORTANT: lat and lon must be standardized\n# before KMeans — on raw degrees the clustering is seed-unstable (the contrast r\n# wobbles 0.60-0.71 between random_states); standardizing makes it identical\n# across seeds at r=0.78. The kiddie cluster is identified by lowest mean PC2\n# loading, so this stays self-contained (no hand-typed ride list).\nfrom sklearn.cluster import KMeans\n\npoi = pd.read_sql(\n    text(\n        \"\"\"\n        SELECT r.ride                  AS ride_name,\n               (p.location)[0]::float  AS lat,\n               (p.location)[1]::float  AS lon\n          FROM knoebels.ride_master r\n          JOIN knoebels.\"LZ_attractions_io_poi\" p\n            ON p._id = r.attractions_dot_io_id\n         WHERE p.location IS NOT NULL\n        \"\"\"\n    ),\n    engine,\n).set_index(\"ride_name\")\n\nlocated = annotated.join(poi, how=\"inner\")\n\n# KMeans on the geometry only — no PCA information leaks in. Standardize the\n# coordinates first so the clustering is reproducible (see note above).\nN_AREAS = 5\ncoords = StandardScaler().fit_transform(located[[\"lat\", \"lon\"]])\nkmeans = KMeans(n_clusters=N_AREAS, n_init=10, random_state=0)\nlocated[\"area_cluster\"] = kmeans.fit_predict(coords)\n\n# Identify the Kiddieland cluster as the one with the lowest mean PC2 loading,\n# then export ride-name lists for the validation cell to build a contrast from\n# geometry-derived groups (rather than the misfiling `category` tag).\nkiddie_cluster = located.groupby(\"area_cluster\")[\"PC2\"].mean().idxmin()\nkiddie_cluster_rides = located.index[located[\"area_cluster\"] == kiddie_cluster]\nmain_cluster_rides   = located.index[located[\"area_cluster\"] != kiddie_cluster]\n\nfig, axes = plt.subplots(1, 2, figsize=(15, 7))\n\n# Left: physical map colored by PC2 loading. If the kiddie hypothesis is right,\n# one corner of the park should be uniformly blue.\nsc = axes[0].scatter(\n    located[\"lon\"], located[\"lat\"],\n    c=located[\"PC2\"], cmap=\"RdBu_r\", s=140,\n    edgecolor=\"black\", linewidth=0.4,\n)\naxes[0].set_title(\"Rides colored by PC2 loading\\n(blue = kiddie side, red = main park)\")\naxes[0].set_xlabel(\"Longitude\")\naxes[0].set_ylabel(\"Latitude\")\nplt.colorbar(sc, ax=axes[0], label=\"PC2 loading\")\nfor ride, row in located.iterrows():\n    axes[0].annotate(ride, (row[\"lon\"], row[\"lat\"]),\n                     fontsize=6, alpha=0.65,\n                     xytext=(3, 3), textcoords=\"offset points\")\n\n# Right: same map, colored by KMeans cluster on coords alone.\nfor cluster, group in located.groupby(\"area_cluster\"):\n    label = f\"Area {cluster}\" + (\" (Kiddieland)\" if cluster == kiddie_cluster else \"\")\n    axes[1].scatter(group[\"lon\"], group[\"lat\"], label=label, s=140, alpha=0.85)\naxes[1].set_title(f\"Spatial clusters (KMeans k={N_AREAS} on standardized lat/lon)\")\naxes[1].set_xlabel(\"Longitude\")\naxes[1].set_ylabel(\"Latitude\")\naxes[1].legend()\n\nplt.tight_layout()\n\nprint(f\"Kiddieland = cluster {kiddie_cluster} ({len(kiddie_cluster_rides)} rides):\")\nprint(\"  \" + \", \".join(sorted(kiddie_cluster_rides)))\nprint(\"\\n=== Mean PC loading per spatial cluster ===\")\nprint(located.groupby(\"area_cluster\")[[\"PC1\", \"PC2\", \"PC3\"]].mean().round(3))\nprint(\"\\n=== Ride counts per cluster ===\")\nprint(located.groupby(\"area_cluster\").size())\n",
   "metadata": {},
   "execution_count": null,
   "outputs": []
  },
  {
   "cell_type": "code",
   "id": "6c723cc6",
   "source": "# Diagnostics: refine the PC labels, and check whether ffill+0 imputation is\n# corrupting any rides into a \"broken-ride\" axis.\n\nfor pc in [\"PC1\", \"PC2\", \"PC3\"]:\n    print(f\"\\n=== {pc} — full sorted loadings (most negative → most positive) ===\")\n    print(loadings[pc].sort_values().to_string())\n\nsilent_rate = (df_matrix == 0).mean().sort_values(ascending=False).rename(\"silent_rate\")\nprint(\"\\n=== Per-ride silent rate (fraction of rows = 0; high = closed often) ===\")\nprint(silent_rate.head(20).round(3).to_string())\n",
   "metadata": {},
   "execution_count": null,
   "outputs": []
  },
  {
   "cell_type": "code",
   "id": "4c567eec",
   "source": "# Validate PC3 as a *time-of-day* (daypart) axis.\n#\n# The original \"queue turnover / throughput\" hypothesis is untestable here:\n# ride_master.capacity and .duration are 100% NULL (0 of 60 rides), so there's\n# no throughput to correlate against. And splitting rides by PC3 sign shows no\n# queue-magnitude difference (neg-PC3 mean queue 9.8 min / busy-rate 0.37 vs\n# pos-PC3 8.8 / 0.35) — PC3 is NOT \"builds a line vs walk-on.\"\n#\n# What PC3 actually tracks is WHEN a ride is busy. The PC3 score runs strongly\n# positive midday and negative after dark. Mechanism (park ops): discounted\n# school/daycare group rates run from open to ~mid-afternoon and flood the\n# daytime family flat-rides (tea_cups, tilt-a-whirl, roto_jets, italian_trapeze);\n# the evening crowd (teens / date-night) shifts to coasters, bumper cars, motor\n# boats and the Ferris wheel after dark (impulse, bumper_cars, giant_wheel).\n\nafternoon_rides = loadings.index[loadings[\"PC3\"] > 0.1]\nevening_rides   = loadings.index[loadings[\"PC3\"] < -0.1]\nprint(f\"daytime rides (PC3 > +0.1): {len(afternoon_rides)}  -> {sorted(afternoon_rides)}\")\nprint(f\"evening rides (PC3 < -0.1): {len(evening_rides)}  -> {sorted(evening_rides)}\")\n\nhour = df_matrix.index.hour\n# De-trend by the park-wide hourly mean so we see *shape* (when each group is\n# busy relative to the whole park), not PC1's overall nightly ramp.\npark_by_hour = df_matrix.mean(axis=1).groupby(hour).mean()\n\nfig, axes = plt.subplots(1, 2, figsize=(15, 5))\n\n# Left: the PC3 score's own daypart profile — this IS the axis.\nscores.groupby(scores.index.hour)[\"PC3\"].mean().plot(ax=axes[0], marker=\"o\")\naxes[0].axhline(0, color=\"grey\", linewidth=0.5)\naxes[0].set_title(\"PC3 score by hour of day\\n(+ = daytime pattern, − = evening pattern)\")\naxes[0].set_xlabel(\"Hour (local)\")\naxes[0].set_ylabel(\"Mean PC3 score\")\n\n# Right: each loading group's queue as a share of the park-wide level, by hour.\nfor name, grp, color in [(\"daytime rides (PC3 +)\", afternoon_rides, \"tab:orange\"),\n                         (\"evening rides (PC3 −)\", evening_rides, \"tab:blue\")]:\n    share = df_matrix[grp].mean(axis=1).groupby(hour).mean() / park_by_hour\n    share.plot(ax=axes[1], marker=\"o\", label=name, color=color)\naxes[1].axhline(1.0, color=\"grey\", linewidth=0.5)\naxes[1].set_title(\"Group queue ÷ park-wide queue, by hour\\n(de-trended — isolates daypart shape from PC1)\")\naxes[1].set_xlabel(\"Hour (local)\")\naxes[1].set_ylabel(\"Share of park-wide mean queue\")\naxes[1].legend()\n\nplt.tight_layout()\n",
   "metadata": {},
   "execution_count": null,
   "outputs": []
  },
  {
   "cell_type": "code",
   "id": "6d7970ab",
   "source": "# Validate the PC labels against the most direct possible ground truth:\n# the average queue time of the rides each PC is supposed to represent.\n# PC1 should track park-wide mean queue.\n# PC2 should track the Kiddieland-vs-main-midway contrast (NOT raw kiddie mean —\n# on busy days both go up together, so raw kiddie mean is muddied by PC1).\n#\n# The kiddie/main split here comes from the GPS-cluster cell above\n# (`kiddie_cluster_rides` / `main_cluster_rides`), NOT the `category` tag. The\n# taxonomy misfiles ~3 rides across the divide (the big giant_wheel and\n# grand_carousel are tagged Family but sit on the main midway; the small\n# s&g_carousel sits in Kiddieland), which capped the category-based contrast at\n# r=0.73. The geometry-derived grouping (k=5) lifts it to ~0.78 — and uses\n# spatial information the PCA never saw, so it's an independent validation.\n#\n# Run the GPS-cluster cell first; this depends on its exported ride lists.\nkiddie_cols = df_matrix.columns.intersection(kiddie_cluster_rides)\nmain_cols   = df_matrix.columns.intersection(main_cluster_rides)\nprint(f\"Kiddieland rides in df_matrix: {len(kiddie_cols)}  |  main-park rides: {len(main_cols)}\")\n\npark_mean   = df_matrix.mean(axis=1)\nkiddie_mean = df_matrix[kiddie_cols].mean(axis=1)\nmain_mean   = df_matrix[main_cols].mean(axis=1)\ncontrast    = main_mean - kiddie_mean\n\nfig, axes = plt.subplots(1, 3, figsize=(18, 5))\n\naxes[0].scatter(park_mean, scores[\"PC1\"], s=4, alpha=0.25)\naxes[0].set_xlabel(\"Park-wide mean queue (min)\")\naxes[0].set_ylabel(\"PC1 score\")\naxes[0].set_title(f\"PC1 vs park-wide mean\\nPearson r = {park_mean.corr(scores['PC1']):.3f}\")\n\naxes[1].scatter(kiddie_mean, scores[\"PC2\"], s=4, alpha=0.25)\naxes[1].set_xlabel(\"Kiddieland mean queue (min)\")\naxes[1].set_ylabel(\"PC2 score\")\naxes[1].set_title(f\"PC2 vs raw Kiddieland mean\\nPearson r = {kiddie_mean.corr(scores['PC2']):.3f}\")\n\naxes[2].scatter(contrast, scores[\"PC2\"], s=4, alpha=0.25)\naxes[2].set_xlabel(\"Main-midway mean − Kiddieland mean (min)\")\naxes[2].set_ylabel(\"PC2 score\")\naxes[2].set_title(f\"PC2 vs main-vs-Kiddieland contrast\\nPearson r = {contrast.corr(scores['PC2']):.3f}\")\n\nplt.tight_layout()\n",
   "metadata": {},
   "execution_count": null,
   "outputs": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.14.4"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}