This exercise implements Stage 2 of the capstone: the Spark Structured Streaming processing layer that reads from the three IoT Kafka topics, applies watermarks for late sensor data, computes 1-minute tumbling window aggregates per sensor type and match, detects anomalies using a rolling baseline built from the windowed results, and writes anomaly alerts to a Kafka output topic. The pipeline uses `foreachBatch` for the Delta write sink and verifies the physical plan shows `BroadcastHashJoin` for the small anomaly threshold lookup table.
The streaming pipeline reads IoT events from local Parquet files written from the Stage 1 output — the same pattern as Module 5's practice exercise. All Structured Streaming concepts from Module 5 are applied in sequence: Kafka source schema parsing, watermark definition, tumbling window aggregation, `append` output mode for the windowed results, and `trigger(once=True)` for deterministic testing. The anomaly detection uses a second-pass window function on the materialised aggregates, exactly as demonstrated in the Module 5 practice exercise.
Step 1 — Write Stage 1 Output to Parquet and Build Stream
Materialise the 300 IoT events from Stage 1 into three Parquet files — one per sensor type — with proper timestamp columns. Build the Structured Streaming pipeline that reads all three files, applies a 30-second watermark, and computes 1-minute tumbling window aggregates: mean heart rate and max acceleration per player per window for wearables, mean ball speed and impact zone distribution for pitch sensors, and mean decibels and peak density per zone for crowd sensors. Write all three aggregation streams to separate Delta table paths.
# capstone_spark_streaming.py — Step 1: Stream setup and windowed aggregations
from pyspark.sql import SparkSession, functions as F, Window
from pyspark.sql.types import *
from delta import configure_spark_with_delta_pip
import pandas as pd
import numpy as np
from datetime import datetime, timezone, timedelta
from pathlib import Path
import shutil
np.random.seed(42)
builder = (
SparkSession.builder.appName("IPL-IoT-Streaming").master("local[4]")
.config("spark.sql.shuffle.partitions", "8")
.config("spark.sql.adaptive.enabled", "true")
.config("spark.sql.extensions",
"io.delta.sql.DeltaSparkSessionExtension")
.config("spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog")
)
spark = configure_spark_with_delta_pip(builder).getOrCreate()
# Write Stage 1 events to Parquet (simulates Kafka → HDFS landing zone)
base_ts = datetime(2024, 4, 20, 19, 30, 0, tzinfo=timezone.utc)
MATCHES = [10001, 10002, 10003]
PLAYERS = [(1,"Rohit"),(2,"Kohli"),(3,"Bumrah"),(4,"Dhoni")]
ZONES = ["North","South","East","West"]
SOURCE = Path("/tmp/iot_source")
DELTA_BASE= "/tmp/iot_delta"
if SOURCE.exists(): shutil.rmtree(SOURCE)
SOURCE.mkdir(parents=True)
wearable_rows, pitch_rows, crowd_rows = [], [], []
for i in range(100):
ts = base_ts + timedelta(seconds=i)
m = np.random.choice(MATCHES)
pid, pname = PLAYERS[i % len(PLAYERS)]
wearable_rows.append({"event_id":i+1, "match_id":int(m),
"player_id":pid, "player_name":pname,
"heart_rate":int(np.random.normal(155,15)),
"acceleration":round(float(np.random.uniform(0,8)),2),
"running_dist":round(float(np.random.uniform(0,10)),2),
"event_ts": ts})
pitch_rows.append({"event_id":i+101, "match_id":int(m), "delivery_id":i+1,
"impact_x":round(float(np.random.uniform(-1.5,1.5)),3),
"impact_y":round(float(np.random.uniform(0,22)),3),
"ball_speed":round(float(np.random.uniform(110,145)),1),
"spin_rate":round(float(np.random.choice([0.0,float(np.random.uniform(1000,3000))])),0),
"event_ts":ts})
crowd_rows.append({"event_id":i+201, "match_id":int(m),
"zone_id":np.random.choice(ZONES),
"decibels":round(float(np.random.normal(85,12)),1),
"density":round(float(np.random.uniform(0.5,4.0)),2),
"event_ts":ts})
for name, rows in [("wearable",wearable_rows),("pitch",pitch_rows),("crowd",crowd_rows)]:
spark.createDataFrame(pd.DataFrame(rows)).write.mode("overwrite") \
.parquet(str(SOURCE / name))
print(f" {name}: {len(rows)} events written to Parquet")
# Schemas
WEARABLE_SCHEMA = StructType([
StructField("event_id", IntegerType(),False), StructField("match_id",IntegerType(),False),
StructField("player_id", IntegerType(),False), StructField("player_name",StringType(),False),
StructField("heart_rate", IntegerType(),False), StructField("acceleration",FloatType(),False),
StructField("running_dist",FloatType(), False), StructField("event_ts",TimestampType(),False),
])
PITCH_SCHEMA = StructType([
StructField("event_id", IntegerType(),False), StructField("match_id",IntegerType(),False),
StructField("delivery_id",IntegerType(),False), StructField("impact_x",FloatType(),False),
StructField("impact_y", FloatType(), False), StructField("ball_speed",FloatType(),False),
StructField("spin_rate", FloatType(), False), StructField("event_ts",TimestampType(),False),
])
CROWD_SCHEMA = StructType([
StructField("event_id",IntegerType(),False), StructField("match_id",IntegerType(),False),
StructField("zone_id", StringType(), False), StructField("decibels",FloatType(),False),
StructField("density", FloatType(), False), StructField("event_ts",TimestampType(),False),
])
def read_stream(schema, path):
return (spark.readStream.schema(schema).parquet(path)
.withWatermark("event_ts", "30 seconds"))
wearable_stream = read_stream(WEARABLE_SCHEMA, str(SOURCE/"wearable"))
pitch_stream = read_stream(PITCH_SCHEMA, str(SOURCE/"pitch"))
crowd_stream = read_stream(CROWD_SCHEMA, str(SOURCE/"crowd"))
# 1-minute tumbling windows
def window_agg(stream, groupby_cols, agg_exprs, delta_path, chk_path):
q = (stream
.groupBy(groupby_cols + [F.window("event_ts","1 minute")])
.agg(*agg_exprs)
.withColumn("window_start",F.col("window.start"))
.withColumn("window_end", F.col("window.end")).drop("window")
.writeStream.outputMode("append").format("delta")
.option("checkpointLocation", chk_path)
.trigger(once=True).start(delta_path))
q.awaitTermination()
print(f" {delta_path}: {spark.read.format('delta').load(delta_path).count()} window rows")
window_agg(wearable_stream, ["match_id","player_name"],
[F.round(F.avg("heart_rate"),1).alias("avg_hr"),
F.round(F.max("acceleration"),2).alias("max_accel")],
f"{DELTA_BASE}/wearable_windows", "/tmp/chk/wearable")
window_agg(pitch_stream, ["match_id"],
[F.round(F.avg("ball_speed"),1).alias("avg_speed"),
F.round(F.avg("spin_rate"),0).alias("avg_spin"),
F.count("*").alias("deliveries")],
f"{DELTA_BASE}/pitch_windows", "/tmp/chk/pitch")
window_agg(crowd_stream, ["match_id","zone_id"],
[F.round(F.avg("decibels"),1).alias("avg_db"),
F.round(F.max("density"),2).alias("peak_density")],
f"{DELTA_BASE}/crowd_windows", "/tmp/chk/crowd")
print("\nStep 1 ✓: all three windowed aggregations written to Delta")Step 2 — Anomaly Detection on Wearable Data
Read the wearable window aggregates from Delta, compute each player's rolling 3-window mean and standard deviation for heart rate, and flag windows where `avg_hr` exceeds mean + 1.5 standard deviations as fatigue alerts. Write the alert flags back to Delta via MERGE. Assert that at least one player has at least one window flagged as a fatigue alert (given the random heart rate distribution with mean 155 and SD 15), and verify the MERGE is idempotent by running it twice and asserting the alert count is unchanged.
# capstone_spark_streaming.py — Step 2: Anomaly detection on wearable data
from delta import DeltaTable
WEARABLE_DELTA = f"{DELTA_BASE}/wearable_windows"
# Read wearable aggregates and compute rolling baseline
wearable_df = spark.read.format("delta").load(WEARABLE_DELTA)
player_time_window = (
Window.partitionBy("player_name")
.orderBy("window_start")
.rowsBetween(-2, 0) # 3-window rolling
)
with_stats = (
wearable_df
.withColumn("rolling_hr_mean",
F.round(F.avg("avg_hr").over(player_time_window), 1))
.withColumn("rolling_hr_std",
F.round(F.stddev("avg_hr").over(player_time_window), 2))
.withColumn("is_fatigue_alert",
F.when(
F.col("avg_hr") > (
F.col("rolling_hr_mean")
+ 1.5 * F.coalesce(F.col("rolling_hr_std"), F.lit(0.0))
), True
).otherwise(False)
)
)
alerts = with_stats.filter(F.col("is_fatigue_alert") == True)
print(f"\nFatigue alerts detected: {alerts.count()}")
print("Alert details:")
alerts.select("player_name","match_id","window_start",
"avg_hr","rolling_hr_mean","rolling_hr_std").show(truncate=False)
assert alerts.count() > 0, "Expected at least one fatigue alert given heart rate distribution"
# MERGE alert flags back to Delta
delta_tbl = DeltaTable.forPath(spark, WEARABLE_DELTA)
def merge_alerts(df_with_flags):
delta_tbl.alias("t").merge(
df_with_flags.select(
"player_name","match_id","window_start",
"is_fatigue_alert","rolling_hr_mean","rolling_hr_std"
).alias("s"),
"t.player_name=s.player_name AND t.match_id=s.match_id AND t.window_start=s.window_start"
).whenMatchedUpdate(set_={
"is_fatigue_alert": "s.is_fatigue_alert",
"rolling_hr_mean": "s.rolling_hr_mean",
"rolling_hr_std": "s.rolling_hr_std",
}).execute()
merge_alerts(with_stats)
count_1 = spark.read.format("delta").load(WEARABLE_DELTA) \
.filter(F.col("is_fatigue_alert") == True).count()
# Idempotency: second MERGE must not change alert count
merge_alerts(with_stats)
count_2 = spark.read.format("delta").load(WEARABLE_DELTA) \
.filter(F.col("is_fatigue_alert") == True).count()
assert count_1 == count_2, f"Idempotency failed: {count_1} → {count_2} alerts"
print(f"\nIdempotency verified: {count_1} alerts unchanged after re-MERGE ✓")
print("Step 2 complete: anomaly detection pipeline verified")Warning: `F.stddev` returns `null` for groups with only one data point (cannot compute standard deviation from a single observation). The `F.coalesce(F.col('rolling_hr_std'), F.lit(0.0))` in the anomaly condition handles this by treating single-window baselines as having zero standard deviation — meaning the anomaly threshold equals the rolling mean exactly, flagging any window that exceeds its own mean. In production, set a minimum window count threshold (e.g. require at least 3 windows before flagging anomalies) to avoid false positives from single-observation baselines.
Extension Challenge: Add a third step that joins the wearable fatigue alerts with the pitch window aggregates on `match_id` and `window_start` to correlate player fatigue with pitch deterioration. Use a broadcast join on the pitch aggregates (fewer rows) and identify windows where both fatigue alerts and above-average ball spin rates occurred simultaneously — the hypothesis being that spin bowlers are more effective when batters are fatigued. Write the correlated results to a third Delta table `ipl-fatigue-pitch-correlation`.