Food-Ratio/lib/Food/Ratio.pm
# -*- Perl -*-
#
# Ultima Ratio Coquorum
package Food::Ratio 0.04;
use strict;
use warnings;
use Object::Pad 0.802;
class Food::Ratio :strict(params);
use Carp 'croak';
use List::UtilsBy 'nsort_by';
use Scalar::Util 'looks_like_number';
use constant {
MASS => 0, # array index for $things, $groups, $total
NAME => 1,
GROUPS => 2,
ORDER => 3,
RATIO => 4,
};
field $things :reader; # individual ingredients (aref of aref)
field $groups :reader; # groups of ingredients (href of aref)
field $total :reader; # (aref)
field $key :reader; # ratio key ingredient, group, or total
field $index_group = 0; # to keep the output in input addition order
ADJUST {
$groups = {};
$things = [];
$total = [];
}
method add ( $mass, $name, @rest ) {
croak "mass must be positive"
unless defined $mass
and looks_like_number($mass)
and $mass > 0;
croak "things must be something"
unless defined $name and length $name;
for my $grname (@rest) {
croak "groups must be something"
unless defined $grname and length $grname;
}
# hopefully after here nothing blows up that might leave the object
# in an inconsistent state
my @meta;
@meta[ MASS, NAME, GROUPS, RATIO ] =
( $mass, $name, @rest ? \@rest : [], 0 );
push @$things, \@meta;
for my $grname (@rest) {
my $gmeta = $groups->{$grname} //= [];
$gmeta->@[ NAME, RATIO ] = ( $grname, 0 );
$gmeta->[MASS] += $mass;
$gmeta->[ORDER] = $index_group++ unless defined $gmeta->[ORDER];
}
$total->[MASS] += $mass;
return $self;
}
method details() {
croak "ratio has not been called" unless defined $key;
my %details;
for my $ref (@$things) {
push $details{ingredients}->@*,
{ groups => [ $ref->[GROUPS]->@* ],
mass => $ref->[MASS],
name => $ref->[NAME],
ratio => $ref->[RATIO],
};
}
for my $ref ( nsort_by { $_->[ORDER] } values %$groups ) {
push $details{groups}->@*,
{ mass => $ref->[MASS],
name => $ref->[NAME],
order => $ref->[ORDER],
ratio => $ref->[RATIO],
};
}
$details{total} = {
mass => $total->[MASS],
ratio => $total->[RATIO],
};
return \%details;
}
# the ratio could be based on the total amount, or for cooking there is
# more likely some key ingredient--flour--or a group of ingredients,
# such as a variety of flours that together form the total for the ratio
method ratio (%param) {
my $amount;
if ( exists $param{id} ) {
croak "id must be something"
unless defined $param{id} and length $param{id};
# NOTE only the first match is used if there are duplicates in
# the ingredients list
my $okay = 0;
for my $ref (@$things) {
if ( $ref->[NAME] eq $param{id} ) {
( $key, $amount ) = ( $ref, $ref->[MASS] );
$okay = 1;
last;
}
}
croak "no such id '$param{id}'" unless $okay;
} elsif ( exists $param{group} ) {
croak "group must be something"
unless defined $param{group} and length $param{group};
croak "no such group '$param{group}'"
unless exists $groups->{ $param{group} };
$key = $groups->{ $param{group} };
$amount = $key->[MASS];
} else {
$key = $total;
$amount = $total->[MASS];
}
for my $ref ( @$things, values %$groups, $total ) {
$ref->[RATIO] = $ref->[MASS] * 100 / $amount;
}
return $self;
}
method string() {
croak "ratio has not been called" unless defined $key;
my $s = '';
for my $ref (@$things) {
$s .= join( "\t",
sprintf( "%.4g\t%.4g%%", $ref->@[ MASS, RATIO ] ),
$ref->[NAME], $ref->[GROUPS]->@* )
. "\n";
}
if ( keys %$groups ) {
$s .= "--\n";
for my $ref ( nsort_by { $_->[ORDER] } values %$groups ) {
$s .= join( "\t",
sprintf( "%.4g\t%.4g%%", $ref->@[ MASS, RATIO ] ),
$ref->[NAME] )
. "\n";
}
}
$s .= "--\n";
$s .= join "\t", $total->[MASS], sprintf( '%.4g%%', $total->[RATIO] ),
"*total\n";
return $s;
}
method weigh ( $mass, %param ) {
croak "ratio has not been called" unless defined $key;
croak "mass must be positive"
unless defined $mass
and looks_like_number($mass)
and $mass > 0;
my $ratio;
if ( exists $param{id} ) {
croak "id must be something"
unless defined $param{id} and length $param{id};
# NOTE only the first match is used if there are duplicates in
# the ingredients list
my $okay = 0;
for my $ref (@$things) {
if ( $ref->[NAME] eq $param{id} ) {
$ratio = $mass / $ref->[MASS];
$okay = 1;
last;
}
}
croak "no such id '$param{id}'" unless $okay;
} elsif ( exists $param{group} ) {
croak "group must be something"
unless defined $param{group} and length $param{group};
croak "no such group '$param{group}'"
unless exists $groups->{ $param{group} };
$ratio = $mass / $groups->{ $param{group} }->[MASS];
} else {
$ratio = $mass / $total->[MASS];
}
for my $ref ( @$things, values %$groups, $total ) {
$ref->[MASS] *= $ratio;
}
return $self;
}
1;
__END__
=head1 NAME
Food::Ratio - calculate ingredient ratios
=head1 SYNOPSIS
use Food::Ratio;
my $fr = Food::Ratio->new;
# add some ingredients of various amounts
$fr->add( 500, 'flour' );
$fr->add( 360, 'water' ); # at 90F to 95F
$fr->add( 11.5, 'salt' );
$fr->add( 2, 'yeast' );
# make flour the basis for the ratio
$fr->ratio( id => 'flour' );
# emit to a string form
print $fr->string;
# 500 100.00% flour
# 360 72.00% water
# 11.5 2.30% salt
# 2 0.40% yeast
# --
# 873.5 174.7% *total
# how much of the other are required given 9 grams of yeast?
$fr->weigh( 9, id => 'yeast' );
# emit to a data structure that could be converted to JSON
use Data::Dumper;
print Dumper $fr->details;
The observant may notice that the water and salt are a bit off from the
normal bread recipe. This is easier to see in ratio form.
=head1 DESCRIPTION
This module calculates ratios of ingredients, with the ability to select
what ingredient or group of ingredients the ratio is based on. With a
ratio, the masses of the ingredients can then be adjusted with B<weigh>,
which can be of any particular ingredient or group of ingredients.
=head1 METHODS
=over 4
=item B<add> I<mass> I<name> [ I<groups> .. ]
Adds the given amount of the given ingredient. The I<mass> probably
should be consistent across the ingredients; grams might be a good
choice. The I<mass> could instead be volumes, provided that the units
are consistent.
The optional I<groups> indicate what groups the ingredient belongs to,
for example
$fr->add( 160, 'cornmeal', 'gflour', 'dry' );
$fr->add( 150, 'flour', 'gflour', 'dry' );
$fr->add( 3.5, 'salt', 'dry' );
...
when there are multiple types of flour, and one wants to base the ratio
on the I<group> gflour, not the ingredient flour. The group could be
named "flour"; it is named "gflour" here for clarity of documentation.
=item B<details>
Returns a hash reference of the internal details. Must be called after
B<ratio>, and ideally after a few B<add> calls. L<Data::Dumper> will
show the form of the resulting structure.
=item B<new>
Constructor.
=item B<ratio> I<param>
Calculates the ratios for the ingredients and any ingredient groups.
Must be called after B<add> has been used to add things to the object.
There are three possible ways to select the key ingredient for the basis
of the ratio:
$fr->ratio; # $total is used
$fr->ratio( id => 'flour' ); # use the first 'flour' ingredient
$fr->ratio( group => 'gflour' ); # the group 'gflour' is used
The I<id> argument takes priority over I<group> which in turn takes
priority over the ratio being based on the total mass of the ingredients
involved. Ingredients may be duplicated in the list; in this case, the
first matching ingredient is used, in the order the ingredients were
added with the B<add> method.
B<ratio> must be called before using various output methods.
A new ratio can be calculated by calling B<ratio> again; the ratios
stick around in the object.
=item B<string>
Returns a string form of the ingredients and groups. The form depends on
whether B<ratio> has been called.
B<ratio> (and probably some B<add> calls) must be called before calling
this method.
=item B<weigh> I<mass> I<param>
Updates the mass for all the ingredients and ingredient groups based
on the new I<mass> that optionally may be associated by I<id> or
I<group> to a particular ingredient or ingredient group, as also
supported by B<ratio>.
A typical use for this would be to adjust a recipe based on the weight
of the egg you have, given that some fraction of flour is easier to
weigh instead of breaking out the fractional eggs.
$fr->weigh( 53, id => 'egg' );
This updates the mass of all the ingredients, etc, in the object.
=back
=head1 ACCESSORS
The data returned by these probably should not be fiddled with. On the
other hand, the internal details are unlikely to change. I assume you
know your way around L<Data::Dumper>. The B<details> method is probably
a better way to get at this data?
=over 4
=item B<amount>
After B<ratio> has been called contains the amount of the B<total>
or key ingredient or ingredient group, depending on how B<ratio>
was called. Is C<0> if B<ratio> has not been called.
=item B<groups>
Hash reference of any optional ingredient groups.
=item B<key>
After B<ratio> has been called, maybe contains a reference to the key
ingredient or ingredient group.
=item B<things>
Array reference of the ingredients, if any.
=item B<total>
The total mass of every ingredient added.
=back
=head1 BUGS
Bugs are commonly present in flour and other ingredients.
=head1 SEE ALSO
Forkish, Ken. Flour water salt yeast: The fundamentals of artisan bread and pizza. Random House Digital, Inc., 2012.
Ruhlman, Michael. Ratio: The simple codes behind the craft of everyday cooking. Simon and Schuster, 2009.
L<https://github.com/hendricius/the-sourdough-framework>
=head1 COPYRIGHT AND LICENSE
Copyright 2022 Jeremy Mates
This program is distributed under the (Revised) BSD License:
L<https://opensource.org/licenses/BSD-3-Clause>
=cut