wowlab_analytics/analytics/merge/
emit.rs1use 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#[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#[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}