fforge_core/match_engine/
calibrate.rs1use super::MatchOutcome;
14use super::stream::{MatchEventKind, ShotKind, ShotOutcome, ShotSource, Side};
15use std::collections::BTreeMap;
16
17#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
21pub struct FormationStats {
22 pub uses: u32,
24 pub goals: u32,
25 pub shots: u32,
26}
27
28impl FormationStats {
29 pub fn goals_per_match(&self) -> f64 {
30 if self.uses == 0 {
31 return 0.0;
32 }
33 self.goals as f64 / self.uses as f64
34 }
35
36 pub fn shots_per_match(&self) -> f64 {
37 if self.uses == 0 {
38 return 0.0;
39 }
40 self.shots as f64 / self.uses as f64
41 }
42}
43
44#[derive(Debug, Default, Clone)]
48pub struct StreamTelemetry {
49 pub matches: u32,
50 pub home_wins: u32,
51 pub draws: u32,
52 pub away_wins: u32,
53 pub goals: u32,
54 pub shots: u32,
55 pub shots_on_target: u32,
56 pub goals_by_kind: BTreeMap<ShotKind, u32>,
57 pub goals_by_source: BTreeMap<ShotSource, u32>,
61 pub home_events: u32,
64 pub away_events: u32,
65 pub by_formation: BTreeMap<u8, FormationStats>,
67}
68
69impl StreamTelemetry {
70 pub fn record(&mut self, outcome: &MatchOutcome, home_formation: u8, away_formation: u8) {
74 self.matches += 1;
75 self.goals += outcome.home_goals as u32 + outcome.away_goals as u32;
76 match outcome.home_goals.cmp(&outcome.away_goals) {
77 std::cmp::Ordering::Greater => self.home_wins += 1,
78 std::cmp::Ordering::Equal => self.draws += 1,
79 std::cmp::Ordering::Less => self.away_wins += 1,
80 }
81
82 let mut home_shots = 0u32;
83 let mut away_shots = 0u32;
84 let mut home_goals = 0u32;
85 let mut away_goals = 0u32;
86
87 for event in &outcome.stream {
88 match event.side {
89 Side::Home => self.home_events += 1,
90 Side::Away => self.away_events += 1,
91 }
92 if let MatchEventKind::Shot {
93 kind,
94 source,
95 outcome: shot_outcome,
96 } = event.kind
97 {
98 self.shots += 1;
99 match event.side {
100 Side::Home => home_shots += 1,
101 Side::Away => away_shots += 1,
102 }
103 if matches!(shot_outcome, ShotOutcome::Goal | ShotOutcome::Saved) {
104 self.shots_on_target += 1;
105 }
106 if shot_outcome == ShotOutcome::Goal {
107 *self.goals_by_kind.entry(kind).or_default() += 1;
108 *self.goals_by_source.entry(source).or_default() += 1;
109 match event.side {
110 Side::Home => home_goals += 1,
111 Side::Away => away_goals += 1,
112 }
113 }
114 }
115 }
116
117 let home_stats = self.by_formation.entry(home_formation).or_default();
118 home_stats.uses += 1;
119 home_stats.goals += home_goals;
120 home_stats.shots += home_shots;
121
122 let away_stats = self.by_formation.entry(away_formation).or_default();
123 away_stats.uses += 1;
124 away_stats.goals += away_goals;
125 away_stats.shots += away_shots;
126 }
127
128 pub fn goals_per_match(&self) -> f64 {
129 if self.matches == 0 {
130 return 0.0;
131 }
132 self.goals as f64 / self.matches as f64
133 }
134
135 pub fn shots_per_match(&self) -> f64 {
136 if self.matches == 0 {
137 return 0.0;
138 }
139 self.shots as f64 / self.matches as f64
140 }
141
142 pub fn home_win_rate(&self) -> f64 {
143 self.rate(self.home_wins)
144 }
145
146 pub fn draw_rate(&self) -> f64 {
147 self.rate(self.draws)
148 }
149
150 pub fn away_win_rate(&self) -> f64 {
151 self.rate(self.away_wins)
152 }
153
154 fn rate(&self, n: u32) -> f64 {
155 if self.matches == 0 {
156 return 0.0;
157 }
158 n as f64 / self.matches as f64
159 }
160
161 pub fn shot_on_target_rate(&self) -> f64 {
162 if self.shots == 0 {
163 return 0.0;
164 }
165 self.shots_on_target as f64 / self.shots as f64
166 }
167
168 pub fn conversion_rate(&self) -> f64 {
169 if self.shots == 0 {
170 return 0.0;
171 }
172 self.goals as f64 / self.shots as f64
173 }
174
175 pub fn headed_goal_share(&self) -> f64 {
178 if self.goals == 0 {
179 return 0.0;
180 }
181 *self.goals_by_kind.get(&ShotKind::Header).unwrap_or(&0) as f64 / self.goals as f64
182 }
183
184 pub fn wide_origin_goal_share(&self) -> f64 {
188 if self.goals == 0 {
189 return 0.0;
190 }
191 let cross = *self.goals_by_source.get(&ShotSource::Cross).unwrap_or(&0);
192 let cutback = *self.goals_by_source.get(&ShotSource::Cutback).unwrap_or(&0);
193 (cross + cutback) as f64 / self.goals as f64
194 }
195
196 pub fn home_possession_share(&self) -> f64 {
198 let total = self.home_events + self.away_events;
199 if total == 0 {
200 return 0.0;
201 }
202 self.home_events as f64 / total as f64
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::match_engine::Zone;
210 use crate::match_engine::stream::MatchEvent;
211
212 fn shot(side: Side, kind: ShotKind, source: ShotSource, outcome: ShotOutcome) -> MatchEvent {
213 MatchEvent {
214 minute: 10,
215 side,
216 zone: Zone::Box,
217 kind: MatchEventKind::Shot {
218 kind,
219 source,
220 outcome,
221 },
222 }
223 }
224
225 #[test]
226 fn record_reproduces_hand_counted_aggregates() {
227 let stream = vec![
231 shot(
232 Side::Home,
233 ShotKind::Finish,
234 ShotSource::Through,
235 ShotOutcome::Goal,
236 ),
237 shot(
238 Side::Home,
239 ShotKind::Finish,
240 ShotSource::Cutback,
241 ShotOutcome::Off,
242 ),
243 shot(
244 Side::Home,
245 ShotKind::Header,
246 ShotSource::Cross,
247 ShotOutcome::Goal,
248 ),
249 shot(
250 Side::Away,
251 ShotKind::LongShot,
252 ShotSource::Long,
253 ShotOutcome::Goal,
254 ),
255 shot(
256 Side::Away,
257 ShotKind::LongShot,
258 ShotSource::Long,
259 ShotOutcome::Saved,
260 ),
261 MatchEvent {
262 minute: 20,
263 side: Side::Home,
264 zone: Zone::Mid,
265 kind: MatchEventKind::Pass { success: true },
266 },
267 MatchEvent {
268 minute: 21,
269 side: Side::Away,
270 zone: Zone::Mid,
271 kind: MatchEventKind::Pass { success: true },
272 },
273 ];
274 let outcome = MatchOutcome {
275 home_goals: 2,
276 away_goals: 1,
277 stream,
278 };
279
280 let mut telemetry = StreamTelemetry::default();
281 telemetry.record(&outcome, 0, 2); assert_eq!(telemetry.matches, 1);
284 assert_eq!(telemetry.goals, 3);
285 assert_eq!(telemetry.home_wins, 1);
286 assert_eq!(telemetry.draws, 0);
287 assert_eq!(telemetry.away_wins, 0);
288 assert_eq!(telemetry.shots, 5);
289 assert_eq!(telemetry.shots_on_target, 4); assert_eq!(telemetry.goals_by_kind.get(&ShotKind::Finish), Some(&1));
291 assert_eq!(telemetry.goals_by_kind.get(&ShotKind::Header), Some(&1));
292 assert_eq!(telemetry.goals_by_kind.get(&ShotKind::LongShot), Some(&1));
293 assert_eq!(
294 telemetry.goals_by_source.get(&ShotSource::Through),
295 Some(&1)
296 );
297 assert_eq!(telemetry.goals_by_source.get(&ShotSource::Cross), Some(&1));
298 assert_eq!(telemetry.goals_by_source.get(&ShotSource::Long), Some(&1));
299 assert_eq!(telemetry.goals_by_source.get(&ShotSource::Cutback), None); assert_eq!(telemetry.home_events, 4); assert_eq!(telemetry.away_events, 3); let home_formation = telemetry.by_formation.get(&0).unwrap();
304 assert_eq!(home_formation.uses, 1);
305 assert_eq!(home_formation.goals, 2);
306 assert_eq!(home_formation.shots, 3);
307
308 let away_formation = telemetry.by_formation.get(&2).unwrap();
309 assert_eq!(away_formation.uses, 1);
310 assert_eq!(away_formation.goals, 1);
311 assert_eq!(away_formation.shots, 2);
312
313 assert_eq!(telemetry.goals_per_match(), 3.0);
314 assert_eq!(telemetry.shots_per_match(), 5.0);
315 assert!((telemetry.shot_on_target_rate() - 0.8).abs() < 1e-9);
316 assert!((telemetry.conversion_rate() - 0.6).abs() < 1e-9);
317 assert!((telemetry.headed_goal_share() - (1.0 / 3.0)).abs() < 1e-9);
318 assert!((telemetry.wide_origin_goal_share() - (1.0 / 3.0)).abs() < 1e-9); assert!((telemetry.home_possession_share() - (4.0 / 7.0)).abs() < 1e-9);
320 }
321}