Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 48 additions & 26 deletions app/controllers/api/v1/stats_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,63 @@ module V1
class StatsController < BaseController
before_action -> { authenticate_actor!(scope: "telemetry:read") }

AGGREGATES = <<~SQL.squish.freeze
clubs.id AS club_id,
clubs.label AS club_label,
clubs.static_loft_deg,
COUNT(shots.id) AS shots_count,
AVG(shots.club_speed) AS avg_club_speed,
AVG(shots.ball_speed) AS avg_ball_speed,
AVG(shots.smash_factor) AS avg_smash_factor,
AVG(shots.launch_angle) AS avg_launch_angle,
AVG(shots.spin_rate) AS avg_spin_rate,
AVG(shots.max_height) AS avg_max_height,
AVG(shots.carry) AS avg_carry,
STDDEV_SAMP(shots.carry) AS sd_carry,
MIN(shots.carry) AS min_carry,
MAX(shots.carry) AS max_carry,
AVG(shots.total_distance) AS avg_total_distance,
STDDEV_SAMP(shots.carry_side) AS sd_carry_side,
AVG(shots.attack_angle) AS avg_attack_angle,
AVG(shots.club_path) AS avg_club_path,
STDDEV_SAMP(shots.club_path) AS sd_club_path,
AVG(shots.face_angle) AS avg_face_angle,
STDDEV_SAMP(shots.face_angle) AS sd_face_angle,
AVG(shots.face_to_path) AS avg_face_to_path,
STDDEV_SAMP(shots.face_to_path) AS sd_face_to_path
SQL
# Direction metrics are measured against the bay's target line.
# With calibrated=1, each session's calibration_offset_deg is added
# at query time (lateral distances rotate by the same angle).
# face_to_path is a difference of two directions, so the offset
# cancels and it is never corrected. Stored telemetry is untouched.
def self.aggregates(face:, path:, side:)
<<~SQL.squish.freeze
clubs.id AS club_id,
clubs.label AS club_label,
clubs.static_loft_deg,
COUNT(shots.id) AS shots_count,
AVG(shots.club_speed) AS avg_club_speed,
AVG(shots.ball_speed) AS avg_ball_speed,
AVG(shots.smash_factor) AS avg_smash_factor,
AVG(shots.launch_angle) AS avg_launch_angle,
AVG(shots.spin_rate) AS avg_spin_rate,
AVG(shots.max_height) AS avg_max_height,
AVG(shots.carry) AS avg_carry,
STDDEV_SAMP(shots.carry) AS sd_carry,
MIN(shots.carry) AS min_carry,
MAX(shots.carry) AS max_carry,
AVG(shots.total_distance) AS avg_total_distance,
STDDEV_SAMP(#{side}) AS sd_carry_side,
AVG(shots.attack_angle) AS avg_attack_angle,
AVG(#{path}) AS avg_club_path,
STDDEV_SAMP(#{path}) AS sd_club_path,
AVG(#{face}) AS avg_face_angle,
STDDEV_SAMP(#{face}) AS sd_face_angle,
AVG(shots.face_to_path) AS avg_face_to_path,
STDDEV_SAMP(shots.face_to_path) AS sd_face_to_path
SQL
end

OFFSET_SQL = "COALESCE(training_sessions.calibration_offset_deg, 0)".freeze

AGGREGATES = aggregates(
face: "shots.face_angle",
path: "shots.club_path",
side: "shots.carry_side"
)

CALIBRATED_AGGREGATES = aggregates(
face: "(shots.face_angle + #{OFFSET_SQL})",
path: "(shots.club_path + #{OFFSET_SQL})",
side: "(shots.carry_side + shots.carry * SIN(RADIANS(#{OFFSET_SQL})))"
)

def clubs
shots = Shot.for_user(current_user).analyzed
shots = shots.where(training_session_id: params[:session_id]) if params[:session_id].present?
shots = shots.where(carry: params[:min_carry].to_f..) if params[:min_carry].present?

aggregates = params[:calibrated] == "1" ? CALIBRATED_AGGREGATES : AGGREGATES
rows = shots.joins(:club)
.group("clubs.id", "clubs.label", "clubs.static_loft_deg")
.order("clubs.static_loft_deg")
.select(AGGREGATES)
.select(aggregates)

render json: rows.map { |row| serialize(row) }
end
Expand Down
15 changes: 13 additions & 2 deletions app/controllers/api/v1/training_sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
module Api
module V1
class TrainingSessionsController < BaseController
before_action -> { authenticate_actor!(scope: "telemetry:read") }
before_action -> { authenticate_actor!(scope: "telemetry:read") }, only: %i[index show]
before_action -> { authenticate_actor!(scope: "telemetry:write") }, only: :update

def index
sessions = current_user.training_sessions
Expand All @@ -20,10 +21,20 @@ def show
)
end

# Owners set the bay calibration offset. That is the only mutable
# field: telemetry stays exactly as the launch monitor reported it,
# and the offset is applied as a read-time calculation layer.
def update
session = current_user.training_sessions.find(params[:id])
session.update!(params.permit(:calibration_offset_deg))
render json: serialize(session).merge(shots_count: session.shots.count)
end

private

def serialize(session)
session.as_json(only: %i[id external_id source played_on facility bay ball_type temperature created_at])
session.as_json(only: %i[id external_id source played_on facility bay ball_type temperature
calibration_offset_deg created_at])
end
end
end
Expand Down
5 changes: 5 additions & 0 deletions app/models/training_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ class TrainingSession < ApplicationRecord

validates :external_id, presence: true, uniqueness: { scope: :user_id }
validates :source, presence: true
# Bay target-line correction, in degrees. Telemetry itself is never
# rewritten; readers add this to direction metrics on the way out.
validates :calibration_offset_deg,
numericality: { greater_than_or_equal_to: -15, less_than_or_equal_to: 15 },
allow_nil: true
end
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

# Telemetry pipeline
resources :imports, only: %i[create index show]
resources :sessions, only: %i[index show], controller: :training_sessions
resources :sessions, only: %i[index show update], controller: :training_sessions
resources :shots, only: %i[index update]
resources :clubs, only: %i[index update]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class AddCalibrationOffsetToTrainingSessions < ActiveRecord::Migration[8.1]
def change
# Degrees added to bay-reported direction metrics (face angle, club
# path, launch direction) to express them against the true target
# line. Positive when the bay read left of true. Stored telemetry
# stays exactly as TrackMan reported it; the correction is a read
# layer applied at serialization and display time.
add_column :training_sessions, :calibration_offset_deg, :float
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions spec/requests/stats_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,36 @@
expect(wedge["shots_count"]).to eq(7)
end

it "applies the session calibration offset when calibrated=1" do
user.training_sessions.sole.update!(calibration_offset_deg: 2.8)

get "/api/v1/stats/clubs", headers: api_key_headers(user, scopes: %w[telemetry:read])
raw = response.parsed_body.find { |r| r.dig("club", "static_loft_deg").to_f == 31.0 }

get "/api/v1/stats/clubs", params: { calibrated: "1" },
headers: api_key_headers(user, scopes: %w[telemetry:read])
calibrated = response.parsed_body.find { |r| r.dig("club", "static_loft_deg").to_f == 31.0 }

expect(calibrated.dig("averages", "face_angle"))
.to be_within(0.11).of(raw.dig("averages", "face_angle") + 2.8)
expect(calibrated.dig("averages", "club_path"))
.to be_within(0.11).of(raw.dig("averages", "club_path") + 2.8)
# A difference of two directions: the offset cancels.
expect(calibrated.dig("averages", "face_to_path")).to eq(raw.dig("averages", "face_to_path"))
# A constant shift never changes a spread.
expect(calibrated.dig("dispersion", "face_angle_sd")).to eq(raw.dig("dispersion", "face_angle_sd"))
end

it "returns raw aggregates when no offset is stored, calibrated or not" do
get "/api/v1/stats/clubs", headers: api_key_headers(user, scopes: %w[telemetry:read])
raw = response.parsed_body

get "/api/v1/stats/clubs", params: { calibrated: "1" },
headers: api_key_headers(user, scopes: %w[telemetry:read])

expect(response.parsed_body).to eq(raw)
end

it "does not leak other users' telemetry" do
stranger = create(:user)
get "/api/v1/stats/clubs", headers: api_key_headers(stranger, scopes: %w[telemetry:read])
Expand Down
78 changes: 78 additions & 0 deletions spec/requests/training_sessions_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
require "rails_helper"

RSpec.describe "Training sessions", type: :request do
let(:user) { create(:user) }
let(:session) { user.training_sessions.sole }

before { Trackman::Importer.new(user: user, payload: trackman_payload).call }

describe "GET /api/v1/sessions" do
it "serializes the calibration offset" do
session.update!(calibration_offset_deg: 2.8)
get "/api/v1/sessions", headers: api_key_headers(user, scopes: %w[telemetry:read])

expect(response).to have_http_status(:ok)
row = response.parsed_body.sole
expect(row["calibration_offset_deg"]).to eq(2.8)
end
end

describe "PATCH /api/v1/sessions/:id" do
it "sets the offset for the owner and audits the edit" do
patch "/api/v1/sessions/#{session.id}", params: { calibration_offset_deg: 2.8 },
headers: jwt_headers(user)

expect(response).to have_http_status(:ok)
expect(response.parsed_body["calibration_offset_deg"]).to eq(2.8)
expect(session.reload.calibration_offset_deg).to eq(2.8)

version = session.versions.last
expect(version.event).to eq("update")
expect(version.whodunnit).to eq("user:#{user.id}")
end

it "clears the offset with null" do
session.update!(calibration_offset_deg: 2.8)
patch "/api/v1/sessions/#{session.id}",
params: { calibration_offset_deg: nil }.to_json,
headers: jwt_headers(user).merge("Content-Type" => "application/json")

expect(response).to have_http_status(:ok)
expect(session.reload.calibration_offset_deg).to be_nil
end

it "rejects an implausible offset" do
patch "/api/v1/sessions/#{session.id}", params: { calibration_offset_deg: 45 },
headers: jwt_headers(user)

expect(response).to have_http_status(:unprocessable_entity)
expect(session.reload.calibration_offset_deg).to be_nil
end

it "rejects a read-only agent key" do
patch "/api/v1/sessions/#{session.id}", params: { calibration_offset_deg: 2.8 },
headers: api_key_headers(user, scopes: %w[telemetry:read])

expect(response).to have_http_status(:forbidden)
expect(response.parsed_body["required_scope"]).to eq("telemetry:write")
end

it "cannot touch another user's session" do
stranger = create(:user)
patch "/api/v1/sessions/#{session.id}", params: { calibration_offset_deg: 2.8 },
headers: jwt_headers(stranger)

expect(response).to have_http_status(:not_found)
end

it "ignores attempts to rewrite telemetry facts" do
original_bay = session.bay
patch "/api/v1/sessions/#{session.id}",
params: { calibration_offset_deg: 1.0, bay: "Forged Bay", facility: "Elsewhere" },
headers: jwt_headers(user)

expect(response).to have_http_status(:ok)
expect(session.reload.bay).to eq(original_bay)
end
end
end
Loading