Codeunit 2000 Time Series Management

App
Base Application
Namespace
System.AI
Versions
17-28

Procedures, 21Obsolete, 2

Versions171819202122232425262728

Source29

Source in 29

src/Layers/W1/BaseApp/System/AI/TimeSeriesManagement.Codeunit.al480 lines, Copyright (c) Microsoft Corporation. MIT

namespace System.AI;

using Microsoft.CashFlow.Setup;
using Microsoft.Foundation.Period;
using System;
using System.Globalization;
using System.Reflection;
using System.Utilities;

codeunit 2000 "Time Series Management"
{

    trigger OnRun()
    begin
    end;

    var
        TempTimeSeriesBuffer: Record "Time Series Buffer" temporary;
        TempTimeSeriesForecast: Record "Time Series Forecast" temporary;
        AzureMLConnector: Codeunit "Azure ML Connector";
        ApiUri: Text;
        ApiKey: SecretText;
        NotInitializedErr: Label 'The connection has not been initialized. Initialize the connection before using the time series functionality.';
        InitializationErr: Label 'Oops, something went wrong when connecting to the Azure Machine Learning endpoint. Please contact your system administrator.';
        TimeOutSec: Integer;
        TimeSeriesPeriodType: Option;
        TimeSeriesForecastingStartDate: Date;
        TimeSeriesObservationPeriods: Integer;
        TimeSeriesFrequency: Integer;
        TimeSeriesCalculationState: Option Uninitialized,Initialized,"Data Prepared",Done;
        DataNotPreparedErr: Label 'The data was not prepared for forecasting. Prepare data before using the forecasting functionality.';
        DataNotProcessedErr: Label 'The data for forecasting has not been processed yet. Results cannot be retrieved.';
        ForecastingPeriodsErr: Label 'The number of forecasting periods must be greater than 0.';
        MinimumHistoricalPeriods: Integer;
        NotADateErr: Label 'PeriodFieldNo must point to a Date field.';
        NotARecordErr: Label 'SourceRecord must point to Record or a RecordRef object.';
        NegativeNumberOfPeriodsErr: Label 'NumberOfPeriods Must be strictly positive.';
        MaximumHistoricalPeriods: Integer;
        TimeSeriesModelOption: Option ARIMA,ETS,STL,"ETS+ARIMA","ETS+STL",ALL;
        UseStandardCredentials: Boolean;
        ForecastSecretNameTxt: Label 'ml-forecast', Locked = true;

    [TryFunction]
    procedure Initialize(Uri: Text; "Key": SecretText; TimeOutSeconds: Integer; UseStdCredentials: Boolean)
    var
        LimitType: Option;
        Limit: Decimal;
        ApiUriSized: Text[250];
    begin
        ApiUri := Uri;
        ApiKey := Key;
        TimeOutSec := TimeOutSeconds;
        UseStandardCredentials := UseStdCredentials;

        if ((ApiUri = '') or ApiKey.IsEmpty()) and UseStdCredentials then begin
            GetMLForecastCredentials(ApiUriSized, ApiKey, LimitType, Limit);
            ApiUri := ApiUriSized;
        end;

        if not AzureMLConnector.Initialize(ApiKey, ApiUri, TimeOutSec) then
            Error(InitializationErr);

        MinimumHistoricalPeriods := 5;
        MaximumHistoricalPeriods := 24;
        TimeSeriesCalculationState := TimeSeriesCalculationState::Initialized;
    end;

    procedure InitializeFromCashFlowSetup(TimeSeriesLibState: Option Uninitialized,Initialized,"Data Prepared",Done): Boolean
    var
        CashFlowSetup: Record "Cash Flow Setup";
        ApiUrl: Text[250];
        MLApiKey: SecretText;
        UsingStandardCredentials: Boolean;
        LimitValue: Decimal;
    begin
        if not CashFlowSetup.Get() then
            exit(false);

        CashFlowSetup.GetMLCredentials(ApiUrl, MLApiKey, LimitValue, UsingStandardCredentials);
        Initialize(ApiUrl, MLApiKey, CashFlowSetup.TimeOut, UsingStandardCredentials);
        SetMaximumHistoricalPeriods(CashFlowSetup."Historical Periods");
        GetState(TimeSeriesLibState);
        if not (TimeSeriesLibState = TimeSeriesLibState::Initialized) then
            exit(false);

        exit(true);
    end;

    [Scope('OnPrem')]
    procedure SetMessageHandler(MessageHandler: DotNet HttpMessageHandler)
    begin
        AzureMLConnector.SetMessageHandler(MessageHandler);
    end;

    procedure PrepareData(RecordVariant: Variant; GroupIDFieldNo: Integer; DateFieldNo: Integer; ValueFieldNo: Integer; PeriodType: Option; ForecastingStartDate: Date; ObservationPeriods: Integer)
    begin
        if TimeSeriesCalculationState < TimeSeriesCalculationState::Initialized then
            Error(NotInitializedErr);

        TempTimeSeriesBuffer.Reset();
        TempTimeSeriesBuffer.DeleteAll();

        TimeSeriesPeriodType := PeriodType;
        TimeSeriesForecastingStartDate := ForecastingStartDate;
        TimeSeriesObservationPeriods := ObservationPeriods;
        TimeSeriesFrequency := GetFrequency(PeriodType);

        FillTimeSeriesBuffer(RecordVariant, GroupIDFieldNo, ValueFieldNo, DateFieldNo);

        TimeSeriesCalculationState := TimeSeriesCalculationState::"Data Prepared";
    end;

    procedure SetPreparedData(var TempTimeSeriesBufferIn: Record "Time Series Buffer" temporary; PeriodType: Option; ForecastingStartDate: Date; ObservationPeriods: Integer)
    begin
        if TimeSeriesCalculationState < TimeSeriesCalculationState::Initialized then
            Error(NotInitializedErr);

        TempTimeSeriesBuffer.Copy(TempTimeSeriesBufferIn, true);

        TimeSeriesPeriodType := PeriodType;
        TimeSeriesForecastingStartDate := ForecastingStartDate;
        TimeSeriesObservationPeriods := ObservationPeriods;
        TimeSeriesFrequency := GetFrequency(PeriodType);

        TimeSeriesCalculationState := TimeSeriesCalculationState::"Data Prepared";
    end;

    procedure GetPreparedData(var TempTimeSeriesBufferOut: Record "Time Series Buffer" temporary)
    begin
        if TimeSeriesCalculationState < TimeSeriesCalculationState::"Data Prepared" then
            Error(DataNotPreparedErr);

        TempTimeSeriesBufferOut.Copy(TempTimeSeriesBuffer, true);
    end;

    procedure Forecast(ForecastingPeriods: Integer; ConfidenceLevel: Integer; TimeSeriesModel: Option)
    begin
        if ConfidenceLevel = 0 then
            ConfidenceLevel := 80;
        if ForecastingPeriods < 1 then
            Error(ForecastingPeriodsErr);

        if TimeSeriesCalculationState < TimeSeriesCalculationState::"Data Prepared" then
            Error(DataNotPreparedErr);

        TempTimeSeriesForecast.Reset();
        TempTimeSeriesForecast.DeleteAll();

        if TempTimeSeriesBuffer.IsEmpty() then begin
            TimeSeriesCalculationState := TimeSeriesCalculationState::Done;
            exit;
        end;

        CreateTimeSeriesInput();
        CreateTimeSeriesParameters(ForecastingPeriods, ConfidenceLevel, TimeSeriesModel);

        if not AzureMLConnector.SendToAzureMLInternal(UseStandardCredentials) then
            Error(GetLastErrorText);

        LoadTimeSeriesForecast();
        TimeSeriesCalculationState := TimeSeriesCalculationState::Done;
    end;

    procedure GetForecast(var TempTimeSeriesForecastOut: Record "Time Series Forecast" temporary)
    begin
        if TimeSeriesCalculationState < TimeSeriesCalculationState::Done then
            Error(DataNotProcessedErr);

        TempTimeSeriesForecastOut.Copy(TempTimeSeriesForecast, true);
    end;

    procedure GetState(var State: Option Uninitialized,Initialized,"Data Prepared",Done)
    begin
        State := TimeSeriesCalculationState;
    end;

    local procedure GetFrequency(PeriodType: Option): Integer
    var
        Date: Record Date;
    begin
        case PeriodType of
            Date."Period Type"::Date:
                exit(365);
            Date."Period Type"::Week:
                exit(52);
            Date."Period Type"::Month:
                exit(12);
            Date."Period Type"::Quarter:
                exit(4);
            Date."Period Type"::Year:
                exit(1);
        end;
    end;

    procedure GetOutput(LineNo: Integer; ColumnNo: Integer): Text
    var
        OutputValue: Text;
    begin
        AzureMLConnector.GetOutput(LineNo, ColumnNo, OutputValue);
        exit(OutputValue);
    end;

    procedure GetOutputLength(): Integer
    var
        Length: Integer;
    begin
        AzureMLConnector.GetOutputLength(Length);
        exit(Length);
    end;

    procedure GetInput(LineNo: Integer; ColumnNo: Integer): Text
    var
        InputValue: Text;
    begin
        AzureMLConnector.GetInput(LineNo, ColumnNo, InputValue);
        exit(InputValue);
    end;

    procedure GetInputLength(): Integer
    var
        Length: Integer;
    begin
        AzureMLConnector.GetInputLength(Length);
        exit(Length);
    end;

    procedure GetParameter(Name: Text): Text
    var
        ParameterValue: Text;
    begin
        AzureMLConnector.GetParameter(Name, ParameterValue);
        exit(ParameterValue);
    end;

    local procedure FillTimeSeriesBuffer(RecordVariant: Variant; GroupIDFieldNo: Integer; ValueFieldNo: Integer; DateFieldNo: Integer)
    var
        TempTimeSeriesBufferDistinct: Record "Time Series Buffer" temporary;
        DataTypeManagement: Codeunit "Data Type Management";
        PeriodPageManagement: Codeunit PeriodPageManagement;
        RecRef: RecordRef;
        GroupIDFieldRef: FieldRef;
        ValueFieldRef: FieldRef;
        DateFieldRef: FieldRef;
        CurrentPeriod: Integer;
        PeriodStartDate: Date;
        PeriodEndDate: Date;
        Value: Decimal;
    begin
        DataTypeManagement.GetRecordRef(RecordVariant, RecRef);

        if RecRef.IsEmpty() then
            exit;

        GroupIDFieldRef := RecRef.Field(GroupIDFieldNo);
        DateFieldRef := RecRef.Field(DateFieldNo);
        ValueFieldRef := RecRef.Field(ValueFieldNo);

        GetDistinctRecords(RecRef, GroupIDFieldNo, TempTimeSeriesBufferDistinct);

        if TempTimeSeriesBufferDistinct.FindSet() then
            repeat
                GroupIDFieldRef.SetRange(TempTimeSeriesBufferDistinct."Group ID");
                for CurrentPeriod := -TimeSeriesObservationPeriods to -1 do begin
                    PeriodStartDate :=
                      PeriodPageManagement.MoveDateByPeriod(TimeSeriesForecastingStartDate, TimeSeriesPeriodType, CurrentPeriod);
                    PeriodEndDate :=
                      PeriodPageManagement.MoveDateByPeriodToEndOfPeriod(TimeSeriesForecastingStartDate, TimeSeriesPeriodType, CurrentPeriod);
                    DateFieldRef.SetRange(PeriodStartDate, PeriodEndDate);
                    if ValueFieldRef.Class = FieldClass::Normal then
                        Value := CalculateValueNormal(ValueFieldRef)
                    else
                        Value := CalculateValueFlowField(RecRef, ValueFieldRef);
                    TempTimeSeriesBuffer.Init();
                    TempTimeSeriesBuffer."Group ID" := GroupIDFieldRef.GetFilter;
                    TempTimeSeriesBuffer."Period No." := TimeSeriesObservationPeriods + CurrentPeriod + 1;
                    TempTimeSeriesBuffer."Period Start Date" := PeriodStartDate;
                    TempTimeSeriesBuffer.Value := Value;
                    TempTimeSeriesBuffer.Insert();
                end;
            until TempTimeSeriesBufferDistinct.Next() = 0;
    end;

    local procedure CalculateValueNormal(var ValueFieldRef: FieldRef) Value: Decimal
    begin
        if ValueFieldRef.Class <> FieldClass::Normal then
            exit(0);

        ValueFieldRef.CalcSum();
        Value := ValueFieldRef.Value();
    end;

    local procedure CalculateValueFlowField(var RecRef: RecordRef; var ValueFieldRef: FieldRef) Value: Decimal
    var
        CurrentValue: Decimal;
    begin
        if ValueFieldRef.Class <> FieldClass::FlowField then
            exit(0);

        if RecRef.FindSet() then
            repeat
                ValueFieldRef.CalcField();
                CurrentValue := ValueFieldRef.Value();
                Value += CurrentValue;
            until RecRef.Next() = 0;
    end;

    local procedure CreateTimeSeriesInput()
    begin
        AzureMLConnector.AddInputColumnName('GranularityAttribute');
        AzureMLConnector.AddInputColumnName('DateKey');
        AzureMLConnector.AddInputColumnName('TransactionQty');

        if TempTimeSeriesBuffer.FindSet() then
            repeat
                AzureMLConnector.AddInputRow();
                AzureMLConnector.AddInputValue(Format(TempTimeSeriesBuffer."Group ID"));
                AzureMLConnector.AddInputValue(Format(TempTimeSeriesBuffer."Period No."));
                AzureMLConnector.AddInputValue(Format(TempTimeSeriesBuffer.Value, 0, 9));
            until TempTimeSeriesBuffer.Next() = 0;
    end;

    local procedure CreateTimeSeriesParameters(ForecastingPeriods: Integer; ConfidenceLevel: Integer; TimeSeriesModel: Option ARIMA,ETS,STL,"ETS+ARIMA","ETS+STL",ALL,TBATS)
    begin
        AzureMLConnector.AddParameter('horizon', Format(ForecastingPeriods));
        AzureMLConnector.AddParameter('seasonality', Format(TimeSeriesFrequency));
        AzureMLConnector.AddParameter('forecast_start_datekey', Format(TimeSeriesObservationPeriods + 1));
        AzureMLConnector.AddParameter('time_series_model', Format(TimeSeriesModel));
        AzureMLConnector.AddParameter('confidence_level', Format(ConfidenceLevel));
    end;

    local procedure LoadTimeSeriesForecast()
    var
        TypeHelper: Codeunit "Type Helper";
        PeriodPageManagement: Codeunit PeriodPageManagement;
        DotNet_CultureInfo: Codeunit DotNet_CultureInfo;
        Value: Variant;
        LineNo: Integer;
        GroupID: Code[50];
        PeriodNo: Integer;
    begin
        for LineNo := 1 to GetOutputLength() do begin
            TempTimeSeriesForecast.Init();

            Evaluate(GroupID, GetOutput(LineNo, 1));
            TempTimeSeriesForecast."Group ID" := GroupID;

            Evaluate(PeriodNo, GetOutput(LineNo, 2));
            TempTimeSeriesForecast."Period No." := PeriodNo;
            TempTimeSeriesForecast."Period Start Date" :=
              PeriodPageManagement.MoveDateByPeriod(
                TimeSeriesForecastingStartDate, TimeSeriesPeriodType, PeriodNo - TimeSeriesObservationPeriods - 1);
            Value := TempTimeSeriesForecast.Value;
            TypeHelper.Evaluate(Value, GetOutput(LineNo, 3), '', DotNet_CultureInfo.CurrentCultureName());
            TempTimeSeriesForecast.Value := Value;

            Value := TempTimeSeriesForecast.Delta;
            TypeHelper.Evaluate(Value, GetOutput(LineNo, 4), '', DotNet_CultureInfo.CurrentCultureName());
            TempTimeSeriesForecast.Delta := Value;
            if TempTimeSeriesForecast.Value <> 0 then
                TempTimeSeriesForecast."Delta %" := Abs(TempTimeSeriesForecast.Delta / TempTimeSeriesForecast.Value) * 100;

            TempTimeSeriesForecast.Insert();
        end;
    end;

    local procedure GetDistinctRecords(RecRef: RecordRef; FieldNo: Integer; var TempTimeSeriesBufferDistinct: Record "Time Series Buffer" temporary)
    var
        FieldRef: FieldRef;
        OptionValue: Integer;
    begin
        FieldRef := RecRef.Field(FieldNo);

        if RecRef.FindSet() then
            repeat
                TempTimeSeriesBufferDistinct.Init();
                if FieldRef.Type = FieldType::Option then begin
                    OptionValue := FieldRef.Value();
                    TempTimeSeriesBufferDistinct."Group ID" := Format(OptionValue);
                end else
                    TempTimeSeriesBufferDistinct."Group ID" := FieldRef.Value();
                if not TempTimeSeriesBufferDistinct.Insert() then;
            until RecRef.Next() = 0;
    end;

    procedure SetMinimumHistoricalPeriods(NumberOfPeriods: Integer)
    begin
        if not (NumberOfPeriods > 0) then
            Error(NegativeNumberOfPeriodsErr);
        MinimumHistoricalPeriods := NumberOfPeriods;
    end;

    procedure SetMaximumHistoricalPeriods(NumberOfPeriods: Integer)
    begin
        if not (NumberOfPeriods > 0) then
            Error(NegativeNumberOfPeriodsErr);
        MaximumHistoricalPeriods := NumberOfPeriods;
    end;

    procedure HasMinimumHistoricalData(var NumberOfPeriodsWithHistory: Integer; SourceRecord: Variant; PeriodFieldNo: Integer; PeriodType: Option Day,Week,Month,Quarter,Year; ForecastStartDate: Date): Boolean
    var
        DataTypeManagement: Codeunit "Data Type Management";
        SourceRecordRef: RecordRef;
        PeriodFieldRef: FieldRef;
        HistoryStartDate: Variant;
        HistoryEndDate: Date;
    begin
        // SourceRecord Should already contain all the necessary filters
        // and should be sorted by the desired date field
        if SourceRecord.IsRecord then
            DataTypeManagement.GetRecordRef(SourceRecord, SourceRecordRef)
        else
            if SourceRecord.IsRecordRef then
                SourceRecordRef := SourceRecord
            else
                Error(NotARecordErr);

        if not SourceRecordRef.FindFirst() then
            exit(false);

        // last date of transaction history that will be used for forecast
        HistoryEndDate := CalcDate('<-1D>', ForecastStartDate);

        PeriodFieldRef := SourceRecordRef.Field(PeriodFieldNo);
        PeriodFieldRef.SetFilter('%1..', CalculateMaxStartDate(HistoryEndDate, PeriodType));
        if SourceRecordRef.FindSet() then;
        PeriodFieldRef := SourceRecordRef.Field(PeriodFieldNo);
        HistoryStartDate := PeriodFieldRef.Value();
        if not (HistoryStartDate.IsDate or HistoryStartDate.IsDateTime) then
            Error(NotADateErr);
        NumberOfPeriodsWithHistory :=
          CalculatePeriodsWithHistory(HistoryStartDate, HistoryEndDate, PeriodType);
        if NumberOfPeriodsWithHistory < MinimumHistoricalPeriods then
            exit(false);
        exit(true);
    end;

    local procedure CalculatePeriodsWithHistory(HistoryStartDate: Date; HistoryEndDate: Date; PeriodType: Option) NumberOfPeriodsWithHistory: Integer
    var
        PeriodPageManagement: Codeunit PeriodPageManagement;
    begin
        while HistoryStartDate <= HistoryEndDate do begin
            NumberOfPeriodsWithHistory += 1;
            HistoryStartDate := PeriodPageManagement.MoveDateByPeriod(HistoryStartDate, PeriodType, 1);
        end;
    end;

    local procedure CalculateMaxStartDate(HistoryEndDate: Date; PeriodType: Option Day,Week,Month,Quarter,Year): Date
    begin
        case PeriodType of
            PeriodType::Day:
                exit(CalcDate(StrSubstNo('<-%1D>', Format(MaximumHistoricalPeriods - 1)), HistoryEndDate));
            PeriodType::Week:
                exit(CalcDate(StrSubstNo('<-%1W+1D>', Format(MaximumHistoricalPeriods)), HistoryEndDate));
            PeriodType::Month:
                exit(CalcDate(StrSubstNo('<-%1M+1D>', Format(MaximumHistoricalPeriods)), HistoryEndDate));
            PeriodType::Quarter:
                exit(CalcDate(StrSubstNo('<-%1Q+1D>', Format(MaximumHistoricalPeriods)), HistoryEndDate));
            PeriodType::Year:
                exit(CalcDate(StrSubstNo('<-%1Y+1D>', Format(MaximumHistoricalPeriods)), HistoryEndDate));
        end;
    end;

    procedure GetTimeSeriesModelOption(TimeSeriesModel: Text): Integer
    begin
        Evaluate(TimeSeriesModelOption, TimeSeriesModel);
        exit(TimeSeriesModelOption);
    end;

    [NonDebuggable]
    [TryFunction]
    [Scope('OnPrem')]
    procedure GetMLForecastCredentials(var LocalApiUri: Text[250]; var "Key": SecretText; var LimitType: Option; var Limit: Decimal)
    var
        MachineLearningKeyVaultMgmt: Codeunit "Machine Learning KeyVaultMgmt.";
    begin
        MachineLearningKeyVaultMgmt.GetMachineLearningCredentials(ForecastSecretNameTxt, LocalApiUri, "Key", LimitType, Limit);
        LocalApiUri += '/execute?api-version=2.0&details=true';
    end;
}