Coverage for fmc/run.py: 83%

191 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-16 09:11 +0000

1#!/usr/bin/env python3 

2 

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 

30 

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) 

76 

77 Notes 

78 ----- 

79 Copyright 2016 Thomas Guymer [1]_ 

80 

81 References 

82 ---------- 

83 .. [1] FMC, https://github.com/Guymer/fmc 

84 """ 

85 

86 # Import standard modules ... 

87 import csv 

88 import datetime 

89 import json 

90 import os 

91 import pathlib 

92 

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 

116 

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 

124 

125 # Import sub-functions ... 

126 from .coordinates_of_IATA import coordinates_of_IATA 

127 from .country_of_IATA import country_of_IATA 

128 

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 = {} 

140 

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 

148 

149 # ************************************************************************** 

150 

151 # Set the half-width of the bars on the histogram ... 

152 hw = 0.2 

153 

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) 

158 

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)) 

165 

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 ) 

216 

217 # Configure axis (top) ... 

218 pyguymer3.geo.add_map_background( 

219 axT, 

220 debug = debug, 

221 resolution = "large8192px", 

222 ) 

223 

224 # Configure axis (left) ... 

225 pyguymer3.geo.add_map_background( 

226 axL, 

227 debug = debug, 

228 resolution = "large8192px", 

229 ) 

230 

231 # Configure axis (right) ... 

232 pyguymer3.geo.add_map_background( 

233 axR, 

234 debug = debug, 

235 resolution = "large8192px", 

236 ) 

237 

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) 

241 

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] 

249 

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 

284 

285 # Extract IATA codes for this flight ... 

286 iata1 = row[0] 

287 iata2 = row[1] 

288 

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 

294 

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 

300 

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] 

310 

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 

321 

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] 

334 

335 # Convert m to km ... 

336 dist *= 0.001 # [km] 

337 

338 # Add it's distance to the total ... 

339 total_dist += dist # [km] 

340 

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 

355 

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 

365 

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 ) 

377 

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 ) 

409 

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 

433 

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) 

458 

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 ) 

471 

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 ) 

484 

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] 

493 

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 ) 

500 

501 # Initialize visited list ... 

502 visited = [] 

503 

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") 

508 

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) 

513 

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 ) 

586 

587 # Configure figure ... 

588 fg.tight_layout() 

589 

590 # Save figure ... 

591 fg.savefig(flightMap) 

592 matplotlib.pyplot.close(fg) 

593 

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 ) 

602 

603 # Print out the countries that were not drawn ... 

604 for country in sorted(list(extraCountries.keys())): 

605 print(f"\"{country}\" was not drawn.") 

606 

607 # Print out the countries that have been visited ... 

608 for country in sorted(visited): 

609 print(f"\"{country}\" has been visited.")