Objective

The main objective of this article is to perform step-by-step the extension of the functionality that D365FO offers for IoT and to be able to add a new scenario entirely created by us. For this, it will be necessary to make modifications at both D365FO and Azure levels.

As is known, D365FO provides us with 5 available scenarios to handle IoT devices; the intention of this article is to learn how to add a new scenario through programming.

Basic Principles

The new scenario we are going to implement responds to the need for those devices subject to constant changes in their properties and that these changes must be within certain margins to avoid their deterioration.

A clear and well-known example by most is diving. When someone dives to great depths, they cannot return to the surface suddenly, but decompression must be gradual to avoid brain damage.

Another example at a more industrial level are the temperature differences that certain materials must undergo to “transform” into others (glass tempering, or steel creation).

So, we are going to implement a scenario that measures these differences between consecutive measurements and compares them with reference values to determine if it is within the range accepted by the product.

Increase = (Current Value - Previous Value) / (Time Increase)

Scenario to Create

Following the principle mentioned above, we are going to create a scenario that allows evaluating whether the differences in connected sensors are within the parameterized threshold for that measurement. In other words, the variation of its measurement per unit of time.

We will use the product quality scenario as a base since the one we intend to create is very similar.

Elements to Modify in D365FO

Form IoTIntCoreScenarioManagement

This is the main form where sensors are configured. This modification is intended to add a new button to configure the new scenario.

It is necessary to add a new group to configure the new scenario:

Form IoTIntCoreScenarioManagement - New Group

BaseEnum IoTIntCoreScenarioType

We will also add a new enumeration value to identify our new scenario.

BaseEnum IoTIntCoreScenarioType - New Value

Form Notifications IoTIntCoreNotification

We will extend the INIT of the Datasource to add our scenario to the notifications filter:

[ExtensionOf(formDataSourceStr(IoTIntCoreNotification, IoTIntCoreNotification))]
public final class IoTIntCoreNotification_IoTIntelligenceManufacturing_Variation_Extension
{
    public void init()
    {
        next init();

        var form = this.formRun();
        if (form && form.args() &&
            form.args().menuItemName() == menuItemDisplayStr(IoTIntCoreNotification))
        {
            var range = this.queryBuildDataSource().addRange(fieldNum(IoTIntCoreNotification, Type));
            range.value(strFmt('((%1.%2 == "%3") || (%1.%2 == "%4") || (%1.%2 == "%5") || (%1.%2 == "%6"))',
                tableStr(IoTIntCoreNotification),
                fieldStr(IoTIntCoreNotification, Type),
                SysQuery::value(enum2Symbol(enumNum(IoTIntCoreScenarioType), enum2int(IoTIntCoreScenarioType::ProductQualityValidation))),
                SysQuery::value(enum2Symbol(enumNum(IoTIntCoreScenarioType), enum2int(IoTIntCoreScenarioType::MachineReportingStatus))),
                SysQuery::value(enum2Symbol(enumNum(IoTIntCoreScenarioType), enum2int(IoTIntCoreScenarioType::ProductVariation))),
                SysQuery::value(enum2Symbol(enumNum(IoTIntCoreScenarioType), enum2int(IoTIntCoreScenarioType::ProductionJobDelayed)))));
        }
    }
}

Class IoTIntMfgNotificationProductVariationDeviationTemplate

Regarding D365FO, what we want is for the new scenario to be exactly the same as the Product Quality one. The substantial change will be carried out in Azure. So let’s make a copy of the IoTIntMfgNotificationQualityAttributeDeviationTemplate class and change its references to point to our scenario as well as the labels. This class is responsible for scenario configuration and notifications that will be shown if parameterized ranges are not met.

/// <summary>
/// The <c>IoTIntMfgNotificationProductVariationDeviationTemplate</c> class contains notification template
/// for quality attribute deviation notification.
/// </summary>
[IoTIntCoreNotificationType('ProductVariation')]
public class IoTIntMfgNotificationProductVariationDeviationTemplate extends IoTIntCoreNotificationTemplate
{
    .
    .
    .   

    /// <summary>
    /// Creates missing <c>IoTIntCoreSensorScenarioMapping</c> records for each <c>IoTIntCoreSensorBusinessRecordMapping</c> records mapped to a
    /// resource.
    /// </summary>
    public void createSensorScenarioMappings()
    {
        IoTIntCoreSensorBusinessRecordMapping sensorBusinessRecordMappingResource;
        IoTIntCoreSensorBusinessRecordMapping sensorBusinessRecordMappingBatchAttribute;
        IoTIntCoreSensorScenarioMapping sensorScenarioMappingInsert;
        IoTIntCoreSensorScenarioMapping sensorScenarioMappingNotExist;

        const IoTIntCoreScenarioType ScenarioType = IoTIntCoreScenarioType::ProductQualityValidation;
        const IoTIntCoreSensorScenarioMappingActiveNoYesId NotActive = NoYes::No;

        insert_recordset sensorScenarioMappingInsert(SensorId, Scenario, Active)
            select SensorId, ScenarioType, NotActive from sensorBusinessRecordMappingResource
                where sensorBusinessRecordMappingResource.RefTableId == tableNum(wrkCtrTable)
            exists join sensorBusinessRecordMappingBatchAttribute
                where sensorBusinessRecordMappingBatchAttribute.RefTableId == tableNum(PdsBatchAttrib)
                    && sensorBusinessRecordMappingBatchAttribute.SensorId == sensorBusinessRecordMappingResource.SensorId
            notexists join sensorScenarioMappingNotExist
                where sensorScenarioMappingNotExist.SensorId == sensorBusinessRecordMappingResource.SensorId
                    && sensorScenarioMappingNotExist.Scenario == ScenarioType;
    }

    public boolean validateScenarioActivation(IoTIntCoreSensorId _sensorId)
    {
        IoTIntCoreSensorBusinessRecordMapping sensorBusinessRecordMappingResource;
        IoTIntCoreSensorBusinessRecordMapping sensorBusinessRecordMappingBatchAttribute;

        select firstonly RecId from sensorBusinessRecordMappingResource
            where sensorBusinessRecordMappingResource.SensorId == _sensorId
                && sensorBusinessRecordMappingResource.RefTableId == tableNum(wrkCtrTable)
            exists join sensorBusinessRecordMappingBatchAttribute
                where sensorBusinessRecordMappingBatchAttribute.RefTableId == tableNum(PdsBatchAttrib)
                    && sensorBusinessRecordMappingBatchAttribute.SensorId == sensorBusinessRecordMappingResource.SensorId;
     
        if (!sensorBusinessRecordMappingResource.RecId)
        {
            return checkFailed(strFmt("@IoTIntelligenceCore:ErrorMessage_SensorScenarioMappingActivationValidationFailed", 
                enum2Str(IoTIntCoreScenarioType::ProductVariation),
                _sensorId, 
                strFmt('%1,%2', tableId2PName(tableNum(WrkCtrTable)), tableId2PName(tableNum(PdsBatchAttrib)))));
        }

        return true;
    }

    public boolean validateDeleteSensorBusinessRecordMappingWithActiveScenario(IoTIntCoreSensorBusinessRecordMapping _sensorBusinessRecordMapping)
    {
        if (_sensorBusinessRecordMapping.RefTableId == tableNum(WrkCtrTable)
           || _sensorBusinessRecordMapping.RefTableId == tableNum(PdsBatchAttrib))
        {
            return checkFailed(strFmt("@IoTIntelligenceCore:ErrorMessage_SensorBusinessRecordMappingDeletionValidationFailed",
                _sensorBusinessRecordMapping.SensorId,
                tableId2PName(_sensorBusinessRecordMapping.RefTableId),
                enum2Str(IoTIntCoreScenarioType::ProductVariation)));
        }
        
        return true;
    }
    
    .
    .
    .

}

Elements to Modify in Azure

New Consumer Group in IoTHub

We create a new group to centralize IoT signals. This creation is mandatory since when we create an Azure Stream Analytics, it must point to a different consumer group.

New Consumer Group in IoTHub

New Stream Analytics Resource

We create a new Stream Analytics resource:

New Stream Analytics Resource

We go to the INPUT tab and create a new element of type IoT Hub with the following characteristics (special attention to the group we created in the previous step):

Stream Analytics INPUT - IoT Hub

This input is what comes from our sensor and will capture the values that then need to be compared.

We also need to add another REFERENCE type BLOB with the following characteristics:

This input will collect the values stored in Blob Storage about tolerances and allow the resource to make the comparisons.

In the OUTPUTS tab we create an output of type Azure Functions with the following characteristics:

And a Service Bus Queue to manage notifications if necessary to display them:

In the QUERY tab we have to modify the statements within it. This is the most complex part and where you need to pay close attention:

//Creation of IoT INPUT
CREATE TABLE IotInput(
  eventEnqueuedUtcTime datetime,
  sensorId nvarchar(max),
  value float
);

//Creation of tolerances table
CREATE TABLE SensorJobItemBatchAttributeVariationReferenceInput(
  sensorId nvarchar(max),
  jobId nvarchar(max),
  orderId nvarchar(max),
  itemNumber nvarchar(max),
  attributeName nvarchar(max),
  jobDataAreaId nvarchar(max),
  jobRegistrationStartDateTime datetime,
  jobRegistrationStopDateTime datetime,
  isJobCompleted nvarchar(max),
  maximumAttributeTolerance float,
  minimumAttributeTolerance float,
  optimalAttributeValue float
);

//Evaluation of values
WITH SensorJobItemBatchAttributeValues AS
(
  SELECT
    I.sensorId,
    I.eventEnqueuedUtcTime,
    I.value,
    R.jobId,
    R.orderId,
    R.itemNumber,
    R.attributeName,
    R.jobDataAreaId,
    R.jobRegistrationStartDateTime,
    R.jobRegistrationStopDateTime,
    R.isJobCompleted,
    R.maximumAttributeTolerance,
    R.minimumAttributeTolerance,
    R.optimalAttributeValue,
    CASE
      WHEN I.value >= R.minimumAttributeTolerance AND I.value <= R.maximumAttributeTolerance THEN 1
      ELSE 0
    END AS attributeValueInRange
  FROM IotInput I
  TIMESTAMP BY I.eventEnqueuedUtcTime
  JOIN SensorJobItemBatchAttributeVariationReferenceInput R
  ON I.sensorId = R.sensorId
  -- Only consider jobs which are in progress and signals which came after the start of the job.
  WHERE DATEDIFF(year, R.jobRegistrationStopDateTime, CAST('1900-01-01' as datetime)) = 0
  AND I.eventEnqueuedUtcTime >= R.jobRegistrationStartDateTime
),
SensorJobItemBatchAttributeValuesState AS
(
  SELECT
  *,
   /** Determine value for last signal was in range or not having same partition values as current signal.
       previousSignalValueInRange will be null if there was no previous signal */
  LAG(attributeValueInRange) OVER
    (PARTITION BY
      sensorId,
      jobId,
      orderId,
      itemNumber,
      attributeName,
      jobDataAreaId
      LIMIT DURATION(minute, 15)
    ) AS previousSignalValueInRange,
    LAG(value) OVER
    (PARTITION BY
      sensorId,
      jobId,
      orderId,
      itemNumber,
      attributeName,
      jobDataAreaId
      LIMIT DURATION(minute, 15)
    ) AS previousValue,
    CASE
      WHEN value-LAG(value) OVER
    (PARTITION BY
      sensorId,
      jobId,
      orderId,
      itemNumber,
      attributeName,
      jobDataAreaId
      LIMIT DURATION(minute, 15)
    ) >= minimumAttributeTolerance AND value-LAG(value) OVER
    (PARTITION BY
      sensorId,
      jobId,
      orderId,
      itemNumber,
      attributeName,
      jobDataAreaId
      LIMIT DURATION(minute, 15)
    ) <= maximumAttributeTolerance THEN 1
      ELSE 0
    END AS attributeValueInRange
    FROM SensorJobItemBatchAttributeValues
)

SELECT
  CONCAT('ProductVariation:', jobId, ':', attributeName) AS metricKey,
  DATEDIFF(millisecond, CAST('1970-01-01' as datetime), eventEnqueuedUtcTime) AS uts,
  value AS val
INTO MetricOutput
from SensorJobItemBatchAttributeValues

SELECT
  jobDataAreaId AS dataAreaId,
  sensorId AS machineId,
  jobId AS jobId,
  orderId AS orderId,
  itemNumber AS itemId,
  minimumAttributeTolerance AS minValue,
  maximumAttributeTolerance AS maxValue,
  optimalAttributeValue AS targetValue,
  attributeName AS batchAttribId,
  sensorId AS sensorId,
  value AS sensorReading,
  eventEnqueuedUtcTime AS timestamp,
  eventEnqueuedUtcTime AS sensorTimestamp,
  System.Timestamp AS processingTimestamp,
  CASE
    WHEN attributeValueInRange = 1 THEN 'TRUE'
    ELSE 'FALSE'
  END AS validAttributeSignal,
  'ProductVariation' AS notificationType,
  CONCAT('ProductVariation:', jobId, ':', attributeName) AS publishedMetric,
  'Product Variation' AS publishedMetricDisplayName
INTO NotificationOutput
FROM SensorJobItemBatchAttributeValuesState
-- This ensures that we are not sending the notification twice.
WHERE
(
  (
    attributeValueInRange = 0 AND
    (previousSignalValueInRange IS NULL OR previousSignalValueInRange = 1)
  )
  OR
  (
    attributeValueInRange = 1 AND
    previousSignalValueInRange = 0
  )
)

And finally and most importantly, you have to modify the Logic App and add an entire new branch that manages the new scenario. Since it’s very complex to explain each step here, I leave you a link to GitHub where the template with the added branch is uploaded. Remember that we are making a modified copy of the current product quality one.

https://github.com/iD365FOnt/D365FO_IoT_LogicApp

Once we’ve reached this point we can test our scenario and verify how the system alerts us when the signals exceed the parameterized tolerances.

To perform the tests, an IoT Emulator was created specifically to be able to play with the values and have a more user-friendly interface. I share with you the GitHub URL with the application:

https://github.com/iD365FOnt/IoTEmulator

I hope you enjoyed this article. In the next post we will see the tool in action and the different options it offers at the user level.

HAPPY HOLIDAYS!