summaryrefslogtreecommitdiff
path: root/Splat.pm
diff options
context:
space:
mode:
authorMark Wells <mark@freeside.biz>2016-04-22 13:36:13 -0700
committerMark Wells <mark@freeside.biz>2016-04-22 13:36:13 -0700
commit0fa4cb99554008d229834423afa0550065e11028 (patch)
treebff3ffd72c07de7bc1dbf2b60f113cad90948f50 /Splat.pm
Import original source of Map-Splat 0.01
Diffstat (limited to 'Splat.pm')
-rw-r--r--Splat.pm521
1 files changed, 521 insertions, 0 deletions
diff --git a/Splat.pm b/Splat.pm
new file mode 100644
index 0000000..5729345
--- /dev/null
+++ b/Splat.pm
@@ -0,0 +1,521 @@
+package Map::Splat;
+
+use 5.006;
+use strict;
+use warnings FATAL => 'all';
+
+use File::chdir;
+use File::Temp qw(tempdir);
+use File::Path qw(make_path);
+use File::Slurp qw(read_file write_file);
+use XML::LibXML::Simple qw(XMLin);
+use List::Util qw(min max);
+use Data::Dumper;
+use Image::Magick;
+use Moo;
+use POSIX ('floor', 'ceil');
+use v5.10;
+
+=head1 NAME
+
+Map::Splat - Wrapper for the Splat! RF propagation modeling tool
+
+=head1 VERSION
+
+Version 0.01
+
+=cut
+
+our $VERSION = '0.01';
+
+=head1 SYNOPSIS
+
+ use Map::Splat;
+
+ my $foo = Map::Splat->new();
+ ...
+
+=head1 METHODS
+
+=over 4
+
+=cut
+
+# requires:
+# - Splat
+# - wget
+# - zip
+# - gdal-bin package (not used yet, but just wait)
+# - XML::LibXML
+# - ImageMagick
+
+my $SRTM_DIR = $ENV{HOME} . '/.splat';
+my $SRTM_PATH = 'http://dds.cr.usgs.gov/srtm/version2_1/SRTM3/North_America';
+my $SPLAT_CMD = '/usr/bin/splat';
+
+=item new PARAMETERS
+
+Constructs a new mapping scenario. This takes the following parameters:
+
+lon: the tower longitude (east is positive)
+
+lat: the tower latitude (north is positive)
+
+height: the tower height above ground level, in feet
+
+freq: the frequency, in MHz
+
+h_width: the horizontal beamwidth, in degrees
+
+v_width: the vertical beamwidth, in degrees
+
+azimuth: the antenna orientation, in degrees from north (90 = east)
+
+tilt: the downward tilt of the antenna, in degrees
+
+and optionally accepts:
+
+dir: the working directory to use; defaults to a random tempdir
+
+id: the filename prefix for working files; defaults to '1'
+
+name: a string describing the tower; defaults to 'Tower'
+
+min_loss: the lowest path loss level to show on the map; defaults to 80 dB
+
+max_loss: the highest path loss level to show on the map; defaults to 160 dB
+
+=cut
+
+foreach ( qw( lon lat height freq h_width v_width azimuth tilt ) ) {
+ has $_ => ( is => 'rw', required => 1 );
+}
+
+has 'dir' => (
+ is => 'ro',
+ default => sub {
+ tempdir(CLEANUP => 1)
+ },
+);
+
+has 'id' => (
+ is => 'ro',
+ default => sub { 1 }
+);
+
+has 'name' => (
+ is => 'ro',
+ default => sub { 'Tower' }
+);
+
+# there are more sophisticated ways to calculate this. for now we just expose
+# the ends of the scale as optional parameters.
+has 'min_loss' => (
+ is => 'ro',
+ default => sub { 80 }
+);
+
+has 'max_loss' => (
+ is => 'ro',
+ default => sub { 160 }
+);
+
+=item image
+
+After calling I<calculate> this will contain an L<Image::Magick> object with
+the raw image data.
+
+=cut
+
+has 'image' => (
+ is => 'rw',
+);
+
+=item box
+
+After calling I<calculate> this will contain a hashref of 'north', 'south',
+'east', and 'west' indicating the bounds of the map area.
+
+=cut
+
+has 'box' => (
+ is => 'rw',
+ default => sub { +{} }
+);
+
+=item db_colors
+
+Returns a hashref of (path loss level in dB) => (color level).
+
+=cut
+
+sub db_colors {
+ my $self = shift;
+ my %color;
+ # we have 32 color bands; find the width of a single band
+ my $db_step = ($self->max_loss - $self->min_loss) / 32;
+ for (my $i = 0; $i < 32; $i++) {
+ # + 1 here so that the highest band is _above_ the -db switch setting
+ my $db = ceil($self->max_loss - ($i * $db_step)) + 1;
+ my $color = 248 - ($i * 8);
+ $color{$db} = $color;
+ }
+ \%color;
+}
+
+sub ew {
+ my $lon = shift;
+ $lon < 0 ? 'W' : 'E';
+}
+
+sub ns {
+ my $lat = shift;
+ $lat < 0 ? 'S' : 'N';
+}
+
+sub debug {
+ my $self = shift;
+ my $arg = shift;
+ if (ref $arg) {
+ warn Dumper($arg);
+ } else {
+ warn "$arg\n";
+ }
+}
+
+=item calculate
+
+Invoke Splat to create a map. Currently, the map will cover the 1-degree
+quadrangle containing the tower location.
+
+=cut
+
+sub calculate {
+ my $self = shift;
+
+ my $signed_lat = $self->lat;
+ my $signed_lon = $self->lon;
+
+ my $id = $self->id;
+
+ local $CWD = $self->dir;
+
+ # write the site file
+ # note that Splat wants longitude in degrees west, from 0 to <360
+ # (this will also matter for SDF files)
+ my $splat_lon = -1 * $signed_lon;
+ $splat_lon += 360 if $splat_lon < 0;
+
+ $self->debug("creating QTH file");
+ write_file("$id.qth",
+ $self->name . "\n" .
+ $signed_lat . "\n" .
+ $splat_lon . "\n" .
+ $self->height . "\n"
+ );
+
+ # Longley-Rice parameters
+ my $freq = $self->freq;
+ my $pol = 1; # polarization = vertical
+ $self->debug("creating LRP file for $freq MHz");
+
+ my $lrp = <<EOF;
+15.000 ; Earth Dielectric Constant (Relative permittivity)
+0.005 ; Earth Conductivity (Siemens per meter)
+301.000 ; Atmospheric Bending Constant (N-Units)
+$freq ; Frequency in MHz (20 MHz to 20 GHz)
+5 ; Radio Climate
+$pol ; Polarization (0 = Horizontal, 1 = Vertical)
+0.50 ; Fraction of situations
+0.50 ; Fraction of time
+0 ; ERP
+EOF
+ write_file("$id.lrp", $lrp);
+
+ # Loss contour file: specifies the color mapping for the output
+ $self->debug("creating $id.lcf\n");
+ my $lcf = '';
+ my $colors = $self->db_colors;
+ for my $db (sort { $a <=> $b } keys %$colors) {
+ my $red = $colors->{$db};
+ $lcf .= "$db: $red, 0, 0\n";
+ }
+ write_file("$id.lcf", $lcf);
+
+ # AZ file: radiation pattern in horizontal plane
+ # should be able to take this as an input (and kinda can, by overriding
+ # antenna_pattern)
+ my $bearing = $self->azimuth;
+ my $downtilt = $self->tilt;
+ my ($az, $el) = $self->antenna_pattern;
+ $self->debug("creating $id.az for bearing $bearing\n");
+
+ open my $fh, '>', "$id.az";
+ printf $fh "%0.1f\n", ($bearing);
+ foreach my $t (sort { $a <=> $b } keys (%$az)) {
+ printf $fh "%d\t%0.6f\n", ($t, $az->{$t});
+ }
+
+ close $fh;
+
+ # EL file: radiation pattern in vertical plane
+ $self->debug("creating $id.el for downtilt $downtilt\n");
+ open $fh, '>', "$id.el";
+ printf $fh "%0.1f\t%0.1f\n", ($downtilt, $bearing);
+ foreach my $t (sort { $a <=> $b } keys (%$el)) {
+ printf $fh "%0.1f\t%0.3f\n", ($t, $el->{$t});
+ }
+ close $fh;
+
+ # make sure we have SRTM for the area around the antenna
+ # ("area around the antenna" should be configurable)
+ # (and support SRTM1/high def version? only works in North America)
+
+ for (my $dx = -2; $dx <= 2; $dx++) {
+ for (my $dy = -2; $dy <= 2; $dy++) {
+ my $splat_lon_east = floor($splat_lon) + $dx;
+ my $splat_lon_west = ($splat_lon_east + 1) % 360;
+ my $lat_south = floor($signed_lat) + $dy;
+ my $lat_north = $lat_south + 1;
+
+ next if $lat_north > 90 or $lat_south < -90; # haha
+
+ # the naming convention used by Splat's SDF files:
+ # south:north:east:west
+ # longitude is degrees west, from 0 to 359.
+ # note that the last one (from 1E to 0W) is 359:0, not 359:360
+ my $sdffile = "$SRTM_DIR/$lat_south:$lat_north:$splat_lon_east:$splat_lon_west.sdf";
+ if ( ! -f $sdffile ) {
+ if ( ! -d $SRTM_DIR ) {
+ make_path($SRTM_DIR); # dies on error
+ }
+ local $CWD = $SRTM_DIR;
+ # the USGS, though, just marks them as N/S and E/W. reference is the
+ # southwest corner.
+ # that's the negative of splat longitude, shifted to the range (-180, 180)
+ # or in the east, it's 360 - splat longitude
+ my $usgs_lon_west = -1 * $splat_lon_west;
+ $usgs_lon_west += 360 if $usgs_lon_west < -180;
+ my $hgtfile = ns($lat_south) . abs($lat_south) .
+ ew($usgs_lon_west) . abs($usgs_lon_west) .
+ '.hgt';
+ my $zipfile = $hgtfile . '.zip';
+ $self->debug("fetching $SRTM_PATH/$zipfile\n");
+ my $status = system('wget', "$SRTM_PATH/$zipfile");
+ if ( $status > 0 ) {
+ # normal if the tower location is near a coast, so try to continue
+ warn "couldn't download $zipfile\n";
+ next;
+ }
+ $status = system('unzip', $zipfile);
+ # this isn't normal though
+ die "couldn't extract $zipfile\n" if $status > 0;
+ # unfortunately gives no useful output
+ $status = system('srtm2sdf', $hgtfile);
+ unlink $hgtfile;
+ unlink $zipfile;
+ }
+ if ( ! -f $sdffile ) {
+ $self->debug("$sdffile could not be downloaded.\n");
+ next;
+ }
+ $self->debug("using file $sdffile\n");
+ } # $dy
+ } # $dx
+
+ $self->debug("running Splat...\n");
+ my @args = (
+ '-d', $SRTM_DIR,
+ '-t', "$id.qth", # transmitter site
+ '-o', "$id.ppm", # output file name
+ '-db', $self->max_loss, # power level range
+ '-L', '30', # assumed receiver antenna height - XXX should be config
+ '-R', '10', # maximum coverage radius - XXX should be config
+ '-ngs', # do not show terrain
+ '-kml', # generate a KML also
+ '-N', # suppress noise
+ '-erp', '0', # transmitter power (only showing relative path loss, so 0)
+ );
+ $self->debug(join(" ", @args) . "\n");
+ my $rv = system($SPLAT_CMD, @args);
+ if ( $rv != 0 ) {
+ $rv = $rv >> 8;
+ die "splat command failed ($rv): $!";
+ }
+
+ # results now in .kml referencing .ppm.
+ # pull the .kml apart to find the coordinates
+ my $kml = XMLin("$id.kml");
+ my $latlon = $kml->{'Folder'}->{'GroundOverlay'}->{'LatLonBox'};
+ $self->box($latlon);
+ my $image = Image::Magick->new;
+ $image->Read("$id.ppm");
+ $self->image($image);
+ # there's a lot we could do with this (GDAL polygonize, etc.) but for
+ # now just hold onto the output.
+}
+
+=item png
+
+Returns the rendered map, as a PNG. The path loss data will be in the red
+channel, scaled according to the I<db_colors> mapping.
+
+=cut
+
+sub png {
+ my $self = shift;
+ my $image = $self->image or return;
+ my $png = $image->Clone;
+ $png->Set(magick => 'png');
+ $png->Transparent('white');
+ return $png->ImageToBlob;
+}
+
+=item mask
+
+Returns the map as a PNG, with solid red in the areas where the path loss is
+below I<max_loss>. The rest of the image will be transparent.
+
+=cut
+
+sub mask {
+ my $self = shift;
+ my $image = $self->image or return;
+ my $png = $image->Clone;
+ $png->Set(magick => 'png');
+ $png->Threshold(threshold => 254, channel => 'Red');
+ $png->Transparent('white');
+ return $png->ImageToBlob;
+}
+
+=item box_geometry
+
+Returns the map area box as a GeoJSON-style geometry object.
+
+=cut
+
+sub box_geometry {
+ my $self = shift;
+ my $box = $self->box;
+ {
+ type => 'Polygon',
+ coordinates => [
+ [ $box->{west}, $box->{south} ],
+ [ $box->{east}, $box->{south} ],
+ [ $box->{east}, $box->{north} ],
+ [ $box->{west}, $box->{north} ],
+ [ $box->{west}, $box->{south} ],
+ ],
+ };
+}
+
+#internal method; you can subclass and override this to create a different
+#antenna pattern.
+
+sub antenna_pattern {
+
+ my $self = shift;
+ my $beamwidth = $self->h_width;
+
+ my $falloff = 2;
+
+ my %az = ();
+ my %el = ();
+ my $theta = 0;
+ while ($theta < 360) {
+
+ if ($theta <= ($beamwidth / 2)) {
+ $az{$theta} = 1.0;
+ } elsif ($theta >= (360 - ($beamwidth / 2))) {
+ $az{$theta} = 1.0;
+ } elsif ($theta > ($beamwidth / 2) and $theta < 180) {
+ my $x = ($theta - ($beamwidth / 2)) / ($beamwidth / 2);
+ $az{$theta} = 1+(-($x*$falloff)**2);
+ $az{$theta} = ($az{$theta} > 0) ? $az{$theta} : 0.001000;
+ } elsif ($theta < (360 - ($beamwidth / 2)) and $theta > 180) {
+ my $x = ($theta - (360 - ($beamwidth / 2))) / ($beamwidth / 2);
+ $az{$theta} = 1+(-(-$x*$falloff)**2);
+ $az{$theta} = ($az{$theta} > 0) ? $az{$theta} : 0.001000;
+ } else {
+ $az{$theta} = 0.001000;
+ }
+
+ $theta++;
+
+ }
+
+ $beamwidth = $self->v_width;
+ $theta = -10;
+ while ($theta <= 90) {
+
+ if (abs($theta) <= ($beamwidth / 2)) {
+ $el{$theta} = 1.0;
+ } else {
+ my $x = (abs($theta) - ($beamwidth / 2)) / ($beamwidth / 2);
+ $el{$theta} = 1+(-($x*$falloff)**2);
+ $el{$theta} = ($el{$theta} > 0) ? $el{$theta} : 0.001000;
+ }
+
+ $theta++;
+ }
+
+# while ($theta <= 90) {
+#
+# if ($theta >= -10 and $theta <= 90) {
+# $el{$theta} = 1.0;
+# } else {
+# $el{$theta} = 0.0;
+# }
+#
+# $theta += 0.5;
+#
+# }
+
+ return({%az}, {%el});
+
+}
+
+=back
+
+=head1 AUTHOR
+
+Mark Wells, C<< <mark at freeside.biz> >>
+
+=head1 BUGS
+
+Currently only works in North America due to a hardcoded download path.
+
+The antenna radiation patterns are somewhat arbitrary at the moment.
+
+Please report any bugs or feature requests to C<bug-map-splat at rt.cpan.org>,
+or through the web interface at
+L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Map-Splat>. I will be
+notified, and then you'll automatically be notified of progress on your bug
+as I make changes.
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc Map::Splat
+
+=head1 ACKNOWLEDGEMENTS
+
+John Magliacane (KD2BD) created Splat! and released it under the GNU GPL.
+
+This library is based in part on code by Kristian Hoffmann of Fire2Wire
+Internet Services.
+
+=head1 LICENSE AND COPYRIGHT
+
+Copyright 2016 Mark Wells.
+
+This program is free software; you can redistribute it and/or modify it
+under the terms of the the Artistic License (2.0). You may obtain a
+copy of the full license at:
+
+L<http://www.perlfoundation.org/artistic_license_2_0>
+
+=cut
+
+1;