Spis treści 

GNU-Awk w służbie człowieka (część 1: podział plików GPX)

Na temat gawk popełniono już wiele tekstu więc i ja dodam coś od siebie :-) Przyczynkiem do popełnienia kilku nowych skryptów była w moim przypadku konieczność dzielenia, łączenia i wyszukiwania różnic w plikach XML zawierających współrzędne geograficzne (GPX). Format ten jest popularnym sposobem (standardem?) przechowywania danych z GPS - przynajmniej jeżeli chodzi o urządzenia firmy Garmin z serii eTrex (HCx - taki mam). Pominę tutaj wyjaśnienia dlaczego akurat ten format jest lepszy od miliona pozostałych - równie "standardowych" co GPX.

Ogólnie mówiąc problem polega na tym, chcę trzymać zdjęcia i współrzędne w podziale na poszczególne dni. Ze zdjęciami nie ma problemu. Główne ich źródło to dłuższe lub krótsze wycieczki rowerowe - najczęściej jednodniowe. Zresztą zawsze można sięgnąć do metadanych EXIF, wyciągnąć z nich datę utworzenia a samo zdjęcie przenieść do odpowiedniego katalogu. Ogranicza się to, w zasadzie, do kilku poleceń (Linux z zainstalowanym pakietem ImageMagick):

FILES=$(find . -name *.JPG)
for FILE in $FILES; do
	DATE=$(identify -format "%[EXIF:DateTime]" $FILE | cut -c 1-10 | sed {s/:/-/g})
	mkdir -p $DATE
	mv -p $FILE ${DATE}/
done

No dobrze. Ale co ze współrzędnymi (tzw. waypointami)?

Tu już nie jest tak fajnie bo najpierw trzeba je "wyciągnąć" z GPS i zapisać w formacie GPX (polecam program G7ToWin - dzięki pakietowi Wine zadziała także pod Linuxem). Plik GPX to w zasadzie odpowiednio sformatowany plik XML. Trzeba tylko właściwie go przetworzyć, posortować po dacie utworzenia waypointów, podzielić na dni z uwzględnieniem strefy czasowej (wszystkie daty w GPS są według czasu UTC) i zapisać wynik. Bułka z masłem ;-)

W praktyce jest kilka problemów do rozwiązania:


Jest jednak coś, co jest w stanie pomóc ogarnąć problem: gawk.
gawk jest dobry w dwóch rzeczach: wyszukiwaniu i przetwarzaniu wzorców w plikach lub strumieniach danych.

Pre-rekwizyty

Mamy więc interpreter gawk (dostępny w każdej dystrybucji Linux, pod Windows za pomocą pakietu Cygwin), plik GPX i kilka pomysłów. Przede wszystkim trzeba przyjrzeć się pacjentowi. Na nasze szczęście program G7ToWin zapisuje pliki w taki sposób, że definicja każdego waypointa jest oddzielona od poprzedniej pustą linią. Wykorzystamy to do automatycznego oddzielania rekordów danych, za pomocą zmiennej RS (Record Separator).

Idea jest taka:

  1. Dzielimy plik GPX na rekordy (separatorem jest pusta linia czyli "podwójny enter").
  2. W każdym rekordzie wyszukujemy datę utworzenia waypointa.
  3. Sortujemy dane.
  4. Grupujemy po dacie z dokładnością do dnia i zapisujemy.

O dzieleniu danych na rekordy było przed chwilą. Teraz kilka słów o "dacie utworzenia waypointa", która czasem jest wiarygodna, a czasem nie. Za przechowywanie daty w pliku GPX odpowiada znacznik <time> ale jak już wiemy - nie każde urządzenie datę taką określa (wtedy <time> zawiera datę odczytania danych z GPS przez komputer). Szczęśliwie dla nas część urządzeń zapisuje lokalną datę utworzenia waypointa w formacie "DD-MMM-YY HH:MI:SS" (np. 10-PAZ-11 17:41:12) jako komentarz. Wykorzystamy ten fakt.

Daty w formacie ISO 8601 czy też "DD-MMM-YY" niestety nie nadają się do sortowania (przynajmniej nie za pomocą gawk). Trzeba je poddać konwersji na coś prostszego… czyli liczbę całkowitą. Zabieg jest prosty i pozwala zamieć datę i czas na liczbę sekund, które upłynęły od "linuxowej epoki", czyli od 1 stycznia 1970 roku. Liczby takie mogą być sortowane, porównywane, odejmowane, dodawane czy też konwertowane do innej postaci za pomocą funkcji strftime(). Trzeba jeszcze pamiętać, że jest różnica między czasem UTC i "czasem lokalnym" (w Polsce zmieniającym się dwa razy w roku: tzw. czas letni i zimowy) oraz, że w pliku GPX daty są zapisane jako UTC (znacznik <time>) lub jako czas lokalny (znacznik <cmt>).

Jako kolejny pewnik przyjmujemy, że w pliku GPX mogą pojawić się rekordy z identyczną datą utworzenia oraz takie, które zostały dodane ręcznie. Te ostatnie można zidentyfikować po tym, że najczęściej nie zawierają znacznika <cmt> (a więc naszej daty utworzenia), definicji wysokości (znacznik <ele>) bądź też wysokość wynosi 0 lub -777. Nazwami waypointów nie przejmujemy się.

Skrypt

Po przydługim wstępie przechodzimy do konkretów.

Po pierwsze definiujemy separatory rekordów i pól (wierszy) za pomocą zmiennych RS (Record Separator) i FS (Field Separator), które mogą zawierać wyrażenia regularne. Jest to pomocne ponieważ plik GPX może być zapisany pod Windows (koniec linii "\r\n") lub Linux (koniec linii "\n").

RS="[\r]?\n[ ]+?[\r]?\n"
FS="[\r]?\n"

Z danych wejściowych odrzucamy rekordy nie mające nic wspólnego ze współrzędnymi (w tym i tzw. Proximity Points - punkty ostrzegawcze, które dublują się z właściwymi waypointami).

# odsiej zbedny xml i punkty ostrzegawcze (sa zdublowane z waypointami)
if ($0 !~ /wpt/) next
if ($0 ~ /gpxx:Proximity/) next

Za wyznaczanie daty utworzenia waypointa (jako liczby sekund) odpowiada funkcja getwptstamp(), która działa według algorytmu:

Poszczególne waypointy są przechowywane w tablicy asocjacyjnej wpt[], której indeksem jest data utworzenia (liczba sekund) oraz krotność (liczba waypointów o tej samej dacie utworzenia). Pomocnicza tablica cnt[] zawiera unikalne daty utworzenia. Skrypt zadba także o to by uspójnić zawartość znaczników <cmt> i <time>.

# unikalne daty utworzenia waypointow (ind) oraz krotnosc (cnt[ind])
cnt[ind]+=1
# tablica waypointow ma indeks zlozony z daty utworzenia i licznika duplikatow
# jesli stamp==0 (brak <cmt> w rekordzie) to zostawiamy oryginalny
# znacznik <time>; w przeciwnym razie podmieniamy na czas UTC zgodny z <cmt>
wpt[ind,cnt[ind]]=(stamp==0)?
   $0:
   gensub(/<time>.?*<\/time>/,strftime("<time>%Y-%m-%dT%H:%M:%SZ</time>",stamp,1),$0)

Sortowanie i grupowanie jest przeprowadzane w pętli, która wyznacza nowy indeks (PREFIX) i łączy pod nim wszystkie "pasujące" waypointy (tablica waypoints[]). Jako bonus przeprowadzane jest usuwanie punktów o takiej samej dacie utworzenia i tych samych współrzędnych. Jeżeli takie rekordy się trafią - brany pod uwagę jest tylko pierwszy z nich.

numwpts=asorti(cnt,key)
# grupuj wpt po dacie utworzenia (PREFIX)
# w przypadku dubli daty: cnt[key[pos]] > 1
for (pos=1;pos<=numwpts;pos++) {
   ind=key[pos]
   ymd=strftime(PREFIX,int(ind))
   duble=cnt[ind]
 
   for (i=1;i<=duble;i++) {
      # zachowaj wspolrzedne lat i lng waypointow z danego dnia
      # do wyznacznia obszaru <bounds> w naglowku gpx
 
      if (match(wpt[ind,i],/lat=\"([0-9,.-]+)\"[ ]+lon=\"([0-9.,-]+)\"/,latlng)) {
          lat[ymd]=(lat[ymd])?(lat[ymd] SUBSEP latlng[1]):latlng[1]
          lng[ymd]=(lng[ymd])?(lng[ymd] SUBSEP latlng[2]):latlng[2]
          # pomin powielone waypointy
          # (taka sama data i wspolrzedne)
          point=(ind SUBSEP sprintf("%2.6f",latlng[1]) SUBSEP sprintf("%2.6f",latlng[2]))
          loc[point]+=1
          if (loc[point]==1) waypoints[ymd]=(waypoints[ymd] LF wpt[ind,i] LF)
      }
      else print "rekord bez współrzędnych",LF,wpt[ind,i],LF > "/dev/stderr"
   }
}

Na koniec zawartość tablicy waypoints[] trafia na dysk, pod nazwą określoną przez połączenie wartości zmiennych PREFIX i SUFFIX (domyślnie jest to YYYY-MM-DD.gpx, np.: 2011-10-10.gpx). By uniknąć pomyłek, wszystkie wynikowe pliki są zapisywane w katalogu o nazwie tworzonej dynamicznie.

Voilá. Prosto, szybko i przyjemnie :-)
Poniżej cały skrypt, który uruchamia się za pomocą polecenia: ./wptsplit <plik.gpx>

#!/usr/bin/gawk -f
# (c) 2011 pijoter
 
# skrypt do dzielenia pliku waypoints/gpx na poszczegolne dni
# definicje waypointow musza byc rozdzielone znakiem nowej linii
 
BEGIN {
	IGNORECASE=1
	RS="[\r]?\n[ ]+?[\r]?\n"
	FS="[\r]?\n"
 
	VERBOSE=1
	DEBUG=0
	INVALID_ELE=-777
	Y2K=2000
	LF="\n"
 
	# katalog docelowy
	if (DIR=="") DIR=strftime("SPLIT_%Y%m%d%H%M%S")
	DIR=(DIR "/"); sub(/[\/]+$/,"/",DIR)
	system(sprintf("mkdir -p %s",DIR))
	# decyduje o nazwie plikow wynikowych
	PREFIX="%Y-%m-%d"
	SUFFIX=".gpx"
 
	CREATOR="pijoter"
	LINK_URL="http://www.rowery.olsztyn.pl"
	LINK_NAME="Olsztynska Strona Rowerowa"
 
	i18n["STY"]=i18n["JAN"]="01"
	i18n["LUT"]=i18n["FEB"]="01"
	i18n["MAR"]=i18n["MAR"]="03"
	i18n["KWI"]=i18n["APR"]="04"
	i18n["MAJ"]=i18n["MAY"]="05"
	i18n["CZE"]=i18n["JUN"]="06"
	i18n["LIP"]=i18n["JUL"]="07"
	i18n["SIE"]=i18n["AUG"]="08"
	i18n["WRZ"]=i18n["SEP"]="09"
	i18n["PAZ"]=i18n["OCT"]="10"
	i18n["LIS"]=i18n["NOV"]="11"
	i18n["GRU"]=i18n["DEC"]="12"
 
	gpx="<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\" ?>\n<gpx xmlns=\"http://www.topografix.com/GPX/1/1\" xmlns:gpxx=\"http://www.garmin.com/xmlschemas/GpxExtensions/v3\" xmlns:gpxtpx=\"http://www.garmin.com/xmlschemas/TrackPointExtension/v1\" creator=\"%s\" version=\"1.1\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd\">\n  <metadata>\n    <link href=\"%s\">\n      <text>%s</text>\n    </link>\n    <name>%s</name>\n    <time>%s</time>\n    <bounds minlat=\"%f\" minlon=\"%f\" maxlat=\"%f\" maxlon=\"%f\"/>\n  </metadata>\n%s\n</gpx>\n"
}
 
function getwptstamp(rec, spec,cmt,datetime,date,time,utc,isotime,zone,ele)
{
	# data utworzenia waypointa (czas LOKALNY - liczba sekund epoch):
	#	data z <cmt></cmt> (domyslnie)
	#	data z <time></time> (gdy nie ma <cmt> i jest <ele>)
	#	0 (w przypadku niejasnosci)
	#	-1 (blad)
 
	spec=0
 
	if (match(rec,/<cmt>(.+)<\/cmt>/,cmt)) {
		split(cmt[1],datetime," ")
 
		split(datetime[1],date,"-")
		split(datetime[2],time,":")
		spec=mktime(sprintf("%4d %02d %02d %02d %02d %02d",
					Y2K+date[3],i18n[toupper(date[2])],date[1],
					time[1],time[2],time[3]))
	}
 
	if (spec<=0) {
 
		# nie ma znacznika <cmt> lub bledny?
		# korzystamy z <time> ale tylko gdy jest poprawny
		# znacznik <ele> (wysokosc)
 
		valid=(match(rec,/<ele>([0-9,.-]+)<\/ele>/,ele))?
			sprintf("%.0f",ele[1])!=INVALID_ELE:
			(0)
 
		if (valid && match(rec,/<time>(.+)<\/time>/,isotime)) {
 
			# data i czas w znaczniku <time> jest w UTC
			# trzeba zamienic go na czas lokalny
 
			gsub(/[-:TZ]/," ",isotime[1])
			utc=mktime(isotime[1])
			zone=mktime(strftime("%Y %m %d %H %M %S",utc,0))-mktime(strftime("%Y %m %d %H %M %S",utc,1))
			spec=utc+zone
		}
		else spec=0
	}
 
	return spec
}
 
function base(name, part,n)
{
	n=split(name,part,"/")
	return part[n]
}
 
# MAIN
{
	# opcjonalnie, usun znaki "\r"
	gsub("\r","",$0)
	# odsiej zbedny xml i punkty ostrzegawcze (sa zdublowane z waypointami)
	if ($0 !~ /wpt/) next
	if ($0 ~ /gpxx:Proximity/) next
 
	# odsiej bledne waypointy
	stamp=getwptstamp($0)
	if (stamp<0) {
		print "błędny rekord",LF,$0,LF > "/dev/stderr"
		next
	}
 
	# proteza! asorti() porownuje indeksy jako napisy (a nie liczby)
	ind=sprintf("%010d",stamp)
 
	# unikalne daty utworzenia waypointow (ind) oraz krotnosc (cnt[ind])
	cnt[ind]+=1
 
	# tablica waypointow ma indeks zlozony z daty utworzenia i licznika duplikatow
	# jesli stamp==0 (brak <cmt> w rekordzie) to zostawiamy oryginalny
	# znacznik <time>; w przeciwnym razie podmieniamy na czas UTC zgodny z <cmt>
 
	wpt[ind,cnt[ind]]=(stamp==0)?
		$0:
		gensub(/<time>.?*<\/time>/,
			strftime("<time>%Y-%m-%dT%H:%M:%SZ</time>",stamp,1),$0)
}
 
END {
	# w pliku wynikowym waypointy beda posortowane narastajaco,
	# po dacie utworzenia
 
	numwpts=asorti(cnt,key)
 
	# grupuj wpt po dacie utworzenia (PREFIX)
	# w przypadku dubli daty: cnt[key[pos]] > 1
 
	for (pos=1;pos<=numwpts;pos++) {
		ind=key[pos]
		ymd=strftime(PREFIX,int(ind))
		duble=cnt[ind]
 
		for (i=1;i<=duble;i++) {
			# zachowaj wspolrzedne lat i lng waypointow z danego dnia
			# do wyznacznia obszaru <bounds> w naglowku gpx
 
			if (match(wpt[ind,i],/lat=\"([0-9,.-]+)\"[ ]+lon=\"([0-9.,-]+)\"/,latlng)) {
				lat[ymd]=(lat[ymd])?(lat[ymd] SUBSEP latlng[1]):latlng[1]
				lng[ymd]=(lng[ymd])?(lng[ymd] SUBSEP latlng[2]):latlng[2]
 
				# pomin powielone waypointy
				# (taka sama data i wspolrzedne)
 
				point=(ind SUBSEP sprintf("%2.6f",latlng[1]) SUBSEP sprintf("%2.6f",latlng[2]))
				#if (DEBUG) printf "wpt[%d,%d]\n%s\n\n",ind,i,wpt[ind,i] > "/dev/stderr"
				loc[point]+=1
				if (loc[point]==1) waypoints[ymd]=(waypoints[ymd] LF wpt[ind,i] LF)
			}
			else print "rekord bez współrzędnych",LF,wpt[ind,i],LF > "/dev/stderr"
		}
	}
 
	# uzupelnij naglowek GPX
	# zapisz waypointy do pliku z danego dnia
 
	for (ymd in waypoints) {
		# wyznacz obszar <bounds> waypointow z danego dnia
		split(lat[ymd],minmaxlat,SUBSEP)
		split(lng[ymd],minmaxlng,SUBSEP)
		num=asort(minmaxlat); latmin=minmaxlat[1]; latmax=minmaxlat[num]
		num=asort(minmaxlng); lngmin=minmaxlng[1]; lngmax=minmaxlng[num]
 
		# if (DEBUG) printf "%s bounds =>\n[latmin=%s]\n[latmax=%s]\n[lngmin=%s]\n[lngmax=%s]\n\n",ymd,latmin,latmax,lngmin,lngmax > "/dev/stderr"
		# daty w pliku sa w UTC (konwersja w funkcji strftime)
		printf(gpx,
			CREATOR, LINK_URL, LINK_NAME,
			ymd,
			strftime("%Y-%m-%dT%H:%M:%SZ",systime(),1),
			latmin,lngmin,latmax,lngmax,
			waypoints[ymd]) > (DIR ymd SUFFIX)
	}
 
	# statystyki
	if (VERBOSE) printf("waypointow=%d, unikalnych=%d, plikow=%d (od %s do %s)\n",
		length(wpt), # waypointy razem z duplikatami
		length(loc), # unikalne waypointy
		length(waypoints), # waypointy pogrupowane do dacie YMD
		strftime("%Y-%m-%d",key[1]),
		strftime("%Y-%m-%d",key[numwpts]))
}