Under-querying vs out of data: the answer depends on the unit
The platoon test's n=452 described how much of the JOIN survived, not how much data exists. There are 1,266 clean settled hits rows and zero quarantined ones. platoon_splits had been ingested from tonight's lineups only (315 players), so any hitter who settled a prop without appearing in an ingest-day lineup was silently absent from every test. Backfilled all 380 hitters (81 fetched, 0 unresolved). Re-ran on 1,059 rows, up from 452. THE DEMOTION IS THE HEADLINE. pitcher_contact_profile, the strongest proven factor in the programme (-0.0064, CI [-0.0113,-0.0014]), roughly halved to -0.0034 on more than double the sample and its corrected interval now spans zero. The Bonferroni denominator also rose to 55, which widens every interval -- but a denominator cannot move a point estimate, and that halved on its own. platoon and platoon_severity now clear the bar and are NOT promoted. Upper bound -0.0001, on season-to-date splits that contain the games they predict: measured contamination is 4.5% median, 12.4% at p90, 137% worst. I had assumed ~1%. They stay CANDIDATE pending point-in-time splits. GAME-LEVEL IS A DIFFERENT PROBLEM. game_context held zero weather rows ever -- not because the fetcher was wrong (it correctly targets Open-Meteo's archive) but because ledger_entries keys a game as mlb:2026-08-03:Away@Home and game_context keys it as mlb:823437. Every lookup missed and NULL columns read as honest absence. Third occurrence of that class. Fixed the join: 96/101 settled games now carry actual archived weather, park dimensions backfilled 15 -> 30 venues. But 928 total_bases rows sit on 47 games at 17.6 rows per game. Park and weather assign one value per game, so resampling rows would have manufactured a pass. factorGate now resamples clusters when rows carry one and judges sample against effective_n; unclustered rows keep the original path byte-for-byte. Verdict: 47 clusters < 500, and the point estimate is +0.0011 -- worse, not merely unproven. Weather needs ~57 more days. Park dimensions need never: there are 30 ballparks in MLB, so a venue-constant factor can never reach 500 independent units. That bar was built for player-level factors and does not transfer. Wind is refused. We have speed and bearing for all 96 games; we lack park orientation, and 220 degrees is blowing out at one park and in at another. Using speed alone would assert an effect while discarding the sign that decides what it is. Counter and frozen clusters untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* backfill-context — WERE WE WAITING, OR UNDER-QUERYING?
|
||||
*
|
||||
* The platoon test ran on 452 rows against 1,266 clean settled hits rows in the
|
||||
* ledger, so "48 short of the gate" was never a statement about how much data
|
||||
* exists. It was a statement about how much the JOIN survived — and the join was
|
||||
* losing rows to inputs we simply had not fetched for every player.
|
||||
*
|
||||
* This backfills the inputs (pure sample, zero waiting) and reports exactly
|
||||
* where each row is lost, so the next "we need more data" claim is a measured
|
||||
* one rather than an inherited one.
|
||||
*
|
||||
* ── THE ONE HONEST CAVEAT, STATED UP FRONT ───────────────────────────────
|
||||
* Platoon splits from statsapi are SEASON-TO-DATE as of the moment they are
|
||||
* fetched. Applying today's split to a 2026-07-15 game means the split contains
|
||||
* that game. For a ~400-PA season line one game is roughly a quarter of one
|
||||
* percent, so the contamination is small — but it is real, it runs in the
|
||||
* flattering direction, and it is why this is labelled a reconstruction rather
|
||||
* than a clean point-in-time backtest.
|
||||
*
|
||||
* SUPABASE_URL=... node scripts/backfill-context.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const ctx = require('../src/services/lineupContextService');
|
||||
const mlb = require('../src/services/adapters/mlbStatsAdapter');
|
||||
const { knownNumber } = require('../src/utils/known');
|
||||
|
||||
const SB_URL = process.env.SUPABASE_URL;
|
||||
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||
const SEASON = Number(process.env.BF_SEASON || 2026);
|
||||
const PAGE = 1000;
|
||||
|
||||
async function page(sb, table, select, apply) {
|
||||
const out = [];
|
||||
for (let from = 0; ; from += PAGE) {
|
||||
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
|
||||
if (error) throw error;
|
||||
if (!data || data.length === 0) break;
|
||||
out.push(...data);
|
||||
if (data.length < PAGE) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!SB_URL || !SB_KEY) throw new Error('SUPABASE_URL / service key required');
|
||||
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
||||
|
||||
// Every hitter who appears on a CLEAN settled row — the true denominator.
|
||||
const led = await page(sb, 'ledger_entries', 'player_key, player_name, stat, outcome, quarantine_reason',
|
||||
(q) => q.eq('sport', 'mlb').is('user_id', null).in('stat', ['hits', 'total_bases'])
|
||||
.in('outcome', ['hit', 'miss']));
|
||||
const need = new Map();
|
||||
for (const r of led) {
|
||||
if ((r.quarantine_reason || '').startsWith('nontakeable_book')) continue;
|
||||
if (!need.has(r.player_key)) need.set(r.player_key, r.player_name);
|
||||
}
|
||||
|
||||
const have = new Set((await page(sb, 'platoon_splits', 'player_key', (q) => q.eq('sport', 'mlb')))
|
||||
.map((r) => r.player_key));
|
||||
const missing = [...need.entries()].filter(([k]) => !have.has(k));
|
||||
|
||||
console.error(`[backfill] hitters on clean settled rows: ${need.size}; splits already held: ${have.size}; to fetch: ${missing.length}`);
|
||||
|
||||
const asOf = ctx.dateET();
|
||||
const rows = [];
|
||||
let unresolved = 0;
|
||||
for (const [key, name] of missing) {
|
||||
let found = null;
|
||||
try { found = await mlb.searchPlayer(name); } catch { found = null; }
|
||||
if (!found || !found.id) { unresolved += 1; continue; }
|
||||
const sp = await ctx.fetchPlatoonSplits(found.id, SEASON, {});
|
||||
if (!sp) continue; // absent, never a symmetric guess
|
||||
rows.push({ player_key: key, player_name: name, source_id: found.id, ...sp });
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
for (let i = 0; i < rows.length; i += 200) {
|
||||
const batch = rows.slice(i, i + 200).map((r) => ({ ...r, sport: 'mlb', season: SEASON, as_of_date: asOf }));
|
||||
const { error } = await sb.from('platoon_splits')
|
||||
.upsert(batch, { onConflict: 'as_of_date,sport,season,player_key' });
|
||||
if (!error) written += batch.length;
|
||||
else console.error('[backfill] write failed:', error.message);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
hitters_on_clean_settled_rows: need.size,
|
||||
splits_held_before: have.size,
|
||||
attempted: missing.length,
|
||||
unresolved_by_name: unresolved,
|
||||
no_splits_available: missing.length - unresolved - rows.length,
|
||||
written,
|
||||
caveat: 'season-to-date splits applied to past games contain those games — small (~0.25% of a 400-PA line) but real and flattering',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -165,18 +165,23 @@ async function main() {
|
||||
byPlayer.set(r.player_key, cur);
|
||||
}
|
||||
|
||||
const loss = { no_batter_profile: 0, thin_base_rate: 0, no_opponent: 0, no_pitcher: 0, kept: 0 };
|
||||
const rows = [];
|
||||
for (const r of clean) {
|
||||
const bat = batters.get(r.player_key);
|
||||
const bp = byPlayer.get(r.player_key);
|
||||
if (!bp || bp.n < 3) continue;
|
||||
if (!bat) loss.no_batter_profile += 1;
|
||||
if (!bp || bp.n < 3) { loss.thin_base_rate += 1; continue; }
|
||||
// Leave-one-out so a row never contributes to its own baseline.
|
||||
const baseline = (bp.w - (r.outcome === 'hit' ? 1 : 0)) / (bp.n - 1);
|
||||
const faced = oppBy.get(`${r.player_key}|${r.game_date}`) || null;
|
||||
const nick = faced ? String(faced).split(' ').pop() : null;
|
||||
const def = faced ? (defByTeam.get(faced) || defByTeam.get(nick)) : null;
|
||||
if (!faced) loss.no_opponent += 1;
|
||||
const starterId = faced ? startersBy.get(`${r.game_date}|OPP:${faced}`) : null;
|
||||
const pit = starterId != null ? pitchersById.get(Number(starterId)) : null;
|
||||
if (faced && !pit) loss.no_pitcher += 1;
|
||||
loss.kept += 1;
|
||||
rows.push({
|
||||
id: r.id,
|
||||
archetype: archOf.get(`${r.player_key}|${r.game_date}`) || null,
|
||||
@@ -244,6 +249,8 @@ async function main() {
|
||||
console.log(JSON.stringify({
|
||||
baseline: "each row scored against the player's OWN leave-one-out base rate — the honest 'he's due' null",
|
||||
total_rows: rows.length,
|
||||
clean_settled_rows_available: clean.length,
|
||||
row_loss: loss,
|
||||
cumulative_bonferroni: mc,
|
||||
gate: 'a factor must MOVE the prediction AND improve out-of-sample Brier; movement alone is THEATER',
|
||||
results,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* prove-park-weather — DOES PARK GEOMETRY AND AIR READ TOTAL BASES?
|
||||
*
|
||||
* Run through the two-part gate like every other factor, with one addition that
|
||||
* changes the answer: the rows are CLUSTERED BY GAME. Park and weather assign a
|
||||
* single value to every hitter in a ballpark on a night, so eighteen prop rows
|
||||
* from one game are one reading of that game's conditions. Resampling rows would
|
||||
* treat them as eighteen and hand back an interval far tighter than the evidence
|
||||
* supports — which is how a gate passes a factor on sample it never had.
|
||||
*
|
||||
* SUPABASE_URL=... node scripts/prove-park-weather.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const pw = require('../src/services/model/parkWeather');
|
||||
const fg = require('../src/services/model/factorGate');
|
||||
const tl = require('../src/services/model/testLedger');
|
||||
const { knownNumber } = require('../src/utils/known');
|
||||
|
||||
const SB_URL = process.env.SUPABASE_URL;
|
||||
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||
const PAGE = 1000;
|
||||
|
||||
async function page(sb, table, select, apply) {
|
||||
const out = [];
|
||||
for (let from = 0; ; from += PAGE) {
|
||||
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
|
||||
if (error) throw error;
|
||||
if (!data || data.length === 0) break;
|
||||
out.push(...data);
|
||||
if (data.length < PAGE) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Hit-type shares → expected total bases, so a reshape has a consequence. */
|
||||
const tbFromShares = (s) => s.single + 2 * s.double + 3 * s.triple + 4 * s.home_run;
|
||||
|
||||
async function main() {
|
||||
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
||||
|
||||
const parks = await page(sb, 'park_dimensions', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const byVenue = new Map();
|
||||
for (const p of parks) if (!byVenue.has(p.venue_id)) byVenue.set(p.venue_id, p);
|
||||
const league = pw.leagueGeometry([...byVenue.values()]);
|
||||
|
||||
const ctx = await page(sb, 'game_context', 'game_id, venue_id, wx_temp_f, wx_wind_speed_mph, wx_wind_direction_deg', (q) => q);
|
||||
const ctxBy = new Map(ctx.map((c) => [c.game_id, c]));
|
||||
|
||||
const led = await page(sb, 'ledger_entries',
|
||||
'game_id, game_date, player_key, stat, line, side, outcome, quarantine_reason, p_win, proj_hits_p_over',
|
||||
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'total_bases').in('outcome', ['hit', 'miss']));
|
||||
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
|
||||
|
||||
// League-typical hit-type shares — the shape the atom reshapes.
|
||||
const BASE_SHARES = { single: 0.655, double: 0.195, triple: 0.017, home_run: 0.133 };
|
||||
const baseTb = tbFromShares(BASE_SHARES);
|
||||
|
||||
const loss = { no_context: 0, no_venue: 0, no_park: 0, no_baseline: 0, kept: 0 };
|
||||
const rows = [];
|
||||
for (const r of clean) {
|
||||
const c = ctxBy.get(r.game_id);
|
||||
if (!c) { loss.no_context += 1; continue; }
|
||||
if (c.venue_id == null) { loss.no_venue += 1; continue; }
|
||||
const dims = byVenue.get(c.venue_id);
|
||||
if (!dims) { loss.no_park += 1; continue; }
|
||||
|
||||
const baseline = knownNumber(r.p_win);
|
||||
if (baseline === null) { loss.no_baseline += 1; continue; }
|
||||
|
||||
const read = pw.parkWeatherRead({ dims, wx: c, league });
|
||||
if (!read) { loss.no_park += 1; continue; }
|
||||
|
||||
// The atom reshapes hit type; the consequence for total bases is the ratio
|
||||
// of expected bases per hit under the reshaped shape.
|
||||
const shaped = pw.applyToShares(BASE_SHARES, read);
|
||||
const ratio = tbFromShares(shaped) / baseTb;
|
||||
const conditioned = Math.max(0.01, Math.min(0.99, baseline * ratio));
|
||||
|
||||
rows.push({
|
||||
cluster: r.game_id, // ONE reading per game — the whole point
|
||||
baseline,
|
||||
conditioned,
|
||||
won: r.outcome === 'hit' ? 1 : 0,
|
||||
});
|
||||
loss.kept += 1;
|
||||
}
|
||||
|
||||
// Cumulative Bonferroni across the programme lifetime — this hypothesis is
|
||||
// one more test, and the bar rises for it like every other.
|
||||
const mc = await tl.recordAndCount(tl.supabaseStore(sb), [
|
||||
{ sport: 'mlb', stat: 'total_bases', archetype: null, interaction: 'factor:park_weather_hit_type', target: 'outcome' },
|
||||
]);
|
||||
const verdict = fg.adjudicate(rows, {
|
||||
factor: 'park_weather_hit_type',
|
||||
stat: 'total_bases',
|
||||
cumulativeTests: mc.cumulative_tests,
|
||||
});
|
||||
|
||||
const games = new Set(rows.map((r) => r.cluster)).size;
|
||||
const venues = new Set(clean.map((r) => ctxBy.get(r.game_id)?.venue_id).filter((v) => v != null)).size;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
clean_settled_tb_rows: clean.length,
|
||||
rows_built: rows.length,
|
||||
row_loss: loss,
|
||||
distinct_games: games,
|
||||
distinct_venues: venues,
|
||||
rows_per_game: games ? Math.round((rows.length / games) * 10) / 10 : null,
|
||||
cumulative_tests: mc.cumulative_tests,
|
||||
verdict,
|
||||
honest_note: 'sample judged in GAMES, not prop rows — park and weather vary per game',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* reconstruct-game-environment — GIVE THE PAST GAMES THEIR REAL CONDITIONS.
|
||||
*
|
||||
* `game_context` has never held a single weather reading. The reason is not the
|
||||
* fetcher, which is correct and points at Open-Meteo's ARCHIVE endpoint; it is
|
||||
* that nothing ever joined. The ledger keys a game as
|
||||
* `mlb:2026-08-03:WashingtonNationals@PhiladelphiaPhillies` and game_context
|
||||
* keys it as `mlb:823437`, so every lookup missed and the columns stayed NULL —
|
||||
* which reads exactly like "the weather was unavailable" rather than "the two
|
||||
* tables have never been introduced." The same class of failure as the doubled
|
||||
* /leaderboard path: graceful degradation wearing the mask of honest absence.
|
||||
*
|
||||
* This walks the dates in the settled ledger, resolves each slug to the real
|
||||
* statsapi game and venue, and writes a game_context row keyed by the LEDGER's
|
||||
* slug so the join exists. Then it pulls the actual archived weather for that
|
||||
* date and location.
|
||||
*
|
||||
* ARCHIVE, NOT FORECAST — asking the forecast endpoint about a past date returns
|
||||
* a re-forecast, which is a model's opinion about the past, not the past. Absent
|
||||
* stays NULL; nothing here is imputed.
|
||||
*
|
||||
* SUPABASE_URL=... node scripts/reconstruct-game-environment.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const axios = require('axios');
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const SB_URL = process.env.SUPABASE_URL;
|
||||
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||
const PAGE = 1000;
|
||||
const SCHEDULE = (d) => `https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${d}&hydrate=venue(location)`;
|
||||
const ARCHIVE = (lat, lon, date) =>
|
||||
`https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lon}`
|
||||
+ `&start_date=${date}&end_date=${date}`
|
||||
+ '&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,precipitation'
|
||||
+ '&temperature_unit=fahrenheit&wind_speed_unit=mph';
|
||||
|
||||
const squash = (s) => String(s || '').toLowerCase().replace(/[^a-z]/g, '');
|
||||
|
||||
async function page(sb, table, select, apply) {
|
||||
const out = [];
|
||||
for (let from = 0; ; from += PAGE) {
|
||||
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
|
||||
if (error) throw error;
|
||||
if (!data || data.length === 0) break;
|
||||
out.push(...data);
|
||||
if (data.length < PAGE) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const get = async (url) => (await axios.get(url, { timeout: 60_000 })).data;
|
||||
|
||||
async function main() {
|
||||
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
||||
|
||||
const led = await page(sb, 'ledger_entries', 'game_id, game_date, stat, outcome, quarantine_reason',
|
||||
(q) => q.eq('sport', 'mlb').is('user_id', null).in('stat', ['hits', 'total_bases'])
|
||||
.in('outcome', ['hit', 'miss']));
|
||||
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
|
||||
|
||||
// Distinct games, as the LEDGER names them.
|
||||
const games = new Map();
|
||||
for (const r of clean) if (r.game_id && !games.has(r.game_id)) games.set(r.game_id, r.game_date);
|
||||
const dates = [...new Set([...games.values()])].sort();
|
||||
console.error(`[env] ${games.size} distinct settled games across ${dates.length} dates`);
|
||||
|
||||
// date -> statsapi games, indexed by the same squashed away@home the slug uses.
|
||||
const resolved = new Map();
|
||||
const venues = new Map();
|
||||
for (const d of dates) {
|
||||
let sched = null;
|
||||
try { sched = await get(SCHEDULE(d)); } catch { sched = null; }
|
||||
for (const day of (sched && sched.dates) || []) {
|
||||
for (const g of day.games || []) {
|
||||
const away = squash(g.teams?.away?.team?.name);
|
||||
const home = squash(g.teams?.home?.team?.name);
|
||||
resolved.set(`${d}|${away}@${home}`, g);
|
||||
const v = g.venue || {};
|
||||
if (v.id && !venues.has(v.id)) {
|
||||
const loc = v.location || {};
|
||||
venues.set(v.id, {
|
||||
venue_id: v.id,
|
||||
venue_name: v.name || null,
|
||||
lat: loc.defaultCoordinates?.latitude ?? null,
|
||||
lon: loc.defaultCoordinates?.longitude ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join each ledger slug to its real game + venue.
|
||||
const rows = []; let unmatched = 0;
|
||||
for (const [gid, date] of games) {
|
||||
const m = /^mlb:(\d{4}-\d{2}-\d{2}):(.+?)@(.+)$/.exec(gid);
|
||||
if (!m) { unmatched += 1; continue; }
|
||||
const g = resolved.get(`${m[1]}|${squash(m[2])}@${squash(m[3])}`);
|
||||
if (!g) { unmatched += 1; continue; }
|
||||
rows.push({
|
||||
game_id: gid, // the LEDGER's key — this is the whole fix
|
||||
game_date: date,
|
||||
venue_id: g.venue?.id ?? null,
|
||||
source_game_pk: g.gamePk ?? null,
|
||||
});
|
||||
}
|
||||
console.error(`[env] matched ${rows.length}, unmatched ${unmatched}, venues seen ${venues.size}`);
|
||||
|
||||
for (let i = 0; i < rows.length; i += 200) {
|
||||
const { error } = await sb.from('game_context')
|
||||
.upsert(rows.slice(i, i + 200), { onConflict: 'game_id' });
|
||||
if (error) console.error('[env] context write failed:', error.message);
|
||||
}
|
||||
|
||||
// ACTUAL archived weather, one call per (venue, date) that we need.
|
||||
const need = new Map();
|
||||
for (const r of rows) {
|
||||
const v = venues.get(r.venue_id);
|
||||
if (!v || v.lat == null || v.lon == null) continue;
|
||||
need.set(`${r.venue_id}|${r.game_date}`, { v, date: r.game_date });
|
||||
}
|
||||
console.error(`[env] fetching ${need.size} venue-days of archived weather`);
|
||||
|
||||
const wx = new Map();
|
||||
for (const [k, { v, date }] of need) {
|
||||
try {
|
||||
const p = await get(ARCHIVE(v.lat, v.lon, date));
|
||||
const h = p && p.hourly;
|
||||
if (h && Array.isArray(h.time) && h.time.length) {
|
||||
const i = Math.min(h.time.length - 1, 19); // ~7pm local, typical first pitch
|
||||
wx.set(k, {
|
||||
wx_temp_f: h.temperature_2m?.[i] ?? null,
|
||||
wx_wind_speed_mph: h.wind_speed_10m?.[i] ?? null,
|
||||
wx_wind_direction_deg: h.wind_direction_10m?.[i] ?? null,
|
||||
wx_precip_mm: h.precipitation?.[i] ?? null,
|
||||
wx_source: 'open_meteo_archive',
|
||||
});
|
||||
}
|
||||
} catch { /* absent stays absent */ }
|
||||
}
|
||||
|
||||
let withWx = 0;
|
||||
for (let i = 0; i < rows.length; i += 200) {
|
||||
const batch = rows.slice(i, i + 200).map((r) => {
|
||||
const w = wx.get(`${r.venue_id}|${r.game_date}`);
|
||||
if (w) withWx += 1;
|
||||
return w ? { ...r, ...w } : r;
|
||||
});
|
||||
const { error } = await sb.from('game_context').upsert(batch, { onConflict: 'game_id' });
|
||||
if (error) console.error('[env] weather write failed:', error.message);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
settled_games: games.size,
|
||||
matched_to_statsapi: rows.length,
|
||||
unmatched,
|
||||
distinct_venues: venues.size,
|
||||
venue_days_requested: need.size,
|
||||
venue_days_returned: wx.size,
|
||||
game_rows_with_actual_weather: withWx,
|
||||
source: 'open_meteo_archive (actual, not re-forecast)',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user