top of page

Extendiendo el IoT


Objetivo


El objetivo principal de este artículo es la realización paso a paso de la extensión de la funcionalidad que nos ofrece D365FO cuanto a IoT y poder añadir un nuevo escenario totalmente creado por nosotros. Para ello será necesario realizar modificaciones tanto a nivel de D365FO como de Azure.

Como es sabido D365FO nos proporciona 5 escenarios disponibles para poder tratar dispositivos IoT; la intención de este artículo es aprender a añadir un nuevo escenario mediante programación.


Principios básicos


El nuevo escenario que vamos a implementar responde a la necesidad de aquellos dispositivos sometidos a cambios constantes en sus propiedades y que estos cambios deben estar dentro de unos márgenes para evitar el deterioro de éstos.

Un ejemplo claro y conocido por la gran mayoría es el buceo. Cuando alguien se sumerge a altas profundidades no puede volver a la superficie de repente sino que la despresurización debe ser gradual para no sufrir daños cerebrales.

Otro ejemplo a nivel más industrial son las diferencias de temperatura que deben sufrir ciertos materiales para que se "transformen" en otros (templado del vidrio, o la creación de acero).

Así pues, vamos a implementar un escenario que mida estas diferencias entre mediciones consecutivas y las compare con los valores de referencia para determinar si se encuentra o no dentro del rango aceptado por el producto.


Incremento = (Valor actual - valor anterior) / (Incremento tiempo)


Escenario a crear


Siguiendo el principio comentado anteriormente vamos a crear un escenario que permita evaluar si las diferencias de los sensores conectados están dentro del umbral parametrizado para aquella medida. Es decir, la variación de su medición por unidad de tiempo.

Vamos a usar como base el escenario de calidad de producto ya que el que pretendemos crear es muy parecido.


Elementos a modificar en D365FO


Formulario IoTIntCoreScenarioManagement

Se trata del formulario principal donde se configuran los sensores. Con esta modificación se pretende añadir un nuevo botón para poder configurar el nuevo escenario.

Es necesario añadir un nuevo grupo para poder configurar el nuevo escenario


BaseEnum IoTIntCoreScenarioType


Vamos a añadir también un nuevo valor de enumerado para identificar nuestro nuevo escenario.

Formulario notificaciones IoTIntCoreNotification


Vamos a extender el INIT del Datasource para añadir nuestro escenario en el filtro de notificaciones:


[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)))));
        }
    }
}

Clase IoTIntMfgNotificationProductVariationDeviationTemplate


En relación a D365FO lo que queremos es que el nuevo escenario sea exactamente igual al de Calidad de producto. El cambio sustancial se llevará a cabo en Azure. Así que vamos a realizar una copia de la clase IoTIntMfgNotificationQualityAttributeDeviationTemplate y cambiaremos sus referencias para que apunten a nuestro escenario así como las etiquetas. Esta clase es la que se encarga de la configuración del escenario y de las notificaciones que se van a ver en caso de no cumplir con los rangos parametrizados.



/// <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;
    }
    
    .
    .
    .

}

Elementos a modificar en Azure


Nuevo grupo de consumo en IoTHub


Creamos un nuevo grupo para centralizar las señales IoT. Esta creación es obligatoria ya que cuando creamos un Azure Stream Analytics debe apuntar a un grupo de consumo diferente.

Nuevo recurso de Stream Analytics


Creamos un nuevo recurso de Stream Analytics

Nos dirigimos a la pestaña INPUT y creamos un nuevo elemento de tipo IoT Hub con las siguientes características (especial atención al grupo que hemos creado en el paso anterior):

Esta entrada es la que proviene de nuestro sensor y capturará los valores que luego hay que comparar.

También hay que añadir otra de REFERENCIA tipo BLOB con las siguientes características:


Esta entrada recogerá los valores almacenados en el Blob Storage sobre las tolerancias y permitirá al recurso realizar las comparaciones.

En la pestaña de OUTPUTS creamos una salida de tipo Azure Functions con las siguientes características:


Y una Service Bus Queue para gestionar las notificaciones en caso que sea necesario mostrarlas:



En la pestaña QUERY tenemos que modificar las sentencias que hay dentro. Esta es la parte más compleja y en la que hay que prestar más atención:


//Creación del INPUT de IoT
CREATE TABLE IotInput(
  eventEnqueuedUtcTime datetime,
  sensorId nvarchar(max),
  value float
);

//Creación tabla de tolerancias
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
);

//Evaluación de valores
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
  )
)

Y por último y más importante hay que modificar la Logic App y añadir una nueva rama entera que gestione el nuevo escenario. Como es muy complejo explicar cada paso por aquí os dejo un enlace a GitHub donde está colgada la plantilla con la rama añadida. Recordad que estamos haciendo una copia modificada de la actual de calidad de producto.



Una vez hemos llegado a este punto ya podemos probar nuestro escenario y comprobar como el sistema nos avisa cuando las señales sobrepasan las tolerancias parametrizadas.


Para hacer las pruebas se ha usado un Emulador de IoT hecho a propósito para poder jugar con los valores y tener una interfaz más amigable. Os comparto la URL de GitHub con la aplicación:



Espero que os haya gustado este artículo. En la siguiente publicación veremos la herramienta en funcionamiento y las distintas opciones que nos propone a nivel de usuario.


FELIZ NAVIDAD!

bottom of page