Skip to content

Commit e82aae8

Browse files
committed
Main commit
1 parent 3dbed90 commit e82aae8

File tree

9 files changed

+624
-1
lines changed

9 files changed

+624
-1
lines changed

LICENSE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Copyright © 2021 Fabio Schick
2+
3+
This software is provided 'as-is', without any express or implied
4+
warranty. In no event will the authors be held liable for any damages
5+
arising from the use of this software.
6+
7+
Permission is granted to anyone to use this software for any purpose,
8+
including commercial applications, and to alter it and redistribute it
9+
freely, subject to the following restrictions:
10+
11+
1. The origin of this software must not be misrepresented; you must not
12+
claim that you wrote the original software. If you use this software
13+
in a product, an acknowledgment in the product documentation would be
14+
appreciated but is not required.
15+
2. Altered source versions must be plainly marked as such, and must not be
16+
misrepresented as being the original software.
17+
3. This notice may not be removed or altered from any source distribution.

README.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,49 @@
11
# igc2acmi
2-
a little script that converts XCSoar's and FLARM's ".igc" flight logs into Tacview's ".acmi" format.
2+
A set of tools to convert [XCSoar](https://github.com/XCSoar/XCSoar#readme)'s, [FLARM](https://flarm.com/)'s and multiple other vendors' `.igc` GPS flight logs into [Tacview](https://www.tacview.net/)'s `.acmi` format.
3+
4+
## The Goal
5+
`.igc` is simple format, useful for recording the flight of a single aircraft, but has its limits.
6+
7+
`.klm`, which can be parsed from `.igc` using other [online utilities](http://cunimb.net/igc2kml.php), may be more visually appealing when viewed in Google Earth, but still isn't as useful for flight debriefing as I'd like to.
8+
9+
For these reasons, I decided to develop igc2acmi, it converts one or more `.igc` logs into [Tacview](https://www.tacview.net/)'s `.acmi` flight recording format, which is specifically designed to efficiently store multiple GPS tracks, its software also provides very useful debriefing features such as:
10+
- customizable labels to display AMSL/AGL Altitude, Vertical Speed, Calibrated Air Speed, True Airspeed, etc.
11+
- dynamic distance indication between 2 aircraft
12+
- custom [terrain textures](https://www.tacview.net/documentation/terrain/en/) and [3D shapes](https://www.tacview.net/documentation/staticobjects/en/), which can be used to display aviation charts and airspace models:
13+
![Tacview flight with custom terrain and airspace shapes](http://fabioschick.altervista.org/img/igc2acmi-Tacview-TerrainAirspace.png)
14+
- online debriefs, where multiple people can observe a flight from a shared perspective
15+
16+
## The Programs
17+
the `ProgramName.exe` `-h`/`--help` command will explain the different command-line arguments of each program.
18+
19+
### igc2acmi
20+
converts a single `.igc` file into `.acmi`, useful when you want to convert a specific file using specific settings.
21+
22+
### convert-all-igcs
23+
converts all `.igc` files in the program's directory (or a given input path) into an equal number of `.acmi` files. Useful when you need to convert multiple flights at once, all with the same settings.
24+
25+
### combine-igcs
26+
arguably the most complex utility of the 3, it finds all `.igc` files in the program's directory (or a given input path), sorts them by date of flight and combines all flights started on the same date into a single `.acmi` file, the timeline starts with the first GPS fix of the first flight and ends with the last fix of the last one.
27+
28+
Aeroclubs may find the greatest utility in this feature, for example by collecting everyone's `.igc` (with pilot consent of course) every day and combining them, to store/archive flights for collective debriefs and/or longer-term analysis of pilot activity.
29+
30+
## Known limitations
31+
Until now, I have only tested my project on `.igc` logs recorded by XCSoar (my own) and FLARM (a collegue's). All of the FLARM logs had an invalid date field of "000000" in their header, which the programs are not capable of handling (yet).
32+
33+
Maybe that has been resolved by newer FLARM versions, or was caused by incorrect setup of the FLARM module. In the meantime, make sure your `.igc` files have a valid date in their "HFDTE" line, using the "DDMMYY" format (i.e: "HFDTE221121" for 22nd November 2021).
34+
35+
## Sources and How to Contribute
36+
Development of this project was significantly aided by available documentation for the [igc](https://xp-soaring.github.io/igc_file_format/igc_format_2008.html) and [acmi](https://www.tacview.net/documentation/acmi/en/) file formats, many thanks to their authors.
37+
38+
The [released executables](https://github.com/RUSHER0600/igc2acmi/releases) are compiled with [pyinstaller](https://www.pyinstaller.org/). I use a batch file at the project root with 3 lines like this (last argument differs) to build all programs:
39+
```winbatch
40+
pyinstaller --specpath "build" --distpath "build\dist" -p "." --clean -F -i NONE --exclude-module _bootlocale "igc2acmi\igc2acmi.py"
41+
```
42+
43+
Feel free to fork this project for your own uses, any Pull Requests back to the original repository would be greatly appreciated!
44+
45+
## To-Do List
46+
- [ ] Add `--version` command that displays version number (and maybe release date)
47+
- [ ] Implement parsing of `igc` [tasks](https://xp-soaring.github.io/igc_file_format/igc_format_2008.html#link_3.6) and [events](https://xp-soaring.github.io/igc_file_format/igc_format_2008.html#link_4.2) into `acmi` [events](https://www.tacview.net/documentation/acmi/en/#Events)
48+
- [ ] Improve and augment error handling, i.e: for flights with missing/invalid date fields in their header
49+
- [ ] Add a way for the user to determine the output file's name or name template

combine-igcs/combine-igcs.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import sys, argparse, pathlib as pl, traceback as tb, datetime as dt
2+
sys.path.append(str(pl.Path(__file__).resolve().parents[1]))
3+
from common import functions as fnc, crashdumping as dump, args as a, console as c
4+
5+
def listIGCsByDate(path, igcs): # builds a dict of igc files, the key is the date of flight, the value is a list containing all igc filepaths recorded on that date
6+
ret = {}
7+
for igc in igcs:
8+
date = fnc.getFlightDate(path+igc+".igc")
9+
if date in ret:
10+
ret[date].append(path+igc+".igc")
11+
else:
12+
ret[date] = [path+igc+".igc"]
13+
return ret
14+
15+
def getLowestRefTime(igcs): # from a list of igc filepaths, find the one with the lowest first fix time and return that time as a time object
16+
lowest = dt.time(23, 59, 59)
17+
18+
for igcpath in igcs:
19+
with open(igcpath) as igc:
20+
for line in igc:
21+
if line.startswith("B"):
22+
fixTime = dt.time(
23+
int(line[1:3]),
24+
int(line[3:5]),
25+
int(line[5:7])
26+
)
27+
break
28+
if fixTime < lowest:
29+
lowest = fixTime
30+
31+
return lowest
32+
33+
def getLastFix(acs): # returns the highest occuring "lastfix" value among all aircraft dicts
34+
lastfix = 0
35+
for id in acs:
36+
if acs[id]["lastfix"] > lastfix:
37+
lastfix = acs[id]["lastfix"]
38+
39+
return lastfix
40+
41+
def getFixes(filename, refTime): # returns a list of fix dictionaries: key is the time offset from reference time in seconds, values are lat/lon/alt
42+
ret = {}
43+
with open(filename) as igc:
44+
for line in igc:
45+
if line.startswith("B"):
46+
fix = fnc.parseFixLocation(line)
47+
fixTime = fnc.parseFixTime(line)
48+
ret[fnc.timeDiff(refTime, fixTime)] = {
49+
"lat": fix[0],
50+
"lon": fix[1],
51+
"alt": fix[2]
52+
}
53+
return ret
54+
55+
def buildAircraft(igcs, refTime): # builds the main data dict of aircraft, with one element for each IGC file read.
56+
i = 0
57+
ac = {}
58+
59+
for igcPath in igcs:
60+
i += 1
61+
fixes = getFixes(igcPath, refTime)
62+
ac[1000+i] = {
63+
"header": fnc.getFlightHeader(igcPath),
64+
"fixes": fixes,
65+
"lastfix": list(fixes.keys())[-1]
66+
}
67+
68+
return ac
69+
70+
71+
def main():
72+
parser = argparse.ArgumentParser(
73+
"combine-igcs.exe",
74+
description = "Use it to combine multiple .igc files recorded on the same day into a single .acmi, containing all aircraft"
75+
)
76+
# import common arguments
77+
parser = a.addDefaultArgs(parser)
78+
# set program-specific arguments
79+
parser.add_argument(
80+
"-i",
81+
"--input",
82+
type = str,
83+
default = "",
84+
help = "Input path for igc files"
85+
)
86+
parser.add_argument(
87+
"-n",
88+
"--name",
89+
type = str,
90+
default = "",
91+
help = "Desired filename for ACMI file, will only be applied to the first file to be handled"
92+
)
93+
# parse all arguments
94+
args = parser.parse_args()
95+
96+
try:
97+
# define input path
98+
inPath = a.parseArgPath(args.input, "Input")
99+
# define output path
100+
outPath = a.parseArgPath(args.output, "Output")
101+
102+
# list available .igc files by their date and parse an acmi for each day
103+
igcs = listIGCsByDate(inPath, fnc.listIGCs(inPath))
104+
if None in igcs: # if .igc files with no valid date header line have been found, list them as corrupted and remove them from the dictionary
105+
c.warn("The following files have no valid date header line and will not be converted:")
106+
for file in igcs[None]:
107+
print("\""+file+"\"")
108+
igcs.pop(None)
109+
110+
for date in igcs:
111+
try:
112+
# initialize data structure of flights on x date
113+
refTime = dt.datetime.combine(
114+
dt.datetime.strptime(str(date), "%y%m%d"),
115+
getLowestRefTime(igcs[date])
116+
)
117+
c.info("Handling flights of date: "+refTime.strftime("%d %b %Y"))
118+
acs = buildAircraft(igcs[date], refTime.time())
119+
120+
# default to ACMI name "YYYY-MM-DD_Callsign1_Callsign2_..." if filename argument is not set or already taken
121+
if args.name != "" and not sorted(pl.Path(args.output).glob(args.name+".*")):
122+
acmiName = args.name
123+
else:
124+
acmiName = refTime.strftime("%Y-%m-%d")
125+
for id in acs:
126+
if acs[id]["header"]["callsign"] not in acmiName:
127+
acmiName += "_"+acs[id]["header"]["callsign"]
128+
129+
# init ACMI file for date x
130+
acmi = open(outPath+acmiName+".tmpacmi", "w")
131+
acmi.write(fnc.acmiFileInit(refTime))
132+
133+
# init Aircraft objects on ACMI
134+
for id in acs:
135+
acmi.write(fnc.acmiObjInit(acs[id]["header"], id))
136+
137+
# write Aircraft fix positions to ACMI, grouped by time offset to refTime
138+
i = 0
139+
while i <= getLastFix(acs):
140+
rec = ""
141+
for id in acs:
142+
if i in acs[id]["fixes"]:
143+
fix = acs[id]["fixes"][i]
144+
rec += str(id)+",T="+fix["lon"]+"|"+fix["lat"]+"|"+fix["alt"]+"\n"
145+
if i == acs[id]["lastfix"]: # if current time offset is last fix of aircraft, pass ACMI object deletion line to record
146+
rec += "-"+str(id)+"\n"
147+
if rec != "":
148+
acmi.write("#"+str(i)+"\n"+rec)
149+
i += 1
150+
151+
acmi.close()
152+
153+
# handle other optional arguments
154+
if args.remove:
155+
for igc in igcs[date]:
156+
pl.Path(igc).unlink()
157+
c.info("Removed "+pl.Path(igc).name)
158+
if args.nozip:
159+
ext = ".txt.acmi"
160+
pl.Path(outPath+acmiName+".tmpacmi").replace(outPath+acmiName+ext)
161+
else:
162+
ext = ".zip.acmi"
163+
fnc.zipAcmi(outPath+acmiName)
164+
165+
c.info("Combined the following files into \""+outPath+acmiName+ext+"\":")
166+
for igc in igcs[date]:
167+
print("\""+igc+"\"")
168+
169+
except:
170+
if "acmi" in locals(): # clean up initialized ".tmpacmi" file in case of a crash
171+
if not acmi.closed: # if tmpacmi is still open, close it
172+
acmi.close()
173+
pl.Path(outPath+acmiName+".tmpacmi").unlink()
174+
if args.debug: # if debug flag is set, print normal error message
175+
c.err("Unable to combine flights of date "+refTime.strftime("%d %b %Y")+", error:\n"+tb.format_exc())
176+
else: # default crashdump writing
177+
c.err("Unable to convert flights of date "+refTime.strftime("%d %b %Y")+", a crashlog for it has been saved in \""+dump.fileDump(acmiName, "combine-igcs")+"\"")
178+
179+
except:
180+
if args.debug: # if debug flag is set, print normal error message
181+
c.err(tb.format_exc())
182+
else: # default crashdump writing
183+
c.err("An error occured in this program's execution, a crash report has been saved to \""+dump.progDump(args, "combine-igcs")+"\"")
184+
185+
main()

common/args.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import pathlib as pl
2+
from common import console as c
3+
4+
# common arguments that every program uses
5+
def addDefaultArgs(parser):
6+
parser.add_argument(
7+
"-r",
8+
"--remove",
9+
action = "store_true",
10+
help = 'Remove ".igc" files after converting them'
11+
)
12+
parser.add_argument(
13+
"-z",
14+
"--nozip",
15+
action = "store_true",
16+
help = 'Do not compress output files as ".zip.acmi", instead output ".txt.acmi", useful for debugging'
17+
)
18+
parser.add_argument(
19+
"-d",
20+
"--debug",
21+
action = "store_true",
22+
help = "Do not zip crashdumps, instead output error messages normally, useful for debugging"
23+
)
24+
parser.add_argument(
25+
"-o",
26+
"--output",
27+
type = str,
28+
default = "",
29+
help = "Output path for acmi file(s)"
30+
)
31+
32+
return parser
33+
34+
def parseArgPath(path, pathName="Argument"): # parses paths received from cmd arguments, checks if they are valid and properly formats them
35+
p = pl.Path(path).resolve()
36+
if p.is_file():
37+
return p.parent.as_posix()+"/"
38+
elif p.is_dir():
39+
return p.as_posix()+"/"
40+
else:
41+
# if path is not valid, set it to script's location and notify user
42+
c.warn(pathName+" path is not valid, defaulting to current program directory")
43+
return pl.Path(__name__).resolve().parent.as_posix()+"/"

common/console.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import colorama as cr
2+
3+
cr.init()
4+
5+
def info(msg):
6+
print(cr.Fore.BLUE+"[INFO] "+cr.Style.RESET_ALL+msg)
7+
8+
def warn(msg):
9+
print(cr.Fore.YELLOW+"[WARN] "+cr.Style.RESET_ALL+msg)
10+
11+
def err(msg):
12+
print(cr.Fore.RED+"[ERROR] "+cr.Style.RESET_ALL+msg)

common/crashdumping.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import traceback as tb
2+
import datetime as dt, zipfile as zf, pprint as pp
3+
4+
# write a program crashdump
5+
def progDump(args, prog, path=""):
6+
arcName = dt.datetime.now().strftime(prog+"_Crashdump_%Y-%m-%d_%H-%M-%S")
7+
body = "ERROR LOG "+dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
8+
body += "\n\nARGS:\n"+pp.pformat(args)
9+
body += "\n\nERROR TRACEBACK:\n"+tb.format_exc()
10+
11+
with zf.ZipFile(path+arcName+".zip", "a", zf.ZIP_DEFLATED) as zlog:
12+
zlog.writestr(arcName+".log", body)
13+
14+
return arcName+".zip"
15+
16+
# write the crashdump for an exception occured while handling a specific file
17+
def fileDump(file, prog, path=""):
18+
arcName = dt.datetime.now().strftime(prog+"_Crashdump_%Y-%m-%d_%H-%M-%S")
19+
fname = file+".igc_Exception.log"
20+
body = "Handled File Name:\n"+file+".igc\n\n"
21+
body += "Error:\n"+tb.format_exc()
22+
23+
with zf.ZipFile(path+arcName+".zip", "a", zf.ZIP_DEFLATED) as zlog:
24+
zlog.writestr(fname, body)
25+
26+
return arcName+".zip"

0 commit comments

Comments
 (0)