Database
 sql >> Base de Dados >  >> RDS >> Database

Bucketizando dados de data e hora


O agrupamento de dados de data e hora envolve a organização de dados em grupos que representam intervalos fixos de tempo para fins analíticos. Muitas vezes, a entrada são dados de séries temporais armazenados em uma tabela em que as linhas representam medições feitas em intervalos de tempo regulares. Por exemplo, as medições podem ser leituras de temperatura e umidade feitas a cada 5 minutos, e você deseja agrupar os dados usando intervalos horários e computar agregados como média por hora. Embora os dados de série temporal sejam uma fonte comum para análise baseada em bucket, o conceito é igualmente relevante para qualquer dado que envolva atributos de data e hora e medidas associadas. Por exemplo, talvez você queira organizar dados de vendas em buckets de ano fiscal e calcular agregações como o valor total de vendas por ano fiscal. Neste artigo, abordo dois métodos para agrupar dados de data e hora. Uma está usando uma função chamada DATE_BUCKET, que no momento da escrita está disponível apenas no Azure SQL Edge. Outra é usar um cálculo personalizado que emula a função DATE_BUCKET, que você pode usar em qualquer versão, edição e sabor do SQL Server e do Banco de Dados SQL do Azure.

Em meus exemplos, usarei o banco de dados de exemplo TSQLV5. Você pode encontrar o script que cria e preenche o TSQLV5 aqui e seu diagrama ER aqui.

DATE_BUCKET


Conforme mencionado, a função DATE_BUCKET está atualmente disponível apenas no Azure SQL Edge. O SQL Server Management Studio já tem suporte ao IntelliSense, conforme mostrado na Figura 1:

Figura 1:suporte de inteligência para DATE_BUCKET no SSMS

A sintaxe da função é a seguinte:
DATE_BUCKET ( , , [, ] )
A entrada origem representa um ponto de ancoragem na seta do tempo. Ele pode ser de qualquer um dos tipos de dados de data e hora com suporte. Se não especificado, o padrão é 1900, 1º de janeiro, meia-noite. Você pode então imaginar a linha do tempo dividida em intervalos discretos começando com o ponto de origem, onde o comprimento de cada intervalo é baseado nas entradas largura do bucket e parte da data . O primeiro é a quantidade e o segundo é a unidade. Por exemplo, para organizar a linha do tempo em unidades de 2 meses, especifique 2 como a largura do bucket entrada e mês como a parte da data entrada.

A entrada timestamp é um ponto no tempo arbitrário que precisa ser associado ao bucket que o contém. Seu tipo de dados precisa corresponder ao tipo de dados da entrada origem . A entrada timestamp é o valor de data e hora associado às medidas que você está capturando.

A saída da função é então o ponto de partida do bucket que o contém. O tipo de dados da saída é o da entrada timestamp .

Se já não fosse óbvio, normalmente você usaria a função DATE_BUCKET como um elemento de conjunto de agrupamento na cláusula GROUP BY da consulta e naturalmente a retornaria na lista SELECT também, junto com medidas agregadas.

Ainda um pouco confuso sobre a função, suas entradas e sua saída? Talvez um exemplo específico com uma representação visual da lógica da função ajude. Começarei com um exemplo que usa variáveis ​​de entrada e, posteriormente, no artigo demonstrarei a maneira mais comum de usá-la como parte de uma consulta em uma tabela de entrada.

Considere o seguinte exemplo:
DECLARE 
  @timestamp   AS DATETIME2 = '20210510 06:30:00',
  @bucketwidth AS INT = 2,
  @origin      AS DATETIME2 = '20210115 00:00:00';
 
SELECT DATE_BUCKET(month, @bucketwidth, @timestamp, @origin);

Você pode encontrar uma representação visual da lógica da função na Figura 2.

Figura 2:representação visual da lógica da função DATE_BUCKET

Como você pode ver na Figura 2, o ponto de origem é o valor DATETIME2 15 de janeiro de 2021, meia-noite. Se este ponto de origem parece um pouco estranho, você estaria certo em perceber intuitivamente que normalmente você usaria um ponto mais natural como o início de algum ano ou o início de algum dia. Na verdade, muitas vezes você ficaria satisfeito com o padrão, que, como você se lembra, é 1º de janeiro de 1900 à meia-noite. Eu intencionalmente queria usar um ponto de origem menos trivial para poder discutir certas complexidades que podem não ser relevantes ao usar um ponto de origem mais natural. Mais sobre isso em breve.

A linha do tempo é então dividida em intervalos discretos de 2 meses, começando com o ponto de origem. O carimbo de data/hora de entrada é o valor DATETIME2 10 de maio de 2021, 6h30.

Observe que o carimbo de data/hora de entrada faz parte do bucket que começa em 15 de março de 2021, meia-noite. De fato, a função retorna esse valor como um valor do tipo DATETIME2:
---------------------------
2021-03-15 00:00:00.0000000

Emulando DATE_BUCKET


A menos que você esteja usando o Azure SQL Edge, se quiser agrupar dados de data e hora, por enquanto, você precisaria criar sua própria solução personalizada para emular o que a função DATE_BUCKET faz. Fazer isso não é muito complexo, mas também não é muito simples. Lidar com dados de data e hora geralmente envolve lógica complicada e armadilhas com as quais você precisa ter cuidado.

Construirei o cálculo em etapas e usarei as mesmas entradas que usei com o exemplo DATE_BUCKET que mostrei anteriormente:
DECLARE 
  @timestamp   AS DATETIME2 = '20210510 06:30:00',
  @bucketwidth AS INT = 2,
  @origin      AS DATETIME2 = '20210115 00:00:00';

Certifique-se de incluir esta parte antes de cada um dos exemplos de código que mostrarei se você realmente quiser executar o código.

Na Etapa 1, você usa a função DATEDIFF para calcular a diferença na parte da data unidades entre origem e carimbo de data e hora . Vou me referir a essa diferença como diff1 . Isso é feito com o seguinte código:
SELECT DATEDIFF(month, @origin, @timestamp) AS diff1;

Com nossas entradas de exemplo, essa expressão retorna 4.

A parte complicada aqui é que você precisa calcular quantas unidades inteiras de parte de data existe entre origem e carimbo de data e hora . Com nossas entradas de exemplo, há 3 meses inteiros entre os dois e não 4. A razão pela qual a função DATEDIFF reporta 4 é que, quando calcula a diferença, ela analisa apenas a parte solicitada das entradas e as partes mais altas, mas não as partes mais baixas . Assim, quando você pede a diferença em meses, a função só se preocupa com as partes do ano e do mês das entradas e não com as partes abaixo do mês (dia, hora, minuto, segundo, etc.). De fato, há 4 meses entre janeiro de 2021 e maio de 2021, mas apenas 3 meses inteiros entre as entradas completas.

O objetivo da Etapa 2 é calcular quantas unidades inteiras de parte de data existe entre origem e carimbo de data e hora . Vou me referir a essa diferença como diff2 . Para conseguir isso, você pode adicionar diff1 unidades de parte de data para origem . Se o resultado for maior que timestamp , você subtrai 1 de diff1 para calcular diff2 , caso contrário, subtraia 0 e, portanto, use diff1 como diff2 . Isso pode ser feito usando uma expressão CASE, assim:
SELECT
  DATEDIFF(month, @origin, @timestamp)
    - CASE
        WHEN DATEADD(
               month, 
               DATEDIFF(month, @origin, @timestamp), 
               @origin) > @timestamp 
          THEN 1
        ELSE 0
      END AS diff2;

Essa expressão retorna 3, que é o número de meses inteiros entre as duas entradas.

Lembre-se de que mencionei anteriormente que no meu exemplo usei intencionalmente um ponto de origem que não é natural, como um início redondo de um período, para poder discutir certas complexidades que podem ser relevantes. Por exemplo, se você usar mês como a parte da data e o início exato de algum mês (1 de algum mês à meia-noite) como origem, você pode pular a Etapa 2 com segurança e usar diff1 como diff2 . Isso porque origem + dif1 nunca pode ser> timestamp nesse caso. No entanto, meu objetivo é fornecer uma alternativa logicamente equivalente à função DATE_BUCKET que funcionaria corretamente para qualquer ponto de origem, comum ou não. Portanto, incluirei a lógica da Etapa 2 em meus exemplos, mas lembre-se de que, ao identificar casos em que essa etapa não é relevante, você pode remover com segurança a parte em que subtrai a saída da expressão CASE.

Na Etapa 3, você identifica quantas unidades de parte de data existem em buckets inteiros que existem entre origin e carimbo de data e hora . Vou me referir a esse valor como diff3 . Isso pode ser feito com a seguinte fórmula:
diff3 = diff2 / <bucket width> * <bucket width>

O truque aqui é que ao usar o operador de divisão / em T-SQL com operandos inteiros, você obtém uma divisão inteira. Por exemplo, 3/2 em T-SQL é 1 e não 1,5. A expressão diff2 / fornece o número de buckets inteiros que existem entre origem e carimbo de data e hora (1 no nosso caso). Multiplicando o resultado pela largura do bucket então lhe dá o número de unidades de parte de data que existem dentro desses buckets inteiros (2 no nosso caso). Essa fórmula se traduz na seguinte expressão em T-SQL:
SELECT
  ( DATEDIFF(month, @origin, @timestamp)
      - CASE
          WHEN DATEADD(
                 month, 
                 DATEDIFF(month, @origin, @timestamp), 
                 @origin) > @timestamp 
            THEN 1
          ELSE 0
        END )  / @bucketwidth * @bucketwidth AS diff3;

Essa expressão retorna 2, que é o número de meses em todos os buckets de 2 meses que existem entre as duas entradas.

Na Etapa 4, que é a etapa final, você adiciona diff3 unidades de parte de data para origem para calcular o início do bucket que o contém. Aqui está o código para conseguir isso:
SELECT DATEADD(
         month, 
         ( DATEDIFF(month, @origin, @timestamp)
             - CASE
                 WHEN DATEADD(
                        month, 
                        DATEDIFF(month, @origin, @timestamp), 
                        @origin) > @timestamp 
                   THEN 1
                 ELSE 0
               END ) / @bucketwidth * @bucketwidth,
         @origin);

Este código gera a seguinte saída:
---------------------------
2021-03-15 00:00:00.0000000

Como você deve se lembrar, esta é a mesma saída produzida pela função DATE_BUCKET para as mesmas entradas.

Sugiro que você tente esta expressão com várias entradas e partes. Vou mostrar alguns exemplos aqui, mas sinta-se à vontade para tentar o seu próprio.

Aqui está um exemplo em que origem está um pouco à frente do timestamp no mês:
DECLARE 
  @timestamp   AS DATETIME2 = '20210510 06:30:00',
  @bucketwidth AS INT = 2,
  @origin      AS DATETIME2 = '20210110 06:30:01';
 
-- SELECT DATE_BUCKET(week, @bucketwidth, @timestamp, @origin);
 
SELECT DATEADD(
         month, 
         ( DATEDIFF(month, @origin, @timestamp)
             - CASE
                 WHEN DATEADD(
                        month, 
                        DATEDIFF(month, @origin, @timestamp), 
                        @origin) > @timestamp 
                   THEN 1
                 ELSE 0
               END ) / @bucketwidth * @bucketwidth,
         @origin);

Este código gera a seguinte saída:
---------------------------
2021-03-10 06:30:01.0000000

Observe que o início do bucket que o contém é em março.

Aqui está um exemplo em que origem está no mesmo ponto dentro do mês que timestamp :
DECLARE 
  @timestamp   AS DATETIME2 = '20210510 06:30:00',
  @bucketwidth AS INT = 2,
  @origin      AS DATETIME2 = '20210110 06:30:00';
 
-- SELECT DATE_BUCKET(week, @bucketwidth, @timestamp, @origin);
 
SELECT DATEADD(
         month, 
         ( DATEDIFF(month, @origin, @timestamp)
             - CASE
                 WHEN DATEADD(
                        month, 
                        DATEDIFF(month, @origin, @timestamp), 
                        @origin) > @timestamp 
                   THEN 1
                 ELSE 0
               END ) / @bucketwidth * @bucketwidth,
         @origin);

Este código gera a seguinte saída:
---------------------------
2021-05-10 06:30:00.0000000

Observe que desta vez o início do bucket que o contém é em maio.

Veja um exemplo com buckets de 4 semanas:
DECLARE 
  @timestamp   AS DATETIME2 = '20210303 21:22:11',
  @bucketwidth AS INT = 4,
  @origin      AS DATETIME2 = '20210115';
 
-- SELECT DATE_BUCKET(week, @bucketwidth, @timestamp, @origin);
 
SELECT DATEADD(
         week, 
         ( DATEDIFF(week, @origin, @timestamp)
             - CASE
                 WHEN DATEADD(
                        week, 
                        DATEDIFF(week, @origin, @timestamp), 
                        @origin) > @timestamp 
                   THEN 1
                 ELSE 0
               END ) / @bucketwidth * @bucketwidth,
         @origin);

Observe que o código usa a semana parte desta vez.

Este código gera a seguinte saída:
---------------------------
2021-02-12 00:00:00.0000000

Aqui está um exemplo com intervalos de 15 minutos:
DECLARE 
  @timestamp   AS DATETIME2 = '20210203 21:22:11',
  @bucketwidth AS INT = 15,
  @origin      AS DATETIME2 = '19000101';
 
-- SELECT DATE_BUCKET(minute, @bucketwidth, @timestamp);
 
SELECT DATEADD(
         minute, 
         ( DATEDIFF(minute, @origin, @timestamp)
             - CASE
                 WHEN DATEADD(
                        minute, 
                        DATEDIFF(minute, @origin, @timestamp), 
                        @origin) > @timestamp 
                   THEN 1
                 ELSE 0
               END ) / @bucketwidth * @bucketwidth,
         @origin);

Este código gera a seguinte saída:
---------------------------
2021-02-03 21:15:00.0000000

Observe que a parte é minuto . Neste exemplo, você deseja usar buckets de 15 minutos começando na parte inferior da hora, portanto, um ponto de origem que seja a parte inferior de qualquer hora funcionaria. De fato, um ponto de origem que tenha uma unidade de minuto de 00, 15, 30 ou 45 com zeros nas partes inferiores, com qualquer data e hora funcionaria. Então o padrão que a função DATE_BUCKET usa para a entrada origem trabalharia. Obviamente, ao usar a expressão personalizada, você deve ser explícito sobre o ponto de origem. Então, para simpatizar com a função DATE_BUCKET, você pode usar a data base à meia-noite como eu faço no exemplo acima.

Aliás, você pode ver por que isso seria um bom exemplo em que é perfeitamente seguro pular a Etapa 2 na solução? Se você realmente optou por pular a Etapa 2, obterá o seguinte código:
DECLARE 
  @timestamp   AS DATETIME2 = '20210203 21:22:11',
  @bucketwidth AS INT = 15,
  @origin      AS DATETIME2 = '19000101';
 
-- SELECT DATE_BUCKET(minute, @bucketwidth, @timestamp);
 
SELECT DATEADD( 
    minute, 
    ( DATEDIFF( minute, @origin, @timestamp ) ) / @bucketwidth * @bucketwidth, 
    @origin 
);

Claramente, o código se torna significativamente mais simples quando a Etapa 2 não é necessária.

Agrupar e agregar dados por intervalos de data e hora


Há casos em que você precisa agrupar dados de data e hora que não exigem funções sofisticadas ou expressões complicadas. Por exemplo, suponha que você queira consultar a exibição Sales.OrderValues ​​no banco de dados TSQLV5, agrupar os dados anualmente e calcular o total de contagens e valores de pedidos por ano. Claramente, é suficiente usar a função YEAR(orderdate) como o elemento do conjunto de agrupamento, assim:
USE TSQLV5;
 
SELECT
  YEAR(orderdate) AS orderyear,
  COUNT(*) AS numorders,
  SUM(val) AS totalvalue
FROM Sales.OrderValues
GROUP BY YEAR(orderdate)
ORDER BY orderyear;

Este código gera a seguinte saída:
orderyear   numorders   totalvalue
----------- ----------- -----------
2017        152         208083.99
2018        408         617085.30
2019        270         440623.93

Mas e se você quisesse agrupar os dados por ano fiscal da sua organização? Algumas organizações usam um ano fiscal para fins de contabilidade, orçamento e relatórios financeiros, não alinhados com o ano civil. Digamos, por exemplo, que o ano fiscal de sua organização opere em um calendário fiscal de outubro a setembro e seja indicado pelo ano civil em que o ano fiscal termina. Assim, um evento ocorrido em 3 de outubro de 2018 pertence ao ano fiscal iniciado em 1º de outubro de 2018, encerrado em 30 de setembro de 2019, e é denotado pelo ano de 2019.

Isso é muito fácil de conseguir com a função DATE_BUCKET, assim:
DECLARE 
  @bucketwidth AS INT = 1,
  @origin      AS DATETIME2 = '19001001'; -- this is Oct 1 of some year
 
SELECT 
  YEAR(startofbucket) + 1 AS fiscalyear,
  COUNT(*) AS numorders,
  SUM(val) AS totalvalue
FROM Sales.OrderValues
  CROSS APPLY ( VALUES( DATE_BUCKET( year, @bucketwidth, orderdate, @origin ) ) ) AS A(startofbucket)
GROUP BY startofbucket
ORDER BY startofbucket;

E aqui está o código usando o equivalente lógico personalizado da função DATE_BUCKET:
DECLARE 
  @bucketwidth AS INT = 1,
  @origin      AS DATETIME2 = '19001001'; -- this is Oct 1 of some year
 
SELECT
  YEAR(startofbucket) + 1 AS fiscalyear,
  COUNT(*) AS numorders,
  SUM(val) AS totalvalue
FROM Sales.OrderValues
  CROSS APPLY ( VALUES( 
    DATEADD(
      year, 
      ( DATEDIFF(year, @origin, orderdate)
          - CASE
              WHEN DATEADD(
                     year, 
                     DATEDIFF(year, @origin, orderdate), 
                     @origin) > orderdate 
                THEN 1
              ELSE 0
            END ) / @bucketwidth * @bucketwidth,
      @origin) ) ) AS A(startofbucket)
GROUP BY startofbucket
ORDER BY startofbucket;

Este código gera a seguinte saída:
fiscalyear  numorders   totalvalue
----------- ----------- -----------
2017        70          79728.58
2018        370         563759.24
2019        390         622305.40

Usei variáveis ​​aqui para a largura do bucket e o ponto de origem para tornar o código mais generalizado, mas você pode substituí-las por constantes se estiver sempre usando as mesmas e simplificar o cálculo conforme apropriado.

Como uma pequena variação do acima, suponha que seu ano fiscal vai de 15 de julho de um ano civil a 14 de julho do próximo ano civil e é indicado pelo ano civil ao qual o início do ano fiscal pertence. Portanto, um evento ocorrido em 18 de julho de 2018 pertence ao ano fiscal de 2018. Um evento ocorrido em 14 de julho de 2018 pertence ao ano fiscal de 2017. Usando a função DATE_BUCKET, você obteria isso da seguinte forma:
DECLARE 
  @bucketwidth AS INT = 1,
  @origin      AS DATETIME2 = '19000715'; -- July 15 marks start of fiscal year
 
SELECT
  YEAR(startofbucket) AS fiscalyear, -- no need to add 1 here
  COUNT(*) AS numorders,
  SUM(val) AS totalvalue
FROM Sales.OrderValues
  CROSS APPLY ( VALUES( DATE_BUCKET( year, @bucketwidth, orderdate, @origin ) ) ) AS A(startofbucket)
GROUP BY startofbucket
ORDER BY startofbucket;

Você pode ver as alterações em comparação com o exemplo anterior nos comentários.

E aqui está o código usando o equivalente lógico personalizado para a função DATE_BUCKET:
DECLARE 
  @bucketwidth AS INT = 1,
  @origin      AS DATETIME2 = '19000715';
 
SELECT
  YEAR(startofbucket) AS fiscalyear,
  COUNT(*) AS numorders,
  SUM(val) AS totalvalue
FROM Sales.OrderValues
  CROSS APPLY ( VALUES( 
    DATEADD(
      year, 
      ( DATEDIFF(year, @origin, orderdate)
          - CASE
              WHEN DATEADD(
                     year, 
                     DATEDIFF(year, @origin, orderdate), 
                     @origin) > orderdate 
                THEN 1
              ELSE 0
            END ) / @bucketwidth * @bucketwidth,
      @origin) ) ) AS A(startofbucket)
GROUP BY startofbucket
ORDER BY startofbucket;

Este código gera a seguinte saída:
fiscalyear  numorders   totalvalue
----------- ----------- -----------
2016        8           12599.88
2017        343         495118.14
2018        479         758075.20

Obviamente, existem métodos alternativos que você pode usar em casos específicos. Veja o exemplo antes do último, onde o ano fiscal vai de outubro a setembro e denotado pelo ano civil em que o ano fiscal termina. Nesse caso, você pode usar a seguinte expressão, muito mais simples:
YEAR(orderdate) + CASE WHEN MONTH(orderdate) BETWEEN 10 AND 12 THEN 1 ELSE 0 END

E então sua consulta ficaria assim:
SELECT
  fiscalyear,
  COUNT(*) AS numorders,
  SUM(val) AS totalvalue
FROM Sales.OrderValues
  CROSS APPLY ( VALUES( 
    YEAR(orderdate)
      + CASE 
          WHEN MONTH(orderdate) BETWEEN 10 AND 12 THEN 1 
          ELSE 0 
        END ) ) AS A(fiscalyear)
GROUP BY fiscalyear
ORDER BY fiscalyear;

No entanto, se você deseja uma solução generalizada que funcione em muitos outros casos, e que você possa parametrizar, você naturalmente deseja usar a forma mais geral. Se você tiver acesso à função DATE_BUCKET, ótimo. Caso contrário, você pode usar o equivalente lógico personalizado.

Conclusão


A função DATE_BUCKET é uma função bastante útil que permite agrupar dados de data e hora. É útil para lidar com dados de séries temporais, mas também para agrupar quaisquer dados que envolvam atributos de data e hora. Neste artigo, expliquei como a função DATE_BUCKET funciona e forneci um equivalente lógico personalizado caso a plataforma que você está usando não a suporte.