package DoseMe::Algorithms::PlasmaConcentrationPlot;

use v5.36;

use strict;
use warnings;
use utf8;

use version; our $VERSION = qv('0.3.0');

# ABSTRACT: Methods and algorithms that process plasma concentration plots

use Exporter;
use base 'Exporter';
use Carp;
use Math::Trig;
use List::MoreUtils qw(pairwise);
use List::Util qw(any first max);

our @ISA = qw(Exporter);
our @EXPORT = qw(adaptive_concentration_plot goldensectionpeak trim_graph PHI HALF_PI);

our $debug_level = 0;

# pre-compute constants

use constant PHI => (1.0 + sqrt(5))/2.0;
use constant HALF_PI => pi/2;
use constant EPSILON => 1e-9;

###


### -- TestExempt
sub adaptive_concentration_plot {   # marked as test exempt as it is currently not used in production
  my ($dm, $opts) = @_;
  croak "Must provide at least a model reference." unless (defined $dm);

  $opts //= {};

  # get dose time and tinf data from model
  $opts->{t_start} //= 0;
  my @proper_doses = grep {$_->{rate} > EPSILON} @{$dm->doses};    # drop zero doses
  $opts->{t_end} //= @proper_doses ? ( $proper_doses[-1]->{time} + 24 ) : 24;

  my @doses_in_time_range =                                         # find doses that fall into time range
      grep {( $_->{time} > ( $opts->{t_start} - EPSILON ) ) and ( $_->{time} < ( $opts->{t_end} + EPSILON ) )} @proper_doses;
  my @dosetimes = map {$_->{time}} @doses_in_time_range;            # get dose times from model
  my @tinfs     = map {$_->{length}} @doses_in_time_range;          # get tinfs from model
  my @end_of_adm_times = pairwise {$a + $b} @dosetimes, @tinfs;     # compute end of adm times

  my $fn_t_conc = sub { $dm->_get_serum_level(@_) };

  #do_debug_model($dm);

  return _adaptive_concentration_plot($fn_t_conc,
                                      \@dosetimes,
                                      \@end_of_adm_times,
                                      $opts);
}


sub _adaptive_concentration_plot {
  my ($fn_t_conc, $dosetimes, $end_of_adm_times, $opts) = @_;

  my %p;      # hash that holds parameters
  my %s;      # hash that holds current algo state

  # default params
  $p{t_start}  = $opts->{t_start};
  $p{t_end}    = $opts->{t_end};
  $p{min_step} = $opts->{min_step}     // 1 / 60;     # 1 minute
  $p{max_step} = $opts->{max_step}     // 6;          # 6 hours
  $p{gamma}    = $opts->{gamma}        // 0.8;        #
  $p{relax}    = $opts->{relax}        // sqrt(2);    # halve rate every two steps at most
  $p{aspect}   = $opts->{aspect_ratio} // 2;          # X:Y is 2:1
  $p{trim}                   = $opts->{trim};                      # do not trim collinear points
  $p{threshold}              = $opts->{trim_threshold} // 1e-3;         #
  $p{noseek_ndose_threshold} = $opts->{noseek_ndose_threshold};    # if there are more than thes many doses, do not optimise peak for all except last dose
  $p{autoscale}              = $opts->{auto_scale_rates};          # off by default
  $p{gstype}                 = $opts->{seekpeak_type} // 'domain';    # precision on domain by default
  $p{gsprecision}            = $opts->{seekpeak_precision} // 1/3600; # one second

  # my $start_t = clock_gettime();

  # buffers for computed peaks and troughs
  my @peaks;
  my @troughs;

  #
  $p{final_dose_time} = max( map {$_} @{$dosetimes} );    # time of final dose to be plotted
  $p{n_doses} = scalar @{$dosetimes};

  # state vars
  $s{prev_sign} = 0;    # sign of first derivative at last iteration
  $s{norm}      = 0;    # current estimated norm to Cp curve
  $s{last_rise_index} = 0;  # buffer index of last segment with positive slope

  my $time_length = $p{t_end} - $p{t_start};

  # decide if we are going to optimise peaks for all doses or just last one
  if((defined $p{noseek_ndose_threshold}) and $p{n_doses} > $p{noseek_ndose_threshold}) {
    $p{seekpeak} = 0;
  } else {
    $p{seekpeak}  = $opts->{seekpeak} // 1;
  }

  # scale min and max rate according to length of time plotted and number of peaks
  if ( defined $p{autoscale} ) {
    $p{max_step} = 6 * int( exp( 0.023105 * $time_length / 24 ) );    # add 6 hours for every month
    $p{min_step} = ( 0.67416 + 0.32584 * $p{n_doses} ) / 60;             # 1 min for 1 dose, 30 min for 90 doses
    $p{min_step} = 0.5 if ( $p{min_step} > 0.5 );                     # cap at half an hour
  }

  # initialise time and concentration buffers
  my @t = ($p{t_start});
  my @bfr = map { { t => $_, c => $fn_t_conc->($_), critical => 0} } @t;
  # and graph array
  my @graph;

  # estimate y extent using max cp at end of administration
  $s{cp_max} = max (map { $fn_t_conc->($_) } @{$end_of_adm_times});
  $s{cp_max} = 0 unless defined $s{cp_max};

  # compute aspect ratio correction factors. we need to consider the graph based on what it will
  # look like when plotted rather than 1:1 ratio of time:cp
  $s{cfx} = 1; $s{cfy} = 1; # assume 1 hour : 1 Cp initial unit ratio
  # function to update correction factors.
  # The length of time is fixed at 1 to keep factors in fp precsision sweetspot, but Cp scale needs updating
  my $update_cf = sub {
        $s{cfy} = 1 / ( $s{cp_max} > 0 ? $s{cp_max} : $time_length ) * ( $time_length * $p{aspect} ); # assume 1:1 if no $s{cp_max}
      };
  $update_cf->();

  # DEBUG: function used to dump buffer for debug
  my $debug_dump_buffer = sub {
      local $Data::Dumper::Indent = 0;
      warn "\n" . Data::Dumper->Dump([\@bfr], [$_[0] // '*bfr']) . "\n\n"
  };
  # $debug_dump_buffer->('*initial_bfr');

  # init crrent step size to min_step
  $s{step} = $p{min_step};

  # function to update critical points that fall within current segment
  my $update_dosetime = sub {
    # do we have a dose that falls within this segment?
    $s{dosetime} = first {($_ >= $bfr[-2]->{t} ) && ($_ < ($bfr[-1]->{t} ))} @{$dosetimes};
    # do we have an end of administration falls within this segment?
    $s{end_of_adm_t} = first {($_ >= $bfr[-2]->{t} ) && ($_ < ($bfr[-1]->{t} ))} @{$end_of_adm_times};
  };

  # do_debug_params($opts, \%p);
  # do_debug_critical_points($dosetimes, $end_of_adm_times, $p{final_dose_time});

  # main loop
  for (my $now = $bfr[-1]->{t} + $s{step}; $now <= $p{t_end}; $now += $s{step}) {
    push @bfr, { t=>$now, c=>$fn_t_conc->($now)};

    my $delta_c = $bfr[-1]->{c} - $bfr[-2]->{c};
    my $delta_t = $bfr[-1]->{t} - $bfr[-2]->{t};

    # estimate angle of norm to concentration curve
    # in graph-proportional coordinates
    # and express it as proportion of pi/2 radian arc
    # essentially converting dc/dt into [-1,1] range
    $s{norm} = _scaled_norm($delta_t, $delta_c, $s{cfx}, $s{cfy});
    # update critical points that fall within current segment
    $update_dosetime->();
    # calculate sign of norm for this step
    $s{sign} = ($s{norm} <=> 0);

    # the Cp graph has some useful features we can use to cheat and use quick and dirty estimates
    # of dc/dt to control sampling rate in a single pass while they get increasingly dirtier towards
    # the shallow end, and still deal with sudden rate change
    #   - we know when doses start, when administration ends, and that Cp will rise and fall

    # TODO: this whole bit of logic is getting ugly and should be re-implemente as an FSM or a deterministic bw petri net
    if((defined $s{end_of_adm_index}) or (defined $s{dosetime}) or (defined $s{end_of_adm_t})) { # we have a critical point in this segment

      if(defined $s{end_of_adm_index}) { # we have end of administration

        if($s{norm} < 0) { # and the curve is falling, then we have a peak in this segment

          if(($p{seekpeak} == 1) or ((defined $p{final_dose_time}) and ($now >= ($p{final_dose_time} - $p{min_step})))) { # seekpeak
            my $lastriseval = $bfr[$s{last_rise_index}];
            if(not defined($s{last_dose_time}) or (abs($lastriseval->{t} - $s{last_dose_time}) > EPSILON)) { # seek peak unless dosetime
              my $h = goldensectionpeak($fn_t_conc,
                                        $bfr[$s{last_rise_index}-1]->{t},
                                        $bfr[-1]->{t},
                                        { type => $p{gstype},
                                          precision => $p{gsprecision},
                                          xvarname => 't',
                                          yvarname => 'c'
                                        });

              if($h->{peak}->{c} > $lastriseval->{c}) { # golden section value is better
                @bfr = @bfr[ 0 .. $s{last_rise_index}-2 ];
                push @bfr, @{$h->{leading}};
                push @bfr, { t=>$h->{peak}->{t}, c=>$h->{peak}->{c}, critical => 1 };
                push @peaks, { t=>$h->{peak}->{t}, c=>$h->{peak}->{c} };
                undef($s{end_of_adm_index});
              } else { # nope, last rising point was better estimate of peak
                $bfr[$s{last_rise_index}]->{critical} = 2;
                push @peaks, { t=>$lastriseval->{t}, c=>$lastriseval->{c} };
                undef($s{end_of_adm_index});
              } # END golden section value is better

            }  # END seek peak unless dosetime

          } else { # not actively seaking peak, just add last point of rising segment as peak
            push @peaks, $bfr[$s{last_rise_index}];
            $bfr[$s{last_rise_index}]->{critical} = 3;
            undef($s{end_of_adm_index});
          } # END seekpeak

          #update y extent estimate and correction factors if needed
          if ( (scalar(@peaks) > 0) and ($peaks[-1]->{c} > $s{cp_max}) ) {
            $s{cp_max} = $peaks[-1]->{c};
            $update_cf->();
          }

          $now = $bfr[-1]->{t}  + $p{min_step};
          $now = $s{last_dose_time} if(defined($s{last_dose_time}) and ($now < $s{last_dose_time} ));
          $s{step} = $p{min_step};

        } elsif ($s{norm} == 0) { # level - highly unlikely but not impossible
          # do nothing and accumulate

        } else { # then we must be still rising after end of administration
          # still rising, accumulate
          $s{last_rise_index} = (scalar @bfr)-1;
          $s{step} = $p{min_step} + ($p{max_step} - $p{min_step}) * ((1 - abs($s{norm}))**$p{gamma});
        } # END curve is falling

      } # END if(defined $s{end_of_adm_index})

      # buffer may have changed so update dosetime and end of adm vars
      $update_dosetime->();

      if ((defined $s{dosetime}) or (defined $s{end_of_adm_t})) { # dose tieme or end of administration?
        # we have either dosetime of end of administration (or both) in this time period

        if(defined $s{dosetime}) { # dose time take precedence
          my $nopeak = defined($s{end_of_adm_index}); # did we reach next dose before peak for last one was found?
          undef($s{end_of_adm_index});
          $s{last_rise_index} = (scalar @bfr)-2;
          $s{end_of_adm_index} = $s{last_rise_index} if (defined $s{end_of_adm_t});
          # make sure dose time is actually included as a critical point in graph
          pop @bfr;
          pop @bfr if ( defined($bfr[-1]->{critical}) and ($bfr[-1]->{critical} == 0) );
          push @bfr, { t=>$s{dosetime}, c=>$fn_t_conc->($s{dosetime}), critical => 4 };
          push @bfr, { t=>$s{dosetime}+$p{min_step}, c=>$fn_t_conc->($s{dosetime}+$p{min_step}) };
          push @troughs, $bfr[-2];
          push @peaks, $troughs[-1] if $nopeak; # if didn't find peak for last dose, then this trough is also a peak
          $now = $s{dosetime} + $p{min_step} * 2;
          $s{step} = $p{min_step};
          $s{last_dose_time} = $s{dosetime};
        } else { # END defined $s{end_of_adm_t}
          $s{last_rise_index} = (scalar @bfr)-2;
          $s{end_of_adm_index} = $s{last_rise_index};
          # make sure end of administration is actually included as a critical point in graph
          pop @bfr; pop @bfr;
          push @bfr, { t=>$s{end_of_adm_t}, c=>$fn_t_conc->($s{end_of_adm_t}), critical => 5 };
          push @bfr, { t=>$s{end_of_adm_t}+$p{min_step}, c=>$fn_t_conc->($s{end_of_adm_t}+$p{min_step}) };
          $now = $s{end_of_adm_t} + $p{min_step} * 2;
          $s{step} = $p{min_step};
          undef($s{last_dose_time});
        } # END dose time

      } # END dose time or end of aministration

    } elsif($s{sign} != $s{prev_sign}) { # no critical point, but first derivative reversed sign
      # TODO: May need to consider direction of reversal for models that continue to drop after administration
      undef($s{end_of_adm_index});
      $s{step} = $p{min_step};

    } else {  # nothing interetsing happening on this iteration
      # base case - adjust sampling rate according to dc/dt
      # using the following tranfer function
      undef($s{end_of_adm_index});
      $s{step} = $p{min_step} + ($p{max_step} - $p{min_step}) * ((1 - abs($s{norm}))**$p{gamma});
    } # end "critical point if"

    # restrict step size relaxation so that there are no sudden drops in sampling rate
    # that will cause failure of primary assumption: lim_{t->0} norm_{t} = norm_{t-1}
    my $step_threshold = ($bfr[-1]->{t} - $bfr[-2]->{t}) * $p{relax};
    if($s{step} > $step_threshold ) {
      $s{step} = $step_threshold;
    }

    # update previous itertion sign. TODO: use buffer to compute this on the fly
    $s{prev_sign} = $s{sign};
  }
  # make sure supplied end of graph point is actually included
  push @bfr, { t=>$p{t_end}, c=>$fn_t_conc->($p{t_end})};

  # my $start_trim = clock_gettime();

  # trim collinear points
  my @trim_bfr;
  if(defined $p{trim}) {
    @trim_bfr = @{trim_graph(\@bfr, {%$opts, sfx=>$s{cfx}, sfy=>$s{cfy}} )}
  } else {
    @trim_bfr = @bfr;
  }
  # warn "trimmed " . ((scalar @bfr) - (scalar @trim_bfr)) . " collinear points.\n";
  # my $end_trim = clock_gettime();

  # dump the buffer into the graph
  while(scalar @trim_bfr > 0) {
    my $point = shift @trim_bfr;
    push @graph, { x=> $point->{t}, y => $point->{c}, critical => $point->{critical} };
  }

  push @troughs, { t=> $p{t_end}, c => $fn_t_conc->($p{t_end})}; # add tail value to troughs as well

  my $retval = {graph => \@graph, peaks => \@peaks, troughs => \@troughs};

  # add debug if requested
  $retval->{p} = \%p if $debug_level > 0;           # add parameters to return value for debug
  $retval->{final_state} = \%s if $debug_level > 1; # add final state to return value for debug

  # warn "done in " . (clock_gettime() - $start_t) . ", " . ($end_trim - $start_trim) . " trimming\n";

  return $retval;
}


# utility function to calculate norm to graph in graph coordinates

sub _scaled_norm {
  my ( $dx, $dy, $sfx, $sfy ) = @_;
  if($sfx and $sfy) {
    $dx = $dx * $sfx;
    $dy = $dy * $sfy;
  }
  return atan2($dy , $dx ) / HALF_PI;
}


### trim collinear points in graph


### -- Unit.Fault:TrimGraph.Params.Plot{Undefined,NotArayref}
### -- Unit.Accept:TrimGraph.Params.{PlotOnly,PlotWithOptions,EmptyPlot}
sub trim_graph  {
  my( $graphref, $opts ) = @_;

  croak "Must provide at least a graph array reference." unless (defined $graphref);
  croak "Graph must be an array reference." unless (ref $graphref eq ref []);

  $opts //= {};
  my $threshold = $opts->{trim_threshold} // 1e-3;
  my $iv = $opts->{iv} // 't';
  my $dv = $opts->{dv} // 'c';
  my $maxstep = $opts->{maxstep};
  my $sfx = $opts->{sfx} // 1;
  my $sfy = $opts->{sfy} // 1;

  warn "#i,ni,nb-1,ti,ti-1,tb-1,ci,ci-1,cb-1,keep,over,reverse,exceedstep,critical\n" if $debug_level > 1;

  my @graph = @{$graphref};
  my @trim_bfr;
  if( @graph) {
    $graph[0]->{n} = 0;
    push @trim_bfr, $graph[0];
    if( $#graph > 0 ) {
      $graph[1]->{n} = _scaled_norm( $graph[1]->{$iv} - $graph[0]->{$iv},
                                      $graph[1]->{$dv} - $graph[0]->{$dv},
                                      $sfx,
                                      $sfy);
      for my $i (2 .. $#graph-1) {
        $graph[$i]->{n} = _scaled_norm( $graph[$i]->{$iv} - $graph[$i-1]->{$iv},
                                        $graph[$i]->{$dv} - $graph[$i-1]->{$dv},
                                        $sfx,
                                        $sfy);
        # curvature over threshold if point at i-1 is dropped
        my $over = (abs($graph[$i]->{n} - $trim_bfr[-1]->{n}) > $threshold);
        # graph reverses at point i-1, so include it
        my $reverse = (($graph[$i]->{n} * $graph[$i-1]->{n}) < 0);
        # time step exceeded at point i, so include point i-1
        my $exceedstep = ($maxstep and (($graph[$i]->{$iv} - $trim_bfr[-1]->{$iv}) > $maxstep));
        # the point at i-1 is a critical point, so should be included
        my $critical = (defined $graph[$i-1]->{critical});

        my $keep = ($over or $reverse or $exceedstep or $critical);

        my $debug = $graph[$i]->{n} . "," . $trim_bfr[-1]->{n} . ",".
            $graph[$i]->{$iv} . ",". $graph[$i-1]->{$iv} . ",". $trim_bfr[-1]->{$iv} . ",".
            $graph[$i]->{$dv} . ",". $graph[$i-1]->{$dv} . ",". $trim_bfr[-1]->{$dv} ."";

        push @trim_bfr, $graph[$i-1] if( $keep );

        warn "$i,$debug,".($keep?1:0).",".($over?1:0).",".($reverse?1:0).",".($exceedstep?1:0).",".($critical?1:0)."\n" if $debug_level > 1;

      }
      push @trim_bfr, $graph[-1]; # always include the last point
    } # if $#graph > 0
  } # if @graph
  return \@trim_bfr;
}


### peak search function

# ┌─────────────────────────────────┐
# │  ____ ____ _    ___  ____ _  _  │
# │  | __ |  | |    |  \ |___ |\ |  │
# │  |__] |__| |___ |__/ |___ | \|  │
# │                                 │
# │ ____ ____ ____ ___ _ ____ _  _  │
# │ [__  |___ |     |  | |  | |\ |  │
# │ ___] |___ |___  |  | |__| | \|  │
# └─────────────────────────────────┘


### -- Unit.Fault:GoldenSectionPeak.Params.Undefined{Fun,Min,Max}
### -- Unit.Accept:GoldenSectionPeak.Params.Valid
sub goldensectionpeak {
  my ( $fn, $min, $max, $opts ) = @_;

  croak "Must provide a function." unless ( defined $fn );
  croak "Must provide at least min and max." unless ( (defined $min) and (defined $max) );

  my $precision = $opts->{precision} // 1e-3;       # one part per thousand
  my $type      = $opts->{type}      // 'domain';
  my $xv        = $opts->{xvarname}  // 'x';
  my $yv        = $opts->{yvarname}  // 'y';

  my $start = $min;
  my $end   = $max;
  my $fstart = $fn->($start);
  my $fend   = $fn->($end);

  my %pairs;

  my $left  = $start;
  my $right = $end;

  my $fl = $fn->($left);
  my $fr = $fn->($right);

  $pairs{$left}  = $fl;
  $pairs{$right} = $fr;

  my $precision_test;
  if ( $type eq 'range' ) {
    $precision_test = sub {(abs( $left - $right ) > $precision)
                             or (abs( $fl - $fr ) > $precision)};
  } else { # domain
    $precision_test = sub { abs( $left - $right ) > $precision };
  }

  while ( $precision_test->() ) {
    if ( $fl > $fr ) {
      $end = $right;
    } else {
      $start = $left;
    }

    $left = $end - ( $end - $start ) / PHI;
    $right = $start + ( $end - $start ) / PHI;

    $fl = $fn->($left);
    $fr = $fn->($right);

    $pairs{$left}  = $fl;
    $pairs{$right} = $fr;
  }

  my ($peak_x, $peak_y);
  if ($fl > $fr) {
    $peak_x = $left;
    $peak_y = $fl;
  } else {
    $peak_x = $right;
    $peak_y = $fr;
  }

  # take care of corner case when there is no peak
  # and function rises or falls continuously over specified interval.
  # classical golden section results in suboptimal solution in such case.
  # technically, this breaks golden section assumption that there exists a
  # single peak in specified interval, but we want to correctly return
  # interval extreme in this case
  if ($fstart > $peak_y) {
    $peak_x = $min;
    $peak_y = $fstart;
  } elsif ($fend > $peak_y) {
    $peak_x = $max;
    $peak_y = $fend;
  }

  my @leading  = map { { $xv => $_, $yv => $pairs{$_} } } grep { $_ < ($peak_x - EPSILON) } sort { $a <=> $b } keys(%pairs);
  my @trailing = map { { $xv => $_, $yv => $pairs{$_} } } grep { $_ > ($peak_x + EPSILON) } sort { $a <=> $b } keys(%pairs);

  return {peak      => {$xv => $peak_x, $yv => $peak_y},
          leading   => \@leading,
          trailing  => \@trailing,
          start     => $min,
          end       => $max,
          precision => $precision,
          type      => $type
  };
}



# ┌───────────────────────────────────────────────────────────────┐
# │___  ____ ___  _  _ ____    _  _ ____ _    ___  ____ ____ ____ │
# │|  \ |___ |__] |  | | __    |__| |___ |    |__] |___ |__/ [__  │
# │|__/ |___ |__] |__| |__]    |  | |___ |___ |    |___ |  \ ___] │
# └───────────────────────────────────────────────────────────────┘

#***

# use Time::HiRes 'clock_gettime';
# use Data::Dumper;
# $Data::Dumper::Deepcopy = 1;
# #$Data::Dumper::Terse = 1;
# $Data::Dumper::Indent = 3;
# $Data::Dumper::Quotekeys = 0;
# $Data::Dumper::Sortkeys = 1;
#
# sub do_debug_model {
#   my ($dm) = @_;
#   croak "Must provde at least a model reference." unless ( defined $dm );
#   my $dump = Data::Dumper->Dump( [$dm->doses, $dm->responses, $dm->patient, $dm->meta->name],
#                                  ['*doses', '*responses', '*patient', 'module'] );
#   warn "\n\n$dump\n\n";
# }
#
# sub do_debug_params {
#   my ( $opts, $p ) = @_;
#   my $dump = Data::Dumper->Dump( [$opts, $p], ['*params', '*p'] );
#   warn "\n\n$dump\n\n";
# }
#
# sub do_debug_critical_points {
#   my ( $dosetimes, $end_of_adm_times, $final_dose_time ) = @_;
#   my $dump = Data::Dumper->Dump( [$dosetimes, $end_of_adm_times, $final_dose_time],
#                                  ['*dosetimes', '*end_of_adm_times', 'final_dose_time'] );
#   warn "\n\n$dump\n\n";
# }

#***



# A Perl module must end with a true value or else it is considered not to
# have loaded.  By convention this value is usually 1 though it can be
# any true value.  A module can end with false to indicate failure but
# this is rarely used and it would instead die() (exit with an error).
1;

__END__

=pod

=encoding UTF-8

=head1 NAME

DoseMe::Algorithms::PlasmaConcentrationPlot - Methods and algorithms that process plasma concentration plots

=head1 VERSION

version 0.3.0

=head2 adaptive_concentration_plot

create a plot of Cp using variable sampling rate

NB: This code is not finished.
At least the following has to be done before it is ready:

* documentation
* check for valid parameters
* unit testing
* fix bugs

Suggested further improvements after bedded down

* optimise
* clean up logic and re-implement as a FSM or bw petri net

=head2 trim_graph

Trim collinear points in given graph

We can trim collinear points by considering the second derivative d^{2}c/dt
this is not as hard as it sounds, as the prmary assumption kinda holds
as we have already quantised the curve, all we need to do is compare norms of consecutive
segments. If norms of two consecutive segments are close enough, then the three points are
close enough to collinear, and we can eliminate the middle point. Rinse, repeat.

* takes a reference to array of hashes containing the graph
* sfx, sfy are x and y graph scaling factors
* params:
** threshold for difference in norms of two consecutive section of graph at which they are considered to be collinear
** iv, ds - names of IV and DV, 't'' and 'c' are assumed if not specified

=head2 goldensectionpeak

perform golden section bisection function optimisation

* takes a generic y = fn(x)
* that is known to have a single extreme between mix and max x

* TODO: Parameter testing and unit tests, documentation

=head1 AUTHOR

Paul Sabourenkov <paul.sabourenkov@doseme.com.au>

=head1 LICENCE AND COPYRIGHT

Copyright (c) 2017, DoseMe. All rights reserved.

This module is not for distribution. It is for internal DoseMe use only.

=head1 DISCLAIMER OF WARRANTY

THERE IS NO WARRANTY FOR THE SOFTWARE, TO THE EXTENT PERMITTED BY APPLICABLE
LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER
PARTIES PROVIDE THE SOFTWARE "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER
EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE
QUALITY AND PERFORMANCE OF THE SOFTWARE IS WITH YOU. SHOULD THE SOFTWARE PROVE
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR, OR
CORRECTION.

IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY
COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE
SOFTWARE AS PERMITTED BY THE ABOVE LICENCE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE SOFTWARE (INCLUDING BUT NOT LIMITED TO
LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR
THIRD PARTIES OR A FAILURE OF THE SOFTWARE TO OPERATE WITH ANY OTHER SOFTWARE),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.

=head1 AUTHOR

DoseMe Pty Ltd <software@doseme.com.au>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2023 by DoseMe Pty Ltd.  No
license is granted to other entities.

=cut
