File Coverage

blib/lib/RF/Antenna/Planet/MSI/Format.pm
Criterion Covered Total %
statement 213 231 92.2
branch 82 118 69.4
condition 3 3 100.0
subroutine 32 34 94.1
pod 24 24 100.0
total 354 410 86.3


line stmt bran cond sub pod time code
1             package RF::Antenna::Planet::MSI::Format;
2 12     12   1240894 use strict;
  12         141  
  12         347  
3 12     12   81 use warnings;
  12         29  
  12         329  
4 12     12   60 use Scalar::Util qw();
  12         24  
  12         254  
5 12     12   6057 use Tie::IxHash qw{};
  12         41248  
  12         300  
6 12     12   1314 use Path::Class qw{};
  12         121667  
  12         305  
7 12     12   5286 use RF::Functions 0.04, qw{dbd_dbi dbi_dbd};
  12         115529  
  12         35322  
8              
9             our $VERSION = '0.11';
10             our $PACKAGE = __PACKAGE__;
11              
12             =head1 NAME
13              
14             RF::Antenna::Planet::MSI::Format - RF Antenna Pattern File Reader and Writer in Planet MSI Format
15              
16             =head1 SYNOPSIS
17              
18             Read from MSI file
19              
20             use RF::Antenna::Planet::MSI::Format;
21             my $antenna = RF::Antenna::Planet::MSI::Format->new;
22             $antenna->read($filename);
23              
24             Create a blank object, load data from other sources, then write antenna pattern file.
25              
26             my $antenna = RF::Antenna::Planet::MSI::Format->new;
27             $antenna->name("My Name");
28             $antenna->make("My Make");
29             my $file = $antenna->write($filename);
30              
31             =head1 DESCRIPTION
32              
33             This package reads and writes antenna radiation patterns in Planet MSI antenna format.
34              
35             Planet is a RF propagation simulation tool initially developed by MSI. Planet was a 2G radio planning tool which has set a standard in the early days of computer aided radio network design. The antenna pattern file and the format which is currently known as the ".msi" format or an msi file has become a standard.
36              
37             =head1 CONSTRUCTORS
38              
39             =head2 new
40              
41             Creates a new blank object for creating files or loading data from other sources
42              
43             my $antenna = RF::Antenna::Planet::MSI::Format->new;
44              
45             Creates a new object and loads data from other sources
46              
47             my $antenna = RF::Antenna::Planet::MSI::Format->new(
48             NAME => "My Antenna Name",
49             MAKE => "My Manufacturer Name",
50             FREQUENCY => "2437" || "2437 MHz" || "2.437 GHz",
51             GAIN => "10.0" || "10.0 dBd" || "12.15 dBi",
52             COMMENT => "My Comment",
53             horizontal => [[0.00, 0.96], [1.00, 0.04], ..., [180.00, 31.10], ..., [359.00, 0.04]],
54             vertical => [[0.00, 1.08], [1.00, 0.18], ..., [180.00, 31.23], ..., [359.00, 0.18]],
55             );
56              
57             =cut
58              
59             sub new {
60 19     19 1 6226 my $this = shift;
61 19 50       106 die("Error: new constructor requires key/value pairs") if @_ % 2;
62 19 50       77 my $class = ref($this) ? ref($this) : $this;
63 19         49 my $self = {};
64 19         43 bless $self, $class;
65              
66 19         50 my @later = ();
67 19         68 while (@_) { #preserve order
68 4         8 my $key = shift;
69 4         7 my $value = shift;
70 4 100       16 if ($key eq 'header') {
    50          
    50          
71 2 100       10 if (ref($value) eq 'HASH') {
    50          
72 1         6 my @order = qw{NAME MAKE FREQUENCY GAIN TILT POLARIZATION COMMENT};
73 1         5 my %copy = %$value;
74 1         3 foreach my $key (@order) {
75 7 100       20 $self->header($key => delete($copy{$key})) if exists $copy{$key};
76             }
77 1         6 $self->header(%copy); #appends the rest in hash order
78             } elsif (ref($value) eq 'ARRAY') {
79 1         6 $self->header(@$value); #preserves order
80             } else {
81 0         0 die("Error: header value expected to be either array reference or hash reference");
82             }
83             } elsif ($key eq 'horizontal') {
84 0 0       0 die("Error: horizontal value expected to be array reference") unless ref($value) eq 'ARRAY';
85 0         0 $self->horizontal($value);
86             } elsif ($key eq 'vertical') {
87 0 0       0 die("Error: vertical value expected to be array reference") unless ref($value) eq 'ARRAY';
88 0         0 $self->vertical($value);
89             } else {
90 2 50       5 die("Error: header key/value pairs must be strings") if ref($value);
91 2         6 push @later, $key, $value; #store for later so that we process header before header keys
92             }
93             }
94 19 100       63 $self->header(@later) if @later;
95              
96 19         61 return $self;
97             }
98              
99             =head2 read
100              
101             Reads an antenna pattern file and parses the data into the object data structure. Returns the object so that the call can be chained.
102              
103             $antenna->read($filename);
104             $antenna->read(\$scalar);
105              
106             Assumptions:
107             The first line in the MSI file contains the name of the antenna. It appears that some vendors suppress the "NAME" token but we always write the NAME token.
108             The keys can be mixed case but convention appears to be all upper case keys for common keys and lower case keys for vendor extensions.
109              
110             =cut
111              
112 0         0 sub read {
113 9     9 1 5445 my $self = shift;
114 9         21 my $file = shift;
115 9 100       49 my $blob = ref($file) eq 'SCALAR' ? ${$file} : Path::Class::file($file)->slurp;
  7         19  
116 9 100       1073 die(qq{Error: Package: $PACKAGE: Method: read, file: "$file" is empty}) unless length($blob);
117 8         46 $self->{'blob'} = $blob; #store for blob method
118 8         774 my @lines = split(/[\n\r]+/, $blob);
119 8         84 my $loop = 0;
120 8         44 while (1) {
121 34         67 my $line = shift @lines;
122 34         145 $line =~ s/\A\s*//; #ltrim
123 34         173 $line =~ s/\s*\Z//; #rtrim
124 34 100       91 if ($line) {
125 30         51 $loop++;
126 30         128 my ($key, $value) = split /\s+/, $line, 2; #split with limit returns undef value if empty string
127 30 100       86 $value = '' unless defined $value;
128            
129 30 100 100     114 if ($loop == 1 and $key ne 'NAME') { #First line of file is NAME even if NAME token is surpessed
130 2         7 $self->header(NAME => $line);
131             } else {
132             #printf "Key: $key, Value: $value\n";
133 28 100       106 if (uc($key) eq 'HORIZONTAL') {
    100          
134 6         28 $self->_parse_polarization(\@lines, horizontal => $value);
135             } elsif (uc($key) eq 'VERTICAL') {
136 6         46 $self->_parse_polarization(\@lines, vertical => $value);
137             } else {
138 16         47 $self->header($key => $value);
139             }
140             }
141             }
142 34 100       105 last unless @lines;
143             }
144 8         56 return $self;
145              
146             sub _parse_polarization {
147 12     12   29 my $self = shift;
148 12         23 my $lines = shift;
149 12         23 my $method = shift;
150 12         21 my $value = shift; #string
151 12 100       61 if ($value =~ m/([0-9]+)/) { #support bad data like missing 360 or "360 0"
152 10         42 $value = $1 + 0; #convert string to number
153             } else {
154 2         4 $value = 360; #default
155             }
156 12         56 my @data = map {s/\s+\Z//; s/\A\s+//; [split /\s+/, $_, 2]} splice @$lines, 0, $value;
  1808         3824  
  1808         3139  
  1808         5994  
157 12 50       143 die(sprintf('Error: %s records with %s records returned %s records', uc($method), $value, scalar(@data))) unless scalar(@data) == $value;
158 12         59 $self->$method(\@data);
159             }
160             }
161              
162             =head2 read_fromZipMember
163              
164             Reads an antenna pattern file from a zipped archive and parses the data into the object data structure.
165              
166             $antenna->read_fromZipMember($zip_filename, $member_filename);
167              
168             =cut
169              
170             sub read_fromZipMember {
171 1     1 1 600 my $self = shift;
172 1 50       5 my $zip_filename = shift or die("Error: zip filename required");
173 1 50       11 my $member_filename = shift or die("Error: zip member name requried");
174              
175 1         829 require Archive::Zip;
176 1         64560 require Archive::Zip::MemberRead;
177              
178 1         1433 my $zip_archive = Archive::Zip->new;
179 1 50       57 unless ( $zip_archive->read("$zip_filename") == Archive::Zip::AZ_OK() ) {die "Error: $zip_filename read error"};
  0         0  
180              
181 1         3177 my $blob = '';
182 1         8 my $fh = Archive::Zip::MemberRead->new($zip_archive, "$member_filename");
183 1         1091 while (defined(my $line = $fh->getline)) {$blob .= "$line$/"}; #getline chomps but preserve_line_ending does not work for foreign line endings
  6         522  
184 1         34 $fh->close;
185              
186 1         29 return $self->read(\$blob);
187             }
188              
189             =head2 blob
190              
191             Returns the data blob that was read by the read($file), read($scalar_ref), or read_fromZipMember($,$) methods.
192              
193             =cut
194              
195             sub blob {
196 1     1 1 905 my $self = shift;
197 1         5 return $self->{'blob'};
198             }
199              
200             =head2 write
201              
202             Writes the object's data to an antenna pattern file and returns a Path::Class file object of the written file.
203              
204             my $file = $antenna->write($filename); #isa Path::Class::file
205             my $tempfile = $antenna->write; #isa Path::Class::file in temp directory
206             $antenna->write(\$scalar); #returns undef with data writen to the variable
207              
208             =cut
209              
210             sub write {
211 2     2 1 526 my $self = shift;
212 2         5 my $filename = shift;
213              
214             #Open file handle
215 2         9 my $fh;
216             my $file;
217 2 50       15 if (ref($filename) eq 'SCALAR') {
    0          
218 2         7 $file = undef;
219 2     2   97 open $fh, '>', $filename;
  2         20  
  2         5  
  2         15  
220             } elsif (length $filename) {
221 0         0 $file = Path::Class::file($filename);
222 0 0       0 $fh = $file->open('w') or die(qq{Error: Cannot open "$filename" for writing});
223             } else {
224 0         0 require File::Temp;
225 0         0 my $suffix = $self->file_extension;
226 0         0 ($fh, $filename) = File::Temp::tempfile('antenna_pattern_XXXXXXXX', TMPDIR => 1, SUFFIX => $suffix);
227 0         0 $file = Path::Class::file($filename);
228             }
229              
230             #Print to file handle
231              
232             sub _print_fh_key_value {
233 736     736   1034 my $fh = shift;
234 736         974 my $key = shift;
235 736         1025 my $value = shift;
236 736         2017 print $fh "$key $value\n";
237             }
238              
239 2         2047 my $header = $self->header; #isa Tie::IxHash ordered hash
240              
241             ##Print NAME as first line
242 2         19 _print_fh_key_value($fh, 'NAME', $header->{'NAME'});
243              
244             ##Print rest of the headers
245 2         17 foreach my $key (keys %$header) {
246 10 100       102 next if $key eq 'NAME'; #written above
247 8         26 my $value = $header->{$key};
248 8 50       74 _print_fh_key_value($fh, $key, $value) if defined $value;
249             }
250              
251             sub _print_fh_key_array {
252 4     4   9 my $fh = shift;
253 4         8 my $key = shift;
254 4         7 my $array = shift;
255 4 50       13 if (@$array) {
256 4         40 _print_fh_key_value($fh, $key, scalar(@$array));
257 4         25 foreach my $row (@$array) {
258 722         1197 my $key = $row->[0];
259 722         1016 my $value = $row->[1];
260 722         1176 _print_fh_key_value($fh, $key, $value);
261             }
262             }
263             }
264              
265             ##Print antenna pattern angle and loss values
266 2         8 foreach my $method (qw{horizontal vertical}) {
267 4         20 my $array = $self->$method;
268 4 50       13 next unless $array;
269 4         17 my $key = uc($method);
270 4         11 _print_fh_key_array($fh, $key, $array);
271             }
272              
273             #Close file handle and Return file object
274 2         9 close $fh;
275 2         12 return $file;
276             }
277              
278             =head2 file_extension
279              
280             Sets and returns the file extension to use for write method when called without any parameters.
281            
282             my $suffix = $antenna->file_extension('.prn');
283              
284             Default: .msi
285              
286             Alternatives: .pla, .pln, .ptn, .txt, .ant
287              
288             =cut
289              
290             sub file_extension {
291 0     0 1 0 my $self = shift;
292 0 0       0 $self->{'file_extension'} = shift if @_;
293 0 0       0 $self->{'file_extension'} = '.msi' unless defined($self->{'write_file_extension'});
294 0         0 return $self->{'file_extension'};
295             }
296              
297             =head2 media_type
298              
299             Returns the Media Type (formerly known as MIME Type) for use in Internet applications.
300              
301             Default: application/vnd.planet-antenna-pattern
302              
303             =cut
304              
305 0     0 1 0 sub media_type {'application/vnd.planet-antenna-pattern'};
306              
307             =head1 DATA STRUCTURE METHODS
308              
309             =head2 header
310              
311             Set header values and returns the header data structure which is a hash reference tied to L to preserve header sort order.
312              
313             Set a key/value pair
314              
315             $antenna->header(COMMENT => "My comment"); #upper case keys are common/reserved whereas mixed/lower case keys are vendor extensions
316              
317             Set multiple keys/values with one call
318              
319             $antenna->header(NAME => $myname, MAKE => $mymake);
320              
321             Read arbitrary values
322              
323             my $value = $antenna->header->{$key};
324              
325             Returns ordered list of header keys
326              
327             my @keys = keys %{$antenna->header};
328              
329             Common Header Keys: NAME MAKE FREQUENCY GAIN TILT POLARIZATION COMMENT
330              
331             =cut
332              
333             sub header {
334 220     220 1 959 my $self = shift;
335 220 50       525 die('Error: header method requires key/value pairs') if @_ % 2;
336 220 100       536 unless (defined $self->{'header'}) {
337 17         39 my %data = ();
338 17         118 tie(%data, 'Tie::IxHash');
339 17         383 $self->{'header'} = \%data;
340             }
341 220         529 while (@_) {
342 59         141 my $key = shift;
343 59         100 my $value = shift;
344 59         391 $self->{'header'}->{$key} = $value;
345             }
346 220         2080 return $self->{'header'};
347             }
348              
349             =head2 horizontal
350              
351             Sets and returns the horizontal data structure for angles with relative loss values from the specified gain in the header. The data structure is an array reference of array references [[$angle1, $value1], [$angle2, $value2], ...]
352              
353             Conventions: The industry has standardized on using 360 points from 0 to 359 degrees with non-negative loss values. The angle 0 is the boresight with increasing values continuing clockwise (e.g., top-down view). Typically, plots show horizontal patterns with 0 degrees pointing up (i.e., North). This is standard compass convention.
354              
355             =cut
356              
357             sub horizontal {
358 1103     1103 1 373034 my $self = shift;
359 1103 100       2869 $self->{'horizontal'} = shift if @_;
360 1103         5409 return $self->{'horizontal'};
361             }
362              
363             =head2 vertical
364              
365             Sets and returns the vertical data structure for angles with relative loss values from the specified gain in the header. The data structure is an array reference of array references [[$angle1, $value1], [$angle2, $value2], ...]
366              
367             Conventions: The industry has standardized on using 360 points from 0 to 359 degrees with non-negative loss values. The angle 0 is the boresight with increasing values continuing clockwise (e.g., left-side view). The angle 0 is the boresight pointing towards the horizon with increasing values continuing clockwise where 90 degrees is pointing to the ground and 270 is pointing into the sky. Typically, plots show vertical patterns with 0 degrees pointing right (i.e., East).
368              
369             =cut
370              
371             sub vertical {
372 1103     1103 1 2758 my $self = shift;
373 1103 100       2839 $self->{'vertical'} = shift if @_;
374 1103         5241 return $self->{'vertical'};
375             }
376              
377             =head1 HELPER METHODS
378              
379             Helper methods are wrappers around the header data structure to aid in usability.
380              
381             =head2 name
382              
383             Sets and returns the name of the antenna in the header structure
384              
385             my $name = $antenna->name;
386             $antenna->name("My Antenna Name");
387              
388             Assumed: Less than about 40 ASCII characters
389              
390             =cut
391              
392             sub name {
393 12     12 1 641 my $self = shift;
394 12 100       51 $self->header(NAME => shift) if @_;
395 12         40 return $self->header->{'NAME'};
396             }
397              
398             =head2 make
399              
400             Sets and returns the name of the manufacturer in the header structure
401              
402             my $make = $antenna->make;
403             $antenna->make("My Antenna Manufacturer");
404              
405             Assumed: Less than about 40 ASCII characters
406              
407             =cut
408              
409             sub make {
410 3     3 1 1789 my $self = shift;
411 3 50       13 $self->header(MAKE => shift) if @_;
412 3         7 return $self->header->{'MAKE'};
413             }
414              
415             =head2 frequency
416              
417             Sets and returns the frequency string as displayed in header structure
418              
419             my $frequency = $antenna->frequency;
420             $antenna->frequency("2450"); #correct format in MHz
421             $antenna->frequency("2450 MHz"); #acceptable format
422             $antenna->frequency("2.45 GHz"); #common format but technically not to spec
423             $antenna->frequency("2450-2550"); #common range format but technically not to spec
424             $antenna->frequency("2.45-2.55 GHz"); #common range format but technically not to spec
425              
426             =cut
427              
428             sub frequency {
429 53     53 1 3460 my $self = shift;
430 53 100       157 $self->header(FREQUENCY => shift) if @_;
431 53         118 return $self->header->{'FREQUENCY'};
432             }
433              
434             =head2 frequency_mhz, frequency_ghz, frequency_mhz_lower, frequency_mhz_upper, frequency_ghz_lower, frequency_ghz_upper
435              
436             Attempts to read and parse the string header value and return the frequency as a number in the requested unit of measure.
437              
438             =cut
439              
440             #supported formats
441             #123.1 => assumed MHz
442             #123.1 MHz
443             #123.1 GHz
444             #123.1 kHz
445             #123.1-124.1 => assumed MHz
446             #123.1-124.1 MHz
447             #123.1-124.1 GHz
448             #123.1-124.1 kHz
449             #123x124
450             #123x124 MHz
451             #123x124 GHz
452             #123x124 kHz
453              
454             sub frequency_mhz {
455 35     35 1 7078 my $self = shift;
456 35         85 my $string = $self->frequency;
457 35         277 my $number = undef; #return undef if cannot parse
458              
459 35 50       86 if (defined($string)) {
460              
461 35         58 my $upper = undef;
462 35         53 my $lower = undef;
463              
464 35         61 my $scale = 1; #Default: MHz
465 35 100       200 if ($string =~ m/GHz/i) {
    100          
    100          
466 14         25 $scale = 1e3;
467             } elsif ($string =~ m/kHz/i) {
468 5         12 $scale = 1e-3;
469             } elsif ($string =~ m/MHz/i) {
470 2         4 $scale = 1;
471             }
472              
473 35 100       332 if (Scalar::Util::looks_like_number($string)) { #entire string looks like a number
    100          
    50          
474 11         33 $number = $scale * $string;
475 11         23 $lower = $number;
476 11         16 $upper = $number;
477             } elsif ($string =~ m/([0-9]*\.?[0-9]+)[^0-9.]+([0-9]*\.?[0-9]+)/) { #two non-negative numbers with any separator
478 18         58 $lower = $scale * $1;
479 18         34 $upper = $scale * $2;
480 18         40 $number = ($lower + $upper) / 2;
481             } elsif ($string =~ m/([0-9]*\.?[0-9]+)/) { #one non-negative number
482 6         22 $number = $scale * $1;
483 6         12 $lower = $number;
484 6         9 $upper = $number;
485             }
486 35         62 $self->{'frequency_mhz'} = $number;
487 35         56 $self->{'frequency_mhz_lower'} = $lower;
488 35         67 $self->{'frequency_mhz_upper'} = $upper;
489              
490             }
491 35         103 return $number;
492             }
493              
494             sub frequency_ghz {
495 9     9 1 21 my $self = shift;
496 9         28 my $mhz = $self->frequency_mhz;
497 9 50       62 return $mhz ? $mhz/1000 : undef;
498             }
499              
500             sub frequency_mhz_lower {
501 7     7 1 16 my $self = shift;
502 7         19 $self->frequency_mhz; #initialize
503 7         27 return $self->{'frequency_mhz_lower'};
504             }
505              
506             sub frequency_mhz_upper {
507 7     7 1 15 my $self = shift;
508 7         20 $self->frequency_mhz; #initialize
509 7         29 return $self->{'frequency_mhz_upper'};
510             }
511              
512             sub frequency_ghz_lower {
513 2     2 1 5 my $self = shift;
514 2         5 my $mhz = $self->frequency_mhz_lower;
515 2 50       15 return $mhz ? $mhz/1000 : undef;
516             }
517              
518             sub frequency_ghz_upper {
519 2     2 1 5 my $self = shift;
520 2         7 my $mhz = $self->frequency_mhz_upper;
521 2 50       12 return $mhz ? $mhz/1000 : undef;
522             }
523              
524             =head2 gain
525              
526             Sets and returns the antenna gain string as displayed in file (dBd is the default unit of measure)
527              
528             my $gain = $antenna->gain;
529             $antenna->gain("9.1"); #correct format in dBd
530             $antenna->gain("9.1 dBd"); #correct format in dBd
531             $antenna->gain("9.1 dBi"); #correct format in dBi
532             $antenna->gain("(dBi) 9.1"); #supported format
533              
534             =cut
535              
536             sub gain {
537 74     74 1 10880 my $self = shift;
538 74 100       235 $self->header(GAIN => shift) if @_;
539 74         166 return $self->header->{'GAIN'};
540             }
541              
542             =head2 gain_dbd, gain_dbi
543              
544             Attempts to read and parse the string header value and return the gain as a number in the requested unit of measure.
545              
546             =cut
547              
548             sub gain_dbd {
549 48     48 1 12798 my $self = shift;
550 48         112 my $string = $self->gain;
551 48         368 my $number = undef;
552 48 50       111 if (defined($string)) {
553              
554 48 100       360 if (Scalar::Util::looks_like_number($string)) { #entire string looks like a number
    50          
555 12         65 $number = $string + 0; #default: dBd
556             } elsif ($string =~ m/([+-]?[0-9]*\.?[0-9]+)/) { #extract number
557 36         87 my $match = $1;
558 36 100       201 $number = $string =~ m/dBi/i ? dbd_dbi($match) : $match + 0;
559             }
560              
561             }
562 48         272 return $number;
563             }
564              
565             sub gain_dbi {
566 24     24 1 52 my $self = shift;
567 24         51 my $dbd = $self->gain_dbd;
568 24 50       101 return defined($dbd) ? dbi_dbd($dbd) : undef;
569             }
570              
571             =head2 electrical_tilt
572              
573             Antenna electrical_tilt string as displayed in file.
574              
575             my $electrical_tilt = $antenna->electrical_tilt;
576             $antenna->electrical_tilt("MECHINICAL");
577              
578             =cut
579              
580             sub electrical_tilt {
581 3     3 1 1094 my $self = shift;
582 3 100       14 $self->header(ELECTRICAL_TILT => shift) if @_;
583 3         9 return $self->header->{'ELECTRICAL_TILT'};
584             }
585              
586             =head2 comment
587              
588             Antenna comment string as displayed in file.
589              
590             my $comment = $antenna->comment;
591             $antenna->comment("My Comment");
592              
593             =cut
594              
595             sub comment {
596 4     4 1 2101 my $self = shift;
597 4 100       22 $self->header(COMMENT => shift) if @_;
598 4         13 return $self->header->{'COMMENT'};
599             }
600              
601             =head1 SEE ALSO
602              
603             Format Definition: L
604              
605             Antenna Pattern File Library L
606              
607             Format Definition from RCC: L
608              
609             =head1 AUTHOR
610              
611             Michael R. Davis, MRDVT
612              
613             =head1 COPYRIGHT AND LICENSE
614              
615             MIT License
616              
617             Copyright (c) 2022 Michael R. Davis
618              
619             =cut
620              
621             1;