agrd commited on
Commit
ebbe4df
·
verified ·
1 Parent(s): 93d1b1b

Upload 5 files

Browse files
Files changed (5) hide show
  1. Agent.py +154 -0
  2. TakeoffRequest.py +35 -0
  3. app.py +23 -0
  4. calculate_takeoff_roll_data.py +163 -0
  5. requirements.txt +1 -0
Agent.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from huggingface_hub import InferenceClient
4
+ from schemas import TakeoffRequest
5
+ from calculator import calculate_takeoff_roll_data
6
+ import os
7
+ # Initialize the client with your token
8
+ hf_token = os.environ.get("Token_Apertus")
9
+
10
+ # Fallback for local testing if needed, or raise error if missing
11
+ if not hf_token:
12
+ # Optional: You can try to look for a local .env file or just warn the user
13
+ print("Warning: HF_TOKEN not found in environment variables.")
14
+
15
+ client = InferenceClient(token=hf_token)
16
+
17
+ def _extract_json_string(text):
18
+ """Helper to strip markdown code blocks if the LLM adds them."""
19
+ pattern = r"```json\s*(.*?)\s*```"
20
+ match = re.search(pattern, text, re.DOTALL)
21
+ if match:
22
+ return match.group(1)
23
+ return text
24
+
25
+ def call_apertus_llm(system_prompt, user_prompt):
26
+ """
27
+ Calls the Apertus Instruct model via Hugging Face API.
28
+ """
29
+
30
+ # 1. Define the model (Use the Instruct version for chat!)
31
+ model_id = "swiss-ai/Apertus-8B-Instruct-2509"
32
+
33
+ # 2. Format the messages
34
+ # The InferenceClient automatically applies the correct chat template
35
+ # (user/assistant tags) required by Apertus.
36
+ messages = [
37
+ {"role": "system", "content": system_prompt},
38
+ {"role": "user", "content": user_prompt}
39
+ ]
40
+
41
+ try:
42
+ # 3. Call the API
43
+ response = client.chat_completion(
44
+ model=model_id,
45
+ messages=messages,
46
+ max_tokens=500,
47
+ temperature=0.1, # Keep low for JSON data extraction
48
+ seed=42 # Optional: Helps keep results deterministic
49
+ )
50
+
51
+ # 4. Extract the content
52
+ # The API returns a structured object; we just need the content string.
53
+ raw_content = response.choices[0].message.content
54
+
55
+ # 5. Clean JSON (strips markdown ```json ... ``` if present)
56
+ return _extract_json_string(raw_content)
57
+
58
+ except Exception as e:
59
+ # Graceful error handling for API timeouts or loading errors
60
+ print(f"API Error: {e}")
61
+ return "{}"
62
+
63
+ class TakeoffAgent:
64
+ def __init__(self):
65
+ self.current_state = TakeoffRequest()
66
+
67
+ def process_message(self, user_message: str):
68
+ """
69
+ 1. Parse user message with LLM to update state.
70
+ 2. Check correctness/completeness.
71
+ 3. Calculate or Ask for more info.
72
+ """
73
+
74
+ # 1. System Prompt construction
75
+ system_instruction = """
76
+ You are an extraction assistant for a PA-28 Flight Calculator.
77
+ Extract parameters from the user text into JSON format matching these keys:
78
+ - altitude_ft (float)
79
+ - qnh_hpa (float)
80
+ - temperature_c (float)
81
+ - weight_kg (float)
82
+ - wind_type ("Headwind" or "Tailwind")
83
+ - wind_speed_kt (float)
84
+ - safety_factor (float)
85
+
86
+ Return ONLY valid JSON.
87
+ """
88
+
89
+ # 2. Call LLM (In real life, pass the history + new message)
90
+ llm_response_json = call_apertus_llm(system_instruction, user_message)
91
+
92
+ # 3. Update Pydantic Model (State Management)
93
+ try:
94
+ new_data = json.loads(llm_response_json)
95
+ # Update only fields that are present in the new extraction
96
+ updated_fields = self.current_state.model_dump(exclude_defaults=True)
97
+ updated_fields.update(new_data)
98
+ self.current_state = TakeoffRequest(**updated_fields)
99
+ except Exception as e:
100
+ # If LLM returns garbage, just ignore extraction for this turn
101
+ pass
102
+
103
+ # 4. Check Completeness (Guardrails)
104
+ if not self.current_state.is_complete():
105
+ missing = self.current_state.get_missing_fields()
106
+ response_text = f"I updated your flight parameters. I still need: {', '.join(missing)}.\n\n"
107
+ response_text += f"**Current State:**\n{self._format_state_summary()}"
108
+ return response_text
109
+
110
+ # 5. Check Correctness (Validation Logic)
111
+ warnings = []
112
+ if self.current_state.temperature_c > 45:
113
+ warnings.append("⚠️ Warning: Temperature is extremely high.")
114
+ if self.current_state.weight_kg > 1160:
115
+ warnings.append("⚠️ Warning: Weight exceeds typical MTOW.")
116
+
117
+ # 6. Run Calculation
118
+ try:
119
+ result = calculate_takeoff_roll_data(
120
+ indicated_altitude_ft=self.current_state.altitude_ft,
121
+ qnh_hpa=self.current_state.qnh_hpa,
122
+ temperature_c=self.current_state.temperature_c,
123
+ weight_kg=self.current_state.weight_kg,
124
+ wind_type=self.current_state.wind_type,
125
+ wind_speed=self.current_state.wind_speed_kt,
126
+ safety_factor=self.current_state.safety_factor
127
+ )
128
+
129
+ # 7. Formulate Response
130
+ response = "### ✅ Takeoff Performance Calculated\n\n"
131
+ if warnings:
132
+ response += "**Alerts:**\n" + "\n".join(warnings) + "\n\n"
133
+
134
+ response += f"**Environmental:**\n"
135
+ response += f"- Pressure Alt: {result['pressure_altitude']:.0f} ft\n"
136
+ response += f"- Density Alt: {result['density_altitude']:.0f} ft\n\n"
137
+
138
+ response += f"**Ground Roll:**\n"
139
+ response += f"- Base: {result['ground_roll']['base']:.1f} ft\n"
140
+ response += f"- Corrections: Weight {result['ground_roll']['weight_adj']:.1f}, Wind {result['ground_roll']['wind_adj']:.1f}\n"
141
+ response += f"- **Final: {result['ground_roll']['final_m']:.0f} meters** ({result['ground_roll']['final_ft']:.0f} ft)\n\n"
142
+
143
+ response += f"**50ft Obstacle:**\n"
144
+ response += f"- **Final: {result['obstacle_50ft']['final_m']:.0f} meters** ({result['obstacle_50ft']['final_ft']:.0f} ft)\n"
145
+
146
+ return response
147
+
148
+ except Exception as e:
149
+ return f"Error in calculation: {str(e)}"
150
+
151
+ def _format_state_summary(self):
152
+ s = self.current_state
153
+ return (f"- Alt: {s.altitude_ft} ft\n- Temp: {s.temperature_c} C\n"
154
+ f"- Weight: {s.weight_kg} kg\n- Wind: {s.wind_speed_kt} kt ({s.wind_type})")
TakeoffRequest.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field, field_validator
2
+ from typing import Optional, Literal
3
+
4
+ class TakeoffRequest(BaseModel):
5
+ altitude_ft: Optional[float] = Field(None, description="Indicated altitude in feet")
6
+ qnh_hpa: Optional[float] = Field(1013.25, description="QNH pressure setting in hPa")
7
+ temperature_c: Optional[float] = Field(None, description="Outside air temperature in Celsius")
8
+ weight_kg: Optional[float] = Field(None, description="Aircraft mass in kg")
9
+ wind_type: Literal["Headwind", "Tailwind"] = Field("Headwind", description="Direction of wind component")
10
+ wind_speed_kt: Optional[float] = Field(None, description="Wind speed in knots")
11
+ safety_factor: float = Field(1.0, description="Safety factor multiplier (usually 1.0 to 1.5)")
12
+
13
+ @field_validator('weight_kg')
14
+ def check_weight(cls, v):
15
+ if v is not None and (v < 800 or v > 1500):
16
+ # Soft warning logic could go here, but for now we just validate bounds roughly
17
+ pass
18
+ return v
19
+
20
+ def is_complete(self) -> bool:
21
+ """Checks if all mandatory fields are present."""
22
+ return all(x is not None for x in [
23
+ self.altitude_ft,
24
+ self.temperature_c,
25
+ self.weight_kg,
26
+ self.wind_speed_kt
27
+ ])
28
+
29
+ def get_missing_fields(self) -> list[str]:
30
+ missing = []
31
+ if self.altitude_ft is None: missing.append("Altitude")
32
+ if self.temperature_c is None: missing.append("Temperature")
33
+ if self.weight_kg is None: missing.append("Aircraft Weight")
34
+ if self.wind_speed_kt is None: missing.append("Wind Speed")
35
+ return missing
app.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from agent import TakeoffAgent
3
+
4
+ # Initialize the agent
5
+ agent = TakeoffAgent()
6
+
7
+ def chat_response(message, history):
8
+ # The agent handles state, parsing, and calculation logic
9
+ response = agent.process_message(message)
10
+ return response
11
+
12
+ # Clean Chat Interface
13
+ with gr.Blocks(theme=gr.themes.Soft()) as demo:
14
+ gr.Markdown("#✈️ PA-28-181 AI Performance Engineer")
15
+ gr.Markdown("Describe your conditions naturally. Example: *'I am at 2000ft, 25C, QNH 1013, 1090kg with 10kt headwind'*")
16
+
17
+ chat_interface = gr.ChatInterface(
18
+ fn=chat_response,
19
+ type="messages",
20
+ )
21
+
22
+ if __name__ == "__main__":
23
+ demo.launch()
calculate_takeoff_roll_data.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ # --- Data Digitized from the PA-28-181 Chart ---
4
+ TAKEOFF_DATA = {
5
+ 0: {8.283945422852682: 892.4445513013161, 29.982568972771546: 1283.66893069664},
6
+ 1000: {-3.8119853338943273: 888.693875097673, 30.775981246618983: 1507.1707639598485},
7
+ 2000: {-12.181282683176057: 892.3002945242533, 30.905812345975832: 1674.6528821301918},
8
+ 3000: {-22.421109575043573: 882.9236040151468, 31.045260563803566: 1854.5410831279678},
9
+ 4000: {-29.855142153032396: 893.021578409569, 30.908216625593553: 2077.7544028370503},
10
+ 5000: {-37.61856103864879: 878.2112159644166, 30.775981246618983: 2307.1707639598485},
11
+ }
12
+
13
+ TAKEOFF_DATA_50Ft = {
14
+ 1000 : {-5.699053711848606: 1500, 4.250299880047962: 1770.491803278689, 12.581634013061446: 2024.5901639344265, 21.543382646941225: 2319.6721311475417, 29.2123150739704: 2598.3606557377057},
15
+ 2000 : {-14.972007464676082:1494.6680885097298, -8.914956011730212:1681.9514796054377, -0.6291655558517704:1942.5486536923481, 5.737136763529719:2154.3588376432945, 12.092775259930704:2382.564649426819, 28.936283657691277:2985.670487869901},
16
+ 3000 : {-23.417755265262592:1480.0053319114895, -18.011197014129564:1667.422020794454, -12.60463876299653:1854.8387096774186, -5.60917088776327:2099.306851506264, -0.21860837110104114:2311.3169821380957, 10.26392961876833:2694.4148227139426, 21.370301252999198:3118.368435083977, 28.978938949613436:3420.087976539589},
17
+ 4000 : {-35.78245801119701:1490.735803785657, -27.155425219941343:1726.6728872300714, -21.73287123433751:1889.496134364169, -12.828579045587837:2199.1468941615562, -3.62036790189282:2541.5222607304713, 2.0954412156758337:2753.4657424686748, 10.64782724606772:3104.1722207411353, 20.122633964276204:3536.6568914956006, 28.63236470274593:3952.9458810983733},
18
+ 5000 : { -37.53132498000533:1679.6187683284456, -28.595041322314046:1940.0826446280985, -18.11783524393494:2331.378299120234, -8.621700879765399:2731.0717142095436, 1.519594774726741:3138.829645427885, 12.1940815782458:3726.806185017328, 20.047987203412433:4151.426286323647, 25.03332444681419:4486.470274593441},
19
+ }
20
+ REFERENCE_WEIGHT_LBS = 2850.0
21
+ MIN_CHART_WEIGHT_LBS = 2050.0
22
+
23
+ def _interpolate_1d(x, x_points, y_points):
24
+ if not x_points or not y_points: return 0
25
+ if x <= x_points[0]: return y_points[0]
26
+ if x >= x_points[-1]: return y_points[-1]
27
+ for i in range(len(x_points) - 1):
28
+ if x_points[i] <= x <= x_points[i+1]:
29
+ x1, x2 = x_points[i], x_points[i+1]
30
+ y1, y2 = y_points[i], y_points[i+1]
31
+ if (x2 - x1) == 0: return y1
32
+ return y1 + (y2 - y1) * ((x - x1) / (x2 - x1))
33
+ return y_points[0]
34
+
35
+ def _get_distance_at_temp_and_alt(temp, alt, data):
36
+ alt_points = sorted(data.keys())
37
+ if not alt_points: return 0
38
+ alt_low, alt_high = -1, -1
39
+ if alt <= alt_points[0]: alt_low = alt_high = alt_points[0]
40
+ elif alt >= alt_points[-1]: alt_low = alt_high = alt_points[-1]
41
+ else:
42
+ for i in range(len(alt_points) - 1):
43
+ if alt_points[i] <= alt <= alt_points[i+1]:
44
+ alt_low, alt_high = alt_points[i], alt_points[i+1]
45
+ break
46
+
47
+ if alt_low not in data: return 0
48
+ temp_points_low = sorted(data[alt_low].keys())
49
+ dist_points_low = [data[alt_low][t] for t in temp_points_low]
50
+ dist_at_alt_low = _interpolate_1d(temp, temp_points_low, dist_points_low)
51
+
52
+ if alt_low == alt_high: return dist_at_alt_low
53
+
54
+ if alt_high not in data: return dist_at_alt_low
55
+ temp_points_high = sorted(data[alt_high].keys())
56
+ dist_points_high = [data[alt_high][t] for t in temp_points_high]
57
+ dist_at_alt_high = _interpolate_1d(temp, temp_points_high, dist_points_high)
58
+
59
+ return _interpolate_1d(alt, [alt_low, alt_high], [dist_at_alt_low, dist_at_alt_high])
60
+
61
+ def _calculate_weight_correction(base_distance, weight_lbs):
62
+ if weight_lbs >= REFERENCE_WEIGHT_LBS:
63
+ return 0.0
64
+ x_points = [1999.6411139821994, 2544.621494879893]
65
+ y_points = [840.3100775193798, 1410.8527131782948]
66
+ distance_at_actual_weight = _interpolate_1d(weight_lbs, x_points, y_points)
67
+ distance_at_reference_weight = _interpolate_1d(REFERENCE_WEIGHT_LBS, x_points, y_points)
68
+ weight_correction_delta = distance_at_actual_weight - distance_at_reference_weight
69
+ return weight_correction_delta
70
+
71
+ def _calculate_weight_correction_50ft(base_distance, weight_lbs):
72
+ if weight_lbs >= REFERENCE_WEIGHT_LBS:
73
+ return 0.0
74
+ x_points = [2074.7658688865768, 2545.629552549428]
75
+ y_points = [1557.622268470344, 2718.652445369407]
76
+ distance_at_actual_weight = _interpolate_1d(weight_lbs, x_points, y_points)
77
+ distance_at_reference_weight = _interpolate_1d(REFERENCE_WEIGHT_LBS, x_points, y_points)
78
+ weight_correction_delta = distance_at_actual_weight - distance_at_reference_weight
79
+ return weight_correction_delta
80
+
81
+ def _calculate_headwind_correction(wind_knots):
82
+ x_points = [0.16104294478526526, 15.04722311914756]
83
+ y_points = [1139.2960929932183, 847.9173393606702]
84
+ distance_at_actual_wind = _interpolate_1d(wind_knots, x_points, y_points)
85
+ distance_at_zero_wind = _interpolate_1d(0, x_points, y_points)
86
+ return distance_at_actual_wind - distance_at_zero_wind
87
+
88
+ def _calculate_headwind_correction_50ft(wind_knots):
89
+ x_points = [-0.4118297401879545, 14.90049751243781]
90
+ y_points = [2083.6557950985825, 1657.0849456421615]
91
+ distance_at_actual_wind = _interpolate_1d(wind_knots, x_points, y_points)
92
+ distance_at_zero_wind = _interpolate_1d(0, x_points, y_points)
93
+ return distance_at_actual_wind - distance_at_zero_wind
94
+
95
+ def _calculate_tailwind_correction(wind_knots):
96
+ x_points = [0.16104294478526526, 5.268404907975452]
97
+ y_points = [1139.2960929932183, 1414.1427187600893]
98
+ distance_at_actual_wind = _interpolate_1d(wind_knots, x_points, y_points)
99
+ distance_at_zero_wind = _interpolate_1d(0, x_points, y_points)
100
+ return distance_at_actual_wind - distance_at_zero_wind
101
+
102
+ def _calculate_tailwind_correction_50ft(wind_knots):
103
+ x_points = [-0.10779436152570554, 4.483139856274192]
104
+ y_points = [1681.684171733924, 2061.912658927585]
105
+ distance_at_actual_wind = _interpolate_1d(wind_knots, x_points, y_points)
106
+ distance_at_zero_wind = _interpolate_1d(0, x_points, y_points)
107
+ return distance_at_actual_wind - distance_at_zero_wind
108
+
109
+ def calculate_density_altitude(pressure_altitude_ft, outside_air_temp_c):
110
+ isa_temp_c = 15 - (2 * (pressure_altitude_ft / 1000))
111
+ return pressure_altitude_ft + (120 * (outside_air_temp_c - isa_temp_c))
112
+
113
+ def calculate_takeoff_roll_data(indicated_altitude_ft, qnh_hpa, temperature_c, weight_kg, wind_type, wind_speed, safety_factor):
114
+ # Common calculations
115
+ weight_lbs = weight_kg * 2.20462
116
+ pressure_altitude = indicated_altitude_ft + ((1013.2 - qnh_hpa) * 27)
117
+ density_altitude = calculate_density_altitude(pressure_altitude, temperature_c)
118
+
119
+ # --- 0ft (Ground Roll) Calculation Path ---
120
+ base_distance_0ft = _get_distance_at_temp_and_alt(temperature_c, pressure_altitude, TAKEOFF_DATA)
121
+ weight_delta_0ft = _calculate_weight_correction(base_distance_0ft, weight_lbs)
122
+ distance_after_weight_0ft = base_distance_0ft + weight_delta_0ft
123
+ wind_delta_0ft = 0.0
124
+ if wind_type == "Headwind":
125
+ wind_delta_0ft = _calculate_headwind_correction(wind_speed)
126
+ elif wind_type == "Tailwind":
127
+ wind_delta_0ft = _calculate_tailwind_correction(wind_speed)
128
+ distance_after_wind_0ft = distance_after_weight_0ft + wind_delta_0ft
129
+ final_distance_ft_0ft = distance_after_wind_0ft * safety_factor
130
+ final_distance_m_0ft = final_distance_ft_0ft * 0.3048
131
+
132
+ # --- 50ft Obstacle Calculation Path ---
133
+ base_distance_50ft = _get_distance_at_temp_and_alt(temperature_c, pressure_altitude, TAKEOFF_DATA_50Ft)
134
+ weight_delta_50ft = _calculate_weight_correction_50ft(base_distance_50ft, weight_lbs)
135
+ distance_after_weight_50ft = base_distance_50ft + weight_delta_50ft
136
+ wind_delta_50ft = 0.0
137
+ if wind_type == "Headwind":
138
+ wind_delta_50ft = _calculate_headwind_correction_50ft(wind_speed)
139
+ elif wind_type == "Tailwind":
140
+ wind_delta_50ft = _calculate_tailwind_correction_50ft(wind_speed)
141
+ distance_after_wind_50ft = distance_after_weight_50ft + wind_delta_50ft
142
+ final_distance_ft_50ft = distance_after_wind_50ft * safety_factor
143
+ final_distance_m_50ft = final_distance_ft_50ft * 0.3048
144
+
145
+ # Return dictionary for easy access
146
+ return {
147
+ "pressure_altitude": pressure_altitude,
148
+ "density_altitude": density_altitude,
149
+ "ground_roll": {
150
+ "base": base_distance_0ft,
151
+ "weight_adj": weight_delta_0ft,
152
+ "wind_adj": wind_delta_0ft,
153
+ "final_ft": final_distance_ft_0ft,
154
+ "final_m": final_distance_m_0ft
155
+ },
156
+ "obstacle_50ft": {
157
+ "base": base_distance_50ft,
158
+ "weight_adj": weight_delta_50ft,
159
+ "wind_adj": wind_delta_50ft,
160
+ "final_ft": final_distance_ft_50ft,
161
+ "final_m": final_distance_m_50ft
162
+ }
163
+ }
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio