Coverage for fmc/run.py: 83%

190 statements  

« prev     ^ index     » next       coverage.py v7.8.1, created at 2025-05-23 08:53 +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 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 

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

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 

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 

115 

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 

123 

124 # Import sub-functions ... 

125 from .coordinates_of_IATA import coordinates_of_IATA 

126 from .country_of_IATA import country_of_IATA 

127 

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

139 

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 

147 

148 # ************************************************************************** 

149 

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

151 hw = 0.2 

152 

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) 

157 

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

164 

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 ) 

215 

216 # Configure axis (top) ... 

217 pyguymer3.geo.add_map_background( 

218 axT, 

219 debug = debug, 

220 resolution = "large8192px", 

221 ) 

222 

223 # Configure axis (left) ... 

224 pyguymer3.geo.add_map_background( 

225 axL, 

226 debug = debug, 

227 resolution = "large8192px", 

228 ) 

229 

230 # Configure axis (right) ... 

231 pyguymer3.geo.add_map_background( 

232 axR, 

233 debug = debug, 

234 resolution = "large8192px", 

235 ) 

236 

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) 

240 

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] 

248 

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 

283 

284 # Extract IATA codes for this flight ... 

285 iata1 = row[0] 

286 iata2 = row[1] 

287 

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 

293 

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 

299 

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] 

309 

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 

320 

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] 

333 

334 # Convert m to km ... 

335 dist *= 0.001 # [km] 

336 

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

338 total_dist += dist # [km] 

339 

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 

354 

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 

364 

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 ) 

376 

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 ) 

408 

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 

432 

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) 

457 

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 ) 

470 

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 ) 

483 

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] 

492 

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 ) 

499 

500 # Initialize visited list ... 

501 visited = [] 

502 

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

507 

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) 

512 

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 ) 

585 

586 # Configure figure ... 

587 fg.tight_layout() 

588 

589 # Save figure ... 

590 fg.savefig(flightMap) 

591 matplotlib.pyplot.close(fg) 

592 

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 ) 

601 

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

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

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

605 

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

607 for country in sorted(visited): 

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