Skip to main content

wowlab_analytics/analytics/merge/
emit.rs

1use wowlab_types::{
2    constants::{
3        QUANTILE_P01, QUANTILE_P05, QUANTILE_P10, QUANTILE_P25, QUANTILE_P50, QUANTILE_P75,
4        QUANTILE_P90, QUANTILE_P95, QUANTILE_P99,
5    },
6    proto, stats,
7};
8
9use super::{
10    histogram::decode_hdr_v2,
11    keys::{ActionKey, AuraKey},
12};
13
14pub(super) fn sort_state(state: &mut proto::RunningAggregateStateV1) {
15    state.actions.sort_by_key(|row| ActionKey::from(row));
16    state.auras.sort_by_key(|row| AuraKey::from(row));
17    state.resources.sort_by_key(|r| r.resource_type);
18    state.cooldowns.sort_by_key(|c| c.spell_id);
19
20    if let Some(dp) = &mut state.damage_profile {
21        dp.by_target.sort_by_key(|t| t.target_id);
22    }
23
24    if let Some(dict) = &mut state.dictionary {
25        dict.spell_ids.sort_unstable();
26        dict.spell_ids.dedup();
27        dict.aura_ids.sort_unstable();
28        dict.aura_ids.dedup();
29        dict.targets.sort_by_key(|t| t.id);
30        dict.units.sort_by_key(|unit| (unit.kind, unit.id));
31    }
32}
33
34/// Emit a `ResultViewV1` snapshot derived from the current running state.
35#[must_use]
36pub fn emit_result_snapshot(state: &proto::RunningAggregateStateV1) -> proto::ResultViewV1 {
37    let (mean_dps_x10, std_dps_x10, ci95_half_pct_x10000) = stats::compute_result_core(
38        state.iterations_total,
39        state.weighted_mean_num_x10,
40        state.m2_total_bits,
41    );
42
43    let core = proto::ResultCoreV1 {
44        iterations: state.iterations_total,
45        chunks_completed: state.chunks_completed,
46        chunks_total: state.chunks_total,
47        total_fight_time_ms: state.total_fight_time_ms,
48        mean_dps_x10,
49        min_dps_x10: if state.min_dps_x10 == u32::MAX {
50            0
51        } else {
52            state.min_dps_x10
53        },
54        max_dps_x10: state.max_dps_x10,
55        std_dps_x10,
56        ci95_half_pct_x10000,
57    };
58
59    let distribution = state.histogram.as_ref().map(|hist| {
60        let (sample_count, percentiles) = decode_hdr_v2(hist)
61            .map(|hdr| {
62                let pct = |q: f64| -> u32 {
63                    let v = hdr.value_at_quantile(q);
64
65                    u32::try_from(v).unwrap_or(u32::MAX)
66                };
67
68                (
69                    hdr.len(),
70                    proto::Percentiles {
71                        p01_x10: pct(QUANTILE_P01),
72                        p05_x10: pct(QUANTILE_P05),
73                        p10_x10: pct(QUANTILE_P10),
74                        p25_x10: pct(QUANTILE_P25),
75                        p50_x10: pct(QUANTILE_P50),
76                        p75_x10: pct(QUANTILE_P75),
77                        p90_x10: pct(QUANTILE_P90),
78                        p95_x10: pct(QUANTILE_P95),
79                        p99_x10: pct(QUANTILE_P99),
80                    },
81                )
82            })
83            .unwrap_or_default();
84
85        proto::DistributionTelemetry {
86            sample_count,
87            histogram: Some(hist.clone()),
88            percentiles: Some(percentiles),
89        }
90    });
91
92    let actions = if state.actions.is_empty() {
93        None
94    } else {
95        Some(proto::ActionTelemetry {
96            rows: state.actions.clone(),
97        })
98    };
99
100    let auras = if state.auras.is_empty() {
101        None
102    } else {
103        Some(proto::AuraTelemetry {
104            rows: state.auras.clone(),
105        })
106    };
107
108    let resources = if state.resources.is_empty() {
109        None
110    } else {
111        Some(proto::ResourceTelemetry {
112            rows: state.resources.clone(),
113        })
114    };
115
116    let cooldowns = if state.cooldowns.is_empty() {
117        None
118    } else {
119        Some(proto::CooldownTelemetry {
120            rows: state.cooldowns.clone(),
121        })
122    };
123
124    proto::ResultViewV1 {
125        core: Some(core),
126        distribution,
127        actions,
128        auras,
129        resources,
130        cooldowns,
131        execution: state.execution.clone(),
132        damage_profile: state.damage_profile.clone(),
133        dictionary: state.dictionary.clone(),
134        instrumentation_coverage: state.instrumentation_coverage,
135    }
136}
137
138/// Emit a `TimelineViewV1` snapshot derived from the current running state.
139#[must_use]
140pub fn emit_timeline_snapshot(state: &proto::RunningAggregateStateV1) -> proto::TimelineViewV1 {
141    let dps_per_bucket_x10 =
142        stats::compute_bucket_averages_x10(&state.dps_bucket_sums_x10, &state.dps_bucket_samples);
143
144    let (markers, aura_windows, cooldown_windows, phase_markers, encounter_events) =
145        if let Some(repr) = &state.representative {
146            (
147                repr.markers.clone(),
148                repr.aura_windows.clone(),
149                repr.cooldown_windows.clone(),
150                repr.phase_markers.clone(),
151                repr.encounter_events.clone(),
152            )
153        } else {
154            (vec![], vec![], vec![], vec![], vec![])
155        };
156
157    let duration_ms = compute_timeline_duration(state, &markers);
158
159    proto::TimelineViewV1 {
160        duration_ms,
161        bucket_ms: state.bucket_ms,
162        dps_per_bucket_x10,
163        dps_bucket_samples: state.dps_bucket_samples.clone(),
164        markers,
165        aura_windows,
166        cooldown_windows,
167        phase_markers,
168        encounter_events,
169    }
170}
171
172fn compute_timeline_duration(
173    state: &proto::RunningAggregateStateV1,
174    markers: &[proto::TimelineMarker],
175) -> u32 {
176    let max_marker_time: u32 = if markers.is_empty() {
177        0
178    } else {
179        let mut abs_time: u32 = 0;
180
181        for m in markers {
182            abs_time = abs_time.saturating_add(m.delta_time_ms);
183        }
184
185        abs_time
186    };
187
188    if max_marker_time > 0 {
189        max_marker_time
190    } else if state.bucket_ms > 0 {
191        let bucket_count = u32::try_from(
192            state
193                .dps_bucket_sums_x10
194                .len()
195                .max(state.dps_bucket_samples.len()),
196        )
197        .unwrap_or(u32::MAX);
198
199        bucket_count.saturating_mul(state.bucket_ms)
200    } else {
201        0
202    }
203}