Skip to content
This repository was archived by the owner on Jan 2, 2026. It is now read-only.

Commit c4d2ea1

Browse files
committed
Aggregate error codes by phase of approach
1 parent cfdc2ae commit c4d2ea1

5 files changed

Lines changed: 210 additions & 18 deletions

File tree

‎app/controllers/squadron_pilots_controller.rb‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def show
4040
@passes = @pilot.passes.where(time: start_date..end_date).order(time: :desc)
4141
@boarding_rate = calculate_boarding_rate(@passes)
4242
@error_statistics = calculate_error_statistics(@passes)
43+
@error_statistics_by_phase = calculate_error_statistics_by_phase(@passes)
4344

4445
respond_with @pilot
4546
end
@@ -72,7 +73,21 @@ def calculate_error_statistics(passes)
7273
end
7374

7475
aggregator = ErrorCodeAggregator.new(all_errors)
75-
aggregator.top(5)
76+
aggregator.top(3)
77+
end
78+
79+
def calculate_error_statistics_by_phase(passes)
80+
all_errors = []
81+
82+
passes.each do |pass|
83+
next if pass.notes.blank?
84+
85+
parser = RemarksParser.new(pass.notes)
86+
all_errors.concat(parser.parse)
87+
end
88+
89+
aggregator = ErrorCodeAggregator.new(all_errors)
90+
aggregator.by_phase(3)
7691
end
7792

7893
def valid_date_params?

‎app/services/error_code_aggregator.rb‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,26 @@ def aggregate
6666
# @param n [Integer] The number of top errors to return
6767
# @return [Array<Hash>] The top N aggregated errors
6868
def top(n=5) = aggregate.first(n)
69+
70+
# Aggregates errors by phase, calculating top N errors for each phase.
71+
#
72+
# @param n [Integer] The number of top errors to return per phase
73+
# @param exclude_phases [Array<String>] Phases to exclude from results
74+
# @return [Hash] Hash with phase codes as keys and arrays of top errors as values
75+
# Only includes phases that have errors
76+
def by_phase(n=3, exclude_phases: %w[AW])
77+
@technique_errors.
78+
reject { |e| exclude_phases.include?(e.phase) }.
79+
group_by(&:phase).
80+
transform_values do |phase_errors|
81+
ErrorCodeAggregator.new(phase_errors).top(n)
82+
end.
83+
reject { |_phase, errors| errors.empty? }.
84+
sort_by { |phase, _errors| PHASE_ORDER.index(phase) || 999 }.
85+
to_h
86+
end
87+
88+
# @api private
89+
PHASE_ORDER = %w[X BC IM IC AR TL IW AW].freeze
90+
private_constant :PHASE_ORDER
6991
end

‎app/views/squadron_pilots/show.json.jbuilder‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,29 @@ end
1111

1212
json.boarding_rate @boarding_rate
1313

14-
json.error_statistics @error_statistics do |stat|
15-
json.code stat[:code]
16-
json.description t("error_codes.#{stat[:code]}", default: nil)
17-
json.score stat[:score]
18-
json.count stat[:count]
14+
json.error_statistics do
15+
json.overall @error_statistics do |stat|
16+
json.code stat[:code]
17+
json.description t("error_codes.#{stat[:code]}", default: nil)
18+
json.score stat[:score]
19+
json.count stat[:count]
20+
end
21+
22+
if @error_statistics_by_phase.empty?
23+
json.by_phase({})
24+
else
25+
json.by_phase do
26+
@error_statistics_by_phase.each do |phase, stats|
27+
json.set! phase do
28+
json.phase_description t("phase_codes.#{phase}", default: phase)
29+
json.errors stats do |stat|
30+
json.code stat[:code]
31+
json.description t("error_codes.#{stat[:code]}", default: nil)
32+
json.score stat[:score]
33+
json.count stat[:count]
34+
end
35+
end
36+
end
37+
end
38+
end
1939
end

‎spec/requests/squadron_pilots_request_spec.rb‎

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,28 +102,63 @@
102102
expect(body["boarding_rate"]).to eq(0.5)
103103
end
104104

105-
it "returns aggregated error statistics" do
105+
it "returns aggregated error statistics with overall top 3" do
106106
get "/squadrons/#{squadron.to_param}/pilots/#{pilot.to_param}.json?start_date=#{start_date}&end_date=#{end_date}"
107107

108108
body = response.parsed_body
109109
stats = body["error_statistics"]
110110

111-
expect(stats).to be_an(Array)
112-
expect(stats.length).to be <= 5 # Top 5 only
111+
expect(stats).to be_a(Hash)
112+
expect(stats).to include("overall", "by_phase")
113+
114+
overall = stats["overall"]
115+
expect(overall).to be_an(Array)
116+
expect(overall.length).to be <= 3 # Top 3 only
113117

114118
# LUL should be the top error (appears 3 times with high intensity)
115-
lul_stat = stats.find { |s| s["code"] == "LUL" }
119+
lul_stat = overall.find { |s| s["code"] == "LUL" }
116120
expect(lul_stat).not_to be_nil
117121
expect(lul_stat["description"]).to eq("Lined up left")
118122
expect(lul_stat["score"]).to eq(6.0) # 3 * 2.0 (high intensity)
119123
expect(lul_stat["count"]).to eq(3)
120124
end
121125

122-
it "sorts error statistics by score descending" do
126+
it "returns error statistics grouped by phase" do
123127
get "/squadrons/#{squadron.to_param}/pilots/#{pilot.to_param}.json?start_date=#{start_date}&end_date=#{end_date}"
124128

125129
body = response.parsed_body
126-
stats = body["error_statistics"]
130+
by_phase = body["error_statistics"]["by_phase"]
131+
132+
expect(by_phase).to be_a(Hash)
133+
134+
# X phase should have LUL and F errors
135+
expect(by_phase).to have_key("X")
136+
expect(by_phase["X"]["phase_description"]).to eq("At the start")
137+
x_errors = by_phase["X"]["errors"]
138+
expect(x_errors).to be_an(Array)
139+
expect(x_errors.length).to be <= 3
140+
141+
lul_in_x = x_errors.find { |e| e["code"] == "LUL" }
142+
expect(lul_in_x).not_to be_nil
143+
144+
# IM phase should have LUL and DL errors
145+
expect(by_phase).to have_key("IM")
146+
expect(by_phase["IM"]["phase_description"]).to eq("In the middle")
147+
148+
# IC phase should have AFU errors
149+
expect(by_phase).to have_key("IC")
150+
expect(by_phase["IC"]["phase_description"]).to eq("In close")
151+
152+
# IW phase should have 3PTS and B errors
153+
expect(by_phase).to have_key("IW")
154+
expect(by_phase["IW"]["phase_description"]).to eq("In the wires")
155+
end
156+
157+
it "sorts overall error statistics by score descending" do
158+
get "/squadrons/#{squadron.to_param}/pilots/#{pilot.to_param}.json?start_date=#{start_date}&end_date=#{end_date}"
159+
160+
body = response.parsed_body
161+
stats = body["error_statistics"]["overall"]
127162

128163
scores = stats.pluck("score")
129164
expect(scores).to eq(scores.sort.reverse)
@@ -133,9 +168,14 @@
133168
get "/squadrons/#{squadron.to_param}/pilots/#{pilot.to_param}.json?start_date=#{start_date}&end_date=#{end_date}"
134169

135170
body = response.parsed_body
136-
stats = body["error_statistics"]
137-
138-
expect(stats).to all(include("code", "description", "score", "count"))
171+
overall = body["error_statistics"]["overall"]
172+
by_phase = body["error_statistics"]["by_phase"]
173+
174+
expect(overall).to all(include("code", "description", "score", "count"))
175+
by_phase.each_value do |phase_data|
176+
expect(phase_data).to include("phase_description", "errors")
177+
expect(phase_data["errors"]).to all(include("code", "description", "score", "count"))
178+
end
139179
end
140180
end
141181

@@ -148,7 +188,8 @@
148188

149189
expect(body["passes"]).to be_empty
150190
expect(body["boarding_rate"]).to eq(0.0)
151-
expect(body["error_statistics"]).to be_empty
191+
expect(body["error_statistics"]["overall"]).to be_empty
192+
expect(body["error_statistics"]["by_phase"]).to be_empty
152193
end
153194
end
154195

@@ -164,7 +205,8 @@
164205
get "/squadrons/#{squadron.to_param}/pilots/#{pilot.to_param}.json?start_date=#{start_date}&end_date=#{end_date}"
165206

166207
body = response.parsed_body
167-
expect(body["error_statistics"]).to be_empty
208+
expect(body["error_statistics"]["overall"]).to be_empty
209+
expect(body["error_statistics"]["by_phase"]).to be_empty
168210
end
169211
end
170212

@@ -176,7 +218,8 @@
176218
expect(response).to have_http_status(:success)
177219
body = response.parsed_body
178220
expect(body["passes"].count).to eq(1)
179-
expect(body["error_statistics"]).to be_empty
221+
expect(body["error_statistics"]["overall"]).to be_empty
222+
expect(body["error_statistics"]["by_phase"]).to be_empty
180223
end
181224
end
182225
end

‎spec/services/error_code_aggregator_spec.rb‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,98 @@
137137
end
138138
end
139139

140+
describe "#by_phase" do
141+
it "groups errors by phase and returns top N for each" do
142+
errors = [
143+
technique_error.new(code: "LUL", intensity: :high, phase: "X", modifiers: []),
144+
technique_error.new(code: "F", intensity: :medium, phase: "X", modifiers: []),
145+
technique_error.new(code: "H", intensity: :low, phase: "X", modifiers: []),
146+
technique_error.new(code: "DR", intensity: :low, phase: "X", modifiers: []),
147+
technique_error.new(code: "LUL", intensity: :high, phase: "IM", modifiers: []),
148+
technique_error.new(code: "F", intensity: :high, phase: "IM", modifiers: []),
149+
technique_error.new(code: "H", intensity: :medium, phase: "IC", modifiers: [])
150+
]
151+
152+
aggregator = described_class.new(errors)
153+
result = aggregator.by_phase(3)
154+
155+
expect(result.keys).to contain_exactly("X", "IM", "IC")
156+
157+
# X phase: LUL (2.0), F (1.0), H (0.5), DR (0.5) -> top 3: LUL, F, H
158+
expect(result["X"].length).to eq(3)
159+
expect(result["X"][0][:code]).to eq("LUL")
160+
expect(result["X"][1][:code]).to eq("F")
161+
expect(result["X"][2][:code]).to eq("H")
162+
163+
# IM phase: LUL (2.0), F (2.0)
164+
expect(result["IM"].length).to eq(2)
165+
166+
# IC phase: H (1.0)
167+
expect(result["IC"].length).to eq(1)
168+
expect(result["IC"][0][:code]).to eq("H")
169+
end
170+
171+
it "excludes AW phase by default" do
172+
errors = [
173+
technique_error.new(code: "LUL", intensity: :high, phase: "X", modifiers: []),
174+
technique_error.new(code: "F", intensity: :high, phase: "AW", modifiers: [])
175+
]
176+
177+
aggregator = described_class.new(errors)
178+
result = aggregator.by_phase(3)
179+
180+
expect(result.keys).to contain_exactly("X")
181+
expect(result).not_to have_key("AW")
182+
end
183+
184+
it "excludes custom phases when specified" do
185+
errors = [
186+
technique_error.new(code: "LUL", intensity: :high, phase: "X", modifiers: []),
187+
technique_error.new(code: "F", intensity: :high, phase: "IM", modifiers: []),
188+
technique_error.new(code: "H", intensity: :high, phase: "IC", modifiers: [])
189+
]
190+
191+
aggregator = described_class.new(errors)
192+
result = aggregator.by_phase(3, exclude_phases: %w[AW IM])
193+
194+
expect(result.keys).to contain_exactly("X", "IC")
195+
end
196+
197+
it "returns phases in correct order" do
198+
errors = [
199+
technique_error.new(code: "H", intensity: :high, phase: "IC", modifiers: []),
200+
technique_error.new(code: "LUL", intensity: :high, phase: "X", modifiers: []),
201+
technique_error.new(code: "F", intensity: :high, phase: "IW", modifiers: []),
202+
technique_error.new(code: "DR", intensity: :high, phase: "IM", modifiers: [])
203+
]
204+
205+
aggregator = described_class.new(errors)
206+
result = aggregator.by_phase(3)
207+
208+
expect(result.keys).to eq(%w[X IM IC IW])
209+
end
210+
211+
it "handles nil phase errors" do
212+
errors = [
213+
technique_error.new(code: "LUL", intensity: :high, phase: "X", modifiers: []),
214+
technique_error.new(code: "F", intensity: :high, phase: nil, modifiers: [])
215+
]
216+
217+
aggregator = described_class.new(errors)
218+
result = aggregator.by_phase(3)
219+
220+
expect(result.keys).to include("X")
221+
expect(result.keys).to include(nil)
222+
end
223+
224+
it "handles empty errors array" do
225+
aggregator = described_class.new([])
226+
result = aggregator.by_phase(3)
227+
228+
expect(result).to be_empty
229+
end
230+
end
231+
140232
describe "integration with RemarksParser" do
141233
it "aggregates errors from parsed remarks" do
142234
remarks = "GRADE:WO (DLX) _LULX_ _LULIM_ (DLIM) WO(AFU)IC"

0 commit comments

Comments
 (0)