UI to show sector coverage maps, #37802
[freeside.git] / FS / FS / tower_sector.pm
1 package FS::tower_sector;
2
3 use Class::Load qw(load_class);
4 use File::Path qw(make_path);
5 use Data::Dumper;
6
7 use strict;
8 use base qw( FS::Record );
9 use FS::Record qw( qsearch qsearchs );
10 use FS::tower;
11 use FS::svc_broadband;
12
13 =head1 NAME
14
15 FS::tower_sector - Object methods for tower_sector records
16
17 =head1 SYNOPSIS
18
19   use FS::tower_sector;
20
21   $record = new FS::tower_sector \%hash;
22   $record = new FS::tower_sector { 'column' => 'value' };
23
24   $error = $record->insert;
25
26   $error = $new_record->replace($old_record);
27
28   $error = $record->delete;
29
30   $error = $record->check;
31
32 =head1 DESCRIPTION
33
34 An FS::tower_sector object represents a tower sector.  FS::tower_sector
35 inherits from FS::Record.  The following fields are currently supported:
36
37 =over 4
38
39 =item sectornum
40
41 primary key
42
43 =item towernum
44
45 towernum
46
47 =item sectorname
48
49 sectorname
50
51 =item ip_addr
52
53 ip_addr
54
55 =item height
56
57 The height of this antenna on the tower, measured from ground level. This
58 plus the tower's altitude should equal the height of the antenna above sea
59 level.
60
61 =item freq_mhz
62
63 The band center frequency in MHz.
64
65 =item direction
66
67 The antenna beam direction in degrees from north.
68
69 =item width
70
71 The -3dB horizontal beamwidth in degrees.
72
73 =item downtilt
74
75 The antenna beam elevation in degrees below horizontal.
76
77 =item v_width
78
79 The -3dB vertical beamwidth in degrees.
80
81 =item margin
82
83 The signal loss margin allowed on the sector, in dB. This is normally
84 transmitter EIRP minus receiver sensitivity.
85
86 =item image 
87
88 The coverage map, as a PNG.
89
90 =item west, east, south, north
91
92 The coordinate boundaries of the coverage map.
93
94 =back
95
96 =head1 METHODS
97
98 =over 4
99
100 =item new HASHREF
101
102 Creates a new sector.  To add the sector to the database, see L<"insert">.
103
104 Note that this stores the hash reference, not a distinct copy of the hash it
105 points to.  You can ask the object for a copy with the I<hash> method.
106
107 =cut
108
109 sub table { 'tower_sector'; }
110
111 =item insert
112
113 Adds this record to the database.  If there is an error, returns the error,
114 otherwise returns false.
115
116 =item delete
117
118 Delete this record from the database.
119
120 =cut
121
122 sub delete {
123   my $self = shift;
124
125   #not the most efficient, not not awful, and its not like deleting a sector
126   # with customers is a common operation
127   return "Can't delete a sector with customers" if $self->svc_broadband;
128
129   $self->SUPER::delete;
130 }
131
132 =item check
133
134 Checks all fields to make sure this is a valid sector.  If there is
135 an error, returns the error, otherwise returns false.  Called by the insert
136 and replace methods.
137
138 =cut
139
140 sub check {
141   my $self = shift;
142
143   my $error = 
144     $self->ut_numbern('sectornum')
145     || $self->ut_number('towernum', 'tower', 'towernum')
146     || $self->ut_text('sectorname')
147     || $self->ut_textn('ip_addr')
148     || $self->ut_floatn('height')
149     || $self->ut_numbern('freq_mhz')
150     || $self->ut_numbern('direction')
151     || $self->ut_numbern('width')
152     || $self->ut_numbern('v_width')
153     || $self->ut_numbern('downtilt')
154     || $self->ut_floatn('sector_range')
155     || $self->ut_numbern('margin')
156     || $self->ut_anything('image')
157     || $self->ut_sfloatn('west')
158     || $self->ut_sfloatn('east')
159     || $self->ut_sfloatn('south')
160     || $self->ut_sfloatn('north')
161   ;
162   return $error if $error;
163
164   $self->SUPER::check;
165 }
166
167 =item tower
168
169 Returns the tower for this sector, as an FS::tower object (see L<FS::tower>).
170
171 =cut
172
173 sub tower {
174   my $self = shift;
175   qsearchs('tower', { 'towernum'=>$self->towernum } );
176 }
177
178 =item description
179
180 Returns a description for this sector including tower name.
181
182 =cut
183
184 sub description {
185   my $self = shift;
186   if ( $self->sectorname eq '_default' ) {
187     $self->tower->towername
188   }
189   else {
190     $self->tower->towername. ' sector '. $self->sectorname
191   }
192 }
193
194 =item svc_broadband
195
196 Returns the services on this tower sector.
197
198 =cut
199
200 sub svc_broadband {
201   my $self = shift;
202   qsearch('svc_broadband', { 'sectornum' => $self->sectornum });
203 }
204
205 =item need_fields_for_coverage
206
207 Returns a list of required fields for the coverage map that aren't yet filled.
208
209 =cut
210
211 sub need_fields_for_coverage {
212   my $self = shift;
213   my $tower = $self->tower;
214   my %fields = (
215     height    => 'Height',
216     freq_mhz  => 'Frequency',
217     direction => 'Direction',
218     downtilt  => 'Downtilt',
219     width     => 'Horiz. width',
220     v_width   => 'Vert. width',
221     margin    => 'Signal margin',
222     latitude  => 'Latitude',
223     longitude => 'Longitude',
224   );
225   my @need;
226   foreach (keys %fields) {
227     if ($self->get($_) eq '' and $tower->get($_) eq '') {
228       push @need, $fields{$_};
229     }
230   }
231   @need;
232 }
233
234 =item queue_generate_coverage
235
236 Starts a job to recalculate the coverage map.
237
238 =cut
239
240 sub queue_generate_coverage {
241   my $self = shift;
242   if ( length($self->image) > 0 ) {
243     foreach (qw(image west south east north)) {
244       $self->set($_, '');
245     }
246     my $error = $self->replace;
247     return $error if $error;
248   }
249   my $job = FS::queue->new({
250       job => 'FS::tower_sector::process_generate_coverage',
251   });
252   $job->insert('_JOB', { sectornum => $self->sectornum});
253 }
254
255 =back
256
257 =head1 SUBROUTINES
258
259 =over 4
260
261 =item process_generate_coverage JOB, PARAMS
262
263 Queueable routine to fetch the sector coverage map from the tower mapping
264 server and store it. Highly experimental. Requires L<Map::Splat> to be
265 installed.
266
267 PARAMS must include 'sectornum'.
268
269 =cut
270
271 sub process_generate_coverage {
272   my $job = shift;
273   my $param = shift;
274   $job->update_statustext('0,generating map') if $job;
275   my $sectornum = $param->{sectornum};
276   my $sector = FS::tower_sector->by_key($sectornum)
277     or die "sector $sectornum does not exist";
278   my $tower = $sector->tower;
279
280   load_class('Map::Splat');
281   # since this is still experimental, put it somewhere we can find later
282   my $workdir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/" .
283                 "generate_coverage/sector$sectornum-". time;
284   make_path($workdir);
285   my $splat = Map::Splat->new(
286     lon         => $tower->longitude,
287     lat         => $tower->latitude,
288     height      => ($sector->height || $tower->height || 0),
289     freq        => $sector->freq_mhz,
290     azimuth     => $sector->direction,
291     h_width     => $sector->width,
292     tilt        => $sector->downtilt,
293     v_width     => $sector->v_width,
294     max_loss    => $sector->margin,
295     min_loss    => $sector->margin - 80,
296     dir         => $workdir,
297   );
298   $splat->calculate;
299
300   my $box = $splat->box;
301   foreach (qw(west east south north)) {
302     $sector->set($_, $box->{$_});
303   }
304   $sector->set('image', $splat->mask);
305   # mask returns a PNG where everything below max_loss is solid colored,
306   # and everything above it is transparent. More useful for our purposes.
307   my $error = $sector->replace;
308   die $error if $error;
309 }
310
311 =head1 BUGS
312
313 =head1 SEE ALSO
314
315 L<FS::tower>, L<FS::Record>, schema.html from the base documentation.
316
317 =cut
318
319 1;
320