diff --git a/CHANGELOG.md b/CHANGELOG.md index 579bbba..89e9f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,51 @@ # Changelog -## Unreleased - -- Added `getTeamEvents(team, {year})` for a team's full Statbotics history - across events (part of issue #9). It reuses the `/team_events` endpoint with - a `team` filter instead of `event`, and optionally a `year`, returning every - team-event row for that team sorted newest season first, then by event key. - List endpoints return an empty list on 404, so an unknown team or a team - with no recorded events answers with an empty list rather than throwing. +## 0.4.0 + +Breaking. The live v3 API does not send the shape this client's EPA and +team-event models were written against, so `getEventTeams` threw a +`TypeError` on every real response. Statbotics answered HTTP 500 on every +data endpoint from 2026-06-15 to around 2026-09-02, which is why nothing +caught it sooner: the models could not be exercised against a live body, and +the tests asserted against hand-written maps that agreed with the models +rather than with the API. + +- `StatboticsEpa` now decodes the shape the API sends. `epa.total_points` is + a bare number, not a `{mean, sd}` object, and the per-phase measures live + under `epa.breakdown`. Renamed to match: `totalPointsMean` -> + `totalPoints`, `autoPointsMean` -> `autoPoints`, `teleopPointsMean` -> + `teleopPoints`, `endgamePointsMean` -> `endgamePoints`. Added `unitless` + and `norm`, the cross-season comparable scales. Removed `totalPointsSd`, + which the API no longer reports anywhere. +- `StatboticsTeamEvent` reads its record from the nested `record` object: + `wins`/`losses`/`ties` from `record.total`, and `rank`/`numTeams` from + `record.qual`. They were previously read as top-level fields, which the + API has no such thing as, so every one decoded to 0 or null. +- Added `getTeamYears(team)` and `StatboticsTeamYear` for season-over-season + history, completing issue #9. One row per season with that season's EPA, + record, and worldwide EPA rank. Note `/team_years` reports its record flat, + unlike `/team_events`. +- No cache migration, deliberately. An on-device last-good cache (#512) + cannot hold team-event data written by an earlier version: the cache writer + requires a successful decode, decoding threw on every live response, and + every release of this package (0.1.0 on 2026-08-05 onward) shipped inside + the outage. A consumer that reads an old record gets a decode miss its own + cache layer already treats as no cache. +- Model tests now assert against captured live response bodies in + `test/fixtures/`, so an API shape change fails a test. +- Corrected two doc comments: an unknown team number answers 200 with `[]`, + not 404. A team number of 100000 or more is rejected with HTTP 422. +- Includes `getTeamEvents(team, {year})`, merged but never released under + 0.3.2: a team's history across events, from `/team_events` with a `team` + filter, sorted newest season first then by event key. + +Migrating: rename the four EPA field reads, and drop any use of +`totalPointsSd`. That is the whole of it. Nothing else that already existed +moved or changed signature, so code reading `record`, `rank` or `numTeams` off +a `StatboticsTeamEvent` needs no edit, though it was reading zeros before +this. The rest of the release is additive: `getTeamYears`, +`StatboticsTeamYear`, `getTeamEvents`, and the `unitless` and `norm` fields +are all new surface no existing caller has to adopt. ## 0.3.1 diff --git a/README.md b/README.md index 5ae4c0d..6020ded 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,17 @@ call `close()` when you are done so the underlying HTTP client is released. | `getEvents(year)` | `GET /events?year={year}` | `List` | | `getEventTeams(eventKey)` | `GET /team_events?event={eventKey}` | `List` | | `getTeamEvents(team, {year})` | `GET /team_events?team={team}[&year={year}]` | `List` | +| `getTeamYears(team)` | `GET /team_years?team={team}` | `List` | | `getEventTeamsBasic(eventKey)` | `GET /teams?event={eventKey}` | `List` | | `getEventMatches(eventKey)` | `GET /matches?event={eventKey}` | `List` | - `getEventTeams` sorts results by rank ascending. - `getTeamEvents` sorts results newest season first, then by event key, so a team's most recent results lead. Pass `year` to narrow to one season. +- `getTeamYears` sorts results newest season first, one row per season the + team competed in. Compare seasons on `epa.unitless` or `epa.norm`, not + `epa.totalPoints`: point values belong to a season's game, so a 2002 total of + 16.6 and a 2025 total of 92.77 say nothing about which robot was better. - `getEvents` sorts results by week then name. - `getEventMatches` sorts results by comp level (`qm`, `ef`, `qf`, `sf`, `f`) then match number. @@ -42,9 +47,21 @@ call `close()` when you are done so the underlying HTTP client is released. an empty list if the endpoint is unavailable. The API is public and read-only, so models only decode the fields each endpoint -exposes (EPA means and standard deviations on `StatboticsTeamEvent`, alliance -teams on `StatboticsMatch`, dates and location on `StatboticsEvent`). Each model -also round-trips through `toJson` for caching. +exposes (EPA and the per-phase breakdown on `StatboticsTeamEvent` and +`StatboticsTeamYear`, alliance teams on `StatboticsMatch`, dates and location +on `StatboticsEvent`). Each model also round-trips through `toJson` for +caching. + +`StatboticsEpa.totalPoints` is the point estimate the API reports at +`epa.total_points`, not an average over matches. The per-phase measures +(`autoPoints`, `teleopPoints`, `endgamePoints`) come from `epa.breakdown` and +are null for seasons predating that game's scoring split, so treat every EPA +field as nullable. + +Model tests assert against captured live response bodies in `test/fixtures/`, +not only hand-written maps, so a change to the API's shape fails a test rather +than reaching a consumer. Refresh a fixture by re-running the `curl` in the +comment above `_fixture` in the test file. ## Retries and backoff diff --git a/lib/src/statbotics_client.dart b/lib/src/statbotics_client.dart index 1a1739d..1c4075a 100644 --- a/lib/src/statbotics_client.dart +++ b/lib/src/statbotics_client.dart @@ -72,8 +72,9 @@ class StatboticsClient { /// just drops the `event` filter and adds `team` (and optionally `year`), /// answering with every row that mentions the team instead of every row for /// one event. Results are sorted by year descending, then event key - /// ascending. Returns an empty list on 404 (an unknown team number) or if - /// the team has no recorded events. + /// ascending. For a team Statbotics has no events for, the endpoint answers + /// 200 with `[]`, so an unknown team number comes back as an empty list + /// rather than an error. /// /// The 1000-row cap is fixed rather than a parameter, the way the other /// list methods fix theirs: a team plays a handful of events a season, so @@ -105,6 +106,40 @@ class StatboticsClient { return results; } + /// `GET /v3/team_years?team={team}&limit=100` — returns one row per season + /// the team competed in, newest season first. + /// + /// The season-over-season view [getTeamEvents] cannot give: that one answers + /// per event within a season, this one answers per season. For a team + /// Statbotics has no seasons for, the endpoint answers 200 with `[]`, so an + /// unknown team number comes back as an empty list rather than an error. A + /// [team] of 100000 or more is rejected with HTTP 422 and throws + /// [StatboticsApiException]: that is a malformed request, not an unknown + /// team. + /// + /// The 100-row cap is fixed the way the other list methods fix theirs. FRC + /// has run since 1992, so no team has more seasons than that. + Future> getTeamYears(int team) async { + final body = await _get( + '/team_years', + queryParameters: { + 'team': team.toString(), + 'limit': '100', + }, + ); + if (body == null) return const []; + final list = jsonDecode(body) as List; + final results = list + .map( + (json) => StatboticsTeamYear.fromJson( + (json as Map).cast(), + ), + ) + .toList(growable: true); + results.sort((a, b) => b.year.compareTo(a.year)); + return results; + } + /// `GET /v3/events?year={year}` — returns all events for the given year, /// sorted by week then name. Future> getEvents(int year) async { diff --git a/lib/src/statbotics_models.dart b/lib/src/statbotics_models.dart index c28b404..6b032a9 100644 --- a/lib/src/statbotics_models.dart +++ b/lib/src/statbotics_models.dart @@ -175,39 +175,50 @@ class StatboticsEvent { } } -/// EPA (Expected Points Added) breakdown for a team at an event. +/// EPA (Expected Points Added) for a team, as `/team_events` and +/// `/team_years` report it. +/// +/// [totalPoints] is the point estimate the API puts at `epa.total_points`, +/// not an average over matches -- `epa.stats.mean` is a different number and +/// this model does not carry it. The per-phase measures live under +/// `epa.breakdown` and are absent for seasons predating that game's scoring +/// split, so every field here is nullable. class StatboticsEpa { const StatboticsEpa({ - this.totalPointsMean, - this.totalPointsSd, - this.autoPointsMean, - this.teleopPointsMean, - this.endgamePointsMean, + this.totalPoints, + this.unitless, + this.norm, + this.autoPoints, + this.teleopPoints, + this.endgamePoints, }); factory StatboticsEpa.fromJson(Map json) { - final total = - (json['total_points'] as Map?)?.cast() ?? const {}; - final auto = - (json['auto_points'] as Map?)?.cast() ?? const {}; - final teleop = - (json['teleop_points'] as Map?)?.cast() ?? const {}; - final endgame = - (json['endgame_points'] as Map?)?.cast() ?? const {}; + final breakdown = + (json['breakdown'] as Map?)?.cast() ?? const {}; return StatboticsEpa( - totalPointsMean: (total['mean'] as num?)?.toDouble(), - totalPointsSd: (total['sd'] as num?)?.toDouble(), - autoPointsMean: (auto['mean'] as num?)?.toDouble(), - teleopPointsMean: (teleop['mean'] as num?)?.toDouble(), - endgamePointsMean: (endgame['mean'] as num?)?.toDouble(), + totalPoints: (json['total_points'] as num?)?.toDouble(), + unitless: (json['unitless'] as num?)?.toDouble(), + norm: (json['norm'] as num?)?.toDouble(), + autoPoints: (breakdown['auto_points'] as num?)?.toDouble(), + teleopPoints: (breakdown['teleop_points'] as num?)?.toDouble(), + endgamePoints: (breakdown['endgame_points'] as num?)?.toDouble(), ); } - final double? totalPointsMean; - final double? totalPointsSd; - final double? autoPointsMean; - final double? teleopPointsMean; - final double? endgamePointsMean; + /// The team's EPA in game points. + final double? totalPoints; + + /// The unitless EPA scale, comparable across seasons where [totalPoints] is + /// not, since a season's point values are its own. + final double? unitless; + + /// The normalized EPA scale, centred so 1500 is an average team. + final double? norm; + + final double? autoPoints; + final double? teleopPoints; + final double? endgamePoints; static const StatboticsEpa empty = StatboticsEpa(); @@ -215,13 +226,14 @@ class StatboticsEpa { /// last-good cache (#512). Map toJson() { return { - 'total_points': { - 'mean': totalPointsMean, - 'sd': totalPointsSd, + 'total_points': totalPoints, + 'unitless': unitless, + 'norm': norm, + 'breakdown': { + 'auto_points': autoPoints, + 'teleop_points': teleopPoints, + 'endgame_points': endgamePoints, }, - 'auto_points': {'mean': autoPointsMean}, - 'teleop_points': {'mean': teleopPointsMean}, - 'endgame_points': {'mean': endgamePointsMean}, }; } } @@ -244,17 +256,22 @@ class StatboticsTeamEvent { factory StatboticsTeamEvent.fromJson(Map json) { final rawEpa = (json['epa'] as Map?)?.cast(); + final record = + (json['record'] as Map?)?.cast() ?? const {}; + final total = + (record['total'] as Map?)?.cast() ?? const {}; + final qual = (record['qual'] as Map?)?.cast() ?? const {}; return StatboticsTeamEvent( team: (json['team'] as num?)?.toInt() ?? 0, event: (json['event'] as String?) ?? '', eventName: (json['event_name'] as String?) ?? '', teamName: (json['team_name'] as String?) ?? '', year: (json['year'] as num?)?.toInt() ?? 0, - wins: (json['wins'] as num?)?.toInt() ?? 0, - losses: (json['losses'] as num?)?.toInt() ?? 0, - ties: (json['ties'] as num?)?.toInt() ?? 0, - rank: (json['rank'] as num?)?.toInt(), - numTeams: (json['num_teams'] as num?)?.toInt(), + wins: (total['wins'] as num?)?.toInt() ?? 0, + losses: (total['losses'] as num?)?.toInt() ?? 0, + ties: (total['ties'] as num?)?.toInt() ?? 0, + rank: (qual['rank'] as num?)?.toInt(), + numTeams: (qual['num_teams'] as num?)?.toInt(), epa: rawEpa != null ? StatboticsEpa.fromJson(rawEpa) : StatboticsEpa.empty, ); @@ -272,9 +289,15 @@ class StatboticsTeamEvent { /// omits it. final String teamName; final int year; + + /// Won-lost-tied over the whole event, quals and elims together, from + /// `record.total`. final int wins; final int losses; final int ties; + + /// Qualification rank and field size, from `record.qual`. Null before the + /// event has ranked anyone, and for an event Statbotics has no record of. final int? rank; final int? numTeams; final StatboticsEpa epa; @@ -288,14 +311,106 @@ class StatboticsTeamEvent { 'event_name': eventName, 'team_name': teamName, 'year': year, - 'wins': wins, - 'losses': losses, - 'ties': ties, - 'rank': rank, - 'num_teams': numTeams, + 'record': { + 'qual': {'rank': rank, 'num_teams': numTeams}, + 'total': { + 'wins': wins, + 'losses': losses, + 'ties': ties, + }, + }, 'epa': epa.toJson(), }; } String get record => '$wins-$losses${ties > 0 ? '-$ties' : ''}'; } + +/// One team's season, as `/team_years` reports it. +/// +/// The season-over-season counterpart to [StatboticsTeamEvent]: one row per +/// year a team competed, carrying that season's EPA and record. +/// +/// Compare seasons on [StatboticsEpa.unitless] or [StatboticsEpa.norm] rather +/// than [StatboticsEpa.totalPoints]. Point values belong to a season's game, +/// so a 2002 total of 16.6 and a 2025 total of 92.77 say nothing about which +/// robot was better. +class StatboticsTeamYear { + StatboticsTeamYear({ + required this.team, + required this.year, + this.name = '', + required this.wins, + required this.losses, + required this.ties, + this.epaRank, + this.epaRankTeamCount, + required this.epa, + }); + + factory StatboticsTeamYear.fromJson(Map json) { + final rawEpa = (json['epa'] as Map?)?.cast(); + // Flat here, unlike `/team_events`, which splits the record into + // qual/elim/total. + final record = + (json['record'] as Map?)?.cast() ?? const {}; + final ranks = + (rawEpa?['ranks'] as Map?)?.cast() ?? const {}; + final total = (ranks['total'] as Map?)?.cast() ?? const {}; + return StatboticsTeamYear( + team: (json['team'] as num?)?.toInt() ?? 0, + year: (json['year'] as num?)?.toInt() ?? 0, + name: (json['name'] as String?) ?? '', + wins: (record['wins'] as num?)?.toInt() ?? 0, + losses: (record['losses'] as num?)?.toInt() ?? 0, + ties: (record['ties'] as num?)?.toInt() ?? 0, + epaRank: (total['rank'] as num?)?.toInt(), + epaRankTeamCount: (total['team_count'] as num?)?.toInt(), + epa: + rawEpa != null ? StatboticsEpa.fromJson(rawEpa) : StatboticsEpa.empty, + ); + } + + final int team; + final int year; + + /// The team's nickname for that season. + final String name; + + /// Won-lost-tied across the whole season. + final int wins; + final int losses; + final int ties; + + /// Where this season's EPA placed the team worldwide, from + /// `epa.ranks.total`, with the number of teams it was ranked against. + final int? epaRank; + final int? epaRankTeamCount; + + final StatboticsEpa epa; + + /// Round-trips through [StatboticsTeamYear.fromJson] for the on-device + /// last-good cache (#512). + Map toJson() { + final epaJson = epa.toJson(); + epaJson['ranks'] = { + 'total': { + 'rank': epaRank, + 'team_count': epaRankTeamCount, + }, + }; + return { + 'team': team, + 'year': year, + 'name': name, + 'record': { + 'wins': wins, + 'losses': losses, + 'ties': ties, + }, + 'epa': epaJson, + }; + } + + String get record => '$wins-$losses${ties > 0 ? '-$ties' : ''}'; +} diff --git a/pubspec.yaml b/pubspec.yaml index 5247b49..7f20177 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: statbotics_client description: A typed Dart client for the Statbotics API v3 (FRC EPA statistics, events, matches). Pure Dart, no Flutter dependency. -version: 0.3.1 +version: 0.4.0 repository: https://github.com/Project516/statbotics_client topics: diff --git a/test/fixtures/event.json b/test/fixtures/event.json new file mode 100644 index 0000000..47226a6 --- /dev/null +++ b/test/fixtures/event.json @@ -0,0 +1,56 @@ +{ + "key": "2025cabe", + "year": 2025, + "name": "East Bay Regional", + "time": 1743652800, + "country": "USA", + "state": "CA", + "district": null, + "start_date": "2025-04-03", + "end_date": "2025-04-06", + "type": "regional", + "week": 6, + "video": "https://www.twitch.tv/firstinspires1", + "status": "Completed", + "status_str": "Completed", + "num_teams": 55, + "current_match": 98, + "qual_matches": 83, + "epa": { + "max": 91.91, + "top_8": 55.82, + "top_24": 31.3, + "mean": 33.56, + "sd": 19.09 + }, + "metrics": { + "win_prob": { + "count": 98, + "conf": 0.7532, + "acc": 0.8367, + "mse": 0.123 + }, + "score_pred": { + "count": 196, + "rmse": 26.2204, + "error": -7.2394 + }, + "rp_pred": { + "count": 166, + "auto_rp": { + "error": -0.0824, + "acc": 0.7289 + }, + "coral_rp": { + "error": -0.0862, + "acc": 0.7651 + }, + "barge_rp": [ + { + "error": -0.0021, + "acc": 0.753 + } + ] + } + } +} diff --git a/test/fixtures/events_by_year.json b/test/fixtures/events_by_year.json new file mode 100644 index 0000000..add18f8 --- /dev/null +++ b/test/fixtures/events_by_year.json @@ -0,0 +1,114 @@ +[ + { + "key": "2025alhu", + "year": 2025, + "name": "Rocket City Regional", + "time": 1741752000, + "country": "USA", + "state": "AL", + "district": null, + "start_date": "2025-03-12", + "end_date": "2025-03-15", + "type": "regional", + "week": 3, + "video": "https://www.twitch.tv/firstinspires5", + "status": "Completed", + "status_str": "Completed", + "num_teams": 44, + "current_match": 96, + "qual_matches": 81, + "epa": { + "max": 65.62, + "top_8": 35.42, + "top_24": 18.9, + "mean": 24.72, + "sd": 14.48 + }, + "metrics": { + "win_prob": { + "count": 96, + "conf": 0.6675, + "acc": 0.7604, + "mse": 0.1556 + }, + "score_pred": { + "count": 192, + "rmse": 22.7338, + "error": -2.1215 + }, + "rp_pred": { + "count": 162, + "auto_rp": { + "error": -0.0681, + "acc": 0.6605 + }, + "coral_rp": { + "error": -0.0109, + "acc": 0.9259 + }, + "barge_rp": [ + { + "error": -0.0472, + "acc": 0.6296 + } + ] + } + } + }, + { + "key": "2025arc", + "year": 2025, + "name": "Archimedes Division", + "time": 1744776000, + "country": "USA", + "state": "TX", + "district": null, + "start_date": "2025-04-16", + "end_date": "2025-04-19", + "type": "champs_div", + "week": 8, + "video": "https://www.twitch.tv/firstinspires_archimedes", + "status": "Completed", + "status_str": "Completed", + "num_teams": 75, + "current_match": 140, + "qual_matches": 125, + "epa": { + "max": 101.05, + "top_8": 84.65, + "top_24": 71.8, + "mean": 59.92, + "sd": 19.85 + }, + "metrics": { + "win_prob": { + "count": 140, + "conf": 0.7556, + "acc": 0.8, + "mse": 0.1377 + }, + "score_pred": { + "count": 280, + "rmse": 23.7671, + "error": 1.1597 + }, + "rp_pred": { + "count": 250, + "auto_rp": { + "error": -0.0274, + "acc": 0.956 + }, + "coral_rp": { + "error": -0.0386, + "acc": 0.78 + }, + "barge_rp": [ + { + "error": 0.0081, + "acc": 0.788 + } + ] + } + } + } +] diff --git a/test/fixtures/matches_by_event.json b/test/fixtures/matches_by_event.json new file mode 100644 index 0000000..12b1486 --- /dev/null +++ b/test/fixtures/matches_by_event.json @@ -0,0 +1,690 @@ +[ + { + "key": "2025cabe_f1m1", + "year": 2025, + "event": "2025cabe", + "week": 6, + "elim": true, + "comp_level": "f", + "set_number": 1, + "match_number": 1, + "match_name": "Finals Match 1", + "time": 1743980040, + "predicted_time": 1743981273, + "status": "Completed", + "video": "rC6xTPwthSg", + "alliances": { + "red": { + "team_keys": [ + 254, + 2204, + 4270 + ], + "surrogate_team_keys": [], + "dq_team_keys": [] + }, + "blue": { + "team_keys": [ + 8033, + 972, + 8793 + ], + "surrogate_team_keys": [], + "dq_team_keys": [] + } + }, + "pred": { + "winner": "red", + "red_win_prob": 0.8297, + "red_score": 222.36, + "blue_score": 179.73, + "red_auto_rp": 0.9877, + "blue_auto_rp": 0.9862, + "red_coral_rp": 0.8694, + "blue_coral_rp": 0.5635, + "red_barge_rp": 0.969, + "blue_barge_rp": 0.9048, + "red_rp_1": 0.9877, + "blue_rp_1": 0.9862, + "red_rp_2": 0.8694, + "blue_rp_2": 0.5635, + "red_rp_3": 0.969, + "blue_rp_3": 0.9048 + }, + "result": { + "winner": "red", + "red_score": 239, + "blue_score": 220, + "red_no_foul": 239, + "blue_no_foul": 220, + "red_auto_points": 44, + "blue_auto_points": 47, + "red_teleop_points": 179, + "blue_teleop_points": 153, + "red_endgame_points": 16, + "blue_endgame_points": 20, + "red_auto_rp": false, + "blue_auto_rp": false, + "red_coral_rp": false, + "blue_coral_rp": false, + "red_barge_rp": false, + "blue_barge_rp": false, + "red_tiebreaker_points": 0, + "blue_tiebreaker_points": 0, + "red_auto_coral_points": 35.0, + "blue_auto_coral_points": 38.0, + "red_teleop_coral_points": 135.0, + "blue_teleop_coral_points": 141.0, + "red_coral_l1": 8.0, + "blue_coral_l1": 16.0, + "red_coral_l2": 12.0, + "blue_coral_l2": 12.0, + "red_coral_l3": 12.0, + "blue_coral_l3": 10.0, + "red_coral_l4": 12.0, + "blue_coral_l4": 12.0, + "red_processor_algae": 0.0, + "blue_processor_algae": 0.0, + "red_processor_algae_points": 0.0, + "blue_processor_algae_points": 0.0, + "red_net_algae_points": 44.0, + "blue_net_algae_points": 12.0, + "red_barge_points": 16.0, + "blue_barge_points": 20.0, + "red_rp_1": false, + "blue_rp_1": false, + "red_rp_2": false, + "blue_rp_2": false, + "red_rp_3": false, + "blue_rp_3": false + }, + "pre_epas": { + "2204": { + "auto_epa": 11.54, + "comp_0_epa": 8.52, + "comp_1_epa": 23.91, + "comp_2_epa": 0.37, + "comp_3_epa": 1.15, + "comp_4_epa": 1.48, + "comp_5_epa": 3.97, + "comp_6_epa": -0.03, + "comp_7_epa": -0.08, + "comp_8_epa": 1.47, + "comp_9_epa": 1.68, + "endgame_epa": 1.68, + "epa": 38.53, + "rp_1_epa": 0.5526, + "rp_2_epa": -0.1412, + "rp_3_epa": 0.0725, + "teleop_epa": 25.3, + "tiebreaker_epa": 0.02 + }, + "254": { + "auto_epa": 21.03, + "comp_0_epa": 17.55, + "comp_1_epa": 49.73, + "comp_2_epa": 2.03, + "comp_3_epa": 3.83, + "comp_4_epa": 4.63, + "comp_5_epa": 5.64, + "comp_6_epa": 0.32, + "comp_7_epa": 0.96, + "comp_8_epa": 7.93, + "comp_9_epa": 10.68, + "endgame_epa": 10.68, + "epa": 90.33, + "rp_1_epa": 0.582, + "rp_2_epa": 0.6194, + "rp_3_epa": 0.6968, + "teleop_epa": 58.62, + "tiebreaker_epa": 0.17 + }, + "4270": { + "auto_epa": 17.69, + "comp_0_epa": 14.68, + "comp_1_epa": 43.87, + "comp_2_epa": 1.88, + "comp_3_epa": 3.45, + "comp_4_epa": 4.41, + "comp_5_epa": 4.52, + "comp_6_epa": 0.69, + "comp_7_epa": 2.08, + "comp_8_epa": 4.32, + "comp_9_epa": 10.73, + "endgame_epa": 10.73, + "epa": 78.7, + "rp_1_epa": 0.4628, + "rp_2_epa": 0.4957, + "rp_3_epa": 0.5914, + "teleop_epa": 50.28, + "tiebreaker_epa": 0.13 + }, + "8033": { + "auto_epa": 20.85, + "comp_0_epa": 17.52, + "comp_1_epa": 39.43, + "comp_2_epa": 0.69, + "comp_3_epa": 2.77, + "comp_4_epa": 3.69, + "comp_5_epa": 5.5, + "comp_6_epa": 0.71, + "comp_7_epa": 2.14, + "comp_8_epa": 4.96, + "comp_9_epa": 8.07, + "endgame_epa": 8.07, + "epa": 75.46, + "rp_1_epa": 0.6001, + "rp_2_epa": 0.4814, + "rp_3_epa": 0.539, + "teleop_epa": 46.54, + "tiebreaker_epa": 0.24 + }, + "8793": { + "auto_epa": 11.34, + "comp_0_epa": 8.34, + "comp_1_epa": 20.88, + "comp_2_epa": 8.99, + "comp_3_epa": 0.06, + "comp_4_epa": 0.53, + "comp_5_epa": 1.26, + "comp_6_epa": -0.1, + "comp_7_epa": -0.31, + "comp_8_epa": -0.73, + "comp_9_epa": 4.26, + "endgame_epa": 4.26, + "epa": 35.44, + "rp_1_epa": 0.5157, + "rp_2_epa": -0.0507, + "rp_3_epa": -0.0054, + "teleop_epa": 19.84, + "tiebreaker_epa": -0.03 + }, + "972": { + "auto_epa": 10.86, + "comp_0_epa": 7.88, + "comp_1_epa": 33.69, + "comp_2_epa": 1.22, + "comp_3_epa": 2.19, + "comp_4_epa": 2.82, + "comp_5_epa": 3.8, + "comp_6_epa": -0.31, + "comp_7_epa": -0.93, + "comp_8_epa": 3.11, + "comp_9_epa": 9.39, + "endgame_epa": 9.39, + "epa": 56.13, + "rp_1_epa": 0.4511, + "rp_2_epa": 0.1332, + "rp_3_epa": 0.5294, + "teleop_epa": 35.88, + "tiebreaker_epa": -0.03 + } + }, + "epas": { + "2204": { + "auto_epa": 11.4, + "comp_0_epa": 8.39, + "comp_1_epa": 24.3, + "comp_2_epa": 0.46, + "comp_3_epa": 1.23, + "comp_4_epa": 1.52, + "comp_5_epa": 3.93, + "comp_6_epa": -0.05, + "comp_7_epa": -0.15, + "comp_8_epa": 2.13, + "comp_9_epa": 1.52, + "endgame_epa": 1.52, + "epa": 39.2, + "rp_1_epa": 0.5526, + "rp_2_epa": -0.1412, + "rp_3_epa": 0.0725, + "teleop_epa": 26.28, + "tiebreaker_epa": 0.01 + }, + "254": { + "auto_epa": 20.9, + "comp_0_epa": 17.42, + "comp_1_epa": 50.12, + "comp_2_epa": 2.12, + "comp_3_epa": 3.91, + "comp_4_epa": 4.66, + "comp_5_epa": 5.59, + "comp_6_epa": 0.3, + "comp_7_epa": 0.89, + "comp_8_epa": 8.58, + "comp_9_epa": 10.52, + "endgame_epa": 10.52, + "epa": 91.01, + "rp_1_epa": 0.582, + "rp_2_epa": 0.6194, + "rp_3_epa": 0.6968, + "teleop_epa": 59.59, + "tiebreaker_epa": 0.16 + }, + "4270": { + "auto_epa": 17.55, + "comp_0_epa": 14.55, + "comp_1_epa": 44.26, + "comp_2_epa": 1.96, + "comp_3_epa": 3.53, + "comp_4_epa": 4.44, + "comp_5_epa": 4.47, + "comp_6_epa": 0.67, + "comp_7_epa": 2.01, + "comp_8_epa": 4.98, + "comp_9_epa": 10.58, + "endgame_epa": 10.58, + "epa": 79.38, + "rp_1_epa": 0.4628, + "rp_2_epa": 0.4957, + "rp_3_epa": 0.5914, + "teleop_epa": 51.25, + "tiebreaker_epa": 0.12 + }, + "8033": { + "auto_epa": 20.94, + "comp_0_epa": 17.61, + "comp_1_epa": 40.48, + "comp_2_epa": 0.8, + "comp_3_epa": 2.93, + "comp_4_epa": 3.76, + "comp_5_epa": 5.54, + "comp_6_epa": 0.71, + "comp_7_epa": 2.12, + "comp_8_epa": 5.0, + "comp_9_epa": 8.03, + "endgame_epa": 8.03, + "epa": 76.57, + "rp_1_epa": 0.6001, + "rp_2_epa": 0.4814, + "rp_3_epa": 0.539, + "teleop_epa": 47.6, + "tiebreaker_epa": 0.23 + }, + "8793": { + "auto_epa": 11.43, + "comp_0_epa": 8.44, + "comp_1_epa": 21.92, + "comp_2_epa": 9.1, + "comp_3_epa": 0.21, + "comp_4_epa": 0.6, + "comp_5_epa": 1.3, + "comp_6_epa": -0.11, + "comp_7_epa": -0.33, + "comp_8_epa": -0.69, + "comp_9_epa": 4.22, + "endgame_epa": 4.22, + "epa": 36.55, + "rp_1_epa": 0.5157, + "rp_2_epa": -0.0507, + "rp_3_epa": -0.0054, + "teleop_epa": 20.9, + "tiebreaker_epa": -0.03 + }, + "972": { + "auto_epa": 10.95, + "comp_0_epa": 7.97, + "comp_1_epa": 34.74, + "comp_2_epa": 1.33, + "comp_3_epa": 2.35, + "comp_4_epa": 2.88, + "comp_5_epa": 3.83, + "comp_6_epa": -0.32, + "comp_7_epa": -0.95, + "comp_8_epa": 3.15, + "comp_9_epa": 9.35, + "endgame_epa": 9.35, + "epa": 57.24, + "rp_1_epa": 0.4511, + "rp_2_epa": 0.1332, + "rp_3_epa": 0.5294, + "teleop_epa": 36.94, + "tiebreaker_epa": -0.04 + } + } + }, + { + "key": "2025cabe_f1m2", + "year": 2025, + "event": "2025cabe", + "week": 6, + "elim": true, + "comp_level": "f", + "set_number": 1, + "match_number": 2, + "match_name": "Finals Match 2", + "time": 1743981300, + "predicted_time": 1743983671, + "status": "Completed", + "video": "LbKHvJjXNVw", + "alliances": { + "red": { + "team_keys": [ + 254, + 2204, + 4270 + ], + "surrogate_team_keys": [], + "dq_team_keys": [] + }, + "blue": { + "team_keys": [ + 8033, + 972, + 8793 + ], + "surrogate_team_keys": [], + "dq_team_keys": [] + } + }, + "pred": { + "winner": "red", + "red_win_prob": 0.8224, + "red_score": 224.23, + "blue_score": 182.97, + "red_auto_rp": 0.9877, + "blue_auto_rp": 0.9862, + "red_coral_rp": 0.8694, + "blue_coral_rp": 0.5635, + "red_barge_rp": 0.969, + "blue_barge_rp": 0.9048, + "red_rp_1": 0.9877, + "blue_rp_1": 0.9862, + "red_rp_2": 0.8694, + "blue_rp_2": 0.5635, + "red_rp_3": 0.969, + "blue_rp_3": 0.9048 + }, + "result": { + "winner": "red", + "red_score": 263, + "blue_score": 204, + "red_no_foul": 251, + "blue_no_foul": 204, + "red_auto_points": 58, + "blue_auto_points": 50, + "red_teleop_points": 167, + "blue_teleop_points": 134, + "red_endgame_points": 26, + "blue_endgame_points": 20, + "red_auto_rp": false, + "blue_auto_rp": false, + "red_coral_rp": false, + "blue_coral_rp": false, + "red_barge_rp": false, + "blue_barge_rp": false, + "red_tiebreaker_points": 0, + "blue_tiebreaker_points": 0, + "red_auto_coral_points": 49.0, + "blue_auto_coral_points": 41.0, + "red_teleop_coral_points": 131.0, + "blue_teleop_coral_points": 118.0, + "red_coral_l1": 11.0, + "blue_coral_l1": 13.0, + "red_coral_l2": 12.0, + "blue_coral_l2": 6.0, + "red_coral_l3": 12.0, + "blue_coral_l3": 12.0, + "red_coral_l4": 12.0, + "blue_coral_l4": 11.0, + "red_processor_algae": 0.0, + "blue_processor_algae": 0.0, + "red_processor_algae_points": 0.0, + "blue_processor_algae_points": 0.0, + "red_net_algae_points": 36.0, + "blue_net_algae_points": 16.0, + "red_barge_points": 26.0, + "blue_barge_points": 20.0, + "red_rp_1": false, + "blue_rp_1": false, + "red_rp_2": false, + "blue_rp_2": false, + "red_rp_3": false, + "blue_rp_3": false + }, + "pre_epas": { + "2204": { + "auto_epa": 11.4, + "comp_0_epa": 8.39, + "comp_1_epa": 24.3, + "comp_2_epa": 0.46, + "comp_3_epa": 1.23, + "comp_4_epa": 1.52, + "comp_5_epa": 3.93, + "comp_6_epa": -0.05, + "comp_7_epa": -0.15, + "comp_8_epa": 2.13, + "comp_9_epa": 1.52, + "endgame_epa": 1.52, + "epa": 39.2, + "rp_1_epa": 0.5526, + "rp_2_epa": -0.1412, + "rp_3_epa": 0.0725, + "teleop_epa": 26.28, + "tiebreaker_epa": 0.01 + }, + "254": { + "auto_epa": 20.9, + "comp_0_epa": 17.42, + "comp_1_epa": 50.12, + "comp_2_epa": 2.12, + "comp_3_epa": 3.91, + "comp_4_epa": 4.66, + "comp_5_epa": 5.59, + "comp_6_epa": 0.3, + "comp_7_epa": 0.89, + "comp_8_epa": 8.58, + "comp_9_epa": 10.52, + "endgame_epa": 10.52, + "epa": 91.01, + "rp_1_epa": 0.582, + "rp_2_epa": 0.6194, + "rp_3_epa": 0.6968, + "teleop_epa": 59.59, + "tiebreaker_epa": 0.16 + }, + "4270": { + "auto_epa": 17.55, + "comp_0_epa": 14.55, + "comp_1_epa": 44.26, + "comp_2_epa": 1.96, + "comp_3_epa": 3.53, + "comp_4_epa": 4.44, + "comp_5_epa": 4.47, + "comp_6_epa": 0.67, + "comp_7_epa": 2.01, + "comp_8_epa": 4.98, + "comp_9_epa": 10.58, + "endgame_epa": 10.58, + "epa": 79.38, + "rp_1_epa": 0.4628, + "rp_2_epa": 0.4957, + "rp_3_epa": 0.5914, + "teleop_epa": 51.25, + "tiebreaker_epa": 0.12 + }, + "8033": { + "auto_epa": 20.94, + "comp_0_epa": 17.61, + "comp_1_epa": 40.48, + "comp_2_epa": 0.8, + "comp_3_epa": 2.93, + "comp_4_epa": 3.76, + "comp_5_epa": 5.54, + "comp_6_epa": 0.71, + "comp_7_epa": 2.12, + "comp_8_epa": 5.0, + "comp_9_epa": 8.03, + "endgame_epa": 8.03, + "epa": 76.57, + "rp_1_epa": 0.6001, + "rp_2_epa": 0.4814, + "rp_3_epa": 0.539, + "teleop_epa": 47.6, + "tiebreaker_epa": 0.23 + }, + "8793": { + "auto_epa": 11.43, + "comp_0_epa": 8.44, + "comp_1_epa": 21.92, + "comp_2_epa": 9.1, + "comp_3_epa": 0.21, + "comp_4_epa": 0.6, + "comp_5_epa": 1.3, + "comp_6_epa": -0.11, + "comp_7_epa": -0.33, + "comp_8_epa": -0.69, + "comp_9_epa": 4.22, + "endgame_epa": 4.22, + "epa": 36.55, + "rp_1_epa": 0.5157, + "rp_2_epa": -0.0507, + "rp_3_epa": -0.0054, + "teleop_epa": 20.9, + "tiebreaker_epa": -0.03 + }, + "972": { + "auto_epa": 10.95, + "comp_0_epa": 7.97, + "comp_1_epa": 34.74, + "comp_2_epa": 1.33, + "comp_3_epa": 2.35, + "comp_4_epa": 2.88, + "comp_5_epa": 3.83, + "comp_6_epa": -0.32, + "comp_7_epa": -0.95, + "comp_8_epa": 3.15, + "comp_9_epa": 9.35, + "endgame_epa": 9.35, + "epa": 57.24, + "rp_1_epa": 0.4511, + "rp_2_epa": 0.1332, + "rp_3_epa": 0.5294, + "teleop_epa": 36.94, + "tiebreaker_epa": -0.04 + } + }, + "epas": { + "2204": { + "auto_epa": 11.58, + "comp_0_epa": 8.58, + "comp_1_epa": 24.57, + "comp_2_epa": 0.6, + "comp_3_epa": 1.3, + "comp_4_epa": 1.55, + "comp_5_epa": 3.88, + "comp_6_epa": -0.07, + "comp_7_epa": -0.21, + "comp_8_epa": 2.56, + "comp_9_epa": 1.6, + "endgame_epa": 1.6, + "epa": 40.11, + "rp_1_epa": 0.5526, + "rp_2_epa": -0.1412, + "rp_3_epa": 0.0725, + "teleop_epa": 26.93, + "tiebreaker_epa": 0.0 + }, + "254": { + "auto_epa": 21.08, + "comp_0_epa": 17.62, + "comp_1_epa": 50.39, + "comp_2_epa": 2.26, + "comp_3_epa": 3.99, + "comp_4_epa": 4.69, + "comp_5_epa": 5.55, + "comp_6_epa": 0.28, + "comp_7_epa": 0.83, + "comp_8_epa": 9.01, + "comp_9_epa": 10.59, + "endgame_epa": 10.59, + "epa": 91.91, + "rp_1_epa": 0.582, + "rp_2_epa": 0.6194, + "rp_3_epa": 0.6968, + "teleop_epa": 60.24, + "tiebreaker_epa": 0.15 + }, + "4270": { + "auto_epa": 17.73, + "comp_0_epa": 14.74, + "comp_1_epa": 44.54, + "comp_2_epa": 2.11, + "comp_3_epa": 3.6, + "comp_4_epa": 4.47, + "comp_5_epa": 4.43, + "comp_6_epa": 0.65, + "comp_7_epa": 1.95, + "comp_8_epa": 5.41, + "comp_9_epa": 10.65, + "endgame_epa": 10.65, + "epa": 80.28, + "rp_1_epa": 0.4628, + "rp_2_epa": 0.4957, + "rp_3_epa": 0.5914, + "teleop_epa": 51.9, + "tiebreaker_epa": 0.12 + }, + "8033": { + "auto_epa": 21.09, + "comp_0_epa": 17.77, + "comp_1_epa": 40.94, + "comp_2_epa": 0.84, + "comp_3_epa": 2.94, + "comp_4_epa": 3.86, + "comp_5_epa": 5.54, + "comp_6_epa": 0.7, + "comp_7_epa": 2.11, + "comp_8_epa": 5.13, + "comp_9_epa": 8.0, + "endgame_epa": 8.0, + "epa": 77.26, + "rp_1_epa": 0.6001, + "rp_2_epa": 0.4814, + "rp_3_epa": 0.539, + "teleop_epa": 48.17, + "tiebreaker_epa": 0.23 + }, + "8793": { + "auto_epa": 11.58, + "comp_0_epa": 8.59, + "comp_1_epa": 22.39, + "comp_2_epa": 9.14, + "comp_3_epa": 0.22, + "comp_4_epa": 0.7, + "comp_5_epa": 1.3, + "comp_6_epa": -0.12, + "comp_7_epa": -0.35, + "comp_8_epa": -0.56, + "comp_9_epa": 4.18, + "endgame_epa": 4.18, + "epa": 37.23, + "rp_1_epa": 0.5157, + "rp_2_epa": -0.0507, + "rp_3_epa": -0.0054, + "teleop_epa": 21.47, + "tiebreaker_epa": -0.04 + }, + "972": { + "auto_epa": 11.1, + "comp_0_epa": 8.13, + "comp_1_epa": 35.2, + "comp_2_epa": 1.37, + "comp_3_epa": 2.36, + "comp_4_epa": 2.99, + "comp_5_epa": 3.84, + "comp_6_epa": -0.32, + "comp_7_epa": -0.96, + "comp_8_epa": 3.28, + "comp_9_epa": 9.32, + "endgame_epa": 9.32, + "epa": 57.93, + "rp_1_epa": 0.4511, + "rp_2_epa": 0.1332, + "rp_3_epa": 0.5294, + "teleop_epa": 37.51, + "tiebreaker_epa": -0.04 + } + } + } +] diff --git a/test/fixtures/team_events_by_event.json b/test/fixtures/team_events_by_event.json new file mode 100644 index 0000000..883b1b1 --- /dev/null +++ b/test/fixtures/team_events_by_event.json @@ -0,0 +1,162 @@ +[ + { + "team": 199, + "year": 2025, + "event": "2025cabe", + "time": 1743652800, + "team_name": "Deep Blue", + "event_name": "East Bay Regional", + "country": "USA", + "state": "CA", + "district": null, + "type": "regional", + "week": 6, + "status": "Completed", + "first_event": false, + "epa": { + "total_points": 25.32, + "unitless": 1484.0, + "norm": 1494.0, + "breakdown": { + "total_points": 25.32, + "auto_points": 6.1, + "teleop_points": 18.23, + "endgame_points": 1.0, + "auto_rp": 0.1599, + "coral_rp": -0.0579, + "barge_rp": 0.0766, + "tiebreaker_points": 0.0, + "auto_coral_points": 3.35, + "teleop_coral_points": 15.14, + "coral_l1": 1.1, + "coral_l2": 0.84, + "coral_l3": 0.6, + "coral_l4": 2.08, + "processor_algae": 0.07, + "processor_algae_points": 0.21, + "net_algae_points": 2.88, + "barge_points": 1.0, + "rp_1": 0.1599, + "rp_2": -0.0579, + "rp_3": 0.0766, + "total_coral_points": 18.490000000000002, + "total_algae_points": 3.09 + }, + "stats": { + "start": 16.95, + "pre_elim": 26.84, + "mean": 23.11, + "max": 26.84 + } + }, + "record": { + "qual": { + "wins": 7, + "losses": 2, + "ties": 0, + "count": 9, + "winrate": 0.7778, + "rps": 37, + "rps_per_match": 4.1111, + "rank": 8, + "num_teams": 55 + }, + "elim": { + "wins": 0, + "losses": 1, + "ties": 0, + "count": 1, + "winrate": 0.0, + "alliance": null, + "is_captain": null + }, + "total": { + "wins": 7, + "losses": 3, + "ties": 0, + "count": 10, + "winrate": 0.7 + } + } + }, + { + "team": 253, + "year": 2025, + "event": "2025cabe", + "time": 1743652800, + "team_name": "Boba Bots", + "event_name": "East Bay Regional", + "country": "USA", + "state": "CA", + "district": null, + "type": "regional", + "week": 6, + "status": "Completed", + "first_event": false, + "epa": { + "total_points": 23.53, + "unitless": 1472.0, + "norm": 1485.0, + "breakdown": { + "total_points": 23.53, + "auto_points": 3.01, + "teleop_points": 17.9, + "endgame_points": 2.62, + "auto_rp": 0.1382, + "coral_rp": -0.022, + "barge_rp": 0.0387, + "tiebreaker_points": 0.12, + "auto_coral_points": 0.27, + "teleop_coral_points": 15.32, + "coral_l1": 0.35, + "coral_l2": 1.14, + "coral_l3": 1.2, + "coral_l4": 1.32, + "processor_algae": 0.26, + "processor_algae_points": 0.78, + "net_algae_points": 1.81, + "barge_points": 2.62, + "rp_1": 0.1382, + "rp_2": -0.022, + "rp_3": 0.0387, + "total_coral_points": 15.59, + "total_algae_points": 2.59 + }, + "stats": { + "start": 11.45, + "pre_elim": 21.95, + "mean": 16.26, + "max": 21.95 + } + }, + "record": { + "qual": { + "wins": 5, + "losses": 4, + "ties": 0, + "count": 9, + "winrate": 0.5556, + "rps": 28, + "rps_per_match": 3.1111, + "rank": 25, + "num_teams": 55 + }, + "elim": { + "wins": 0, + "losses": 0, + "ties": 0, + "count": 0, + "winrate": 0, + "alliance": null, + "is_captain": null + }, + "total": { + "wins": 5, + "losses": 4, + "ties": 0, + "count": 9, + "winrate": 0.5556 + } + } + } +] diff --git a/test/fixtures/team_events_by_team.json b/test/fixtures/team_events_by_team.json new file mode 100644 index 0000000..f62b2fb --- /dev/null +++ b/test/fixtures/team_events_by_team.json @@ -0,0 +1,162 @@ +[ + { + "team": 254, + "year": 2025, + "event": "2025cabe", + "time": 1743652800, + "team_name": "The Cheesy Poofs", + "event_name": "East Bay Regional", + "country": "USA", + "state": "CA", + "district": null, + "type": "regional", + "week": 6, + "status": "Completed", + "first_event": false, + "epa": { + "total_points": 91.91, + "unitless": 1936.0, + "norm": 1835.0, + "breakdown": { + "total_points": 91.91, + "auto_points": 21.08, + "teleop_points": 60.24, + "endgame_points": 10.59, + "auto_rp": 0.582, + "coral_rp": 0.6194, + "barge_rp": 0.6968, + "tiebreaker_points": 0.15, + "auto_coral_points": 17.62, + "teleop_coral_points": 50.39, + "coral_l1": 2.26, + "coral_l2": 3.99, + "coral_l3": 4.69, + "coral_l4": 5.55, + "processor_algae": 0.28, + "processor_algae_points": 0.83, + "net_algae_points": 9.01, + "barge_points": 10.59, + "rp_1": 0.582, + "rp_2": 0.6194, + "rp_3": 0.6968, + "total_coral_points": 68.01, + "total_algae_points": 9.84 + }, + "stats": { + "start": 82.52, + "pre_elim": 89.48, + "mean": 87.45, + "max": 91.01 + } + }, + "record": { + "qual": { + "wins": 9, + "losses": 0, + "ties": 0, + "count": 9, + "winrate": 1.0, + "rps": 53, + "rps_per_match": 5.8889, + "rank": 1, + "num_teams": 55 + }, + "elim": { + "wins": 5, + "losses": 0, + "ties": 0, + "count": 5, + "winrate": 1.0, + "alliance": null, + "is_captain": null + }, + "total": { + "wins": 14, + "losses": 0, + "ties": 0, + "count": 14, + "winrate": 1.0 + } + } + }, + { + "team": 254, + "year": 2025, + "event": "2025cada", + "time": 1741924800, + "team_name": "The Cheesy Poofs", + "event_name": "Sacramento Regional", + "country": "USA", + "state": "CA", + "district": null, + "type": "regional", + "week": 3, + "status": "Completed", + "first_event": true, + "epa": { + "total_points": 82.52, + "unitless": 1872.0, + "norm": 1776.0, + "breakdown": { + "total_points": 82.52, + "auto_points": 19.69, + "teleop_points": 51.86, + "endgame_points": 10.97, + "auto_rp": 0.5173, + "coral_rp": 0.349, + "barge_rp": 0.5737, + "tiebreaker_points": 0.15, + "auto_coral_points": 15.91, + "teleop_coral_points": 44.46, + "coral_l1": 0.83, + "coral_l2": 2.3, + "coral_l3": 4.05, + "coral_l4": 6.21, + "processor_algae": 0.2, + "processor_algae_points": 0.61, + "net_algae_points": 6.79, + "barge_points": 10.97, + "rp_1": 0.5173, + "rp_2": 0.349, + "rp_3": 0.5737, + "total_coral_points": 60.370000000000005, + "total_algae_points": 7.4 + }, + "stats": { + "start": 55.99, + "pre_elim": 79.59, + "mean": 72.31, + "max": 82.63 + } + }, + "record": { + "qual": { + "wins": 10, + "losses": 2, + "ties": 0, + "count": 12, + "winrate": 0.8333, + "rps": 58, + "rps_per_match": 4.8333, + "rank": 2, + "num_teams": 34 + }, + "elim": { + "wins": 5, + "losses": 0, + "ties": 0, + "count": 5, + "winrate": 1.0, + "alliance": null, + "is_captain": null + }, + "total": { + "wins": 15, + "losses": 2, + "ties": 0, + "count": 17, + "winrate": 0.8824 + } + } + } +] diff --git a/test/fixtures/team_years_by_team.json b/test/fixtures/team_years_by_team.json new file mode 100644 index 0000000..4df166c --- /dev/null +++ b/test/fixtures/team_years_by_team.json @@ -0,0 +1,104 @@ +[ + { + "team": 254, + "year": 2002, + "name": "The Cheesy Poofs", + "country": "USA", + "state": "CA", + "district": null, + "rookie_year": 1999, + "epa": { + "total_points": 16.6, + "unitless": 1657.0, + "norm": 1691.0, + "breakdown": { + "total_points": 16.6 + }, + "stats": { + "start": 11.96, + "pre_champs": 16.6, + "max": 16.6 + }, + "ranks": { + "total": { + "rank": 30, + "percentile": 0.9532, + "team_count": 641 + }, + "country": { + "rank": 30, + "percentile": 0.9511, + "team_count": 613 + }, + "state": { + "rank": 1, + "percentile": 0.9868, + "team_count": 76 + }, + "district": { + "rank": 30, + "percentile": 0.9532, + "team_count": 641 + } + } + }, + "record": { + "wins": 18, + "losses": 6, + "ties": 1, + "count": 25, + "winrate": 0.74 + } + }, + { + "team": 254, + "year": 2003, + "name": "The Cheesy Poofs", + "country": "USA", + "state": "CA", + "district": null, + "rookie_year": 1999, + "epa": { + "total_points": 40.22, + "unitless": 1692.0, + "norm": 1705.0, + "breakdown": { + "total_points": 40.22 + }, + "stats": { + "start": 30.63, + "pre_champs": 39.23, + "max": 44.93 + }, + "ranks": { + "total": { + "rank": 31, + "percentile": 0.9605, + "team_count": 785 + }, + "country": { + "rank": 31, + "percentile": 0.9582, + "team_count": 741 + }, + "state": { + "rank": 2, + "percentile": 0.9783, + "team_count": 92 + }, + "district": { + "rank": 31, + "percentile": 0.9605, + "team_count": 785 + } + } + }, + "record": { + "wins": 29, + "losses": 17, + "ties": 0, + "count": 46, + "winrate": 0.6304 + } + } +] diff --git a/test/fixtures/teams.json b/test/fixtures/teams.json new file mode 100644 index 0000000..75f9e4e --- /dev/null +++ b/test/fixtures/teams.json @@ -0,0 +1,48 @@ +[ + { + "team": 1, + "name": "The Juggernauts", + "country": "USA", + "state": "MI", + "district": "fim", + "rookie_year": 1997, + "active": true, + "last_active_year": 2026, + "record": { + "wins": 342, + "losses": 356, + "ties": 21, + "count": 719, + "winrate": 0.4903 + }, + "norm_epa": { + "current": 1432.0, + "recent": 1523.0, + "mean": 1552.0, + "max": 1686.0 + } + }, + { + "team": 4, + "name": "Team 4 ELEMENT", + "country": "USA", + "state": "CA", + "district": "ca", + "rookie_year": 1997, + "active": true, + "last_active_year": 2026, + "record": { + "wins": 243, + "losses": 270, + "ties": 15, + "count": 528, + "winrate": 0.4744 + }, + "norm_epa": { + "current": 1604.0, + "recent": 1564.0, + "mean": 1511.0, + "max": 1604.0 + } + } +] diff --git a/test/statbotics_client_test.dart b/test/statbotics_client_test.dart index 7e2cb66..2654258 100644 --- a/test/statbotics_client_test.dart +++ b/test/statbotics_client_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:test/test.dart'; import 'package:http/http.dart' as http; @@ -98,13 +99,12 @@ void main() { 'event': '2026mrcmp', 'event_name': 'Mid-Atlantic Championship', 'year': 2026, - 'wins': 8, - 'losses': 4, - 'ties': 0, - 'rank': 2, - 'num_teams': 40, + 'record': { + 'qual': {'rank': 2, 'num_teams': 40}, + 'total': {'wins': 8, 'losses': 4, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 42.1, 'sd': 3.0}, + 'total_points': 42.1, }, }, { @@ -112,13 +112,12 @@ void main() { 'event': '2026mrcmp', 'event_name': 'Mid-Atlantic Championship', 'year': 2026, - 'wins': 10, - 'losses': 2, - 'ties': 0, - 'rank': 1, - 'num_teams': 40, + 'record': { + 'qual': {'rank': 1, 'num_teams': 40}, + 'total': {'wins': 10, 'losses': 2, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 48.5, 'sd': 2.8}, + 'total_points': 48.5, }, }, ]), @@ -134,7 +133,7 @@ void main() { // Sorted by rank ascending: rank 1 first. expect(teams[0].team, 1234); expect(teams[0].rank, 1); - expect(teams[0].epa.totalPointsMean, closeTo(48.5, 0.01)); + expect(teams[0].epa.totalPoints, closeTo(48.5, 0.01)); expect(teams[0].record, '10-2'); expect(teams[1].team, 2714); expect(teams[1].rank, 2); @@ -157,9 +156,9 @@ void main() { 'event_name': 'Houston', 'team_name': 'Spectrum', 'year': 2026, - 'wins': 1, - 'losses': 0, - 'ties': 0, + 'record': { + 'total': {'wins': 1, 'losses': 0, 'ties': 0}, + }, 'epa': {}, }); expect(te.teamName, 'Spectrum'); @@ -170,9 +169,9 @@ void main() { 'team': 3847, 'event': '2026txhou', 'year': 2026, - 'wins': 0, - 'losses': 0, - 'ties': 0, + 'record': { + 'total': {'wins': 0, 'losses': 0, 'ties': 0}, + }, 'epa': {}, }); expect(te.teamName, isEmpty); @@ -190,14 +189,15 @@ void main() { 'event_name': 'Houston', 'team_name': 'Spectrum', 'year': 2026, - 'wins': 1, - 'losses': 0, - 'ties': 0, - 'rank': 7, - 'num_teams': 42, + 'record': { + 'qual': {'rank': 7, 'num_teams': 42}, + 'total': {'wins': 1, 'losses': 0, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 41.2, 'sd': 2.9}, - 'auto_points': {'mean': 9.5}, + 'total_points': 41.2, + 'unitless': 1710.0, + 'norm': 1655.0, + 'breakdown': {'auto_points': 9.5}, }, }); final restored = StatboticsTeamEvent.fromJson(original.toJson()); @@ -211,8 +211,8 @@ void main() { expect(restored.ties, 0); expect(restored.rank, 7); expect(restored.numTeams, 42); - expect(restored.epa.totalPointsMean, closeTo(41.2, 0.01)); - expect(restored.epa.autoPointsMean, closeTo(9.5, 0.01)); + expect(restored.epa.totalPoints, closeTo(41.2, 0.01)); + expect(restored.epa.autoPoints, closeTo(9.5, 0.01)); }); test('StatboticsTeamEvent.toJson round-trips an empty team_name', () { @@ -221,9 +221,9 @@ void main() { 'event': '2026x', 'event_name': 'X', 'year': 2026, - 'wins': 0, - 'losses': 0, - 'ties': 0, + 'record': { + 'total': {'wins': 0, 'losses': 0, 'ties': 0}, + }, 'epa': {}, }); final restored = StatboticsTeamEvent.fromJson(original.toJson()); @@ -236,9 +236,9 @@ void main() { 'event': 'test', 'event_name': 'Test', 'year': 2026, - 'wins': 6, - 'losses': 3, - 'ties': 0, + 'record': { + 'total': {'wins': 6, 'losses': 3, 'ties': 0}, + }, 'epa': {}, }); expect(te.record, '6-3'); @@ -250,9 +250,9 @@ void main() { 'event': 'test', 'event_name': 'Test', 'year': 2026, - 'wins': 5, - 'losses': 3, - 'ties': 1, + 'record': { + 'total': {'wins': 5, 'losses': 3, 'ties': 1}, + }, 'epa': {}, }); expect(te.record, '5-3-1'); @@ -264,12 +264,12 @@ void main() { 'event': 'test', 'event_name': 'Test', 'year': 2026, - 'wins': 0, - 'losses': 0, - 'ties': 0, + 'record': { + 'total': {'wins': 0, 'losses': 0, 'ties': 0}, + }, 'epa': {}, }); - expect(te.epa.totalPointsMean, isNull); + expect(te.epa.totalPoints, isNull); }); test('StatboticsMatch parses team_keys from alliances', () { @@ -571,13 +571,12 @@ void main() { 'event_name': 'Cal Games', 'team_name': 'The Cheesy Poofs', 'year': 2024, - 'wins': 9, - 'losses': 1, - 'ties': 0, - 'rank': 1, - 'num_teams': 40, + 'record': { + 'qual': {'rank': 1, 'num_teams': 40}, + 'total': {'wins': 9, 'losses': 1, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 55.0, 'sd': 2.0}, + 'total_points': 55.0, }, }, { @@ -586,13 +585,12 @@ void main() { 'event_name': 'Cal Games', 'team_name': 'The Cheesy Poofs', 'year': 2023, - 'wins': 8, - 'losses': 2, - 'ties': 0, - 'rank': 2, - 'num_teams': 40, + 'record': { + 'qual': {'rank': 2, 'num_teams': 40}, + 'total': {'wins': 8, 'losses': 2, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 50.0, 'sd': 2.5}, + 'total_points': 50.0, }, }, { @@ -601,13 +599,12 @@ void main() { 'event_name': 'Austin', 'team_name': 'The Cheesy Poofs', 'year': 2024, - 'wins': 7, - 'losses': 3, - 'ties': 0, - 'rank': 3, - 'num_teams': 38, + 'record': { + 'qual': {'rank': 3, 'num_teams': 38}, + 'total': {'wins': 7, 'losses': 3, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 52.0, 'sd': 2.2}, + 'total_points': 52.0, }, }, ]), @@ -657,4 +654,183 @@ void main() { expect(history, isEmpty); }); }); + + fixtureTests(); +} + +/// Decodes a captured response body from `test/fixtures/`. +/// +/// These are real `api.statbotics.io/v3` bodies, saved verbatim on +/// 2026-09-02. The hand-written maps above are readable but they are also +/// the author's belief about the API, and a model can satisfy every one of +/// them while failing on the real thing: that is how the client came to read +/// `epa.total_points` as a `{mean, sd}` object it has never been. Assert +/// against a captured body, and a shape change fails a test instead of +/// reaching a device. +/// +/// Refresh one with, for example: +/// `curl -s 'https://api.statbotics.io/v3/team_years?team=254&limit=2'` +Object _fixture(String name) { + return jsonDecode(File('test/fixtures/$name.json').readAsStringSync()); +} + +List> _fixtureList(String name) { + return (_fixture(name) as List) + .map((e) => (e as Map).cast()) + .toList(growable: false); +} + +/// Every model, run against a captured live body rather than a hand-written +/// one. +void fixtureTests() { + group('captured live responses', () { + test('StatboticsTeamEvent decodes a real /team_events row', () { + // Row order is the API's own and is not by rank: getEventTeams applies + // that sort itself. + final rows = _fixtureList('team_events_by_event'); + final te = StatboticsTeamEvent.fromJson(rows.first); + + // Pinned to the values in the captured body, not just non-null: a + // field read from the wrong but still populated place passes an + // isNotNull check. Team 199 at the 2025 East Bay Regional. + expect(te.team, 199); + expect(te.event, '2025cabe'); + expect(te.eventName, 'East Bay Regional'); + expect(te.teamName, 'Deep Blue'); + expect(te.year, 2025); + // The regression this file exists to catch: every one of these reads a + // nested or bare field the client previously looked for in the wrong + // place, so each would be null or zero against a live body. + expect(te.epa.totalPoints, closeTo(25.32, 0.01)); + expect(te.epa.autoPoints, closeTo(6.1, 0.01)); + expect(te.epa.teleopPoints, closeTo(18.23, 0.01)); + expect(te.epa.endgamePoints, closeTo(1.0, 0.01)); + expect(te.epa.unitless, closeTo(1484.0, 0.01)); + expect(te.epa.norm, closeTo(1494.0, 0.01)); + // record.qual, not top level. + expect(te.rank, 8); + expect(te.numTeams, 55); + // record.total, so elims count too: 7-3 overall. + expect(te.record, '7-3'); + }); + + test('a real /team_events row survives the cache round-trip', () { + final original = StatboticsTeamEvent.fromJson( + _fixtureList('team_events_by_event').first, + ); + final restored = StatboticsTeamEvent.fromJson(original.toJson()); + + expect(restored.team, original.team); + expect(restored.teamName, original.teamName); + expect(restored.eventName, original.eventName); + expect(restored.record, original.record); + expect(restored.rank, original.rank); + expect(restored.numTeams, original.numTeams); + expect(restored.epa.totalPoints, original.epa.totalPoints); + expect(restored.epa.autoPoints, original.epa.autoPoints); + expect(restored.epa.teleopPoints, original.epa.teleopPoints); + expect(restored.epa.endgamePoints, original.epa.endgamePoints); + }); + + test('a team-filtered /team_events row decodes the same way', () { + final rows = _fixtureList('team_events_by_team'); + final te = StatboticsTeamEvent.fromJson(rows.first); + + expect(te.team, 254); + expect(te.event, '2025cabe'); + expect(te.eventName, 'East Bay Regional'); + expect(te.epa.totalPoints, closeTo(91.91, 0.01)); + expect(te.rank, 1); + }); + + test('StatboticsTeamYear decodes a real /team_years row', () { + final rows = _fixtureList('team_years_by_team'); + final ty = StatboticsTeamYear.fromJson(rows.first); + + // 254's first season in the captured body, values pinned. + expect(ty.team, 254); + expect(ty.year, 2002); + expect(ty.name, 'The Cheesy Poofs'); + // /team_years reports the record flat, unlike /team_events. + expect(ty.record, '18-6-1'); + expect(ty.epa.totalPoints, closeTo(16.6, 0.01)); + expect(ty.epa.unitless, closeTo(1657.0, 0.01)); + expect(ty.epa.norm, closeTo(1691.0, 0.01)); + // epa.ranks.total, two levels down. + expect(ty.epaRank, 30); + expect(ty.epaRankTeamCount, 641); + }); + + test('a real /team_years row survives the cache round-trip', () { + final original = StatboticsTeamYear.fromJson( + _fixtureList('team_years_by_team').first, + ); + final restored = StatboticsTeamYear.fromJson(original.toJson()); + + expect(restored.team, original.team); + expect(restored.year, original.year); + expect(restored.name, original.name); + expect(restored.record, original.record); + expect(restored.epaRank, original.epaRank); + expect(restored.epaRankTeamCount, original.epaRankTeamCount); + expect(restored.epa.totalPoints, original.epa.totalPoints); + expect(restored.epa.unitless, original.epa.unitless); + expect(restored.epa.norm, original.epa.norm); + }); + + test('an early season has no per-phase breakdown and reads as null', () { + // 2002 predates the auto/teleop/endgame split, so breakdown carries + // total_points alone. Nullable fields, not zeros. + final early = _fixtureList('team_years_by_team') + .map(StatboticsTeamYear.fromJson) + .firstWhere((ty) => ty.year < 2010); + + expect(early.epa.totalPoints, isNotNull); + expect(early.epa.autoPoints, isNull); + expect(early.epa.teleopPoints, isNull); + expect(early.epa.endgamePoints, isNull); + }); + + test('StatboticsMatch decodes a real /matches row', () { + final rows = _fixtureList('matches_by_event'); + final match = StatboticsMatch.fromJson(rows.first); + + expect(match.key, startsWith('2025cabe_')); + expect(match.event, '2025cabe'); + expect(match.compLevel, isNotEmpty); + expect(match.redTeams, hasLength(3)); + expect(match.blueTeams, hasLength(3)); + expect(match.allTeams.every((t) => t > 0), isTrue); + }); + + test('StatboticsEvent decodes a real /events row', () { + final rows = _fixtureList('events_by_year'); + final event = StatboticsEvent.fromJson(rows.first); + + expect(event.key, isNotEmpty); + expect(event.name, isNotEmpty); + expect(event.year, 2025); + expect(event.week, isNotNull); + expect(event.startDate, isNotNull); + expect(event.endDate, isNotNull); + }); + + test('StatboticsEvent decodes a real /event/{key} body', () { + final event = StatboticsEvent.fromJson( + (_fixture('event') as Map).cast(), + ); + + expect(event.key, '2025cabe'); + expect(event.name, isNotEmpty); + expect(event.year, 2025); + }); + + test('StatboticsTeamBasic decodes a real /teams row', () { + final rows = _fixtureList('teams'); + final team = StatboticsTeamBasic.fromJson(rows.first); + + expect(team.team, greaterThan(0)); + expect(team.nickname, isNotEmpty); + }); + }); }