%-*-Mode:erlang;coding:utf-8;tab-width:4;c-basic-offset:4;indent-tabs-mode:()-*-
% ex: set ft=erlang fenc=utf-8 sts=4 ts=4 sw=4 et nomod:
%%%
%%%------------------------------------------------------------------------
%%% @doc
%%% ==CloudI Statistics==
%%% Calculate statistics using the online algorithm.
%%%
%%% Philippe Pébay, Timothy B. Terriberry, Hemanth Kolla, Janine Bennett.
%%% Formulas for the Computation of Higher-Order Central Moments.
%%% Technical Report SAND2014-17343J, Sandia National Laboratories, 2014.
%%%
%%% Pébay, Philippe.  Formulas for Robust, One-Pass Parallel Computation of
%%% Covariances and Arbitrary-Order Statistical Moments.
%%% Technical Report SAND2008-6212, Sandia National Laboratories, 2008.
%%%
%%% Welford, B. P.. Note on a method for calculating corrected sums of
%%% squares and products. Technometrics vol. 4, no. 3, pp. 419–420, 1962.
%%% @end
%%%
%%% MIT License
%%%
%%% Copyright (c) 2022-2023 Michael Truog <mjtruog at protonmail dot com>
%%%
%%% Permission is hereby granted, free of charge, to any person obtaining a
%%% copy of this software and associated documentation files (the "Software"),
%%% to deal in the Software without restriction, including without limitation
%%% the rights to use, copy, modify, merge, publish, distribute, sublicense,
%%% and/or sell copies of the Software, and to permit persons to whom the
%%% Software is furnished to do so, subject to the following conditions:
%%%
%%% The above copyright notice and this permission notice shall be included in
%%% all copies or substantial portions of the Software.
%%%
%%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
%%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
%%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
%%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
%%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
%%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
%%% DEALINGS IN THE SOFTWARE.
%%%
%%% @author Michael Truog <mjtruog at protonmail dot com>
%%% @copyright 2022-2023 Michael Truog
%%% @version 2.0.7 {@date} {@time}
%%%------------------------------------------------------------------------

-module(cloudi_statistics).
-author('mjtruog at protonmail dot com').

%% external interface
-export([add/2,
         add_from_list/2,
         add_from_list/3,
         count/1,
         describe_distribution/1,
         describe_kurtosis/1,
         describe_skewness/1,
         kurtosis/1,
         mean/1,
         merge/2,
         new/0,
         normal_from_log_normal/1,
         normal_to_log_normal/1,
         skewness/1,
         standard_deviation/1,
         maximum/1,
         minimum/1,
         variance/1]).

-include("cloudi_core_i_constants.hrl").

-record(statistics,
    {
        n = 0 :: non_neg_integer(),
        mean = 0.0 :: float(),
        m2 = 0.0 :: float(),
        m3 = 0.0 :: float(),
        m4 = 0.0 :: float(),
        minimum = ?DBL_MAX :: float(),
        maximum = -?DBL_MAX :: float(),
        cached_kurtosis = undefined :: float() | undefined,
        cached_skewness = undefined :: float() | undefined,
        cached_stddev = undefined :: float() | undefined,
        cached_variance = undefined :: float() | undefined
    }).

%%%------------------------------------------------------------------------
%%% External interface functions
%%%------------------------------------------------------------------------

-type describe_distribution() ::
    normal | uniform | logistic | exponential |
    gamma_family | log_normal_family | undefined.
-type describe_kurtosis() ::
    leptokurtic | platykurtic | mesokurtic | undefined.
-type describe_skewness() ::
    highly_skewed | moderately_skewed | approximately_symmetric | undefined.
-type state() :: #statistics{}.
-export_type([describe_distribution/0,
              describe_kurtosis/0,
              describe_skewness/0,
              state/0]).

%%-------------------------------------------------------------------------
%% @doc
%% ===Add a sample for computing statistics.===
%% @end
%%-------------------------------------------------------------------------

-spec add(X :: number(),
          State :: state()) ->
    state().

add(X, #statistics{n = NOld,
                   mean = Mean,
                   m2 = M2,
                   m3 = M3,
                   m4 = M4,
                   minimum = Minimum,
                   maximum = Maximum} = State)
    when is_number(X) ->
    XAdd = float(X),
    N = NOld + 1,
    Delta = XAdd - Mean,
    DeltaN = Delta / N,
    DeltaN2 = DeltaN * DeltaN,
    Term1 = Delta * DeltaN * NOld,
    MeanNew = Mean + DeltaN,
    M4New = M4 +
            Term1 * DeltaN2 * (N * N - 3 * N + 3) +
            6 * DeltaN2 * M2 -
            4 * DeltaN * M3,
    M3New = M3 +
            Term1 * DeltaN * (N - 2) -
            3 * DeltaN * M2,
    M2New = M2 + Term1,
    MinimumNew = erlang:min(Minimum, XAdd),
    MaximumNew = erlang:max(Maximum, XAdd),
    State#statistics{n = N,
                     mean = MeanNew,
                     m2 = M2New,
                     m3 = M3New,
                     m4 = M4New,
                     minimum = MinimumNew,
                     maximum = MaximumNew,
                     cached_kurtosis = undefined,
                     cached_skewness = undefined,
                     cached_stddev = undefined,
                     cached_variance = undefined}.

%%-------------------------------------------------------------------------
%% @doc
%% ===Add samples from a list for computing statistics.===
%% @end
%%-------------------------------------------------------------------------

-spec add_from_list(L :: list(number()),
                    State :: state()) ->
    state().

add_from_list([], State) ->
    State;
add_from_list([X | L], State) ->
    add_from_list(L, add(X, State)).

%%-------------------------------------------------------------------------
%% @doc
%% ===Add samples from a function mapped on a list for computing statistics.===
%% @end
%%-------------------------------------------------------------------------

-spec add_from_list(F :: fun(),
                    L :: list(number()),
                    State :: state()) ->
    state().

add_from_list(F, L, State) ->
    if
        is_function(F, 1) ->
            add_from_list_1(L, State, F);
        is_function(F, 2) ->
            add_from_list_2(L, State, F);
        is_function(F, 3) ->
            add_from_list_3(L, State, F)
    end.

%%-------------------------------------------------------------------------
%% @doc
%% ===Count of samples previously added.===
%% @end
%%-------------------------------------------------------------------------

-spec count(State :: state()) ->
    non_neg_integer().

count(#statistics{n = N}) ->
    N.

%%-------------------------------------------------------------------------
%% @doc
%% ===Describe the distribution with a guess about its shape.===
%% Only use if the sample count is large enough to cover the distribution.
%% @end
%%-------------------------------------------------------------------------

-spec describe_distribution(State :: state()) ->
    {describe_distribution(), state()}.

describe_distribution(State) ->
    case kurtosis(State) of
        {undefined, _} = Result ->
            Result;
        {Kurtosis, StateNext} ->
            case skewness(StateNext) of
                {undefined, _} = Result ->
                    Result;
                {Skewness, StateNew} ->
                    SkewnessDelta = if
                        Skewness =< -4 orelse Skewness >= 4 ->
                            math:sqrt(abs(Skewness)) * 0.5;
                        true ->
                            0.25
                    end,
                    KurtosisDelta = if
                        Kurtosis >= 9 ->
                            math:sqrt(Kurtosis) * 0.5;
                        true ->
                            0.175
                    end,
                    describe_distribution_0(Skewness,
                                            Skewness - SkewnessDelta,
                                            Skewness + SkewnessDelta,
                                            Kurtosis,
                                            Kurtosis - KurtosisDelta,
                                            Kurtosis + KurtosisDelta,
                                            StateNew)
            end
    end.

%%-------------------------------------------------------------------------
%% @doc
%% ===Describe the excess kurtosis value.===
%% Only use if the sample count is large enough to cover the distribution.
%% @end
%%-------------------------------------------------------------------------

-spec describe_kurtosis(State :: state()) ->
    {describe_kurtosis(), state()}.

describe_kurtosis(State) ->
    case kurtosis(State) of
        {undefined, _} = Result ->
            Result;
        {Kurtosis, StateNew} ->
            KurtosisX10 = round(Kurtosis * 10),
            if
                KurtosisX10 > 0 ->
                    {leptokurtic, StateNew};
                KurtosisX10 < 0 ->
                    {platykurtic, StateNew};
                KurtosisX10 == 0 ->
                    {mesokurtic, StateNew}
            end
    end.

%%-------------------------------------------------------------------------
%% @doc
%% ===Describe the skewness value.===
%% Only use if the sample count is large enough to cover the distribution.
%%
%% (based on)
%% Bulmer, M. G..
%% Principles of Statistics. Dover Publications, 1979.
%% @end
%%-------------------------------------------------------------------------

-spec describe_skewness(State :: state()) ->
    {describe_skewness(), state()}.

describe_skewness(State) ->
    case skewness(State) of
        {undefined, _} = Result ->
            Result;
        {Skewness, StateNew} ->
            if
                Skewness < -1.0 orelse Skewness > 1.0 ->
                    {highly_skewed, StateNew};
                Skewness =< -0.5 orelse Skewness >= 0.5 ->
                    {moderately_skewed, StateNew};
                Skewness > -0.5 andalso Skewness < 0.5 ->
                    {approximately_symmetric, StateNew}
            end
    end.

%%-------------------------------------------------------------------------
%% @doc
%% ===Excess kurtosis of samples previously added.===
%% The minimum value returned is -2.0 (Fisher’s definition).
%% @end
%%-------------------------------------------------------------------------

-spec kurtosis(State :: state()) ->
    {float() | undefined, state()}.

kurtosis(#statistics{n = N,
                     m2 = M2,
                     m4 = M4,
                     cached_kurtosis = undefined} = State) ->
    Denominator = M2 * M2,
    if
        Denominator /= 0.0 ->
            Kurtosis = N * M4 / Denominator - 3.0,
            {Kurtosis, State#statistics{cached_kurtosis = Kurtosis}};
        true ->
            {undefined, State}
    end;
kurtosis(#statistics{cached_kurtosis = Kurtosis} = State) ->
    {Kurtosis, State}.

%%-------------------------------------------------------------------------
%% @doc
%% ===Maximum of samples previously added.===
%% @end
%%-------------------------------------------------------------------------

-spec maximum(State :: state()) ->
    float().

maximum(#statistics{maximum = Maximum}) ->
    Maximum.

%%-------------------------------------------------------------------------
%% @doc
%% ===Mean of samples previously added.===
%% The value returned is the arithmetic mean.
%% @end
%%-------------------------------------------------------------------------

-spec mean(State :: state()) ->
    float().

mean(#statistics{mean = Mean}) ->
    Mean.

%%-------------------------------------------------------------------------
%% @doc
%% ===Merge statistics state.===
%% @end
%%-------------------------------------------------------------------------

-spec merge(StateA :: state(),
            StateB :: state()) ->
    state().

merge(#statistics{} = StateA,
      #statistics{n = 0}) ->
    StateA;
merge(#statistics{n = 0},
      #statistics{} = StateB) ->
    StateB;
merge(#statistics{n = NA,
                  mean = MeanA,
                  m2 = M2A,
                  m3 = M3A,
                  m4 = M4A,
                  minimum = MinimumA,
                  maximum = MaximumA},
      #statistics{n = NB,
                  mean = MeanB,
                  m2 = M2B,
                  m3 = M3B,
                  m4 = M4B,
                  minimum = MinimumB,
                  maximum = MaximumB}) ->
    N = NA + NB,
    Delta = MeanA - MeanB,
    DeltaN = Delta / N,
    DeltaN2 = DeltaN * DeltaN,
    NA2 = NA * NA,
    NB2 = NB * NB,
    N_2 = NA * NB,
    M4 = M4A + M4B +
         DeltaN2 * DeltaN2 * N_2 * (NB * NB2 + NA * NA2) +
         6.0 * (NB2 * M2A + NA2 * M2B) * DeltaN2 +
         4.0 * (NB * M3A - NA * M3B) * DeltaN,
    M3 = M3A + M3B +
         N_2 * (NB - NA) * Delta * DeltaN2 +
         3.0 * (NB * M2A - NA * M2B) * DeltaN,
    M2 = M2A + M2B +
         N_2 * Delta * DeltaN,
    Mean = MeanB + NA * DeltaN,
    Minimum = erlang:min(MinimumA, MinimumB),
    Maximum = erlang:max(MaximumA, MaximumB),
    #statistics{n = N,
                mean = Mean,
                m2 = M2,
                m3 = M3,
                m4 = M4,
                minimum = Minimum,
                maximum = Maximum}.

%%-------------------------------------------------------------------------
%% @doc
%% ===Minimum of samples previously added.===
%% @end
%%-------------------------------------------------------------------------

-spec minimum(State :: state()) ->
    float().

minimum(#statistics{minimum = Minimum}) ->
    Minimum.

%%-------------------------------------------------------------------------
%% @doc
%% ===Create statistics state.===
%% @end
%%-------------------------------------------------------------------------

-spec new() ->
    state().

new() ->
    #statistics{}.

%%-------------------------------------------------------------------------
%% @doc
%% ===Convert the Log-normal distribution mean and standard deviation to Normal distribution mean and standard deviation.===
%% @end
%%-------------------------------------------------------------------------

-spec normal_from_log_normal(State :: state()) ->
    {{NormalMean :: float(),
      NormalStdDev :: float()} | undefined, state()}.

normal_from_log_normal(State) ->
    case variance(State) of
        {undefined, _} = Result ->
            Result;
        {Variance, #statistics{mean = Mean} = StateNew} ->
            NormalMean = math:exp(Mean + Variance / 2),
            NormalStandardDeviation = math:sqrt((math:exp(Variance) - 1) *
                                                math:exp(2 * Mean + Variance)),
            {{NormalMean, NormalStandardDeviation}, StateNew}
    end.

%%-------------------------------------------------------------------------
%% @doc
%% ===Convert the Normal distribution mean and standard deviation to Log-normal distribution mean and standard deviation.===
%% @end
%%-------------------------------------------------------------------------

-spec normal_to_log_normal(State :: state()) ->
    {{LogNormalMean :: float(),
      LogNormalStdDev :: float()} | undefined, state()}.

normal_to_log_normal(State) ->
    case variance(State) of
        {undefined, _} = Result ->
            Result;
        {Variance, #statistics{mean = Mean} = StateNew} ->
            Mean2 = Mean * Mean,
            Sum = Mean2 + Variance,
            LogNormalMean = math:log(Mean2 / math:sqrt(Sum)),
            LogNormalStandardDeviation = math:sqrt(math:log(Sum / Mean2)),
            {{LogNormalMean, LogNormalStandardDeviation}, StateNew}
    end.

%%-------------------------------------------------------------------------
%% @doc
%% ===Sample skewness of samples previously added.===
%% The value returned is the Fisher-Pearson coefficient of skewness.
%% @end
%%-------------------------------------------------------------------------

-spec skewness(State :: state()) ->
    {float() | undefined, state()}.

skewness(#statistics{n = N,
                     m2 = M2,
                     m3 = M3,
                     cached_skewness = undefined} = State) ->
    Denominator = math:pow(M2, 1.5),
    if
        Denominator /= 0.0 ->
            Skewness = math:sqrt(N) * M3 / Denominator,
            {Skewness, State#statistics{cached_skewness = Skewness}};
        true ->
            {undefined, State}
    end;
skewness(#statistics{cached_skewness = Skewness} = State) ->
    {Skewness, State}.

%%-------------------------------------------------------------------------
%% @doc
%% ===Sample standard deviation of samples previously added.===
%% @end
%%-------------------------------------------------------------------------

-spec standard_deviation(State :: state()) ->
    {float() | undefined, state()}.

standard_deviation(#statistics{cached_stddev = undefined} = State) ->
    case variance(State) of
        {undefined, _} = Result ->
            Result;
        {Variance, StateNew} ->
            StandardDeviation = math:sqrt(Variance),
            {StandardDeviation,
             StateNew#statistics{cached_stddev = StandardDeviation}}
    end;
standard_deviation(#statistics{cached_stddev = StandardDeviation} = State) ->
    {StandardDeviation, State}.

%%-------------------------------------------------------------------------
%% @doc
%% ===Sample variance of samples previously added.===
%% @end
%%-------------------------------------------------------------------------

-spec variance(State :: state()) ->
    {float() | undefined, state()}.

variance(#statistics{n = N,
                     m2 = M2,
                     cached_variance = undefined} = State) ->
    if
        N > 1 ->
            Variance = M2 / (N - 1.0),
            {Variance, State#statistics{cached_variance = Variance}};
        true ->
            {undefined, State}
    end;
variance(#statistics{cached_variance = Variance} = State) ->
    {Variance, State}.

%%%------------------------------------------------------------------------
%%% Private functions
%%%------------------------------------------------------------------------

add_from_list_1([X0 | L], State, F) ->
    add_from_list_1(L, add(F(X0), State), F);
add_from_list_1([], State, _) ->
    State.

add_from_list_2([X0, X1 | L], State, F) ->
    add_from_list_2(L, add(F(X0, X1), State), F);
add_from_list_2(_, State, _) ->
    State.

add_from_list_3([X0, X1, X2 | L], State, F) ->
    add_from_list_3(L, add(F(X0, X1, X2), State), F);
add_from_list_3(_, State, _) ->
    State.

describe_distribution_0(Skewness, SkewnessLow, SkewnessHigh,
                        Kurtosis, KurtosisLow, KurtosisHigh, State)
    when SkewnessLow < 0 andalso SkewnessHigh > 0 ->
    if
        KurtosisLow < 0 andalso KurtosisHigh > 0 ->
            {normal, State};
        KurtosisLow < -1.2 andalso KurtosisHigh > -1.2 ->
            {uniform, State};
        KurtosisLow < 1.2 andalso KurtosisHigh > 1.2 ->
            {logistic, State};
        true ->
            describe_distribution_1(Skewness, SkewnessLow, SkewnessHigh,
                                    Kurtosis, KurtosisLow, KurtosisHigh, State)
    end;
describe_distribution_0(Skewness, SkewnessLow, SkewnessHigh,
                        Kurtosis, KurtosisLow, KurtosisHigh, State)
    when SkewnessLow < 2 andalso SkewnessHigh > 2 ->
    if
        KurtosisLow < 6 andalso KurtosisHigh > 6 ->
            {exponential, State};
        true ->
            describe_distribution_1(Skewness, SkewnessLow, SkewnessHigh,
                                    Kurtosis, KurtosisLow, KurtosisHigh, State)
    end;
describe_distribution_0(Skewness, SkewnessLow, SkewnessHigh,
                        Kurtosis, KurtosisLow, KurtosisHigh, State) ->
    describe_distribution_1(Skewness, SkewnessLow, SkewnessHigh,
                            Kurtosis, KurtosisLow, KurtosisHigh, State).

describe_distribution_1(Skewness, SkewnessLow, SkewnessHigh,
                        Kurtosis, KurtosisLow, KurtosisHigh, State) ->
    TestL0 = [],
    TestL1 = skewness_check(TestL0,
                            gamma_family, fun gamma_kurtosis/1,
                            Skewness, SkewnessLow, SkewnessHigh,
                            Kurtosis, KurtosisLow, KurtosisHigh),
    TestLN = skewness_check(TestL1,
                            log_normal_family, fun log_normal_kurtosis/1,
                            Skewness, SkewnessLow, SkewnessHigh,
                            Kurtosis, KurtosisLow, KurtosisHigh),
    case TestLN of
        [{_, TestType} | _] ->
            {TestType, State};
        [] ->
            {undefined, State}
    end.

skewness_check(TestL, TestType, TestF,
               Skewness, SkewnessLow, SkewnessHigh,
               Kurtosis, KurtosisLow, KurtosisHigh)
    when is_function(TestF, 1) ->
    TestKurtosis = TestF(Skewness),
    Passed = if
        KurtosisLow < TestKurtosis andalso
        KurtosisHigh > TestKurtosis ->
            true;
        true ->
            TestKurtosisLow = TestF(SkewnessLow),
            TestKurtosisHigh = TestF(SkewnessHigh),
            TestKurtosisLow < Kurtosis andalso TestKurtosisHigh > Kurtosis
    end,
    if
        Passed =:= true ->
            lists:keymerge(1, TestL,
                           [{abs(TestKurtosis - Kurtosis), TestType}]);
        Passed =:= false ->
            TestL
    end.

gamma_kurtosis(Skewness) ->
    X = 2 / Skewness,
    6 / (X * X).

log_normal_kurtosis(Skewness) ->
    if
        Skewness /= 0.0 ->
            A = log_normal_skewness_a(Skewness),
            A2 = A * A,
            A2 * A2 + 2 * A2 * A + 3 * A2 - 6;
        true ->
            0.0
    end.

log_normal_skewness_a(Skewness) ->
    % use Halley's method to determine A based on Log-normal skewness
    % A = e^(NormalStandardDeviation^2)
    % (A + 2)^2 * (A - 1) = Skewness^2
    % B = A - 1
    % (B + 3)^2 * B = Skewness^2
    Skewness2 = Skewness * Skewness,
    log_normal_skewness_b(math:pow(Skewness2, 1 / 3) - 1, Skewness2) + 1.

log_normal_skewness_b(B, Skewness2) ->
    B2 = B * B,
    F0B = B2 * B + 6 * B2 + 9 * B - Skewness2,
    F1B = 3 * B2 + 12 * B + 9,
    F2B = 6 * B + 12,
    BE = B - (2 * F0B * F1B) / ((2 * F1B * F1B) - (F0B * F2B)),
    if
        abs(BE - B) > 0.0001 ->
            log_normal_skewness_b(BE, Skewness2);
        true ->
            BE
    end.

