//****************************************************************************************************** // DynamicCalculator.cs - Gbtc // // Copyright © 2012, Grid Protection Alliance. All Rights Reserved. // // Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See // the NOTICE file distributed with this work for additional information regarding copyright ownership. // The GPA licenses this file to you under the MIT License (MIT), the "License"; you may // not use this file except in compliance with the License. You may obtain a copy of the License at: // // http://www.opensource.org/licenses/MIT // // Unless agreed to in writing, the subject software distributed under the License is distributed on an // "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the // License for the specific language governing permissions and limitations. // // Code Modification History: // ---------------------------------------------------------------------------------------------------- // 02/06/2012 - Stephen C. Wills // Generated original version of source code. // 12/13/2012 - Starlynn Danyelle Gilliam // Modified Header. // //****************************************************************************************************** using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Linq; using System.Reflection; using System.Text; using Ciloci.Flee; using GSF; using GSF.Configuration; using GSF.Diagnostics; using GSF.Threading; using GSF.TimeSeries; using GSF.TimeSeries.Adapters; namespace DynamicCalculator { /// /// Represents the source of a timestamp. /// public enum TimestampSource { /// /// An incoming frame timestamp. /// Frame, /// /// Real-time as defined by the concentration engine. /// RealTime, /// /// The system's local clock. /// LocalClock } /// /// The DynamicCalculator is an action adapter which takes multiple /// input measurements and performs a calculation on those measurements /// to generate its own calculated measurement. /// [Description("Dynamic Calculator: Performs arithmetic operations on multiple input signals")] public class DynamicCalculator : ActionAdapterBase { #region [ Members ] // Constants /// /// Defines the default value for property. /// public const string DefaultImports = "AssemblyName=mscorlib, TypeName=System.Math; AssemblyName=mscorlib, TypeName=System.DateTime"; private const string TimeVariable = "TIME"; private const string UtcTimeVariable = "UTCTIME"; private const string LocalTimeVariable = "LOCALTIME"; private const string SystemNameVariable = "SYSTEMNAME"; private static readonly string[] ReservedVariableNames = { TimeVariable, UtcTimeVariable, LocalTimeVariable, SystemNameVariable }; // Fields private string m_expressionText; private string m_variableList; private string m_imports; private bool m_supportsTemporalProcessing; private readonly ImmediateMeasurements m_latestMeasurements; private Ticks m_latestTimestamp; private double m_latestValue; private readonly HashSet m_variableNames; private readonly Dictionary m_keyMapping; private readonly SortedDictionary m_nonAliasedTokens; private string m_aliasedExpressionText; private readonly ExpressionContext m_expressionContext; private IDynamicExpression m_expression; private readonly DelayedSynchronizedOperation m_timerOperation; #endregion #region [ Constructors ] /// /// Creates a new instance of the . /// public DynamicCalculator() { m_latestMeasurements = new ImmediateMeasurements { RealTimeFunction = () => RealTime }; m_variableNames = new HashSet(); m_keyMapping = new Dictionary(); m_nonAliasedTokens = new SortedDictionary(); m_expressionContext = new ExpressionContext(); m_timerOperation = new DelayedSynchronizedOperation(ProcessLatestMeasurements, ex => OnProcessException(MessageLevel.Error, ex)); } #endregion #region [ Properties ] /// /// Gets or sets output measurements that the action adapter will produce, if any. /// [ConnectionStringParameter] [Description("Defines primary keys of output measurements the action adapter expects; can be one of a filter expression, measurement key, point tag or Guid.")] [CustomConfigurationEditor("GSF.TimeSeries.UI.WPF.dll", "GSF.TimeSeries.UI.Editors.MeasurementEditor")] public override IMeasurement[] OutputMeasurements { get => base.OutputMeasurements; set => base.OutputMeasurements = value; } /// /// Gets or sets the textual representation of the expression. /// [ConnectionStringParameter] [Description("Define the arithmetic expression used to perform calculations.")] public string ExpressionText { get => m_expressionText; set { m_expressionText = value; PerformAliasReplacement(); } } /// /// Gets or sets the list of variables used in the expression. /// [ConnectionStringParameter] [Description("Define the unique list of variables used in the expression. Any defined aliased variables must be unique per defined dynamic calculator or e-mail notifier instance. Note that \"TIME\" is reserved, this returns the current timestamp for the calculation in ticks.")] public string VariableList { get => m_variableList; set { if (m_variableList == value) return; // Store the variable list for get operations m_variableList = value; // Empty the collection of variable names m_variableNames.Clear(); m_keyMapping.Clear(); m_nonAliasedTokens.Clear(); // If the value is null, do not attempt to process it if (value is null) return; // Build the collection of variable names with the new value foreach (string token in value.Split(';')) AddVariable(token); // Perform alias replacement on tokens that were not explicitly aliased PerformAliasReplacement(); // Build the key list which will define the input measurements for this adapter string keyList = m_keyMapping.Keys.Select(key => key.ToString()) .Aggregate((runningKeyList, nextKey) => runningKeyList + ";" + nextKey); // Set the input measurements for this adapter InputMeasurementKeys = AdapterBase.ParseInputMeasurementKeys(DataSource, true, keyList); } } /// /// Gets or sets the list of types which define /// methods to be imported into the expression parser. /// [ConnectionStringParameter] [Description("Define the list of types which define methods to be imported into the expression parser.")] [DefaultValue(DefaultImports)] public string Imports { get => m_imports; set { foreach (string typeDef in value.Split(';')) { try { Dictionary parsedTypeDef = typeDef.ParseKeyValuePairs(','); string assemblyName = parsedTypeDef["assemblyName"]; string typeName = parsedTypeDef["typeName"]; Assembly assembly = Assembly.Load(new AssemblyName(assemblyName)); Type type = assembly.GetType(typeName); m_expressionContext.Imports.AddType(type); } catch (Exception ex) { string message = $"Unable to load type from assembly: {typeDef}"; OnProcessException(MessageLevel.Error, new ArgumentException(message, ex)); } } m_imports = value; } } /// /// Gets the flag indicating if this adapter supports temporal processing. /// [ConnectionStringParameter] [Description("Define the flag indicating if this adapter supports temporal processing.")] [DefaultValue(false)] public override bool SupportsTemporalProcessing => m_supportsTemporalProcessing; /// /// Gets or sets the flag indicating whether to use the latest /// received values to fill in values missing from the current frame. /// [ConnectionStringParameter] [Description("Define the flag indicating if this adapter should use latest values for missing measurements.")] [DefaultValue(true)] public bool UseLatestValues { get; set; } /// /// Gets or sets the flag indicating whether to skip processing of an output with a value of NaN. /// [ConnectionStringParameter] [Description("Define the flag indicating whether to skip processing of an output with a value of NaN.")] [DefaultValue(false)] public bool SkipNaNOutput { get; set; } /// /// Gets or sets the value used when no other value can be determined for a variable. /// [ConnectionStringParameter] [Description("Defines the value used when no other value can be determined for a variable.")] [DefaultValue(double.NaN)] public double SentinelValue { get; set; } /// /// Gets or sets the interval at which the adapter should calculate values. /// /// /// Set to zero to disable the timer and calculate values upon receipt of input data. /// [ConnectionStringParameter] [Description("Define the interval, in seconds, at which the adapter should calculate values. Zero value executes calculations at received data rate.")] [DefaultValue(0)] public double CalculationInterval { get => m_timerOperation.Delay / 1000.0D; set => m_timerOperation.Delay = (int)(value * 1000.0D); } /// /// Gets or sets the source of the timestamps of the calculated values. /// [ConnectionStringParameter] [Description("Define the source of the timestamps of the calculated values.")] [DefaultValue(TimestampSource.Frame)] public TimestampSource TimestampSource { get; set; } /// /// Gets or sets primary keys of input measurements the dynamic calculator expects. /// [EditorBrowsable(EditorBrowsableState.Never)] public override MeasurementKey[] InputMeasurementKeys { get => base.InputMeasurementKeys; set { base.InputMeasurementKeys = value; m_latestMeasurements.ClearMeasurementCache(); } } /// /// Gets flag that determines if the implementation of the requires an output measurement. /// protected virtual bool ExpectsOutputMeasurement => true; /// /// Gets defined variable collection with current values. /// protected IDictionary Variables => m_expressionContext.Variables; /// /// Returns the detailed status of the data input source. /// public override string Status { get { StringBuilder status = new StringBuilder(); status.Append(base.Status); status.AppendLine(); status.AppendLine($" Expression Text: {ExpressionText}"); status.AppendLine($" Variable List: {VariableList}"); status.AppendLine($" Use Latest Values: {UseLatestValues}"); status.AppendLine($" Calculation Interval: {(CalculationInterval == 0.0D ? "At received data rate" : $"{CalculationInterval:N3} seconds")}"); status.AppendLine($" Timestamp Source: {TimestampSource}"); status.AppendLine($" Skip NaN Values: {SkipNaNOutput}"); status.AppendLine($" Sentinel Value: {SentinelValue}"); if (ExpectsOutputMeasurement) status.AppendLine($" Last Calculated Value: {m_latestValue}"); List imports = new List(); if (!string.IsNullOrWhiteSpace(Imports)) { foreach (string typeDef in Imports.Split(';')) { Dictionary parsedTypeDef = typeDef.ParseKeyValuePairs(','); if (parsedTypeDef.TryGetValue("typeName", out string typeName)) imports.Add(typeName); } } status.AppendLine(); status.AppendLine($"Imported .NET Types ({imports.Count:N0} total):"); foreach (string import in imports) status.AppendLine($" {import.TruncateRight(70)}"); return status.ToString(); } } private new Ticks RealTime { get { switch (TimestampSource) { case TimestampSource.RealTime: return base.RealTime; case TimestampSource.LocalClock: return DateTime.UtcNow; case TimestampSource.Frame: return m_latestTimestamp; default: return m_latestTimestamp; } } } #endregion #region [ Methods ] /// /// Initializes . /// public override void Initialize() { const string ErrorMessage = "{0} is missing from Settings - Example: expressionText=x+y; variableList={{x = PPA:1; y = PPA:2}}"; Dictionary settings = Settings; base.Initialize(); if (ExpectsOutputMeasurement && OutputMeasurements?.Length != 1) throw new ArgumentException($"Exactly one output measurement must be defined. Amount defined: {OutputMeasurements?.Length ?? 0}"); // Load required parameters if (!settings.TryGetValue(nameof(VariableList), out string setting)) throw new ArgumentException(string.Format(ErrorMessage, nameof(VariableList))); VariableList = setting; if (!settings.TryGetValue(nameof(ExpressionText), out setting)) throw new ArgumentException(string.Format(ErrorMessage, nameof(ExpressionText))); ExpressionText = setting; // Load optional parameters Imports = settings.TryGetValue(nameof(Imports), out setting) ? setting : DefaultImports; m_supportsTemporalProcessing = settings.TryGetValue(nameof(SupportsTemporalProcessing), out setting) && setting.ParseBoolean(); SkipNaNOutput = settings.TryGetValue(nameof(SkipNaNOutput), out setting) && setting.ParseBoolean(); TimestampSource = settings.TryGetValue(nameof(TimestampSource), out setting) && Enum.TryParse(setting, out TimestampSource timestampSource) ? timestampSource : TimestampSource.Frame; CalculationInterval = settings.TryGetValue(nameof(CalculationInterval), out setting) ? double.Parse(setting) : 0; UseLatestValues = !settings.TryGetValue(nameof(UseLatestValues), out setting) || setting.ParseBoolean(); SentinelValue = settings.TryGetValue(nameof(SentinelValue), out setting) ? double.Parse(setting) : double.NaN; m_latestMeasurements.LagTime = LagTime; m_latestMeasurements.LeadTime = LeadTime; } /// /// Starts the or restarts it if it is already running. /// [AdapterCommand("Starts the action adapter or restarts it if it is already running.", "Administrator", "Editor")] public override void Start() { base.Start(); if (UseLatestValues && m_timerOperation.Delay > 0) m_timerOperation.RunOnceAsync(); } /// /// Publish of time-aligned collection of values that arrived within the /// concentrator's defined . /// /// of measurements with the same timestamp that arrived within that are ready for processing. /// Index of within a second ranging from zero to - 1. protected override void PublishFrame(IFrame frame, int index) { m_latestTimestamp = frame.Timestamp; if (UseLatestValues) ProcessMeasurements(frame.Measurements.Values); else Calculate(frame.Measurements); } /// /// Handler for the values calculated by the . /// /// The value calculated by the . protected virtual void HandleCalculatedValue(object value) { // Evaluate the expression and generate the measurement GenerateCalculatedMeasurement(RealTime, value as IConvertible); } private void ProcessMeasurements(IEnumerable measurements) { foreach (IMeasurement measurement in measurements) { m_latestMeasurements.UpdateMeasurementValue(measurement); if (measurement.Timestamp > m_latestTimestamp && measurement.Timestamp.UtcTimeIsValid(LagTime, LeadTime)) m_latestTimestamp = measurement.Timestamp; } if (m_timerOperation.Delay <= 0) ProcessLatestMeasurements(); } private void ProcessLatestMeasurements() { if (!Enabled) return; IDictionary measurementLookup = m_latestMeasurements .Cast() .ToDictionary(measurement => measurement.Key); Calculate(measurementLookup); if (m_timerOperation.Delay > 0) m_timerOperation.RunOnceAsync(); } private void Calculate(IDictionary measurements) { m_expressionContext.Variables.Clear(); // Set the values of variables in the expression foreach (MeasurementKey key in m_keyMapping.Keys) { string name = m_keyMapping[key]; if (measurements.TryGetValue(key, out IMeasurement measurement)) m_expressionContext.Variables[name] = measurement.AdjustedValue; else m_expressionContext.Variables[name] = SentinelValue; } // Handle special constants m_expressionContext.Variables[TimeVariable] = RealTime.Value; m_expressionContext.Variables[UtcTimeVariable] = DateTime.UtcNow; m_expressionContext.Variables[LocalTimeVariable] = DateTime.Now; m_expressionContext.Variables[SystemNameVariable] = SystemName; // Compile the expression if it has not been compiled already if (m_expression is null) m_expression = m_expressionContext.CompileDynamic(m_aliasedExpressionText); // Evaluate the expression and generate the measurement HandleCalculatedValue(m_expression.Evaluate()); } // Adds a variable to the key-variable map. private void AddVariable(string token) { // This determines whether the variable has been // explicitly aliased or not and then delegates // the work to the appropriate helper method if (token.Contains('=')) AddAliasedVariable(token); else AddNotAliasedVariable(token); } // Adds an explicitly aliased variable to the key-variable map. private void AddAliasedVariable(string token) { string[] splitToken = token.Split('='); if (splitToken.Length > 2) throw new FormatException($"Too many equals signs: {token}"); MeasurementKey key = GetKey(splitToken[1].Trim()); string alias = splitToken[0].Trim(); AddMapping(key, alias); } // Adds a variable to the key-variable map which has not been explicitly aliased. private void AddNotAliasedVariable(string token) { token = token.Trim(); m_nonAliasedTokens.Add(-token.Length, token); MeasurementKey key = GetKey(token); string alias = token.ReplaceCharacters('_', c => !char.IsLetterOrDigit(c)); // Ensure that the generated alias is unique while (m_variableNames.Contains(alias)) alias += "_"; AddMapping(key, alias); } // Adds the given mapping to the key-variable map. private void AddMapping(MeasurementKey key, string alias) { if (m_variableNames.Contains(alias)) throw new ArgumentException($"Variable name is not unique: {alias}"); foreach (string reservedName in ReservedVariableNames) { if (alias.Equals(reservedName, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException($"Variable name \"{reservedName}\" is reserved."); } m_variableNames.Add(alias); m_keyMapping.Add(key, alias); } // Performs alias replacement on tokens that were not explicitly aliased. private void PerformAliasReplacement() { StringBuilder aliasedExpressionTextBuilder = new StringBuilder(m_expressionText); foreach (string token in m_nonAliasedTokens.Values) { MeasurementKey key = GetKey(token); string alias = m_keyMapping[key]; aliasedExpressionTextBuilder.Replace(token, alias); } m_aliasedExpressionText = aliasedExpressionTextBuilder.ToString(); } // Gets a measurement key based on a token which // may be either a signal ID, measurement key or point yah. private MeasurementKey GetKey(string token) { if (Guid.TryParse(token, out Guid signalID)) return MeasurementKey.LookUpBySignalID(signalID); if (MeasurementKey.TryParse(token, out MeasurementKey key)) return key; const string measurementTable = "ActiveMeasurements"; if (!(DataSource is null) && DataSource.Tables.Contains(measurementTable)) { DataRow[] rows = DataSource.Tables[measurementTable].Select($"PointTag = '{token}'"); if (rows.Length > 0) key = MeasurementKey.LookUpOrCreate(rows[0]["SignalID"].ToNonNullString(Guid.Empty.ToString()).ConvertToType(), rows[0]["ID"].ToString()); } // If all else fails, attempt to parse the item as a measurement key if (key == default || key == MeasurementKey.Undefined) throw new InvalidOperationException($"Could not parse token \"{token}\" as a Guid, measurement key, or point tag"); return key; } // Generates a measurement with the given value and sends it into the system private void GenerateCalculatedMeasurement(long timestamp, IConvertible value) { if (value is null) throw new InvalidOperationException("Calculation must not return a type that does not convert to double."); IMeasurement calculatedMeasurement = Measurement.Clone(OutputMeasurements[0], Convert.ToDouble(value), timestamp); OnNewMeasurement(calculatedMeasurement); m_latestValue = calculatedMeasurement.AdjustedValue; } // Helper method to raise the NewMeasurements event // when only a single measurement is to be provided. private void OnNewMeasurement(IMeasurement measurement) { // Skip processing of an output with a value of NaN unless configured to process NaN outputs if (!SkipNaNOutput || !double.IsNaN(measurement.Value)) OnNewMeasurements(new[] { measurement }); } #endregion #region [ Static ] // Static Constructor static DynamicCalculator() { try { CategorizedSettingsElementCollection systemSettings = ConfigurationFile.Current.Settings["systemSettings"]; systemSettings.Add("SystemName", "", "Name of system that will be prefixed to system level tags, when defined. Value should follow tag naming conventions, e.g., no spaces and all upper case."); SystemName = systemSettings["SystemName"].Value; } catch (Exception ex) { Logger.SwallowException(ex); } if (string.IsNullOrWhiteSpace(SystemName)) SystemName = string.Empty; } // Static Properties internal static string SystemName { get; } #endregion } }