"""
ELT IoT Pipeline: Extract/Load raw data, Transform in SQL
This pipeline demonstrates the ELT pattern where raw data is loaded first,
then transformation happens in the data warehouse using SQL.
"""
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
from datetime import datetime, timedelta
import pandas as pd
import os
# Configuration
DATA_DIR = "/opt/airflow/data"
POSTGRES_CONN_ID = "postgres_default"
def load_sensors_to_staging(**context):
"""
Load raw sensors data directly to staging table
"""
print("🔄 Loading sensors data to staging...")
# Read CSV file
sensors_path = os.path.join(DATA_DIR, "sensors.csv")
sensors_df = pd.read_csv(sensors_path)
print(f"📊 Loading {len(sensors_df)} sensor records")
# Get PostgreSQL connection and load data
postgres_hook = PostgresHook(postgres_conn_id=POSTGRES_CONN_ID)
engine = postgres_hook.get_sqlalchemy_engine()
# Load raw data to staging table
sensors_df.to_sql(
'raw_sensors',
engine,
if_exists='replace',
index=False,
method='multi'
)
print("✅ Sensors data loaded to raw_sensors staging table!")
def load_readings_to_staging(**context):
"""
Load raw readings data directly to staging table
"""
print("🔄 Loading readings data to staging...")
# Read CSV file
readings_path = os.path.join(DATA_DIR, "readings.csv")
readings_df = pd.read_csv(readings_path)
print(f"📊 Loading {len(readings_df)} reading records")
# Get PostgreSQL connection and load data
postgres_hook = PostgresHook(postgres_conn_id=POSTGRES_CONN_ID)
engine = postgres_hook.get_sqlalchemy_engine()
# Load raw data to staging table
readings_df.to_sql(
'raw_readings',
engine,
if_exists='replace',
index=False,
method='multi'
)
print("✅ Readings data loaded to raw_readings staging table!")
def load_weather_to_staging(**context):
print("🔄 Loading weather data to staging...")
weather_path = os.path.join(DATA_DIR, "weather.csv")
weather_df = pd.read_csv(weather_path)
# ✅ Convert to datetime
weather_df['date'] = pd.to_datetime(weather_df['date'])
print(f"📊 Loading {len(weather_df)} weather records")
postgres_hook = PostgresHook(postgres_conn_id=POSTGRES_CONN_ID)
engine = postgres_hook.get_sqlalchemy_engine()
weather_df.to_sql(
'raw_weather',
engine,
if_exists='replace',
index=False,
method='multi'
)
print("✅ Weather data loaded to raw_weather staging table!")
# Default arguments
default_args = {
'owner': 'data-engineer',
'depends_on_past': False,
'start_date': datetime(2025, 9, 24),
'email_on_failure': False,
'email_on_retry': False,
'retries': 1,
'retry_delay': timedelta(minutes=5),
}
# Create DAG
with DAG(
dag_id='elt_iot_pipeline',
default_args=default_args,
description='IoT ELT Pipeline - Transform in Database',
schedule_interval=None, # Manual trigger
catchup=False,
tags=['iot', 'elt', 'demo'],
) as dag:
# Create staging tables
create_staging_tables = PostgresOperator(
task_id='create_staging_tables',
postgres_conn_id=POSTGRES_CONN_ID,
sql="""
-- Drop existing staging tables
DROP TABLE IF EXISTS raw_sensors CASCADE;
DROP TABLE IF EXISTS raw_readings CASCADE;
-- Create sensors staging table
CREATE TABLE raw_sensors (
sensor_id INTEGER,
location VARCHAR(100)
);
-- Create readings staging table
CREATE TABLE raw_readings (
reading_id INTEGER,
sensor_id INTEGER,
temperature DECIMAL(5,2),
timestamp TIMESTAMP
);
-- Create weather staging table
CREATE TABLE IF NOT EXISTS raw_weather (
date DATE,
temperature FLOAT,
humidity INTEGER,
wind_speed FLOAT
);
""",
)
# Load tasks - Extract and Load in one step
load_sensors_task = PythonOperator(
task_id='load_sensors',
python_callable=load_sensors_to_staging,
provide_context=True,
)
load_readings_task = PythonOperator(
task_id='load_readings',
python_callable=load_readings_to_staging,
provide_context=True,
)
load_weather_task = PythonOperator(
task_id='load_weather',
python_callable=load_weather_to_staging,
provide_context=True,
)
# Transform in SQL - This is where ELT differs from ETL
transform_in_sql = PostgresOperator(
task_id='transform_in_sql',
postgres_conn_id=POSTGRES_CONN_ID,
sql="""
-- Create partitioned summary table if not exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM pg_tables WHERE tablename = 'daily_sensor_summary_elt'
) THEN
CREATE TABLE daily_sensor_summary_elt (
location VARCHAR(100),
date DATE,
avg_temp DECIMAL(5,2),
min_temp DECIMAL(5,2),
max_temp DECIMAL(5,2),
reading_count INTEGER,
humidity INTEGER,
wind_speed FLOAT,
moving_avg_temp DECIMAL(5,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY RANGE (date);
END IF;
END $$;
-- Create temporary table to hold new data
DROP TABLE IF EXISTS daily_sensor_summary_elt_temp;
CREATE TEMP TABLE daily_sensor_summary_elt_temp AS
WITH base AS (
SELECT
s.location,
DATE(r.timestamp) AS date,
r.temperature,
w.humidity,
w.wind_speed
FROM raw_readings r
JOIN raw_sensors s ON r.sensor_id = s.sensor_id
LEFT JOIN raw_weather w ON DATE(r.timestamp) = w.date
WHERE DATE(r.timestamp) > (
SELECT COALESCE(MAX(date), '1900-01-01') FROM daily_sensor_summary_elt
)
),
aggregated AS (
SELECT
location,
date,
ROUND(AVG(temperature)::numeric, 2) AS avg_temp,
ROUND(MIN(temperature)::numeric, 2) AS min_temp,
ROUND(MAX(temperature)::numeric, 2) AS max_temp,
COUNT(*) AS reading_count,
MAX(humidity) AS humidity,
MAX(wind_speed) AS wind_speed
FROM base
GROUP BY location, date
),
moving_avg AS (
SELECT *,
ROUND(AVG(avg_temp) OVER (
PARTITION BY location ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
)::numeric, 2) AS moving_avg_temp
FROM aggregated
),
validated AS (
SELECT *
FROM moving_avg
WHERE avg_temp BETWEEN -10 AND 50 AND humidity BETWEEN 0 AND 100
)
SELECT * FROM validated;
-- Insert new data into partitioned table
INSERT INTO daily_sensor_summary_elt (
location, date, avg_temp, min_temp, max_temp,
reading_count, humidity, wind_speed, moving_avg_temp, created_at
)
SELECT
location, date, avg_temp, min_temp, max_temp,
reading_count, humidity, wind_speed, moving_avg_temp, CURRENT_TIMESTAMP
FROM daily_sensor_summary_elt_temp;
-- Add indexes for performance (only once)
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM pg_indexes WHERE tablename = 'daily_sensor_summary_elt' AND indexname = 'idx_daily_summary_elt_location'
) THEN
CREATE INDEX idx_daily_summary_elt_location ON daily_sensor_summary_elt(location);
END IF;
IF NOT EXISTS (
SELECT FROM pg_indexes WHERE tablename = 'daily_sensor_summary_elt' AND indexname = 'idx_daily_summary_elt_date'
) THEN
CREATE INDEX idx_daily_summary_elt_date ON daily_sensor_summary_elt(date);
END IF;
END $$;
""",
)
alert_extreme_temp = PostgresOperator(
task_id='alert_extreme_temperature',
postgres_conn_id=POSTGRES_CONN_ID,
sql="""
SELECT location, date, avg_temp
FROM daily_sensor_summary_elt
WHERE avg_temp > 40;
""",
)
# Data quality checks
quality_checks = PostgresOperator(
task_id='data_quality_checks',
postgres_conn_id=POSTGRES_CONN_ID,
sql="""
-- Check for data completeness
DO $$
DECLARE
staging_sensors_count INTEGER;
staging_readings_count INTEGER;
summary_count INTEGER;
BEGIN
SELECT COUNT(*) INTO staging_sensors_count FROM raw_sensors;
SELECT COUNT(*) INTO staging_readings_count FROM raw_readings;
SELECT COUNT(*) INTO summary_count FROM daily_sensor_summary_elt;
RAISE NOTICE 'Data Quality Report:';
RAISE NOTICE '- Raw sensors: % records', staging_sensors_count;
RAISE NOTICE '- Raw readings: % records', staging_readings_count;
RAISE NOTICE '- Daily summaries: % records', summary_count;
-- Basic validation
IF staging_sensors_count = 0 THEN
RAISE EXCEPTION 'No sensor data found in staging';
END IF;
IF staging_readings_count = 0 THEN
RAISE EXCEPTION 'No readings data found in staging';
END IF;
IF summary_count = 0 THEN
RAISE EXCEPTION 'No summary data generated';
END IF;
RAISE NOTICE 'All data quality checks passed!';
END $$;
""",
)
# Verify final results
verify_results = PostgresOperator(
task_id='verify_elt_results',
postgres_conn_id=POSTGRES_CONN_ID,
sql="""
SELECT
'ELT Pipeline Results' as pipeline,
COUNT(*) as total_records,
COUNT(DISTINCT location) as locations,
COUNT(DISTINCT date) as dates,
ROUND(AVG(avg_temp)::numeric, 2) as overall_avg_temp,
MIN(date) as earliest_date,
MAX(date) as latest_date
FROM daily_sensor_summary_elt;
""",
autocommit=True,
)
# Task dependencies
create_staging_tables >> [load_sensors_task, load_readings_task, load_weather_task] >> transform_in_sql >> alert_extreme_temp >> quality_checks >> verify_results