zeroclickinfo-goodies/lib/DDG/Goodie/Conversions.pm

196 lines
6.8 KiB
Perl
Executable File

package DDG::Goodie::Conversions;
# ABSTRACT: convert between various units of measurement
use strict;
use DDG::Goodie;
with 'DDG::GoodieRole::NumberStyler';
use Math::Round qw/nearest/;
use bignum;
use Convert::Pluggable;
use utf8;
name 'Conversions';
description 'convert between various units of measurement';
category 'calculations';
topics 'computing', 'math';
primary_example_queries 'convert 5 oz to grams';
secondary_example_queries '5 ounces to g', '0.5 nautical miles in km';
code_url 'https://github.com/duckduckgo/zeroclickinfo-goodies/blob/master/lib/DDG/Goodie/Conversions.pm';
attribution github => 'https://github.com/elohmrow',
github => ['https://github.com/mintsoft', 'Rob Emery'],
email => 'bradley@pvnp.us';
zci answer_type => 'conversions';
zci is_cached => 1;
# build the keys:
# unit types available for conversion
my $c = new Convert::Pluggable();
my @types = @{$c->get_units()};
my @units = ();
foreach my $type (@types) {
push(@units, $type->{'unit'});
push(@units, @{$type->{'aliases'}});
}
# build triggers based on available conversion units:
triggers any => @units;
# match longest possible key (some keys are sub-keys of other keys):
my $keys = join '|', reverse sort { length($a) <=> length($b) } @units;
my $question_prefix = qr/(?<prefix>convert|what (?:is|are|does)|how (?:much|many|long) (?:is|are)?|(?:number of))?/;
# guards and matches regex
my $factor_re = join('|', ('a', 'an', number_style_regex()));
my $guard = qr/^(?<question>$question_prefix)\s?(?<left_num>$factor_re*)\s?(?<left_unit>$keys)\s(?<connecting_word>in|to|into|(?:in to)|from)?\s?(?<right_num>$factor_re*)\s?(?:of\s)?(?<right_unit>$keys)[\?]?$/i;
# exceptions for pluralized forms:
my %plural_exceptions = (
'stone' => 'stone',
'foot' => 'feet',
'inch' => 'inches',
'pounds per square inch' => 'pounds per square inch',
'ton of TNT' => 'tons of TNT',
'metric horsepower' => 'metric horsepower',
'horsepower' => 'horsepower',
'electrical horsepower' => 'electrical horsepower',
'pounds force' => 'pounds force',
);
my %singular_exceptions = reverse %plural_exceptions;
my %temperature_aliases = (
'celsius' => '°C',
'fahrenheit' => '°F',
'rankine' => '°R',
'kelvin' => 'K',
);
handle query_lc => sub {
# hack around issues with feet and inches for now
$_ =~ s/"/inches/;
$_ =~ s/'/feet/;
# hack support for "degrees" prefix on temperatures
$_ =~ s/ degrees (celsius|fahrenheit)/ $1/;
# hack - convert "oz" to "fl oz" if "ml" contained in query
s/(oz|ounces)/fl oz/ if(/ml/ && not /fl oz/);
# guard the query from spurious matches
return unless $_ =~ /$guard/;
my @matches = ($+{'left_unit'}, $+{'right_unit'});
return if ("" ne $+{'left_num'} && "" ne $+{'right_num'});
my $factor = $+{'left_num'};
# if the query is in the format <unit> in <num> <unit> we need to flip
# also if it's like "how many cm in metre"; the "1" is implicitly metre so also flip
# But if the second unit is plural, assume we want the the implicit one on the first
# It's always ambiguous when they are both countless and plural, so shouldn't be too bad.
if (
"" ne $+{'right_num'}
|| ( "" eq $+{'left_num'}
&& "" eq $+{'right_num'}
&& $+{'question'} !~ qr/convert/i
&& !looks_plural($+{'right_unit'})))
{
$factor = $+{'right_num'};
@matches = ($matches[1], $matches[0]);
}
$factor = 1 if ($factor =~ qr/^(a[n]?)?$/);
# fix precision and rounding:
my $precision = 3;
my $nearest = '.' . ('0' x ($precision-1)) . '1';
my $styler = number_style_for($factor);
return unless $styler;
my $result = $c->convert( {
'factor' => $styler->for_computation($factor),
'from_unit' => $matches[0],
'to_unit' => $matches[1],
'precision' => $precision,
} );
return if !$result->{'result'};
my $f_result;
# if $result = 1.00000 .. 000n, where n <> 0 then $result != 1 and throws off pluralization, so:
$result->{'result'} = nearest($nearest, $result->{'result'});
if ($result->{'result'} == 0 || length($result->{'result'}) > 2*$precision + 1) {
if ($result->{'result'} == 0) {
# rounding error
$result = $c->convert( {
'factor' => $styler->for_computation($factor),
'from_unit' => $matches[0],
'to_unit' => $matches[1],
'precision' => $precision,
} );
}
# We only display it in exponent form if it's above a certain number.
# We also want to display numbers from 0 to 1 in exponent form.
if($result->{'result'} > 1_000_000 || $result->{'result'} < 1) {
$f_result = (sprintf "%.${precision}g", $result->{'result'});
} else {
$f_result = (sprintf "%.${precision}f", $result->{'result'});
}
}
# handle pluralisation of units
# however temperature is never plural and does require "degrees" to be prepended
if ($result->{'type_1'} ne 'temperature') {
$result->{'from_unit'} = set_unit_pluralisation($result->{'from_unit'}, $factor);
$result->{'to_unit'} = set_unit_pluralisation($result->{'to_unit'}, $result->{'result'});
} else {
$result->{'from_unit'} = $temperature_aliases{$result->{'from_unit'}};
$result->{'to_unit'} = $temperature_aliases{$result->{'to_unit'}};
}
$result->{'result'} = defined($f_result) ? $f_result : sprintf("%.${precision}f", $result->{'result'});
$result->{'result'} =~ s/\.0{$precision}$//;
$result->{'result'} = $styler->for_display($result->{'result'});
$factor = $styler->for_display($factor);
return $factor . " $result->{'from_unit'} = $result->{'result'} $result->{'to_unit'}",
structured_answer => {
input => [$styler->with_html($factor) . ' ' . $result->{'from_unit'}],
operation => 'Convert',
result => $styler->with_html($result->{'result'}) . ' ' . $result->{'to_unit'},
};
};
sub set_unit_pluralisation {
my ($unit, $count) = @_;
my $proper_unit = $unit; # By default, we'll leave it unchanged.
my $already_plural = looks_plural($unit);
if ($count == 1 && $already_plural) {
$proper_unit = $singular_exceptions{$unit} || substr($unit, 0, -1);
} elsif ($count != 1 && !$already_plural) {
$proper_unit = $plural_exceptions{$unit} || $unit . 's';
}
return $proper_unit;
}
sub looks_plural {
my $unit = shift;
my @unit_letters = split //, $unit;
return exists $singular_exceptions{$unit} || $unit_letters[-1] eq 's';
}
1;