Coverage for fmc/run.py: 83%
191 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-16 09:11 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-16 09:11 +0000
1#!/usr/bin/env python3
3# Define function ...
4def run(
5 flightLog,
6 /,
7 *,
8 colorByPurpose = False,
9 debug = __debug__,
10 extraCountries = None,
11 flightMap = None,
12 leftDist = 2392.2e3, # These default values come from my own
13 leftLat = +39.411078, # personal flight log. These correspond
14 leftLon = -97.871822, # to the United States Of America.
15 maxYear = None,
16 minYear = None,
17 nIter = 100,
18 notVisited = None,
19 onlyValid = False,
20 optimise = True,
21 renames = None,
22 repair = False,
23 rightDist = 2345.0e3, # These default values come from my own
24 rightLat = +49.879310, # personal flight log. These correspond
25 rightLon = +3.172021, # to Continental Europe.
26 strip = True,
27 timeout = 60.0,
28):
29 """Make a PNG map from a CSV file
31 Parameters
32 ----------
33 flightLog : str
34 the CSV of your flights
35 debug : bool, optional
36 print debug messages
37 extraCountries : list of str, optional
38 a list of extra countries that you have visited but which you have not
39 flown to (e.g., you took a train)
40 flightMap : str, optional
41 the PNG map
42 leftDist : float, optional
43 the field-of-view around the left-hand sub-map central point (in metres)
44 leftLat : float, optional
45 the latitude of the central point of the left-hand sub-map (in degrees)
46 leftLon : float, optional
47 the longitude of the central point of the left-hand sub-map (in degrees)
48 maxYear : int, optional
49 the maximum year to use for the survey
50 minYear : int, optional
51 the minimum year to use for the survey
52 nIter : int, optional
53 the maximum number of iterations (particularly the Vincenty formula)
54 notVisited : list of str, optional
55 a list of countries which you have flown to but not visited (e.g., you
56 just transferred planes)
57 onlyValid : bool, optional
58 only return valid Polygons (checks for validity can take a while, if
59 being called often)
60 optimise : bool, optional
61 optimise the PNG map
62 renames : dict, optional
63 a mapping from OpenFlights country names to Natural Earth country names
64 repair : bool, optional
65 attempt to repair invalid Polygons
66 rightDist : float, optional
67 the field-of-view around the right-hand sub-map central point (in metres)
68 rightLat : float, optional
69 the latitude of the central point of the right-hand sub-map (in degrees)
70 rightLon : float, optional
71 the longitude of the central point of the right-hand sub-map (in degrees)
72 strip : bool, optional
73 strip metadata from PNG map too
74 timeout : float, optional
75 the timeout for any requests/subprocess calls (in seconds)
77 Notes
78 -----
79 Copyright 2016 Thomas Guymer [1]_
81 References
82 ----------
83 .. [1] FMC, https://github.com/Guymer/fmc
84 """
86 # Import standard modules ...
87 import csv
88 import datetime
89 import json
90 import os
91 import pathlib
93 # Import special modules ...
94 try:
95 import cartopy
96 cartopy.config.update(
97 {
98 "cache_dir" : pathlib.PosixPath("~/.local/share/cartopy_cache").expanduser(),
99 }
100 )
101 except:
102 raise Exception("\"cartopy\" is not installed; run \"pip install --user Cartopy\"") from None
103 try:
104 import matplotlib
105 matplotlib.rcParams.update(
106 {
107 "backend" : "Agg", # NOTE: See https://matplotlib.org/stable/gallery/user_interfaces/canvasagg.html
108 "figure.dpi" : 300,
109 "figure.figsize" : (9.6, 7.2), # NOTE: See https://github.com/Guymer/misc/blob/main/README.md#matplotlib-figure-sizes
110 "font.size" : 8,
111 }
112 )
113 import matplotlib.pyplot
114 except:
115 raise Exception("\"matplotlib\" is not installed; run \"pip install --user matplotlib\"") from None
117 # Import my modules ...
118 try:
119 import pyguymer3
120 import pyguymer3.geo
121 import pyguymer3.image
122 except:
123 raise Exception("\"pyguymer3\" is not installed; run \"pip install --user PyGuymer3\"") from None
125 # Import sub-functions ...
126 from .coordinates_of_IATA import coordinates_of_IATA
127 from .country_of_IATA import country_of_IATA
129 # Populate default values ...
130 if extraCountries is None:
131 extraCountries = []
132 if flightMap is None:
133 flightMap = f'{flightLog.removesuffix(".csv")}.png'
134 if maxYear is None:
135 maxYear = pyguymer3.now().year
136 if notVisited is None:
137 notVisited = []
138 if renames is None:
139 renames = {}
141 # Convert the list of extra countries to a dictionary of extra countries
142 # where the key is the country and the value is the colour to draw it with ...
143 newExtraCountries = {}
144 for extraCountry in extraCountries:
145 newExtraCountries[extraCountry] = (1.0, 0.0, 0.0, 0.25)
146 extraCountries = newExtraCountries
147 del newExtraCountries
149 # **************************************************************************
151 # Set the half-width of the bars on the histogram ...
152 hw = 0.2
154 # Create short-hands ...
155 c0, c1 = matplotlib.rcParams["axes.prop_cycle"].by_key()["color"][:2]
156 c0 = matplotlib.colors.to_rgb(c0)
157 c1 = matplotlib.colors.to_rgb(c1)
159 # Create figure ...
160 # NOTE: I would like to use (4.8, 7.2) so as to be consistent with all my
161 # other figures (see linked 4K discussion above), however, the result
162 # is very poor due to the too wide, single line, summary label/string.
163 # The result gets even worse when ".tight_layout()" is called.
164 fg = matplotlib.pyplot.figure(figsize = (2 * 4.8, 2 * 7.2))
166 # Create axes ...
167 axT = pyguymer3.geo.add_axis(
168 fg,
169 add_coastlines = True,
170 add_gridlines = True,
171 debug = debug,
172 index = (1, 2),
173 ncols = 2,
174 nIter = nIter,
175 nrows = 3,
176 onlyValid = onlyValid,
177 repair = repair,
178 )
179 axL = pyguymer3.geo.add_axis(
180 fg,
181 add_coastlines = True,
182 add_gridlines = True,
183 debug = debug,
184 dist = leftDist,
185 index = 3,
186 lat = leftLat,
187 lon = leftLon,
188 ncols = 2,
189 nIter = nIter,
190 nrows = 3,
191 onlyValid = onlyValid,
192 repair = repair,
193 satellite_height = False,
194 )
195 axR = pyguymer3.geo.add_axis(
196 fg,
197 add_coastlines = True,
198 add_gridlines = True,
199 debug = debug,
200 dist = rightDist,
201 index = 4,
202 lat = rightLat,
203 lon = rightLon,
204 ncols = 2,
205 nIter = nIter,
206 nrows = 3,
207 onlyValid = onlyValid,
208 repair = repair,
209 satellite_height = False,
210 )
211 axB = fg.add_subplot(
212 3,
213 2,
214 (5, 6),
215 )
217 # Configure axis (top) ...
218 pyguymer3.geo.add_map_background(
219 axT,
220 debug = debug,
221 resolution = "large8192px",
222 )
224 # Configure axis (left) ...
225 pyguymer3.geo.add_map_background(
226 axL,
227 debug = debug,
228 resolution = "large8192px",
229 )
231 # Configure axis (right) ...
232 pyguymer3.geo.add_map_background(
233 axR,
234 debug = debug,
235 resolution = "large8192px",
236 )
238 # Load airport list ...
239 with open(f"{os.path.dirname(__file__)}/db.json", "rt", encoding = "utf-8") as fObj:
240 airports = json.load(fObj)
242 # Initialize flight dictionary, histograms and total distance ...
243 flights = {}
244 businessX = []
245 businessY = [] # [1000 km]
246 pleasureX = []
247 pleasureY = [] # [1000 km]
248 total_dist = 0.0 # [km]
250 # Open flight log ...
251 with open(flightLog, "rt", encoding = "utf-8") as fObj:
252 # Loop over all flights ...
253 for row in csv.reader(fObj):
254 # Extract date that this flight started (silenty skipping rows which
255 # do not have a four digit year in the date) ...
256 # NOTE: Wouldn't it be nice if "datetime.datetime.fromisoformat()"
257 # could handle reduced precision?
258 parts = row[2].split("-")
259 if len(parts[0]) != 4:
260 if debug:
261 print(f"DEBUG: A row has a date column which does not have a year which is four characters long (\"{row[2]}\").")
262 continue
263 if not parts[0].isdigit():
264 if debug:
265 print(f"DEBUG: A row has a date column which does not have a year which is made up of digits (\"{row[2]}\").")
266 continue
267 match len(parts):
268 case 1:
269 date = datetime.datetime(
270 year = int(parts[0]),
271 month = 1,
272 day = 1,
273 )
274 case 2:
275 date = datetime.datetime(
276 year = int(parts[0]),
277 month = int(parts[1]),
278 day = 1,
279 )
280 case 3:
281 date = datetime.datetime.fromisoformat(row[2])
282 case _:
283 raise ValueError(f"I don't know how to convert \"{row[2]}\" in to a Python datetime object.") from None
285 # Extract IATA codes for this flight ...
286 iata1 = row[0]
287 iata2 = row[1]
289 # Skip this flight if the codes are not what I expect ...
290 if len(iata1) != 3 or len(iata2) != 3:
291 if debug:
292 print(f"DEBUG: A flight does not have valid IATA codes (\"{iata1}\" and/or \"{iata2}\").")
293 continue
295 # Check if this is the first line ...
296 if len(businessX) == 0:
297 # Set the minimum year (if required)...
298 if minYear is None:
299 minYear = date.year
301 # Loop over the full range of years ...
302 for year in range(minYear, maxYear + 1):
303 # NOTE: This is a bit of a hack, I should really use NumPy
304 # but I do not want to bring in another dependency
305 # that people may not have.
306 businessX.append(year - hw)
307 businessY.append(0.0) # [1000 km]
308 pleasureX.append(year + hw)
309 pleasureY.append(0.0) # [1000 km]
311 # Skip this flight if the year that this flight started it is out of
312 # scope ...
313 if date.year < minYear:
314 if debug:
315 print(f"DEBUG: A flight between {iata1} and {iata2} took place in {date.year:d}, which was before {minYear:d}.")
316 continue
317 if date.year > maxYear:
318 if debug:
319 print(f"DEBUG: A flight between {iata1} and {iata2} took place in {date.year:d}, which was after {maxYear:d}.")
320 continue
322 # Find coordinates for this flight ...
323 lon1, lat1 = coordinates_of_IATA(airports, iata1) # [°], [°]
324 lon2, lat2 = coordinates_of_IATA(airports, iata2) # [°], [°]
325 if debug:
326 print(f"INFO: You have flown between {iata1}, which is at ({lat1:+10.6f}°,{lon1:+11.6f}°), and {iata2}, which is at ({lat2:+10.6f}°,{lon2:+11.6f}°).")
327 dist, _, _ = pyguymer3.geo.calc_dist_between_two_locs(
328 lon1,
329 lat1,
330 lon2,
331 lat2,
332 nIter = nIter,
333 ) # [m]
335 # Convert m to km ...
336 dist *= 0.001 # [km]
338 # Add it's distance to the total ...
339 total_dist += dist # [km]
341 # Add it's distance to the histogram (if it is one of the two
342 # recognised fields) ...
343 edgecolor = (1.0, 0.0, 0.0, 1.0)
344 match row[3].lower():
345 case "business":
346 businessY[businessX.index(date.year - hw)] += 0.001 * dist # [1000 km]
347 if colorByPurpose:
348 edgecolor = c0 + (1.0,)
349 case "pleasure":
350 pleasureY[pleasureX.index(date.year + hw)] += 0.001 * dist # [1000 km]
351 if colorByPurpose:
352 edgecolor = c1 + (1.0,)
353 case _:
354 pass
356 # Create flight name and skip this flight if it has already been
357 # drawn ...
358 if iata1 < iata2:
359 flight = f"{iata1}→{iata2}"
360 else:
361 flight = f"{iata2}→{iata1}"
362 if flight in flights:
363 continue
364 flights[flight] = True
366 # Find the great circle ...
367 circle = pyguymer3.geo.great_circle(
368 lon1,
369 lat1,
370 lon2,
371 lat2,
372 debug = debug,
373 maxdist = 12.0 * 1852.0,
374 nIter = nIter,
375 npoint = None,
376 )
378 # Draw the great circle ...
379 axT.add_geometries(
380 pyguymer3.geo.extract_lines(
381 circle,
382 onlyValid = onlyValid,
383 ),
384 cartopy.crs.PlateCarree(),
385 edgecolor = edgecolor,
386 facecolor = "none",
387 linewidth = 1.0,
388 )
389 axL.add_geometries(
390 pyguymer3.geo.extract_lines(
391 circle,
392 onlyValid = onlyValid,
393 ),
394 cartopy.crs.PlateCarree(),
395 edgecolor = edgecolor,
396 facecolor = "none",
397 linewidth = 1.0,
398 )
399 axR.add_geometries(
400 pyguymer3.geo.extract_lines(
401 circle,
402 onlyValid = onlyValid,
403 ),
404 cartopy.crs.PlateCarree(),
405 edgecolor = edgecolor,
406 facecolor = "none",
407 linewidth = 1.0,
408 )
410 # Find countries and add them to the list if either are missing ...
411 country1 = country_of_IATA(airports, iata1)
412 country2 = country_of_IATA(airports, iata2)
413 if country1 not in extraCountries:
414 extraCountries[country1] = (1.0, 0.0, 0.0, 0.25)
415 if colorByPurpose:
416 match row[3].lower():
417 case "business":
418 extraCountries[country1] = c0 + (0.25,)
419 case "pleasure":
420 extraCountries[country1] = c1 + (0.25,)
421 case _:
422 pass
423 if country2 not in extraCountries:
424 extraCountries[country2] = (1.0, 0.0, 0.0, 0.25)
425 if colorByPurpose:
426 match row[3].lower():
427 case "business":
428 extraCountries[country2] = c0 + (0.25,)
429 case "pleasure":
430 extraCountries[country2] = c1 + (0.25,)
431 case _:
432 pass
434 # Plot histograms ...
435 axB.bar(
436 businessX,
437 businessY,
438 color = c0 + (1.0,),
439 label = "Business",
440 width = 2.0 * hw,
441 )
442 axB.bar(
443 pleasureX,
444 pleasureY,
445 color = c1 + (1.0,),
446 label = "Pleasure",
447 width = 2.0 * hw,
448 )
449 axB.legend(loc = "upper right")
450 axB.set_xticks(
451 range(minYear, maxYear + 1),
452 labels = [f"{year:d}" for year in range(minYear, maxYear + 1)],
453 ha = "right",
454 rotation = 45,
455 )
456 axB.set_ylabel("Distance [1000 km/year]")
457 axB.yaxis.grid(True)
459 # Loop over years ...
460 for i in range(minYear, maxYear + 1, 2):
461 # Configure axis ...
462 # NOTE: As of 13/Aug/2023, the default "zorder" of the bars is 1.0 and
463 # the default "zorder" of the vspans is 1.0.
464 axB.axvspan(
465 i - 0.5,
466 i + 0.5,
467 alpha = 0.25,
468 facecolor = "grey",
469 zorder = 0.0,
470 )
472 # Add annotation ...
473 label = f"You have flown {total_dist:,.1f} km."
474 label += f" You have flown around the Earth {total_dist / (0.001 * pyguymer3.CIRCUMFERENCE_OF_EARTH):,.1f} times."
475 label += f" You have flown to the Moon {total_dist / (0.001 * pyguymer3.EARTH_MOON_DISTANCE):,.1f} times."
476 axT.text(
477 0.5,
478 -0.02,
479 label,
480 horizontalalignment = "center",
481 transform = axT.transAxes,
482 verticalalignment = "center",
483 )
485 # Clean up the list ...
486 # NOTE: The airport database and the country shape database use different
487 # names for some countries. The user may provide a dictionary to
488 # rename countries.
489 for country1, country2 in renames.items():
490 if country1 in extraCountries:
491 extraCountries[country2] = extraCountries[country1]
492 del extraCountries[country1]
494 # Find file containing all the country shapes ...
495 sfile = cartopy.io.shapereader.natural_earth(
496 category = "cultural",
497 name = "admin_0_countries",
498 resolution = "10m",
499 )
501 # Initialize visited list ...
502 visited = []
504 # Loop over records ...
505 for record in cartopy.io.shapereader.Reader(sfile).records():
506 # Create short-hand ...
507 neName = pyguymer3.geo.getRecordAttribute(record, "NAME")
509 # Check if this country is in the list ...
510 if neName in extraCountries and neName not in notVisited:
511 # Append country name to visited list ...
512 visited.append(neName)
514 # Fill the country in and remove it from the list ...
515 # NOTE: Removing them from the list enables us to print out the ones
516 # that where not found later on.
517 axT.add_geometries(
518 pyguymer3.geo.extract_polys(
519 record.geometry,
520 onlyValid = onlyValid,
521 repair = repair,
522 ),
523 cartopy.crs.PlateCarree(),
524 edgecolor = extraCountries[neName],
525 facecolor = extraCountries[neName],
526 linewidth = 0.5,
527 )
528 axL.add_geometries(
529 pyguymer3.geo.extract_polys(
530 record.geometry,
531 onlyValid = onlyValid,
532 repair = repair,
533 ),
534 cartopy.crs.PlateCarree(),
535 edgecolor = extraCountries[neName],
536 facecolor = extraCountries[neName],
537 linewidth = 0.5,
538 )
539 axR.add_geometries(
540 pyguymer3.geo.extract_polys(
541 record.geometry,
542 onlyValid = onlyValid,
543 repair = repair,
544 ),
545 cartopy.crs.PlateCarree(),
546 edgecolor = extraCountries[neName],
547 facecolor = extraCountries[neName],
548 linewidth = 0.5,
549 )
550 del extraCountries[neName]
551 else:
552 # Outline the country ...
553 axT.add_geometries(
554 pyguymer3.geo.extract_polys(
555 record.geometry,
556 onlyValid = onlyValid,
557 repair = repair,
558 ),
559 cartopy.crs.PlateCarree(),
560 edgecolor = (0.0, 0.0, 0.0, 0.25),
561 facecolor = "none",
562 linewidth = 0.5,
563 )
564 axL.add_geometries(
565 pyguymer3.geo.extract_polys(
566 record.geometry,
567 onlyValid = onlyValid,
568 repair = repair,
569 ),
570 cartopy.crs.PlateCarree(),
571 edgecolor = (0.0, 0.0, 0.0, 0.25),
572 facecolor = "none",
573 linewidth = 0.5,
574 )
575 axR.add_geometries(
576 pyguymer3.geo.extract_polys(
577 record.geometry,
578 onlyValid = onlyValid,
579 repair = repair,
580 ),
581 cartopy.crs.PlateCarree(),
582 edgecolor = (0.0, 0.0, 0.0, 0.25),
583 facecolor = "none",
584 linewidth = 0.5,
585 )
587 # Configure figure ...
588 fg.tight_layout()
590 # Save figure ...
591 fg.savefig(flightMap)
592 matplotlib.pyplot.close(fg)
594 # Optimize PNG (if required) ...
595 if optimise:
596 pyguymer3.image.optimise_image(
597 flightMap,
598 debug = debug,
599 strip = strip,
600 timeout = timeout,
601 )
603 # Print out the countries that were not drawn ...
604 for country in sorted(list(extraCountries.keys())):
605 print(f"\"{country}\" was not drawn.")
607 # Print out the countries that have been visited ...
608 for country in sorted(visited):
609 print(f"\"{country}\" has been visited.")