Coverage for fmc/run.py: 83%
190 statements
« prev ^ index » next coverage.py v7.8.1, created at 2025-05-23 08:53 +0000
« prev ^ index » next coverage.py v7.8.1, created at 2025-05-23 08:53 +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 optimize = 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 optimize : bool, optional
61 optimize 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
92 # Import special modules ...
93 try:
94 import cartopy
95 cartopy.config.update(
96 {
97 "cache_dir" : os.path.expanduser("~/.local/share/cartopy_cache"),
98 }
99 )
100 except:
101 raise Exception("\"cartopy\" is not installed; run \"pip install --user Cartopy\"") from None
102 try:
103 import matplotlib
104 matplotlib.rcParams.update(
105 {
106 "backend" : "Agg", # NOTE: See https://matplotlib.org/stable/gallery/user_interfaces/canvasagg.html
107 "figure.dpi" : 300,
108 "figure.figsize" : (9.6, 7.2), # NOTE: See https://github.com/Guymer/misc/blob/main/README.md#matplotlib-figure-sizes
109 "font.size" : 8,
110 }
111 )
112 import matplotlib.pyplot
113 except:
114 raise Exception("\"matplotlib\" is not installed; run \"pip install --user matplotlib\"") from None
116 # Import my modules ...
117 try:
118 import pyguymer3
119 import pyguymer3.geo
120 import pyguymer3.image
121 except:
122 raise Exception("\"pyguymer3\" is not installed; run \"pip install --user PyGuymer3\"") from None
124 # Import sub-functions ...
125 from .coordinates_of_IATA import coordinates_of_IATA
126 from .country_of_IATA import country_of_IATA
128 # Populate default values ...
129 if extraCountries is None:
130 extraCountries = []
131 if flightMap is None:
132 flightMap = f'{flightLog.removesuffix(".csv")}.png'
133 if maxYear is None:
134 maxYear = pyguymer3.now().year
135 if notVisited is None:
136 notVisited = []
137 if renames is None:
138 renames = {}
140 # Convert the list of extra countries to a dictionary of extra countries
141 # where the key is the country and the value is the colour to draw it with ...
142 newExtraCountries = {}
143 for extraCountry in extraCountries:
144 newExtraCountries[extraCountry] = (1.0, 0.0, 0.0, 0.25)
145 extraCountries = newExtraCountries
146 del newExtraCountries
148 # **************************************************************************
150 # Set the half-width of the bars on the histogram ...
151 hw = 0.2
153 # Create short-hands ...
154 c0, c1 = matplotlib.rcParams["axes.prop_cycle"].by_key()["color"][:2]
155 c0 = matplotlib.colors.to_rgb(c0)
156 c1 = matplotlib.colors.to_rgb(c1)
158 # Create figure ...
159 # NOTE: I would like to use (4.8, 7.2) so as to be consistent with all my
160 # other figures (see linked 4K discussion above), however, the result
161 # is very poor due to the too wide, single line, summary label/string.
162 # The result gets even worse when ".tight_layout()" is called.
163 fg = matplotlib.pyplot.figure(figsize = (2 * 4.8, 2 * 7.2))
165 # Create axes ...
166 axT = pyguymer3.geo.add_axis(
167 fg,
168 add_coastlines = True,
169 add_gridlines = True,
170 debug = debug,
171 index = (1, 2),
172 ncols = 2,
173 nIter = nIter,
174 nrows = 3,
175 onlyValid = onlyValid,
176 repair = repair,
177 )
178 axL = pyguymer3.geo.add_axis(
179 fg,
180 add_coastlines = True,
181 add_gridlines = True,
182 debug = debug,
183 dist = leftDist,
184 index = 3,
185 lat = leftLat,
186 lon = leftLon,
187 ncols = 2,
188 nIter = nIter,
189 nrows = 3,
190 onlyValid = onlyValid,
191 repair = repair,
192 satellite_height = False,
193 )
194 axR = pyguymer3.geo.add_axis(
195 fg,
196 add_coastlines = True,
197 add_gridlines = True,
198 debug = debug,
199 dist = rightDist,
200 index = 4,
201 lat = rightLat,
202 lon = rightLon,
203 ncols = 2,
204 nIter = nIter,
205 nrows = 3,
206 onlyValid = onlyValid,
207 repair = repair,
208 satellite_height = False,
209 )
210 axB = fg.add_subplot(
211 3,
212 2,
213 (5, 6),
214 )
216 # Configure axis (top) ...
217 pyguymer3.geo.add_map_background(
218 axT,
219 debug = debug,
220 resolution = "large8192px",
221 )
223 # Configure axis (left) ...
224 pyguymer3.geo.add_map_background(
225 axL,
226 debug = debug,
227 resolution = "large8192px",
228 )
230 # Configure axis (right) ...
231 pyguymer3.geo.add_map_background(
232 axR,
233 debug = debug,
234 resolution = "large8192px",
235 )
237 # Load airport list ...
238 with open(f"{os.path.dirname(__file__)}/db.json", "rt", encoding = "utf-8") as fObj:
239 airports = json.load(fObj)
241 # Initialize flight dictionary, histograms and total distance ...
242 flights = {}
243 businessX = []
244 businessY = [] # [1000 km]
245 pleasureX = []
246 pleasureY = [] # [1000 km]
247 total_dist = 0.0 # [km]
249 # Open flight log ...
250 with open(flightLog, "rt", encoding = "utf-8") as fObj:
251 # Loop over all flights ...
252 for row in csv.reader(fObj):
253 # Extract date that this flight started (silenty skipping rows which
254 # do not have a four digit year in the date) ...
255 # NOTE: Wouldn't it be nice if "datetime.datetime.fromisoformat()"
256 # could handle reduced precision?
257 parts = row[2].split("-")
258 if len(parts[0]) != 4:
259 if debug:
260 print(f"DEBUG: A row has a date column which does not have a year which is four characters long (\"{row[2]}\").")
261 continue
262 if not parts[0].isdigit():
263 if debug:
264 print(f"DEBUG: A row has a date column which does not have a year which is made up of digits (\"{row[2]}\").")
265 continue
266 match len(parts):
267 case 1:
268 date = datetime.datetime(
269 year = int(parts[0]),
270 month = 1,
271 day = 1,
272 )
273 case 2:
274 date = datetime.datetime(
275 year = int(parts[0]),
276 month = int(parts[1]),
277 day = 1,
278 )
279 case 3:
280 date = datetime.datetime.fromisoformat(row[2])
281 case _:
282 raise ValueError(f"I don't know how to convert \"{row[2]}\" in to a Python datetime object.") from None
284 # Extract IATA codes for this flight ...
285 iata1 = row[0]
286 iata2 = row[1]
288 # Skip this flight if the codes are not what I expect ...
289 if len(iata1) != 3 or len(iata2) != 3:
290 if debug:
291 print(f"DEBUG: A flight does not have valid IATA codes (\"{iata1}\" and/or \"{iata2}\").")
292 continue
294 # Check if this is the first line ...
295 if len(businessX) == 0:
296 # Set the minimum year (if required)...
297 if minYear is None:
298 minYear = date.year
300 # Loop over the full range of years ...
301 for year in range(minYear, maxYear + 1):
302 # NOTE: This is a bit of a hack, I should really use NumPy
303 # but I do not want to bring in another dependency
304 # that people may not have.
305 businessX.append(year - hw)
306 businessY.append(0.0) # [1000 km]
307 pleasureX.append(year + hw)
308 pleasureY.append(0.0) # [1000 km]
310 # Skip this flight if the year that this flight started it is out of
311 # scope ...
312 if date.year < minYear:
313 if debug:
314 print(f"DEBUG: A flight between {iata1} and {iata2} took place in {date.year:d}, which was before {minYear:d}.")
315 continue
316 if date.year > maxYear:
317 if debug:
318 print(f"DEBUG: A flight between {iata1} and {iata2} took place in {date.year:d}, which was after {maxYear:d}.")
319 continue
321 # Find coordinates for this flight ...
322 lon1, lat1 = coordinates_of_IATA(airports, iata1) # [°], [°]
323 lon2, lat2 = coordinates_of_IATA(airports, iata2) # [°], [°]
324 if debug:
325 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}°).")
326 dist, _, _ = pyguymer3.geo.calc_dist_between_two_locs(
327 lon1,
328 lat1,
329 lon2,
330 lat2,
331 nIter = nIter,
332 ) # [m]
334 # Convert m to km ...
335 dist *= 0.001 # [km]
337 # Add it's distance to the total ...
338 total_dist += dist # [km]
340 # Add it's distance to the histogram (if it is one of the two
341 # recognised fields) ...
342 edgecolor = (1.0, 0.0, 0.0, 1.0)
343 match row[3].lower():
344 case "business":
345 businessY[businessX.index(date.year - hw)] += 0.001 * dist # [1000 km]
346 if colorByPurpose:
347 edgecolor = c0 + (1.0,)
348 case "pleasure":
349 pleasureY[pleasureX.index(date.year + hw)] += 0.001 * dist # [1000 km]
350 if colorByPurpose:
351 edgecolor = c1 + (1.0,)
352 case _:
353 pass
355 # Create flight name and skip this flight if it has already been
356 # drawn ...
357 if iata1 < iata2:
358 flight = f"{iata1}→{iata2}"
359 else:
360 flight = f"{iata2}→{iata1}"
361 if flight in flights:
362 continue
363 flights[flight] = True
365 # Find the great circle ...
366 circle = pyguymer3.geo.great_circle(
367 lon1,
368 lat1,
369 lon2,
370 lat2,
371 debug = debug,
372 maxdist = 12.0 * 1852.0,
373 nIter = nIter,
374 npoint = None,
375 )
377 # Draw the great circle ...
378 axT.add_geometries(
379 pyguymer3.geo.extract_lines(
380 circle,
381 onlyValid = onlyValid,
382 ),
383 cartopy.crs.PlateCarree(),
384 edgecolor = edgecolor,
385 facecolor = "none",
386 linewidth = 1.0,
387 )
388 axL.add_geometries(
389 pyguymer3.geo.extract_lines(
390 circle,
391 onlyValid = onlyValid,
392 ),
393 cartopy.crs.PlateCarree(),
394 edgecolor = edgecolor,
395 facecolor = "none",
396 linewidth = 1.0,
397 )
398 axR.add_geometries(
399 pyguymer3.geo.extract_lines(
400 circle,
401 onlyValid = onlyValid,
402 ),
403 cartopy.crs.PlateCarree(),
404 edgecolor = edgecolor,
405 facecolor = "none",
406 linewidth = 1.0,
407 )
409 # Find countries and add them to the list if either are missing ...
410 country1 = country_of_IATA(airports, iata1)
411 country2 = country_of_IATA(airports, iata2)
412 if country1 not in extraCountries:
413 extraCountries[country1] = (1.0, 0.0, 0.0, 0.25)
414 if colorByPurpose:
415 match row[3].lower():
416 case "business":
417 extraCountries[country1] = c0 + (0.25,)
418 case "pleasure":
419 extraCountries[country1] = c1 + (0.25,)
420 case _:
421 pass
422 if country2 not in extraCountries:
423 extraCountries[country2] = (1.0, 0.0, 0.0, 0.25)
424 if colorByPurpose:
425 match row[3].lower():
426 case "business":
427 extraCountries[country2] = c0 + (0.25,)
428 case "pleasure":
429 extraCountries[country2] = c1 + (0.25,)
430 case _:
431 pass
433 # Plot histograms ...
434 axB.bar(
435 businessX,
436 businessY,
437 color = c0 + (1.0,),
438 label = "Business",
439 width = 2.0 * hw,
440 )
441 axB.bar(
442 pleasureX,
443 pleasureY,
444 color = c1 + (1.0,),
445 label = "Pleasure",
446 width = 2.0 * hw,
447 )
448 axB.legend(loc = "upper right")
449 axB.set_xticks(
450 range(minYear, maxYear + 1),
451 labels = [f"{year:d}" for year in range(minYear, maxYear + 1)],
452 ha = "right",
453 rotation = 45,
454 )
455 axB.set_ylabel("Distance [1000 km/year]")
456 axB.yaxis.grid(True)
458 # Loop over years ...
459 for i in range(minYear, maxYear + 1, 2):
460 # Configure axis ...
461 # NOTE: As of 13/Aug/2023, the default "zorder" of the bars is 1.0 and
462 # the default "zorder" of the vspans is 1.0.
463 axB.axvspan(
464 i - 0.5,
465 i + 0.5,
466 alpha = 0.25,
467 facecolor = "grey",
468 zorder = 0.0,
469 )
471 # Add annotation ...
472 label = f"You have flown {total_dist:,.1f} km."
473 label += f" You have flown around the Earth {total_dist / (0.001 * pyguymer3.CIRCUMFERENCE_OF_EARTH):,.1f} times."
474 label += f" You have flown to the Moon {total_dist / (0.001 * pyguymer3.EARTH_MOON_DISTANCE):,.1f} times."
475 axT.text(
476 0.5,
477 -0.02,
478 label,
479 horizontalalignment = "center",
480 transform = axT.transAxes,
481 verticalalignment = "center",
482 )
484 # Clean up the list ...
485 # NOTE: The airport database and the country shape database use different
486 # names for some countries. The user may provide a dictionary to
487 # rename countries.
488 for country1, country2 in renames.items():
489 if country1 in extraCountries:
490 extraCountries[country2] = extraCountries[country1]
491 del extraCountries[country1]
493 # Find file containing all the country shapes ...
494 sfile = cartopy.io.shapereader.natural_earth(
495 category = "cultural",
496 name = "admin_0_countries",
497 resolution = "10m",
498 )
500 # Initialize visited list ...
501 visited = []
503 # Loop over records ...
504 for record in cartopy.io.shapereader.Reader(sfile).records():
505 # Create short-hand ...
506 neName = pyguymer3.geo.getRecordAttribute(record, "NAME")
508 # Check if this country is in the list ...
509 if neName in extraCountries and neName not in notVisited:
510 # Append country name to visited list ...
511 visited.append(neName)
513 # Fill the country in and remove it from the list ...
514 # NOTE: Removing them from the list enables us to print out the ones
515 # that where not found later on.
516 axT.add_geometries(
517 pyguymer3.geo.extract_polys(
518 record.geometry,
519 onlyValid = onlyValid,
520 repair = repair,
521 ),
522 cartopy.crs.PlateCarree(),
523 edgecolor = extraCountries[neName],
524 facecolor = extraCountries[neName],
525 linewidth = 0.5,
526 )
527 axL.add_geometries(
528 pyguymer3.geo.extract_polys(
529 record.geometry,
530 onlyValid = onlyValid,
531 repair = repair,
532 ),
533 cartopy.crs.PlateCarree(),
534 edgecolor = extraCountries[neName],
535 facecolor = extraCountries[neName],
536 linewidth = 0.5,
537 )
538 axR.add_geometries(
539 pyguymer3.geo.extract_polys(
540 record.geometry,
541 onlyValid = onlyValid,
542 repair = repair,
543 ),
544 cartopy.crs.PlateCarree(),
545 edgecolor = extraCountries[neName],
546 facecolor = extraCountries[neName],
547 linewidth = 0.5,
548 )
549 del extraCountries[neName]
550 else:
551 # Outline the country ...
552 axT.add_geometries(
553 pyguymer3.geo.extract_polys(
554 record.geometry,
555 onlyValid = onlyValid,
556 repair = repair,
557 ),
558 cartopy.crs.PlateCarree(),
559 edgecolor = (0.0, 0.0, 0.0, 0.25),
560 facecolor = "none",
561 linewidth = 0.5,
562 )
563 axL.add_geometries(
564 pyguymer3.geo.extract_polys(
565 record.geometry,
566 onlyValid = onlyValid,
567 repair = repair,
568 ),
569 cartopy.crs.PlateCarree(),
570 edgecolor = (0.0, 0.0, 0.0, 0.25),
571 facecolor = "none",
572 linewidth = 0.5,
573 )
574 axR.add_geometries(
575 pyguymer3.geo.extract_polys(
576 record.geometry,
577 onlyValid = onlyValid,
578 repair = repair,
579 ),
580 cartopy.crs.PlateCarree(),
581 edgecolor = (0.0, 0.0, 0.0, 0.25),
582 facecolor = "none",
583 linewidth = 0.5,
584 )
586 # Configure figure ...
587 fg.tight_layout()
589 # Save figure ...
590 fg.savefig(flightMap)
591 matplotlib.pyplot.close(fg)
593 # Optimize PNG (if required) ...
594 if optimize:
595 pyguymer3.image.optimize_image(
596 flightMap,
597 debug = debug,
598 strip = strip,
599 timeout = timeout,
600 )
602 # Print out the countries that were not drawn ...
603 for country in sorted(list(extraCountries.keys())):
604 print(f"\"{country}\" was not drawn.")
606 # Print out the countries that have been visited ...
607 for country in sorted(visited):
608 print(f"\"{country}\" has been visited.")