5 use warnings FATAL => 'all';
8 use File::Temp qw(tempdir);
9 use File::Path qw(make_path);
10 use File::Slurp qw(read_file write_file);
11 use XML::LibXML::Simple qw(XMLin);
12 use List::Util qw(min max);
16 use POSIX ('floor', 'ceil');
21 Map::Splat - Wrapper for the Splat! RF propagation modeling tool
29 our $VERSION = '0.01';
35 my $foo = Map::Splat->new();
48 # - gdal-bin package (not used yet, but just wait)
52 my $SRTM_DIR = $ENV{HOME} . '/.splat';
53 my $SRTM_PATH = 'http://dds.cr.usgs.gov/srtm/version2_1/SRTM3/North_America';
54 my $SPLAT_CMD = '/usr/bin/splat';
58 Constructs a new mapping scenario. This takes the following parameters:
60 lon: the tower longitude (east is positive)
62 lat: the tower latitude (north is positive)
64 height: the tower height above ground level, in feet
66 freq: the frequency, in MHz
68 h_width: the horizontal beamwidth, in degrees
70 v_width: the vertical beamwidth, in degrees
72 azimuth: the antenna orientation, in degrees from north (90 = east)
74 tilt: the downward tilt of the antenna, in degrees
76 and optionally accepts:
78 dir: the working directory to use; defaults to a random tempdir
80 id: the filename prefix for working files; defaults to '1'
82 name: a string describing the tower; defaults to 'Tower'
84 min_loss: the lowest path loss level to show on the map; defaults to 80 dB
86 max_loss: the highest path loss level to show on the map; defaults to 160 dB
90 foreach ( qw( lon lat height freq h_width v_width azimuth tilt ) ) {
91 has $_ => ( is => 'rw', required => 1 );
108 default => sub { 'Tower' }
111 # there are more sophisticated ways to calculate this. for now we just expose
112 # the ends of the scale as optional parameters.
115 default => sub { 80 }
120 default => sub { 160 }
125 After calling I<calculate> this will contain an L<Image::Magick> object with
136 After calling I<calculate> this will contain a hashref of 'north', 'south',
137 'east', and 'west' indicating the bounds of the map area.
143 default => sub { +{} }
148 Returns a hashref of (path loss level in dB) => (color level).
155 # we have 32 color bands; find the width of a single band
156 my $db_step = ($self->max_loss - $self->min_loss) / 32;
157 for (my $i = 0; $i < 32; $i++) {
158 # + 1 here so that the highest band is _above_ the -db switch setting
159 my $db = ceil($self->max_loss - ($i * $db_step)) + 1;
160 my $color = 248 - ($i * 8);
161 $color{$db} = $color;
168 $lon < 0 ? 'W' : 'E';
173 $lat < 0 ? 'S' : 'N';
188 Invoke Splat to create a map. Currently, the map will cover the 1-degree
189 quadrangle containing the tower location.
196 my $signed_lat = $self->lat;
197 my $signed_lon = $self->lon;
201 local $CWD = $self->dir;
203 # write the site file
204 # note that Splat wants longitude in degrees west, from 0 to <360
205 # (this will also matter for SDF files)
206 my $splat_lon = -1 * $signed_lon;
207 $splat_lon += 360 if $splat_lon < 0;
209 $self->debug("creating QTH file");
210 write_file("$id.qth",
217 # Longley-Rice parameters
218 my $freq = $self->freq;
219 my $pol = 1; # polarization = vertical
220 $self->debug("creating LRP file for $freq MHz");
223 15.000 ; Earth Dielectric Constant (Relative permittivity)
224 0.005 ; Earth Conductivity (Siemens per meter)
225 301.000 ; Atmospheric Bending Constant (N-Units)
226 $freq ; Frequency in MHz (20 MHz to 20 GHz)
228 $pol ; Polarization (0 = Horizontal, 1 = Vertical)
229 0.50 ; Fraction of situations
230 0.50 ; Fraction of time
233 write_file("$id.lrp", $lrp);
235 # Loss contour file: specifies the color mapping for the output
236 $self->debug("creating $id.lcf\n");
238 my $colors = $self->db_colors;
239 for my $db (sort { $a <=> $b } keys %$colors) {
240 my $red = $colors->{$db};
241 $lcf .= "$db: $red, 0, 0\n";
243 write_file("$id.lcf", $lcf);
245 # AZ file: radiation pattern in horizontal plane
246 # should be able to take this as an input (and kinda can, by overriding
248 my $bearing = $self->azimuth;
249 my $downtilt = $self->tilt;
250 my ($az, $el) = $self->antenna_pattern;
251 $self->debug("creating $id.az for bearing $bearing\n");
253 open my $fh, '>', "$id.az";
254 printf $fh "%0.1f\n", ($bearing);
255 foreach my $t (sort { $a <=> $b } keys (%$az)) {
256 printf $fh "%d\t%0.6f\n", ($t, $az->{$t});
261 # EL file: radiation pattern in vertical plane
262 $self->debug("creating $id.el for downtilt $downtilt\n");
263 open $fh, '>', "$id.el";
264 printf $fh "%0.1f\t%0.1f\n", ($downtilt, $bearing);
265 foreach my $t (sort { $a <=> $b } keys (%$el)) {
266 printf $fh "%0.1f\t%0.3f\n", ($t, $el->{$t});
270 # make sure we have SRTM for the area around the antenna
271 # ("area around the antenna" should be configurable)
272 # (and support SRTM1/high def version? only works in North America)
274 for (my $dx = -2; $dx <= 2; $dx++) {
275 for (my $dy = -2; $dy <= 2; $dy++) {
276 my $splat_lon_east = floor($splat_lon) + $dx;
277 my $splat_lon_west = ($splat_lon_east + 1) % 360;
278 my $lat_south = floor($signed_lat) + $dy;
279 my $lat_north = $lat_south + 1;
281 next if $lat_north > 90 or $lat_south < -90; # haha
283 # the naming convention used by Splat's SDF files:
284 # south:north:east:west
285 # longitude is degrees west, from 0 to 359.
286 # note that the last one (from 1E to 0W) is 359:0, not 359:360
287 my $sdffile = "$SRTM_DIR/$lat_south:$lat_north:$splat_lon_east:$splat_lon_west.sdf";
288 if ( ! -f $sdffile ) {
289 if ( ! -d $SRTM_DIR ) {
290 make_path($SRTM_DIR); # dies on error
292 local $CWD = $SRTM_DIR;
293 # the USGS, though, just marks them as N/S and E/W. reference is the
295 # that's the negative of splat longitude, shifted to the range (-180, 180)
296 # or in the east, it's 360 - splat longitude
297 my $usgs_lon_west = -1 * $splat_lon_west;
298 $usgs_lon_west += 360 if $usgs_lon_west < -180;
299 my $hgtfile = ns($lat_south) . abs($lat_south) .
300 ew($usgs_lon_west) . abs($usgs_lon_west) .
302 my $zipfile = $hgtfile . '.zip';
303 $self->debug("fetching $SRTM_PATH/$zipfile\n");
304 my $status = system('wget', "$SRTM_PATH/$zipfile");
306 # normal if the tower location is near a coast, so try to continue
307 warn "couldn't download $zipfile\n";
310 $status = system('unzip', $zipfile);
311 # this isn't normal though
312 die "couldn't extract $zipfile\n" if $status > 0;
313 # unfortunately gives no useful output
314 $status = system('srtm2sdf', $hgtfile);
318 if ( ! -f $sdffile ) {
319 $self->debug("$sdffile could not be downloaded.\n");
322 $self->debug("using file $sdffile\n");
326 $self->debug("running Splat...\n");
329 '-t', "$id.qth", # transmitter site
330 '-o', "$id.ppm", # output file name
331 '-db', $self->max_loss, # power level range
332 '-L', '30', # assumed receiver antenna height - XXX should be config
333 '-R', '10', # maximum coverage radius - XXX should be config
334 '-ngs', # do not show terrain
335 '-kml', # generate a KML also
336 '-N', # suppress noise
337 '-erp', '0', # transmitter power (only showing relative path loss, so 0)
339 $self->debug(join(" ", @args) . "\n");
340 my $rv = system($SPLAT_CMD, @args);
343 die "splat command failed ($rv): $!";
346 # results now in .kml referencing .ppm.
347 # pull the .kml apart to find the coordinates
348 my $kml = XMLin("$id.kml");
349 my $latlon = $kml->{'Folder'}->{'GroundOverlay'}->{'LatLonBox'};
351 my $image = Image::Magick->new;
352 $image->Read("$id.ppm");
353 $self->image($image);
354 # there's a lot we could do with this (GDAL polygonize, etc.) but for
355 # now just hold onto the output.
360 Returns the rendered map, as a PNG. The path loss data will be in the red
361 channel, scaled according to the I<db_colors> mapping.
367 my $image = $self->image or return;
368 my $png = $image->Clone;
369 $png->Set(magick => 'png');
370 $png->Transparent('white');
371 return $png->ImageToBlob;
376 Returns the map as a PNG, with solid red in the areas where the path loss is
377 below I<max_loss>. The rest of the image will be transparent.
383 my $image = $self->image or return;
384 my $png = $image->Clone;
385 $png->Set(magick => 'png');
386 $png->Threshold(threshold => 254, channel => 'Red');
387 $png->Transparent('white');
388 return $png->ImageToBlob;
393 Returns the map area box as a GeoJSON-style geometry object.
399 my $box = $self->box;
403 [ $box->{west}, $box->{south} ],
404 [ $box->{east}, $box->{south} ],
405 [ $box->{east}, $box->{north} ],
406 [ $box->{west}, $box->{north} ],
407 [ $box->{west}, $box->{south} ],
412 #internal method; you can subclass and override this to create a different
415 sub antenna_pattern {
418 my $beamwidth = $self->h_width;
425 while ($theta < 360) {
427 if ($theta <= ($beamwidth / 2)) {
429 } elsif ($theta >= (360 - ($beamwidth / 2))) {
431 } elsif ($theta > ($beamwidth / 2) and $theta < 180) {
432 my $x = ($theta - ($beamwidth / 2)) / ($beamwidth / 2);
433 $az{$theta} = 1+(-($x*$falloff)**2);
434 $az{$theta} = ($az{$theta} > 0) ? $az{$theta} : 0.001000;
435 } elsif ($theta < (360 - ($beamwidth / 2)) and $theta > 180) {
436 my $x = ($theta - (360 - ($beamwidth / 2))) / ($beamwidth / 2);
437 $az{$theta} = 1+(-(-$x*$falloff)**2);
438 $az{$theta} = ($az{$theta} > 0) ? $az{$theta} : 0.001000;
440 $az{$theta} = 0.001000;
447 $beamwidth = $self->v_width;
449 while ($theta <= 90) {
451 if (abs($theta) <= ($beamwidth / 2)) {
454 my $x = (abs($theta) - ($beamwidth / 2)) / ($beamwidth / 2);
455 $el{$theta} = 1+(-($x*$falloff)**2);
456 $el{$theta} = ($el{$theta} > 0) ? $el{$theta} : 0.001000;
462 # while ($theta <= 90) {
464 # if ($theta >= -10 and $theta <= 90) {
474 return({%az}, {%el});
482 Mark Wells, C<< <mark at freeside.biz> >>
486 Currently only works in North America due to a hardcoded download path.
488 The antenna radiation patterns are somewhat arbitrary at the moment.
490 Please report any bugs or feature requests to C<bug-map-splat at rt.cpan.org>,
491 or through the web interface at
492 L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Map-Splat>. I will be
493 notified, and then you'll automatically be notified of progress on your bug
498 You can find documentation for this module with the perldoc command.
502 =head1 ACKNOWLEDGEMENTS
504 John Magliacane (KD2BD) created Splat! and released it under the GNU GPL.
506 This library is based in part on code by Kristian Hoffmann of Fire2Wire
509 =head1 LICENSE AND COPYRIGHT
511 Copyright 2016 Mark Wells.
513 This program is free software; you can redistribute it and/or modify it
514 under the terms of the the Artistic License (2.0). You may obtain a
515 copy of the full license at:
517 L<http://www.perlfoundation.org/artistic_license_2_0>