Changelog
1.0.0
Added a work-in-progress notice to the top of the docs homepage (wa-setpieces) and the README: this package is under active development with heavy updates expected in the near future, and a contact address (marc@waltzinganalytics.com) for feedback or trouble. No functional changes. Docs build clean.
0.31.3
Doc-accuracy pass, prompted by the user asking to check every page of the
published docs for stale or invalid text after 0.31.1/0.31.2’s loading-API
changes. Read every hand-written page (index, installation,
quickstart, categories, by_metric, by_phase, by_report,
by_routine, value_models) against the current code – every
function/class reference, code example and CLI invocation checked
individually against its actual signature or argparse definition.
Fixed: the
wa-setpiecesCLI’s own--helptext (summary/workflow/report’sinputargument, andseason’sinputsargument) still described the pre-0.31.1 behaviour – “*.json for opta/statsbomb, *.csv for impect” and “*.json/*.csv per --provider” – directly contradicting howload_matchesactually resolves a folder’s files today (every*.jsonand*.csvfile, regardless of provider). The underlying loading code was already correct; only the help strings were stale. Updated both to describe the real behaviour.All nine hand-written docs pages otherwise verified accurate as-is: every
:func:/:class:reference resolves to a real symbol, every code example matches its function’s real signature, every CLI example matchescli.py’s actual argument definitions. No changes needed there.382 tests still passing, docs build clean.
0.31.2
Follow-up to 0.31.1, prompted directly by user feedback: “every provider
can be json or csv.” 0.31.1 made the type="season" folder scan
extension-agnostic; this release makes each provider’s own parser
extension-agnostic too, so a single type="match" file works either
way regardless of which format that provider natively ships as.
Changed:
load_events()(Opta) now accepts a.csvpath in addition to native.json– read as-is, since a.csvhere always means a previously-exported round-trip of this same internal events shape (e.g.match.events.to_csv(...)from an earlier run), never a different Opta feed format.match_detailsis{}for a CSV source, since a CSV round-trip carries nomatchDetailsblock.Changed:
wa_setpieces.providers.statsbomb.load_statsbomb_events()now accepts a.csvpath the same way – StatsBomb open data itself is JSON-only, so a.csvsource always means “already converted,” read directly rather than run through the StatsBomb-specific event conversion.Changed:
wa_setpieces.providers.impect.load_impect_events()now accepts a.jsonpath in addition to native.csv– a JSON-encoded version of the same raw IMPECT record shape (e.g.raw_df.to_json(orient="records")), handled identically to the CSV path from that point on.Verified round-trip safe against real data before writing any of the above, not assumed: an Opta match’s events written to CSV and reread produce identical
delivery_locations()/set_piece_summary()output to the original JSON-parsed frame (despite someq_<id>columns relabelingobject->float64through the round-trip – harmless, since every value that matters goes throughpandas.to_numeric()downstream regardless); a real IMPECT export’s raw records written to JSON and reread produce identical output to the CSV-sourced version (only an unused passthroughidcolumn’s dtype label differs).Docstrings (module-level and per-function, in
core/loader.py,providers/statsbomb.pyandproviders/impect.py) and the Installation page updated to describe the round-trip acceptance.4 new tests (382 total): Opta CSV round-trip via both
load_events()andload_matches(), StatsBomb CSV round-trip, IMPECT JSON round-trip. Docs build clean.
0.31.1
Follow-up to 0.31.0, prompted directly by user feedback on the new
type= parameter: “all load matches can be any of them, all seasons
can be any of them, and they all can be csv or json.”
Changed:
load_matches()no longer gates atype="season"folder scan to one fixed extension per provider (*.jsonfor Opta/StatsBomb,*.csvfor IMPECT). It now scans for every*.jsonand*.csvfile together, regardless ofprovider– the file extension never decides which files are even considered;provideralone decides whether a given file’s content parses. A folder can mix extensions freely; anything that doesn’t parse under the chosen provider is skipped with the sameUserWarningmechanism 0.31.0 already added formatches.json-style sidecar files, not filtered out silently by extension up front.type="match"was already extension-agnostic (a singlesourcefile was always handed straight to the provider’s loader, whatever its extension) – this release makestype="season"consistent with that, rather than changingtype="match"itself.Docstrings and the Installation page updated to describe the provider/extension relationship accurately.
1 new test (378 total): a mixed-extension season folder where the wrong-provider file gets skipped, not excluded from the scan up front. Docs build clean.
0.31.0
Two changes prompted by actually running the new loading API against a full real season, not synthetic test fixtures: an API redesign for consistency, and a real bug the redesign’s own verification run caught.
Changed:
load_matches()gained an explicittype: str = "match"parameter ("match"or"season", case-insensitive) instead of silently inferring “single file” versus “folder” from whethersourcehappens to be a directory. Every call now has the same two keywords regardless of provider or scope –load_matches("match.json", provider="opta", type="match"),load_matches("season/", provider="statsbomb", type="season")– and passing a directory withtype="match"(or a file withtype="season") raisesValueErrorinstead of silently doing the other thing. This is a breaking change for any code relying on the old auto-detection with a directorysource– addtype="season"explicitly.Fixed a real bug, caught while re-verifying the above against a real ~240-match StatsBomb season folder: the folder also contained a
matches.json(StatsBomb’s own match-list metadata file, not an events export) which matched the*.jsonglob and got parsed as if it were 242 events, silently corrupting the combined frame with garbage rows (id/eventIdempty,typeId0) untilvalidate_eventsrejected it several steps downstream with a confusing “column ‘eventId’ contains non-numeric values” error nowhere near the actual cause. Fixed at the source (load_statsbomb_events()now validates its input actually looks like an events list – hasidandtypefields – before parsing it) and at the call site (load_matches()catches a bad file per-source during atype="season"scan, skips it with aUserWarningnaming it and why, and keeps loading the rest of the folder, rather than either corrupting the frame or aborting over one bad file).The CLI is unaffected – its own file-or-folder auto-detection on
input/inputsis unchanged; it now resolves the explicittype=internally before callingload_matches().examples/load_matches_demo.ipynb– a new notebook exercising all three providers (Opta bundled sample, a real StatsBomb match, a real ~240-match StatsBomb season folder, a real 20-match IMPECT export) end to end, actually executed against real data rather than just written – is what caught thematches.jsonbug above in the first place.Every
provider=/type=example across wa-setpieces, Installation, Quickstart and the README updated to show both keywords explicitly and consistently.7 new tests (377 total, up from 370): explicit
typevalidation, thematches.json-skip behavior, and the underlyingproviders.statsbombinput-shape check. Docs build clean.
0.30.2
Second follow-up to 0.30.0: 0.30.1 fixed the gallery, README and
index/quickstart; this sweep found the rest – the six pick-one
reference pages (By set piece, By value model, By metric,
By phase and outcome, By routine, By report and export), which all still
showed events implicitly loaded via the old match.events pattern
in their code samples.
All six reference pages now consistently assume
events = load_matches(...), matching Quickstart.Two runnable example scripts also swept:
examples/analyse_opta.pyand its pairedexamples/analyse_opta.ipynbnow useload_matches()(neither used thematchDetailsblock, so there was no reason to keep the lower-level function) – the notebook was actually re-executed, not just text-edited, so its saved outputs (including the now-presentmatchIdcolumn in the events preview) reflect what the new code really produces, not a stale cell result.examples/quickstart.pyandexamples/full_walkthrough.ipynbwere deliberately left onload_events()– both genuinely readmatch.match_details(kickoff status, final score), whichload_matches()doesn’t provide, soload_events()is the correct call there, not a leftover.Docs build clean (zero warnings), 370 tests unchanged – text and example code only, no library behavior changed.
0.30.1
Follow-up to 0.30.0: made load_matches() the
example code actually shows, not just a function that exists alongside
the old pattern.
Every gallery example (all 39 scripts), the README, and the docs’
index/quickstartpages now load withevents = load_matches(DATA)instead ofmatch = load_events(DATA)/match.events– the pattern the rest of this changelog has been telling people to prefer since 0.30.0, now actually followed everywhere it’s demonstrated.load_events()(returning thematchDetailsblock alongside events) is still documented as the lower-level alternative for when that block is actually needed.Caught and fixed two stale example outputs while touching this code, the same kind of drift found in the 0.29.1 docs review: the README’s sample
set_piece_summary()table showed ashotscolumn of 1 for two rows where a fresh run against the same sample match gives 0 throughout.No functional changes –
load_events()andload_events_multi()are unchanged and still exported; this is example code and documentation only.Docs build clean (39/39 gallery scripts, zero warnings), 370 tests unchanged.
0.30.0
A unified, provider-based loading entry point plus a new IMPECT converter – built and verified against two real exports the user provided (a StatsBomb open-data match and a 20-match IMPECT CSV, iteration 2128), not synthetic guesses at either schema.
Added:
load_matches()– the one loading entry point for any provider (provider="opta"default,"statsbomb"or"impect") and either a single match file or a whole folder of them (a season):load_matches("match.json"),load_matches("season/", provider="statsbomb"),load_matches("impect_export.csv", provider="impect"). Always returns one combined events DataFrame with amatchIdcolumn, the same shapeload_events_multi()produces, ready forSeasonDatasetor any match-safe function in this package. Fails loudly (not silently) if two different files would resolve to the samematchId– same safety netload_events_multi()already had.Added:
wa_setpieces.providers.impect–load_impect_events()converts an IMPECT CSV export into the same internal events shape every other provider produces. Verified against a real 20-match, ~53k-event export end to end –set_piece_summary(),delivery_locations(),retention_rate(),second_phases(),link_set_piece_shots()andplot_delivery_map()all ran against the converted output and produced plausible results cross-checked against the raw export (e.g. a team’s real corner count for one match matched exactly). See the module docstring for exactly what’s mapped and what isn’t, including two coordinate/timing conventions confirmed by inspecting the real export rather than assumed:adjCoordinatesis already attacking-direction-normalized (rawcoordinatesflips sign at half-time;adjCoordinatesdoesn’t), and each period’sgameTime.gameTimeInSecrestarts at a(periodId - 1) * 10000offset.Two real bugs caught and fixed during that verification, not just polish: a KeyError when the optional
setPiece.id/setPiece.mainEventcolumns are absent from a stripped export (assist-chain linking now degrades to “unavailable” instead of crashing), and 423 events with nosquadId(video gaps, the final whistle, referee touches – confirmed exhaustive against the reference export) getting a nulleventIdthatvalidate_events()rejected outright – now dropped, since there’s no meaningful team-scoped identity for a teamless event anyway.Fixed a real bug in the CLI’s own new duplicate-matchId check, caught by running it against the real IMPECT export: the first version checked for duplicated
matchIdvalues across every row in the combined frame, which always fires since one match’smatchIdlegitimately repeats across all of that match’s own event rows – fixed to compare each input’s set of match IDs instead.Fixed an unrelated, adjacent bug found while touching this same file:
wa_setpieces.__version__was a hardcoded string that had drifted to"0.18.3"whilepyproject.tomlwas already at0.29.1– now read from the installed package’s own metadata viaimportlib.metadata, a single source of truth that can’t silently go stale again.The CLI’s
--providerchoices gainedimpecteverywhere they already hadopta/statsbomb; every command’s file argument now also accepts a folder (loaded and combined the same wayload_matchesdoes).wa_setpieces.providers.statsbomb.load_statsbomb_eventsand the CLI are otherwise unchanged –load_matchesis a new entry point in front of them, not a rewrite.wa-setpieces, Installation, Quickstart and By report and export updated for the new loading story.
20 new tests (370 total, up from 350) – synthetic IMPECT fixtures in the test suite itself (small, fast, no dependency on the external files this feature was verified against), plus regression tests for both bugs above. Docs build clean, zero warnings.
0.29.1
Docs content review – read every narrative page (index,
installation, quickstart, categories, value_models,
by_metric, by_phase, by_routine, by_report) end to end
and checked all four admonition boxes render correctly (they do). Found
and fixed three real inaccuracies, not just polish:
Fixed: Quickstart’s sample
set_piece_summary()output table was stale and incomplete – it showed ashotscolumn of 1 for two rows where the real output (re-run against the same sample match) is 0 throughout, and only showed one team’s 5 rows out of the 10 the function actually returns. Replaced with the current, complete output.Fixed: By set piece’ penalty section claimed “six placement zones” –
goal_placement()’s own docstring and code both say a 3x3 grid (nine zones); confirmed against the source before fixing the doc, not the other way around.Fixed: Installation’s “Not yet published to PyPI? Install from source instead” framing was stale – the package has been on PyPI for many versions – reworded to state the actual reason to install from source (unreleased changes, contributing).
Two accuracy improvements, not corrections: By phase and outcome’s Outcomes section now also points to
plot_outcome_flow()(built on exactly the data that section documents, previously unmentioned there); and By value model’s Added Value section now lists the five value-focused charts built on it (plot_set_piece_value_flow(),plot_value_waterfall,plot_value_distribution,plot_value_ridgeline,plot_value_boxplot) instead of none of them.Docs build clean (zero warnings,
-W --keep-going), 350 tests still passing (unchanged – doc content only, no code behavior touched).
0.29.0
Three more new chart forms – a delivery combination network, a histogram, and a box plot – none of which existed anywhere in the module before.
Added:
plot_delivery_combination_network()– a node-link graph of which player takes deliveries and which teammate usually wins the first contact, built fromclassify_phase()’s ownfirst_contact_*fields (scoped to contacts the delivering team itself won). The first node-link graph in this module – every other multi-entity chart here compares a fixed small set of teams or categories, not an open set of players connected by who-delivers-to-whom. Nodes sit evenly around a circle; edge width is pairing frequency, node size is total involvement.Added:
plot_metric_histogram()– a classic binned histogram of any continuous metric, with no smoothing assumption baked in the wayplot_value_distribution()’s violins andplot_value_ridgeline()’s KDE curves both have.Added:
plot_value_boxplot()– exact quartiles, median and outliers per set-piece type, for when the specific numbers matter more than the overall distribution shape.Three new gallery examples (
plot_37-plot_39); module docstring updated.5 new tests (350 total). Docs build clean (39/39 gallery scripts, zero warnings); every chart rendered and inspected directly, including verifying the combination network’s circular node layout actually traces a true circle (node size and label length made it look irregular at a glance, so this was checked against the computed positions directly, not just eyeballed).
0.28.0
Three more new chart forms – a stacked area timeline, a progress ring, and a first-half/second-half slope chart – none of which existed anywhere in the module before. Two real bugs caught and fixed during verification, not just visual polish.
Added:
plot_type_area_timeline()– set-piece attempts over match time, binned by type, as a stacked area chart – the mix shifting over the match, not individual events (plot_match_timeline()) or a single cumulative value (plot_set_piece_value_flow()). Caught a real bug in verification: two of the categorical palette colors are adjacent shades of orange, fine everywhere else they’re used but with no gap between touching stacked bands “throw in” and “goal kick” blended into one indistinguishable region – fixed with a stroke between every band, which guarantees a visible seam regardless of which two colors happen to land next to each other.Added:
plot_success_ring()– a single ratio as a circular progress ring, a different visual metaphor for the same jobplot_success_waffle()does with filled squares.Added:
plot_half_comparison_slope()– each set-piece type’s value in the first half versus the second, as a two-point connected line, colored by direction of change. Caught a real correctness issue in verification: a type with an unchanged value (first half == second half) was colored the same green as an improvement, which is a misleading claim – “flat” isn’t “better” – fixed with a third neutral color for exact ties.Three new gallery examples (
plot_34-plot_36); module docstring updated.7 new tests (345 total). Docs build clean (36/36 gallery scripts, zero warnings); every chart rendered and inspected directly, and both issues above were caught that way.
0.27.0
Three more new chart forms – zone bubbles, a KPI bullet chart, and a ridgeline – none of which existed anywhere in the module before.
Added:
plot_zone_bubble()– zone counts as size-encoded bubbles on the pitch, a more intuitive (if less precise for close counts) encoding of “how many” thanplot_zone_heatmap()’s color ramp. Each bubble carries its own count label so the trade-off costs nothing.Added:
plot_kpi_bullet()– a compact single-ratio bullet chart against qualitative poor/fair/good bands and an optional target tick, built for a dashboard strip of several stacked together. Caught and fixed a real spacing bug in verification: at the tight default figure height, the row label sat close enough to the header band that it read as part of the title rather than the chart body – fixed with a taller default figure and a tighter y-range.Added:
plot_value_ridgeline()– a joyplot of per-delivery added value (scipy.stats.gaussian_kde(), already a transitive dependency via mplsoccer), one smooth density curve per set-piece type, for a shape a violin’s mirrored outline shows less clearly. Each type needs >= 3 non-identical values or it’s silently skipped.Three new gallery examples (
plot_31-plot_33); module docstring updated.5 new tests (338 total). Docs build clean (33/33 gallery scripts, zero warnings); every chart rendered and inspected directly, and the bullet-chart spacing bug above was caught that way.
0.26.0
Three more new chart forms – mosaic, parallel coordinates, dumbbell – none of which existed anywhere in the module before. All three had a real legend-placement bug caught and fixed during verification, not just visual polish.
Added:
plot_type_outcome_mosaic()– a Marimekko-style mosaic, segment width = volume (attempts), segment height = quality (success rate), so both are visible in one chart instead of split acrossplot_team_comparison()’s bars andplot_volume_quality_scatter()’s points. Caught two real bugs in verification: the legend landed directly on top of a data segment (fixed by anchoring it above the mosaic body instead of an in-bounds corner), and the sample match’s two smallest segments (1-2 attempts) had their type-name labels run into each other (fixed with a minimum-width threshold below which a label rotates 45° and a percentage inside the segment is skipped rather than overlapping).Added:
plot_team_parallel_coordinates()– the same two-team profile comparison asplot_set_piece_radar(), on straight parallel axes instead of a circle, so every metric carries equal visual weight instead of however much a polar layout happens to exaggerate or compress it.Added:
plot_team_dumbbell()– two teams’ values per category as connected dot pairs, for when the gap between two series is the thing worth seeing directly. Caught a real bug: an autoloc="best"legend placement landed on top of the widest-value row; fixed not by anchoring the legend outside the axes (tried first – it fought the header’s own top-margin reservation and collided with the title on a short figure instead) but by padding the y-axis to create dedicated empty space above the topmost category, which stays clear regardless of figure height.Three new gallery examples (
plot_28-plot_30); module docstring updated.6 new tests (333 total). Docs build clean (30/30 gallery scripts, zero warnings); every chart rendered and inspected directly, and all three legend bugs above were caught that way, not by the code merely running without error.
0.25.0
A revamp pass across the older plotting functions – not new chart types this time, but real defects and thin spots in ones that had been sitting unpolished since before this gallery’s newer additions. Found by rendering every function against the sample match and actually looking at the output, the same way every previous chart in this changelog was verified.
Fixed:
plot_second_phase()’s default title was silently building “set piece — eventId … (…)” on every call, never “corner” or “free kick” –extract_corners/extract_free_kicksnever tagged their rows withset_piece_type, soclassify_phasealways fell back to the generic label. Also fixed: that title routinely overflowed the figure width and rendered truncated (”…second-phase sh…”) – shortened to a fixed “Corner sequence” / “Free kick sequence” title with the eventId/outcome detail moved to the subtitle; and nearby touches in a goalmouth scramble stacked into one unreadable blob of overlapping numbers – fixed with a new_declutter_points()helper that spreads near-coincident points onto a small rosette. A legend (Delivery / Touch / Second-phase shot) was also missing entirely.Fixed:
plot_defensive_routine_bars()’s default title had the same overflow-truncation bug, from the same root cause (team-id-in-title on a narrow figure) – fixed the same way, plus added a headline stat strip (show_stats: bool = True, new parameter) it never had.Fixed:
plot_xt_added_bars()’s value labels usedbar_label’s default outside-the-bar placement, which collided with the y-axis category label for whichever bar was longest (the actual point of a top-N-by-magnitude chart). Replaced with labels placed just inside each bar’s own tip, which can’t collide with anything outside the bar.Improved:
plot_xt_grid()was a bare color grid with no way to read an actual value off it – added per-cell value labels (mplsoccer.Pitch.label_heatmap()) and a colorbar.Improved:
plot_corner_sonar()had no legend distinguishing successful (green) from unsuccessful (red) deliveries – added one, matching the convention already used byplot_delivery_map().Docs build clean (27/27 gallery scripts, zero warnings), 327 tests still passing (unchanged – this pass touched rendering, not the underlying data functions).
0.24.0
Four more new chart forms – waterfall, violin, radial bar, waffle – continuing straight on from 0.23.0’s Sankey and beeswarm. None of these existed anywhere in the module before.
Added:
plot_value_waterfall()– one team’s total set-piece added value, decomposed by restart type as a floating-bar waterfall stepping to a final total. Caught and fixed a real bug during verification: the final “Total” bar always drew upward from zero regardless of sign, so a net-negative total (the sample team’s actual case) rendered as a misleadingly tall bar pointing the wrong way – fixed to span[min(0, total), max(0, total)].Added:
plot_value_distribution()– a violin per set-piece type of per-delivery added value, for the spread a bar chart’s average hides.Added:
plot_type_radial_bar()– a wind-rose bar chart of one metric across set-piece types. Caught and fixed a label-collision bug during verification: value labels placed just beyond each bar’s tip collided with the category-name ring for whichever bar happened to be tallest; fixed by placing labels inside each wedge (at least 14% of the max value out from the pole, so short wedges don’t cluster at the center either) with a dark stroke outline so they stay legible over both colored bars and the plain background.Added:
plot_success_waffle()– a single ratio as a filled-square icon array, for a headline number that deserves more visual weight than a stat-strip figure.Four new gallery examples (
plot_24-plot_27); module docstring updated.8 new tests (327 total, up from 319). Docs build clean (27/27 gallery scripts executed, zero warnings); every chart rendered and inspected directly, and both bugs above were caught by looking at the actual output, not by the code merely running without error.
0.23.0
Two genuinely new chart forms, not more variations on bars/scatter/pitch maps – requested after the gallery rebuild in 0.22.0 still rendered the same images as before it (that rebuild only reorganized scripts; the plotting functions themselves hadn’t changed).
Added:
plot_outcome_flow()– a Sankey-style flow diagram from every delivery, through itsdelivery_outcomes()category, down to whether it ended in a goal. Ribbon width is proportional to delivery count at every stage, built from scratch on cubic-BezierPathPatchribbons (_flow_ribbon()) since matplotlib has no built-in proportional Sankey. No chart in this module previously showed a funnel – verified against both the zero-goal sample-match case (ribbons flow straight through, “Goal” node omitted entirely rather than drawn empty) and a synthetic multi-goal case (diagonal ribbons correctly split and cross into the “Goal” node).Added:
plot_rating_beeswarm()– every player as one dot on a 0-100 rating scale, deterministically spread just enough to avoid overlap (_beeswarm_offsets(), a binned alternate-side stack, not a random jitter or a physics packing). Answers a different question thanplot_rating_benchmark()’s ranked bars: how spread out a group’s ratings are, not each player’s rank. Labels push outward in whichever direction their own dot is already displaced, so they clear a tightly packed cluster instead of colliding with the next dot up.Two new gallery examples (
plot_22_outcome_flow.py,plot_23_rating_beeswarm.py);categories.rstupdated to mention the flow diagram alongside the corner/free-kick outcome tools.6 new tests (319 total). Docs build clean (23/23 gallery scripts executed, zero warnings), every new chart rendered and inspected directly, including a synthetic dataset built specifically to exercise the goal-split ribbon path the real (goal-less) sample match can’t.
0.22.0
Rebuilt the whole gallery from scratch: every example script deleted and rewritten, renumbered 1-21 as a single ordered curriculum (basics, then non-pitch views, then value/rating/report chains, then the ML and data-driven extras) instead of the ad-hoc ordering it had accumulated. No plotting-function changes – this is a content/structure rebuild only.
The three newest chart types stay front and center where they were already integrated:
plot_zone_scatter()leads the zone page,plot_volume_quality_scatter()leads the team-comparison page, andplot_set_piece_value_flow()leads the timeline page.Every script re-verified end to end: a full Sphinx-Gallery build executed all 21 out of 21 files with zero warnings (
-W --keep-going), 313 tests still pass, and several rendered PNGs were opened and looked at directly, not just checked for a clean exit code.
0.21.0
Three new chart types, not just a redesign of the existing ones – each answers a question none of the current plots could, replacing the corresponding basic chart as the featured example in the gallery.
Added:
plot_zone_scatter()– every event as its own point, colored by outcome, density-shaded by default. The individual-event alternative toplot_zone_heatmap()’s binned grid: a grid answers “how many landed in this rectangle,” this answers “where exactly, and did it work” – no bin-size choice implicitly deciding how coarse the picture is. Now the featured example in the gallery’s zone page; the grid is still there for exact counts.Added:
plot_set_piece_value_flow()– cumulative set-piece added value per team, over match time, as a step chart. A genuinely new analytical angle this package didn’t have at all: when threat was created, not just the final total. Built fromset_piece_added_value()merged back to each delivery’stimeMin(scoped to(eventId, contestantId)together, sinceeventIdalone collides across teams).Added:
plot_volume_quality_scatter()– a labeled quadrant scatter (volume vs. quality, median reference lines) for anyset_piece_summary()-shaped table. Answers a question no single bar-chart metric can: does the type/team with the most attempts also convert them, or is high volume masking low quality.Three gallery examples updated to feature the new charts as the lead visual (
plot_02_delivery_zones.py,plot_06_team_comparison.py,plot_09_match_timeline.py), each verified by rendering and looking at the actual output, not just checking it builds.
313 tests passing (unchanged), docs build clean.
0.20.0
The gallery looked polished but shallow after 0.19.2’s quality pass – every chart plotted the data and left the reader to work out the takeaway. This adds the layer that was actually missing: charts that state their own headline number, and one chart (the delivery map) that can show the shape of a pattern, not just individual deliveries.
Added: an auto-computed headline stat strip – bold value, muted label, e.g. “9 deliveries / 22% success rate” – under the subtitle on
plot_delivery_map(),plot_zone_heatmap()(count and the busiest zone’s share of it),plot_corner_sonar(), andplot_dashboard()(attempts/success rate/goals for the report’s own team). Newshow_stats: bool = Trueparameter on each – on by default; every value is computed straight from the data being plotted, never passed in, so it can’t drift out of sync with the chart. Newdraw_stat_strip()is the shared, reusable building block.Added:
density: bool = Falseonplot_delivery_map()– a kernel-density shade of where deliveries land, underneath the arrows (mplsoccer.Pitch.kdeplot(), via seaborn – already a transitive dependency of mplsoccer, no new dependency added). Off by default: a smooth density surface implies a continuous pattern a single match’s handful of deliveries can’t actually support – meaningful once turned on for a season’s worth.Hero image and all gallery plots regenerated; a new example (
plot_21_vertical_pitch.pyfrom 0.19.3, already covering orientation) sits alongside the existing set – no new gallery script needed for this change, since the enhancement applies to functions already demonstrated throughout the gallery.
313 tests passing (unchanged), docs build clean.
0.19.3
Added:
vertical: bool = Falseon every pitch-based plotting function –plot_delivery_map(),plot_zone_heatmap(),plot_xt_grid(),plot_second_phase(),plot_set_piece_outcomes(),plot_routine_clusters(), andplot_dashboard()(which draws two of them). Draws onmplsoccer.VerticalPitch(goal-at-top) instead of the defaultmplsoccer.Pitch(goal-at-right) – both classes share mplsoccer’s own drawing API, so nothing else about a call changes; a zone heatmap’s grid rotates with the pitch too (6x3 becomes 3x6), not just the outline.The default vertical canvas (5.6in x 8.2in) is taller/narrower than the horizontal default (8in x 5.2in), so a portrait pitch doesn’t sit in a landscape canvas with wasted space on either side.
New gallery example,
plot_21_vertical_pitch.py, showing the same deliveries and zone heatmap in both orientations side by side.Noted in
plot_dashboard()’s docstring:vertical=Truethere letterboxes the two pitch panels within their existing landscape-shaped grid cells rather than reshaping the cells themselves – usable, but the standalone functions are the better fit for a vertical pitch as the main image.
313 tests passing (unchanged), docs build clean.
0.19.2
Visual quality pass on wa_setpieces.viz, prompted by feedback that
the gallery didn’t feel “befitting” the package’s own WA branding –
found several concrete, real problems by actually rendering and looking
at the output, not just guessing at what “higher quality” might mean.
Fixed a real bug: a long header title collided with the WA lockup on narrower figures (e.g. the polar sonar plot, 6in wide) – confirmed by rendering.
draw_header()now draws the lockup first, measures its actual left edge, and truncates the title/subtitle with an ellipsis (measured, not guessed) so they stop short of it instead of running through it.Fixed a real bug: bar-chart y-axis labels (team/player IDs) were truncated, missing their first character, on every horizontal bar chart – the default matplotlib left margin didn’t account for label width. New
_reserve_ytick_marginmeasures the widest rendered label and reserves exactly enough space.Fixed a design flaw: the sequential heatmap ramp (zone counts, xT grids) used a fixed light-to-dark palette designed for a white background – on the dark navy canvas its pale pastel low end looked like a different, lighter-themed app pasted into the WA card. Ramps are now mode-aware: the low end blends toward this palette’s own surface color (receding into the canvas) and only the high end (WA’s saturated categorical hue) is fixed across both modes.
Added: value labels (
ax.bar_label) on every bar chart – team comparison, xT-added bars, rating benchmark, defensive routine bars, aerial duel win rate – reading exact values off a bar no longer requires eyeballing against gridlines.Changed: delivery-map arrows are bolder (wider, larger heads) and bar-chart gridlines lighter/more subtle, closer to how the reference WA report charts actually look rather than matplotlib defaults.
Changed: gallery images render at 200 dpi in the docs build (was matplotlib’s default 100) – crisper text and lines once a browser displays the image at its natural size.
Hero image and all twenty gallery plots regenerated, in both light and dark mode, and visually reviewed image by image before committing – not just checked for a clean build.
313 tests passing (was 310; two sequential-cmap tests rewritten for the new mode-aware ramp direction, one new test added), docs build clean.
0.19.1
Wording-only pass – no functional or schema change. “F24” (the Opta/
Stats Perform event-feed name) is still this package’s native format,
exactly as before – only the word itself is gone from docs, docstrings,
comments and the changelog, replaced with “Opta” or “the (event) feed”
depending on context. Applied everywhere it appeared: README, every
docs/source/*.rst page, every module docstring and inline comment in
src/, the example scripts and notebooks, and past changelog entries.
310 tests passing (unchanged), docs build clean.
0.19.0
Every plot in wa_setpieces.viz gets a real chart “card” anatomy
now, not just a colored title – the same WA house-style card layout
used in Waltzing Analytics’ own report work, generalized into the
package’s shared theme so every function gets it automatically.
Added: a header band (eyebrow category label, serif title, muted subtitle) and the “WA · WALTZING ANALYTICS” brand lockup, top-right of every chart that owns its own figure – on by default, since this is the package’s own brand mark, not any individual’s. New
draw_header()/draw_wa_lockup()/draw_eyebrow()/draw_footer()onPalette.Added:
eyebrowandauthorparameters on every plotting function inwa_setpieces.viz.plots.authoradds a personal byline under the WA lockup and a “{author} | Created on DD-MM-YYYY” stamp to the footer – opt-in, no default, so a chart from this package never carries a name that isn’t yours.Changed: the footer’s
source/credit text (the pre-existingfooter=argument) moved from bottom-right to bottom-left, to make room for the new author/date stamp on the right.Changed: when a plotting function creates its own figure (i.e.
axwasn’t passed in), title/subtitle now render in this new figure-level header band instead of as the axes’ ownax.set_title()–ax.get_title()is empty in that case now. Passing your ownaxstill uses the older, simpler axes-level title (this function doesn’t own the whole figure in that case and can’t safely reserve header margin on it).Fixed: a real layout bug this surfaced –
mplsoccer.Pitch.draw()turns on aTightLayoutEngineby default, which silently re-runs on every subsequent draw/save and overridessubplots_adjust, undoing reserved header/footer margins the moment a pitch-based figure is saved (confirmed by rendering: the title drew directly on top of the legend). Fixed by disabling the figure’s layout engine once the WA card’s own margins are set.Hero image and all twenty gallery plots regenerated against the new anatomy, in both light and dark mode.
310 tests passing (was 309; the axes-title behavior change above gets its own regression test alongside the updated one), docs build clean.
0.18.7
Docs-only removal – no library code changed.
Removed: the narrative
user_guide/pages (nine files), the Opta qualifier reference (qualifiers.rst) and the API reference (api.rst, and with itsphinx.ext.autodoc/napoleon/viewcodefrom the Sphinx build – nothing left to generate). The six “pick one, see how it works” reference pages (By set piece, By value model,by_metric,by_phase,by_routine,by_report) are now the whole of the docs past install/quickstart/gallery – every cross-reference that pointed into a removed page was retargeted at its replacement, or dropped where no direct replacement exists.:func:/:class:/:mod:roles throughout the docs (there are many) still parse fine withoutautodocloaded – they’re core Sphinx Python-domain roles – they just render as plain code text now instead of a hyperlink, since there’s no API reference page left to link to.
309 tests passing (unchanged – docs only), docs build clean, zero Sphinx warnings.
0.18.6
Docs-only addition, completing the reference-page pattern 0.18.5 started for value models – no library code changed.
Four new pages:
by_metric.rst,by_phase.rst,by_routine.rst,by_report.rst– the same pick-one-see-how-it-works reference By set piece/By value model give set pieces and value models, now for the remaining fouruser_guidetopics (metrics/ deliveries/zones, second phases/retention/outcomes, routines/long throws/penalties, reports/export/CLI). Every entry follows the same What it returns/detects/describes/produces, Requirements, Compute it, Where it’s used template, cross-linked back to its narrative guide page and forward to whatever consumes it.Every code snippet in all four pages – including every CLI subcommand in
by_report.rst– was executed against the bundled sample match to confirm it actually runs.Homepage and quickstart “where next” sections now list all six reference pages together.
309 tests passing (unchanged – docs only), docs build clean.
0.18.5
Docs-only addition, following up on 0.18.4’s restructure – no library code changed.
New page:
value_models.rst(“By value model”) – the same pick-one-see-what-it-does reference By set piece gives set pieces, but for the value-model layer: xT, added value, the experimental ML-based shot value, and team/player/defensive rating – what each one measures, what it needs, and a runnable example, plus a short diagram of how they chain together (xT -> added value -> ratings).``categories.rst``: every set-piece section now has runnable code boxes alongside the prose (export/metrics/value model/visualisation), matching every other page in the docs – previously prose-and-cross-refs only.
Every code snippet in both pages was executed against the bundled sample match to confirm it actually runs, including the
mlextra’s shot-value path.
309 tests passing (unchanged – docs only), docs build clean.
0.18.4
Docs rewrite and a house-style pass – no library code changed, only documentation content/build and the chart palette.
Docs restructure: the old two-page
quickstart/advancedwall split into a proper mplsoccer-style user guide – eight focused, example-first pages underuser_guide/(loading, metrics, phases/outcomes, value/ratings, routines, defending/season, reports/ export, visualisation), plus a newcategories.rst“By set piece” reference organized by set-piece type (corner, free kick, throw-in, penalty, goal kick, kick-off) rather than pipeline stage.api.rstregrouped into the same six sections instead of one flat module list.Docs theme: switched from
pydata-sphinx-theme(top navbar) tosphinx-rtd-theme– confirmed against mplsoccer’s own built docs that this is the theme they actually use, and it’s the one with the classic always-visible left navigation tree, rather than pydata’s contextual per-section sidebar.House style:
wa_setpieces.viz.theme’s dark and light chart palettes are now Waltzing Analytics’ own brand colors (dark navy/coral, light paper/amber) instead of a generic blue/orange palette – four of each mode’s eight categorical colors, the hairline, ink, muted tones, and the team-identity pairing (now teal-then-blue, was orange-then-blue) are WA’s own documented values; the rest extend from WA’s other brand hues (confidence green/red, brand amber) to fill out eight categorical slots. The docs site chrome (sidebar, links, admonitions) picked up the same navy/coral treatment viacustom.css. Hero image and all twenty gallery plots regenerated against the new palette.
309 tests passing (was 307), docs build clean, zero Sphinx warnings.
0.18.3
Verification pass on the two lowest-level trust boundaries left with real
gaps: core/phases.py (the heuristic every set-piece module downstream
ultimately builds on) and core/schema.py (validate_events, the
gatekeeper every adapter’s output passes through). No bugs found – both
already behaved correctly – but neither had a single test proving it,
which is its own risk on code this central.
Tests:
_phase_window’s period-boundary stop (a corner right at the end of a half no longer bleeding its follow-up window into the next half’s kickoff, the intra-match analog of the already-tested cross-match boundary) had zero coverage; confirmed correct and locked in as a regression test. Same forclassify_phase’s two-team guard.Tests:
validate_events’s four other rejection paths (non-DataFrameinput, a non-numeric core column, an implausible coordinate, a missingcontestantId) were entirely untested beyond the one “missing column” case – all four now have explicit regression tests confirming the right error and message.
307 tests passing (was 301), docs build clean.
0.18.2
A hygiene pass following up on the pattern behind three prior real bugs
(QUALIFIER_INSWINGER, QUALIFIER_HEADED, the goal-mouth-z/xG
collision): a systematic cross-check of every qualifierId constant in the
package against real co-occurrence data in the sample match, plus closing
the one meaningful test-coverage gap it turned up along the way. No live
bugs this time – everything actually read or written by this package
checked out – but two constants were simply wrong and sitting unused,
which is exactly how the three prior bugs started.
Removed:
QUALIFIER_NOT_ASSISTED(was 26) andQUALIFIER_ASSISTED(was 28) – both wrong and both unreferenced anywhere in the codebase. 26 is actually Opta’s “Free kick” shot-situation flag (it partitions cleanly with four sibling situation qualifiers across every shot in the sample, and all three q_26 shots fire directly off a foul with no intervening pass); 28 never appears in the sample at all, while 29 – the valueQUALIFIER_ASSISTalready correctly uses – does. Neither removal fixes a live bug (nothing referenced them), only a landmine for whoever reached for them next.Docs:
core.placement’s module docstring now documents the qualifier 102/103 confirmation in full (previously onlygoal_y_normwas called confirmed) and a newly-noticed Opta convention: blocked shots carry a fixed placeholderq_103of"19"(exactly half the assumed 0-38 scale) rather than a real height, sogoal_h_normfor a blocked shot is a fabricated 0.5, not a measurement.Tests: closed the one real coverage gap the audit surfaced –
set_piece_added_value’s branch that actually extracts a assist-chain-linked shot’s coordinates and computesshot_value/is_goalhad never been exercised by any test, because the sample match has no direct linked shot off a corner or free kick (its two corner shots are both second-phase, which the assist-chain qualifier doesn’t reach). Also closedproviders/statsbomb.py’s remaining coverage gaps (outswinger technique, headed pass, blocked shot, penalty shot) – both files now at 100%/97%.
0.18.1
Bugfix release: a dedicated review of convert/corners.py (74% test
coverage, the weakest in the package, and never through this project’s
earlier section-by-section correctness pass) found five real bugs, all
with regression tests. One of them led to a sixth in
providers/statsbomb.py.
Fixed:
Q_HEADEDwas reading qualifier 22 (“Regular play”, a shot situation flag) instead of 15 (“Head”) – same family as the historicalQ_INSWING = 72mistake. Every headed shot/corner was mislabelled, and_body_partadditionally always fell back to “Right Foot” for left-footed shots since it never checked the left-foot qualifier. Centralized asconstants.QUALIFIER_HEADED(15) soconvert.cornersandproviders.statsbombshare one definition instead of each risking their own drift.Fixed:
Q_XGwas reading qualifier 103, which iscore.placement.QUALIFIER_GOAL_MOUTH_Z– the feed has no xG qualifier at all, so this was reading goal-mouth-z as if it were an expected-goals value (an off-target miss’s z of 65.3 became an “xG” of 0.653).shot.statsbomb_xginconvert.corners’s output is now always empty, documented in the module docstring.Fixed: the same qualifier-103 mistake existed on the write side in
providers.statsbomb, encodingstatsbomb_xgintoq_103– colliding with real goal-mouth-z placement and corruptingml.shot_value’sgoal_h_normfeature for every StatsBomb-sourced shot with a nonzero xG. StatsBomb’s xG has no Opta qualifier to map onto and is no longer written.Fixed:
_to_sb_yrescaled Opta’s y coordinate to StatsBomb’s 0-80 scale but never flipped it – Opta’s y origin is the bottom touchline, StatsBomb’s is the top, so every delivery landed in the mirror-image corner of the pitch.Fixed:
build_possessionsincremented on every team alternation, but Opta interleaves lone defensive touches into the attacking team’s own stream, inflating possession counts roughly 3-4x (the sample match: ~700 possessions for 1613 events, StatsBomb-plausible is ~200). Combined with the shot-linking window’s off-by-one tolerance, this meant zero corner-to-shot links resolved in the sample match even where a real one existed a few events later. Now requires two consecutive touches by the new team before counting it as a possession change.Fixed: a blank cell in the match-list CSV parsed to the string
"nan"(truthy), defeating the “skip incomplete row” guard and keeping id-less rows as matches with a literal"nan"team name.
convert/corners.py coverage: 74% -> 89%. Full suite: 296 passed,
2 skipped (was 268).
0.18.0
A completeness pass prompted by a coverage audit (cli.py 72%,
convert/corners.py 74%, core/season.py 82%, against ~95%+ for the
rest of the package): closed the gaps that were actually missing
capability rather than just missing tests, and found one more real bug
along the way.
New:
SeasonDataset.season_report– the whole-season roll-up that was missing next to.summary()(basic counts only) and.report()(the full field set, but one row per team per match). Sums every count across matches first and re-derives rates from the sums, never averaging a per-match rate directly – the same idiomrolling_summary/rolling_defensive_summaryalready used for a trailing window, now applied to the whole season.New CLI:
seasonsubcommand putsSeasonDataseton the command line (--action summary|report|season-report|rolling|rolling-defense);scoutputsopponent_scouting_report_htmlon the command line. Both were Python-only before.Improved CLI:
report --type cornernow writes the curatedcorner_report_html(rating, outcome/routine breakdowns, delivery/ outcome maps) instead of a generic table dump; other types still use the generic dump since there’s no curated report for them yet.workflowgained--format csv|xlsx(was CSV-only).Fixed a real bug:
load_events_multi(and thereforeSeasonDataset.from_sourcesand the newseason/scout/train-xtCLI commands) derived a defaultmatchIdfrom each source’s filename stem with no collision check – two sources sharing a stem (the same filename from two different directories, or the same file passed twice by mistake) silently merged under onematchId, defeating every match-safety guarantee built on top of it being unique. Found while manually testing the newseasonCLI subcommand with a copy-pasted example. Now raises a clearValueErrornaming the colliding sources, for both default and explicitly-passedmatch_ids.Docs: deduplicated two README sections that had drifted into covering the same
SeasonDatasetground (one had gained the CLI section wedged in between them);core.attributionandXTModel.save/.loadpersistence, previously only mentioned in passing, now have real usage examples.
0.17.1
Bugfix release: the cross-match eventId-collision defect fixed in
0.13.x/0.14.x for chains/metrics/value/shot_value turned out
to have three more instances in modules built after that fix, found by a
targeted audit of every module touching a raw eventId lookup. All three
only misfire on a multi-match frame (SeasonDataset/load_events_multi)
that shares eventId values across matches for the same team – a normal
single match is unaffected – and all three are covered by new regression
tests that fail against the pre-fix code.
Fix:
delivery_outcomes(and thereforeoutcome_summary,aerial_duel_summary) resolved a delivery’s first-contact/aerial event by bare(contestantId, eventId), with no per-match loop. On a multi-match frame this silently returned whichever match’s row came first in the concatenated frame for every delivery – most visibly, misattributing who won an aerial duel. Now match-safe the same wayrestart_routinesalready was, and carries amatchIdcolumn when the input has one.Fix:
core.attribution.first_contact_detaillooked ahead by row position with only aperiodIdcheck, so a delivery near the end of one match could pick up an unrelated event from the start of the next match (clock reset to ~0) as its “first contact”. Now loops per match likerestart_routines/delivery_outcomesand carriesmatchId.Fix:
core.routines.long_throw_second_phasesbuiltmatchId-aware keys to pick out long throws but then handedclassify_phasethe whole multi-match frame, which walks forward by position and only breaks on aperiodIdchange – the same failure mode as thefirst_contact_detailbug above, for the one throw-in path that reusesclassify_phasedirectly. Now classifies each throw-in against its own match’s events only.Improved:
penalty_placement_detailnow carries amatchIdcolumn when the input has one (previously the only per-restart detail table that didn’t), so a penalty’seventIdstays attributable to the right match on a season-combined frame.
0.17.0
A larger batch: a column rename for clarity, CSV/Excel export, and five new analyses (opponent scouting, penalty placement, long-throw specialists, rolling defensive form) wired into the flagship pipeline.
Breaking change:
delivery_outcomes/classify_delivery_outcome/outcome_summary’scategorycolumn andrestart_routines’soutcome_categorycolumn are both renamed todelivery_outcome. Both classified the same underlying concept – what happened to one delivery – under two different names, neither of which read distinctly from the raw pass/shot successoutcomeflag they sit next to in the same tables. No deprecation alias: these are new enough (0.6.0 and 0.12.0 respectively) that a silent rename is preferable to carrying two names forward.New:
save_table/save_tables– save any table this package produces to CSV or Excel, chosen by file extension. Excel needs the new optionalxlsxextra (openpyxl).New:
opponent_scouting_report_html– pre-match scouting report on how an opponent defends a set-piece type (conceded routines/zones, aerial-duel record), the conceding-side mirror ofcorner_report_html.New:
core.penalties–penalty_placement_detail/penalty_taker_summary: per-penalty result and placement zone in the goal frame, per-taker conversion rate. Extracted the underlying goal-mouth placement geometry out ofml.shot_valueinto a newcore.placementmodule (pure qualifier geometry, no model dependency) so it doesn’t need the optionalmlextra;ml.shot_valuere-exports it as_goal_placementfor backward compatibility.New:
core.routines.long_throw_taker_summary/long_throw_second_phases– per-player long-throw usage/threat created, and event-sequence-based flick-on/knockdown detection restricted to throws that actually travel far enough to threaten (the one throw-in pattern that plays like a corner).New:
SeasonDataset.rolling_defensive_summary– the conceding-side mirror ofrolling_summary. Also documentedrolling_summary’s existing match-order caveat on both methods: there’s no date field in the loaded schema, so “rolling” only means something if matches were supplied tofrom_sourcesalready in chronological order.run_workflow/SetPieceWorkflowgaineddefensive_routine_summary,defensive_zone_summary,routine_clusters,aerial_duel_team_summary/aerial_duel_player_summary,penalty_placement/penalty_taker_summaryandlong_throw_taker_summary/long_throw_second_phases– gatedNonewhere they don’t apply toset_piece_type, or where the optionalmlextra isn’t installed forroutine_clusters. No CLI changes needed:wa-setpieces workflow/reportalready collect every DataFrame field generically.
0.16.0
Finished the module-by-module review that produced 0.14.0/0.15.0, then built out the corner-focused additions further: plots, gallery coverage, and confirmation they already generalize to every set-piece type.
Fixed a real correctness bug:
convert.cornersandproviders.statsbombboth tagged in-swinging deliveries with qualifier 72, which is actually “Left Footed” in this package’s Opta-derived schema (confirmed against the sample match: qualifier 72 also appears on shot events, where swing direction is meaningless, and co-occurs with both real swing-direction qualifiers depending on which foot the taker used). The real in-swinger qualifier is 223, confirmed mutually exclusive with 224 (out-swinger) across every corner in the sample match. Every genuine in-swinger was previously going undetected unless the taker also happened to be left-footed (right label, wrong reason); a left-footed out-swinger was mislabeled “Inswinging”. AddedQUALIFIER_INSWINGER/QUALIFIER_OUTSWINGERtocore.constantsas the shared, verified source.Fixed a real correctness bug:
ml.shot_value.build_shot_featuresre-looked-up each shot’s raw event by(contestantId, eventId)alone – only unique within one match, sinceeventIdresets every match – so a shot in one match could silently inherit another match’s qualifiers (foot preference, goal placement, …) for the same pair. Reproduced directly and fixed by scoping to(matchId, contestantId, eventId)whenmatchIdis present, the same pattern already applied tochains.link_set_piece_shots/value.set_piece_added_valuein 0.14.0.build_shot_features/shot_valuenow also carrymatchIdthrough to their output.Fixed a real correctness bug:
providers.statsbomb.load_statsbomb_eventsproduced a columnless DataFrame for an empty export (same bugcore.loader.load_eventshad for a zero-event match, fixed in 0.14.0), failingschema.validate_eventsfor the wrong reason.New:
plot_routine_clusters,plot_defensive_routine_barsandplot_aerial_duel_win_rateinwa_setpieces.viz.plots, forcluster_routines,defensive_routine_summary/defensive_zone_summaryandaerial_duel_summaryrespectively. Gallery grew from 17 examples to 20.Confirmed (no code change needed) that every 0.15.0 addition –
delivery_technique,post_target,cluster_routines,defensive_routine_summary/defensive_zone_summary,aerial_duel_summary– already works for every set-piece type, not just corners, since none of them hard-coded a corner-only restriction.Reviewed every remaining previously-unreviewed module (
ml.shot_value,viz.plots,viz.theme,providers.statsbomb,cli) for correctness;viz.plots,viz.themeandclihad no issues.
0.15.0
A batch of corner-focused additions, all built on already-existing pieces rather than new inference:
New:
restart_routinesgainsdelivery_technique("inswinger"/"outswinger", from Opta qualifiers 223/224) andpost_target("near_post"/"far_post"/"central", relative to which flank the restart was taken from) columns for corners and free kicks.Fixed a real bug found along the way:
convert.corners’sQ_INSWINGwas qualifier 72, which is actually “Left Footed” (also used, correctly, byml.shot_value.QUALIFIER_LEFT_FOOTED) – confirmed against the sample match, where qualifier 72 also appears on shot events (where swing direction is meaningless) and co-occurs with both the real in-swing and out-swing qualifiers depending on which foot the taker used. Every genuine in-swinger was going undetected unless the taker also happened to be left-footed, and a left-footed player’s out-swinger was mislabeled “Inswinging”. The real in-swinger qualifier is 223 (confirmed mutually exclusive with 224 across every corner in the sample match), now shared ascore.constants.QUALIFIER_INSWINGER.New:
cluster_routines– k-means over delivery geometry as a data-driven alternative toroutine_type’s fixed rule-based taxonomy, surfacing whatever patterns a team actually repeats. Optionalmlextra (scikit-learn);cluster_summaryrolls it up per cluster.New:
defending.defensive_routine_summary/defensive_zone_summary– fliprestart_routines’s attacking perspective to “which routines/zones is this team’s defending most exposed to”, e.g. conceding disproportionately from near-post inswingers.New:
outcomes.classify_delivery_outcomeidentifies the winner of eachaerial_dueldelivery from the raw Opta Aerial event’s own outcome flag (confirmed as a matched winner/loser pair per duel);aerial_duel_summaryrolls that up into per-team win rate and per-player wins.New:
corner_report_html– a ready-to-view HTML scouting report for corners (team report, rating, outcome breakdown, routine mix, plus a delivery map and outcome shot map when thevizextra is installed), assembled from already-existing tables via the existingrender_html_report.New:
delivery_clip_windows– clip in/out timestamp windows (in match-clock seconds) for every delivery of any set-piece type, for handing off to video-clipping tools.
0.14.0
A code-review pass through every core module, working module by module in
dependency order. Nine real correctness bugs found and fixed, most verified
against the bundled sample match or a targeted synthetic reproduction:
Fixed a real correctness bug:
core.xt.XTModel.fit’s value recurrence computedmove_probabilityfrom all pass/take-on attempts (success or fail) but normalized the zone-to-zone transition matrix only over successful transitions (each row forced to sum to 1). Multiplying the two together silently assumed every attempted pass succeeds and lands like the successful ones did, discarding the value of a failed pass (a turnover, worth 0) – a cell with a 100% pass-completion rate and one with 10% got identical xT as long as both sent their (rare, for the second) successful passes to the same destination. The transition matrix is now normalized by total move attempts, matchingmove_probability’s own denominator, so rows sum to the cell’s actual pass-success rate.Fixed a real correctness bug: several functions indexed shots or deliveries by
(contestantId, eventId)alone – safe within one match, buteventIdnumbering restarts every match, so a season-combined frame (matchIdpresent) could silently attribute a goal or shot to a set piece actually taken in a different match that happened to reuse the sameeventIdfor that team. Fixed inchains.link_set_piece_shotsandvalue.set_piece_added_valueby scoping the lookup to(matchId, contestantId, eventId)whenmatchIdis present;metrics.delivery_locationsandlink_set_piece_shotsnow also carrymatchIdthrough to their output so callers can key on it.Fixed a real correctness bug:
retention.retention_detaillooked ahead for the next touch by filtering onperiodIdalone, which repeats every match, so a season-combined frame could pick up an unrelated later match’s event as if it followed the current delivery – reproduced directly (a throw-in with no real follow-up in its own match was misreported as lost possession, using a later match’s kickoff). Unlikephases.py, which already guards the analogous risk with a 2-team check,retention.pyhad no guard at all; it now raises a clear error instead of silently corrupting results. The existing internal caller (routines.restart_routines) was already unaffected, since it splits bymatchIdbefore calling in.Fixed a real correctness bug:
zones.add_thirds/add_channelsreturnedNaNfor the out-of-play events just beyond the nominal 0-100 pitch boundary that Opta records andcore.schemaexplicitly tolerates (-5..105) – 26 and 100 rows respectively on the bundled sample match, mostly throw-ins, corners and clearances near the boundary lines.x/yare now clipped to 0-100 before binning, matching howzone_id/add_zone_gridalready clamp.Fixed a real correctness bug:
routines.pydefined its own private third/channel helpers duplicatingzones.add_thirds/add_channels, but with different bin edges and the opposite left/right direction (lowywas “right” inroutines.py, “left_wide” inzones.py) – the same delivery could get contradictory flank labels depending on which function was called.routines.pynow reuseszones.py’s canonical helpers directly.Fixed a real correctness bug:
core.loader.load_eventsproduced a columnless (not just empty) DataFrame for a match with zero recorded events, failingschema.validate_eventsfor the wrong reason. Sorting also broke ties within the same roundedperiodId/timeMin/timeSecbyeventId, which is only unique within one team’s own stream – not a valid cross-team ordering key – when the already-captured sub-secondtimeStampfield was available and unused; using it corrected one delivery’s outcome classification in the sample match (aerial_duel->first_touch_lost, verified by hand against the real timestamps).Fixed a real bug:
run_workflowforwardedmodelstraight through toset_piece_reportregardless ofset_piece_type, which raises for anything other than corner/free-kick – unlike every other model-dependent field in the same function, which already no-ops instead of crashing. A caller fitting oneXTModeland loopingrun_workflowover all six set-piece types would crash on the first non-phase type.Fixed a real bug:
outcomes.classify_delivery_outcome’sno_actionbranch fell back withvalue or default, which treats any falsy qualifier value – including a genuine end position of 0.0, right on the touchline/byline – as if it were missing.Hardened
tests/test_filters.py’s free-kick/corner disjointness check to compare on the globally-uniqueidcolumn instead of the per-team-scopedeventId, which could theoretically mask or spuriously flag a collision between two different teams’ events.Every fix above has a regression test reproducing the original bug.
0.13.0
Added the structured
RoutineAnalysisbundle: restart detail, routine summary, team tactical profiles, taker profiles and target matrices.Added metric distance, delivery angle, verticality, destination zones, hierarchical outcomes and stable tactical pattern keys.
Added team routine diversity, concentration/predictability, preferred patterns, shot/goal rates and taker-specific routine preferences.
Made routine analysis safe on multi-match frames by isolating temporal and event-link calculations within each
matchId.
0.12.0
Added
restart_routinesandroutine_summaryfor corners, free kicks, throw-ins, penalties, goal kicks and kick-offs. Every restart now carries distance, progression, direction, side, start/end thirds, start/target channels, retention, shots, goals and a type-specific routine classification.Added
all_routine_summariesfor a complete routine inventory and included detailed and aggregate routine tables inrun_workflowand generated reports.
0.11.0
Added a validated provider-neutral event schema and capability discovery.
Added
SeasonDatasetfor match-safe season aggregation and rolling form.Added defensive set-piece summaries, ratings, and first-contact attribution.
Added full xT model persistence, metadata, and held-out Brier evaluation.
Added self-contained HTML reports and expanded CLI subcommands for summaries, workflows, reports, model training, providers, and structured exports.
Added Python 3.13, coverage, wheel-install and lint checks to CI.
0.10.0
New:
wa_setpieces.core.rating– benchmarked 0-100 team/player “how good” scores. Each metric in a report is z-scored against the sample it’s given (50 = that sample’s own average, +/-1 SD =~ +/-15 points, clipped to [0, 100]) then combined into a compositerating.team_rating()works on anyset_piece_report/corner_report/free_kick_reporttable.player_rating()splits into a delivery score (taker quality, fromset_piece_added_value) and a finishing score (shooter quality, from the assist-chain shot link andXTModel.shot_value), merged so a pure taker or pure finisher is rated on the component they actually have. Always benchmark against a full season/competition, not one match – see the module docstring.New:
wa_setpieces.providers– adapters that convert other providers’ event feeds into the same internal framewa_setpieces.core.loader.load_eventsproduces from Opta, so every other module works unchanged regardless of source.wa_setpieces.providers.statsbomb.load_statsbomb_events(also exported aswa_setpieces.load_statsbomb_events) converts a StatsBomb open-data events export: set-piece detection, the assist-chain shot link (key_pass_id), retention, xT, added value and the new rating module are all faithfully mapped; one narrow second-phase-timing edge case is a documented approximation (StatsBomb has no event type equivalent to Opta’s distinct “ball went out of play” event). Impect is not supported – it’s a closed feed with no public schema to build and verify an adapter against.New:
plot_rating_benchmarkinwa_setpieces.viz.plots– horizontal benchmark chart for ateam_rating/player_ratingtable, diverging from the sample-average baseline of 50.New:
wa_setpieces.core.workflow.run_workflow(also exported aswa_setpieces.run_workflow) – runs this package’s full pipeline for one set-piece type in a single call (extraction, team/player counts, second phases, retention, added value, report, team/player rating) and returns every table in oneSetPieceWorkflow. Computes nothing new; it’s a convenience wrapper around the existing functions for when you want the whole chain without wiring five calls together yourself.
0.9.0
Breaking: the package is now organized into subpackages instead of ~20 flat modules directly under
wa_setpieces. Import paths change; the top-level API (from wa_setpieces import load_events, XTModel, ...) is unaffected.wa_setpieces.core– loading, extraction, metrics, chains, phases, retention, xT, added-value, outcomes, report, constants. No extra dependencies; imported eagerly (e.g.wa_setpieces.xtmoved towa_setpieces.core.xt,wa_setpieces.constantstowa_setpieces.core.constants, and so on for every other module that used to sit directly underwa_setpieces).wa_setpieces.ml–wa_setpieces.shot_valuemoved towa_setpieces.ml.shot_value; the bundled.pklmodels moved with it towa_setpieces/ml/models/(mlextra unchanged).wa_setpieces.viz–wa_setpieces.viz(the plotting module) moved towa_setpieces.viz.plots;wa_setpieces.thememoved towa_setpieces.viz.theme.wa_setpieces.viz.themestill imports without mplsoccer installed (only matplotlib) – thevizpackage’s__init__does not eagerly importplots, so pulling in the palette doesn’t pull in the heavier plotting dependency.wa_setpieces.convert– new. See below.
New:
wa_setpieces.convert.corners(optionalconvertextra – pyarrow) batch-converts a directory of Opta match JSON exports, plus a companion match-list CSV, into a flat “corners” table (one row per corner delivery, with any shot it produced linked by a time/possession heuristic) matching the schema external tools already consume. Also available as thewa-setpieces-convert-cornersconsole script.
0.8.0
New (experimental)
wa_setpieces.ml.shot_value: five pre-trained gradient-boosted models (xgboost + isotonic calibration), bundled with the package (wa_setpieces/models/*.pkl, newmloptional extra – xgboost, scikit-learn, joblib), score every shot in a match:on_target_prob,xgot(xG On Target),psxg(Post-Shot xG),situational_prob, a 4-class outcome distribution, and a blendedshot_valuecolumn.build_shot_features()reconstructs the models’ training feature schema from Opta qualifiers, reusing already-tested logic elsewhere in this package (chains.link_set_piece_shots,phases.second_phases, the validatedQUALIFIER_ASSISTconstant) wherever possible. Read the module’s docstring before trusting the output: several situational flags (big chance, one-on-one, fast break, scramble, header/volley) have no reliable qualifier signal in the real match data this was checked against and default toFalserather than a guessed-and-possibly-wrong mapping – documented explicitly as a known limitation, not silently assumed correct.Fixed a real bug found by the new test suite:
bool(float('nan'))isTruein Python, so the first implementation of the qualifier-flag read (bool(raw_event.get("q_20"))) treated every absent qualifier column as present – every shot came out “assisted” and often “both right- and left-footed” regardless of the real data. Fixed with apd.notna()-aware helper (_qualifier_flag); verified against the sample match that assisted/unassisted and left/right-foot splits are now correctly mutually exclusive and match the real qualifier data.Gallery grew from 14 examples to 15.
0.7.0
Restyled the whole plotting suite and added light/dark mode. Every function in
wa_setpieces.viznow takesdark: bool = True– the pitch, chart chrome and team colors all switch between two validated palettes inwa_setpieces.viz.theme(get_palette(dark)) with that one argument. Both a navy dark surface (#0d1117) and a clean white light surface (#ffffff) pass the full colorblind-safety and contrast validator, run against their own chart surface.Two-team charts (
plot_team_comparison,plot_match_timeline,plot_dashboard,plot_set_piece_radar) now use a fixed, validated orange-then-blueteam_colorspairing instead of the general 8-slot categorical order’s blue/green – the first team is always orange, the second always blue, consistent across a whole report. This is a separate, standalone-validated 2-color convention; the generalCATEGORICALorder (used by e.g.plot_set_piece_outcomes’s 8 outcome categories) is unchanged.New fixed
theme.GOLDaccent for goals, used for the “Goal” ring inplot_set_piece_outcomesand the second-phase-shot highlight inplot_second_phase(previously the categorical yellow slot and a plain white ring, respectively) – distinct from both team colors and the good/critical status colors, never reused for series identity.Every plotting function gained optional
subtitle(a muted line under the title) andfooter(a small credit/source line, bottom-right of the figure) parameters. Neither is set by default – a source credit belongs to whoever is publishing the chart.Fixed a legend/bar overlap in
plot_team_comparison: the defaultloc="lower right"legend clipped into the throw-in bars (the longest, at the bottom of the chart, in the sample match); moved toupper right, verified against the sample match with no collision.theme.Paletteandtheme.get_palette(dark)are the new recommended API; the old module-level constants (theme.SURFACE,theme.CATEGORICAL, …) still work, pinned to the dark palette.Gallery grew from 13 examples to 14, with a new light/dark side-by-side example.
0.6.0
wa_setpieces.core.outcomes: per-delivery outcome classification for corners and free kicks –short_corner,direct_shot,second_phase_shot,aerial_duel(“50/50”,typeId44),cleared,first_touch_won,first_touch_lostorno_action.delivery_outcomes(per-delivery) andoutcome_summary(per-team rollup). Built onwa_setpieces.core.phases, which now also records whether a set piece produced a direct shot on the delivery itself (PhaseResult.direct_shot/direct_shot_event_id/direct_shot_is_goal) – previously that information was discarded once a direct shot was detected.viz.plot_set_piece_outcomes: a colored-scatter shot map, one point per delivery at the outcome location, colored by category, with a ring around goals. Gallery grew from 12 examples to 13.Fixed a threshold contradiction in the short-corner heuristic: the original
max_distance=15.0andmax_end_x=85.0conditions were nearly mutually exclusive given corners start at x≈99.5 (at most ~0.5 units of overlap, eliminated entirely by any y-component), so almost no short corner could ever satisfy both. Dropped the end-x condition and lowered the distance threshold to a more realistic 12.0; verified this correctly reclassifies a real short corner in the sample match that the old thresholds mislabeled as a lost first touch.
0.5.1
Fixed the README’s hero image not rendering on the PyPI project page – it used a repo-relative path, which GitHub resolves but PyPI’s README renderer can’t (no access to the repo tree); switched to an absolute URL and verified the whole README renders correctly through the actual PyPI renderer.
Added
Repository,ChangelogandBug Trackerlinks to the PyPI project sidebar, alongside the existingHomepage/Documentation.
0.5.0
Fixed a real correctness bug: the feed’s
eventIdis only unique within one team’s own event stream (both teams number their events 1, 2, 3, … independently – confirmed 1464 of 1613 rows in the sample match share aneventIdwith a same-numbered row from the other team).chains.link_set_piece_shotswas resolving the assist-chain qualifier byeventIdalone, which could silently attribute a shot to the wrong team’s set piece; it’s now scoped to(contestantId, eventId).viz.plot_second_phasehad the same class of bug (an unscopedeventIdlookup across all event types); it now searches only corner/free-kick deliveries and raises clearly on remaining ambiguity instead of silently picking the wrong one (newcontestant_idparameter to disambiguate when needed).wa_setpieces.core.value: set-piece added value –delivery_xt_added + shot_valueper delivery (the latter via a newXTModel.shot_valueusing the fitted shot/goal probability grids), always summable (0, not NaN, when nothing happened).set_piece_added_value(per-delivery) andset_piece_value_summary(per-team).wa_setpieces.core.report:set_piece_report/corner_report/free_kick_reportmerge attempts, success rate, second-phase rate, retention rate, and (with a model) added value and goals into one table per team – previously five separate function calls.viz.plot_set_piece_radar: two-team radar over acorner_report/free_kick_report, built onmplsoccer.Radarwith per-axis auto-ranging (a raw count and a 0-1 rate need different scales, and a small-magnitude metric like avg added value needs a tight range or it flattens to invisible against a 0-1 default). Gallery grew from 10 examples to 12.viz.plot_xt_added_barsgeneralized with avalue_colparameter so it works foradded_valuetoo, not justxt_added.
0.4.0
Five new plots in
wa_setpieces.viz:plot_team_comparison,plot_xt_added_bars,plot_corner_sonar,plot_match_timeline, andplot_dashboard(a one-figure “report card” combining several of the others). Gallery grew from 5 examples to 10.wa_setpieces.viz.theme: a validated color palette (categorical, status, sequential blue/green, diverging blue/red) every plot now draws from, replacing ad hoc per-function color choices. Fixed a real bug along the way –plot_team_comparison/plot_dashboarddidn’t guarantee which team got the first (blue) categorical slot, so “our team” could silently swap colors between panels.wa_setpieces.load_events_multi: loads and stacks several Opta exports into one events DataFrame tagged with amatchIdcolumn, for match-independent aggregation (team/player counts, zone heatmaps, fittingXTModelacross a season) – documented as unsafe to feed directly into the per-matchphases/retentionwindow functions.Fixed
plot_match_timeline’s minute axis: Opta’stimeMinalready runs cumulatively across periods in this feed (period 2 continues from ~45, it doesn’t reset to 0), so the previous per-period offset was double counting second-half events. Verified against the sample match.GitHub Actions: PyPI trusted-publisher release workflow and a pytest CI matrix (3.9/3.11/3.12).
0.3.0
wa_setpieces.viz: mplsoccer-based pitch plots – delivery maps, zone heatmaps, xT grids, and second-phase sequence plots (newvizoptional extra).wa_setpieces.core.zones.to_reference_frame: mirrors one team’s events onto a shared pitch frame, fixing a real bug where plotting both teams’ raw coordinates together produced nonsensical positions (each event’s x/y is in that team’s own attacking direction).Docs rebuilt on
pydata-sphinx-theme+sphinx-gallery(mplsoccer’s stack): a runnable example gallery with embedded plots and DataFrame output, executed fresh on every docs build.
0.2.0
Second-phase detection for corners and free kicks (
wa_setpieces.core.phases).Possession retention after any restart (
wa_setpieces.core.retention).Pitch zones, thirds and channels (
wa_setpieces.core.zones).Expected Threat (xT) engine, fit from data, with helpers for corner/free-kick delivery value (
wa_setpieces.core.xt).CLI now prints second-phase, retention, and (with
--xt) xT sections.
0.1.0
Initial release: loader, extractors and metrics for penalties, kick-offs, free kicks, corners, throw-ins and goal kicks from Opta event data.
Set-piece-to-shot/goal chain linking via the assist-chain qualifier.
wa-setpiecescommand-line tool.