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
8 changes: 4 additions & 4 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ name: Build Status

on:
push:
branches: [ master ]
branches: [ develop ]
pull_request:
branches: [ master ]
branches: [ develop ]

jobs:
build:
Expand All @@ -17,10 +17,10 @@ jobs:
steps:
- uses: actions/checkout@v2

- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.8
python-version: '3.9'

- name: Install dependencies
run: |
Expand Down
31 changes: 31 additions & 0 deletions frispy/aero_calculator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import math
import logging
from frispy.model import Model # Assuming frispy.model.Model is the correct path

def calculate_aero_coefficients(model: Model) -> dict:
"""
Calculates aerodynamic coefficients (lift, drag, pitch) for a given model
across a range of angles of attack.

Args:
model: An instance of frispy.model.Model.

Returns:
A dictionary where keys are angles of attack in degrees (int) and
values are dictionaries containing "lift", "drag", and "pitch"
coefficients (float). Errors during calculation for a specific angle
will store an "error" message for that angle's entry.
"""
results = {}
for angle_degrees in range(-90, 91): # -90 to +90 inclusive
angle_radians = math.radians(angle_degrees)
try:
cl = model.C_lift(angle_radians)
cd = model.C_drag(angle_radians)
cy = model.C_y(angle_radians)
results[angle_degrees] = {"lift": cl, "drag": cd, "pitch": cy}
except Exception as e:
# Log the error and potentially skip this angle or return partial results
logging.error(f"Error calculating coefficients for angle {angle_degrees} degrees: {str(e)}")
results[angle_degrees] = {"error": str(e)}
return results
35 changes: 28 additions & 7 deletions service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
from typing import Dict

import numpy as np
from flask import Flask, request
from flask import Flask, request, jsonify
from scipy.spatial.transform import Rotation

from frispy import Disc, Discs, Environment
from frispy import Disc, Discs, Environment, Model
from frispy.wind import ConstantWind
from frispy.aero_calculator import calculate_aero_coefficients
from flask_cors import CORS
from flask_sock import Sock
from frispy.disc import FrisPyResults
Expand Down Expand Up @@ -85,11 +86,12 @@ def flight_paths():
res[discName] = to_result(content.get('gamma', 0), result)
else:
discs = content.get('disc_numbers')
for index, discNumbers in enumerate(discs):
content['flight_numbers'] = discNumbers
disc = create_disc(content)
result = compute_trajectory(disc)
res[index] = to_result(content.get('gamma', 0), result)
if discs: # Check if discs is not None before iterating
for index, discNumbers in enumerate(discs):
content['flight_numbers'] = discNumbers
disc = create_disc(content)
result = compute_trajectory(disc)
res[index] = to_result(content.get('gamma', 0), result)

return res

Expand Down Expand Up @@ -251,6 +253,25 @@ def to_flight_path_request(throw_summary: Dict) -> Dict:
return flight_path_request


@app.route("/api/aero_coefficients", methods=['POST'])
def aero_coefficients():
content = request.json
if not content or 'flight_numbers' not in content:
return jsonify({"error": "Missing flight_numbers in request body"}), 400

flight_numbers = content['flight_numbers']
if not isinstance(flight_numbers, dict):
return jsonify({"error": "flight_numbers must be an object"}), 400

try:
model = Discs.from_flight_numbers(flight_numbers)
except Exception as e:
return jsonify({"error": f"Invalid flight_numbers: {str(e)}"}), 400

results = calculate_aero_coefficients(model)
return jsonify(results)


@app.route("/")
def hello_world():
return "Frispy service!"
Expand Down
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import pytest
from service.main import app # Assuming service.main is the correct path to your Flask app

@pytest.fixture
def client():
"""A test client for the app."""
with app.test_client() as client:
yield client
76 changes: 76 additions & 0 deletions tests/test_aero_calculator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import pytest
import math
from frispy.model import Model
from frispy.discs import Discs
from frispy.aero_calculator import calculate_aero_coefficients

def test_calculate_aero_coefficients():
"""
Tests the calculate_aero_coefficients function with a real model.
Checks for the correct structure and content of the results.
"""
# Use a real disc model for testing
# Flight numbers for a disc like Innova Wraith or similar stable-overstable driver
flight_numbers = {"speed": 11, "glide": 5, "turn": -1, "fade": 2}
try:
model = Discs.from_flight_numbers(flight_numbers)
except Exception as e:
pytest.fail(f"Failed to create model from flight numbers: {e}")

results = calculate_aero_coefficients(model)

assert isinstance(results, dict)
assert len(results) == 181 # For angles -90 to +90 inclusive

# Check for specific angle keys (as integers, matching the implementation)
assert 0 in results
assert 90 in results
assert -90 in results

# Check the structure of a sample angle's data
sample_angle_key = 0
assert isinstance(results[sample_angle_key], dict)

if "error" not in results[sample_angle_key]:
assert "lift" in results[sample_angle_key]
assert "drag" in results[sample_angle_key]
assert "pitch" in results[sample_angle_key]

# Check that coefficient values are numbers (floats)
assert isinstance(results[sample_angle_key]["lift"], float)
assert isinstance(results[sample_angle_key]["drag"], float)
assert isinstance(results[sample_angle_key]["pitch"], float)
else:
# If there was an error calculating for this specific angle (e.g. alpha out of bounds for some model types)
# The test should acknowledge this structure. For most standard models, 0 degrees should be fine.
logging.warning(f"Coefficient calculation for angle {sample_angle_key} resulted in an error: {results[sample_angle_key]['error']}")

# Check a boundary angle, e.g., 90 degrees
boundary_angle_key = 90
assert isinstance(results[boundary_angle_key], dict)
if "error" not in results[boundary_angle_key]:
assert "lift" in results[boundary_angle_key]
assert "drag" in results[boundary_angle_key]
assert "pitch" in results[boundary_angle_key]
assert isinstance(results[boundary_angle_key]["lift"], float)
assert isinstance(results[boundary_angle_key]["drag"], float)
assert isinstance(results[boundary_angle_key]["pitch"], float)
else:
logging.warning(f"Coefficient calculation for angle {boundary_angle_key} resulted in an error: {results[boundary_angle_key]['error']}")

# Verify that all entries are either valid coefficient dicts or error dicts
for angle, data in results.items():
assert isinstance(angle, int)
assert isinstance(data, dict)
if "error" in data:
assert isinstance(data["error"], str)
else:
assert "lift" in data and isinstance(data["lift"], float)
assert "drag" in data and isinstance(data["drag"], float)
assert "pitch" in data and isinstance(data["pitch"], float)

# It might be useful to also test the error handling part of calculate_aero_coefficients
# by mocking a model that raises an exception on C_lift/C_drag/C_y calls.
# For now, this test covers the main functionality.
# Adding a test for logging (requires capturing logs or more complex mocking)
# is also out of scope for this direct test.
59 changes: 59 additions & 0 deletions tests/test_aero_coefficients_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import json

# client fixture is provided by conftest.py

def test_aero_coefficients_success(client):
"""Test successful retrieval of aerodynamic coefficients."""
flight_numbers = {"speed": 10, "glide": 5, "turn": -1, "fade": 2}
response = client.post("/api/aero_coefficients", json={"flight_numbers": flight_numbers})

assert response.status_code == 200
data = json.loads(response.data)

assert isinstance(data, dict)
# Check for 181 keys, from -90 to 90 inclusive
assert len(data.keys()) == 181
assert "-90" in data
assert "0" in data
assert "90" in data

# Check structure for a sample angle
sample_angle_data = data["0"]
assert isinstance(sample_angle_data, dict)
assert "lift" in sample_angle_data
assert "drag" in sample_angle_data
assert "pitch" in sample_angle_data

# Check data types for coefficients
assert isinstance(sample_angle_data["lift"], (int, float))
assert isinstance(sample_angle_data["drag"], (int, float))
assert isinstance(sample_angle_data["pitch"], (int, float))

def test_aero_coefficients_missing_flight_numbers(client):
"""Test error response when flight_numbers are missing."""
response = client.post("/api/aero_coefficients", json={})
assert response.status_code == 400
data = json.loads(response.data)
assert "error" in data
assert data["error"] == "Missing flight_numbers in request body"

def test_aero_coefficients_invalid_flight_numbers_type(client):
"""Test error response when flight_numbers is not an object."""
response = client.post("/api/aero_coefficients", json={"flight_numbers": "invalid_type"})
assert response.status_code == 400
data = json.loads(response.data)
assert "error" in data
assert data["error"] == "flight_numbers must be an object"

def test_aero_coefficients_invalid_flight_numbers_content(client):
"""Test error response for invalid content in flight_numbers (e.g., missing fields)."""
# This test assumes Discs.from_flight_numbers will raise an error if fields are missing.
# The exact error message might depend on the frispy library's implementation.
flight_numbers = {"speed": 10} # Missing glide, turn, fade
response = client.post("/api/aero_coefficients", json={"flight_numbers": flight_numbers})
assert response.status_code == 400
data = json.loads(response.data)
assert "error" in data
# The error message from Discs.from_flight_numbers can be specific.
# For now, we check that it starts with "Invalid flight_numbers:"
assert data["error"].startswith("Invalid flight_numbers:")
Loading