Analyse des données de séries temporelles

Vous pouvez analyser des données de séries temporelles dans Snowflake, à l’aide d’une fonctionnalité spécialement conçue à cet effet. Les administrateurs de bases de données, les scientifiques des données et les développeurs d’applications doivent s’assurer que les séries temporelles sont stockées et chargées efficacement et, dans de nombreux cas, résumées sous une forme complète et cohérente, avant de mettre les données à la disposition des analystes commerciaux et d’autres consommateurs.

Introduction : Qu’est-ce qu’une série temporelle ?

Une série temporelle est constituée d’observations séquentielles qui rendent compte de l’évolution des systèmes, des processus et des comportements au cours d’une période donnée. Les données de séries temporelles sont collectées à partir d’un large éventail de dispositifs dans un grand nombre de secteurs. Parmi les exemples courants, on peut citer les données boursières collectées pour les applications financières, les observations météorologiques, les relevés de température collectés par des capteurs dans les usines intelligentes et les journaux des clics des utilisateurs dans la publicité numérique.

Un enregistrement unique dans une série temporelle comporte généralement les éléments suivants :

  • Une date, une heure ou un horodatage ayant un niveau de granularité cohérent (millisecondes, secondes, minutes, heures, etc.).

  • Une ou plusieurs mesures ou métriques quelconques, généralement numériques (faits susceptibles de révéler des tendances ou des anomalies dans les données).

  • Dimensions d’intérêt associées à la mesure, telles que l’emplacement d’un relevé de température ou le symbole d’une action pour une transaction donnée.

Par exemple, l’observation météorologique suivante comporte des horodatages de début et de fin, une mesure des précipitations (0.32) et des informations sur le lieu :

EVENTID | TYPE | SEVERITY | START_TIME              | END_TIME                | PRECIP | TIME_ZONE   | CITY       | COUNTY    | STATE | ZIP
W100    | Rain | Moderate | 2020-12-20 16:35:00.000 | 2020-12-20 17:15:00.000 |   0.32 | US/Eastern  | Southport  | Brunswick | NC    | 28461

Les données suivantes, collectées à partir d’un appareil industriel, ont un espace de noms (IOT), un ID de balise ou un ID de capteur (3000), un horodatage pour la lecture de la température sur l’appareil, la lecture de la température elle-même (21.1673), et un « horodatage du courtier », qui est le moment où les données sont arrivées par la suite au courtier en données. Par exemple, le courtier de données peut être un serveur Kafka qui ingère des données dans une table Snowflake.

DEVICE | LINE | DEVICE_TIMESTAMP        | TEMP     | BROKER_TIMESTAMP
IOT    | 3000 | 2023-01-01 00:01:00.000 | 21.1673  | 2023-01-01 00:01:32.000

Une série temporelle peut révéler des pics lorsque les relevés changent radicalement pour une raison ou une autre. Par exemple, l’image suivante montre une séquence de relevés de température effectués à 15 secondes d’intervalle, avec des valeurs culminant à plus de 40 °C après avoir été constamment dans la plage des 35 °C pendant la journée précédente.

Graphique linéaire montrant des relevés de température de capteurs augmentant de façon spectaculaire pendant une période donnée.

Les sections suivantes montrent comment analyser et visualiser de grands volumes de ce type de données à l’aide de fonctions SQL et de jointures qui fournissent des résultats rapides et précis.

Stockage de données de séries temporelles

Les types de données datetime suivants sont pris en charge :

  • DATE

  • TIME

  • TIMESTAMP (et variantes, y compris TIMESTAMP_TZ)

Pour obtenir des informations sur le chargement, la gestion et l’interrogation de données utilisant ces types de données, consultez Utilisation des valeurs de date et d’heure.

Un certain nombre de fonctions SQL couramment utilisées sont disponibles pour faciliter le stockage et la requête de données de séries temporelles. Par exemple, vous pouvez utiliser CONVERT_TIMEZONE pour convertir les horodatages d’un fuseau horaire à un autre et vous pouvez utiliser des fonctions telles que EXTRACT et TIMEADD pour manipuler les données temporelles selon vos besoins.

Note

Pour les données TIMESTAMP_TZ, Snowflake ne stocke actuellement que le décalage d’un fuseau horaire au moment de la création d’une valeur donnée et non le fuseau horaire réel.

Pour optimiser les performances des requêtes, les tables utilisées pour les analyses de séries temporelles sont souvent mises en cluster en fonction du temps (et parfois aussi en fonction de l’ID du capteur ou d’une dimension similaire). Voir Clés de clustering et tables en cluster.

Agrégation de données de séries temporelles

La gestion des données de séries temporelles peut nécessiter l’agrégation de grands volumes d’enregistrements fins sous une forme plus synthétique (un processus parfois appelé « sous-échantillonnage » ou « downsampling »). Si vous disposez d’un grand nombre d’enregistrements avec une granularité temporelle spécifique (millisecondes, secondes, minutes, etc.), vous pouvez regrouper ces enregistrements à une granularité plus grossière, ce qui permet d’obtenir un échantillon plus petit.

Le sous-échantillonnage est utile, car il permet de réduire la taille d’un ensemble de données et ses besoins en stockage. Un niveau de granularité plus grossier réduit également les besoins en ressources de calcul pendant l’exécution de la requête. Une autre raison clé du sous-échantillonnage est qu’un grand nombre d’enregistrements dans une série temporelle peut être redondant du point de vue de l’analyste. Par exemple, si un capteur émet une nouvelle valeur une fois par seconde, mais que cette mesure change rarement dans chaque intervalle de 60 secondes, les données peuvent être remontées au niveau de la minute pour être analysées.

Un autre cas pour le sous-échantillonnage se produit lorsque deux ensembles de données différents doivent être analysés comme un seul, mais qu’ils ont des granularités temporelles différentes. Par exemple, le capteur A d’une usine collecte des données toutes les 15 secondes, mais le capteur B collecte des données connexes toutes les 30 secondes. Dans ce cas, l’agrégation des enregistrements en compartiments d’une minute peut être une bonne solution. Les IDs et les dimensions de chaque ensemble de données sont conservés tels quels, mais les mesures numériques sont additionnées ou moyennées par un intervalle de temps commun.

Exemples de sous-échantillonnage

Vous pouvez réduire l’échantillonnage d’un ensemble de données stockées dans une table en utilisant la fonction TIME_SLICE. Cette fonction calcule les heures de début et de fin des « compartiments » de largeur fixe afin que les enregistrements individuels puissent être regroupés et résumés à l’aide de fonctions d’agrégation standard, telles que SUM et AVG.

De même, la fonction DATE_TRUNC tronque une partie d’une série de valeurs de date ou d’horodatage, réduisant ainsi leur granularité. Les sections suivantes présentent des exemples de chaque fonction.

Sous-échantillonnage à l’aide de TIME_SLICE

L’exemple suivant sous-échantillonne une table nommée sensor_data_ts, qui contient les relevés de deux capteurs d’usine et contient 5,3 millions de lignes. Ces relevés ont été ingérés par seconde, de sorte que 5,3 millions de lignes représentent seulement un mois de données, avec un peu plus de 2,5 millions de lignes par capteur. Vous pouvez utiliser la fonction TIME_SLICE pour agréger jusqu’à une seule ligne par minute, par heure ou par jour, par exemple.

Pour exécuter cet exemple, créez et chargez d’abord la table sensor_data_ts ; voir Création de la table sensor_data_ts. Voici un petit échantillon des données figurant dans la table :

+-----------+-------------------------+-------------+-----------+-----------+
| DEVICE_ID | TIMESTAMP               | TEMPERATURE | VIBRATION | MOTOR_RPM |
|-----------+-------------------------+-------------+-----------+-----------|
| DEVICE1   | 2024-03-01 00:00:00.000 |     32.6908 |    0.3158 |      1492 |
| DEVICE2   | 2024-03-01 00:00:00.000 |     35.2086 |    0.3232 |      1461 |
| DEVICE1   | 2024-03-01 00:00:01.000 |     35.9578 |    0.3302 |      1452 |
| DEVICE2   | 2024-03-01 00:00:01.000 |     26.2468 |    0.3029 |      1455 |
+-----------+-------------------------+-------------+-----------+-----------+

La table contient 60 relevés de ce type par minute pour chaque appareil, comme le montre cette requête :

SELECT device_id, count(*) FROM sensor_data_ts
  WHERE TIMESTAMP >= ('2024-03-01 00:01:00')
    AND TIMESTAMP < ('2024-03-01 00:02:00')
  GROUP BY device_id;
Copy
+-----------+----------+
| DEVICE_ID | COUNT(*) |
|-----------+----------|
| DEVICE2   |       60 |
| DEVICE1   |       60 |
+-----------+----------+

Dans cette requête de sous-échantillonnage, la fonction TIME_SLICE définit des compartiments d’une minute et renvoie l’heure de début de chaque compartiment. La fonction AVG calcule la température moyenne de chaque compartiment par appareil. La fonction COUNT(*) est incluse à titre de référence, juste pour montrer combien de lignes atterrissent dans chaque compartiment temporel.

Les colonnes vibration et motor_rpm ne sont pas incluses, mais elles pourraient être agrégées de la même manière que la colonne temperature ou en utilisant des fonctions d’agrégation différentes.

Important

Si vous exécutez cet exemple vous-même, votre sortie ne correspondra pas exactement, car la table sensor_data_ts est chargée de valeurs générées de manière aléatoire.

SELECT
    TIME_SLICE(TO_TIMESTAMP_NTZ(timestamp), 1, 'MINUTE') minute_slice,
    device_id,
    COUNT(*),
    AVG(temperature) avg_temp
  FROM sensor_data_ts
  WHERE TIMESTAMP >= ('2024-03-01 00:01:00')
    AND TIMESTAMP < ('2024-03-01 00:02:00')
  GROUP BY 1,2
  ORDER BY 1,2;
Copy
+-------------------------+-----------+----------+---------------+
| MINUTE_SLICE            | DEVICE_ID | COUNT(*) |      AVG_TEMP |
|-------------------------+-----------+----------+---------------|
| 2024-03-01 00:01:00.000 | DEVICE1   |       60 | 32.4315466667 |
| 2024-03-01 00:01:00.000 | DEVICE2   |       60 | 30.4967783333 |
+-------------------------+-----------+----------+---------------+

En utilisant la fonction TIME_SLICE, vous pouvez créer des tables agrégées plus petites à des fins d’analyse et vous pouvez appliquer le processus de sous-échantillonnage à différents niveaux (heure, jour, semaine, etc.).

Sous-échantillonnage à l’aide de DATE_TRUNC

L’exemple suivant sélectionne les données d’une table nommée order_header dans le schéma raw.pos de la base de données d’échantillon Tasty Bytes. Cette table contient 248 millions de lignes.

La table order_header possède une colonne TIMESTAMP nommée order_ts. La requête crée une série temporelle agrégée en utilisant cette colonne comme deuxième argument de la fonction DATE_TRUNC. Le premier argument spécifie un intervalle day. Cela signifie que chaque enregistrement, ayant une granularité heures/minutes/secondes, sont regroupés par jour.

La requête regroupe les enregistrements selon deux dimensions : truck_id et location_id. La colonne avg_amount indique le prix moyen par commande, par camion-restaurant, par emplacement pour chaque jour ouvrable enregistré.

La requête présentée ici limite les résultats aux 25 premières lignes pour le 1er janvier 2022. Si vous supprimez ce filtre de date et la clause LIMIT, la requête sous-échantillonne les 248 millions de lignes d’origine à environ 500 000 lignes.

SELECT DATE_TRUNC('day', order_ts)::date sliced_ts, truck_id, location_id, AVG(order_amount)::NUMBER(4,2) as avg_amount
  FROM order_header
  WHERE EXTRACT(YEAR FROM order_ts)='2022'
  GROUP BY date_trunc('day', order_ts), truck_id, location_id
  ORDER BY 1, 2, 3 LIMIT 25;
Copy
+------------+----------+-------------+------------+
| SLICED_TS  | TRUCK_ID | LOCATION_ID | AVG_AMOUNT |
|------------+----------+-------------+------------|
| 2022-01-01 |        1 |        3223 |      19.23 |
| 2022-01-01 |        1 |        3869 |      20.15 |
| 2022-01-01 |        2 |        2401 |      39.29 |
| 2022-01-01 |        2 |        4199 |      34.29 |
| 2022-01-01 |        3 |        2883 |      35.01 |
| 2022-01-01 |        3 |        2961 |      39.15 |
| 2022-01-01 |        4 |        2614 |      35.95 |
| 2022-01-01 |        4 |        2899 |      40.29 |
| 2022-01-01 |        6 |        1946 |      26.58 |
| 2022-01-01 |        6 |       14960 |      18.59 |
| 2022-01-01 |        7 |        1427 |      26.91 |
| 2022-01-01 |        7 |        3224 |      28.88 |
| 2022-01-01 |        9 |        1557 |      35.52 |
| 2022-01-01 |        9 |        2612 |      43.80 |
| 2022-01-01 |       10 |        2217 |      32.35 |
| 2022-01-01 |       10 |        2694 |      32.23 |
| 2022-01-01 |       11 |        2656 |      44.23 |
| 2022-01-01 |       11 |        3327 |      52.00 |
| 2022-01-01 |       12 |        3181 |      52.84 |
| 2022-01-01 |       12 |        3622 |      49.59 |
| 2022-01-01 |       13 |        2516 |      31.13 |
| 2022-01-01 |       13 |        3876 |      28.13 |
| 2022-01-01 |       14 |        1359 |      72.04 |
| 2022-01-01 |       14 |        2505 |      68.75 |
| 2022-01-01 |       15 |        2901 |      41.90 |
+------------+----------+-------------+------------+

Utilisation d’agrégations fenêtrées pour les calculs glissants

En utilisant les fonctions d’agrégation fenêtrées pour observer l’évolution d’une métrique dans le temps, vous pouvez analyser les tendances d’une série temporelle. Les agrégations fenêtrées sont utiles pour analyser des données dans des sous-ensembles définis (« fenêtres ») d’un ensemble de données plus large. Vous pouvez effectuer des calculs glissants (comme des moyennes et des sommes mobiles) pour chaque ligne d’un ensemble de données, en tenant compte d’un groupe de lignes précédant, suivant ou entourant la ligne actuelle. Ce type d’analyse contraste avec les agrégations régulières qui résument l’ensemble des données.

En utilisant des cadres de fenêtre basés sur des plages avec des décalages explicites, vous pouvez appliquer une approche très flexible au calcul de ces agrégations glissantes. Le cadre de fenêtre RANGE BETWEEN, classé par horodatages ou par numéros, n’est pas perturbé par les écarts qui peuvent se produire dans les données de séries temporelles. Par exemple, dans l’illustration suivante, le fait que les données du Day 4 manquent dans la série d’enregistrements n’affecte pas le calcul des fonctions d’agrégation sur une fenêtre mobile de trois jours. En particulier, les cadres 3, 4 et 5 sont calculés correctement, en tenant compte du fait que les données du Day 4 sont inconnues.

Graphique illustrant un cadre de fenêtre mobile sur sept jours avec un enregistrement manquant pour le jour 4.

L’exemple suivant calcule une somme mobile sur des données météorologiques qui enregistrent les relevés de précipitations par heure dans différentes villes et différents comtés. Vous pouvez exécuter ce type de requête pour évaluer les tendances dans divers ensembles de données de séries chronologiques, comme des capteurs et autres dispositifs IoT, notamment lorsque ces ensembles de données sont connus pour comporter des écarts ou sont susceptibles d’en comporter.

La fonction de fenêtre inclut dans son cadre le relevé actuel des précipitations et tous les relevés qui se situent dans l’intervalle de temps spécifié avant le relevé actuel. Le calcul glissant est basé sur cette plage flexible et logique de lignes plutôt que sur un nombre exact de lignes. La première ligne de chaque ville a des valeurs precip et moving_sum_precip correspondantes. Après cela, la somme est recalculée pour chaque ligne suivante dans le cadre. Les valeurs brutes fluctuent de manière significative, mais les sommes mobiles ont un fort effet de lissage.

Pour exécuter cet exemple, suivez d’abord ces instructions : Créer et charger la table heavy_weather. Cette toute petite table contient des observations météorologiques par heure sporadiques, avec de nombreux écarts, y compris un jour manquant. La requête renvoie la somme mobile des valeurs de précipitations triées en fonction de la colonne start_time. Le cadre de fenêtre définit une plage comprise entre 12 heures avant la ligne actuelle et la ligne actuelle. Par conséquent, le cadre se compose de la ligne actuelle et uniquement des lignes dont les horodatages sont antérieurs de 12 heures maximum à l’horodatage ORDER BY de la ligne actuelle.

SELECT city, start_time, precip,
    SUM(precip) OVER(
      PARTITION BY city
      ORDER BY start_time
      RANGE BETWEEN INTERVAL '12 hours' PRECEDING AND CURRENT ROW) moving_sum_precip
  FROM heavy_weather
  WHERE city IN('South Lake Tahoe','Big Bear City')
  GROUP BY city, precip, start_time
  ORDER BY city;
Copy
+------------------+-------------------------+--------+-------------------+
| CITY             | START_TIME              | PRECIP | MOVING_SUM_PRECIP |
|------------------+-------------------------+--------+-------------------|
| Big Bear City    | 2021-12-24 05:35:00.000 |   0.42 |              0.42 |
| Big Bear City    | 2021-12-24 16:55:00.000 |   0.09 |              0.51 |
| Big Bear City    | 2021-12-26 09:55:00.000 |   0.07 |              0.07 |
| South Lake Tahoe | 2021-12-23 16:23:00.000 |   0.56 |              0.56 |
| South Lake Tahoe | 2021-12-23 17:24:00.000 |   0.38 |              0.94 |
| South Lake Tahoe | 2021-12-23 18:30:00.000 |   0.28 |              1.22 |
| South Lake Tahoe | 2021-12-23 19:36:00.000 |   0.80 |              2.02 |
| South Lake Tahoe | 2021-12-24 06:49:00.000 |   0.17 |              0.97 |
| South Lake Tahoe | 2021-12-24 15:53:00.000 |   0.07 |              0.24 |
| South Lake Tahoe | 2021-12-26 05:43:00.000 |   0.16 |              0.16 |
| South Lake Tahoe | 2021-12-27 14:53:00.000 |   0.07 |              0.07 |
| South Lake Tahoe | 2021-12-27 17:53:00.000 |   0.07 |              0.14 |
+------------------+-------------------------+--------+-------------------+

Les trois valeurs moving_sum_precip pour Big Bear City sont calculées comme suit :

  • 0,42 = 0,42 (pas de lignes précédentes)

  • 0,42 + 0,09 = 0,51 (les deux premières lignes se situent dans la fenêtre de 12 heures)

  • 0,07 = 0,07 (aucune ligne précédente ne se trouve dans la fenêtre de 12 heures)

Les lignes relatives à South Lake Tahoe incluent ces calculs, par exemple :

  • 0,56 + 0,38 + 0,28 + 0,80 = 2,02 (les quatre lignes du 23/12/2024 sont à 12 heures d’intervalle)

  • 0,80 + 0,17 = 0,97 (une ligne précédente se trouve dans la fenêtre de 12 heures)

D’autres fonctions de fenêtre telles que les fonctions de classement LEAD et LAG sont également couramment utilisées dans l’analyse des séries temporelles. Utilisez la fonction de fenêtre LEAD pour trouver le point de données suivant dans la série temporelle, par rapport au point de données actuel et la fonction LAG pour trouver le point de données précédent.

Visualisation des résultats de la requête dans Snowsight

Vous pouvez utiliser Snowsight pour visualiser les résultats des requêtes d’agrégation et obtenir une meilleure idée de l’effet de lissage des calculs avec des fenêtres glissantes. Dans la feuille de calcul de la requête, cliquez sur le bouton Chart à côté de Results.

Par exemple, la ligne jaune du diagramme à barres suivant montre une tendance beaucoup plus douce pour la température moyenne que la ligne bleue pour la température brute. La requête elle-même ressemble à ceci :

SELECT device_id, timestamp, temperature, AVG(temperature)
  OVER (PARTITION BY device_id ORDER BY timestamp
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS avg_temp
FROM sensor_data_ts
WHERE timestamp BETWEEN '2024-03-15 00:00:59.000' AND '2024-03-15 00:01:10.000'
ORDER BY 1, 2;
Copy
Graphique linéaire qui présente une ligne plus irrégulière dans le temps pour la température et une ligne plus lisse pour la température moyenne.

Utilisation des fonctions d’agrégation MIN_BY et MAX_BY

La possibilité de sélectionner une colonne en fonction de la valeur minimale ou maximale d’une autre colonne de la même ligne est une exigence courante pour les développeurs SQL qui travaillent avec des données de séries temporelles. MIN_BY et MAX_BY sont des fonctions de commodité qui renvoient les valeurs de début et de fin (ou la plus haute et la plus basse, ou la première et la dernière) d’une table lorsque les données sont triées en fonction d’une autre colonne, telle qu’un horodatage.

Le premier exemple permet simplement de trouver la dernière valeur (la plus récente) de precip dans l’ensemble de la table. La fonction MAX_BY trie toutes les lignes en fonction de leur valeur start_time, puis renvoie la valeur precip pour l’heure de début « max ».

Pour créer et charger la table utilisée dans les exemples suivants, voir Création de la table heavy_weather.

SELECT MAX_BY(precip, start_time) most_recent_precip
  FROM heavy_weather;
Copy
+--------------------+
| MOST_RECENT_PRECIP |
|--------------------|
|               0.07 |
+--------------------+

Vous pouvez vérifier ce résultat (et obtenir plus d’informations à ce sujet) en exécutant cette requête :

SELECT * FROM heavy_weather WHERE start_time=
  (SELECT MAX(start_time) FROM heavy_weather);
Copy
+-------------------------+--------+-------+-------------+
| START_TIME              | PRECIP | CITY  | COUNTY      |
|-------------------------+--------+-------+-------------|
| 2021-12-30 20:53:00.000 |   0.07 | Lebec | Los Angeles |
+-------------------------+--------+-------+-------------+

Vous pouvez ajouter une clause GROUP BY pour poser des questions plus intéressantes sur ces données. Par exemple, la requête suivante permet de trouver la dernière valeur de précipitations observée pour chaque ville de Californie, classée par ordre de valeurs precip (de la plus élevée à la plus faible). Les résultats sont regroupés par city afin d’obtenir la dernière valeur de precip pour chaque ville différente.

SELECT city, MAX_BY(precip, start_time) most_recent_precip
  FROM heavy_weather
  GROUP BY city
  ORDER BY 2 DESC;
Copy
+------------------+--------------------+
| CITY             | MOST_RECENT_PRECIP |
|------------------+--------------------|
| Alta             |               0.89 |
| Bishop           |               0.75 |
| Mammoth Lakes    |               0.37 |
| Alturas          |               0.23 |
| Mount Shasta     |               0.09 |
| South Lake Tahoe |               0.07 |
| Big Bear City    |               0.07 |
| Montague         |               0.07 |
| Lebec            |               0.07 |
+------------------+--------------------+

La dernière fois qu’une observation a été faite pour la ville d’Alta, la valeur de precip était 0.89, et la dernière fois qu’une observation a été faite pour les villes de South Lake Tahoe, Big Bear City, Montague et Lebec, la valeur de precip était 0.07 pour les quatre localités. (Notez que la requête ne vous indique pas quand ces observations ont été faites)

Vous pouvez renvoyer l’ensemble de résultats « opposé » (l’enregistrement le plus ancien de precip par rapport au plus récent) en utilisant la fonction MIN_BY.

SELECT city, MIN_BY(precip, start_time) oldest_precip
  FROM heavy_weather
  GROUP BY city
  ORDER BY 2 DESC;
Copy
+------------------+---------------+
| CITY             | OLDEST_PRECIP |
|------------------+---------------|
| South Lake Tahoe |          0.56 |
| Big Bear City    |          0.42 |
| Mammoth Lakes    |          0.37 |
| Alta             |          0.25 |
| Alturas          |          0.23 |
| Bishop           |          0.08 |
| Lebec            |          0.08 |
| Mount Shasta     |          0.08 |
| Montague         |          0.07 |
+------------------+---------------+

Jointure de données de séries temporelles

Vous pouvez utiliser la construction ASOF JOIN pour joindre des tables contenant des données de série chronologique. Bien que les requêtes ASOF JOIN puissent être émulées par l’utilisation de SQL complexe, d’autres types de jointures et de fonctions de fenêtre, ces requêtes sont plus faciles à écrire (et sont optimisées) si vous utilisez la syntaxe ASOF JOIN.

L’analyse des données de transactions financières est une utilisation courante des jointures ASOF. L’analyse des coûts de transaction, par exemple, nécessite des calculs de « glissement », qui mesurent la différence entre le prix coté au moment de la décision d’acheter des actions et le prix effectivement payé lorsque la transaction a été exécutée et enregistrée. La syntaxe ASOF JOIN peut accélérer ce type d’analyse. Étant donné que la capacité clé de cette méthode de jointure est l’analyse d’une série temporelle par rapport à une autre, ASOF JOIN peut être utile pour l’analyse de tout ensemble de données de nature historique. Dans bon nombre de ces cas d’utilisation, la syntaxe ASOF JOIN peut être utilisée pour associer des données lorsque les relevés de différents appareils ont des horodatages qui ne sont pas exactement les mêmes.

On suppose que les données de série chronologique que vous devez analyser existent dans deux tables, et qu’il existe un horodatage pour chaque ligne de chaque table. Cet horodatage représente la date et l’heure « à compter de » précises d’un événement enregistré. Pour chaque ligne de la première table (ou table de gauche), la jointure utilise une « condition de correspondance » avec un opérateur de comparaison que vous spécifiez pour trouver une seule ligne dans la deuxième table (ou table de droite) où la valeur de l’horodatage est l’une des suivantes :

  • Inférieure ou égale à la valeur d’horodatage dans la table de gauche.

  • Supérieure ou égale à la valeur d’horodatage dans la table de gauche.

  • Inférieure à la valeur d’horodatage dans la table de gauche.

  • Supérieure à la valeur d’horodatage dans la table de gauche.

La ligne éligible située à droite est la correspondance la plus proche, qui peut être égale dans le temps, antérieure dans le temps ou postérieure dans le temps, selon l’opérateur de comparaison spécifié.

La cardinalité du résultat de ASOF JOIN est toujours égale à la cardinalité de la table de gauche. Si la table de gauche contient 40 millions de lignes, ASOF JOIN renvoie 40 millions de lignes. Par conséquent, la table de gauche peut être considérée comme la table « préservante » et la table de droite comme la table « référencée ».

Jointure de deux tables sur la correspondance la plus proche (alignement)

Par exemple, dans une application financière, vous pouvez avoir une table nommée quotes et une table nommée trades. Une table enregistre l’historique des offres d’achat d’actions et l’autre l’historique des transactions effectives. Une offre d’achat d’actions a lieu avant la transaction (ou éventuellement au « même » moment, selon la granularité du temps enregistré). Les deux tables contiennent des horodatages et d’autres colonnes intéressantes que vous souhaiterez peut-être comparer. Une simple requête ASOF JOIN renverra la valeur cotée la plus proche (dans le temps) avant chaque transaction. En d’autres termes, la requête pose la question suivante : « Quel était le prix d’une action donnée au moment où j’ai effectué une transaction ? ».

Supposons que la table trades contienne trois lignes et que la table quotes en contienne sept. La couleur d’arrière-plan des cellules indique les trois lignes de quotes qui seront éligibles à ASOF JOIN lorsque les lignes seront jointes sur des symboles boursiers correspondants et que leurs colonnes d’horodatage seront comparées.

Table TRADES (table gauche ou « préservante »)

Données de la table de transactions, composées de trois lignes, qui sont jointes à trois lignes de la table de valeurs cotées.

Table QUOTES (table droite ou « référencée »)

Données de la table de valeurs cotées, composées de sept lignes, identifiant les trois lignes spécifiques qui sont éligibles à la jointure avec la table de valeurs cotées.

Cet exemple conceptuel est facile à transformer en une requête ASOF JOIN spécifique :

SELECT t.stock_symbol, t.trade_time, t.quantity, q.quote_time, q.price
  FROM trades t ASOF JOIN quotes q
    MATCH_CONDITION(t.trade_time >= quote_time)
    ON t.stock_symbol=q.stock_symbol
  ORDER BY t.stock_symbol;
Copy
+--------------+-------------------------+----------+-------------------------+--------------+
| STOCK_SYMBOL | TRADE_TIME              | QUANTITY | QUOTE_TIME              |        PRICE |
|--------------+-------------------------+----------+-------------------------+--------------|
| AAPL         | 2023-10-01 09:00:05.000 |     2000 | 2023-10-01 09:00:03.000 | 139.00000000 |
| SNOW         | 2023-10-01 09:00:05.000 |     1000 | 2023-10-01 09:00:02.000 | 163.00000000 |
| SNOW         | 2023-10-01 09:00:10.000 |     1500 | 2023-10-01 09:00:08.000 | 165.00000000 |
+--------------+-------------------------+----------+-------------------------+--------------+

La condition ON regroupe les lignes correspondantes en fonction de leurs symboles boursiers.

Pour exécuter cet exemple, créez et chargez les tables comme suit :

CREATE OR REPLACE TABLE trades (
  stock_symbol VARCHAR(4),
  trade_time TIMESTAMP_NTZ(9),
  quantity NUMBER(38,0)
  );

CREATE OR REPLACE TABLE quotes (
  stock_symbol VARCHAR(4),
  quote_time TIMESTAMP_NTZ(9),
  price NUMBER(12,8)
  );

INSERT INTO trades VALUES
  ('SNOW','2023-10-01 09:00:05.000', 1000),
  ('AAPL','2023-10-01 09:00:05.000', 2000),
  ('SNOW','2023-10-01 09:00:10.000', 1500);

INSERT INTO quotes VALUES
  ('SNOW','2023-10-01 09:00:01.000', 166.00),
  ('SNOW','2023-10-01 09:00:02.000', 163.00),
  ('SNOW','2023-10-01 09:00:07.000', 166.00),
  ('SNOW','2023-10-01 09:00:08.000', 165.00),
  ('AAPL','2023-10-01 09:00:03.000', 139.00),
  ('AAPL','2023-10-01 09:00:07.000', 142.00),
  ('AAPL','2023-10-01 09:00:11.000', 142.00);
Copy

Pour d’autres exemples de requêtes ASOF JOIN, voir Exemples.

Combler les écarts dans les données avec ASOF JOIN

Outre l’alignement des données dans deux tables par le biais de correspondances non exactes sur des colonnes temporelles, ASOF JOIN est utile pour combler les écarts d’une série temporelle lorsque votre table de données brutes ne contient pas de lignes pour des dates ou des horodatages particuliers. Ce processus est appelé « comblement d’écart » ou « interpolation » Lorsque des lignes sont manquantes parce qu’un équipement défectueux ou une panne de courant entraîne l’omission de relevés de capteurs, vous pouvez utiliser ASOF JOIN pour interpoler les valeurs d’une série temporelle générée dans la table. Les lignes manquantes sont complétées par la dernière valeur connue pour les relevés manquants. Cette valeur est également appelée « dernière observation reportée » (LOCF). La requête ASOF JOIN renvoie un ensemble complet de lignes qui sont dans l’ordre chronologique et contiguës.

Pour utiliser ASOF JOIN pour l’interpolation, procédez comme suit :

  1. Identifiez les écarts de votre table en exécutant une requête simple.

  2. Générez une série temporelle complète, avec le grain approprié, pour la période que vous devez couvrir. Par exemple, votre série temporelle peut être une simple séquence de dates pour une année donnée ou une séquence beaucoup plus granulaire d’horodatages par seconde pour un certain nombre de jours. Vous pouvez utiliser SQL ou un tableur pour générer la liste des valeurs.

    La série temporelle aura également besoin d’un ID significatif ou d’une dimension significative pour chaque ligne, que vous spécifierez plus tard dans la condition ASOF JOIN ON.

  3. Écrivez une requête ASOF JOIN qui interpole les valeurs dans les lignes manquantes. La série temporelle générée sera la table de conservation et la table de données brutes sera la table de référence.

L’exemple suivant requiert la table sensor_data_ts. Si vous ne l’avez pas encore créée et chargée, consultez Création de la table sensor_data_ts. Pour simuler la nécessité d’une opération de comblement des écarts, supprimez quelques lignes de la table comme suit :

DELETE FROM sensor_data_ts
  WHERE device_id='DEVICE2'
    AND TIMESTAMP > ('2024-03-07 00:01:15')
    AND TIMESTAMP <= ('2024-03-07 00:01:20');
Copy

Le résultat est une table à laquelle il manque cinq lignes pour DEVICE2 le 7 mars (1:16 à 1:20).

+------------------------+
| number of rows deleted |
|------------------------|
|                      5 |
+------------------------+

Suivez maintenant les étapes suivantes pour compléter l’exercice de comblement des écarts.

Note

Si vous exécutez cet exemple vous-même, votre sortie ne correspondra pas exactement, car la table sensor_data_ts est chargée de valeurs générées de manière aléatoire.

Étape 1 : vérifier que la table présente des écarts

Exécutez la requête suivante pour identifier les écarts :

SELECT * FROM sensor_data_ts
  WHERE device_id='DEVICE2'
  AND TIMESTAMP >= ('2024-03-07 00:01:15')
  AND TIMESTAMP <= ('2024-03-07 00:01:21')
ORDER BY TIMESTAMP;
Copy
+-----------+-------------------------+-------------+-----------+-----------+
| DEVICE_ID | TIMESTAMP               | TEMPERATURE | VIBRATION | MOTOR_RPM |
|-----------+-------------------------+-------------+-----------+-----------|
| DEVICE2   | 2024-03-07 00:01:15.000 |     30.1088 |    0.2960 |      1457 |
| DEVICE2   | 2024-03-07 00:01:21.000 |     28.0426 |    0.2944 |      1448 |
+-----------+-------------------------+-------------+-----------+-----------+

Cette requête renvoie deux lignes pour DEVICE2 : la dernière ligne avant l’écart et la première ligne après l’écart.

Étape 2 : générer une série temporelle complète pour couvrir les écarts connus

Pour générer une série temporelle avec un grain fin (une ligne par seconde) pour l’écart dans la table sensor_data_ts , créez la table suivante, qui contient les horodatages générés :

CREATE OR REPLACE TABLE continuous_timestamps AS
  SELECT 'DEVICE2' as DEVICE_ID,
    DATEADD('SECOND', ROW_NUMBER() OVER (ORDER BY SEQ8()), '2024-03-07 00:01:15')::TIMESTAMP_NTZ AS TS
  FROM TABLE(GENERATOR(ROWCOUNT => 5));
Copy

Dans cette instruction SQL, 5 est le nombre de secondes dont vous avez besoin pour couvrir l’écart. Notez que la valeur de l’ID de l’appareil (DEVICE2) est incluse dans les lignes générées.

La requête suivante renvoie les cinq lignes générées.

SELECT * FROM continuous_timestamps ORDER BY ts;
Copy
+-----------+-------------------------+
| DEVICE_ID | TS                      |
|-----------+-------------------------|
| DEVICE2   | 2024-03-07 00:01:16.000 |
| DEVICE2   | 2024-03-07 00:01:17.000 |
| DEVICE2   | 2024-03-07 00:01:18.000 |
| DEVICE2   | 2024-03-07 00:01:19.000 |
| DEVICE2   | 2024-03-07 00:01:20.000 |
+-----------+-------------------------+

Étape 3 : interpoler les valeurs à l’aide de ASOF JOIN

Vous pouvez maintenant exécuter une requête ASOF JOIN qui joint continuous_timestamps à sensor_data_ts et interpole les valeurs des lignes manquantes pour DEVICE2. La condition de correspondance permet de trouver la ligne la plus proche dans le temps pour chaque ligne manquante et la condition ON garantit que l’interpolation a lieu sur les IDs des appareils correspondants.

La ligne la plus proche des lignes manquantes est la ligne avec l’horodatage 2024-03-07 00:01:16.000 en supposant que >= soit spécifié dans la condition de correspondance, comme indiqué dans cet exemple.

INSERT INTO sensor_data_ts(device_id, timestamp, temperature, vibration, motor_rpm)
  SELECT t.device_id, t.ts, s.temperature, s.vibration, s.motor_rpm
    FROM continuous_timestamps t
      ASOF JOIN sensor_data_ts s
        MATCH_CONDITION(t.ts >= s.timestamp)
        ON t.device_id = s.device_id
    WHERE TIMESTAMP >= ('2024-03-07 00:01:15')
      AND TIMESTAMP < ('2024-03-07 00:01:21');
Copy

Cette instruction INSERT sélectionne cinq lignes de l’opération ASOF JOIN et les insère dans la table sensor_data_ts.

+-------------------------+
| number of rows inserted |
|-------------------------|
|                       5 |
+-------------------------+

Pour vérifier les résultats de l’interpolation, sélectionnez ces cinq lignes, ainsi que les deux lignes qui les précèdent et les suivent directement, dans la table sensor_data_ts. Notez que les cinq lignes interpolées ont repris les mêmes valeurs pour les colonnes temperature, vibration, et motor_rpm que celles enregistrées dans la ligne 2024-03-07 00:01:15.000. L’interpolation a réussi.

SELECT * FROM sensor_data_ts
  WHERE device_id='DEVICE2'
    AND TIMESTAMP >= ('2024-03-07 00:01:15')
    AND TIMESTAMP <= ('2024-03-07 00:01:21')
  ORDER BY TIMESTAMP;
Copy
+-----------+-------------------------+-------------+-----------+-----------+
| DEVICE_ID | TIMESTAMP               | TEMPERATURE | VIBRATION | MOTOR_RPM |
|-----------+-------------------------+-------------+-----------+-----------|
| DEVICE2   | 2024-03-07 00:01:15.000 |     30.1088 |    0.2960 |      1457 |
| DEVICE2   | 2024-03-07 00:01:16.000 |     30.1088 |    0.2960 |      1457 |
| DEVICE2   | 2024-03-07 00:01:17.000 |     30.1088 |    0.2960 |      1457 |
| DEVICE2   | 2024-03-07 00:01:18.000 |     30.1088 |    0.2960 |      1457 |
| DEVICE2   | 2024-03-07 00:01:19.000 |     30.1088 |    0.2960 |      1457 |
| DEVICE2   | 2024-03-07 00:01:20.000 |     30.1088 |    0.2960 |      1457 |
| DEVICE2   | 2024-03-07 00:01:21.000 |     28.0426 |    0.2944 |      1448 |
+-----------+-------------------------+-------------+-----------+-----------+

Application de fonctions ML à des données de séries temporelles

Vous pouvez former un modèle avec des fonctions ML pour effectuer une analyse prédictive sur des données de séries temporelles :

La prévision utilise des données historiques de séries temporelles pour faire des prédictions sur les données futures. Étant donné une série temporelle enregistrée avec des valeurs réelles observées pour des dates et des heures passées, le modèle ML prévoit ce que les valeurs observées pourraient être pour des dates et des heures futures.

La détection des anomalies permet d’identifier les valeurs aberrantes, c’est-à-dire les points de données qui s’écartent d’une fourchette attendue. Dans le contexte d’une série temporelle, une valeur aberrante est une mesure qui est beaucoup plus grande ou plus petite que d’autres mesures dans un intervalle de temps similaire. Pour trouver les valeurs aberrantes, la fonction ML produit une prévision pour la même période que celle qui fait l’objet de la recherche d’anomalies, puis compare les résultats de la prévision aux données réelles.

Top Insights détermine les dimensions les plus importantes d’un ensemble de données, construit des segments à partir de ces dimensions et détecte lesquels de ces segments ont influencé une métrique.

Note

À des fins de machine-learning, les horodatages de vos séries temporelles doivent représenter des intervalles de temps fixes. Si nécessaire, vous pouvez utiliser la fonction DATE_TRUNC ou TIME_SLICE sur les colonnes TIMESTAMP pour supprimer les irrégularités lors de l’entraînement du modèle de prévision.

Exemple de détection d’anomalies dans une série temporelle

L’exemple suivant utilise une vue ne comportant que 30 lignes pour entraîner un modèle de détection d’anomalies. Commencez par générer des données dans une table, puis créez une vue sur cette table. La vue n’est pas obligatoire (vous pouvez utiliser une table pour entraîner un modèle), mais l’option de vue vous donne une certaine flexibilité pour entraîner des modèles de manière itérative, avec différents nombres de lignes, sans mettre à jour les données sources.

Note

Si vous exécutez cet exemple vous-même, votre sortie ne correspondra pas exactement, car la table sensor_data_30_rows est chargée de valeurs générées de manière aléatoire.

CREATE OR REPLACE TABLE sensor_data_30_rows (
  device_id VARCHAR(10),
  timestamp TIMESTAMP,
  temperature DECIMAL(6,4),
  vibration DECIMAL(6,4),
  motor_rpm INT);

INSERT INTO sensor_data_30_rows (device_id, timestamp, temperature, vibration, motor_rpm)
  SELECT 'DEVICE3', timestamp,
    UNIFORM(30.2345, 36.3456, RANDOM()), --
    UNIFORM(0.4000, 0.4718, RANDOM()), --
    UNIFORM(1510, 1625, RANDOM()) --
  FROM (
    SELECT DATEADD(SECOND, SEQ4(), '2024-03-01') AS timestamp
      FROM TABLE(GENERATOR(ROWCOUNT => 30))
  );

CREATE OR REPLACE VIEW sensor_data_view AS SELECT * FROM sensor_data_30_rows;
Copy

Créez maintenant le modèle :

CREATE OR REPLACE SNOWFLAKE.ML.ANOMALY_DETECTION sensor_model(
  INPUT_DATA => SYSTEM$REFERENCE('VIEW', 'sensor_data_view'),
  TIMESTAMP_COLNAME => 'timestamp',
  TARGET_COLNAME => 'temperature',
  LABEL_COLNAME => '');
Copy
+---------------------------------------------+
| status                                      |
|---------------------------------------------|
| Instance SENSOR_MODEL successfully created. |
+---------------------------------------------+

Lorsque le modèle a été construit avec succès, appelez la méthode <nom_du_modèle>!DETECT_ANOMALIES pour détecter les valeurs aberrantes dans l’ensemble de données de test spécifié. Les horodatages des données de test doivent suivre chronologiquement les horodatages des données d’entraînement, mais il ne doit pas y avoir d’écart trop important entre les données d’entraînement et les données de test. Par exemple, si vous disposez d’horodatages pour chaque seconde, n’utilisez pas de données de test qui ont des millions de secondes d’avance sur les données d’entraînement.

Cet exemple utilise une autre table comme données de test, avec seulement trois lignes. Ces lignes ont des horodatages qui suivent de près ceux des données d’entraînement.

CREATE OR REPLACE TABLE sensor_data_device3 (
  device_id VARCHAR(10),
  timestamp TIMESTAMP,
  temperature DECIMAL(6,4),
  vibration DECIMAL(6,4),
  motor_rpm INT);

INSERT INTO sensor_data_device3 VALUES
  ('DEVICE3','2024-03-01 00:00:30.000',36.0422,0.4226,1560),
  ('DEVICE3','2024-03-01 00:00:31.000',36.1519,0.4341,1515),
  ('DEVICE3','2024-03-01 00:00:32.000',36.1524,0.4321,1591);

CALL sensor_model!DETECT_ANOMALIES(
  INPUT_DATA => SYSTEM$REFERENCE('TABLE', 'sensor_data_device3'),
  TIMESTAMP_COLNAME => 'timestamp',
  TARGET_COLNAME => 'temperature'
);
Copy

Lorsque l’appel de détection d’anomalie se termine, il renvoie une sortie similaire à celui qui suit :

+-------------------------+---------+--------------+--------------+--------------+------------+--------------+-------------+
| TS                      |       Y |     FORECAST |  LOWER_BOUND |  UPPER_BOUND | IS_ANOMALY |   PERCENTILE |    DISTANCE |
|-------------------------+---------+--------------+--------------+--------------+------------+--------------+-------------|
| 2024-03-01 00:00:30.000 | 36.0422 | 30.809998241 | 25.583156942 | 36.036839539 | True       | 0.9950380683 | 2.578470982 |
| 2024-03-01 00:00:31.000 | 36.1519 | 32.559470456 | 27.332629158 | 37.786311755 | False      | 0.961667911  | 1.770378085 |
| 2024-03-01 00:00:32.000 | 36.1524 | 32.205610776 | 26.978769478 | 37.432452075 | False      | 0.9741130751 | 1.945009377 |
+-------------------------+---------+--------------+--------------+--------------+------------+--------------+-------------+

Les colonnes TS et Y renvoient les horodatages et les valeurs de température des données de test. Dans ce très petit cas, la fonction a détecté une anomalie (IS_ANOMALY=True). Pour plus d’informations sur les colonnes de sortie, voir la section « Renvois » dans la description de la fonction.

Création de la table sensor_data_ts

Si vous souhaitez tester les exemples de cette section qui interrogent la table sensor_data_ts , vous pouvez créer et charger une copie de cette table en exécutant le script SQL suivant. Le script génère un mois de données synthétiques pour les relevés des capteurs en appelant les fonctions UNIFORM, RANDOM, et GENERATOR ; par conséquent, votre copie de la table ne renverra pas des résultats identiques. Les relevés se situent dans la même fourchette, mais ils ne sont pas identiques.

 CREATE OR REPLACE TABLE sensor_data_device1 (
   device_id VARCHAR(10),
   timestamp TIMESTAMP,
   temperature DECIMAL(6,4),
   vibration DECIMAL(6,4),
   motor_rpm INT
 );

 INSERT INTO sensor_data_device1 (device_id, timestamp, temperature, vibration, motor_rpm)
   SELECT 'DEVICE1', timestamp,
     UNIFORM(25.1111, 40.2222, RANDOM()), -- Temperature range in °C
     UNIFORM(0.2985, 0.3412, RANDOM()), -- Vibration range in mm/s
     UNIFORM(1400, 1495, RANDOM()) -- Motor RPM range
   FROM (
     SELECT DATEADD(SECOND, SEQ4(), '2024-03-01') AS timestamp
       FROM TABLE(GENERATOR(ROWCOUNT => 2678400)) -- seconds in 31 days
 );

CREATE OR REPLACE TABLE sensor_data_device2 (
   device_id VARCHAR(10),
   timestamp TIMESTAMP,
   temperature DECIMAL(6,4),
   vibration DECIMAL(6,4),
   motor_rpm INT
 );

INSERT INTO sensor_data_device2 (device_id, timestamp, temperature, vibration, motor_rpm)
   SELECT 'DEVICE2', timestamp,
     UNIFORM(24.6642, 36.3107, RANDOM()), -- Temperature range in °C
     UNIFORM(0.2876, 0.3333, RANDOM()), -- Vibration range in mm/s
     UNIFORM(1425, 1505, RANDOM()) -- Motor RPM range
   FROM (
     SELECT DATEADD(SECOND, SEQ4(), '2024-03-01') AS timestamp
       FROM TABLE(GENERATOR(ROWCOUNT => 2678400)) -- seconds in 31 days
 );

 INSERT INTO sensor_data_device1 SELECT * FROM sensor_data_device2;

 DROP TABLE IF EXISTS sensor_data_ts;

 ALTER TABLE sensor_data_device1 rename to sensor_data_ts;

 DROP TABLE sensor_data_device2;

 SELECT COUNT(*) FROM sensor_data_ts; -- verify row count = 5356800
Copy

Création de la table heavy_weather

Le script suivant crée et charge la table heavy_weather qui est utilisée dans les exemples pour les fonctions MAX_BY. La table contient 55 lignes d’enregistrements de précipitations neigeuses pour les villes de Californie au cours de la dernière semaine de l’année 2021.

CREATE OR REPLACE TABLE heavy_weather
   (start_time TIMESTAMP, precip NUMBER(3,2), city VARCHAR(20), county VARCHAR(20));

INSERT INTO heavy_weather VALUES
  ('2021-12-23 06:56:00.000',0.08,'Mount Shasta','Siskiyou'),
  ('2021-12-23 07:51:00.000',0.09,'Mount Shasta','Siskiyou'),
  ('2021-12-23 16:23:00.000',0.56,'South Lake Tahoe','El Dorado'),
  ('2021-12-23 17:24:00.000',0.38,'South Lake Tahoe','El Dorado'),
  ('2021-12-23 18:30:00.000',0.28,'South Lake Tahoe','El Dorado'),
  ('2021-12-23 19:35:00.000',0.37,'Mammoth Lakes','Mono'),
  ('2021-12-23 19:36:00.000',0.80,'South Lake Tahoe','El Dorado'),
  ('2021-12-24 04:43:00.000',0.25,'Alta','Placer'),
  ('2021-12-24 05:26:00.000',0.34,'Alta','Placer'),
  ('2021-12-24 05:35:00.000',0.42,'Big Bear City','San Bernardino'),
  ('2021-12-24 06:49:00.000',0.17,'South Lake Tahoe','El Dorado'),
  ('2021-12-24 07:40:00.000',0.07,'Alta','Placer'),
  ('2021-12-24 08:36:00.000',0.07,'Alta','Placer'),
  ('2021-12-24 11:52:00.000',0.08,'Alta','Placer'),
  ('2021-12-24 12:52:00.000',0.38,'Alta','Placer'),
  ('2021-12-24 15:44:00.000',0.13,'Alta','Placer'),
  ('2021-12-24 15:53:00.000',0.07,'South Lake Tahoe','El Dorado'),
  ('2021-12-24 16:55:00.000',0.09,'Big Bear City','San Bernardino'),
  ('2021-12-24 21:53:00.000',0.07,'Montague','Siskiyou'),
  ('2021-12-25 02:52:00.000',0.07,'Alta','Placer'),
  ('2021-12-25 07:52:00.000',0.07,'Alta','Placer'),
  ('2021-12-25 08:52:00.000',0.08,'Alta','Placer'),
  ('2021-12-25 09:48:00.000',0.18,'Alta','Placer'),
  ('2021-12-25 12:52:00.000',0.10,'Alta','Placer'),
  ('2021-12-25 17:21:00.000',0.23,'Alturas','Modoc'),
  ('2021-12-25 17:52:00.000',1.54,'Alta','Placer'),
  ('2021-12-26 01:52:00.000',0.61,'Alta','Placer'),
  ('2021-12-26 05:43:00.000',0.16,'South Lake Tahoe','El Dorado'),
  ('2021-12-26 05:56:00.000',0.08,'Bishop','Inyo'),
  ('2021-12-26 06:52:00.000',0.75,'Bishop','Inyo'),
  ('2021-12-26 06:53:00.000',0.08,'Lebec','Los Angeles'),
  ('2021-12-26 07:52:00.000',0.65,'Alta','Placer'),
  ('2021-12-26 09:52:00.000',2.78,'Alta','Placer'),
  ('2021-12-26 09:55:00.000',0.07,'Big Bear City','San Bernardino'),
  ('2021-12-26 14:22:00.000',0.32,'Alta','Placer'),
  ('2021-12-26 14:52:00.000',0.34,'Alta','Placer'),
  ('2021-12-26 15:43:00.000',0.35,'Alta','Placer'),
  ('2021-12-26 17:31:00.000',5.24,'Alta','Placer'),
  ('2021-12-26 22:52:00.000',0.07,'Alta','Placer'),
  ('2021-12-26 23:15:00.000',0.52,'Alta','Placer'),
  ('2021-12-27 02:52:00.000',0.08,'Alta','Placer'),
  ('2021-12-27 03:52:00.000',0.14,'Alta','Placer'),
  ('2021-12-27 04:52:00.000',1.52,'Alta','Placer'),
  ('2021-12-27 14:37:00.000',0.89,'Alta','Placer'),
  ('2021-12-27 14:53:00.000',0.07,'South Lake Tahoe','El Dorado'),
  ('2021-12-27 17:53:00.000',0.07,'South Lake Tahoe','El Dorado'),
  ('2021-12-30 11:23:00.000',0.12,'Lebec','Los Angeles'),
  ('2021-12-30 11:43:00.000',0.98,'Lebec','Los Angeles'),
  ('2021-12-30 13:53:00.000',0.23,'Lebec','Los Angeles'),
  ('2021-12-30 14:53:00.000',0.13,'Lebec','Los Angeles'),
  ('2021-12-30 15:15:00.000',0.29,'Lebec','Los Angeles'),
  ('2021-12-30 17:53:00.000',0.10,'Lebec','Los Angeles'),
  ('2021-12-30 18:53:00.000',0.09,'Lebec','Los Angeles'),
  ('2021-12-30 19:53:00.000',0.07,'Lebec','Los Angeles'),
  ('2021-12-30 20:53:00.000',0.07,'Lebec','Los Angeles')
  ;
Copy