F1 circuit layouts with year-by-year SVGs — manually traced track variations
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

455 строки
15KB

  1. import fastf1
  2. import numpy as np
  3. import matplotlib.pyplot as plt
  4. import os
  5. import json
  6. from datetime import datetime
  7. import re
  8. import fastf1.core as f1
  9. # Enable cache to speed up subsequent data loading
  10. fastf1.Cache.enable_cache('cache')
  11. def rotate(xy, *, angle):
  12. """Rotate coordinates by the given angle."""
  13. rot_mat = np.array([[np.cos(angle), np.sin(angle)],
  14. [-np.sin(angle), np.cos(angle)]])
  15. return np.matmul(xy, rot_mat)
  16. def get_track_coordinates(session: f1.Session):
  17. """Extract track coordinates from session data."""
  18. try:
  19. # Get the fastest lap and position data
  20. lap = session.laps.pick_fastest()
  21. pos = lap.get_pos_data()
  22. # Get circuit information
  23. circuit_info = session.get_circuit_info()
  24. # Print available attributes for debugging
  25. print("\nAvailable circuit_info attributes:")
  26. for attr in dir(circuit_info):
  27. if not attr.startswith('_'): # Skip private attributes
  28. try:
  29. value = getattr(circuit_info, attr)
  30. print(f" {attr}: {value}")
  31. except Exception as e:
  32. print(f" {attr}: Error accessing ({str(e)})")
  33. # Get track coordinates
  34. track = pos.loc[:, ('X', 'Y')].to_numpy()
  35. # Convert the rotation angle from degrees to radian
  36. track_angle = circuit_info.rotation / 180 * np.pi
  37. # Rotate the track coordinates
  38. rotated_track = rotate(track, angle=track_angle)
  39. # Create circuit_info dictionary with safe attribute access
  40. circuit_info_dict = {
  41. 'name': session.event['EventName'],
  42. 'country': session.event['Country'],
  43. 'city': session.event['Location'],
  44. 'length': getattr(circuit_info, 'length', None),
  45. 'rotation': getattr(circuit_info, 'rotation', None),
  46. }
  47. # Create event_info dictionary
  48. event_info_dict = {
  49. 'name': session.event['EventName'],
  50. 'year': session.event['EventDate'].year,
  51. 'official_name': session.event.get('OfficialEventName', session.event['EventName']),
  52. 'location': session.event['Location']
  53. }
  54. return {
  55. 'coordinates': rotated_track.tolist(),
  56. 'circuit_info': circuit_info_dict,
  57. 'event_info': event_info_dict
  58. }
  59. except Exception as e:
  60. print(f"Error extracting track coordinates: {str(e)}")
  61. import traceback
  62. traceback.print_exc()
  63. return None
  64. def create_geojson(track_data):
  65. """Create a GeoJSON representation of the track."""
  66. # Extract data from track_data
  67. coordinates = track_data['coordinates']
  68. circuit_info = track_data['circuit_info']
  69. event_info = track_data['event_info']
  70. # Create a simplified ID based on country and year
  71. country_code = get_country_code(circuit_info['country'])
  72. year = event_info['year']
  73. circuit_id = f"{country_code.lower()}-{year}"
  74. # Ensure length is an integer if available, or None if not
  75. circuit_length = None
  76. if circuit_info.get('length'):
  77. try:
  78. circuit_length = int(circuit_info['length'])
  79. except (ValueError, TypeError):
  80. pass
  81. # Create GeoJSON structure
  82. geojson = {
  83. "type": "FeatureCollection",
  84. "name": circuit_id,
  85. "features": [
  86. {
  87. "type": "Feature",
  88. "properties": {
  89. "id": circuit_id,
  90. "Location": event_info['location'],
  91. "Name": circuit_info['name'],
  92. "opened": None, # Not available from API
  93. "firstgp": None, # Not available from API
  94. "length": circuit_length,
  95. "altitude": None # Not available from API
  96. },
  97. "geometry": {
  98. "type": "LineString",
  99. "coordinates": coordinates
  100. }
  101. }
  102. ]
  103. }
  104. # Calculate bounding box if coordinates are available
  105. if coordinates and len(coordinates) > 0:
  106. x_coords = [coord[0] for coord in coordinates]
  107. y_coords = [coord[1] for coord in coordinates]
  108. bbox = [min(x_coords), min(y_coords), max(x_coords), max(y_coords)]
  109. # Add bounding box to GeoJSON
  110. geojson["bbox"] = bbox
  111. geojson["features"][0]["bbox"] = bbox
  112. return geojson
  113. def get_country_code(country_name):
  114. """Get a two-letter country code from a country name."""
  115. country_codes = {
  116. "Australia": "AU",
  117. "Austria": "AT",
  118. "Azerbaijan": "AZ",
  119. "Bahrain": "BH",
  120. "Belgium": "BE",
  121. "Brazil": "BR",
  122. "Canada": "CA",
  123. "China": "CN",
  124. "Denmark": "DK",
  125. "France": "FR",
  126. "Germany": "DE",
  127. "Hungary": "HU",
  128. "Italy": "IT",
  129. "Japan": "JP",
  130. "Mexico": "MX",
  131. "Monaco": "MC",
  132. "Netherlands": "NL",
  133. "Portugal": "PT",
  134. "Qatar": "QA",
  135. "Russia": "RU",
  136. "Saudi Arabia": "SA",
  137. "Singapore": "SG",
  138. "Spain": "ES",
  139. "Sweden": "SE",
  140. "Switzerland": "CH",
  141. "United Arab Emirates": "AE",
  142. "United Kingdom": "GB",
  143. "United States": "US",
  144. "USA": "US",
  145. "Abu Dhabi": "AE", # Special case for events
  146. "Great Britain": "GB",
  147. "UK": "GB",
  148. "Emilia Romagna": "IT", # Special case for events
  149. "Styria": "AT", # Special case for events
  150. "Miami": "US",
  151. "Las Vegas": "US"
  152. }
  153. return country_codes.get(country_name, "XX")
  154. def sanitize_filename(name):
  155. """Convert a string to a valid filename."""
  156. # Replace special characters with spaces
  157. name = re.sub(r'[\\/*?:"<>|]', ' ', name)
  158. # Replace multiple spaces with single space
  159. name = re.sub(r'\s+', ' ', name).strip()
  160. return name
  161. def save_svg(coordinates, output_path):
  162. """Generate and save an SVG representation of the circuit."""
  163. if not coordinates:
  164. print("No coordinates available to create SVG")
  165. return False
  166. # Extract x and y coordinates
  167. x_coords = [coord[0] for coord in coordinates]
  168. y_coords = [coord[1] for coord in coordinates]
  169. # Normalize coordinates to fit in SVG
  170. min_x, max_x = min(x_coords), max(x_coords)
  171. min_y, max_y = min(y_coords), max(y_coords)
  172. # Add some padding
  173. x_padding = (max_x - min_x) * 0.05
  174. y_padding = (max_y - min_y) * 0.05
  175. min_x -= x_padding
  176. max_x += x_padding
  177. min_y -= y_padding
  178. max_y += y_padding
  179. # SVG dimensions
  180. svg_width = 800
  181. svg_height = int(svg_width * (max_y - min_y) / (max_x - min_x))
  182. # Create SVG header
  183. svg = [
  184. f'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
  185. f'<svg width="{svg_width}" height="{svg_height}" viewBox="0 0 {svg_width} {svg_height}" xmlns="http://www.w3.org/2000/svg">',
  186. ]
  187. # Create path data
  188. path_data = []
  189. for i, (x, y) in enumerate(coordinates):
  190. # Convert coordinates to SVG coordinates
  191. svg_x = svg_width * (x - min_x) / (max_x - min_x)
  192. # Flip y-axis because SVG has origin at top-left
  193. svg_y = svg_height * (1 - (y - min_y) / (max_y - min_y))
  194. if i == 0:
  195. path_data.append(f"M{svg_x:.2f},{svg_y:.2f}")
  196. else:
  197. path_data.append(f"L{svg_x:.2f},{svg_y:.2f}")
  198. # Add path to SVG
  199. svg.append(f' <path d="{" ".join(path_data)}" fill="none" stroke="black" stroke-width="2"/>')
  200. svg.append('</svg>')
  201. # Write SVG to file
  202. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  203. with open(output_path, 'w', encoding='utf-8') as f:
  204. f.write('\n'.join(svg))
  205. return True
  206. def save_png(coordinates, output_path):
  207. """Generate and save a PNG representation of the circuit."""
  208. if not coordinates:
  209. print("No coordinates available to create PNG")
  210. return False
  211. # Extract x and y coordinates
  212. x_coords = [coord[0] for coord in coordinates]
  213. y_coords = [coord[1] for coord in coordinates]
  214. # Create figure with transparent background
  215. fig = plt.figure(figsize=(10, 8), facecolor="none")
  216. ax = fig.add_subplot(111)
  217. # Plot the circuit with black line
  218. ax.plot(x_coords, y_coords, 'k-', linewidth=2)
  219. # Remove grid, axes, and border
  220. ax.grid(False)
  221. ax.set_xticks([])
  222. ax.set_yticks([])
  223. ax.spines['top'].set_visible(False)
  224. ax.spines['right'].set_visible(False)
  225. ax.spines['bottom'].set_visible(False)
  226. ax.spines['left'].set_visible(False)
  227. # Keep aspect ratio equal
  228. ax.set_aspect('equal')
  229. # Add padding around the circuit
  230. ax.margins(0.1)
  231. # Save with transparent background
  232. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  233. fig.savefig(output_path, transparent=True, bbox_inches='tight', pad_inches=0.1)
  234. plt.close(fig)
  235. return True
  236. def get_available_seasons():
  237. """Get a list of available F1 seasons."""
  238. current_year = datetime.now().year
  239. # F1 API has data from 1950 to current year
  240. return list(range(1950, current_year + 1))
  241. def prompt_season_selection():
  242. """Prompt the user to select a season."""
  243. seasons = get_available_seasons()
  244. print("\nAvailable F1 Seasons:")
  245. # Display seasons in rows of 10
  246. for i in range(0, len(seasons), 10):
  247. row = seasons[i:i+10]
  248. print(" ".join(f"{year:<6}" for year in row))
  249. while True:
  250. try:
  251. selection = input("\nEnter season year (e.g., 2023): ")
  252. year = int(selection)
  253. if year in seasons:
  254. return year
  255. else:
  256. print(f"Invalid season: {year}. Please choose a year between 1950 and {seasons[-1]}.")
  257. except ValueError:
  258. print("Please enter a valid year (e.g., 2023).")
  259. def get_events_for_season(year):
  260. """Get all events for a specific season."""
  261. try:
  262. schedule = fastf1.get_event_schedule(year)
  263. return schedule
  264. except Exception as e:
  265. print(f"Error fetching events for {year}: {str(e)}")
  266. return None
  267. def prompt_event_selection(events):
  268. """Prompt the user to select an event from the schedule."""
  269. if events is None or len(events) == 0:
  270. print("No events available for the selected season.")
  271. return None
  272. print("\nAvailable Grands Prix:")
  273. for i, (_, event) in enumerate(events.iterrows(), 1):
  274. event_name = event['EventName']
  275. location = event['Location']
  276. date = event['EventDate'].strftime('%Y-%m-%d')
  277. print(f"{i:2}. {event_name:<35} {location:<20} {date}")
  278. while True:
  279. try:
  280. selection = input("\nEnter the number of the Grand Prix: ")
  281. idx = int(selection) - 1
  282. if 0 <= idx < len(events):
  283. return events.iloc[idx]
  284. else:
  285. print(f"Invalid selection. Please choose a number between 1 and {len(events)}.")
  286. except ValueError:
  287. print("Please enter a valid number.")
  288. def create_output_directory(country, location, circuit_name, year):
  289. """Create the appropriate directory structure for saving files."""
  290. # Sanitize names for directory structure
  291. country = sanitize_filename(country)
  292. location = sanitize_filename(location)
  293. circuit_name = sanitize_filename(circuit_name)
  294. # Create directory path
  295. base_dir = os.path.join("circuits", country, location)
  296. os.makedirs(base_dir, exist_ok=True)
  297. # Create filename base (without extension)
  298. filename_base = f"{circuit_name} - {year}-"
  299. return base_dir, filename_base
  300. def main():
  301. try:
  302. print("F1 Circuit Coordinate Generator")
  303. print("===============================")
  304. print("This tool allows you to download coordinates for F1 circuits and save them as GeoJSON, SVG, and PNG files.")
  305. # Step 1: Select a season
  306. year = prompt_season_selection()
  307. print(f"\nSelected season: {year}")
  308. # Step 2: Get events for the selected season
  309. print(f"\nFetching events for {year}...")
  310. events = get_events_for_season(year)
  311. if events is None or len(events) == 0:
  312. print(f"No events found for the {year} season. Exiting.")
  313. return
  314. # Step 3: Select an event
  315. event = prompt_event_selection(events)
  316. if event is None:
  317. print("No event selected. Exiting.")
  318. return
  319. print(f"\nSelected event: {event['EventName']} at {event['Location']}")
  320. # Step 4: Download the session data (try qualifying first, then other sessions)
  321. print("\nDownloading session data (this may take a moment)...")
  322. session_types = ['Q', 'FP1', 'FP2', 'FP3', 'R']
  323. session = None
  324. for session_type in session_types:
  325. try:
  326. print(f"Trying to load {session_type} session...")
  327. session = fastf1.get_session(year, event['EventName'], session_type)
  328. session.load()
  329. if session and session.total_laps is not None and session.total_laps > 0:
  330. print(f"Successfully loaded {session_type} session")
  331. break
  332. except Exception as e:
  333. print(f"Could not load {session_type} session: {str(e)}")
  334. if session is None:
  335. print("Could not load any session data. Exiting.")
  336. return
  337. # Step 5: Extract track coordinates
  338. print("\nExtracting track coordinates...")
  339. track_data = get_track_coordinates(session)
  340. if not track_data:
  341. print("Failed to extract track coordinates. Exiting.")
  342. return
  343. # Step 6: Create GeoJSON data
  344. print("\nCreating GeoJSON data...")
  345. geojson_data = create_geojson(track_data)
  346. # Step 7: Determine output directory and filenames
  347. circuit_info = track_data['circuit_info']
  348. event_info = track_data['event_info']
  349. country = circuit_info['country']
  350. location = event_info['location']
  351. circuit_name = circuit_info['name']
  352. year = event_info['year']
  353. print(f"\nPreparing to save circuit: {circuit_name} ({location}, {country}) from {year}")
  354. base_dir, filename_base = create_output_directory(country, location, circuit_name, year)
  355. geojson_path = os.path.join(base_dir, f"{filename_base}.geo.json")
  356. svg_path = os.path.join(base_dir, f"{filename_base}.svg")
  357. png_path = os.path.join(base_dir, f"{filename_base}.png")
  358. # Step 8: Save files
  359. print("\nSaving files...")
  360. # Save GeoJSON
  361. with open(geojson_path, 'w', encoding='utf-8') as f:
  362. json.dump(geojson_data, f, indent=2)
  363. print(f"Saved GeoJSON to: {geojson_path}")
  364. # Save SVG
  365. if save_svg(track_data['coordinates'], svg_path):
  366. print(f"Saved SVG to: {svg_path}")
  367. # Save PNG
  368. if save_png(track_data['coordinates'], png_path):
  369. print(f"Saved PNG to: {png_path}")
  370. print("\nDone! Files have been saved successfully.")
  371. except Exception as e:
  372. print(f"\nAn error occurred: {str(e)}")
  373. import traceback
  374. traceback.print_exc()
  375. if __name__ == "__main__":
  376. main()