2020 census data
[freeside.git] / FS / FS / tower_sector.pm
1 package FS::tower_sector;
2 use base qw( FS::Record );
3
4 use FS::Record qw(dbh qsearch);
5 use Class::Load qw(load_class);
6 use File::Path qw(make_path);
7 use Data::Dumper;
8 use Cpanel::JSON::XS;
9
10 use strict;
11
12 our $noexport_hack = 0;
13
14 =head1 NAME
15
16 FS::tower_sector - Object methods for tower_sector records
17
18 =head1 SYNOPSIS
19
20   use FS::tower_sector;
21
22   $record = new FS::tower_sector \%hash;
23   $record = new FS::tower_sector { 'column' => 'value' };
24
25   $error = $record->insert;
26
27   $error = $new_record->replace($old_record);
28
29   $error = $record->delete;
30
31   $error = $record->check;
32
33 =head1 DESCRIPTION
34
35 An FS::tower_sector object represents a tower sector.  FS::tower_sector
36 inherits from FS::Record.  The following fields are currently supported:
37
38 =over 4
39
40 =item sectornum
41
42 primary key
43
44 =item towernum
45
46 towernum
47
48 =item sectorname
49
50 sectorname
51
52 =item ip_addr
53
54 ip_addr
55
56 =item height
57
58 The height of this antenna on the tower, measured from ground level. This
59 plus the tower's altitude should equal the height of the antenna above sea
60 level.
61
62 =item freq_mhz
63
64 The band center frequency in MHz.
65
66 =item direction
67
68 The antenna beam direction in degrees from north.
69
70 =item width
71
72 The -3dB horizontal beamwidth in degrees.
73
74 =item downtilt
75
76 The antenna beam elevation in degrees below horizontal.
77
78 =item v_width
79
80 The -3dB vertical beamwidth in degrees.
81
82 =item db_high
83
84 The signal loss margin to treat as "high quality".
85
86 =item db_low
87
88 The signal loss margin to treat as "low quality".
89
90 =item image 
91
92 The coverage map, as a PNG.
93
94 =item west, east, south, north
95
96 The coordinate boundaries of the coverage map.
97
98 =item title
99
100 The sector title.
101
102 =item up_rate_limit
103
104 Up rate limit for sector.
105
106 =item down_rate_limit
107
108 down rate limit for sector.
109
110 =back
111
112 =head1 METHODS
113
114 =over 4
115
116 =item new HASHREF
117
118 Creates a new sector.  To add the sector to the database, see L<"insert">.
119
120 Note that this stores the hash reference, not a distinct copy of the hash it
121 points to.  You can ask the object for a copy with the I<hash> method.
122
123 =cut
124
125 sub table { 'tower_sector'; }
126
127 =item insert
128
129 Adds this record to the database.  If there is an error, returns the error,
130 otherwise returns false.
131
132 =cut
133
134 sub insert {
135   my $self = shift;
136
137   my $oldAutoCommit = $FS::UID::AutoCommit;
138   local $FS::UID::AutoCommit = 0;
139   my $dbh = dbh;
140
141   my $error = $self->SUPER::insert;
142   return $error if $error;
143
144   unless ($noexport_hack) {
145     foreach my $part_export ($self->part_export) {
146       my $error = $part_export->export_insert($self);
147       if ( $error ) {
148         $dbh->rollback if $oldAutoCommit;
149         return "exporting to ".$part_export->exporttype.
150                " (transaction rolled back): $error";
151       }
152     }
153   }
154
155   # XXX exportify
156   if (scalar($self->need_fields_for_coverage) == 0) {
157     $self->queue_generate_coverage;
158   }
159 }
160
161 sub replace {
162   my $self = shift;
163
164   my $oldAutoCommit = $FS::UID::AutoCommit;
165   local $FS::UID::AutoCommit = 0;
166   my $dbh = dbh;
167
168   my $old = shift || $self->replace_old;
169   my $error = $self->SUPER::replace($old);
170   return $error if $error;
171
172   unless ( $noexport_hack ) {
173     foreach my $part_export ($self->part_export) {
174       my $error = $part_export->export_replace($self, $old);
175       if ( $error ) {
176         $dbh->rollback if $oldAutoCommit;
177         return "exporting to ".$part_export->exporttype.
178                " (transaction rolled back): $error";
179       }
180     }
181   }
182
183   #XXX exportify
184   my $regen_coverage = 0;
185   if ( !$self->get('no_regen') ) {
186     foreach (qw(height freq_mhz direction width downtilt
187                 v_width db_high db_low))
188     {
189       $regen_coverage = 1 if ($self->get($_) ne $old->get($_));
190     }
191   }
192
193
194   if ($regen_coverage) {
195     $self->queue_generate_coverage;
196   }
197 }
198
199 =item delete
200
201 Delete this record from the database.
202
203 =cut
204
205 sub delete {
206   my $self = shift;
207
208   my $oldAutoCommit = $FS::UID::AutoCommit;
209   local $FS::UID::AutoCommit = 0;
210   my $dbh = dbh;
211
212   #not the most efficient, not not awful, and its not like deleting a sector
213   # with customers is a common operation
214   return "Can't delete a sector with customers" if $self->svc_broadband;
215
216   unless ($noexport_hack) {
217     foreach my $part_export ($self->part_export) {
218       my $error = $part_export->export_delete($self);
219       if ( $error ) {
220         $dbh->rollback if $oldAutoCommit;
221         return "exporting to ".$part_export->exporttype.
222                " (transaction rolled back): $error";
223       }
224     }
225   }
226
227   my $error = $self->SUPER::delete;
228   if ( $error ) {
229     $dbh->rollback if $oldAutoCommit;
230     return $error;
231   }
232
233 }
234
235 =item check
236
237 Checks all fields to make sure this is a valid sector.  If there is
238 an error, returns the error, otherwise returns false.  Called by the insert
239 and replace methods.
240
241 =cut
242
243 sub check {
244   my $self = shift;
245
246   my $error = 
247     $self->ut_numbern('sectornum')
248     || $self->ut_number('towernum', 'tower', 'towernum')
249     || $self->ut_text('sectorname')
250     || $self->ut_ip46n('ip_addr')
251     || $self->ut_floatn('height')
252     || $self->ut_numbern('freq_mhz')
253     || $self->ut_numbern('direction')
254     || $self->ut_numbern('width')
255     || $self->ut_numbern('v_width')
256     || $self->ut_numbern('downtilt')
257     || $self->ut_floatn('sector_range')
258     || $self->ut_decimaln('power')
259     || $self->ut_decimaln('line_loss')
260     || $self->ut_decimaln('antenna_gain')
261     || $self->ut_numbern('hardware_typenum')
262     || $self->ut_textn('title')
263     || $self->ut_numbern('up_rate_limit')
264     || $self->ut_numbern('down_rate_limit')
265     # all of these might get relocated as part of coverage refactoring
266     || $self->ut_anything('image')
267     || $self->ut_sfloatn('west')
268     || $self->ut_sfloatn('east')
269     || $self->ut_sfloatn('south')
270     || $self->ut_sfloatn('north')
271     || $self->ut_numbern('db_high')
272     || $self->ut_numbern('db_low')
273   ;
274   return $error if $error;
275
276   $self->SUPER::check;
277 }
278
279 =item tower
280
281 Returns the tower for this sector, as an FS::tower object (see L<FS::tower>).
282
283 =item description
284
285 Returns a description for this sector including tower name.
286
287 =cut
288
289 sub description {
290   my $self = shift;
291   if ( $self->sectorname eq '_default' ) {
292     $self->tower->towername
293   }
294   else {
295     $self->tower->towername. ' sector '. $self->sectorname
296   }
297 }
298
299 =item svc_broadband
300
301 Returns the services on this tower sector.
302
303 =item need_fields_for_coverage
304
305 Returns a list of required fields for the coverage map that aren't yet filled.
306
307 =cut
308
309 sub need_fields_for_coverage {
310   # for now assume exports require all of this
311   my $self = shift;
312   my $tower = $self->tower;
313   my %fields = (
314     height    => 'Height',
315     freq_mhz  => 'Frequency',
316     direction => 'Direction',
317     downtilt  => 'Downtilt',
318     width     => 'Horiz. width',
319     v_width   => 'Vert. width',
320     db_high   => 'High quality signal margin',
321     db_low    => 'Low quality signal margin',
322     latitude  => 'Latitude',
323     longitude => 'Longitude',
324   );
325   my @need;
326   foreach (keys %fields) {
327     if ($self->get($_) eq '' and $tower->get($_) eq '') {
328       push @need, $fields{$_};
329     }
330   }
331   @need;
332 }
333
334 =item queue_generate_coverage
335
336 Starts a job to recalculate the coverage map.
337
338 =cut
339
340 # XXX move to an export
341
342 sub queue_generate_coverage {
343   my $self = shift;
344   my $need_fields = join(',', $self->need_fields_for_coverage);
345   return "$need_fields required" if $need_fields;
346   $self->set('no_regen', 1); # avoid recursion
347   if ( length($self->image) > 0 ) {
348     foreach (qw(image west south east north)) {
349       $self->set($_, '');
350     }
351     my $error = $self->replace;
352     return $error if $error;
353   }
354   my $job = FS::queue->new({
355       job => 'FS::tower_sector::process_generate_coverage',
356   });
357   $job->insert('_JOB', { sectornum => $self->sectornum});
358 }
359
360 =back
361
362 =head1 CLASS METHODS
363
364 =over 4
365
366 =item part_export
367
368 Returns all sector exports. Eventually this may be refined to the level
369 of enabling exports on specific sectors.
370
371 =cut
372
373 sub part_export {
374   my $info = $FS::part_export::exports{'tower_sector'} or return;
375   my @exporttypes = map { dbh->quote($_) } keys %$info or return;
376   qsearch({
377     'table'     => 'part_export',
378     'extra_sql' => 'WHERE exporttype IN(' . join(',', @exporttypes) . ')'
379   });
380 }
381
382 =item part_export_svc_broadband
383
384 Returns all svc_broadband exports.
385
386 =cut
387
388 sub part_export_svc_broadband {
389   my $info = $FS::part_export::exports{'svc_broadband'} or return;
390   my @exporttypes = map { dbh->quote($_) } keys %$info or return;
391   qsearch({
392     'table'     => 'part_export',
393     'extra_sql' => 'WHERE exporttype IN(' . join(',', @exporttypes) . ')'
394   });
395 }
396
397 =back
398
399 =head1 SUBROUTINES
400
401 =over 4
402
403 =item process_generate_coverage JOB, PARAMS
404
405 Queueable routine to fetch the sector coverage map from the tower mapping
406 server and store it. Highly experimental. Requires L<Map::Splat> to be
407 installed.
408
409 PARAMS must include 'sectornum'.
410
411 =cut
412
413 sub process_generate_coverage {
414   my $job = shift;
415   my $param = shift;
416   $job->update_statustext('0,generating map') if $job;
417   my $sectornum = $param->{sectornum};
418   my $sector = FS::tower_sector->by_key($sectornum)
419     or die "sector $sectornum does not exist";
420   $sector->set('no_regen', 1); # avoid recursion
421   my $tower = $sector->tower;
422
423   load_class('Map::Splat');
424
425   # since this is still experimental, put it somewhere we can find later
426   my $workdir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/" .
427                 "generate_coverage/sector$sectornum-". time;
428   make_path($workdir);
429   my $splat = Map::Splat->new(
430     lon         => $tower->longitude,
431     lat         => $tower->latitude,
432     height      => ($sector->height || $tower->height || 0),
433     freq        => $sector->freq_mhz,
434     azimuth     => $sector->direction,
435     h_width     => $sector->width,
436     tilt        => $sector->downtilt,
437     v_width     => $sector->v_width,
438     db_levels   => [ $sector->db_low, $sector->db_high ],
439     dir         => $workdir,
440     #simplify    => 0.0004, # remove stairstepping in SRTM3 data?
441   );
442   $splat->calculate;
443
444   my $box = $splat->box;
445   foreach (qw(west east south north)) {
446     $sector->set($_, $box->{$_});
447   }
448   $sector->set('image', $splat->png);
449   my $error = $sector->replace;
450   die $error if $error;
451
452   foreach ($sector->sector_coverage) {
453     $error = $_->delete;
454     die $error if $error;
455   }
456   # XXX undecided whether Map::Splat should even do this operation
457   # or how to store it
458   # or anything else
459   $DB::single = 1;
460   my $data = decode_json( $splat->polygonize_json );
461   for my $feature (@{ $data->{features} }) {
462     my $db = $feature->{properties}{level};
463     my $coverage = FS::sector_coverage->new({
464       sectornum => $sectornum,
465       db_loss   => $db,
466       geometry  => encode_json($feature->{geometry})
467     });
468     $error = $coverage->insert;
469   }
470
471   die $error if $error;
472 }
473
474 sub _upgrade_data {
475
476   require FS::Misc::FixIPFormat;
477   FS::Misc::FixIPFormat::fix_bad_addresses_in_table(
478       'tower_sector', 'sectornum', 'ip_addr',
479   );
480
481   '';
482
483 }
484
485 =head1 BUGS
486
487 =head1 SEE ALSO
488
489 L<FS::tower>, L<FS::Record>, schema.html from the base documentation.
490
491 =cut
492
493 1;