hikereport

All the statistics and profiles of my hikes are obtained with a bit of computer work. What you need is two things: the data and the GIS software to work on that data.

The data

In my case I have obtained all the data from institutional sources (Regione Lombardia). They provide several maps and databases; they can send you cdroms for free, which I think is very cool… you don’t even pay for the shipping! In particular for my work I use the CTR (“Carta Tecnica Regionale”, 4 cdroms) at the highest resolution available (1:10000) and the Digital Elevation Model, that has a different resolution, with squares of 20×20 meters.

The GIS software

I have chosen GRASS because it is very mature, extensible, open source, and runs on Linux. In one word it is just great. Even if I am not at all a GIS expert I enjoy using it.

Preliminary steps

So, back to how to obtain the profiles. The first step is, of course, to create locations and mapsets and import the selected maps. It’s also good to patch some of the DEMs in order to have bigger maps. Once you have imported the needed maps, you can enjoy the processing ;-)

Processing

Actually you can draw a simple profile with GRASS’ command

d.profile

but that works only on a straight line, from point A to point B… but you don’t usually walk like that, do you?

No problem, GRASS has another command,

r.profile

that can accept a series of points (vertices of a polyline) as input, and outputs the profile (coordinates and height) along that polyline with a given resolution. Once we have that, the trick is nearly done… the part that’s missing is to use

r.profile

’s output to compute cumulative uphill, downhill, slopes and so on. That’s what I do with my

hikereport

script.

hikereport.py

hikereport

is a simple script that I have written. In the beginning it was a bash script, because I didn’t know python (you can find the bash version here). Unfortunately it was not portable, because it used arrays and arrays are not supported by

ash

. Moreover the bash version has dependencies (

awk, bc

) and runs very slow (because of all the piping with external tools like

bc

, for example).

So I wanted to make it portable and faster, and I tried a little bit with Tcl… but I didn’t like it at all. I don’t know why but it just looks awkward and ugly to me…

After that I looked at python and I think that this script, although simple, shows how you can immediately be productive with python. python is really worth learning. I just read the online tutorial and I started coding. Lots of fun, in other words!

Here is the code. You can also download it

  1 #!/usr/bin/python
  2 
  3 """
  4  MODULE:       hikereport
  5 
  6  AUTHOR(S):    Stefano Negri <ste.negri AT gmail.com>
  7 
  8  PURPOSE:      let the user draw a path, then report path's length,
  9                cumulative uphill climb, downhill climb, highest and lowest 
 10                 altitude, maximum and average uphill and downhill slope.
 11 
 12  COPYRIGHT:    (c) 2007 Stefano Negri
 13 
 14                This program is free software under the GNU General Public
 15                License (>=v2). Read the file COPYING that comes with GRASS
 16                for details.
 17 
 18  updates:
 19 
 20 24 December 2007: first version (bash)
 21 07 April 2008: first Python version
 22 08 April 2008: added "slope" option
 23 """
 24 
 25 import sys
 26 import os
 27 import subprocess
 28 import math
 29 
 30 class ParsingError(Exception):
 31     """
 32     Exception for errors while parsing r.profile's output
 33     """
 34     def __init__(self, value):
 35         self.value = value
 36     def __str__(self):
 37         return repr(self.value)
 38 
 39 def gmessage(message):
 40     """
 41     simple remapping of g.message
 42     """
 43     try:
 44         subprocess.call(["g.message", message])
 45     except OSError:
 46         sys.stderr.write(message + "\n" )
 47 
 48 def mapExists(mapname):
 49     """
 50     check if the map exists in current mapset (if so it returns True)
 51     """
 52     try:
 53         if subprocess.call(["g.findfile", "element=cell", "file=%s" % mapname]) != 0:
 54             gmessage('Raster map %s not found in mapset search path' % mapname)
 55             return False
 56         else:
 57             return True
 58     except OSError:
 59         return False
 60 
 61 def getTempFile():
 62     """
 63     ask grass a temporary file name and return it
 64     """
 65     return os.popen('g.tempfile pid=%d' % os.getpid()).readlines()[0]
 66 
 67 def getResolution(gmap):
 68     """
 69     get resolution from r.info for the given map
 70     """
 71 
 72     res = os.getenv("GIS_OPT_resolution" )
 73     if res == '':
 74         line = os.popen('r.info -s map=%s' % gmap).readlines()[0]
 75         res = line.split("=" )[1][:-1] #[:-1] removes trailing newline
 76     return res
 77 
 78 def getLen(line):
 79     """
 80     parses a line of r.profile's output and extracts the transect length
 81 
 82     >>> print getLen("a b 100 856" )
 83     100.0
 84 
 85     >>> print getLen("100 856" )
 86     100.0
 87     """
 88     fields = line.split(" " )
 89     if len(fields) == 2:
 90         return float(fields[0])
 91     elif len(fields) == 4:
 92         return float(fields[2])
 93     else:
 94         raise ParsingError("%s has an invalid number of fields" % line)
 95 
 96 def getZ(line):
 97     """
 98     parses a line of r.profile's output and extracts the height coordinate
 99 
100     >>> print getZ("a b 100 856" )
101     856.0
102 
103     >>> print getZ("100 856" )
104     856.0
105     """
106     fields = line.split(" " )
107     if len(fields) == 2:
108         return float(fields[1])
109     elif len(fields) == 4:
110         return float(fields[3])
111     else:
112         raise ParsingError("%s has an invalid number of fields" % line)
113 
114 def average(array):
115     """
116     given an array of numeric values, it returns its average
117     
118     >>> print average([20, 30, 70])
119     40
120     """
121     return sum(array)/len(array)
122 
123 def cumulativeUpHill(array):
124     """
125     given an altitude profile, expressed as an array of numeric values,
126     it returns the cumulative uphill
127 
128     >>> print cumulativeUpHill([10, 20, 30.5])
129     20.5
130 
131     >>> print cumulativeUpHill([10, 9, 8.1, 8.2, 7, 0, 4])
132     4.1
133 
134     >>> print cumulativeUpHill([2])
135     0
136     """
137     if len(array) < 2:
138         return 0
139 
140     i, j = 0, 1
141     total = 0
142     while j < len(array):
143         if array[j] > array[i]:
144             total += array[j] - array[i]
145         i, j = i+1, j+1
146 
147     return total
148 
149 def cumulativeDownHill(array):
150     """
151     given an altitude profile, expressed as an array of numeric values,
152     it returns the cumulative downhill
153 
154     >>> print  cumulativeDownHill([10, 20, 30.5])
155     0
156 
157     >>> print cumulativeDownHill([10, 9, 8.1, 8.2, 7, 0, 4])
158     10.1
159 
160     >>> print cumulativeDownHill([2])
161     0
162     """
163     if len(array) < 2:
164         return 0
165 
166     i, j = 0, 1
167     total = 0
168     while j < len(array):
169         if array[j] < array[i]:
170             total += array[i] - array[j]
171         i, j = i+1, j+1
172 
173     return total
174 
175 def extractArrays(outfile):
176     """
177     Extract an array of z coordinates and an array of lengths
178     from r.profile's output (contained in the file 'outfile')
179     """
180     lengthArr = []
181     heightArr = []
182     profile = open(outfile, 'r')
183     for curline in profile:
184         lengthArr.append(getLen(curline))
185         heightArr.append(getZ(curline))
186 
187     assert len(lengthArr) == len(heightArr), \
188             "An error has occurred while processing r.profile's output"
189 
190     #uncomment for debug
191 #    i = 0
192 #    for x, y in zip(lengthArr, heightArr):
193 #        print("%d: %f    %f" % (i, x, y))
194 #        i = i+1
195 
196     return heightArr, lengthArr
197 
198 def computeSlope(h, l, s):
199     """
200     return slope on single segment, 
201     expressed in % or degs according to parameter s
202 
203     >>> print computeSlope(10, 10, 'percent')
204     100
205 
206     >>> print computeSlope(10, 10, 'degrees')
207     45.0
208     """
209     if s == "percent":
210         return h / l * 100
211     if s == "degrees":
212         return math.degrees(math.atan(h / l))
213 
214 def buildSlopeArrays(ZArr, lenArr, slope):
215     """
216     Build an array of segments with upslope and downslope,
217     given ZArr, an array of heights, and lenArr, an array of lengths.
218     Resulting array contain slopes expressed in % or degrees, 
219     according to the value of parameter "slope"
220 
221     len(ZArr) == len(lenArr) while this is not necessarily true for
222     upSlopeArr and downSlopeArr
223     """
224 
225     assert (slope == "percent" or slope == "degrees" ), \
226             "slope preference incorrectly set"
227     upSlopes = []
228     downSlopes = []
229     i, j = 0, 1
230     #uncomment for debug:
231 #    print("%4s, %4s: (%5s - %11s), (%5s - %11s); slope percentage" % \
232 #        ("i", "j", "len", "z coord", "len", "z coord" ))
233 #    print("-" * 80)
234     while j < len(ZArr):
235         assert lenArr[j] != lenArr[i], "Corrupted profile"
236         segLen = lenArr[j] - lenArr[i]
237         if ZArr[j] > ZArr[i]:
238             heightDiff = ZArr[j] - ZArr[i]
239             upSlopes.append(computeSlope(heightDiff, segLen, slope))
240             #uncomment for debug:
241 #            print("%4d, %4d: (%5d - %f), (%5d - %f); up=%f" % \
242 #                (i, j, lenArr[i], ZArr[i], lenArr[j], ZArr[j], upSlopes[-1]))
243         else:
244             heightDiff = ZArr[i] - ZArr[j]
245             downSlopes.append(computeSlope(heightDiff, segLen, slope))
246             #uncomment for debug:
247 #            print("%4d, %4d: (%5d - %f), (%5d - %f); down=%f" % \
248 #                (i, j, lenArr[i], ZArr[i], lenArr[j], ZArr[j], downSlopes[-1]))
249         i, j = i+1, j+1
250     return upSlopes, downSlopes
251 
252 def main():
253     DEM = os.getenv("GIS_OPT_DEM" )
254     if not mapExists(DEM):
255         gmessage('Raster map %s not found in mapset search path' % DEM)
256         sys.exit(1)
257 
258     resolution = os.getenv("GIS_OPT_resolution" )
259     if resolution == '':
260         resolution = getResolution(DEM)
261         gmessage('using DEM map resolution: %s' % resolution)
262 
263     slope = os.getenv("GIS_OPT_slope" )
264     if not (slope == "percent" or slope == "degrees" ):
265         slope = "degrees"
266         gmessage('slope preference not set (available: "percent", "degrees" ): \
267                 using degrees')
268     if slope == "degrees":
269         slopeSymbol = "deg"
270     elif slope == "percent":
271         slopeSymbol = "%"
272 
273     outfile = getTempFile()
274     if outfile == '':
275         gmessage('Unable to create temporary file')
276         sys.exit(1)
277 
278     try:
279         subprocess.call(["r.profile", "-gi", "res=%s" % resolution, \
280                 "input=%s" % DEM, "output=%s" % outfile])
281         lenArr = [] #an array where all the segment lengths will be stored
282         ZArr = []   #an array where all the z coordinates will be stored
283         ZArr, lenArr = extractArrays(outfile)
284 
285         upSlopeArr = []   #up slope array
286         downSlopeArr = [] #down slope array
287         upSlopeArr, downSlopeArr = buildSlopeArrays(ZArr, lenArr, slope)
288 
289         minHeight = min(ZArr)
290         maxHeight = max(ZArr)
291         pathLength = lenArr[-1]
292         cumulativeUphill = cumulativeUpHill(ZArr)
293         cumulativeDownhill = cumulativeDownHill(ZArr)
294         maxUpSlope = max(upSlopeArr)
295         maxDownSlope = max(downSlopeArr)
296         avgUpSlope = average(upSlopeArr)
297         avgDownSlope = average(downSlopeArr)
298 
299         ##################################################
300         # output
301         ##################################################
302         gmessage("Path length:               %d" % pathLength)
303         gmessage("Cumulative uphill climb:   %d" % cumulativeUphill)
304         gmessage("Cumulative downhill climb: %d" % cumulativeDownhill)
305         gmessage("Highest altitude:          %d" % maxHeight)
306         gmessage("Lowest altitude:           %d" % minHeight)
307         gmessage("Maximum uphill slope:      %.1f%s" % \
308                 (maxUpSlope, slopeSymbol))
309         gmessage("Maximum downhill slope:    %.1f%s" % \
310                 (maxDownSlope, slopeSymbol))
311         gmessage("Average uphill slope:      %.1f%s" % \
312                 (avgUpSlope, slopeSymbol))
313         gmessage("Average downhill slope:    %.1f%s" % \
314                 (avgDownSlope, slopeSymbol))
315         gmessage("Profile:                   %s" % \
316                 outfile)
317 
318     except OSError:
319         gmessage("A fatal error has occurred while processing r.profile" )
320         sys.exit(1)
321     except ParsingError, pe:
322         gmessage(pe.value)
323         sys.exit(1)
324 
325 
326 if __name__ == "__main__":
327 
328     if os.getenv("GISBASE" ) == None:
329         gmessage("You must be in GRASS GIS to run this program" )
330         #if invoked from outside GRASS... at least run the tests :-) 
331         gmessage("...running tests for this module:" )
332         import doctest
333         failures, tests = doctest.testmod(report=1)
334         gmessage("    %d failures in %d tests" % (failures, tests))
335         sys.exit(1)
336 
337     if ( len(sys.argv) <= 1 or sys.argv[1] != "@ARGS_PARSED@" ):
338         os.execvp("g.parser", [sys.argv[0]] + sys.argv)
339     else:
340         main()
341 
342 
343 #%Module
344 #%  description: computes lenght and cumulative up and down climb for a given path
345 #%End
346 #%option
347 #%  key: DEM
348 #%  type: string
349 #%  gisprompt: old,cell,raster
350 #%  description: DEM map
351 #%  required : yes
352 #%end
353 #%option
354 #%  key: resolution
355 #%  type: integer
356 #%  description: resolution (default: DEM's resolution)
357 #%  required : no
358 #%end
359 #%option
360 #%  key: slope
361 #%  type: string
362 #%  description: express slope in percent or degrees? (default: degrees)
363 #%  required : no
364 #%end

I think (hope) it’s quite self explanatory. Something worth noting is the python way to do unit testing: you just write calls and expected results, see for example line 157, 158. Writing test code for such small modules might look useless but I can assure it is not. In my opinion unit tests are always a great help, and in python it’s really easy to write simple ones.

Usage

Anyway, how do you use it? First of all you must have a display open on a map of your choice. The script doesn’t open a display for you, it just fails if you don’t have one open. This is actually because the script relies on

r.profile

and that’s

r.profile

’s behaviour.

You can call the script giving three options: DEM, resolution and slope. Only DEM is mandatory.

DEM
the digital elevation model that contains the elevation information. Of course this is not necessarily the same map you have on display (for example I always use the CTR with 1:10000 scale on display, because it is so detailed and it’s easy to draw my path on it)
resolution
the resolution to use along the path; defaults to DEM’s resolution
slope
this has to be “percent” or “degrees”, one of these two strings. You can use it to specify (guess…) if you want slopes expressed in percent or degrees. It defaults to degrees

Once you issue the command you can start drawing your path on the screen, by left-clicking. When you right click your input session ends and the program outputs the results.

Una Risposta

  1. Hi, I would like to be able to run this script in GRASS 6.4.0RC3 under Windows XP but i am getting a number of errors. I am not too experienced with Python hence I cannot fix the problems, but I would like to ask you if this is the latest version of the hikereport.py script and if it works for you. Also, an example of a command line using a GRASS raster map as an argument would be helpful for me.
    Grazie
    George

Lascia un commento