Author: kongastral

  • Building an Apache Kafka Multivariate Time Series Engine

    Summary

    What this post covers: An end-to-end blueprint for building a production-grade Kafka ingestion engine for multivariate server time series, including psutil collection, Avro schema design, a tuned Python producer, partitioning, retention, and downstream consumer patterns.

    Key insights:

    • Kafka belongs between collectors and storage because it decouples failure modes—when InfluxDB or TimescaleDB goes down, producers keep writing and consumers replay from the log rather than dropping data.
    • Correlated multivariate metrics should be emitted as a single Avro record on one topic; splitting them across topics forces consumers to perform expensive joins and defeats the purpose of capturing them together.
    • Partition by hostname or instance ID—never by timestamp, since monotonic timestamps create rolling hot spots—and keep partition count comfortably larger than host count for even load distribution.
    • Tuning linger.ms, batch.size, and compression.type (lz4 or snappy) can raise a single Python producer’s throughput by more than an order of magnitude over the conservative defaults, while keeping tail latency bounded.
    • Set Schema Registry compatibility to BACKWARD and give every new Avro field a default value, then deploy schema → producer → consumer in that order to evolve safely without breaking running consumers.

    Main topics: The Challenge of Server Telemetry at Scale, Why Kafka for Multivariate Time Series, What Multivariate Time Series Actually Means, Architecture of the Engine, Collecting Server Metrics with psutil, Designing the Avro Message Schema, Building the Kafka Producer, Partitioning Strategy for Time Series, Topic Design and Retention, Consumer Patterns and Downstream Sinks, Production Concerns, Producer Tuning and Throughput.

    The Challenge of Server Telemetry at Scale

    A single modern server, when fully instrumented, can easily emit more than 10,000 metric samples per second. Multiplied across a few hundred machines in a modest production fleet, this produces millions of timestamped numbers per second — all correlated, all required, and all useless if they cannot be stored and replayed reliably. This is where most homegrown monitoring stacks quietly fail. A script that scrapes /proc/stat every five seconds and pushes rows directly into a time-series database appears elegant in a demonstration, but the moment the database goes down for maintenance, the collector crashes, or a network disruption drops packets, the data is lost permanently. For observability, missing data is often more harmful than no data at all, because dashboards continue drawing lines and the gap goes unnoticed.

    A representative incident illustrates the point: a fleet of ingestion machines began dropping metrics during a peak load spike. Grafana dashboards interpolated across the gap, and three full days passed before anyone recognised that the next quarter’s capacity plan had been built on fabricated values. That incident underscores a principle that has guided every observability pipeline since: the boundary between systems that produce data and systems that store data is one of the most important boundaries in a distributed architecture, and Apache Kafka remains the most appropriate component to sit on that boundary.

    This guide describes how to build a production-grade Kafka time-series engine end to end. The discussion covers collecting multivariate metrics from a Linux server, serializing them with Avro, pushing them through a tuned Python producer, routing them through deliberate partitioning, and feeding them to downstream consumers that depend on them. Working code, complete Avro schemas, copy-ready configuration, and the kind of detail that surfaces only after observing production failures are all provided.

    Kafka Multivariate Time Series Engine Architecture Server (Host) psutil · CPU psutil · Memory psutil · Disk I/O psutil · Network Kafka Producer Avro serializer Batch + compress acks=all Kafka Broker topic: server.metrics.v1 partition 0 · host-a partition 1 · host-b partition 2 · host-c Schema Registry Avro schemas · evolution rules InfluxDB sink long-term storage Flink processor windowed aggregates Alerting consumer threshold + anomaly Producers emit multivariate samples · Broker durably stores them · Consumers fan out independently

    Why Kafka for Multivariate Time Series

    Before any code is written, the question every engineer raises when Kafka is proposed deserves a direct answer: is Kafka actually required? Kafka is not the cheapest or simplest tool in the observability toolbox, but it is almost always the appropriate one once a deployment outgrows a single machine and a single storage target. Five properties make Kafka indispensable for multivariate time series, and each one addresses a specific failure mode that emerges the first time a stack of this kind is built without Kafka in the middle.

    The first property is durability. Kafka persists every message to disk before acknowledging it, and with replication factor three, two broker failures can be tolerated without data loss. Time-series databases such as InfluxDB or TimescaleDB are durable in their own right, but they are stateful, tuned for query performance, and frequently the first systems taken down during an upgrade. When producers write directly to the database, an upgrade window becomes a data-loss window. With Kafka in the middle, producers continue writing, Kafka continues storing, and the database catches up when it returns.

    The second property is replay. Because Kafka retains data for a configurable window — hours, days, or weeks — any consumer can reset its offset and re-read history. This transforms incident postmortems from inference based on prior dashboards into precise replay of the exact data the monitoring system observed. It is also how a new downstream system is onboarded: a fresh consumer is pointed at earliest and catches up.

    The third property is fan-out. Metrics are rarely consumed by a single system. A typical deployment includes a long-term store, a fast-access store, a stream processor for alerting, and possibly a machine-learning training sink. Kafka allows any number of independent consumer groups to attach to the same topic without coordination between them. Each group reads at its own pace, and a slow consumer cannot apply back-pressure to a fast one.

    The fourth property is decoupling. The producer requires no knowledge of the consumer, and vice versa. InfluxDB can be swapped for TimescaleDB without modifying a single line of collector code. This is the same argument that motivated the move toward microservices, and it applies with equal force to data pipelines. For an examination of this decoupling at the storage layer, the time series database comparison guide reviews the tradeoffs between common sinks.

    The fifth property is horizontal scale. A single Kafka topic can be partitioned across dozens or hundreds of brokers, and each partition is an independent log. As a fleet grows from fifty servers to five thousand, partitions and brokers are added rather than the pipeline being rewritten. The same Kafka cluster architecture can scale by orders of magnitude in throughput without a fundamental redesign, which is not a property most alternatives can claim.

    Key Takeaway: Kafka constitutes the boundary between systems that generate data and systems that store or react to it. If that boundary is absent from an architecture, the cost will eventually be paid in lost observability during precisely the incidents in which visibility is most needed.

    What Multivariate Time Series Actually Means

    The term “multivariate time series” is often used loosely, so a precise definition is in order. A univariate time series is a single signal indexed by time — for example, CPU utilisation sampled every second. A multivariate time series is a collection of two or more signals sampled at the same timestamps that are correlated with one another. On a server, CPU rarely matters in isolation. It matters together with memory pressure, disk I/O wait, network throughput, and possibly temperature, because the meaningful patterns reside in the relationships between those signals.

    Consider a representative example: a sudden CPU spike. In isolation, it conveys little information. If, at the same timestamp, memory usage is also climbing, disk I/O is dropping to near zero, and network bytes per second are flatlining, the signature most likely indicates a CPU-bound computation, perhaps a runaway regular expression or a JVM in a garbage-collection storm. By contrast, a CPU spike accompanied by high iowait, growing disk queue depth, and falling network throughput indicates disk saturation causing downstream throttling. These diagnoses are possible only because the signals arrive together, on the same timeline, in the same record.

    This has two concrete implications for engine design. First, all signals should be captured at the same instant in a single message rather than as separate messages per metric. Second, the storage and query layer should make it inexpensive to align those signals on the time axis, which is precisely what purpose-built time-series databases provide. For a deeper treatment of forecasting on this type of data, the guide on time series forecasting models describes how models exploit the correlations captured here.

    Multivariate Server Metrics—Same Time Axis, Correlated Signals 100 75 50 25 0 12:00 12:01 12:02 12:03 12:04 time normalized value CPU % Memory % Disk I/O Net bytes/s

    The chart above illustrates how CPU and memory climb together during the middle of the window while disk I/O and network activity move in the opposite direction. This divergence is the principal reason for capturing these signals together. Storing them in different Kafka topics with different timestamps and different partitioning schemes results in downstream query effort being dominated by realignment, which should be avoided.

    Architecture of the Engine

    The engine has four layers, and the most useful way to conceptualise them is as a sequence in which each layer must hand off correctly to the next.

    Layer one is collection. On each server, a small Python process samples metrics at a fixed interval (typically one second) using psutil. It bundles CPU, memory, disk, and network counters into a single record keyed by hostname and timestamp. This process runs as a systemd service and consumes minimal resources, with negligible steady-state CPU on a small instance.

    Layer two is production. The same Python process serializes each record using an Avro schema fetched from the Schema Registry, then hands it to a confluent-kafka-python producer configured for durability and throughput. The producer batches records, compresses them with lz4, and sends them to the broker with acks=all.

    Layer three is the broker. Kafka persists the records to a topic called server.metrics.v1, partitioned by hostname. Replication factor three ensures no data loss on broker failure. The topic has a retention of 72 hours, which is sufficient to replay into a new consumer without exhausting broker disk.

    Layer four is consumption. Multiple independent consumer groups read from the topic. One writes to InfluxDB for long-term storage, one runs Flink jobs for windowed aggregations and anomaly detection, and one feeds a lightweight alerting service. Each may be deployed, restarted, or replaced without affecting the others. For a local Kafka environment suitable for development, the Docker containers guide covers the necessary container basics.

    Tip: The collector process on each server should be kept as small and uneventful as possible. It should contain no feature flags and no complex routing logic — only sample, serialize, and produce. Substantive logic belongs in consumers, where it can be modified without touching every server in the fleet.

    Collecting Server Metrics with psutil

    The psutil library is the appropriate tool for cross-platform metric collection in Python. It provides CPU, memory, disk, and network statistics through a consistent interface that operates identically on Linux, macOS, and Windows. One rule must be observed: many of its counters are cumulative — for example, psutil.net_io_counters() returns total bytes since boot rather than bytes per second — so a delta between two consecutive samples is required to derive a rate.

    The following is a clean collector that captures a multivariate sample at each tick:

    import socket
    import time
    from dataclasses import dataclass, asdict
    from typing import Optional
    
    import psutil
    
    
    @dataclass
    class MetricSample:
        host: str
        timestamp_ms: int
        cpu_percent: float
        cpu_user: float
        cpu_system: float
        cpu_iowait: float
        mem_percent: float
        mem_used_bytes: int
        mem_available_bytes: int
        swap_percent: float
        disk_read_bytes_per_sec: float
        disk_write_bytes_per_sec: float
        disk_read_iops: float
        disk_write_iops: float
        net_rx_bytes_per_sec: float
        net_tx_bytes_per_sec: float
        net_rx_packets_per_sec: float
        net_tx_packets_per_sec: float
        load_1m: float
        load_5m: float
        load_15m: float
    
    
    class MetricCollector:
        def __init__(self, interval_seconds: float = 1.0):
            self.interval = interval_seconds
            self.host = socket.gethostname()
            self._prev_disk = psutil.disk_io_counters()
            self._prev_net = psutil.net_io_counters()
            self._prev_time = time.monotonic()
            # First CPU call is non-blocking and returns 0.0; prime it.
            psutil.cpu_percent(interval=None)
            psutil.cpu_times_percent(interval=None)
    
        def sample(self) -> MetricSample:
            now = time.monotonic()
            elapsed = max(now - self._prev_time, 1e-6)
    
            cpu_pct = psutil.cpu_percent(interval=None)
            cpu_times = psutil.cpu_times_percent(interval=None)
            vm = psutil.virtual_memory()
            sm = psutil.swap_memory()
            load = psutil.getloadavg()
    
            disk = psutil.disk_io_counters()
            d_read_b = (disk.read_bytes - self._prev_disk.read_bytes) / elapsed
            d_write_b = (disk.write_bytes - self._prev_disk.write_bytes) / elapsed
            d_read_iops = (disk.read_count - self._prev_disk.read_count) / elapsed
            d_write_iops = (disk.write_count - self._prev_disk.write_count) / elapsed
    
            net = psutil.net_io_counters()
            n_rx_b = (net.bytes_recv - self._prev_net.bytes_recv) / elapsed
            n_tx_b = (net.bytes_sent - self._prev_net.bytes_sent) / elapsed
            n_rx_p = (net.packets_recv - self._prev_net.packets_recv) / elapsed
            n_tx_p = (net.packets_sent - self._prev_net.packets_sent) / elapsed
    
            self._prev_disk = disk
            self._prev_net = net
            self._prev_time = now
    
            return MetricSample(
                host=self.host,
                timestamp_ms=int(time.time() * 1000),
                cpu_percent=cpu_pct,
                cpu_user=cpu_times.user,
                cpu_system=cpu_times.system,
                cpu_iowait=getattr(cpu_times, "iowait", 0.0),
                mem_percent=vm.percent,
                mem_used_bytes=vm.used,
                mem_available_bytes=vm.available,
                swap_percent=sm.percent,
                disk_read_bytes_per_sec=d_read_b,
                disk_write_bytes_per_sec=d_write_b,
                disk_read_iops=d_read_iops,
                disk_write_iops=d_write_iops,
                net_rx_bytes_per_sec=n_rx_b,
                net_tx_bytes_per_sec=n_tx_b,
                net_rx_packets_per_sec=n_rx_p,
                net_tx_packets_per_sec=n_tx_p,
                load_1m=load[0],
                load_5m=load[1],
                load_15m=load[2],
            )
    

    Several details are worth highlighting. The implementation uses time.monotonic() for the elapsed calculation because it is immune to wall-clock adjustments; if NTP shifts the system clock backwards, time.time() deltas can become negative and produce meaningless rates. time.time() is still used for the sample timestamp itself because downstream consumers expect wall-clock time. getattr is used for iowait because it exists only on Linux; on macOS it silently returns zero.

    Regarding the hostname: augmenting it with cloud metadata (instance ID, region, availability zone) is strongly recommended when running on AWS, GCP, or Azure. Hostnames are acceptable as a partition key but can collide across environments, and during incident triage it is essential to identify the exact instance that emitted an anomalous value. The related article on managing metadata for time series signals describes this pattern in greater detail.

    Designing the Avro Message Schema

    Every production Kafka deployment eventually suffers from the absence of a schema, typically on the day another team adds a new field to the producer and the downstream consumer begins throwing KeyError exceptions in the middle of the night. Avro with a Schema Registry addresses this by making the schema a first-class part of the message itself. Producers register their schema once, and every message carries a five-byte prefix with the schema ID. Consumers use that ID to fetch the exact schema the producer used and deserialize deterministically. It is one of the most valuable components in the Kafka ecosystem, and it can be set up in approximately fifty lines of code.

    The following is the Avro schema for the multivariate sample. It should be saved as schemas/server_metric.avsc:

    {
      "type": "record",
      "name": "ServerMetric",
      "namespace": "com.aicodeinvest.metrics",
      "doc": "A multivariate sample of host-level server metrics.",
      "fields": [
        {"name": "host", "type": "string", "doc": "Hostname or instance ID"},
        {"name": "timestamp_ms", "type": "long", "doc": "Unix epoch ms"},
        {"name": "cpu_percent", "type": "double"},
        {"name": "cpu_user", "type": "double"},
        {"name": "cpu_system", "type": "double"},
        {"name": "cpu_iowait", "type": "double", "default": 0.0},
        {"name": "mem_percent", "type": "double"},
        {"name": "mem_used_bytes", "type": "long"},
        {"name": "mem_available_bytes", "type": "long"},
        {"name": "swap_percent", "type": "double", "default": 0.0},
        {"name": "disk_read_bytes_per_sec", "type": "double"},
        {"name": "disk_write_bytes_per_sec", "type": "double"},
        {"name": "disk_read_iops", "type": "double"},
        {"name": "disk_write_iops", "type": "double"},
        {"name": "net_rx_bytes_per_sec", "type": "double"},
        {"name": "net_tx_bytes_per_sec", "type": "double"},
        {"name": "net_rx_packets_per_sec", "type": "double"},
        {"name": "net_tx_packets_per_sec", "type": "double"},
        {"name": "load_1m", "type": "double"},
        {"name": "load_5m", "type": "double"},
        {"name": "load_15m", "type": "double"},
        {"name": "tags", "type": {"type": "map", "values": "string"}, "default": {}}
      ]
    }
    

    Three design decisions merit explanation. First, every field that is not strictly required carries a default. This makes schema evolution safe: if gpu_percent is added with a default of zero, older consumers unaware of GPUs can still deserialize new messages without crashing. The Schema Registry enforces this rule automatically when the compatibility mode is set to BACKWARD, which is the recommended configuration.

    Second, a free-form tags map is included. Tags hold values such as environment, region, team, and cluster ID — anything that varies between deployments and may be useful for downstream filtering. Keeping them in a map rather than as top-level fields permits new tags to be added without a schema change. A small serialization cost is incurred, but it is negligible compared with the operational overhead of coordinating schema updates.

    Third, nested records are avoided. Avro supports them, but flat schemas serialize faster, are easier to query in downstream SQL systems, and integrate more smoothly with Kafka Connect sinks. For metrics specifically, a flat schema is almost always the appropriate choice.

    Caution: Schema-evolution compatibility is directional. BACKWARD means new consumers can read old messages, FORWARD means old consumers can read new messages, and FULL means both. For metrics, BACKWARD is usually sufficient, but the team should agree on the mode before the first producer is deployed. Changing the compatibility mode on a running topic is operationally painful.

    Building the Kafka Producer

    The collector and the schema are now combined into a working producer. The implementation uses confluent-kafka-python, which wraps the production-proven librdkafka C library and is significantly faster than the pure-Python alternatives. For readers interested in the performance gap between Python and compiled languages on this kind of workload, the Python vs Rust comparison guide provides context, but for metric producers Python is almost always sufficient when the appropriate client is used.

    import json
    import logging
    import signal
    import sys
    import time
    from dataclasses import asdict
    
    from confluent_kafka import Producer, KafkaError
    from confluent_kafka.schema_registry import SchemaRegistryClient
    from confluent_kafka.schema_registry.avro import AvroSerializer
    from confluent_kafka.serialization import (
        SerializationContext,
        MessageField,
        StringSerializer,
    )
    
    from collector import MetricCollector, MetricSample
    
    log = logging.getLogger("kafka-metrics")
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    
    TOPIC = "server.metrics.v1"
    
    
    def load_schema(path: str) -> str:
        with open(path) as f:
            return f.read()
    
    
    def to_dict(sample: MetricSample, ctx) -> dict:
        return asdict(sample)
    
    
    def delivery_report(err, msg):
        if err is not None:
            log.error("delivery failed for key=%s: %s", msg.key(), err)
        # Success path is intentionally silent — we would drown in logs otherwise.
    
    
    def build_producer() -> Producer:
        conf = {
            "bootstrap.servers": "kafka-1:9092,kafka-2:9092,kafka-3:9092",
            "client.id": "metric-collector",
            # Durability
            "acks": "all",
            "enable.idempotence": True,
            "max.in.flight.requests.per.connection": 5,
            "retries": 10_000_000,
            "delivery.timeout.ms": 120_000,
            # Throughput
            "linger.ms": 20,
            "batch.size": 65_536,
            "compression.type": "lz4",
            # Memory bound
            "queue.buffering.max.messages": 100_000,
            "queue.buffering.max.kbytes": 1_048_576,
        }
        return Producer(conf)
    
    
    def main():
        sr_client = SchemaRegistryClient({"url": "http://schema-registry:8081"})
        avro_serializer = AvroSerializer(
            schema_registry_client=sr_client,
            schema_str=load_schema("schemas/server_metric.avsc"),
            to_dict=to_dict,
        )
        key_serializer = StringSerializer("utf_8")
    
        producer = build_producer()
        collector = MetricCollector(interval_seconds=1.0)
    
        running = True
    
        def shutdown(signum, frame):
            nonlocal running
            log.info("shutdown requested, flushing producer...")
            running = False
    
        signal.signal(signal.SIGTERM, shutdown)
        signal.signal(signal.SIGINT, shutdown)
    
        next_tick = time.monotonic()
        try:
            while running:
                sample = collector.sample()
                key = key_serializer(sample.host)
                value = avro_serializer(
                    sample,
                    SerializationContext(TOPIC, MessageField.VALUE),
                )
                producer.produce(
                    topic=TOPIC,
                    key=key,
                    value=value,
                    timestamp=sample.timestamp_ms,
                    on_delivery=delivery_report,
                )
                # Serve delivery callbacks without blocking.
                producer.poll(0)
    
                next_tick += collector.interval
                sleep_for = next_tick - time.monotonic()
                if sleep_for > 0:
                    time.sleep(sleep_for)
                else:
                    # Fell behind; log once and resync.
                    log.warning("collector is behind by %.3fs", -sleep_for)
                    next_tick = time.monotonic()
        finally:
            remaining = producer.flush(timeout=30)
            if remaining > 0:
                log.error("%d messages undelivered at shutdown", remaining)
                sys.exit(1)
            log.info("clean shutdown")
    
    
    if __name__ == "__main__":
        main()
    

    Each of the configuration choices warrants explanation because each contributes specific behaviour.

    acks=all instructs the broker to wait until all in-sync replicas have written the message before acknowledging. Combined with enable.idempotence=true, this provides exactly-once semantics at the producer level: retries will not duplicate messages even if the network drops an acknowledgment. This is the single most important configuration for durability and should not be disabled outside of throwaway demonstrations.

    linger.ms=20 instructs the producer to wait up to twenty milliseconds before sending a batch, even when the batch is not full. This represents a throughput-versus-latency tradeoff. For metrics sampled at 1 Hz, the additional latency is negligible, while throughput can increase by a factor of five to ten because network and serialization overhead is amortised across many records.

    batch.size=65536 sets the maximum size of a single batch. With twenty milliseconds of linger and a reasonable message rate, each batch typically fills before the timer fires.

    compression.type=lz4 is the recommended default for metrics. It compresses repetitive numeric data well (often by a factor of three to five) and is faster than both snappy and zstd at reasonable compression levels. Benchmarking against actual data is advisable, but lz4 rarely underperforms.

    The table below summarises how these configuration choices trade off, together with common alternatives:

    Setting Value Tradeoff
    acks all Durability over latency. Worth every millisecond.
    enable.idempotence true Exactly-once producer semantics. No duplicates on retry.
    linger.ms 20 Up to 20ms extra latency for 5–10x throughput.
    compression.type lz4 Fastest high-ratio compression for numeric data.
    batch.size 65,536 Large batches amortize network costs.
    max.in.flight 5 Max allowed with idempotence. Higher values are rejected.

     

    Kafka Producer Data Flow Metric sample dataclass Avro serializer schema id + bytes Partitioner hash(key) Producer Buffer batch: linger.ms=20 compress: lz4 batch.size=64KB Broker (partition N) replicate + fsync ack path (acks=all)—broker confirms after all ISR replicas have written idempotent producer guarantees no duplicates on retry · sticky partitioner keeps records in-order per host

    Partitioning Strategy for Time Series

    Selecting the wrong partition key is the most common and most damaging mistake in a Kafka time-series deployment. The challenge is that partitioning has two competing goals: records from the same logical entity should land on the same partition so that their order is preserved, while load should be distributed evenly across partitions so that no single partition becomes a hotspot. For time series, one tempting choice is to use the timestamp. The timestamp should never be used as a partition key. A monotonic timestamp creates a pathological pattern in which every new record lands on whichever partition is currently hottest, producing a rolling hotspot that shifts across partitions over time.

    The partition keys that work well for multivariate server metrics are all variations on the same principle: key by the source of the data. The main options are:

    Strategy Good for Watch out for
    hostname Most fleets. Preserves per-host ordering. Imbalance if one host is much busier.
    cluster_id + hostname Multi-tenant setups where clusters are the billing unit. Cluster-sized hot spots.
    metric_family When consumers only care about one family. Small number of partitions—only as many as families.
    random/sticky Perfectly even load, no ordering needs. Loses per-host ordering.
    timestamp Never. Rolling hot spots, reprocessing nightmares.

     

    For nearly every deployment encountered in practice, partitioning by hostname is the correct default. It preserves per-host ordering (which matters because consumers often perform stateful work per host, such as anomaly detection), and it distributes load evenly as long as the partition count is comfortably larger than the host count. The modern Kafka client defaults to the sticky partitioner for records without a key, which is a useful throughput optimisation; since a key is being provided here, that optimisation does not apply, and records are routed to hash(hostname) % partition_count.

    One recommendation is particularly important: set the partition count to a round number that is comfortably larger than the current fleet and grows in increments of five or ten — for example, thirty, fifty, or one hundred, rather than twenty-three or forty-seven. Kafka supports adding partitions to a topic, but doing so changes the hash mapping for keyed records, which is a substantive operational disruption. Begin with headroom.

    Caution: Adding partitions to a keyed topic breaks ordering guarantees for records in flight at the moment of the change. If consumers depend on per-host ordering — and most do — adding partitions requires a coordinated drain-and-restart across all consumers. Plan the partition count once, generously, and leave it unchanged.

    Topic Design and Retention

    The question of whether to use one topic for all metrics or a topic per metric family arises frequently. The answer for multivariate time series is almost always one topic. The very purpose of capturing correlated signals together is that downstream consumers require them together. Splitting them into separate topics forces every consumer to join across topics to reconstruct a sample, which is precisely the complexity Kafka is intended to mitigate.

    Exceptions are rare but real. When fundamentally different data types have different retention or sizing requirements — for example, high-frequency metrics and low-frequency events — placing them in separate topics is reasonable, because different retention policies typically apply. Within “host metrics” itself, however, one topic is the right answer.

    The following is a reasonable topic configuration for a production multivariate metrics topic, applied with kafka-topics.sh:

    kafka-topics.sh --bootstrap-server kafka-1:9092 \
      --create \
      --topic server.metrics.v1 \
      --partitions 50 \
      --replication-factor 3 \
      --config retention.ms=259200000 \
      --config segment.bytes=536870912 \
      --config compression.type=producer \
      --config min.insync.replicas=2 \
      --config cleanup.policy=delete \
      --config max.message.bytes=1048576
    

    The significant settings are as follows: retention.ms=259200000 retains data for three days, which is sufficient to reprocess into a new sink or recover from a downstream outage without exhausting broker disks. segment.bytes=536870912 (512 MiB) controls when a new log segment is rolled; larger segments mean fewer files and faster startup but coarser cleanup granularity. compression.type=producer instructs the broker to store messages in whatever format the producer sent, avoiding unnecessary decompress-recompress cycles. min.insync.replicas=2 combined with acks=all on the producer is what actually provides durability; acks=all alone offers no guarantee if only one replica is in sync.

    Finally, cleanup.policy=delete is almost always appropriate for metrics. Log compaction (the alternative) retains the latest record per key, which is suitable for changelog streams but inappropriate for time series, where every record matters.

    Consumer Patterns and Downstream Sinks

    Once data is in Kafka, consumers are comparatively straightforward. The following is a minimal consumer that reads multivariate samples and writes them to InfluxDB. For an end-to-end treatment of that pipeline, the article on InfluxDB to Iceberg with Telegraf covers the long-term storage side in depth.

    from confluent_kafka import Consumer
    from confluent_kafka.schema_registry import SchemaRegistryClient
    from confluent_kafka.schema_registry.avro import AvroDeserializer
    from confluent_kafka.serialization import SerializationContext, MessageField
    from influxdb_client import InfluxDBClient, Point, WriteOptions
    
    TOPIC = "server.metrics.v1"
    
    sr_client = SchemaRegistryClient({"url": "http://schema-registry:8081"})
    avro_deser = AvroDeserializer(schema_registry_client=sr_client)
    
    consumer = Consumer({
        "bootstrap.servers": "kafka-1:9092",
        "group.id": "influxdb-sink",
        "auto.offset.reset": "latest",
        "enable.auto.commit": False,
        "max.poll.interval.ms": 300_000,
        "session.timeout.ms": 30_000,
    })
    consumer.subscribe([TOPIC])
    
    influx = InfluxDBClient(url="http://influxdb:8086", token="...", org="aic")
    write_api = influx.write_api(write_options=WriteOptions(batch_size=5_000, flush_interval=2_000))
    
    try:
        while True:
            msg = consumer.poll(1.0)
            if msg is None:
                continue
            if msg.error():
                print(f"consumer error: {msg.error()}")
                continue
    
            record = avro_deser(
                msg.value(),
                SerializationContext(msg.topic(), MessageField.VALUE),
            )
            point = (
                Point("server_metrics")
                .tag("host", record["host"])
                .field("cpu_percent", record["cpu_percent"])
                .field("mem_percent", record["mem_percent"])
                .field("disk_read_bps", record["disk_read_bytes_per_sec"])
                .field("disk_write_bps", record["disk_write_bytes_per_sec"])
                .field("net_rx_bps", record["net_rx_bytes_per_sec"])
                .field("net_tx_bps", record["net_tx_bytes_per_sec"])
                .field("load_1m", record["load_1m"])
                .time(record["timestamp_ms"], "ms")
            )
            write_api.write(bucket="metrics", record=point)
            consumer.commit(msg, asynchronous=True)
    finally:
        consumer.close()
        write_api.close()
        influx.close()
    

    Several consumer-side details matter. Auto-commit is disabled because commits should be tied to successful downstream writes; the pattern is “write, then commit the offset that was just written,” which provides at-least-once semantics end to end. The InfluxDB write API is used with batching for the same reason batching is used on the producer: per-record writes are slow, while batches are fast.

    For more sophisticated consumers — particularly anything that requires windowing, joins, or complex event patterns — a plain consumer should be replaced by a full stream processor. Flink CEP is a common choice; the Flink CEP pipeline guide describes precisely the kind of pattern that can be built on top of this Kafka topic.

    Production Concerns

    Everything described above works in a demonstration. To run it in production, five additional concerns must be addressed: monitoring consumer lag, handling backpressure at the producer, handling broker failures, managing exactly-once semantics, and capacity planning.

    Consumer lag is the single most important metric to monitor on this pipeline. It indicates whether consumers are keeping pace with producers. The standard tool is kafka-consumer-groups.sh, but continuous monitoring is better served by Kafka’s built-in JMX metrics or a tool such as Burrow or Kafka Exporter feeding Prometheus. Alerts should fire on sustained lag growth rather than absolute lag values; a transient bump during a deployment is normal, while lag that has been growing for five minutes is a problem.

    Backpressure at the producer appears as a full internal queue. In confluent-kafka-python, producer.produce() raises BufferError when the queue is full. Two responses are possible: block until space becomes available (which eventually blocks the metric collector), or drop samples (which produces gaps but keeps the collector responsive). For metrics, the first option, bounded by a timeout, is usually preferable, because dropped samples can conceal incidents. The pattern is as follows:

    from confluent_kafka import KafkaException
    
    def produce_with_backpressure(producer, topic, key, value, ts):
        for attempt in range(3):
            try:
                producer.produce(
                    topic=topic, key=key, value=value, timestamp=ts,
                    on_delivery=delivery_report,
                )
                return
            except BufferError:
                # Internal queue is full; poll to serve callbacks and drain.
                producer.poll(0.5)
        log.error("dropping sample for %s after 3 backpressure retries", key)
    

    Broker failures are handled automatically by the client when the configuration is correct. With acks=all, enable.idempotence=true, and retries set to an effectively unbounded value, a broker outage causes the producer to retain messages in its buffer and retry until a new leader is elected. The delivery.timeout.ms setting is the ultimate deadline; messages older than that are considered failed and returned through the delivery callback.

    Exactly-once semantics is overloaded terminology. The producer provides exactly-once delivery to the broker through idempotence. End-to-end exactly-once from producer to downstream sink requires the sink to be idempotent as well — either because it is naturally idempotent (upserts, deduplication by key plus timestamp) or because it participates in Kafka transactions. For metrics, full transactions are rarely required; at-least-once plus an idempotent sink (the InfluxDB write API is one such sink) is usually sufficient, because writing the same point twice merely overwrites it with the same value.

    Producer Tuning and Throughput

    The most important lever on producer throughput is how records are batched and compressed. The table below describes, qualitatively, how each successive configuration change affects throughput and latency for a single Python producer sending small (a few hundred bytes) metric records. Absolute numbers depend heavily on hardware, broker count, record size, and partition count, so the table shows the direction and relative magnitude of each change rather than figures from any single run.

    Configuration Relative throughput Typical latency Tail latency
    No batching, no compression Lowest (baseline) Lowest Lowest
    linger.ms=5, snappy Substantially higher Slightly higher Comparable
    linger.ms=20, lz4 Higher still Moderately higher Moderately higher
    linger.ms=50, lz4, 128KB batches Highest Highest Highest, still bounded

     

    Several observations follow. First, batching and compression together improve throughput by more than an order of magnitude over the naive configuration. Second, the latency cost is real but modest; even at the most aggressive setting, tail latency remains within a range that is acceptable for metrics. Third, a single well-tuned Python producer on modest hardware can sustain a high enough rate that one producer comfortably handles a fleet of hundreds or thousands of hosts at 1 Hz sampling. Running one producer per server is not required when aggregation is preferred.

    Compression ratios on metric data also merit attention. Repetitive numeric records compress well under lz4 — commonly by a factor of three to five — which reduces network and broker disk cost proportionally. In a large fleet this represents one of the largest savings in the entire pipeline.

    Key Takeaway: The defaults in confluent-kafka-python are conservative. Setting linger.ms, batch.size, and compression.type can improve producer throughput by more than an order of magnitude over the defaults. These three settings should be tuned first, with all other adjustments following.

    Frequently Asked Questions

    Why Kafka instead of writing directly to InfluxDB or TimescaleDB?

    Direct-to-database works until something breaks. When the database is down for maintenance, your collector crashes or backs up. When you want to add a second consumer—say, an alerting service—you either double-write from the collector (error-prone) or read back from the database (slow and fragile). Kafka puts a durable, replayable buffer between producers and consumers, which decouples the failure modes of the two sides. For a small single-sink deployment, direct writes are fine. For anything where observability matters during incidents, Kafka is worth the extra moving part.

    How many messages per second can a single Python producer handle?

    With the configuration in this post (linger.ms=20, lz4 compression, and larger batches), a single well-tuned confluent-kafka-python producer on modest hardware sustains throughput more than an order of magnitude above the conservative defaults, which is comfortably more than enough for a fleet of thousands of hosts at 1Hz sampling. If more is needed, the usual answer is not a faster producer, it is multiple producers, one per host or one per small group of hosts, which also gives better fault isolation.

    Should I use one topic or multiple topics for different metric types?

    For multivariate metrics that are correlated and consumed together, use one topic. Splitting them into separate topics forces downstream consumers to join across topics, which defeats the purpose of capturing multivariate data in the first place. Use separate topics only when the data has genuinely different retention, sizing, or consumer profiles—for example, high-frequency metrics versus low-frequency events, or metrics versus logs.

    How do I handle schema evolution when adding new metrics?

    Set your Schema Registry compatibility mode to BACKWARD. When adding a field, give it a default value in the Avro schema. This lets new consumers read old messages (with the default filled in) and lets old consumers safely ignore the new field. Deploy the schema change to the registry first, then deploy the producer change, then deploy the consumer change—in that order. Never remove a field without first making sure no active consumer reads it.

    What partitioning key should I use for multivariate time series?

    Partition by hostname (or instance ID) in almost every case. This preserves per-host ordering, which is what stateful consumers like anomaly detectors need, and it distributes load evenly as long as your partition count is comfortably larger than your host count. Never use the timestamp as a partition key, monotonic timestamps create rolling hot spots where each new batch of records lands on the same partition.

    Concluding Observations

    Building a Kafka-based engine for multivariate time series is one of those projects that appears excessive on day one and proves foundational by month three. The core ideas are straightforward: collect correlated signals together, serialize them with a schema, partition by source, tune the producer for throughput, and allow Kafka to serve as the durable spine that decouples collectors from consumers. Everything else — the choice of time-series database, the streaming framework, the anomaly detectors and dashboards — is a downstream decision that can be changed without touching the engine itself. That decoupling is the real product, not any individual element of the pipeline.

    Three specific actions follow from this discussion: set acks=all and enable.idempotence=true on every producer; partition by hostname rather than timestamp; and always register schemas with a Schema Registry configured for BACKWARD compatibility. These three choices alone prevent the majority of outages observed on observability pipelines over many years. The remainder of this post represents optimisation and refinement — beneficial but not essential.

    A final observation: this engine is a starting point rather than an endpoint. Once multivariate metrics flow reliably through Kafka, the substantive work begins — anomaly detection, capacity forecasting, automated remediation, and correlation with business metrics. Kafka is the unobtrusive, reliable infrastructure that enables all of this. When built carefully and left alone, it can operate quietly for years while more sophisticated systems are built on top of it.

    References

  • Clean Code Principles: Writing Maintainable Software That Lasts

    The Short Version

    Maintainability is decided less by how software is written than by how easily it is later read, and reading dominates the working day: one large field study of 78 professional developers measured roughly 58 percent of working time spent on program comprehension rather than on editing (Xia et al., 2018). The chapters that follow treat clean code as the single discipline of lowering that comprehension cost, worked through intent-revealing names, small single-purpose functions, the SOLID and DRY/KISS/YAGNI principles, disciplined refactoring of common code smells, tests used as executable documentation, and inward-pointing architectural boundaries. The recurring claim is deliberately modest: the cheapest maintainable codebase is the one a future reader, including the author six months later, can understand without re-deriving it. The Boy Scout Rule, leaving each touched file slightly cleaner than it was found, is offered as the realistic path there, because compounding small improvements outperform any deferred large-scale rewrite.

    Most of a working programmer’s day is not spent producing new code. In a field study that instrumented 78 professional developers across seven projects for more than three thousand working hours, roughly 58 percent of that time went to program comprehension, that is, reading, navigating, and reconstructing the intent of code that already existed, rather than to writing or editing it (Xia et al., 2018). The exact proportion varies by team, language, and seniority, but the ordering does not: code is read far more often than it is written, and the reader is frequently a different person from the author, or the same person after enough months to have forgotten the original reasoning. When existing code is disorganized, poorly named, and entangled with hidden dependencies, that reading is slow and error-prone. When it is deliberate and well-structured, comprehension approaches the effort of reading ordinary prose.

    The cost of getting this wrong is measurable at national scale. The Consortium for Information & Software Quality (CISQ) estimated that poor software quality cost US organizations $2.41 trillion in 2022, of which accumulated technical debt accounted for roughly $1.52 trillion, described in that report as the single largest obstacle to changing existing systems. Behind those aggregates lie ordinary outcomes: missed deadlines, features that ship late, and teams that slow to a crawl because every change must first navigate code that no one fully understands.

    Robert C. Martin, the author of Clean Code, compressed the argument into a single sentence: “The only way to go fast is to go well.” Clean code, on that view, is neither perfectionism nor aesthetic preference but pragmatic craftsmanship, the writing of software that a future maintainer can understand, change, and extend without dread. The sections that follow work through the principles, patterns, and habits that separate code which ages gracefully from code that collapses under accumulated shortcuts.

    Key Takeaway: Clean code is not a matter of writing less code or producing aesthetically pleasing output. It is a matter of reducing the cognitive load required to understand, modify, and extend software over its lifetime.

    Why Clean Code Matters

    Every codebase tells a story. Some convey careful thought and deliberate design. Others convey haste, shortcuts, and “we will fix it later” promises that are never fulfilled. The difference between these two narratives has profound consequences for teams, products, and businesses.

    The Reality of Technical Debt

    Ward Cunningham coined the term “technical debt” in 1992 as a metaphor for the accumulated cost of shortcuts in software development. Like financial debt, technical debt accrues interest. The longer messy code remains in place, the more expensive any change to it becomes. A brief shortcut that saves two hours today may cost a team two weeks six months later when a feature must be built on top of it.

    The aggregate cost of low-quality software has been measured more than once. The three findings below are grouped in one place precisely because each traces to a named, datable source rather than to folklore; readers should treat any uncited “developers waste X percent” figure with suspicion, including the ones that circulate widely without provenance.

    Finding Figure Source
    Share of developer time spent comprehending existing code approximately 58 percent (78 professionals, 3,148 working hours) Xia et al., IEEE TSE, 2018
    Time spent reading code relative to writing it well over ten to one Martin, Clean Code, 2008
    Cost of poor software quality, United States, 2022 $2.41 trillion total, of which roughly $1.52 trillion is technical debt CISQ, 2022

     

    The Maintenance Equation

    Software maintenance typically accounts for 60 to 80 percent of total software costs over a product’s lifetime. The code written today will be read, debugged, and modified hundreds of times in the years ahead. Every minute invested in writing clean code pays dividends across all of those future interactions.

    Consider the arithmetic: if a function requires 5 minutes to understand because it is well-named and well-structured, versus 30 minutes because it is tangled, and that function is read 200 times over its lifetime, then either 16 hours or 100 hours of cumulative developer time has been consumed by comprehension alone. This is the value of clean code: an investment that compounds over time.

    In real-world application development, whether the work involves creating REST APIs with FastAPI or deploying services with Docker containers, clean-code principles remain the foundation that determines whether a project flourishes or is overwhelmed by complexity.

    The Art of Meaningful Names

    Naming is among the most difficult problems in computer science, not because it requires deep algorithmic thinking, but because it demands empathy and clarity. A good name informs the reader of what a variable holds, what a function does, or what a class represents without requiring inspection of the implementation. A poor name compels the reader to act as a detective.

    Variable Names That Reveal Intent

    The name of a variable should answer three questions: what it represents, why it exists, and how it is used. If a name requires a comment to explain it, the name is not good enough.

    # Bad: What do these variables mean?
    d = 7
    t = []
    flag = True
    temp = get_data()
    
    # Good: Names reveal intent
    days_until_deadline = 7
    active_transactions = []
    is_user_authenticated = True
    unprocessed_orders = get_pending_orders()

    The “good” examples eliminate the need for mental translation. When the reader encounters days_until_deadline, the purpose, type (a number), and context (something time-related) are immediately apparent. When the reader encounters d, nothing can be inferred.

    Function Names That Describe Behaviour

    Functions should be named with verbs or verb phrases that describe what they do. A function name should make its behaviour predictable; the reader should have a clear expectation of what the function does before reading its body.

    # Bad: Vague, ambiguous names
    def process(data):
        ...
    
    def handle(item):
        ...
    
    def do_stuff(x, y):
        ...
    
    # Good: Names describe specific behavior
    def calculate_monthly_revenue(transactions):
        ...
    
    def send_password_reset_email(user):
        ...
    
    def validate_credit_card_number(card_number):
        ...

    Class Names That Represent Concepts

    Classes should be named with nouns or noun phrases. They represent things, entities, concepts, or services. A well-named class communicates its role in the system immediately.

    # Bad: Generic or misleading class names
    class Manager:        # Manager of what?
    class Data:           # What kind of data?
    class Helper:         # Helps with what?
    class Processor:      # Processes what, how?
    
    # Good: Specific, descriptive class names
    class PaymentGateway:
    class UserRepository:
    class EmailNotificationService:
    class OrderValidator:
    Tip: Difficulty in naming a function or class often indicates that it performs too many distinct tasks. Difficulty in naming is a design smell; the entity likely needs to be decomposed into smaller, more focused pieces.

    Naming Convention Quick Reference

    Element Convention Examples
    Variables Nouns, descriptive, lowercase with underscores user_count, max_retry_attempts
    Booleans Prefix with is_, has_, can_, should_ is_active, has_permission
    Functions Verbs, describe action performed calculate_tax(), send_email()
    Classes Nouns, PascalCase, represent concepts UserAccount, PaymentProcessor
    Constants ALL_CAPS with underscores MAX_CONNECTIONS, API_BASE_URL
    Private members Leading underscore prefix _internal_cache, _validate()

     

    Function Design: Small, Focused, and Purposeful

    Functions are the building blocks of any program. When they are small, focused, and well-designed, code reads as a clear narrative. When they are bloated and perform multiple tasks simultaneously, code reads as a run-on sentence without conclusion.

    One Function, One Job

    The Single Responsibility Principle (SRP) applies to functions as fully as it applies to classes. A function should do one thing, do it well, and do only that. If a function’s behaviour can be described only by use of the word “and,” it probably does too much.

    # Bad: This function does too many things
    def process_order(order):
        # Validate the order
        if not order.items:
            raise ValueError("Order has no items")
        if order.total < 0:
            raise ValueError("Invalid total")
    
        # Calculate tax
        tax_rate = get_tax_rate(order.shipping_address.state)
        tax = order.subtotal * tax_rate
        order.tax = tax
        order.total = order.subtotal + tax
    
        # Charge payment
        payment_result = stripe.charge(order.payment_method, order.total)
        if not payment_result.success:
            raise PaymentError(payment_result.error)
    
        # Update inventory
        for item in order.items:
            product = Product.find(item.product_id)
            product.stock -= item.quantity
            product.save()
    
        # Send confirmation
        email = build_confirmation_email(order)
        send_email(order.customer.email, email)
    
        # Log the transaction
        log_transaction(order, payment_result)
    
        return order

    This function validates, calculates, charges, updates inventory, sends emails, and logs, comprising six distinct responsibilities. The clean version is shown below:

    # Good: Each function has a single responsibility
    def process_order(order):
        validate_order(order)
        apply_tax(order)
        charge_payment(order)
        update_inventory(order)
        send_order_confirmation(order)
        log_transaction(order)
        return order
    
    def validate_order(order):
        if not order.items:
            raise ValueError("Order has no items")
        if order.total < 0:
            raise ValueError("Invalid total")
    
    def apply_tax(order):
        tax_rate = get_tax_rate(order.shipping_address.state)
        order.tax = order.subtotal * tax_rate
        order.total = order.subtotal + order.tax
    
    def charge_payment(order):
        result = stripe.charge(order.payment_method, order.total)
        if not result.success:
            raise PaymentError(result.error)
        order.payment_confirmation = result.confirmation_id
    
    def update_inventory(order):
        for item in order.items:
            product = Product.find(item.product_id)
            product.reduce_stock(item.quantity)
    
    def send_order_confirmation(order):
        email = build_confirmation_email(order)
        send_email(order.customer.email, email)

    The refactored version reads as a coherent sequence. Each function name indicates exactly what occurs at that step. The entire order-processing flow can be understood by reading the process_order function alone; there is no need to parse 40 lines of implementation detail.

    Minimize Function Parameters

    The ideal number of function parameters is zero. One is acceptable. Two is tolerable. Three should be avoided where possible. More than three requires strong justification.

    The reason is that every parameter increases cognitive load. The signature create_user(name, email, age, role, department, manager_id, start_date) requires the reader to remember the order, meaning, and expected type of seven arguments. This is a frequent source of bugs.

    # Bad: Too many parameters
    def create_report(title, start_date, end_date, format, include_charts,
                      department, author, confidential, recipients):
        ...
    
    # Good: Group related parameters into objects
    @dataclass
    class ReportConfig:
        title: str
        date_range: DateRange
        format: ReportFormat = ReportFormat.PDF
        include_charts: bool = True
    
    @dataclass
    class ReportMetadata:
        department: str
        author: str
        confidential: bool = False
        recipients: list[str] = field(default_factory=list)
    
    def create_report(config: ReportConfig, metadata: ReportMetadata):
        ...
    Caution: Boolean flag parameters constitute a particularly strong code smell. A function such as render(data, True) forces the reader to look up the function signature to determine what True means. Splitting the function into two, such as render_with_header(data) and render_without_header(data), is preferable.

    How Long Should a Function Be?

    There is no universal rule, but most practitioners of clean code agree that functions should rarely exceed 20 lines. If a function requires scrolling to read, it is too long. Robert C. Martin suggests that functions should comprise four to six lines. Although this may appear extreme, the principle is sound: shorter functions are easier to understand, test, and reuse.

    The key metric is not line count but levels of abstraction. A function should operate at a single level of abstraction. If it mixes high-level orchestration ("process the order") with low-level details ("parse the CSV field at column 7"), it requires decomposition.

    SOLID Principles in Practice

    The SOLID principles, introduced by Robert C. Martin and later named by Michael Feathers, are five design principles that guide developers toward code that is flexible, maintainable, and resilient to change. These principles are not abstract theory; they are practical tools that address real problems.

    SOLID Principles S Single Responsibility Principle A class should have only one reason to change. Each module owns exactly one responsibility. O Open/Closed Principle Open for extension, closed for modification. Add new behavior without changing existing code. L Liskov Substitution Principle Subtypes must be substitutable for their base types without altering program correctness. I Interface Segregation Principle No client should be forced to depend on methods it does not use. Prefer small, focused interfaces. D Dependency Inversion Principle Depend on abstractions, not concretions. High-level modules should not depend on low-level modules.

    Single Responsibility Principle (SRP)

    "A class should have one, and only one, reason to change." This does not mean that a class should have only one method; it means that it should have only one axis of change. If changes to database logic and changes to email formatting both require modifying the same class, that class has two responsibilities.

    # Bad: This class has multiple responsibilities
    class UserService:
        def create_user(self, name, email):
            # Validation logic
            if not re.match(r'^[\w.-]+@[\w.-]+\.\w+$', email):
                raise ValueError("Invalid email")
    
            # Database logic
            user = User(name=name, email=email)
            self.db.session.add(user)
            self.db.session.commit()
    
            # Email logic
            subject = "Welcome!"
            body = f"Hello {name}, welcome to our platform."
            self.smtp.send(email, subject, body)
    
            # Logging logic
            self.logger.info(f"Created user: {email}")
    
            return user
    
    # Good: Each class has one responsibility
    class UserValidator:
        def validate_email(self, email: str) -> bool:
            return bool(re.match(r'^[\w.-]+@[\w.-]+\.\w+$', email))
    
    class UserRepository:
        def save(self, user: User) -> User:
            self.db.session.add(user)
            self.db.session.commit()
            return user
    
    class WelcomeEmailSender:
        def send(self, user: User):
            subject = "Welcome!"
            body = f"Hello {user.name}, welcome to our platform."
            self.email_service.send(user.email, subject, body)
    
    class UserService:
        def __init__(self, validator, repository, email_sender):
            self.validator = validator
            self.repository = repository
            self.email_sender = email_sender
    
        def create_user(self, name: str, email: str) -> User:
            self.validator.validate_email(email)
            user = self.repository.save(User(name=name, email=email))
            self.email_sender.send(user)
            return user

    Open/Closed Principle (OCP)

    Software entities should be open for extension but closed for modification. In practice, this means that new behaviour should be addable to a system without changing existing, tested code.

    # Bad: Adding a new payment method requires modifying existing code
    class PaymentProcessor:
        def process(self, payment_type, amount):
            if payment_type == "credit_card":
                return self._charge_credit_card(amount)
            elif payment_type == "paypal":
                return self._charge_paypal(amount)
            elif payment_type == "crypto":       # Must modify this class!
                return self._charge_crypto(amount)
    
    # Good: New payment methods extend the system without modifying it
    from abc import ABC, abstractmethod
    
    class PaymentMethod(ABC):
        @abstractmethod
        def charge(self, amount: Decimal) -> PaymentResult:
            pass
    
    class CreditCardPayment(PaymentMethod):
        def charge(self, amount: Decimal) -> PaymentResult:
            # Credit card specific logic
            ...
    
    class PayPalPayment(PaymentMethod):
        def charge(self, amount: Decimal) -> PaymentResult:
            # PayPal specific logic
            ...
    
    class CryptoPayment(PaymentMethod):  # Just add a new class!
        def charge(self, amount: Decimal) -> PaymentResult:
            # Crypto specific logic
            ...
    
    class PaymentProcessor:
        def process(self, method: PaymentMethod, amount: Decimal):
            return method.charge(amount)

    Liskov Substitution Principle (LSP)

    Subtypes must be substitutable for their base types. If a function operates with a base class, it should operate with any derived class without distinguishing between them. The classic violation is the Rectangle/Square problem: a Square that inherits from Rectangle but breaks the contract when width is set independently of height.

    Interface Segregation Principle (ISP)

    No client should be forced to depend on methods it does not use. Rather than a single large interface, several small, focused interfaces should be created.

    # Bad: Fat interface forces implementations to handle irrelevant methods
    class Worker(ABC):
        @abstractmethod
        def code(self): pass
    
        @abstractmethod
        def test(self): pass
    
        @abstractmethod
        def design(self): pass
    
        @abstractmethod
        def manage_team(self): pass  # Not all workers manage teams!
    
    # Good: Segregated interfaces
    class Coder(ABC):
        @abstractmethod
        def code(self): pass
    
    class Tester(ABC):
        @abstractmethod
        def test(self): pass
    
    class Designer(ABC):
        @abstractmethod
        def design(self): pass
    
    class TeamLead(Coder, Tester):
        def code(self): ...
        def test(self): ...
    
    class SeniorDeveloper(Coder, Tester, Designer):
        def code(self): ...
        def test(self): ...
        def design(self): ...

    Dependency Inversion Principle (DIP)

    High-level modules should not depend on low-level modules. Both should depend on abstractions. This principle is the foundation of dependency injection, which renders code testable and flexible.

    # Bad: High-level module depends directly on low-level module
    class OrderService:
        def __init__(self):
            self.database = MySQLDatabase()  # Tightly coupled!
            self.mailer = SmtpMailer()       # Tightly coupled!
    
    # Good: Both depend on abstractions
    class DatabasePort(ABC):
        @abstractmethod
        def save(self, entity): pass
    
    class MailerPort(ABC):
        @abstractmethod
        def send(self, to, subject, body): pass
    
    class OrderService:
        def __init__(self, database: DatabasePort, mailer: MailerPort):
            self.database = database  # Depends on abstraction
            self.mailer = mailer      # Depends on abstraction

    This pattern is particularly useful when selecting among different technology stacks; well-abstracted code permits implementations to be swapped without rewriting business logic.

    DRY, KISS, and YAGNI: The Guiding Triad

    Beyond SOLID, three additional principles form the philosophical backbone of clean code. They are simpler to state but deceptively difficult to practise consistently.

    DRY: Do Not Repeat Yourself

    "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system." When logic is duplicated, a maintenance burden is created: changes made in one location must be remembered in every other location. Such requirements are routinely forgotten.

    # Bad: Tax calculation logic duplicated
    class InvoiceGenerator:
        def calculate_total(self, subtotal, state):
            if state == "CA":
                tax = subtotal * 0.0725
            elif state == "NY":
                tax = subtotal * 0.08
            elif state == "TX":
                tax = subtotal * 0.0625
            return subtotal + tax
    
    class CartService:
        def estimate_total(self, subtotal, state):
            if state == "CA":
                tax = subtotal * 0.0725    # Same logic, duplicated!
            elif state == "NY":
                tax = subtotal * 0.08
            elif state == "TX":
                tax = subtotal * 0.0625
            return subtotal + tax
    
    # Good: Single source of truth for tax rates
    TAX_RATES = {"CA": 0.0725, "NY": 0.08, "TX": 0.0625}
    
    def calculate_tax(subtotal: Decimal, state: str) -> Decimal:
        rate = TAX_RATES.get(state, 0)
        return subtotal * rate
    
    class InvoiceGenerator:
        def calculate_total(self, subtotal, state):
            return subtotal + calculate_tax(subtotal, state)
    
    class CartService:
        def estimate_total(self, subtotal, state):
            return subtotal + calculate_tax(subtotal, state)
    Caution: DRY does not mean "never write similar-looking code." Two pieces of code that appear identical but represent different business concepts should remain separate. Combining them creates accidental coupling. The key question is whether a change to one necessarily requires a change to the other. If not, they are not true duplicates.

    KISS: Keep It Simple

    Simplicity is the ultimate sophistication. KISS reminds practitioners that the best solution is usually the simplest one that works. Over-engineering, the addition of layers of abstraction, design patterns, and frameworks before they are needed, is as harmful as under-engineering.

    # Over-engineered: AbstractSingletonProxyFactoryBean vibes
    class UserFilterStrategyFactoryProvider:
        def get_strategy_factory(self, context):
            factory = UserFilterStrategyFactory(context)
            return factory.create_strategy()
    
    # KISS: Just write the filter
    def get_active_users(users):
        return [user for user in users if user.is_active]

    Some of the most maintainable codebases in existence are not clever; they are unremarkable. Unremarkable code is easy to understand, easy to debug, and easy to modify. Embracing such code is advisable.

    YAGNI: You Are Not Going to Need It

    YAGNI is the antidote to speculative generality. Features, abstractions, and infrastructure should not be built for requirements that do not yet exist. The principle is to build for today's needs and to refactor when tomorrow's needs actually arrive.

    The cost of premature abstraction is often higher than the cost of later refactoring, because premature abstractions encode assumptions about the future that are usually incorrect. The result is complexity maintained for scenarios that never materialize.

    Code Smells and Refactoring Techniques

    The term "code smell" was popularized by Martin Fowler in his book Refactoring. A code smell is not a bug; the code functions, but it indicates that the design could be improved. Code smells are symptoms; refactoring is the remedy.

    Code Smell Detection Flowchart Review a Code Unit Is the function > 20 lines? Yes Long Method Extract Method No Does it have > 3 parameters? Yes Long Parameter List Introduce Parameter Object No Does the class have > 200 lines? Yes Large Class / God Object Extract Class No Does it use another class's data heavily? Yes Feature Envy Move Method No Is similar logic repeated elsewhere? Yes Duplicated Code Extract & Consolidate No Code Looks Clean! Refactoring fixes are shown in colored boxes →

    Common Code Smells and Their Cures

    Code Smell Symptoms Refactoring Technique
    Long Method Function exceeds 20-30 lines, needs scrolling Extract Method
    Large Class Class has many fields, methods, and responsibilities Extract Class, Extract Interface
    Feature Envy Method uses data from another class more than its own Move Method, Move Field
    Data Clumps Same group of variables appears together repeatedly Extract Class, Introduce Parameter Object
    Primitive Obsession Using primitives instead of small domain objects Replace Primitive with Value Object
    Switch Statements Repeated switch/if-else chains on a type code Replace Conditional with Polymorphism
    Shotgun Surgery One change requires modifying many classes Move Method, Inline Class
    Dead Code Unreachable or unused code blocks Delete it (version control has your back)

     

    Refactoring in Action: Extract Method

    The Extract Method refactoring is the most common and most powerful tool in the refactoring toolkit. When a block of code can be grouped together, it should be extracted into a well-named function.

    # Before: Logic buried in a long function
    def generate_invoice(order):
        # ... 20 lines above ...
    
        # Calculate line items
        subtotal = 0
        for item in order.items:
            line_price = item.quantity * item.unit_price
            if item.discount_percent:
                line_price *= (1 - item.discount_percent / 100)
            subtotal += line_price
    
        # Apply bulk discount
        if subtotal > 1000:
            subtotal *= 0.95
        elif subtotal > 500:
            subtotal *= 0.98
    
        # ... 30 lines below ...
    
    # After: Clear, named abstractions
    def generate_invoice(order):
        # ...
        subtotal = calculate_subtotal(order.items)
        subtotal = apply_bulk_discount(subtotal)
        # ...
    
    def calculate_subtotal(items):
        return sum(calculate_line_price(item) for item in items)
    
    def calculate_line_price(item):
        price = item.quantity * item.unit_price
        if item.discount_percent:
            price *= (1 - item.discount_percent / 100)
        return price
    
    def apply_bulk_discount(subtotal):
        if subtotal > 1000:
            return subtotal * Decimal("0.95")
        elif subtotal > 500:
            return subtotal * Decimal("0.98")
        return subtotal

    Replace Conditional with Polymorphism

    When the same type-checking conditional appears throughout a codebase, it should be replaced with polymorphism. This is one of the most transformative refactoring patterns.

    # Before: Type-checking conditionals everywhere
    def calculate_area(shape):
        if shape.type == "circle":
            return math.pi * shape.radius ** 2
        elif shape.type == "rectangle":
            return shape.width * shape.height
        elif shape.type == "triangle":
            return 0.5 * shape.base * shape.height
    
    def draw(shape):
        if shape.type == "circle":
            draw_circle(shape)
        elif shape.type == "rectangle":
            draw_rectangle(shape)
        elif shape.type == "triangle":
            draw_triangle(shape)
    
    # After: Polymorphism eliminates conditionals
    class Shape(ABC):
        @abstractmethod
        def area(self) -> float: pass
    
        @abstractmethod
        def draw(self) -> None: pass
    
    class Circle(Shape):
        def __init__(self, radius):
            self.radius = radius
    
        def area(self):
            return math.pi * self.radius ** 2
    
        def draw(self):
            draw_circle(self)
    
    class Rectangle(Shape):
        def __init__(self, width, height):
            self.width = width
            self.height = height
    
        def area(self):
            return self.width * self.height
    
        def draw(self):
            draw_rectangle(self)

    This approach aligns precisely with the Open/Closed Principle: adding a new shape requires creating a new class rather than modifying existing conditionals throughout the codebase.

    Comments and Self-Documenting Code

    Comments are neither inherently good nor inherently bad, but most comments in real-world codebases are poor. They are outdated, misleading, or state the obvious. The best code does not require comments because it explains itself through clear naming, small functions, and logical structure.

    Comments That Should Not Exist

    # Bad: Comment restates the code (adds no value)
    i += 1  # increment i by 1
    
    # Bad: Comment is a crutch for a bad name
    d = 7  # number of days until the deadline
    
    # Bad: Commented-out code (use version control instead)
    # old_calculation = price * 0.85
    # if customer.is_premium:
    #     old_calculation *= 0.9
    
    # Bad: Journal comments (git log exists)
    # 2024-01-15: Added validation for email field
    # 2024-02-20: Fixed bug where null emails crashed the system
    # 2024-03-10: Refactored to use regex validation
    
    # Bad: Closing brace comments (a sign your function is too long)
    if condition:
        for item in items:
            if another_condition:
                # 50 lines of code
            # end if another_condition
        # end for item in items
    # end if condition

    Comments That Add Real Value

    # Good: Explains WHY, not what
    # We use a 30-second timeout because the payment gateway
    # occasionally takes 20+ seconds during peak hours
    PAYMENT_TIMEOUT = 30
    
    # Good: Warns of consequences
    # WARNING: This cache is shared across threads. Do not modify
    # without acquiring the write lock first.
    shared_cache = {}
    
    # Good: Clarifies complex business logic
    # Tax-exempt status applies to orders from registered nonprofits
    # that have provided a valid EIN and exemption certificate.
    # See: IRS Publication 557 for qualifying organizations.
    def is_tax_exempt(organization):
        ...
    
    # Good: TODO with context and ticket number
    # TODO(PROJ-1234): Replace with batch API call once the
    # vendor supports it. Current approach makes N+1 queries.
    def fetch_user_preferences(user_ids):
        return [fetch_single_preference(uid) for uid in user_ids]
    
    # Good: Documents a non-obvious design decision
    # Using insertion sort here instead of quicksort because the
    # input is nearly sorted (data comes pre-sorted from the API)
    # and insertion sort is O(n) for nearly-sorted data.
    def sort_api_results(results):
        ...
    Key Takeaway: The best comment is the one that did not need to be written because the code is sufficiently clear on its own. When a comment must be written, it should explain why something is done rather than what is done. If the need to comment on what the code does arises, the code itself should be refactored to be self-explanatory.

    Docstrings and API Documentation

    Although inline comments should be rare, docstrings for public APIs are essential. Every public function, class, and module should have a docstring that explains its purpose, parameters, return value, and any exceptions that may be raised.

    def transfer_funds(
        source_account: Account,
        destination_account: Account,
        amount: Decimal,
        currency: str = "USD"
    ) -> TransferResult:
        """Transfer funds between two accounts.
    
        Executes an atomic transfer, debiting the source and crediting
        the destination. Both accounts must be in active status and
        denominated in the same currency.
    
        Args:
            source_account: The account to debit.
            destination_account: The account to credit.
            amount: The positive amount to transfer.
            currency: ISO 4217 currency code. Defaults to "USD".
    
        Returns:
            A TransferResult containing the transaction ID and
            updated balances for both accounts.
    
        Raises:
            InsufficientFundsError: If the source account balance
                is less than the transfer amount.
            AccountFrozenError: If either account is frozen.
            CurrencyMismatchError: If accounts use different currencies.
        """
        ...

    Testing as Documentation

    Well-written tests are the most reliable form of documentation. Unlike comments and README files, tests are verified by the computer every time they run. If behaviour changes and documentation is not updated, a test will fail and the discrepancy will be flagged. Comments, by contrast, quietly become inaccurate.

    Tests That Describe Behaviour

    Good test names read as specifications. They describe what the system does under what conditions.

    # Bad: Test names that tell you nothing
    def test_user():
        ...
    
    def test_process():
        ...
    
    def test_calculate():
        ...
    
    # Good: Test names that read like specifications
    def test_new_user_receives_welcome_email():
        user = create_user(email="alice@example.com")
        assert_email_sent_to("alice@example.com", subject="Welcome!")
    
    def test_order_total_includes_tax_for_taxable_states():
        order = create_order(state="CA", subtotal=Decimal("100"))
        assert order.total == Decimal("107.25")
    
    def test_expired_token_returns_unauthorized_response():
        token = create_token(expires_in=timedelta(seconds=-1))
        response = client.get("/api/profile", headers={"Authorization": f"Bearer {token}"})
        assert response.status_code == 401
    
    def test_bulk_discount_applies_when_subtotal_exceeds_threshold():
        order = create_order(subtotal=Decimal("1500"))
        assert order.discount_applied == True
        assert order.total == Decimal("1425")  # 5% discount

    The Arrange-Act-Assert Pattern

    Every test should be structured in three clear sections: Arrange (set up the conditions), Act (perform the action), and Assert (verify the result). This pattern makes tests predictable and easy to scan.

    def test_password_reset_invalidates_previous_tokens():
        # Arrange
        user = create_user(email="alice@example.com")
        old_token = generate_reset_token(user)
    
        # Act
        new_token = generate_reset_token(user)
    
        # Assert
        assert is_token_valid(new_token) == True
        assert is_token_valid(old_token) == False  # Old token invalidated

    Test-Driven Development Basics

    TDD follows a simple cycle known as Red-Green-Refactor:

    1. Red: Write a failing test that describes the desired behavior
    2. Green: Write the simplest code that makes the test pass
    3. Refactor: Clean up the code while keeping all tests green

    TDD is not principally about testing; it is about design. Writing the test first compels consideration of the interface before the implementation. The result is code with clear APIs, minimal coupling, and testable design. These are precisely the qualities of clean code.

    The discipline of maintaining a robust test suite is closely related to Git and GitHub best practices; both are habits that protect the codebase and give a team the confidence to move rapidly.

    Tip: A test suite that runs in under 30 seconds for unit tests should be the goal. Slow tests cause developers to stop running them, and untested code accumulates. Fast feedback loops are essential for maintaining code quality.

    Code Review Culture and Standards

    Code reviews are the most effective mechanism for maintaining code quality across a team. They serve multiple purposes: catching bugs, sharing knowledge, enforcing standards, and mentoring junior developers. Poorly conducted code reviews, however, can be counterproductive, either rubber-stamping all submissions or attending to trivial points while missing substantive issues.

    What to Examine in a Code Review

    Category Key Questions
    Correctness Does the code do what it claims to do? Are edge cases handled?
    Readability Can you understand the code without asking the author to explain it?
    Design Does it follow SOLID principles? Is it at the right level of abstraction?
    Testing Are there adequate tests? Do they cover meaningful scenarios?
    Security Are inputs validated? Are there SQL injection or XSS risks?
    Performance Are there N+1 queries, unnecessary allocations, or O(n^2) loops?
    Naming Do names clearly communicate intent without being verbose?

     

    Code Review Best Practices

    The most effective code reviews are collaborative conversations rather than adversarial gate-keeping exercises. The following practices yield productive reviews:

    • Review small pull requests. A PR with 50 changed lines receives thorough review. A PR with 500 lines is typically rubber-stamped. PRs should be kept small and focused.
    • Comment on the code, not the coder. The form "this function might be clearer if..." is preferable to "you wrote this incorrectly."
    • Distinguish between blocking issues and suggestions. Labels such as "nit:" for style preferences and "blocking:" for issues that must be addressed before merging are useful.
    • Automate where possible. Linters, formatters, and static analysis tools should catch style issues before human review. Human attention should not be expended on questions such as single versus double quotes.
    • Review within 24 hours. Stale PRs block progress. Reviewing should be a daily habit rather than a weekly task.

    When applications are deployed in Docker containers from development to production, code review becomes even more important. It catches configuration mistakes, security vulnerabilities, and deployment issues before they reach production environments.

    Clean Architecture: Separation of Concerns

    Clean Architecture, popularized by Robert C. Martin, organizes code into concentric layers in which dependencies point inward. The innermost layer contains business logic, namely the rules that make an application unique. The outer layers contain infrastructure concerns such as databases, web frameworks, and external services. The core principle is that business logic should never depend on infrastructure details.

    Clean Architecture Layers FRAMEWORKS & DRIVERS Web Framework Database External APIs UI / CLI INTERFACE ADAPTERS Controllers Gateways Presenters Repositories USE CASES Application Business Rules Interactors Services ENTITIES Core Business Rules ↑ Dependencies always point inward ↑

    Understanding the Layers

    Entities are the core business objects and rules. They contain enterprise-wide business logic that would exist even in the absence of software. For example, a LoanApplication entity knows that a loan cannot exceed 80% of the property value; this rule exists independently of any database or web framework.

    Use Cases contain application-specific business rules. They orchestrate the flow of data to and from entities. A use case such as ApproveLoanApplication coordinates the entity rules, external credit checks, and notification services.

    Interface Adapters convert data between the format most convenient for use cases and the format required by external systems. Controllers, presenters, and repository implementations reside in this layer.

    Frameworks and Drivers form the outermost layer: databases, web servers, messaging systems, and third-party libraries. This layer should contain as little code as possible, primarily glue and configuration.

    Dependency Injection in Practice

    Dependency Injection (DI) is the mechanism through which Clean Architecture operates. Rather than creating dependencies inside a class, they are injected from the outside. This renders code testable (mocks can be injected), flexible (implementations can be swapped), and explicit (dependencies are visible in the constructor).

    # Without DI: Hard to test, tightly coupled
    class NotificationService:
        def __init__(self):
            self.email_client = SendGridClient(api_key=os.getenv("SENDGRID_KEY"))
            self.sms_client = TwilioClient(sid=os.getenv("TWILIO_SID"))
    
        def notify(self, user, message):
            self.email_client.send(user.email, message)
            if user.phone:
                self.sms_client.send(user.phone, message)
    
    # With DI: Testable, flexible, explicit
    class NotificationService:
        def __init__(self, email_sender: EmailSender, sms_sender: SmsSender):
            self.email_sender = email_sender
            self.sms_sender = sms_sender
    
        def notify(self, user: User, message: str):
            self.email_sender.send(user.email, message)
            if user.phone:
                self.sms_sender.send(user.phone, message)
    
    # In tests, inject fakes:
    def test_notification_sends_email():
        fake_email = FakeEmailSender()
        fake_sms = FakeSmsSender()
        service = NotificationService(fake_email, fake_sms)
    
        service.notify(user, "Hello!")
    
        assert fake_email.last_recipient == user.email
        assert fake_email.last_message == "Hello!"

    This architectural pattern is particularly valuable in larger systems. Whether the project involves complex event-processing pipelines or simple CRUD applications, separating concerns makes every component easier to understand, test, and replace.

    Practical Refactoring: From Messy to Clean

    The following section presents a realistic refactoring example that transforms a messy real-world function into clean, maintainable code. This is not a contrived example; variations of this pattern occur in countless codebases.

    The Messy Original

    def process_employees(data):
        results = []
        for d in data:
            if d["type"] == "FT":
                sal = d["base"] * 12
                if d["years"] > 5:
                    sal = sal * 1.1
                if d["years"] > 10:
                    sal = sal * 1.05  # Bug: compounds with 5-year bonus
                tax = sal * 0.3
                net = sal - tax
                ben = 5000  # health
                ben += 2000  # dental
                if d["years"] > 3:
                    ben += 3000  # 401k match
                results.append({
                    "name": d["name"],
                    "type": "Full-Time",
                    "gross": sal,
                    "tax": tax,
                    "net": net,
                    "benefits": ben,
                    "total_comp": net + ben
                })
            elif d["type"] == "PT":
                sal = d["hours"] * d["rate"] * 52
                tax = sal * 0.22
                net = sal - tax
                results.append({
                    "name": d["name"],
                    "type": "Part-Time",
                    "gross": sal,
                    "tax": tax,
                    "net": net,
                    "benefits": 0,
                    "total_comp": net
                })
            elif d["type"] == "CT":
                sal = d["contract_value"]
                tax = 0  # contractors handle own taxes
                net = sal
                results.append({
                    "name": d["name"],
                    "type": "Contractor",
                    "gross": sal,
                    "tax": tax,
                    "net": net,
                    "benefits": 0,
                    "total_comp": net
                })
        return results

    This function is a classic example of multiple code smells combined: long method, primitive obsession, type-checking conditionals, magic numbers, single-letter variable names, and a latent bug in the seniority-bonus logic.

    The Clean Refactored Version

    from abc import ABC, abstractmethod
    from dataclasses import dataclass
    from decimal import Decimal
    
    # --- Value Objects ---
    @dataclass(frozen=True)
    class CompensationSummary:
        name: str
        employment_type: str
        gross_salary: Decimal
        tax: Decimal
        net_salary: Decimal
        benefits_value: Decimal
    
        @property
        def total_compensation(self) -> Decimal:
            return self.net_salary + self.benefits_value
    
    # --- Constants (no magic numbers) ---
    HEALTH_INSURANCE_VALUE = Decimal("5000")
    DENTAL_INSURANCE_VALUE = Decimal("2000")
    RETIREMENT_MATCH_VALUE = Decimal("3000")
    RETIREMENT_ELIGIBILITY_YEARS = 3
    
    FULL_TIME_TAX_RATE = Decimal("0.30")
    PART_TIME_TAX_RATE = Decimal("0.22")
    
    SENIORITY_BONUS_THRESHOLD = 5
    SENIORITY_BONUS_RATE = Decimal("0.10")
    SENIOR_BONUS_THRESHOLD = 10
    SENIOR_BONUS_RATE = Decimal("0.15")  # Fixed: 15% total, not compounded
    
    # --- Strategy Pattern for Employee Types ---
    class CompensationCalculator(ABC):
        @abstractmethod
        def calculate(self, employee: dict) -> CompensationSummary:
            pass
    
    class FullTimeCalculator(CompensationCalculator):
        def calculate(self, employee: dict) -> CompensationSummary:
            gross = self._calculate_gross_salary(employee)
            tax = gross * FULL_TIME_TAX_RATE
            benefits = self._calculate_benefits(employee)
            return CompensationSummary(
                name=employee["name"],
                employment_type="Full-Time",
                gross_salary=gross,
                tax=tax,
                net_salary=gross - tax,
                benefits_value=benefits,
            )
    
        def _calculate_gross_salary(self, employee: dict) -> Decimal:
            annual_salary = Decimal(str(employee["base"])) * 12
            seniority_bonus = self._seniority_multiplier(employee["years"])
            return annual_salary * seniority_bonus
    
        def _seniority_multiplier(self, years: int) -> Decimal:
            if years > SENIOR_BONUS_THRESHOLD:
                return Decimal("1") + SENIOR_BONUS_RATE
            elif years > SENIORITY_BONUS_THRESHOLD:
                return Decimal("1") + SENIORITY_BONUS_RATE
            return Decimal("1")
    
        def _calculate_benefits(self, employee: dict) -> Decimal:
            benefits = HEALTH_INSURANCE_VALUE + DENTAL_INSURANCE_VALUE
            if employee["years"] > RETIREMENT_ELIGIBILITY_YEARS:
                benefits += RETIREMENT_MATCH_VALUE
            return benefits
    
    class PartTimeCalculator(CompensationCalculator):
        def calculate(self, employee: dict) -> CompensationSummary:
            gross = Decimal(str(employee["hours"])) * Decimal(str(employee["rate"])) * 52
            tax = gross * PART_TIME_TAX_RATE
            return CompensationSummary(
                name=employee["name"],
                employment_type="Part-Time",
                gross_salary=gross,
                tax=tax,
                net_salary=gross - tax,
                benefits_value=Decimal("0"),
            )
    
    class ContractorCalculator(CompensationCalculator):
        def calculate(self, employee: dict) -> CompensationSummary:
            contract_value = Decimal(str(employee["contract_value"]))
            return CompensationSummary(
                name=employee["name"],
                employment_type="Contractor",
                gross_salary=contract_value,
                tax=Decimal("0"),
                net_salary=contract_value,
                benefits_value=Decimal("0"),
            )
    
    # --- Registry and Orchestrator ---
    CALCULATORS: dict[str, CompensationCalculator] = {
        "FT": FullTimeCalculator(),
        "PT": PartTimeCalculator(),
        "CT": ContractorCalculator(),
    }
    
    def calculate_employee_compensation(
        employees: list[dict],
    ) -> list[CompensationSummary]:
        return [
            _calculate_single(employee) for employee in employees
        ]
    
    def _calculate_single(employee: dict) -> CompensationSummary:
        calculator = CALCULATORS.get(employee["type"])
        if calculator is None:
            raise ValueError(f"Unknown employee type: {employee['type']}")
        return calculator.calculate(employee)

    The changes and their justifications are as follows:

    • Magic numbers eliminated: every numeric value is a named constant with a clear meaning.
    • Bug fixed: the seniority bonus no longer compounds incorrectly; employees with 10 or more years receive a 15% total, not 10% followed by an additional 5%.
    • Polymorphism replaces conditionals: adding a new employee type requires only a new class and a registry entry.
    • Single Responsibility: each calculator class handles one employee type; the orchestrator only coordinates.
    • Immutable value objects: CompensationSummary is a frozen dataclass that cannot be modified inadvertently.
    • Error handling: unknown employee types produce clear error messages rather than silent failures.
    • Type safety: Decimal is used instead of floats for monetary calculations.
    Key Takeaway: Refactoring is not rewriting. It consists of a series of small, safe transformations, each improving the design while preserving correctness. Tests should be run after every transformation to confirm that nothing has been broken.

    Frequently Asked Questions

    How can clean-code practices be introduced into a messy existing codebase?

    Follow the Boy Scout Rule: leave the code cleaner than it was found. There is no need to refactor the entire codebase at once. Whenever a file is touched to fix a bug, add a feature, or review a pull request, improve one small element: rename a confusing variable, extract a method, or add a missing test. Over weeks and months these incremental improvements compound into a substantially cleaner codebase. Refactoring should be concentrated in the areas of code that change most frequently, since those areas repay improved readability most often.

    What is the difference between clean code and over-engineering?

    Clean code addresses today's problems clearly; over-engineering addresses tomorrow's imagined problems prematurely. Clean code uses the simplest design that works, with good names, small functions, and single responsibilities. Over-engineering adds layers of abstraction, factory patterns, and plugin architectures for requirements that do not yet exist. The YAGNI principle is the guide: adding flexibility for a scenario that may never occur is over-engineering, whereas making existing code easier to read and modify is clean coding.

    Should working code that lacks tests be refactored?

    This is the classic chicken-and-egg problem, and the safest approach is to add characterization tests first: tests that document the current behaviour of the code, even when that behaviour cannot be confirmed to be correct. Such tests act as a safety net, because if a refactoring alters behaviour, a test will fail and the change will be detected. Michael Feathers' book Working Effectively with Legacy Code provides established techniques for adding tests to untested code, and the highest-risk areas should be addressed first.

    Conclusion

    Clean code is not a destination but a daily practice. It is the discipline of choosing clarity over cleverness, simplicity over sophistication, and explicit construction over implicit assumption. It is the professional responsibility of a software developer, as a surgeon maintains sterile instruments and an architect ensures structural integrity.

    The principles examined above (meaningful naming, focused functions, SOLID design, DRY/KISS/YAGNI, refactoring, self-documenting code, testing, code reviews, and clean architecture) are not rules to memorize and apply mechanically. They are tools for thinking. Each situation requires judgment regarding which principles apply and to what degree. The objective is not perfect adherence to any single principle but a codebase in which developers can move confidently and quickly.

    The statistics presented at the beginning of this article merit emphasis: developers spend the substantial majority of their time reading code. Every function written will be read dozens or hundreds of times. Every design decision will either accelerate or impede future development. The code written today is the legacy that teammates inherit tomorrow.

    Begin with small changes. Follow the Boy Scout Rule and leave every file slightly cleaner than it was found. Write one additional test. Rename one confusing variable. Extract one bloated function. These small improvements, accumulated over weeks and months, convert messy codebases into maintainable ones. Maintainable code is code that endures.

    The best time to write clean code was at the beginning of the project. The second-best time is the present.

    References

    • Martin, Robert C. Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall, 2008. O'Reilly
    • Fowler, Martin. Refactoring: Improving the Design of Existing Code, 2nd Edition. Addison-Wesley, 2018. Refactoring Catalog
    • Martin, Robert C. "The Principles of OOD"—SOLID principles reference. Uncle Bob's Articles
    • Feathers, Michael. Working Effectively with Legacy Code. Prentice Hall, 2004.
    • Consortium for Information & Software Quality (CISQ). "The Cost of Poor Software Quality in the US: A 2022 Report." CISQ Report
    • Xia, Xin, Lingfeng Bao, David Lo, Zhenchang Xing, Ahmed E. Hassan, and Shanping Li. "Measuring Program Comprehension: A Large-Scale Field Study with Professionals." IEEE Transactions on Software Engineering, 2018. IEEE Xplore
  • Git and GitHub Best Practices for Professional Developers

    Summary

    What this post covers: A professional-grade reference for Git and GitHub workflows, including branching strategies, commit conventions, pull-request and code-review practices, CI/CD with GitHub Actions, Git hooks, advanced recovery commands, repository security, and the monorepo-versus-polyrepo trade-off.

    Key insights:

    • Trunk-based development with short-lived branches outperforms Git Flow for most teams that ship multiple times per day. Git Flow’s long-lived develop and release branches impose overhead that only versioned-software teams genuinely require.
    • Conventional Commits combined with a clear PR template convert project history into an auditable narrative and enable automated changelogs, semantic versioning, and faster debugging with git bisect.
    • Branch protection rules, required reviews, and signed commits are not optional ceremony. They constitute the single most effective defense against the kind of accidental force-push that destroys weeks of work.
    • Most “Git emergencies” (lost commits, bad merges, detached HEAD) are recoverable through git reflog. Understanding Git as a directed acyclic graph of snapshots rather than as a save button distinguishes senior from junior engineers.
    • Pre-commit hooks (linting, formatting, secret scanning) catch problems before they reach the remote and represent the lowest-cost quality investment a team can make.

    Main topics: Why Git Mastery Matters More Than Is Commonly Recognized, Branching Strategies That Scale, Commit Conventions That Tell a Story, Pull Request Best Practices, Code Review Workflow and Standards, GitHub Actions and CI/CD Integration, Git Hooks for Quality Enforcement, Advanced Git Techniques, Security: Protecting Your Repository, Monorepo versus Polyrepo.

    Consider a failure pattern that recurs in teams without safeguards. A developer accidentally force-pushes to the main branch on a Friday afternoon, overwriting weeks of work from several colleagues. No branch protection rules are in place, no reviews are required, and the backup strategy amounts to a general instruction to be careful. The team spends the weekend reconstructing commits from local copies scattered across developer machines, chat messages containing code snippets, and memory. The cost, once overtime and delayed releases are accounted for, is substantial and entirely avoidable.

    Such incidents are common, and they stem largely from shallow familiarity with the tool. Stack Overflow’s developer survey consistently shows that, although the overwhelming majority of professional developers use Git, most rely on fewer than ten commands. They are familiar with git add, git commit, git push, and git pull. When something goes wrong, they typically panic, copy the working directory to the desktop as a precaution, and consult a search engine.

    The uncomfortable reality is that most developers use approximately 10 percent of Git’s capabilities. They treat it as a save button rather than as the distributed version control system it is. In an era of collaborative, fast-moving software development in which teams ship dozens of times per day through automated pipelines, this knowledge gap is not merely inconvenient; it is hazardous.

    This guide is designed to close that gap. It covers branching strategies used by teams at Google, Meta, and Stripe; commit conventions that render project history genuinely useful; and advanced techniques such as interactive rebase and bisect that can save hours of debugging. The intended audience includes both junior developers seeking to develop their skills and senior engineers who wish to formalize what they already know.

    Why Git Mastery Matters More Than Is Commonly Recognized

    Git is the most widely used version control system in the world. As of 2025, GitHub alone hosts more than 400 million repositories and has over 100 million developers. GitLab and Bitbucket add tens of millions more. Every Fortune 500 company uses Git in some form. It is not a tool that can be used casually.

    Git mastery, however, is not merely a matter of knowing commands. It is a matter of understanding workflows: the patterns and conventions that allow teams of five, fifty, or five thousand developers to work on the same codebase without disruption. A developer who understands Git deeply can perform the following tasks.

    • Resolve merge conflicts in minutes rather than hours, because they understand what Git is actually tracking.
    • Navigate project history to determine when and why a defect was introduced, using tools such as git bisect and git log.
    • Recover from mistakes—accidental commits, bad merges, and even deleted branches—using git reflog.
    • Collaborate effectively through well-structured pull requests and meaningful commit messages.
    • Automate quality checks using Git hooks that run before code reaches the remote repository.

    The difference between a developer who “uses Git” and one who “understands Git” becomes especially apparent during incidents. When production is down and the team must identify the commit that introduced the regression, revert it cleanly, and deploy a fix within minutes, Git proficiency directly affects the team’s mean time to recovery (MTTR).

    Key Takeaway: Git proficiency is a force multiplier. Time invested in learning Git deeply yields daily returns in faster debugging, smoother collaboration, and fewer catastrophic errors.

    Building the Correct Mental Model

    Before discussing specific practices, a mental model that simplifies subsequent material should be established.

    Git is fundamentally a directed acyclic graph (DAG) of snapshots. Every commit is a complete snapshot of the project at a point in time, linked to its parent commits. Branches are movable pointers to commits. Tags are fixed pointers. HEAD is a pointer to the branch or commit currently in use.

    Internalizing this model removes much of Git’s apparent mystery. A merge creates a new commit with two parents. A rebase replays commits on top of a new base. A cherry-pick copies a single commit to a new location. These are graph operations, not arcane procedures.

    Understanding this graph model is particularly important when working with the same repository across Docker-based development environments, where multiple containers may interact with the same codebase, or when a CI/CD pipeline must decide on actions based on what changed between commits.

    Branching Strategies That Scale

    Choosing the appropriate branching strategy is one of the most consequential decisions a team makes. The wrong strategy creates bottlenecks, increases merge conflicts, and slows delivery. The right one makes collaboration feel effortless.

    Three branching strategies dominate professional software development, each optimized for different team sizes and release cadences.

    Git Branching Strategy Comparison Git Flow main develop feature release hotfix Best for: Scheduled releases GitHub Flow main feature-a feature-b PR PR Best for: Continuous deployment Trunk-Based main (trunk) <1 day <1 day <1 day Best for: High-velocity teams

    Git Flow

    Introduced by Vincent Driessen in 2010, Git Flow uses two long-lived branches—main (production) and develop (integration)—along with short-lived feature, release, and hotfix branches. It is the most structured of the three strategies.

    The workflow proceeds as follows.

    1. Developers create feature branches from develop.
    2. Completed features merge back into develop.
    3. When enough features have accumulated, a release branch is cut from develop.
    4. The release branch receives final testing and bug fixes.
    5. The release merges into both main (tagged with a version) and back into develop.
    6. Hotfix branches are created from main for critical production bugs and then merged into both main and develop.

    When to use Git Flow: teams with scheduled releases, such as mobile apps subject to App Store review cycles, products that must maintain multiple versions simultaneously, or organizations with strict release-management processes.

    When to avoid it: for teams that deploy continuously (multiple times per day), Git Flow imposes unnecessary ceremony. The release-branch process becomes a bottleneck when fast shipping is the priority.

    GitHub Flow

    GitHub Flow is substantially simpler. There is one long-lived branch, main; everything else is a feature branch.

    1. Create a branch from main.
    2. Make commits on that branch.
    3. Open a pull request.
    4. Discuss and review the code.
    5. Merge to main and deploy.

    This is the complete workflow. There is no develop branch, no release branches, and no hotfix branches. The simplicity is intentional. Every merge to main triggers a deployment, which means that main must always be deployable.

    When to use GitHub Flow: web applications with continuous deployment, SaaS products, open-source projects, and any team that deploys frequently and wishes to minimize process overhead.

    Trunk-Based Development

    Trunk-Based Development (TBD) simplifies the workflow further. Developers commit directly to the trunk (main) or use very short-lived feature branches that last no more than a day or two. This is the strategy used by Google, where thousands of engineers commit to a single monorepo.

    The key enablers for trunk-based development are listed below.

    • Feature flags: incomplete features are hidden behind toggles so that they can reside in the codebase without being visible to users.
    • Comprehensive automated testing: with no release branch available for manual QA, automated tests must be thorough.
    • Small, incremental changes: large features are decomposed into small, independently deployable pieces.

    When to use TBD: high-velocity teams with strong CI/CD pipelines, experienced developers who can work in small increments, and organizations that prioritize deployment speed over release ceremony.

    Aspect Git Flow GitHub Flow Trunk-Based
    Long-lived branches main + develop main only main only
    Feature branch lifespan Days to weeks Hours to days Hours (max 1-2 days)
    Release process Release branches Merge to main = deploy Continuous from trunk
    Complexity High Low Low
    Best for Scheduled releases Continuous deployment High-velocity teams
    Team size Medium to large Any size Senior/experienced teams

     

    Tip: Teams beginning to formalize a Git workflow should start with GitHub Flow. It is simple enough that everyone can learn it quickly and flexible enough to scale. Migration to trunk-based development is straightforward once CI/CD maturity has improved.

    Commit Conventions That Tell a Story

    The commit history is a narrative of a project’s evolution. A well-maintained history allows any developer to understand what changed, why it changed, and when it changed without reading every line of code. A poorly maintained history is noise.

    The following two commit histories from real projects illustrate the contrast.

    # Bad history — tells you nothing
    fix stuff
    updates
    WIP
    more changes
    asdfasdf
    final fix (for real this time)
    oops
    
    # Good history — tells a story
    feat(auth): add JWT refresh token rotation
    fix(api): handle race condition in concurrent order processing
    docs(readme): add deployment instructions for AWS
    refactor(db): extract connection pooling into shared module
    test(auth): add integration tests for OAuth2 flow

    The difference is substantial. The remainder of this section discusses how to achieve the second style consistently.

    The Conventional Commits Specification

    Conventional Commits is a lightweight convention for commit messages that provides structure without imposing significant overhead. The format is as follows.

    <type>(<scope>): <description>
    
    [optional body]
    
    [optional footer(s)]

    The type describes the category of change.

    Type Purpose Example
    feat New feature feat(cart): add quantity selector to checkout
    fix Bug fix fix(auth): prevent session hijacking on token refresh
    docs Documentation only docs(api): update rate limiting section
    style Formatting, no code change style: apply prettier to all JS files
    refactor Code change that’s not a fix or feature refactor(db): simplify query builder interface
    perf Performance improvement perf(search): add index for full-text queries
    test Adding or fixing tests test(payments): add edge cases for currency conversion
    chore Maintenance tasks chore(deps): upgrade React from 18.2 to 18.3
    ci CI/CD configuration changes ci: add Node.js 20 to test matrix

     

    The scope (optional but recommended) identifies the module, component, or area of the codebase affected. The description is a short, imperative statement of what the commit does: “add,” not “added” or “adds.”

    The Discipline of Atomic Commits

    An atomic commit contains exactly one logical change. Not two; not half of one; exactly one.

    This is more difficult than it sounds. Developers naturally work on multiple things simultaneously. They begin to fix a bug and notice a typo in a comment. They refactor a function and recognize that the tests should also be updated. Within a short time, the working directory contains changes spanning five files and three unrelated concerns.

    The discipline of atomic commits involves using git add -p (patch mode) to stage only the hunks related to one change, committing, and then staging and committing the next change. This approach is fundamental to clean code principles: a commit history should be as well-organized as the code itself.

    # Stage specific parts of a file interactively
    git add -p src/auth/login.py
    
    # Git will show each "hunk" (changed section) and ask:
    # Stage this hunk [y,n,q,a,d,s,e,?]?
    # y = yes, n = no, s = split into smaller hunks, e = edit manually
    
    # After staging the relevant hunks, commit
    git commit -m "fix(auth): validate email format before database lookup"
    
    # Now stage and commit the next logical change
    git add -p src/auth/login.py
    git commit -m "refactor(auth): extract validation logic into separate module"

    The reason this matters is practical. Six months later, when a specific change must be reverted with git revert or a fix cherry-picked to a release branch, atomic commits enable a clean operation. If a single commit combines a bug fix and an unrelated refactor, reverting the buggy part also reverts the good refactor.

    Caution: Work-in-progress (WIP) commits should never be pushed to shared branches. When work must be saved before a context switch, git stash or a personal branch prefixed with WIP is preferable. The history should be cleaned up before a pull request is opened.

    Writing Commit Messages of Lasting Value

    The commit description answers “what.” The commit body answers “why.” A template for non-trivial commits is shown below.

    fix(api): return 429 status when rate limit is exceeded
    
    Previously, the API returned a generic 500 error when a client
    exceeded the rate limit. This made it impossible for clients to
    distinguish between server errors and rate limiting, leading to
    incorrect retry behavior.
    
    Now returns 429 Too Many Requests with a Retry-After header,
    conforming to RFC 6585. Clients can use this header to implement
    proper exponential backoff.
    
    Fixes #1234
    See also: https://datatracker.ietf.org/doc/html/rfc6585

    The structure is straightforward: an imperative subject line (under 72 characters), a blank line, and then a body explaining the state before, the state after, and why the change was required. This pattern, sometimes called the “50/72 rule,” is widely adopted because most Git tools wrap text at these boundaries.

    Pull Request Best Practices

    Pull requests (PRs) are where individual work becomes team work. A good PR makes the reviewer’s task straightforward. A poor PR—a 3,000-line submission with the description “some updates”—leaves everyone frustrated and typically results in a rubber-stamp approval, which defeats the entire purpose of code review.

    Pull Request Lifecycle Create Branch from main Write Code atomic commits Open PR description + context CI Checks lint, test, build Code Review discuss + iterate Changes requested Approved LGTM Merge to main Deploy Key Principle: Keep PRs under 400 lines of code changes. Smaller PRs get reviewed faster and more thoroughly.

    The Primary Rule: Keep PRs Small

    Research from Google’s engineering practices indicates a clear correlation: larger PRs are less effective to review. Reviewer attention degrades sharply after approximately 200 to 400 lines of changes. A 2,000-line PR almost guarantees that subtle bugs will slip through because no reviewer can sustain focused attention across that much code.

    The ideal PR exhibits the following properties.

    • Under 400 lines of changed code, excluding generated files, lock files, and test fixtures.
    • Focused on a single concern: one feature, one bug fix, or one refactor.
    • Self-contained: it does not leave the codebase in a broken state if no subsequent PRs are merged.

    If a feature requires 2,000 lines of code, it should be decomposed into a stack of four or five smaller PRs that build on one another. Many teams use tools such as Graphite, ghstack, or GitHub’s branch protection rules to manage stacked PRs.

    Writing PR Descriptions That Accelerate Review

    A good PR description follows a template that answers three questions: what was changed, why the change was made, and how the reviewer can verify it.

    ## What
    
    Add rate limiting to the public API endpoints using a
    token bucket algorithm. Limits are configurable per
    endpoint and per API key tier.
    
    ## Why
    
    We've been experiencing abuse from scrapers hitting our
    search endpoint at 1000+ requests/minute, degrading
    performance for legitimate users. This was flagged in
    incident INC-2847.
    
    ## How to Test
    
    1. Run `make test-integration` to execute the new rate
       limiting tests
    2. For manual testing:
       - Start the server: `docker compose up`
       - Hit the endpoint rapidly: `for i in {1..100}; do
         curl -s -o /dev/null -w "%{http_code}\n"
         http://localhost:8000/api/search; done`
       - Verify you get 429 responses after exceeding the limit
    
    ## Screenshots
    
    [Before/after screenshots if applicable]
    
    ## Checklist
    
    - [x] Tests pass locally
    - [x] Documentation updated
    - [x] No breaking API changes
    - [x] Rate limit headers added per RFC 6585

    A description of this kind reduces a thirty-minute review to ten minutes. The reviewer does not need to infer why the change exists or how to test it; the information is provided directly.

    PR Etiquette That Builds Team Trust

    Pull requests involve human interaction as much as they involve code. The following conventions help sustain a healthy PR culture.

    For authors:

    • Respond to every review comment, even briefly with “Done” or “Good point, fixed.”
    • Treat review feedback as a critique of code, not of the author personally.
    • Where there is disagreement with feedback, explain the reasoning rather than ignoring the comment.
    • Self-review the PR before requesting reviews; many obvious issues can be caught this way.
    • Add inline comments to complex sections to proactively explain the reasoning.

    For reviewers:

    • Review within twenty-four hours; blocking a colleague’s PR for days disregards their time.
    • Distinguish between blocking concerns and minor suggestions; prefix optional remarks with “nit:” or “optional:”.
    • Explain why something should change, not only what should change.
    • Approve with comments where appropriate; not every suggestion needs to block the merge.
    • Acknowledge good work. A brief “nice approach here” carries weight.
    Tip: The GitHub repository should be configured with branch protection rules that require at least one approving review, passing CI checks, and up-to-date branches before a merge. This prevents accidental merges of broken code and ensures that the review process is followed consistently.

    Code Review Workflow and Standards

    Code review is among the highest-value activities in software engineering. Empirical studies of software inspection have long found that peer review catches a substantial share of defects before they reach production, and its benefits extend well beyond defect detection.

    • Knowledge sharing: reviews distribute awareness of the codebase across the team, reducing single-person dependency.
    • Mentorship: senior developers can guide juniors through real-world coding decisions.
    • Consistency: reviews enforce coding standards and architectural patterns across the team.
    • Documentation: the PR discussion becomes a record of why decisions were made.

    What to Examine in a Code Review

    A thorough code review examines several dimensions.

    Correctness: does the code do what it claims to do? Are edge cases handled? Are off-by-one errors, null-pointer risks, or race conditions present?

    Design: is the approach appropriate? Could it be simpler? Does it follow existing patterns in the codebase? Will it scale?

    Readability: can another developer understand the code six months from now? Are variable names descriptive? Is the logic clear rather than unnecessarily clever?

    Testing: are tests present? Do they cover the important cases? Do they test behavior (preferred) or implementation details (fragile)?

    Security: is user input validated? Are SQL-injection or XSS vulnerabilities present? Are secrets hard-coded? This is especially important when building REST APIs with frameworks such as FastAPI, where input validation must be rigorous.

    Performance: are there N+1 queries, unbounded loops, memory leaks, or large allocations in hot paths?

    Automating the Routine Parts

    Human reviewers should focus on design, logic, and architecture rather than formatting, style, or obvious errors. Everything that can be automated should be automated.

    # .github/workflows/code-quality.yml
    name: Code Quality
    on: [pull_request]
    
    jobs:
      lint:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: Run linter
            run: npx eslint . --format=json --output-file=lint-results.json
    
      format-check:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: Check formatting
            run: npx prettier --check "src/**/*.{ts,tsx,json}"
    
      type-check:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: TypeScript type check
            run: npx tsc --noEmit

    When linting, formatting, and type checking are handled by CI, reviewers can omit “missing semicolon” comments and focus on substantive issues.

    GitHub Actions and CI/CD Integration

    GitHub Actions has become the de facto CI/CD platform for projects hosted on GitHub. It integrates seamlessly with pull requests, branch protection rules, and the wider GitHub ecosystem. Effective use of Actions is a core professional skill.

    Anatomy of a GitHub Actions Workflow

    A workflow is defined in a YAML file under .github/workflows/. The following is a production-ready example for a Python project of the kind one might use when building a FastAPI application.

    # .github/workflows/ci.yml
    name: CI Pipeline
    
    on:
      push:
        branches: [main]
      pull_request:
        branches: [main]
    
    permissions:
      contents: read
      pull-requests: write
    
    jobs:
      test:
        runs-on: ubuntu-latest
        strategy:
          matrix:
            python-version: ["3.11", "3.12", "3.13"]
    
        services:
          postgres:
            image: postgres:16
            env:
              POSTGRES_PASSWORD: testpass
              POSTGRES_DB: testdb
            ports:
              - 5432:5432
            options: >-
              --health-cmd pg_isready
              --health-interval 10s
              --health-timeout 5s
              --health-retries 5
    
        steps:
          - uses: actions/checkout@v4
    
          - name: Set up Python ${{ matrix.python-version }}
            uses: actions/setup-python@v5
            with:
              python-version: ${{ matrix.python-version }}
    
          - name: Cache dependencies
            uses: actions/cache@v4
            with:
              path: ~/.cache/pip
              key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
              restore-keys: ${{ runner.os }}-pip-
    
          - name: Install dependencies
            run: |
              python -m pip install --upgrade pip
              pip install -r requirements.txt
              pip install -r requirements-dev.txt
    
          - name: Run linting
            run: |
              ruff check .
              ruff format --check .
    
          - name: Run tests with coverage
            run: |
              pytest --cov=src --cov-report=xml --cov-report=term-missing
            env:
              DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
    
          - name: Upload coverage
            if: matrix.python-version == '3.12'
            uses: codecov/codecov-action@v4
            with:
              file: ./coverage.xml
    
      security:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: Run security scan
            uses: pyupio/safety-action@v1
          - name: Check for secrets
            uses: trufflesecurity/trufflehog@main
            with:
              extra_args: --only-verified
    
      deploy:
        needs: [test, security]
        runs-on: ubuntu-latest
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        steps:
          - uses: actions/checkout@v4
          - name: Deploy to production
            run: echo "Deploy step here"
            env:
              DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

    This workflow demonstrates several best practices: matrix testing across Python versions, service containers for database tests, dependency caching for faster builds, security scanning as a separate job, and conditional deployment that only runs on main branch pushes after all checks pass.

    Protecting the Main Branch

    Branch protection rules are the safeguards that prevent accidents. At a minimum, the following should be configured for the main branch.

    # Configure via GitHub UI: Settings > Branches > Branch protection rules
    # Or via GitHub CLI:
    gh api repos/{owner}/{repo}/branches/main/protection -X PUT \
      -f "required_status_checks[strict]=true" \
      -f "required_status_checks[contexts][]=test" \
      -f "required_status_checks[contexts][]=security" \
      -f "required_pull_request_reviews[required_approving_review_count]=1" \
      -f "required_pull_request_reviews[dismiss_stale_reviews]=true" \
      -f "enforce_admins=true" \
      -f "restrictions=null"

    These rules ensure that:

    • No one can push directly to main (all changes go through PRs)
    • At least one team member must approve the PR
    • All CI checks must pass before merging
    • Stale approvals are dismissed when new commits are pushed (preventing approval bypass)
    • Even repository admins must follow the rules

    Git Hooks for Quality Enforcement

    Git hooks are scripts that run automatically at specific points in the Git workflow. They serve as a first line of defense, catching issues on the developer’s machine before code even reaches the remote repository.

    Git Hooks in the CI/CD Pipeline Local Machine Remote / CI Server Write Code git add. pre-commit Lint code Format check git commit pre-push Run tests Type check git push GitHub receives push CI Pipeline Full test suite Security scan Build Docker image Artifacts Deploy Production fail: fix & retry fail: fix & retry Git Hooks (local) CI Checks (remote) Deployment

    Essential Git Hooks

    The two most useful client-side hooks are pre-commit and pre-push.

    Pre-commit runs before every commit and is suited to fast checks such as linting, formatting, and static analysis. If the hook fails, the commit is rejected.

    Pre-push runs before every push to a remote and is suited to slower checks such as running the test suite, type checking, or security scanning. It is the last gate before code leaves the developer’s machine.

    #!/bin/sh
    # .git/hooks/pre-commit
    
    echo "Running pre-commit checks..."
    
    # Check for formatting issues
    if ! npx prettier --check "src/**/*.{ts,tsx,json}" 2>/dev/null; then
        echo "ERROR: Formatting issues found. Run 'npx prettier --write .' to fix."
        exit 1
    fi
    
    # Run linter
    if ! npx eslint src/ --quiet; then
        echo "ERROR: Linting errors found. Fix them before committing."
        exit 1
    fi
    
    # Check for console.log statements
    if git diff --cached --name-only | xargs grep -l 'console\.log' 2>/dev/null; then
        echo "WARNING: Found console.log statements in staged files."
        echo "Remove them or use a proper logger before committing."
        exit 1
    fi
    
    # Check for secrets (basic check)
    if git diff --cached | grep -iE '(api_key|secret|password|token)\s*=' | grep -v '#' | grep -v '//'; then
        echo "ERROR: Possible secrets detected in staged changes!"
        exit 1
    fi
    
    echo "All pre-commit checks passed."

    Using Husky and lint-staged for JavaScript/TypeScript Projects

    Managing Git hooks manually is tedious. Husky automates hook installation, and lint-staged runs tools only on staged files (not the entire project), making hooks fast even in large codebases.

    # Install Husky and lint-staged
    npm install --save-dev husky lint-staged
    
    # Initialize Husky
    npx husky init
    
    # Create pre-commit hook
    echo "npx lint-staged" > .husky/pre-commit

    Configure lint-staged in package.json:

    {
      "lint-staged": {
        "*.{ts,tsx}": [
          "eslint --fix",
          "prettier --write"
        ],
        "*.{json,md}": [
          "prettier --write"
        ],
        "*.py": [
          "ruff check --fix",
          "ruff format"
        ]
      }
    }

    For Python projects, the equivalent tool is pre-commit (confusingly named the same as the Git hook). It supports hooks for any language and manages tool versions automatically:

    # .pre-commit-config.yaml
    repos:
      - repo: https://github.com/astral-sh/ruff-pre-commit
        rev: v0.4.0
        hooks:
          - id: ruff
            args: [--fix]
          - id: ruff-format
      - repo: https://github.com/pre-commit/pre-commit-hooks
        rev: v4.6.0
        hooks:
          - id: trailing-whitespace
          - id: end-of-file-fixer
          - id: check-yaml
          - id: check-added-large-files
            args: ['--maxkb=500']
          - id: detect-private-key
    Key Takeaway: Git hooks shift quality enforcement left, catching issues on the developer’s machine rather than in CI. This creates a faster feedback loop and reduces wasted CI minutes. Combine local hooks for fast checks with CI for comprehensive checks.

    Advanced Git Techniques

    The techniques in this section separate competent Git users from Git power users. These commands can save a developer hours of debugging and make complex code-history operations feel routine.

    Interactive Rebase: Rewriting History Carefully

    Interactive rebase (git rebase -i) allows a developer to rewrite commit history before sharing it. This is particularly powerful for consolidating a disorganized development history into a clean, logical sequence of commits before opening a PR.

    # Rebase the last 5 commits interactively
    git rebase -i HEAD~5
    
    # Your editor will show something like:
    pick a1b2c3d feat(auth): add login endpoint
    pick d4e5f6g WIP: working on validation
    pick h7i8j9k fix typo
    pick l0m1n2o add input validation
    pick p3q4r5s feat(auth): add password reset flow
    
    # Change to:
    pick a1b2c3d feat(auth): add login endpoint
    fixup d4e5f6g WIP: working on validation    # merge into previous, discard message
    fixup h7i8j9k fix typo                      # merge into previous, discard message
    squash l0m1n2o add input validation          # merge into previous, edit message
    pick p3q4r5s feat(auth): add password reset flow
    
    # Result: 3 messy commits become part of the first commit
    # with a clean, combined message

    The commands available in interactive rebase are listed below.

    Command What It Does
    pick Keep the commit as-is
    reword Keep changes but edit the commit message
    squash Merge into the previous commit, combine messages
    fixup Merge into previous commit, discard this commit’s message
    edit Pause rebase to amend the commit (add/remove files, split it)
    drop Delete the commit entirely

     

    Caution: Never rebase commits that have been pushed to a shared branch. Rebasing rewrites commit hashes, which means anyone else who has pulled those commits will have conflicts. The golden rule: rebase local commits before pushing; never rebase shared history.

    Git Bisect: Finding Bugs with Binary Search

    git bisect uses binary search to identify which commit introduced a bug. Instead of checking every commit one by one, it narrows down the responsible commit in logarithmic time, examining roughly 10 commits to search through 1,000.

    # Start bisecting
    git bisect start
    
    # Mark the current commit as bad (has the bug)
    git bisect bad
    
    # Mark a known good commit (before the bug existed)
    git bisect good v2.1.0
    
    # Git checks out a commit halfway between good and bad
    # Test it, then tell Git:
    git bisect good  # if this commit doesn't have the bug
    # or
    git bisect bad   # if this commit has the bug
    
    # Git narrows the range and checks out the next commit to test
    # Repeat until Git identifies the exact commit
    
    # When done:
    git bisect reset
    
    # Pro tip: Automate bisect with a test script
    git bisect start HEAD v2.1.0
    git bisect run python -m pytest tests/test_auth.py::test_login -x

    The automated version (git bisect run) is especially powerful. When supplied with a script that exits with code 0 for “good” and a non-zero code for “bad,” it will find the offending commit without any manual intervention. This is a valuable technique when tracking down regressions in complex systems, whether the work involves Python or Rust codebases.

    Cherry-Pick: Surgical Commit Transplanting

    git cherry-pick copies a specific commit from one branch to another. It is essential for backporting fixes to release branches or for selectively applying changes.

    # Apply a specific commit to the current branch
    git cherry-pick a1b2c3d
    
    # Cherry-pick without committing (stage the changes instead)
    git cherry-pick --no-commit a1b2c3d
    
    # Cherry-pick a range of commits
    git cherry-pick a1b2c3d..f4e5d6c
    
    # If there are conflicts during cherry-pick:
    # Fix the conflicts, then:
    git cherry-pick --continue
    # Or abort:
    git cherry-pick --abort

    A common use case arises after an important bug has been fixed on main and the same fix is also required on a release branch. Instead of merging all of main into the release branch, which would include unfinished features, a developer can cherry-pick only the fix commit.

    Reflog: The Git Safety Net

    The reflog (reference log) is Git’s undo history. It records every time HEAD moves, including commits, merges, rebases, resets, and checkouts. Even when commits appear to have been lost through a bad rebase or a hard reset, the reflog usually retains them.

    # View the reflog
    git reflog
    
    # Output looks like:
    # a1b2c3d HEAD@{0}: commit: feat(api): add rate limiting
    # d4e5f6g HEAD@{1}: rebase: finishing
    # h7i8j9k HEAD@{2}: rebase: starting
    # l0m1n2o HEAD@{3}: commit: fix(db): close connection on error
    # p3q4r5s HEAD@{4}: checkout: moving from feature-x to main
    
    # Recover a commit lost during rebase
    git checkout -b recovery-branch HEAD@{3}
    
    # Or reset to a previous state
    git reset --hard HEAD@{4}

    The reflog functions as a time machine. It is the reason that, in Git, it is almost impossible to truly lose work: the data is still present and only needs to be located. Reflog entries are kept for 90 days by default, which provides a generous window for recovery.

    Tip: If a branch is accidentally deleted or a reset targets the wrong commit, recovery is straightforward. Run git reflog, find the required commit hash, and create a new branch pointing to it: git checkout -b rescue HEAD@{n}.

    Git Worktree: Multiple Working Directories

    A developer often needs to work on a hotfix while a feature branch still has uncommitted changes. Instead of stashing, which can become disorganized, git worktree creates a separate working directory for the same repository.

    # Create a new worktree for a hotfix
    git worktree add ../hotfix-branch hotfix/critical-bug
    
    # Work in the new directory
    cd ../hotfix-branch
    # Make changes, commit, push
    
    # When done, remove the worktree
    git worktree remove ../hotfix-branch
    
    # List all worktrees
    git worktree list

    Each worktree is a fully functional checkout with its own staging area and working directory. A developer can maintain as many as required, all sharing the same repository history and objects. This is especially useful for those who frequently context-switch between tasks.

    Security: Protecting the Repository

    Security in Git extends beyond writing secure code. It also requires ensuring that the repository itself does not become a vulnerability vector. A single committed secret can compromise an entire infrastructure.

    A Comprehensive.gitignore

    The .gitignore file is the first line of defense against accidentally committing sensitive files. A comprehensive template should be used as a starting point and then customized for the specific technology stack.

    # Environment and secrets
    .env
    .env.*
    !.env.example
    *.pem
    *.key
    *.p12
    credentials.json
    service-account.json
    
    # Dependencies
    node_modules/
    vendor/
    __pycache__/
    *.pyc
    .venv/
    venv/
    
    # Build output
    dist/
    build/
    *.egg-info/
    target/
    
    # IDE files
    .idea/
    .vscode/settings.json
    *.swp
    *.swo
    .DS_Store
    
    # Logs and databases
    *.log
    *.sqlite3
    *.db
    
    # Test and coverage
    coverage/
    .coverage
    htmlcov/
    .pytest_cache/
    .nyc_output/

    When an application is containerized with Docker for production deployments, the .dockerignore file should mirror the .gitignore to avoid baking secrets into Docker images.

    Secrets Scanning

    Even with a well-configured .gitignore, developers sometimes commit secrets accidentally. GitGuardian’s 2024 State of Secrets Sprawl report found that over 12 million new secrets were detected in public GitHub commits in a single year.

    Multiple layers of protection are advisable.

    Pre-commit hook: tools such as detect-secrets or trufflehog scan changes before they are committed.

    GitHub’s built-in secret scanning: available for public repositories at no cost and for private repositories through GitHub Advanced Security. It scans for known secret patterns from over 200 service providers.

    CI pipeline scanning: a secrets scan added to the CI workflow serves as a final safety net.

    # Install detect-secrets
    pip install detect-secrets
    
    # Create a baseline of existing secrets (to handle legacy code)
    detect-secrets scan > .secrets.baseline
    
    # Scan for new secrets
    detect-secrets scan --baseline .secrets.baseline
    
    # Add to pre-commit config
    # .pre-commit-config.yaml
    repos:
      - repo: https://github.com/Yelp/detect-secrets
        rev: v1.4.0
        hooks:
          - id: detect-secrets
            args: ['--baseline', '.secrets.baseline']
    Caution: If a secret is accidentally committed, simply removing it in a new commit is not enough. The secret remains in Git history permanently. Three steps are required: (1) immediately rotate the compromised credential, (2) use git filter-repo or BFG Repo-Cleaner to purge the secret from history, and (3) force-push the cleaned history. GitHub also provides a guide for removing sensitive data.

    Signed Commits: Verifying Identity

    Git commits include an author field, but nothing prevents someone from setting it to any name or email address. Signed commits use GPG or SSH keys to cryptographically verify that a commit genuinely originated from the claimed author.

    # Option 1: Sign with SSH key (simpler, recommended since Git 2.34)
    git config --global gpg.format ssh
    git config --global user.signingkey ~/.ssh/id_ed25519.pub
    git config --global commit.gpgsign true
    
    # Option 2: Sign with GPG key (traditional approach)
    # First, generate a GPG key:
    gpg --full-generate-key
    
    # Get your key ID:
    gpg --list-secret-keys --keyid-format=long
    
    # Configure Git to use it:
    git config --global user.signingkey YOUR_KEY_ID
    git config --global commit.gpgsign true
    
    # Verify a signed commit
    git log --show-signature
    
    # On GitHub, signed commits show a "Verified" badge

    Many organizations now require signed commits as a matter of security policy. GitHub, GitLab, and Bitbucket all display verification badges on signed commits, giving the team confidence that commits have not been tampered with.

    Monorepo vs Polyrepo

    As an organization grows, it faces a fundamental architectural decision: whether to keep all code in a single repository (monorepo) or to split it across multiple repositories (polyrepo).

    The Monorepo Approach

    Google, Meta, Microsoft, and Twitter/X all use monorepos, single repositories containing multiple projects, services, and libraries. Google’s monorepo is legendary: over 2 billion lines of code, 86 terabytes, with 25,000 developers committing changes daily.

    Advantages:

    • Atomic cross-project changes: Refactor a shared library and update all consumers in a single commit
    • Code sharing: Easy to extract common code into shared packages
    • Unified tooling: One CI/CD pipeline, one set of linting rules, one testing framework
    • Simplified dependency management: No version matrix across repos

    Challenges:

    • Scale: Git slows down considerably with very large repositories (hundreds of GB), requiring tools such as VFS for Git, sparse checkouts, or git clone --filter
    • CI complexity: requires intelligent CI that tests only what changed, not the entire repository
    • Access control: Harder to restrict access to specific directories (GitHub has CODEOWNERS; GitLab has more granular permissions)

    Popular monorepo tooling includes Nx (JavaScript/TypeScript), Bazel (multi-language, used by Google), Turborepo (JavaScript), and Pants (Python). These tools understand the dependency graph of a monorepo and can determine which projects are affected by a change, running only the necessary tests and builds.

    The Polyrepo Approach

    Most organizations use polyrepos—separate repositories for each service, library, or application. This is the default pattern on GitHub and maps naturally to microservices architectures where each service lives in its own Docker container.

    Advantages:

    • Clear ownership: Each repo has a defined team, README, and set of maintainers
    • Independent deployment: Each service can be built, tested, and deployed independently
    • Access control: Simple and granular—each repo has its own permissions
    • Git performance: Never an issue; repos stay small

    Challenges:

    • Cross-repo changes: Updating a shared library requires PRs to every consuming repo
    • Version conflicts: Service A depends on library v1.2, Service B depends on v1.5, and the two are incompatible
    • Inconsistent tooling: Each repo might use different linters, test frameworks, or CI configurations
    • Discovery: Hard for new developers to find relevant code across dozens of repos
    Factor Monorepo Polyrepo
    Cross-project refactoring Easy, single commit Hard—multiple PRs
    Git performance Degrades at scale Always fast
    Access control Complex (CODEOWNERS) Simple per-repo
    CI/CD Needs smart build tools Standard per-repo
    Code sharing Direct imports Via package registries
    Team independence Less—shared rules More, full autonomy
    Best for Tightly coupled services Independent microservices

     

    Key Takeaway: There is no universally correct answer. Many successful organizations use a hybrid approach: a monorepo for closely related services and shared libraries, with separate repositories for truly independent applications. The choice should be based on team size, the degree of coupling between projects, and tooling maturity.

    Frequently Asked Questions

    Should I use merge or rebase to integrate changes from the main branch?

    It depends on your team’s preference and the context. Merge preserves the exact history of how development happened—you can see when branches diverged and reconnected. Rebase creates a linear history that’s easier to read and bisect. A common best practice is to rebase your feature branch onto main before merging (to stay up to date and resolve conflicts early), then use a merge commit to integrate the feature into main. This gives you the best of both worlds: a clean branch history with an explicit record of when the feature was integrated. Many teams enforce this with GitHub’s “Require linear history” or “Squash and merge” options.

    How do I undo the last commit without losing changes?

    Use git reset --soft HEAD~1. This moves HEAD back one commit but keeps all the changes from that commit staged and ready to be recommitted. If you also want to unstage the changes (keep them as working directory modifications), use git reset --mixed HEAD~1 (or simply git reset HEAD~1 since mixed is the default). If you’ve already pushed the commit, use git revert HEAD instead—this creates a new commit that undoes the changes, preserving shared history.

    What’s the difference between git fetch and git pull?

    git fetch downloads new data from the remote repository (new commits, branches, tags) but doesn’t change your working directory or current branch. It updates your remote-tracking branches (like origin/main) so you can see what’s changed. git pull is essentially git fetch followed by git merge (or git rebase if configured). Using git fetch first gives you the opportunity to inspect changes before integrating them, which is safer. Many experienced developers prefer git fetch + git merge (or rebase) over git pull for this reason.

    How should I handle large binary files in Git?

    Git is designed for text files. Large binary files (images, videos, compiled assets, ML models) bloat the repository because Git stores every version. Use Git LFS (Large File Storage) to handle binaries. Git LFS replaces large files with text pointers in the repository while storing the actual file content on a separate server. Set it up with git lfs install and git lfs track "*.psd". GitHub provides 1 GB of free LFS storage per repository, with additional storage available for purchase.

    How many approvals should be required for a pull request?

    For most teams, one approval is the sweet spot. It ensures that at least one other person has reviewed the code without creating a bottleneck. For critical paths (security-sensitive code, database migrations, infrastructure changes), consider requiring two approvals. Use GitHub’s CODEOWNERS file to automatically assign reviewers based on which files are changed. Avoid requiring more than two approvals, it creates delays without proportionally increasing quality. If you have concerns about a specific change, escalate through conversation rather than adding more required reviewers.

    Concluding Remarks

    Git mastery is not a matter of memorizing obscure commands. It rests on understanding the mental model—the DAG of snapshots, the pointers, the graph operations—and on building on that foundation with disciplined practices that improve team productivity, codebase maintainability, and deployment reliability.

    The most consequential practices covered in this guide are summarized below.

    Choose a branching strategy deliberately. GitHub Flow offers simplicity and speed. Git Flow offers structure and release management. Trunk-Based Development offers velocity at the cost of requiring greater discipline and mature CI/CD. The appropriate choice is the one that matches the team’s circumstances rather than the one that sounds most sophisticated.

    Write atomic commits with meaningful messages. A commit history is a communication tool. Conventional Commits provides structure. git add -p helps maintain focus. Messages should explain why, not only what.

    Keep pull requests small and well-described. Under 400 lines. One logical change per PR. Include context, testing instructions, and screenshots. Reviewers will reciprocate with faster and more thorough reviews.

    Automate quality enforcement. Use pre-commit hooks for fast local checks, GitHub Actions for comprehensive CI, and branch protection rules to prevent accidents. The most effective teams structure their tooling so that doing the wrong thing is harder than doing the right thing.

    Learn the advanced tools. Interactive rebase for cleaning up history. Bisect for finding bugs efficiently. Reflog for recovering from mistakes. These are not esoteric tricks but routine instruments for professional developers.

    Take security seriously. Use a comprehensive .gitignore. Scan for secrets in pre-commit hooks and CI. Sign commits. Remember that Git history is permanent: a committed secret is a compromised secret, even if it is removed in the next commit.

    The investment in learning these practices yields compound returns. Each clean commit, well-structured PR, and automated check accumulates into a codebase that is a pleasure to work with rather than a hazard to navigate. In an industry where the ability to ship reliable software quickly is a core competitive advantage, this matters more than any framework or language choice.

    One change should be initiated this week, whether it is adopting Conventional Commits, adding a pre-commit hook, or configuring branch protection rules on the main repository. Small, consistent improvements compound over time, in Git practices as in any other long-term discipline.

    References

  • SVM vs One-Class SVM (OCSVM): A Complete Comparison with Visual Explanations and Implementation Guide

    Summary

    What this post covers: A side-by-side, math-and-code walkthrough of Support Vector Machines (SVM) and One-Class SVM (OCSVM), showing when each is the right tool and how their kernel-based machinery diverges despite the shared name.

    Key insights:

    • SVM is a supervised binary classifier that maximizes the margin between two labeled classes; OCSVM is a semi-supervised anomaly detector that wraps a boundary around a single “normal” class and flags everything outside as suspicious.
    • Use SVM only when you have labeled examples of both classes; use OCSVM when anomalies are rare, diverse, or absent from training data, applying the wrong one will either fail to train or throw away half your information.
    • Feature scaling and the RBF gamma parameter dominate practical performance: a factor-of-two change in gamma can be the difference between a working model and a useless one, more impactful than any algorithmic substitution.
    • OCSVM is highly sensitive to contamination, even a small fraction of anomalies leaking into the “normal” training set produces an overly permissive boundary, so curating clean training data or using a small nu is essential.
    • For datasets with millions of samples, kernel SVM and OCSVM become impractical due to O(n^2) memory; Isolation Forest or SGD-based linear variants are better choices at that scale.

    Main topics: Introduction, What Is SVM (Support Vector Machine)?, What Is OCSVM (One-Class SVM)?, SVM vs OCSVM: Head-to-Head Comparison, Implementation: Complete Python Code, Real-World Use Cases, Practical Decision Guide: When to Use Which?, Advanced Topics, Performance Comparison, Hyperparameter Tuning Guide, Common Pitfalls, Putting It Together, References.

    Introduction

    Consider a manufacturing engineer monitoring an assembly line that produces ten thousand circuit boards per day. Of those ten thousand, perhaps three are defective. A machine learning model must catch those three, yet the available data consists overwhelmingly of examples of good boards, with very few examples of defective ones. The choice is between waiting months to collect sufficient defective samples and building a model that learns the structure of “normal” and flags everything else.

    This dilemma marks the fundamental divide between two of the most important algorithms in machine learning: the Support Vector Machine (SVM) and its less widely recognised counterpart, the One-Class SVM (OCSVM). Despite a shared name and mathematical lineage, the two algorithms address fundamentally different problems. SVM is a supervised classifier that draws a boundary between two labelled groups. OCSVM is a semi-supervised anomaly detector that wraps a boundary around a single group and treats any point falling outside it as suspicious.

    Choosing the wrong method has serious consequences. Applying SVM in the absence of labelled anomalies prevents the model from training at all. Applying OCSVM to perfectly balanced, labelled data discards half of the available information. Yet in tutorials across the internet, the two are routinely conflated, treated cursorily, or illustrated with identical toy examples that obscure their substantive differences.

    The present article addresses these gaps. Both algorithms are presented from first principles, with inline SVG diagrams that render the geometry visible. The mathematics is covered with sufficient depth but without excess, and complete runnable Python implementations of both algorithms are provided. A practical decision framework follows, intended to support correct method selection. The treatment is suitable both for a data scientist choosing between approaches in a fraud detection system and for a student aiming to understand when single-class modelling is appropriate.

    Disclaimer: This article is provided for informational and educational purposes only. References to specific tools, datasets, or products do not constitute endorsements. Model performance should always be validated on the practitioner’s own data before deployment to production.

    What Is SVM (Support Vector Machine)?

    The Support Vector Machine is one of the more elegant algorithms in machine learning. Developed in the 1990s by Vladimir Vapnik and colleagues at AT&T Bell Labs, SVM is a supervised binary classifier that identifies the optimal hyperplane—a decision boundary—that separates two classes of data with the maximum possible margin.

    The intuition is as follows. Consider a scatterplot with blue points on one side and red points on the other. Infinitely many lines could separate them. SVM selects the line that sits as far as possible from the nearest points of both classes. Those nearest points are termed support vectors, and they support the position of the boundary in a literal sense: removing them shifts the boundary. All other points in the dataset are irrelevant to the final model.

    Visualising the Standard SVM

    The following diagram shows how SVM operates in two dimensions. The decision boundary (solid line) sits exactly between the two classes, with the margin (the gap between the dashed lines) maximised:

    Standard SVM: Maximum Margin Classification Margin Class A Class B Decision Boundary Support Vectors (bold outline)

    This is the central insight of SVM: only the support vectors are consequential. The algorithm is efficient precisely because it ignores the vast majority of training points and focuses on the few that determine the boundary.

    Mathematical Formulation

    For readers interested in the mathematics, SVM optimises the following objective. Given training data {(x₁, y₁),…, (xₙ, yₙ)} where yᵢ ∈ {-1, +1}, the hard-margin SVM solves:

    Minimize: ½ ||w||²
    Subject to: yᵢ(w · xᵢ + b) ≥ 1 for all i

    Here, w is the weight vector (perpendicular to the hyperplane), b is the bias term, and the constraint ensures that every point lies on the correct side of the margin. The term ||w||² controls the margin width: minimising it maximises the margin.

    Soft Margin SVM and the C Parameter

    Real-world data is rarely clean. Classes overlap, and outliers occur. The hard-margin SVM fails on any dataset that is not perfectly separable. The soft-margin SVM introduces slack variables ξᵢ that allow some points to violate the margin or even be misclassified:

    Minimize: ½ ||w||² + C Σ ξᵢ
    Subject to: yᵢ(w · xᵢ + b) ≥ 1 – ξᵢ,   ξᵢ ≥ 0

    The parameter C is the regularisation constant. A large C penalises misclassifications heavily (tight fit, risk of overfitting). A small C allows more misclassifications (smoother boundary, better generalisation). Tuning C is among the most important decisions in SVM usage.

    The Kernel Trick

    What if the data is not linearly separable in its original space, so that no hyperplane can divide the classes? The kernel trick is SVM’s principal mechanism for handling this case. It implicitly maps data into a higher-dimensional feature space in which a linear separator does exist, without ever computing coordinates in that space. Instead, every dot product x · x’ is replaced by a kernel function K(x, x’).

    Common kernels include:

    • Linear: K(x, x’) = x · x’, appropriate for linearly separable data.
    • RBF (Gaussian): K(x, x’) = exp(-γ ||x – x’||²), the default choice for most nonlinear problems.
    • Polynomial: K(x, x’) = (γ x · x’ + r)^d, used for polynomial decision boundaries.

    The Kernel Trick: Mapping to Higher Dimensions Original Space (Not Separable) No linear boundary possible! φ(x) Kernel Mapping Feature Space (Separable!) Linear separator works! x₁, x₂ φ₁(x), φ₂(x), φ₃(x)

    The advantage of the kernel trick is computational. SVM optimisation requires only dot products between data points. Replacing those dot products with a kernel function produces the effect of operating in a high-dimensional (possibly infinite-dimensional) space without computing the explicit transformation. This is why SVM with an RBF kernel can handle strongly nonlinear boundaries at reasonable computational cost.

    Key Takeaway: SVM requires labelled data from both classes. It is a supervised algorithm well suited to binary classification, particularly in high-dimensional spaces, on small-to-medium datasets, and in settings where the margin of separation carries useful information.

    When to Use SVM

    SVM performs particularly well in the following scenarios:

    • Binary classification with labelled data: spam versus non-spam, tumour versus healthy, positive versus negative sentiment.
    • High-dimensional data: text classification (TF-IDF vectors with thousands of features) and genomics data.
    • Small to medium datasets: SVM’s training complexity of O(n²) to O(n³) makes it impractical for millions of samples, but it is highly effective on datasets in the thousands.
    • When a clear margin is desired: the margin provides a geometric notion of confidence.
    • When support vector interpretability matters: a practitioner can inspect which training examples serve as support vectors.

    Strengths and Weaknesses

    Strengths: Strong generalisation with appropriate tuning, effectiveness in high dimensions, memory efficiency (only support vectors are stored), robustness to overfitting when C is tuned, and versatility through different kernels.

    Weaknesses: Limited scalability beyond roughly 100,000 samples, sensitivity to feature scaling, substantial dependence on kernel choice and hyperparameter settings, no direct provision of probability estimates (though Platt scaling can approximate them), and difficulty with highly noisy or strongly overlapping classes.

    What Is OCSVM (One-Class SVM)?

    The One-Class SVM, introduced by Bernhard Schölkopf and colleagues in 2001, inverts the standard SVM paradigm. Instead of learning a boundary between two classes, OCSVM learns a boundary around a single class. Points inside the boundary are treated as normal; points outside are treated as anomalous.

    This formulation matches many real-world problems in which only one class is represented in the training data. Examples include:

    • Millions of legitimate credit card transactions but only a handful of fraudulent ones.
    • Years of sensor data from healthy machines but only a few recordings from moments preceding failure.
    • Vast archives of normal network traffic but very few examples of novel attacks—and future attacks tend to differ from past ones.

    In each of these cases, training a standard SVM is not feasible because representative examples of the negative class are unavailable. OCSVM addresses this constraint by requiring only normal data for training.

    Visualising One-Class SVM

    One-Class SVM: Anomaly Detection Boundary Anomaly Region Normal Region Normal Data Anomalies ν controls boundary tightness Decision Boundary

    Unlike standard SVM, which requires two classes to construct a decision boundary, OCSVM requires only normal data. It learns the shape of the normal class and draws a tight boundary around it. Any new data point falling outside that boundary is flagged as anomalous.

    Mathematical Formulation

    Schölkopf’s formulation maps the data into a feature space via a kernel and then identifies a hyperplane that separates the data from the origin with maximum margin. The optimisation problem is:

    Minimize: ½ ||w||² + (1/νn) Σ ξᵢ – ρ
    Subject to: w · φ(xᵢ) ≥ ρ – ξᵢ,   ξᵢ ≥ 0

    Here ρ is the offset from the origin, and ν serves a dual role: it is an upper bound on the fraction of outliers and a lower bound on the fraction of support vectors. Setting ν = 0.05 means that at most 5% of the training data is expected to be outliers, and at least 5% of the points will serve as support vectors.

    The ν Parameter

    The ν (nu) parameter is the most important hyperparameter in OCSVM and warrants careful consideration:

    • ν = 0.01: A very tight setting, permitting only 1% of training data outside the boundary. Appropriate when the training data is clean.
    • ν = 0.05: A common starting point, allowing 5% as potential outliers.
    • ν = 0.1: A more relaxed setting, useful when the training data is suspected to contain some contamination.
    • ν = 0.5: A very loose setting under which up to half the data may fall outside the boundary. Rarely useful in practice.
    Tip: Set ν equal to the best available estimate of the contamination rate in the training data. If the training data is clean (only normal examples), use a small ν in the range 0.01 to 0.05. If anomalies are suspected to have entered the training set, increase ν accordingly.

    The Effect of γ (Gamma) on the Boundary

    When OCSVM is used with an RBF kernel (the most common configuration), the γ parameter controls how tightly the boundary wraps around the data. It is arguably the most sensitive parameter in the entire model:

    Effect of γ on OCSVM Decision Boundary γ = 0.01 (Underfit) Anomalies inside boundary! Too many false negatives γ = 0.1 (Good Fit) Anomalies correctly detected! Good balance γ = 1.0 (Overfit) Normal data flagged as anomaly! Too many false positives

    The diagrams above illustrate the substantial effect of γ. At excessively low values, the boundary becomes so loose that it includes actual anomalies. At excessively high values, the boundary wraps so tightly that normal data is flagged as anomalous. Identifying an appropriate setting requires either domain knowledge of how tight the boundary should be or systematic evaluation against a validation set containing known anomalies.

    When to Use OCSVM

    • Anomaly or novelty detection: identifying unusual data points.
    • Only normal data available: no labelled anomalies are present for training.
    • Rare event detection: anomalies occur so infrequently that balanced classification is not feasible.
    • Open-set recognition: the form of future anomalies is unknown.
    • Manufacturing quality control: training on good parts, detecting defective ones.

    Strengths and Weaknesses

    Strengths: The method requires only normal data for training, naturally handles class imbalance, performs effectively in novelty detection (identifying anomaly types not previously observed), supports kernels for nonlinear boundaries, and provides a decision function score for ranking anomalies.

    Weaknesses: The method shares the scalability constraints of SVM (O(n²) to O(n³)), is highly sensitive to the γ and ν parameters, offers no performance guarantee without labelled anomalies for validation, assumes that normal data is well clustered and anomalies are diffuse, and can struggle when the normal data exhibits multiple modes or clusters.

    SVM and OCSVM: A Direct Comparison

    The two algorithms are now placed side by side. The following diagram illustrates the fundamental difference in what each algorithm does:

    SVM: Separate Two Classes Supervised, needs labels for BOTH classes Class A Class B Margin maximized between classes OCSVM: Bound Normal Data Semi-supervised—needs ONLY normal data Normal Anomalies Boundary wraps around normal data

    Comprehensive Comparison Table

    Feature SVM (SVC) OCSVM (OneClassSVM)
    Type Supervised classification Semi-supervised anomaly detection
    Training Data Labeled examples from BOTH classes Only normal class (unlabeled or single-label)
    Output Class label (+1 or -1) Normal (+1) or anomaly (-1), plus decision score
    Objective Maximize margin between two classes Minimize boundary around normal data
    Key Parameters C (regularization), kernel, γ ν (outlier fraction), kernel, γ
    Primary Use Case Binary/multi-class classification Anomaly detection, novelty detection
    Scalability O(n² to n³)—practical up to ~100K O(n² to n³),practical up to ~100K
    Interpretability Support vectors show boundary examples Decision function score, support vectors on boundary
    sklearn Class sklearn.svm.SVC sklearn.svm.OneClassSVM
    Handles Class Imbalance? With class_weight parameter Naturally (only trains on one class)

     

    Implementation: Complete Python Code

    Theory now gives way to practice. The following sections present complete, runnable Python scripts for both algorithms. Each script generates synthetic data, trains the model, visualises the results, and prints evaluation metrics.

    SVM Implementation

    import numpy as np
    import matplotlib.pyplot as plt
    from sklearn.svm import SVC
    from sklearn.datasets import make_classification
    from sklearn.model_selection import train_test_split, GridSearchCV
    from sklearn.preprocessing import StandardScaler
    from sklearn.metrics import (
        classification_report, confusion_matrix, accuracy_score, f1_score
    )
    
    # --- Generate synthetic 2D data ---
    X, y = make_classification(
        n_samples=300, n_features=2, n_redundant=0,
        n_informative=2, n_clusters_per_class=1,
        class_sep=1.2, random_state=42
    )
    
    # --- Split and scale ---
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.3, random_state=42
    )
    scaler = StandardScaler()
    X_train_s = scaler.fit_transform(X_train)
    X_test_s = scaler.transform(X_test)
    
    # --- Train SVM with RBF kernel ---
    svm = SVC(kernel='rbf', C=1.0, gamma='scale', random_state=42)
    svm.fit(X_train_s, y_train)
    
    # --- Evaluate ---
    y_pred = svm.predict(X_test_s)
    print("=== SVM Results ===")
    print(f"Accuracy: {accuracy_score(y_test, y_pred):.3f}")
    print(f"F1 Score: {f1_score(y_test, y_pred):.3f}")
    print(f"Support Vectors: {svm.n_support_}")
    print("\nConfusion Matrix:")
    print(confusion_matrix(y_test, y_pred))
    print("\nClassification Report:")
    print(classification_report(y_test, y_pred))
    
    # --- Plot decision boundary ---
    fig, ax = plt.subplots(1, 1, figsize=(8, 6))
    xx, yy = np.meshgrid(
        np.linspace(X_train_s[:, 0].min()-1, X_train_s[:, 0].max()+1, 300),
        np.linspace(X_train_s[:, 1].min()-1, X_train_s[:, 1].max()+1, 300)
    )
    Z = svm.decision_function(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    
    ax.contourf(xx, yy, Z, levels=np.linspace(Z.min(), Z.max(), 20),
                cmap='RdBu', alpha=0.3)
    ax.contour(xx, yy, Z, levels=[-1, 0, 1],
               linestyles=['--', '-', '--'], colors='k')
    ax.scatter(X_train_s[y_train==0, 0], X_train_s[y_train==0, 1],
               c='#3b82f6', label='Class 0', edgecolors='k', s=40)
    ax.scatter(X_train_s[y_train==1, 0], X_train_s[y_train==1, 1],
               c='#ef4444', label='Class 1', edgecolors='k', s=40)
    ax.scatter(svm.support_vectors_[:, 0], svm.support_vectors_[:, 1],
               s=120, facecolors='none', edgecolors='gold', linewidths=2,
               label='Support Vectors')
    ax.set_title("SVM Decision Boundary (RBF Kernel)")
    ax.legend()
    plt.tight_layout()
    plt.savefig("svm_decision_boundary.png", dpi=150)
    plt.show()
    
    # --- Hyperparameter tuning ---
    param_grid = {
        'C': [0.1, 1, 10, 100],
        'gamma': ['scale', 'auto', 0.01, 0.1, 1],
        'kernel': ['rbf', 'poly']
    }
    grid = GridSearchCV(SVC(), param_grid, cv=5, scoring='f1', n_jobs=-1)
    grid.fit(X_train_s, y_train)
    print(f"\nBest params: {grid.best_params_}")
    print(f"Best CV F1:  {grid.best_score_:.3f}")

    OCSVM Implementation

    import numpy as np
    import matplotlib.pyplot as plt
    from sklearn.svm import OneClassSVM
    from sklearn.preprocessing import StandardScaler
    from sklearn.metrics import classification_report, f1_score, precision_score, recall_score
    
    # --- Generate synthetic normal data + anomalies ---
    np.random.seed(42)
    n_normal = 300
    n_anomaly = 30
    
    # Normal data: two Gaussian clusters
    normal_data = np.vstack([
        np.random.randn(n_normal // 2, 2) * 0.5 + [2, 2],
        np.random.randn(n_normal // 2, 2) * 0.5 + [3, 3],
    ])
    
    # Anomalies: scattered uniformly in a wider region
    anomalies = np.random.uniform(low=-2, high=7, size=(n_anomaly, 2))
    
    # Labels: +1 = normal, -1 = anomaly (OCSVM convention)
    y_normal = np.ones(n_normal)
    y_anomaly = -np.ones(n_anomaly)
    
    # --- Scale features (critical for SVM-based methods!) ---
    scaler = StandardScaler()
    normal_scaled = scaler.fit_transform(normal_data)
    
    # --- Train OCSVM on normal data only ---
    ocsvm = OneClassSVM(kernel='rbf', gamma=0.3, nu=0.05)
    ocsvm.fit(normal_scaled)
    
    # --- Evaluate on combined dataset ---
    X_all = np.vstack([normal_data, anomalies])
    X_all_scaled = scaler.transform(X_all)
    y_true = np.concatenate([y_normal, y_anomaly])
    
    y_pred = ocsvm.predict(X_all_scaled)
    scores = ocsvm.decision_function(X_all_scaled)
    
    print("=== OCSVM Results ===")
    print(f"Precision: {precision_score(y_true, y_pred, pos_label=-1):.3f}")
    print(f"Recall:    {recall_score(y_true, y_pred, pos_label=-1):.3f}")
    print(f"F1 Score:  {f1_score(y_true, y_pred, pos_label=-1):.3f}")
    print(f"Support Vectors: {ocsvm.support_vectors_.shape[0]}")
    print("\nClassification Report:")
    print(classification_report(y_true, y_pred,
                                target_names=['Anomaly (-1)', 'Normal (+1)']))
    
    # --- Plot decision boundary ---
    fig, ax = plt.subplots(1, 1, figsize=(8, 6))
    xx, yy = np.meshgrid(
        np.linspace(X_all_scaled[:, 0].min()-1, X_all_scaled[:, 0].max()+1, 300),
        np.linspace(X_all_scaled[:, 1].min()-1, X_all_scaled[:, 1].max()+1, 300)
    )
    Z = ocsvm.decision_function(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
    
    ax.contourf(xx, yy, Z, levels=np.linspace(Z.min(), 0, 10),
                cmap='Reds_r', alpha=0.3)
    ax.contourf(xx, yy, Z, levels=np.linspace(0, Z.max(), 10),
                cmap='Greens', alpha=0.3)
    ax.contour(xx, yy, Z, levels=[0], linewidths=2, colors='black')
    
    ax.scatter(normal_scaled[:, 0], normal_scaled[:, 1],
               c='#10b981', s=30, label='Normal', edgecolors='k', linewidths=0.5)
    anomalies_scaled = scaler.transform(anomalies)
    ax.scatter(anomalies_scaled[:, 0], anomalies_scaled[:, 1],
               c='#ef4444', s=60, marker='D', label='Anomaly', edgecolors='k')
    ax.set_title("OCSVM Decision Boundary")
    ax.legend()
    plt.tight_layout()
    plt.savefig("ocsvm_decision_boundary.png", dpi=150)
    plt.show()
    
    # --- Tune nu and gamma ---
    best_f1 = 0
    best_params = {}
    for nu in [0.01, 0.03, 0.05, 0.1, 0.2]:
        for gamma in [0.01, 0.05, 0.1, 0.3, 0.5, 1.0]:
            model = OneClassSVM(kernel='rbf', gamma=gamma, nu=nu)
            model.fit(normal_scaled)
            preds = model.predict(X_all_scaled)
            f1 = f1_score(y_true, preds, pos_label=-1)
            if f1 > best_f1:
                best_f1 = f1
                best_params = {'nu': nu, 'gamma': gamma}
    
    print(f"\nBest params: {best_params}")
    print(f"Best F1:     {best_f1:.3f}")

    Side-by-Side Comparison Script

    import numpy as np
    import matplotlib.pyplot as plt
    from sklearn.svm import SVC, OneClassSVM
    from sklearn.preprocessing import StandardScaler
    from sklearn.metrics import f1_score, accuracy_score
    
    np.random.seed(42)
    
    # Generate data: normal class + rare anomaly class
    n_normal, n_anomaly = 400, 20
    X_normal = np.random.randn(n_normal, 2) * 0.8 + [3, 3]
    X_anomaly = np.random.uniform(0, 6, size=(n_anomaly, 2))
    
    X_all = np.vstack([X_normal, X_anomaly])
    y_all = np.array([1]*n_normal + [-1]*n_anomaly)
    
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X_all)
    X_normal_scaled = scaler.transform(X_normal)
    
    # --- Approach 1: SVM (supervised — uses BOTH labels) ---
    svm = SVC(kernel='rbf', C=10, gamma='scale')
    svm.fit(X_scaled, y_all)
    y_pred_svm = svm.predict(X_scaled)
    
    # --- Approach 2: OCSVM (semi-supervised — trained on normal only) ---
    ocsvm = OneClassSVM(kernel='rbf', gamma=0.3, nu=0.05)
    ocsvm.fit(X_normal_scaled)
    y_pred_ocsvm = ocsvm.predict(X_scaled)
    
    # --- Compare metrics ---
    print("=" * 50)
    print(f"{'Metric':<25} {'SVM':>10} {'OCSVM':>10}")
    print("=" * 50)
    print(f"{'Accuracy':<25} {accuracy_score(y_all, y_pred_svm):>10.3f} "
          f"{accuracy_score(y_all, y_pred_ocsvm):>10.3f}")
    print(f"{'F1 (anomaly class)':<25} {f1_score(y_all, y_pred_svm, pos_label=-1):>10.3f} "
          f"{f1_score(y_all, y_pred_ocsvm, pos_label=-1):>10.3f}")
    print(f"{'F1 (normal class)':<25} {f1_score(y_all, y_pred_svm, pos_label=1):>10.3f} "
          f"{f1_score(y_all, y_pred_ocsvm, pos_label=1):>10.3f}")
    print("=" * 50)
    
    # --- Plot both ---
    fig, axes = plt.subplots(1, 2, figsize=(14, 6))
    for ax, model, title, preds in zip(
        axes, [svm, ocsvm],
        ["SVM (supervised)", "OCSVM (normal-only training)"],
        [y_pred_svm, y_pred_ocsvm]
    ):
        xx, yy = np.meshgrid(
            np.linspace(X_scaled[:,0].min()-1, X_scaled[:,0].max()+1, 200),
            np.linspace(X_scaled[:,1].min()-1, X_scaled[:,1].max()+1, 200)
        )
        Z = model.decision_function(
            np.c_[xx.ravel(), yy.ravel()]
        ).reshape(xx.shape)
        ax.contour(xx, yy, Z, levels=[0], colors='k', linewidths=2)
        ax.contourf(xx, yy, Z, levels=np.linspace(Z.min(), Z.max(), 20),
                    cmap='RdYlGn', alpha=0.3)
        ax.scatter(X_scaled[y_all==1, 0], X_scaled[y_all==1, 1],
                   c='#10b981', s=20, label='Normal')
        ax.scatter(X_scaled[y_all==-1, 0], X_scaled[y_all==-1, 1],
                   c='#ef4444', s=60, marker='D', label='Anomaly')
        ax.set_title(title)
        ax.legend(loc='lower right')
    
    plt.suptitle("SVM vs OCSVM on the Same Dataset", fontsize=14, y=1.02)
    plt.tight_layout()
    plt.savefig("svm_vs_ocsvm_comparison.png", dpi=150, bbox_inches='tight')
    plt.show()
    Key Takeaway: SVM has an inherent advantage when labelled anomalies are available, since it directly optimises separation between the two classes. OCSVM is the appropriate choice when labelled anomalies are unavailable or unreliable, as it constructs a useful model from normal data alone.

    Real-World Use Cases

    SVM Use Cases

    Standard SVM has served as a reliable instrument for classification tasks for more than two decades. The following are among its most consequential applications:

    Use Case Dataset Example Why SVM Works
    Email spam detection SpamAssassin Corpus High-dimensional text features, clear binary labels
    Image classification CIFAR-10, MNIST Kernel trick handles nonlinear pixel relationships
    Medical diagnosis Wisconsin Breast Cancer Small dataset, high-dimensional features, labeled outcomes
    Sentiment analysis IMDB Reviews, Yelp TF-IDF vectors are high-dimensional and sparse
    Gene expression classification Microarray datasets highly high dimensions (thousands of genes), few samples
    Handwriting recognition USPS, MNIST digits RBF kernel handles pixel-space nonlinearity well

     

    OCSVM Use Cases

    OCSVM is particularly well suited to problems in which anomalies are rare, undefined, or continually evolving:

    Use Case Industry Why OCSVM over SVM
    Manufacturing defect detection Automotive, electronics Defects are rare (< 0.1%) and come in unpredictable forms
    Network intrusion detection Cybersecurity New attack types emerge constantly—can’t label them in advance
    Credit card fraud detection Finance Fraud is < 0.01% of transactions; fraudsters change tactics
    Predictive maintenance Manufacturing, energy Machines rarely fail, abundant healthy data, minimal failure data
    IoT sensor anomaly detection Smart buildings, agriculture Continuous stream of normal readings; anomalies are diverse
    Medical device monitoring Healthcare Train on healthy patients, flag unusual vital signs

     

    Practical Decision Guide: When to Use Which

    The decision between SVM and OCSVM for a new problem can be approached through the following sequence of questions:

    Question 1: Are labelled examples available from both classes?

    • Yes → Consider SVM. The data permits training of a supervised classifier.
    • No → Use OCSVM. Learning is possible only from the available class.

    Question 2: Is one class extremely rare (less than 1% of the data)?

    • Yes → OCSVM is likely the better choice. Even when some labelled anomalies are available, the extreme imbalance degrades SVM performance unless heavy resampling is applied.
    • No → SVM with appropriate class weighting should perform well.

    Question 3: Is the objective classification or anomaly detection?

    • Classification (assigning examples to known categories) → SVM.
    • Anomaly detection (identifying examples that do not belong) → OCSVM.

    Question 4: Does the abnormal class have a clear, stable definition?

    • Yes (for example, spam exhibits consistent patterns) → SVM can learn these patterns.
    • No (for example, novel attacks or unprecedented failures) → OCSVM, since it does not require explicit knowledge of how anomalies appear.

    Scenario Recommendations

    Scenario Recommendation Reason
    10K spam + 10K ham emails SVM Balanced labeled data available
    1M normal transactions, 50 fraud cases OCSVM Extreme imbalance, fraud evolves
    Tumor vs healthy tissue (labeled) SVM Both classes labeled by pathologists
    Monitoring a new machine (no failure data) OCSVM Only healthy operation data exists
    Sentiment analysis (positive/negative) SVM Large labeled corpora available
    Detecting unknown malware variants OCSVM New variants are undefined a priori
    Dog vs cat image classifier SVM Clear binary task with labeled images
    Rare disease screening in population OCSVM Disease prevalence < 0.01%

     

    Advanced Topics

    SVDD: Support Vector Data Description

    SVDD, proposed by Tax and Duin (2004), is closely related to OCSVM. Where OCSVM identifies a hyperplane in feature space that separates the data from the origin, SVDD identifies the minimum enclosing hypersphere that contains most of the data. Points outside the sphere are anomalies.

    SVDD (Hypersphere) vs OCSVM (Hyperplane) SVDD: Minimum Enclosing Sphere center R Minimize R² s.t. ||φ(xᵢ) – c||² ≤ R² + ξᵢ OCSVM: Hyperplane from Origin origin ρ/||w|| Maximize ρ s.t. w·φ(xᵢ) ≥ ρ – ξᵢ

    In practice, SVDD with an RBF kernel produces results identical to those of OCSVM (the two are mathematically equivalent under Gaussian kernels). The principal difference is conceptual: SVDD frames the problem in terms of spheres, while OCSVM frames it in terms of hyperplanes. Most practitioners use OCSVM via scikit-learn because of its wider availability.

    Multi-Class SVM

    Standard SVM is inherently binary, but two strategies extend it to multi-class problems:

    • One-vs-Rest (OvR): Train K binary classifiers, each separating one class from all others. Assign the class with the highest decision function value. K classifiers are required.
    • One-vs-One (OvO): Train K(K-1)/2 binary classifiers, one for each pair of classes, and use majority voting. This is the default for scikit-learn’s SVC and often performs better in practice, though more models must be trained.

    Deep SVDD: Neural Networks and OCSVM

    Deep SVDD (Ruff et al., 2018) replaces the kernel trick with a deep neural network. Instead of mapping data to a kernel-defined feature space and identifying a hypersphere, it trains a neural network to map data into a learned representation space in which normal data clusters tightly around a centre point. The loss function minimises the distance from the centre of normal data representations.

    This approach scales considerably better than kernel-based OCSVM and can handle high-dimensional data such as images and time series. Libraries such as PyOD provide Deep SVDD as a default option.

    OCSVM Alternatives: Isolation Forest and LOF

    Method Approach Scalability Best For
    OCSVM Kernel-based boundary O(n²-n³)—up to ~50K Small-medium data, smooth boundaries
    Isolation Forest Random tree partitioning O(n log n)—millions Large datasets, tabular data
    LOF Local density comparison O(n²),up to ~50K Varying density clusters
    Autoencoder Reconstruction error Depends on architecture High-dimensional data (images, sequences)

     

    OCSVM for Time-Series Anomaly Detection

    OCSVM does not natively handle time-series data, but with appropriate feature engineering it becomes an effective time-series anomaly detector. The standard procedure is as follows:

    1. Sliding window: Convert the time series into fixed-length windows (for example, 60-second windows).
    2. Feature extraction: For each window, compute statistical features—mean, standard deviation, minimum, maximum, skewness, kurtosis, spectral features, and rolling statistics.
    3. Train OCSVM: Fit on feature vectors drawn from known-normal periods.
    4. Detect: Score new windows; those below the decision threshold are flagged as anomalies.
    # Time-series anomaly detection with OCSVM
    import numpy as np
    from sklearn.svm import OneClassSVM
    from sklearn.preprocessing import StandardScaler
    
    def extract_features(window):
        """Extract statistical features from a time-series window."""
        return [
            np.mean(window), np.std(window),
            np.min(window), np.max(window),
            np.percentile(window, 25), np.percentile(window, 75),
            np.max(window) - np.min(window),  # range
            np.mean(np.abs(np.diff(window))),  # mean abs change
        ]
    
    # Simulate normal time series + anomaly
    np.random.seed(42)
    normal_ts = np.sin(np.linspace(0, 20*np.pi, 2000)) + np.random.randn(2000)*0.1
    anomaly_ts = np.sin(np.linspace(0, 2*np.pi, 100)) + np.random.randn(100)*0.5 + 3
    
    # Sliding window feature extraction
    window_size = 50
    stride = 10
    features_normal = [
        extract_features(normal_ts[i:i+window_size])
        for i in range(0, len(normal_ts)-window_size, stride)
    ]
    features_anomaly = [
        extract_features(anomaly_ts[i:i+window_size])
        for i in range(0, len(anomaly_ts)-window_size, stride)
    ]
    
    X_normal = np.array(features_normal)
    X_anomaly = np.array(features_anomaly)
    
    scaler = StandardScaler()
    X_normal_s = scaler.fit_transform(X_normal)
    X_anomaly_s = scaler.transform(X_anomaly)
    
    ocsvm = OneClassSVM(kernel='rbf', gamma=0.1, nu=0.05)
    ocsvm.fit(X_normal_s)
    
    print(f"Normal windows flagged as anomaly: "
          f"{(ocsvm.predict(X_normal_s) == -1).sum()}/{len(X_normal_s)}")
    print(f"Anomaly windows detected: "
          f"{(ocsvm.predict(X_anomaly_s) == -1).sum()}/{len(X_anomaly_s)}")

    Performance Comparison

    How do these methods compare on standard anomaly detection benchmarks? The table below gives representative, illustrative AUC values for commonly used datasets to convey the relative ranking of the methods; the numbers are not drawn from a single benchmark run, and exact figures vary substantially with preprocessing and hyperparameter choices. For reproducible, systematically measured results, consult a dedicated benchmark such as the ODDS repository or ADBench (Han et al., NeurIPS 2022):

    Method Shuttle (AUC) Thyroid (AUC) Satellite (AUC) Training Time
    OCSVM (RBF) 0.995 0.920 0.850 Medium
    Isolation Forest 0.997 0.940 0.830 Fast
    LOF 0.540 0.910 0.820 Medium
    Autoencoder 0.985 0.935 0.880 Slow
    SVM (supervised) 0.999 0.980 0.920 Medium

     

    Key observations:

    • Supervised SVM consistently outperforms all unsupervised methods, but it requires labelled anomalies, which are often unavailable.
    • OCSVM performs competitively with Isolation Forest on most benchmarks, with the additional advantage of producing a smooth decision boundary.
    • Isolation Forest is typically the first choice for large datasets owing to its O(n log n) complexity.
    • OCSVM is particularly effective when the normal data has a clear, compact structure in feature space.

    Computational Complexity and Scalability

    Both SVM and OCSVM have a training complexity of O(n²) to O(n³), where n denotes the number of training samples. This arises from solving a quadratic programming problem. In practice:

    • Up to 10,000 samples: Both train in seconds to minutes without concern.
    • 10,000 to 50,000 samples: Training takes minutes to an hour, and remains feasible.
    • 50,000 to 100,000 samples: Training may take hours. Subsampling or approximate methods should be considered.
    • Above 100,000 samples: Direct application is impractical without workarounds.
    Tip: For large datasets, the following alternatives should be considered: (1) Subsampling, training on a representative subset; (2) SGD-based SVM, using sklearn.linear_model.SGDOneClassSVM for linear OCSVM at scale; (3) Nystroem or RBFSampler, which approximate the kernel with explicit feature maps and allow subsequent use of a linear SVM; or (4) switching to Isolation Forest, which handles millions of samples efficiently.

    Hyperparameter Tuning Guide

    Appropriate hyperparameter settings often determine whether a model works at all. The following provides a complete tuning guide:

    Tuning SVM

    Parameter What It Controls Starting Value Search Range
    C Regularization—trade-off between margin width and misclassification penalty 1.0 [0.001, 0.01, 0.1, 1, 10, 100, 1000]
    kernel Shape of the decision boundary ‘rbf’ [‘rbf’, ‘poly’, ‘linear’]
    γ (gamma) RBF kernel width—controls influence radius of each point ‘scale’ (= 1/(n_features * X.var())) [0.001, 0.01, 0.1, 1, 10, ‘scale’, ‘auto’]

     

    Use GridSearchCV or RandomizedSearchCV with 5-fold cross-validation. The appropriate metric depends on the problem: accuracy for balanced classes, F1 for imbalanced classes, and AUC-ROC when threshold-independent evaluation is desired.

    Tuning OCSVM

    Parameter What It Controls Starting Value Search Range
    ν (nu) Upper bound on outlier fraction, lower bound on SV fraction 0.05 [0.001, 0.01, 0.03, 0.05, 0.1, 0.2]
    kernel Shape of the boundary around normal data ‘rbf’ [‘rbf’, ‘poly’]
    γ (gamma) Boundary tightness, most sensitive parameter ‘scale’ [0.001, 0.01, 0.05, 0.1, 0.3, 0.5, 1.0]

     

    Caution: Tuning OCSVM is fundamentally more difficult than tuning SVM. With SVM, cross-validation can be performed on labelled data. With OCSVM, labelled anomalies for validation are typically unavailable. Common approaches include (1) holding out a small set of known anomalies for validation only (not training); (2) using domain knowledge to set ν based on the expected contamination rate; and (3) applying stability-based heuristics, since substantial performance swings under small parameter changes indicate an unstable region.

    Grid Search and Random Search

    For SVM with three parameters (C, γ, kernel), a full grid search over the ranges above requires evaluating over 100 combinations per CV fold. Random search (Bergstra and Bengio, 2012) often finds good hyperparameters more quickly by sampling random combinations, particularly when certain parameters matter more than others. In this setting, γ almost always carries more weight than the remaining parameters.

    from sklearn.model_selection import RandomizedSearchCV
    from scipy.stats import loguniform
    
    param_dist = {
        'C': loguniform(0.01, 1000),
        'gamma': loguniform(0.001, 10),
        'kernel': ['rbf', 'poly'],
    }
    random_search = RandomizedSearchCV(
        SVC(), param_dist, n_iter=50, cv=5,
        scoring='f1', random_state=42, n_jobs=-1
    )
    random_search.fit(X_train_scaled, y_train)
    print(f"Best: {random_search.best_params_} → F1={random_search.best_score_:.3f}")

    Common Pitfalls

    The following mistakes recur frequently among practitioners using these algorithms:

    Using SVM Without Labelled Anomalies

    The mistake is straightforward in principle but common in practice. A team aims to detect anomalies, selects SVM out of familiarity, and then either fabricates anomaly labels or uses the few available anomalies as a tiny minority class. The resulting model performs poorly because SVM requires representative examples from both classes. When labelled anomalies are unavailable—and in most anomaly detection problems they are not—OCSVM should be used instead.

    Setting ν Too Low or Too High

    Setting ν = 0.001 when the training data contains 5% contamination causes the model to enclose everything, including real anomalies, within the normal boundary. Setting ν = 0.5 produces a boundary so loose that half of the normal data is flagged. The value of ν should match the best available estimate of contamination, and when uncertain, a moderately higher value (0.05 is a safe default) should be preferred.

    Failing to Scale Features

    This is the most common mistake encountered with SVM and OCSVM. Both algorithms are based on distances (through their kernels), and features of larger magnitude will dominate. Features should always be standardised (zero mean, unit variance) before training. Use StandardScaler and fit it on training data only:

    # CORRECT: fit on training data, transform both
    scaler = StandardScaler()
    X_train_s = scaler.fit_transform(X_train)
    X_test_s = scaler.transform(X_test)  # use training statistics!
    
    # WRONG: fitting scaler on test data leaks information
    # scaler.fit_transform(X_test)  # NEVER do this

    Using a Linear Kernel on Nonlinear Data

    A linear kernel produces a hyperplane decision boundary. If the classes are arranged in concentric circles, spirals, or any other nonlinear pattern, a linear kernel will fail outright. When in doubt, RBF is the preferred starting point: it can approximate linear boundaries with appropriate γ, so little is lost by defaulting to it.

    Failing to Tune γ

    The γ parameter for the RBF kernel is arguably the most important and most sensitive hyperparameter in both SVM and OCSVM. The default (‘scale’ in scikit-learn) is reasonable but rarely optimal. γ should always be included in the hyperparameter search. Small changes in γ can produce substantial changes in model behaviour; the difference between a working model and an ineffective one can amount to a factor of two in γ.

    Training OCSVM on Contaminated Data

    OCSVM assumes that its training data is “normal.” When anomalies enter the training set, which occurs frequently in practice, the model learns an overly permissive boundary that incorporates those anomalies as normal. Mitigation strategies include careful curation of training data, use of a small ν that allows some contamination, and pre-filtering of obvious outliers before training.

    Key Takeaway: The two most consequential steps for SVM/OCSVM performance are (1) scaling features and (2) tuning γ. These two actions alone typically improve results more than any algorithmic change.

    Putting It Together

    SVM and OCSVM share a name, a mathematical foundation, and a kernel-based approach to learning, but they address fundamentally different problems. SVM is a supervised classifier that requires labelled examples from both classes to draw a separating boundary between them. OCSVM is a semi-supervised anomaly detector that requires only normal data to draw a boundary around the normal class.

    The choice between them is not a matter of which is preferable in general, but of which matches the problem:

    • Labelled data from both classes is available. SVM will almost always outperform OCSVM, since it uses more information.
    • Only normal data is available, or anomalies are too rare and diverse to label. OCSVM is the appropriate tool. It builds a model of normality and detects anything unusual, including anomaly types not previously observed.
    • Scaling to millions of samples is required. Consider Isolation Forest or SGD-based variants in place of kernel SVM or OCSVM.

    Several essential practices apply throughout: scale features, tune γ and C (or ν), start with an RBF kernel unless a specific reason argues otherwise, and validate the model as rigorously as the labelled data permits. With these principles in place, the appropriate SVM variant can be selected for any classification or anomaly detection problem.

    When the distinction between SVM and OCSVM is conflated, the basis for distinguishing them—and the circumstances in which each is appropriate—should now be clear.

    References

    1. Vapnik, V. (1995). The Nature of Statistical Learning Theory. Springer-Verlag.
    2. Schölkopf, B., Platt, J., Shawe-Taylor, J., Smola, A., & Williamson, R. (2001). “Estimating the Support of a High-Dimensional Distribution.” Neural Computation, 13(7), 1443-1471.
    3. Tax, D. M. J., & Duin, R. P. W. (2004). “Support Vector Data Description.” Machine Learning, 54(1), 45-66.
    4. Ruff, L., et al. (2018). “Deep One-Class Classification.” Proceedings of the 35th International Conference on Machine Learning (ICML).
    5. Bergstra, J., & Bengio, Y. (2012). “Random Search for Hyper-Parameter Optimization.” Journal of Machine Learning Research, 13, 281-305.
    6. Pedregosa, F., et al. (2011). “Scikit-learn: Machine Learning in Python.” Journal of Machine Learning Research, 12, 2825-2830.
    7. Liu, F. T., Ting, K. M., & Zhou, Z.-H. (2008). “Isolation Forest.” Proceedings of the 8th IEEE International Conference on Data Mining.
    8. Breunig, M. M., Kriegel, H.-P., Ng, R. T., & Sander, J. (2000). “LOF: Identifying Density-Based Local Outliers.” Proceedings of the 2000 ACM SIGMOD.
    9. Han, S., Hu, X., Huang, H., Jiang, M., & Zhao, Y. (2022). “ADBench: Anomaly Detection Benchmark.” Advances in Neural Information Processing Systems (NeurIPS), Datasets and Benchmarks Track. github.com/Minqi824/ADBench.
    10. scikit-learn documentation: Support Vector Machines.
    11. scikit-learn documentation: Novelty and Outlier Detection.
  • Managing Metadata and Time-Series Data Together: A Practical Guide for Facility and Sensor Signal Systems

    Summary

    What this post covers: A complete reference for designing systems that store facility metadata and high-frequency sensor time-series together, with SQL schemas, ingestion pipelines, Python code, and a manufacturing case study.

    Key insights:

    • Metadata and time-series have fundamentally incompatible workloads — relational/hierarchical/slow-changing versus append-only/time-partitioned/high-volume — so forcing both into one storage engine produces queries that take minutes instead of milliseconds.
    • The correct architecture pairs PostgreSQL for metadata (facilities, equipment, sensors, maintenance logs) with TimescaleDB hypertables for measurements, bridged only by a sensor_id foreign key — not by embedding metadata into every reading.
    • Cross-domain queries like “show vibration anomalies on Building A’s CNC machines installed after 2023” should be answered with a metadata-filter-first pattern that resolves sensor IDs in PostgreSQL, then performs a time-windowed scan in TimescaleDB.
    • Scaling beyond billions of rows requires compressing chunks after roughly seven days, materializing continuous aggregates for dashboards, and pushing tag-rich metadata into a JSONB column to avoid schema explosion.
    • The most common failure modes are duplicating metadata in every time-series row, leaving orphaned sensor IDs when assets are retired, and skipping API-level joins so callers have to manually correlate two opaque payloads.

    Main topics: Introduction, The Data Model Challenge, Architecture Patterns, Detailed Schema Design Best Practices, Data Ingestion Pipeline, Querying Across Metadata and Time-Series, API Design for Metadata + Time-Series, Handling Scale, Real-World Example: Manufacturing Plant, Common Pitfalls, Final Thoughts, References.

    Introduction

    Consider a factory floor with 500 sensors generating roughly 15 billion data points per year. Every vibration reading, every temperature spike, and every pressure anomaly is faithfully captured and stored. When an engineer asks a straightforward question—”Show me all vibration anomalies from Building A’s CNC machines installed after 2023″—the team is unable to provide an answer in under ten minutes. The data exist, scattered across three different systems, but nobody can extract them quickly.

    This scenario recurs in manufacturing plants, energy grids, building management systems, and IoT deployments worldwide. The root cause is consistently the same: the team treated metadata and time-series data as separate problems and never designed the bridge between them. The choice of storage layer is an important first step, and the comparison of databases for preprocessed time-series data covers the options in depth.

    Any industrial, manufacturing, or IoT system involves two fundamentally different types of data that must work in concert. First, there is metadata: information about facilities, equipment, sensors, locations, configurations, maintenance history, and calibration records. These data are relational, hierarchical, and change slowly. Second, there is time-series data: the actual sensor signals (temperature, vibration, pressure, torque, current, flow rate) streaming in at high frequency, sometimes thousands of readings per second. These data are append-only, voluminous, and indexed by time.

    The relationship between these two data types is what enables the system to function. A sensor reading of “47.3” means nothing without the knowledge that sensor S-0142 is a thermocouple mounted on a FANUC CNC spindle in Building A, calibrated last month, with an operating range of 15 to 85 °C. The sensor_id is the bridge: metadata indicate what, while time-series indicate when and how much.

    Most teams handle this relationship incorrectly. They embed metadata in every time-series row, creating substantial bloat; they separate the two completely without proper foreign keys, creating orphaned data; or they force everything into a single database that performs poorly on at least one workload. The outcome is consistent: queries that should take milliseconds take minutes, data that should be connected remain isolated, and engineers who should be detecting anomalies instead contend with data infrastructure.

    This guide provides a reference for designing a system that manages metadata and time-series data together correctly. It examines four architecture patterns, complete SQL schemas, Python code using SQLAlchemy and FastAPI, ingestion pipelines, query optimisation strategies, and a real-world manufacturing example. By the conclusion, the reader will have the necessary material to build a system in which the “CNC vibration anomalies in Building A” query returns results in less than a second.

    Metadata + Time-Series Architecture: PostgreSQL and TimescaleDB Metadata + Time-Series Architecture PostgreSQL (Metadata) facilities equipment sensors maintenance_logs Relational · Hierarchical · Slow-changing sensor_id Foreign Key Bridge TimescaleDB (Measurements) sensor_readings (hypertable) anomaly_events continuous aggregates compressed chunks (7d+) Append-only · Time-partitioned · High-volume

    The Data Model Challenge

    Before considering solutions, it is necessary to understand clearly why these two data types are difficult to manage together. They have fundamentally different characteristics, and a database architecture that is optimal for one is almost always suboptimal for the other.

    Metadata: Relational, Hierarchical, and Slowly Changing

    Facility and sensor metadata follow a natural hierarchy. A typical industrial deployment is structured as follows:

    Organisation → Site → Building → Production Line → Machine → Component → Sensor

    Each level in this hierarchy carries substantial attributes. A sensor record may include sensor type, unit of measurement, sampling rate in Hz, minimum and maximum operating range, calibration date, firmware version, installation date, and the equipment on which it is mounted. A machine record includes manufacturer, model, serial number, commissioning date, maintenance schedule, and operating parameters.

    These data are relational—sensors belong to equipment, equipment belongs to production lines, and production lines belong to buildings. They are hierarchical—queries such as “all sensors in Building A” require tree traversal. They are slowly changing—sensors are recalibrated, machines are moved to different production lines, and firmware is updated. They are schema-rich—each entity type has many attributes with different data types, constraints, and relationships.

    Entity Hierarchy: Facility to Measurements Entity Hierarchy Facility location, type, status Equipment manufacturer, model Sensor type, unit, Hz range Signal channel, quality_code Measurements timestamp, value (billions) facility_id equipment_id sensor_id signal_id Key Attributes at Each Level Facility name, location facility_type commissioned_date status, metadata (JSONB) Equipment manufacturer, model serial_number production_line operating_params (JSONB) Sensor sensor_type, unit sampling_rate_hz min/max range calibration_date Measurements time (TIMESTAMPTZ) sensor_id (FK) value (DOUBLE) Hypertable—billions of rows

    Time-Series: Append-Only, High Volume, and Time-Indexed

    Sensor readings are the opposite in nearly every respect. A typical reading consists of just three fields: timestamp, sensor_id, and value. A few additional channels may exist for multi-axis sensors (x, y, z for accelerometers). The schema is narrow and rarely changes.

    The volume, however, is substantial. A single vibration sensor sampling at 1 kHz generates 86.4 million readings per day. Even at a modest 1 Hz sampling rate, 500 sensors produce 43.2 million readings per day—approximately 15.8 billion per year. These data are append-only (historical readings are almost never updated), time-indexed (every query includes a time range), and write-heavy (ingestion throughput is important).

    Characteristics Comparison

    Characteristic Metadata Time-Series
    Schema Wide, complex, many tables Narrow (timestamp, id, value)
    Volume Thousands to millions of rows Billions to trillions of rows
    Write pattern Infrequent updates, inserts Continuous high-throughput appends
    Read pattern Lookups, JOINs, tree traversal Range scans by time, aggregations
    Relationships Rich foreign keys, hierarchies Single FK (sensor_id)
    Mutability Updates and deletes common Append-only, rarely modified
    Indexing B-tree, GIN, full-text Time-partitioned, BRIN
    Retention Keep forever Tiered (raw → downsampled → archived)

     

    Common Mistakes

    Teams typically fall into one of three traps:

    Mistake 1: Embedding metadata in every time-series row. Instead of storing (timestamp, sensor_id, value), the row stores (timestamp, sensor_id, value, building_name, machine_name, manufacturer, sensor_type, unit, ...). A row that should be 24 bytes becomes 500 bytes. With billions of rows, this results in terabytes of redundant data, slower queries, and serious difficulty when metadata change (does one backfill every historical row?).

    Mistake 2: Complete separation without proper linking. Metadata reside in PostgreSQL, time-series in InfluxDB, and the only link is a sensor-name string entered manually. For teams operating this kind of split architecture and considering migration of the InfluxDB side to a lakehouse, the InfluxDB-to-AWS Iceberg pipeline guide describes how to do so while preserving the sensor-id bridge. Sensor names change, new sensors are added to the time-series database without being registered in the metadata database, and suddenly 15 per cent of readings are orphaned—data exist for sensors absent from the metadata system.

    Mistake 3: Using one database for everything. Forcing all data into PostgreSQL makes time-series queries slow (no time-partitioning, no columnar compression). Forcing everything into InfluxDB makes metadata queries impossible (no JOINs, no foreign keys, no transactions). Neither database excels at the other’s workload.

    Key Takeaway: The sensor_id is the bridge between metadata and time-series. The architecture must make it straightforward to begin from either side—filtering by metadata attributes and then fetching time-series, or detecting time-series anomalies and then retrieving the metadata context.

    Architecture Patterns

    There is no single “right” architecture for combining metadata and time-series data. The most appropriate choice depends on scale, team expertise, existing infrastructure, and query patterns. Four proven patterns are described below, from the most commonly recommended to the most specialised.

    Pattern 1: PostgreSQL with TimescaleDB (Recommended)

    This is the pattern recommended for most teams and the one to which the discussion devotes the most attention. TimescaleDB is a PostgreSQL extension that adds time-series capabilities—hypertables, automatic time partitioning, continuous aggregates, and compression—while preserving full PostgreSQL functionality. Because it runs within PostgreSQL, native SQL JOINs are available between metadata tables and time-series hypertables.

    The complete schema is shown below:

    -- Enable TimescaleDB
    CREATE EXTENSION IF NOT EXISTS timescaledb;
    
    -- ============================================
    -- METADATA TABLES
    -- ============================================
    
    CREATE TABLE facilities (
        id          SERIAL PRIMARY KEY,
        name        VARCHAR(200) NOT NULL,
        location    VARCHAR(500),
        facility_type VARCHAR(50) NOT NULL,  -- 'manufacturing', 'warehouse', 'office'
        commissioned_date DATE,
        status      VARCHAR(20) DEFAULT 'active',
        metadata    JSONB DEFAULT '{}',
        created_at  TIMESTAMPTZ DEFAULT NOW(),
        updated_at  TIMESTAMPTZ DEFAULT NOW()
    );
    
    CREATE TABLE equipment (
        id              SERIAL PRIMARY KEY,
        facility_id     INTEGER NOT NULL REFERENCES facilities(id),
        name            VARCHAR(200) NOT NULL,
        equipment_type  VARCHAR(50) NOT NULL,  -- 'cnc', 'robot', 'conveyor', 'pump'
        manufacturer    VARCHAR(200),
        model           VARCHAR(200),
        serial_number   VARCHAR(100) UNIQUE,
        install_date    DATE,
        production_line VARCHAR(100),
        status          VARCHAR(20) DEFAULT 'operational',
        operating_params JSONB DEFAULT '{}',
        created_at      TIMESTAMPTZ DEFAULT NOW(),
        updated_at      TIMESTAMPTZ DEFAULT NOW()
    );
    
    CREATE INDEX idx_equipment_facility ON equipment(facility_id);
    CREATE INDEX idx_equipment_type ON equipment(equipment_type);
    CREATE INDEX idx_equipment_manufacturer ON equipment(manufacturer);
    CREATE INDEX idx_equipment_line ON equipment(production_line);
    
    CREATE TABLE sensors (
        id                SERIAL PRIMARY KEY,
        equipment_id      INTEGER NOT NULL REFERENCES equipment(id),
        name              VARCHAR(200) NOT NULL,
        sensor_type       VARCHAR(50) NOT NULL,   -- 'temperature', 'vibration', 'pressure'
        unit              VARCHAR(20) NOT NULL,    -- 'celsius', 'mm/s', 'bar', 'A'
        sampling_rate_hz  REAL DEFAULT 1.0,
        min_range         REAL,
        max_range         REAL,
        calibration_date  DATE,
        firmware_version  VARCHAR(50),
        is_active         BOOLEAN DEFAULT TRUE,
        tags              JSONB DEFAULT '{}',
        created_at        TIMESTAMPTZ DEFAULT NOW(),
        updated_at        TIMESTAMPTZ DEFAULT NOW()
    );
    
    CREATE INDEX idx_sensors_equipment ON sensors(equipment_id);
    CREATE INDEX idx_sensors_type ON sensors(sensor_type);
    CREATE INDEX idx_sensors_active ON sensors(is_active) WHERE is_active = TRUE;
    CREATE INDEX idx_sensors_tags ON sensors USING GIN(tags);
    
    CREATE TABLE maintenance_logs (
        id              SERIAL PRIMARY KEY,
        equipment_id    INTEGER NOT NULL REFERENCES equipment(id),
        maintenance_type VARCHAR(50) NOT NULL,  -- 'preventive', 'corrective', 'calibration'
        description     TEXT,
        performed_at    TIMESTAMPTZ NOT NULL,
        completed_at    TIMESTAMPTZ,
        technician      VARCHAR(200),
        parts_replaced  JSONB DEFAULT '[]',
        created_at      TIMESTAMPTZ DEFAULT NOW()
    );
    
    CREATE INDEX idx_maintenance_equipment ON maintenance_logs(equipment_id);
    CREATE INDEX idx_maintenance_time ON maintenance_logs(performed_at);
    
    -- ============================================
    -- TIME-SERIES TABLES (TimescaleDB Hypertables)
    -- ============================================
    
    CREATE TABLE sensor_readings (
        time        TIMESTAMPTZ NOT NULL,
        sensor_id   INTEGER NOT NULL REFERENCES sensors(id),
        value       DOUBLE PRECISION NOT NULL
    );
    
    SELECT create_hypertable('sensor_readings', 'time');
    
    CREATE INDEX idx_readings_sensor_time ON sensor_readings (sensor_id, time DESC);
    
    -- Enable compression (after 7 days)
    ALTER TABLE sensor_readings SET (
        timescaledb.compress,
        timescaledb.compress_segmentby = 'sensor_id',
        timescaledb.compress_orderby = 'time DESC'
    );
    
    SELECT add_compression_policy('sensor_readings', INTERVAL '7 days');
    
    -- Anomaly events table
    CREATE TABLE anomaly_events (
        id              SERIAL PRIMARY KEY,
        sensor_id       INTEGER NOT NULL REFERENCES sensors(id),
        start_time      TIMESTAMPTZ NOT NULL,
        end_time        TIMESTAMPTZ,
        anomaly_type    VARCHAR(50) NOT NULL,  -- 'threshold', 'trend', 'pattern'
        severity        VARCHAR(20) NOT NULL,  -- 'low', 'medium', 'high', 'critical'
        value_at_detection DOUBLE PRECISION,
        model_version   VARCHAR(50),
        notes           TEXT,
        acknowledged    BOOLEAN DEFAULT FALSE,
        created_at      TIMESTAMPTZ DEFAULT NOW()
    );
    
    CREATE INDEX idx_anomaly_sensor ON anomaly_events(sensor_id);
    CREATE INDEX idx_anomaly_time ON anomaly_events(start_time);

    Populating the anomaly_events table in real time is a natural fit for complex event processing with Apache Flink CEP, which can detect multi-event anomaly patterns across thousands of sensor streams with millisecond latency.

    Tip: The compress_segmentby = 'sensor_id' setting is important. It instructs TimescaleDB to group compressed data by sensor, which means queries filtered by sensor_id only decompress the relevant segments. Without this setting, every query would decompress entire chunks.

    The power of native JOINs is illustrated below. The following queries cross the metadata/time-series boundary without difficulty:

    -- Query 1: Average temperature for all sensors in Building A, last 24 hours
    SELECT
        f.name AS facility,
        e.name AS equipment,
        s.name AS sensor,
        AVG(r.value) AS avg_temp,
        MIN(r.value) AS min_temp,
        MAX(r.value) AS max_temp
    FROM sensor_readings r
    JOIN sensors s ON s.id = r.sensor_id
    JOIN equipment e ON e.id = s.equipment_id
    JOIN facilities f ON f.id = e.facility_id
    WHERE f.name = 'Building A'
      AND s.sensor_type = 'temperature'
      AND r.time > NOW() - INTERVAL '24 hours'
    GROUP BY f.name, e.name, s.name
    ORDER BY avg_temp DESC;
    
    -- Query 2: FANUC machines with vibration exceeding threshold
    SELECT
        e.name AS machine,
        e.model,
        s.name AS sensor,
        s.max_range AS threshold,
        MAX(r.value) AS peak_vibration,
        COUNT(*) AS exceedance_count
    FROM sensor_readings r
    JOIN sensors s ON s.id = r.sensor_id
    JOIN equipment e ON e.id = s.equipment_id
    WHERE e.manufacturer = 'FANUC'
      AND s.sensor_type = 'vibration'
      AND r.value > s.max_range
      AND r.time > NOW() - INTERVAL '7 days'
    GROUP BY e.name, e.model, s.name, s.max_range
    ORDER BY peak_vibration DESC;
    
    -- Query 3: Compare vibration across CNC machines on Production Line 3
    SELECT
        e.name AS machine,
        time_bucket('1 hour', r.time) AS hour,
        AVG(r.value) AS avg_vibration,
        PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY r.value) AS p95_vibration
    FROM sensor_readings r
    JOIN sensors s ON s.id = r.sensor_id
    JOIN equipment e ON e.id = s.equipment_id
    WHERE e.production_line = 'Line 3'
      AND e.equipment_type = 'cnc'
      AND s.sensor_type = 'vibration'
      AND r.time > NOW() - INTERVAL '7 days'
    GROUP BY e.name, hour
    ORDER BY e.name, hour;

    Each query seamlessly combines metadata filters (facility name, manufacturer, production line, sensor type) with time-series operations (time ranges, aggregations, percentiles). This is the principal advantage of the PostgreSQL + TimescaleDB pattern: a single SQL statement can traverse the entire data model.

    Pattern 2: PostgreSQL with InfluxDB

    When InfluxDB is already part of the stack, or when write throughput exceeds what PostgreSQL can handle (generally above 500,000 inserts per second on a single node), a split architecture is appropriate. Metadata remain in PostgreSQL, time-series move to InfluxDB, and the application performs the JOIN.

    import asyncpg
    from influxdb_client import InfluxDBClient
    from datetime import datetime, timedelta
    
    class DualDatabaseQuery:
        def __init__(self, pg_dsn: str, influx_url: str, influx_token: str, influx_org: str):
            self.pg_dsn = pg_dsn
            self.influx = InfluxDBClient(url=influx_url, token=influx_token, org=influx_org)
            self.query_api = self.influx.query_api()
    
        async def get_readings_by_facility(
            self, facility_name: str, sensor_type: str, hours: int = 24
        ):
            # Step 1: Query metadata from PostgreSQL
            conn = await asyncpg.connect(self.pg_dsn)
            sensors = await conn.fetch("""
                SELECT s.id, s.name, e.name AS equipment_name
                FROM sensors s
                JOIN equipment e ON e.id = s.equipment_id
                JOIN facilities f ON f.id = e.facility_id
                WHERE f.name = $1 AND s.sensor_type = $2 AND s.is_active = TRUE
            """, facility_name, sensor_type)
            await conn.close()
    
            if not sensors:
                return []
    
            # Step 2: Query time-series from InfluxDB, filtered by sensor IDs
            sensor_ids = [str(s['id']) for s in sensors]
            sensor_filter = ' or '.join(
                f'r["sensor_id"] == "{sid}"' for sid in sensor_ids
            )
    
            flux_query = f'''
            from(bucket: "sensor_data")
              |> range(start: -{hours}h)
              |> filter(fn: (r) => r["_measurement"] == "readings")
              |> filter(fn: (r) => {sensor_filter})
              |> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
            '''
            tables = self.query_api.query(flux_query)
    
            # Step 3: Merge metadata with time-series results
            sensor_lookup = {str(s['id']): s for s in sensors}
            results = []
            for table in tables:
                for record in table.records:
                    sid = record.values.get("sensor_id")
                    meta = sensor_lookup.get(sid, {})
                    results.append({
                        "time": record.get_time(),
                        "sensor_id": sid,
                        "sensor_name": meta.get("name"),
                        "equipment": meta.get("equipment_name"),
                        "value": record.get_value(),
                    })
            return results
    Caution: The two-step query pattern (metadata first, then time-series) places consistency responsibilities on the application. If a sensor is deleted from PostgreSQL but readings still exist in InfluxDB, orphaned data result. Sensor-id existence should always be validated before writing to InfluxDB.

    The PostgreSQL + InfluxDB pattern works but sacrifices the elegance of native JOINs. Every cross-domain query requires two round-trips, and complex queries (such as “compare vibration patterns across machines by manufacturer”) require substantial application-level logic. This pattern is appropriate when InfluxDB is already in production and migration is not feasible, or when write throughput genuinely exceeds PostgreSQL/TimescaleDB limits.

    Pattern 3: PostgreSQL with Parquet/Iceberg on S3

    For very large-scale deployments (terabytes of time-series data) or when the primary consumer is batch ML training pipelines, storing time-series data as Parquet files on S3 is cost-effective and scalable. Metadata remain in PostgreSQL, and joins are performed at query time using DuckDB, Athena, or Spark.

    import duckdb
    import asyncpg
    from pathlib import Path
    
    class ParquetTimeSeriesQuery:
        """
        Time-series stored as Parquet files on S3, partitioned by:
        s3://data-lake/sensor_readings/sensor_id={id}/date={YYYY-MM-DD}/data.parquet
        """
    
        def __init__(self, pg_dsn: str, s3_base: str):
            self.pg_dsn = pg_dsn
            self.s3_base = s3_base
            self.duck = duckdb.connect()
            self.duck.execute("INSTALL httpfs; LOAD httpfs;")
            self.duck.execute("SET s3_region='us-east-1';")
    
        async def query_with_metadata(
            self, facility_name: str, sensor_type: str, start_date: str, end_date: str
        ):
            # Step 1: Get relevant sensor IDs from PostgreSQL
            conn = await asyncpg.connect(self.pg_dsn)
            sensors = await conn.fetch("""
                SELECT s.id, s.name, s.unit, e.name AS equipment,
                       e.manufacturer, f.name AS facility
                FROM sensors s
                JOIN equipment e ON e.id = s.equipment_id
                JOIN facilities f ON f.id = e.facility_id
                WHERE f.name = $1 AND s.sensor_type = $2
            """, facility_name, sensor_type)
            await conn.close()
    
            # Step 2: Build Parquet glob paths for relevant sensors
            sensor_ids = [s['id'] for s in sensors]
            paths = [
                f"{self.s3_base}/sensor_id={sid}/date=*/data.parquet"
                for sid in sensor_ids
            ]
    
            # Step 3: Query with DuckDB
            result = self.duck.execute(f"""
                SELECT
                    sensor_id,
                    date_trunc('hour', time) AS hour,
                    AVG(value) AS avg_value,
                    MAX(value) AS max_value,
                    COUNT(*) AS reading_count
                FROM parquet_scan({paths})
                WHERE time BETWEEN '{start_date}' AND '{end_date}'
                GROUP BY sensor_id, hour
                ORDER BY sensor_id, hour
            """).fetchdf()
    
            # Step 4: Merge with metadata
            sensor_lookup = {s['id']: dict(s) for s in sensors}
            result['equipment'] = result['sensor_id'].map(
                lambda sid: sensor_lookup.get(sid, {}).get('equipment')
            )
            result['facility'] = result['sensor_id'].map(
                lambda sid: sensor_lookup.get(sid, {}).get('facility')
            )
            return result

    This pattern is best suited to data lakes and ML training pipelines requiring cost-effective processing of large volumes of historical data. Parquet’s columnar format provides excellent compression (ten to twenty times that of CSV), and partitioning by sensor_id and date ensures that queries read only the relevant files. The pattern is poorly suited, however, to real-time queries or dashboards that require sub-second response times.

    Pattern 4: TDengine Super Tables

    TDengine takes a substantially different approach. Its “super table” concept embeds metadata as tags directly alongside time-series data. Each physical sensor receives a sub-table inheriting from a super table, and tags (metadata) are stored only once per sub-table rather than repeated in every row.

    -- Create a super table with tags (metadata) and columns (time-series)
    CREATE STABLE sensor_readings (
        ts          TIMESTAMP,
        value       DOUBLE,
        quality     INT
    ) TAGS (
        facility    NCHAR(200),
        building    NCHAR(100),
        equipment   NCHAR(200),
        manufacturer NCHAR(200),
        sensor_type NCHAR(50),
        unit        NCHAR(20),
        line        NCHAR(100)
    );
    
    -- Create sub-tables for each sensor (tags are set once)
    CREATE TABLE sensor_0001 USING sensor_readings TAGS (
        'Plant Chicago', 'Building A', 'CNC-001', 'FANUC', 'vibration', 'mm/s', 'Line 3'
    );
    
    CREATE TABLE sensor_0002 USING sensor_readings TAGS (
        'Plant Chicago', 'Building A', 'CNC-001', 'FANUC', 'temperature', 'celsius', 'Line 3'
    );
    
    -- Insert data (just timestamp + values, no metadata repetition)
    INSERT INTO sensor_0001 VALUES (NOW(), 4.52, 100);
    INSERT INTO sensor_0002 VALUES (NOW(), 67.3, 100);
    
    -- Query across all sensors using metadata tags
    SELECT
        facility,
        equipment,
        AVG(value) AS avg_vibration
    FROM sensor_readings
    WHERE sensor_type = 'vibration'
      AND facility = 'Plant Chicago'
      AND ts > NOW() - 24h
    GROUP BY facility, equipment;

    TDengine’s approach is elegant for IoT: metadata reside alongside the data, tags are indexed automatically, and a separate metadata database is not required. The disadvantage is that complex metadata relationships (maintenance logs, calibration history, hierarchical queries) are difficult to model with flat tags. If the metadata are simple and relatively static, TDengine is worth considering; if rich relational metadata are required, Pattern 1 or Pattern 2 should be preferred.

    Pattern Comparison

    Criteria PG + TimescaleDB PG + InfluxDB PG + Parquet/S3 TDengine
    Complexity Low Medium Medium-High Low
    Native JOINs Yes No (app-level) No (query engine) Tags only
    Write throughput 100K-500K rows/s 1M+ rows/s Batch (unlimited) 1M+ rows/s
    Query flexibility Full SQL Flux + SQL SQL (DuckDB/Athena) SQL subset
    Metadata richness Full relational Full relational Full relational Flat tags only
    Scalability TB scale TB scale PB scale TB scale
    Best for Most teams Existing InfluxDB Data lakes, ML Simple IoT

     

    Detailed Schema Design Best Practices

    Regardless of the architecture pattern chosen, certain schema-design principles apply universally. The most important are discussed below.

    Hierarchical Facility Modelling

    Facility hierarchies are inherently tree-structured. Queries such as “all sensors in Building A” must be answered efficiently, which requires identifying every piece of equipment in every production line in that building. Two effective approaches exist in PostgreSQL.

    Approach 1: the ltree extension.

    CREATE EXTENSION IF NOT EXISTS ltree;
    
    -- Add a path column to each entity
    ALTER TABLE facilities ADD COLUMN path ltree;
    ALTER TABLE equipment ADD COLUMN path ltree;
    ALTER TABLE sensors ADD COLUMN path ltree;
    
    -- Example paths
    -- Facility: 'org.chicago'
    -- Equipment: 'org.chicago.building_a.line_3.cnc_001'
    -- Sensor: 'org.chicago.building_a.line_3.cnc_001.vibration_x'
    
    CREATE INDEX idx_facility_path ON facilities USING GIST(path);
    CREATE INDEX idx_equipment_path ON equipment USING GIST(path);
    CREATE INDEX idx_sensor_path ON sensors USING GIST(path);
    
    -- Find all sensors under Building A (any depth)
    SELECT s.* FROM sensors s
    WHERE s.path <@ 'org.chicago.building_a';
    
    -- Find all equipment exactly 2 levels below org.chicago
    SELECT e.* FROM equipment e
    WHERE e.path ~ 'org.chicago.*{2}';

    Approach 2: recursive CTEs with adjacency list.

    If extensions are to be avoided, recursive CTEs work well for moderate-sized hierarchies:

    -- Find all equipment under a specific facility, including nested structures
    WITH RECURSIVE facility_tree AS (
        -- Base case: the target facility
        SELECT id, name, facility_type, id AS root_id
        FROM facilities
        WHERE name = 'Building A'
    
        UNION ALL
    
        -- Recursive case: equipment belonging to facilities in the tree
        SELECT e.id, e.name, e.equipment_type, ft.root_id
        FROM equipment e
        JOIN facility_tree ft ON e.facility_id = ft.id
    )
    SELECT * FROM facility_tree;

    Slowly Changing Dimensions (SCD Type 2)

    Equipment moves between production lines, sensors are recalibrated, and firmware is updated. Simply overwriting the old value removes the ability to interpret historical data correctly. A vibration reading from last month should be evaluated against the calibration that was active at that time, not against today's calibration.

    SCD Type 2 addresses this requirement by maintaining a history of changes with effective date ranges:

    CREATE TABLE sensor_history (
        id              SERIAL PRIMARY KEY,
        sensor_id       INTEGER NOT NULL REFERENCES sensors(id),
        equipment_id    INTEGER NOT NULL REFERENCES equipment(id),
        calibration_date DATE,
        min_range       REAL,
        max_range       REAL,
        firmware_version VARCHAR(50),
        effective_from  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        effective_to    TIMESTAMPTZ,  -- NULL means "current"
        is_current      BOOLEAN DEFAULT TRUE
    );
    
    CREATE INDEX idx_sensor_history_current
        ON sensor_history(sensor_id) WHERE is_current = TRUE;
    
    CREATE INDEX idx_sensor_history_range
        ON sensor_history(sensor_id, effective_from, effective_to);
    
    -- When recalibrating a sensor:
    -- Step 1: Close the current record
    UPDATE sensor_history
    SET effective_to = NOW(), is_current = FALSE
    WHERE sensor_id = 42 AND is_current = TRUE;
    
    -- Step 2: Insert new record
    INSERT INTO sensor_history
        (sensor_id, equipment_id, calibration_date, min_range, max_range,
         firmware_version, effective_from, is_current)
    VALUES
        (42, 15, '2026-04-01', 0, 100, 'v3.2.1', NOW(), TRUE);
    
    -- Query: What was the calibration when this anomaly was detected?
    SELECT sh.*
    FROM sensor_history sh
    JOIN anomaly_events ae ON ae.sensor_id = sh.sensor_id
    WHERE ae.id = 789
      AND ae.start_time BETWEEN sh.effective_from
          AND COALESCE(sh.effective_to, '9999-12-31'::timestamptz);

    JSONB for Flexible Attributes

    Not every piece of equipment shares the same attributes. A CNC machine has spindle speed and tool count; a conveyor has belt speed and length; a robot has axis count and payload capacity. Rather than creating separate tables for each equipment type, JSONB columns may be used for type-specific attributes:

    -- Equipment with flexible operating parameters
    INSERT INTO equipment (facility_id, name, equipment_type, manufacturer,
                           model, operating_params)
    VALUES
    (1, 'CNC-001', 'cnc', 'FANUC', 'Robodrill a-D21MiB5', '{
        "max_spindle_rpm": 24000,
        "tool_capacity": 21,
        "axes": 5,
        "max_feed_rate_mm_min": 54000
    }'::jsonb),
    (1, 'Robot-001', 'robot', 'ABB', 'IRB 6700', '{
        "axes": 6,
        "payload_kg": 150,
        "reach_mm": 3200,
        "repeatability_mm": 0.05
    }'::jsonb);
    
    -- Query: Find all robots with payload > 100kg
    SELECT name, model, operating_params->>'payload_kg' AS payload
    FROM equipment
    WHERE equipment_type = 'robot'
      AND (operating_params->>'payload_kg')::numeric > 100;
    
    -- Index for fast JSONB queries
    CREATE INDEX idx_equipment_params ON equipment USING GIN(operating_params);

    Tagging System for Ad-Hoc Grouping

    Beyond the formal hierarchy, teams often need to group sensors by arbitrary criteria, such as "all sensors involved in the Q1 reliability study," "sensors monitored by the ML anomaly-detection model," or "critical sensors requiring 24/7 alerting." A flexible tagging system supports this requirement:

    -- Sensors table already has a JSONB 'tags' column
    -- Usage examples:
    UPDATE sensors SET tags = '{
        "monitoring_group": "critical_24x7",
        "ml_model": "vibration_anomaly_v2",
        "study": "q1_reliability",
        "zone": "high_temperature"
    }'::jsonb
    WHERE id = 42;
    
    -- Find all sensors in a monitoring group
    SELECT s.*, e.name AS equipment
    FROM sensors s
    JOIN equipment e ON e.id = s.equipment_id
    WHERE s.tags @> '{"monitoring_group": "critical_24x7"}';
    
    -- Find sensors enrolled in a specific ML model
    SELECT s.id, s.name, s.sensor_type
    FROM sensors s
    WHERE s.tags @> '{"ml_model": "vibration_anomaly_v2"}';

    Data Ingestion Pipeline

    Reliable transfer of data from sensors into the database is half the work. A production ingestion pipeline typically follows this path:

    Sensors → MQTT/Modbus → Kafka/MQTT Broker → Telegraf or Custom Consumer → Database

    Telegraf Configuration

    Telegraf is a widely used agent for collecting and forwarding sensor data. The configuration below reads from MQTT, enriches with metadata tags, and writes to TimescaleDB:

    # telegraf.conf
    [[inputs.mqtt_consumer]]
      servers = ["tcp://mqtt-broker:1883"]
      topics = ["sensors/+/readings"]
      data_format = "json"
      tag_keys = ["sensor_id"]
      json_time_key = "timestamp"
      json_time_format = "2006-01-02T15:04:05Z07:00"
    
    # Enrich with metadata from a lookup file (updated periodically)
    [[processors.enum]]
      [[processors.enum.mapping]]
        tag = "sensor_id"
        dest = "sensor_type"
        [processors.enum.mapping.value_mappings]
          "S-0001" = "vibration"
          "S-0002" = "temperature"
    
    [[outputs.postgresql]]
      connection = "postgres://user:pass@localhost/sensordb"
      table_template = """
        INSERT INTO sensor_readings (time, sensor_id, value)
        VALUES ({time}, {sensor_id}::integer, {value})
      """

    Python Ingestion Script with Validation

    For greater control, a custom Python ingestion script can validate sensor IDs against metadata, handle errors, and batch inserts:

    import asyncio
    import json
    import logging
    from datetime import datetime, timezone
    from typing import Optional
    
    import asyncpg
    import aiomqtt
    
    logger = logging.getLogger(__name__)
    
    
    class SensorDataIngester:
        """Ingests sensor readings with metadata validation."""
    
        def __init__(self, pg_dsn: str, mqtt_host: str, mqtt_port: int = 1883):
            self.pg_dsn = pg_dsn
            self.mqtt_host = mqtt_host
            self.mqtt_port = mqtt_port
            self.pool: Optional[asyncpg.Pool] = None
            self.valid_sensors: set[int] = set()
            self.batch: list[tuple] = []
            self.batch_size = 1000
            self.flush_interval = 5  # seconds
    
        async def start(self):
            """Initialize connections and start ingestion."""
            self.pool = await asyncpg.create_pool(self.pg_dsn, min_size=2, max_size=10)
            await self._load_valid_sensors()
    
            # Run batch flusher and MQTT listener concurrently
            await asyncio.gather(
                self._mqtt_listener(),
                self._periodic_flush(),
                self._periodic_sensor_refresh(),
            )
    
        async def _load_valid_sensors(self):
            """Load active sensor IDs from metadata database."""
            async with self.pool.acquire() as conn:
                rows = await conn.fetch(
                    "SELECT id FROM sensors WHERE is_active = TRUE"
                )
                self.valid_sensors = {row['id'] for row in rows}
                logger.info(f"Loaded {len(self.valid_sensors)} active sensors")
    
        async def _periodic_sensor_refresh(self):
            """Refresh valid sensor list every 5 minutes."""
            while True:
                await asyncio.sleep(300)
                await self._load_valid_sensors()
    
        async def _mqtt_listener(self):
            """Listen for sensor readings on MQTT."""
            async with aiomqtt.Client(self.mqtt_host, self.mqtt_port) as client:
                await client.subscribe("sensors/+/readings")
                async for message in client.messages:
                    try:
                        payload = json.loads(message.payload)
                        sensor_id = int(payload['sensor_id'])
    
                        # Validate against metadata
                        if sensor_id not in self.valid_sensors:
                            logger.warning(
                                f"Rejected reading from unknown sensor {sensor_id}"
                            )
                            continue
    
                        timestamp = datetime.fromisoformat(payload['timestamp'])
                        if timestamp.tzinfo is None:
                            timestamp = timestamp.replace(tzinfo=timezone.utc)
    
                        value = float(payload['value'])
    
                        self.batch.append((timestamp, sensor_id, value))
    
                        if len(self.batch) >= self.batch_size:
                            await self._flush_batch()
    
                    except (json.JSONDecodeError, KeyError, ValueError) as e:
                        logger.error(f"Invalid message: {e}")
    
        async def _periodic_flush(self):
            """Flush batch at regular intervals."""
            while True:
                await asyncio.sleep(self.flush_interval)
                if self.batch:
                    await self._flush_batch()
    
        async def _flush_batch(self):
            """Insert batch of readings into TimescaleDB."""
            if not self.batch:
                return
    
            batch_to_insert = self.batch.copy()
            self.batch.clear()
    
            try:
                async with self.pool.acquire() as conn:
                    await conn.executemany(
                        """INSERT INTO sensor_readings (time, sensor_id, value)
                           VALUES ($1, $2, $3)""",
                        batch_to_insert
                    )
                    logger.info(f"Inserted {len(batch_to_insert)} readings")
            except Exception as e:
                logger.error(f"Batch insert failed: {e}")
                # Re-add failed batch for retry
                self.batch.extend(batch_to_insert)
    
    
    # Data quality checks
    async def check_data_quality(pool: asyncpg.Pool):
        """Detect common data quality issues."""
        async with pool.acquire() as conn:
            # Orphaned readings (sensor_id not in sensors table)
            orphaned = await conn.fetchval("""
                SELECT COUNT(DISTINCT r.sensor_id)
                FROM sensor_readings r
                LEFT JOIN sensors s ON s.id = r.sensor_id
                WHERE s.id IS NULL
                  AND r.time > NOW() - INTERVAL '24 hours'
            """)
    
            # Sensors with no recent readings (possible failure)
            silent = await conn.fetch("""
                SELECT s.id, s.name, e.name AS equipment,
                       MAX(r.time) AS last_reading
                FROM sensors s
                JOIN equipment e ON e.id = s.equipment_id
                LEFT JOIN sensor_readings r ON r.sensor_id = s.id
                    AND r.time > NOW() - INTERVAL '24 hours'
                WHERE s.is_active = TRUE
                GROUP BY s.id, s.name, e.name
                HAVING MAX(r.time) IS NULL
                   OR MAX(r.time) < NOW() - INTERVAL '1 hour'
            """)
    
            # Sensors with values outside their calibrated range
            out_of_range = await conn.fetch("""
                SELECT s.id, s.name, s.min_range, s.max_range,
                       MIN(r.value) AS min_val, MAX(r.value) AS max_val,
                       COUNT(*) AS violation_count
                FROM sensor_readings r
                JOIN sensors s ON s.id = r.sensor_id
                WHERE r.time > NOW() - INTERVAL '24 hours'
                  AND (r.value < s.min_range OR r.value > s.max_range)
                GROUP BY s.id, s.name, s.min_range, s.max_range
            """)
    
            return {
                "orphaned_sensor_ids": orphaned,
                "silent_sensors": [dict(r) for r in silent],
                "out_of_range_sensors": [dict(r) for r in out_of_range],
            }
    Tip: The _load_valid_sensors() method caches active sensor IDs in memory and refreshes every five minutes. This avoids a database round-trip for every incoming message while ensuring new sensor registrations are detected within a reasonable interval.

    Handling Late-Arriving and Out-of-Order Data

    In real-world deployments, data do not always arrive in order. Network delays, edge-device buffering, and batch uploads from remote sites all produce out-of-order events. TimescaleDB handles this situation gracefully: inserts are not required to be in time order. If continuous aggregates or materialised views are used, however, a refresh policy must be configured that covers the maximum expected delay:

    -- Continuous aggregate that tolerates late data (up to 1 hour)
    CREATE MATERIALIZED VIEW hourly_averages
    WITH (timescaledb.continuous) AS
    SELECT
        time_bucket('1 hour', time) AS bucket,
        sensor_id,
        AVG(value) AS avg_value,
        MIN(value) AS min_value,
        MAX(value) AS max_value,
        COUNT(*) AS sample_count
    FROM sensor_readings
    GROUP BY bucket, sensor_id
    WITH NO DATA;
    
    -- Refresh policy: refresh the last 2 hours every 30 minutes
    SELECT add_continuous_aggregate_policy('hourly_averages',
        start_offset => INTERVAL '2 hours',
        end_offset => INTERVAL '30 minutes',
        schedule_interval => INTERVAL '30 minutes'
    );

    Querying Across Metadata and Time-Series

    The genuine value of a well-designed schema emerges when queries cross the metadata/time-series boundary. Five common query patterns are presented below, each with complete SQL and Python implementations.

    Query Flow: From User Query to Aggregated Result Query Execution Flow User Query "Vibration anomalies in Building A, CNC" Join Metadata PostgreSQL: resolve facility → sensor IDs Filter Time-Series TimescaleDB: scan hypertable by time range Aggregated Result AVG / MAX / P99 enriched with metadata What Happens at Each Step Step 1—User Query Client sends structured request with filters: location, sensor_type, time window Step 2,Metadata JOIN JOIN facilities → equipment → sensors to collect matching sensor_id set. Uses B-tree indexes. Step 3—Time-Series Scan Hypertable chunk pruning by time range. Decompress only matching sensor_id segments. Step 4—Result time_bucket aggregations returned with equipment name, facility, sensor context attached.

    All Readings by Location and Sensor Type

    -- All vibration readings from sensors in Building A, last 7 days
    -- Using TimescaleDB time_bucket for efficient aggregation
    SELECT
        time_bucket('15 minutes', r.time) AS period,
        e.name AS equipment,
        s.name AS sensor,
        AVG(r.value) AS avg_vibration,
        MAX(r.value) AS peak_vibration,
        PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY r.value) AS p99_vibration
    FROM sensor_readings r
    JOIN sensors s ON s.id = r.sensor_id
    JOIN equipment e ON e.id = s.equipment_id
    JOIN facilities f ON f.id = e.facility_id
    WHERE f.name = 'Building A'
      AND s.sensor_type = 'vibration'
      AND r.time > NOW() - INTERVAL '7 days'
    GROUP BY period, e.name, s.name
    ORDER BY period DESC, peak_vibration DESC;

    Average Daily Values Grouped by Manufacturer

    -- Average daily temperature per facility, grouped by equipment manufacturer
    SELECT
        f.name AS facility,
        e.manufacturer,
        time_bucket('1 day', r.time) AS day,
        AVG(r.value) AS avg_temperature,
        COUNT(DISTINCT s.id) AS sensor_count
    FROM sensor_readings r
    JOIN sensors s ON s.id = r.sensor_id
    JOIN equipment e ON e.id = s.equipment_id
    JOIN facilities f ON f.id = e.facility_id
    WHERE s.sensor_type = 'temperature'
      AND r.time > NOW() - INTERVAL '30 days'
    GROUP BY f.name, e.manufacturer, day
    ORDER BY f.name, e.manufacturer, day;

    Equipment with Sensors Exceeding Their Range

    -- Find equipment where any sensor exceeded its max_range in the past month
    SELECT
        f.name AS facility,
        e.name AS equipment,
        e.manufacturer,
        s.name AS sensor,
        s.sensor_type,
        s.max_range AS threshold,
        MAX(r.value) AS peak_value,
        COUNT(*) FILTER (WHERE r.value > s.max_range) AS exceedance_count,
        MIN(r.time) FILTER (WHERE r.value > s.max_range) AS first_exceedance,
        MAX(r.time) FILTER (WHERE r.value > s.max_range) AS last_exceedance
    FROM sensor_readings r
    JOIN sensors s ON s.id = r.sensor_id
    JOIN equipment e ON e.id = s.equipment_id
    JOIN facilities f ON f.id = e.facility_id
    WHERE r.time > NOW() - INTERVAL '30 days'
      AND s.max_range IS NOT NULL
    GROUP BY f.name, e.name, e.manufacturer, s.name, s.sensor_type, s.max_range
    HAVING COUNT(*) FILTER (WHERE r.value > s.max_range) > 0
    ORDER BY exceedance_count DESC;

    Readings Before and After Maintenance

    -- Compare sensor readings 24 hours before and after a maintenance event
    WITH maintenance AS (
        SELECT id, equipment_id, performed_at, maintenance_type
        FROM maintenance_logs
        WHERE id = 456  -- specific maintenance event
    ),
    before_maintenance AS (
        SELECT
            s.name AS sensor,
            s.sensor_type,
            AVG(r.value) AS avg_value,
            STDDEV(r.value) AS stddev_value,
            'before' AS period
        FROM sensor_readings r
        JOIN sensors s ON s.id = r.sensor_id
        JOIN maintenance m ON s.equipment_id = m.equipment_id
        WHERE r.time BETWEEN m.performed_at - INTERVAL '24 hours' AND m.performed_at
        GROUP BY s.name, s.sensor_type
    ),
    after_maintenance AS (
        SELECT
            s.name AS sensor,
            s.sensor_type,
            AVG(r.value) AS avg_value,
            STDDEV(r.value) AS stddev_value,
            'after' AS period
        FROM sensor_readings r
        JOIN sensors s ON s.id = r.sensor_id
        JOIN maintenance m ON s.equipment_id = m.equipment_id
        WHERE r.time BETWEEN m.performed_at AND m.performed_at + INTERVAL '24 hours'
        GROUP BY s.name, s.sensor_type
    )
    SELECT
        b.sensor,
        b.sensor_type,
        b.avg_value AS avg_before,
        a.avg_value AS avg_after,
        ROUND(((a.avg_value - b.avg_value) / NULLIF(b.avg_value, 0) * 100)::numeric, 2)
            AS pct_change,
        b.stddev_value AS stddev_before,
        a.stddev_value AS stddev_after
    FROM before_maintenance b
    JOIN after_maintenance a ON a.sensor = b.sensor
    ORDER BY ABS((a.avg_value - b.avg_value) / NULLIF(b.avg_value, 0)) DESC;

    Anomaly Events with Full Context

    -- Anomaly events for FANUC robots installed in 2024, with full context
    SELECT
        ae.id AS anomaly_id,
        ae.anomaly_type,
        ae.severity,
        ae.start_time,
        ae.end_time,
        ae.value_at_detection,
        s.name AS sensor,
        s.sensor_type,
        s.max_range,
        e.name AS equipment,
        e.manufacturer,
        e.model,
        e.install_date,
        f.name AS facility
    FROM anomaly_events ae
    JOIN sensors s ON s.id = ae.sensor_id
    JOIN equipment e ON e.id = s.equipment_id
    JOIN facilities f ON f.id = e.facility_id
    WHERE e.manufacturer = 'FANUC'
      AND e.equipment_type = 'robot'
      AND e.install_date >= '2024-01-01'
      AND ae.start_time > NOW() - INTERVAL '90 days'
    ORDER BY ae.severity DESC, ae.start_time DESC;

    Python Query Service

    Wrapping these queries in a service class provides a clean interface for application code:

    from dataclasses import dataclass
    from datetime import datetime, timedelta
    from typing import Optional
    
    import asyncpg
    
    
    @dataclass
    class SensorReading:
        time: datetime
        sensor_id: int
        sensor_name: str
        equipment_name: str
        facility_name: str
        sensor_type: str
        value: float
        unit: str
    
    
    class QueryService:
        """Combines metadata filtering with time-series queries."""
    
        def __init__(self, pool: asyncpg.Pool):
            self.pool = pool
    
        async def get_readings(
            self,
            facility: Optional[str] = None,
            equipment_type: Optional[str] = None,
            manufacturer: Optional[str] = None,
            sensor_type: Optional[str] = None,
            production_line: Optional[str] = None,
            tags: Optional[dict] = None,
            start: Optional[datetime] = None,
            end: Optional[datetime] = None,
            bucket_interval: str = '1 hour',
        ) -> list[dict]:
            """
            Flexible query combining metadata filters with time-series aggregation.
            """
            if start is None:
                start = datetime.utcnow() - timedelta(hours=24)
            if end is None:
                end = datetime.utcnow()
    
            conditions = ["r.time >= $1", "r.time <= $2"]
            params: list = [start, end]
            param_idx = 3
    
            if facility:
                conditions.append(f"f.name = ${param_idx}")
                params.append(facility)
                param_idx += 1
    
            if equipment_type:
                conditions.append(f"e.equipment_type = ${param_idx}")
                params.append(equipment_type)
                param_idx += 1
    
            if manufacturer:
                conditions.append(f"e.manufacturer = ${param_idx}")
                params.append(manufacturer)
                param_idx += 1
    
            if sensor_type:
                conditions.append(f"s.sensor_type = ${param_idx}")
                params.append(sensor_type)
                param_idx += 1
    
            if production_line:
                conditions.append(f"e.production_line = ${param_idx}")
                params.append(production_line)
                param_idx += 1
    
            if tags:
                conditions.append(f"s.tags @> ${param_idx}::jsonb")
                params.append(json.dumps(tags))
                param_idx += 1
    
            where_clause = " AND ".join(conditions)
    
            query = f"""
                SELECT
                    time_bucket('{bucket_interval}', r.time) AS bucket,
                    s.id AS sensor_id,
                    s.name AS sensor_name,
                    s.sensor_type,
                    s.unit,
                    e.name AS equipment_name,
                    e.manufacturer,
                    f.name AS facility_name,
                    AVG(r.value) AS avg_value,
                    MIN(r.value) AS min_value,
                    MAX(r.value) AS max_value,
                    COUNT(*) AS sample_count
                FROM sensor_readings r
                JOIN sensors s ON s.id = r.sensor_id
                JOIN equipment e ON e.id = s.equipment_id
                JOIN facilities f ON f.id = e.facility_id
                WHERE {where_clause}
                GROUP BY bucket, s.id, s.name, s.sensor_type, s.unit,
                         e.name, e.manufacturer, f.name
                ORDER BY bucket DESC, sensor_name
            """
    
            async with self.pool.acquire() as conn:
                rows = await conn.fetch(query, *params)
                return [dict(r) for r in rows]
    
        async def get_equipment_health(self, equipment_id: int) -> dict:
            """Get comprehensive health status for a piece of equipment."""
            async with self.pool.acquire() as conn:
                # Equipment metadata
                equipment = await conn.fetchrow("""
                    SELECT e.*, f.name AS facility_name
                    FROM equipment e
                    JOIN facilities f ON f.id = e.facility_id
                    WHERE e.id = $1
                """, equipment_id)
    
                # Latest readings from all sensors
                latest_readings = await conn.fetch("""
                    SELECT DISTINCT ON (s.id)
                        s.id AS sensor_id, s.name, s.sensor_type, s.unit,
                        s.min_range, s.max_range,
                        r.time AS last_reading_time,
                        r.value AS last_value,
                        CASE
                            WHEN r.value > s.max_range THEN 'exceeded'
                            WHEN r.value < s.min_range THEN 'below_range'
                            ELSE 'normal'
                        END AS range_status
                    FROM sensors s
                    LEFT JOIN sensor_readings r ON r.sensor_id = s.id
                        AND r.time > NOW() - INTERVAL '1 hour'
                    WHERE s.equipment_id = $1 AND s.is_active = TRUE
                    ORDER BY s.id, r.time DESC
                """, equipment_id)
    
                # Recent anomalies
                anomalies = await conn.fetch("""
                    SELECT ae.*, s.name AS sensor_name, s.sensor_type
                    FROM anomaly_events ae
                    JOIN sensors s ON s.id = ae.sensor_id
                    WHERE s.equipment_id = $1
                      AND ae.start_time > NOW() - INTERVAL '7 days'
                    ORDER BY ae.start_time DESC
                    LIMIT 20
                """, equipment_id)
    
                # Last maintenance
                last_maintenance = await conn.fetchrow("""
                    SELECT * FROM maintenance_logs
                    WHERE equipment_id = $1
                    ORDER BY performed_at DESC LIMIT 1
                """, equipment_id)
    
                return {
                    "equipment": dict(equipment) if equipment else None,
                    "sensors": [dict(r) for r in latest_readings],
                    "recent_anomalies": [dict(a) for a in anomalies],
                    "last_maintenance": dict(last_maintenance) if last_maintenance else None,
                    "overall_status": self._calculate_status(latest_readings, anomalies),
                }
    
        @staticmethod
        def _calculate_status(readings, anomalies) -> str:
            critical_anomalies = [a for a in anomalies if a['severity'] == 'critical']
            exceeded_sensors = [r for r in readings if r['range_status'] == 'exceeded']
    
            if critical_anomalies or len(exceeded_sensors) > 2:
                return "critical"
            elif exceeded_sensors or any(a['severity'] == 'high' for a in anomalies):
                return "warning"
            return "healthy"

    API Design for Metadata and Time-Series

    A well-designed API layer makes the combined metadata/time-series system accessible to dashboards, mobile applications, and other services. A FastAPI implementation that exposes the key endpoints is shown below:

    from datetime import datetime, timedelta
    from typing import Optional
    
    import asyncpg
    from fastapi import FastAPI, HTTPException, Query
    from pydantic import BaseModel
    
    app = FastAPI(title="Sensor Data API")
    pool: asyncpg.Pool = None
    
    
    @app.on_event("startup")
    async def startup():
        global pool
        pool = await asyncpg.create_pool(
            "postgresql://user:pass@localhost/sensordb",
            min_size=5, max_size=20
        )
    
    
    @app.on_event("shutdown")
    async def shutdown():
        await pool.close()
    
    
    # ---- Pydantic Models ----
    
    class FacilityResponse(BaseModel):
        id: int
        name: str
        location: Optional[str]
        facility_type: str
        status: str
        equipment_count: int
    
    
    class EquipmentResponse(BaseModel):
        id: int
        name: str
        equipment_type: str
        manufacturer: Optional[str]
        model: Optional[str]
        status: str
        sensor_count: int
        production_line: Optional[str]
    
    
    class SensorReadingResponse(BaseModel):
        time: datetime
        value: float
        sensor_name: str
        sensor_type: str
        unit: str
    
    
    class EquipmentHealthResponse(BaseModel):
        equipment_id: int
        equipment_name: str
        facility: str
        status: str
        sensors: list[dict]
        recent_anomalies: list[dict]
        last_maintenance: Optional[dict]
    
    
    # ---- Endpoints ----
    
    @app.get("/facilities/{facility_id}/equipment",
             response_model=list[EquipmentResponse])
    async def list_equipment(facility_id: int):
        """List all equipment in a facility with metadata."""
        async with pool.acquire() as conn:
            rows = await conn.fetch("""
                SELECT e.id, e.name, e.equipment_type, e.manufacturer,
                       e.model, e.status, e.production_line,
                       COUNT(s.id) AS sensor_count
                FROM equipment e
                LEFT JOIN sensors s ON s.equipment_id = e.id AND s.is_active = TRUE
                WHERE e.facility_id = $1
                GROUP BY e.id
                ORDER BY e.production_line, e.name
            """, facility_id)
    
            if not rows:
                raise HTTPException(404, "Facility not found or has no equipment")
            return [dict(r) for r in rows]
    
    
    @app.get("/sensors/{sensor_id}/readings",
             response_model=list[SensorReadingResponse])
    async def get_sensor_readings(
        sensor_id: int,
        start: datetime = Query(default_factory=lambda: datetime.utcnow() - timedelta(hours=24)),
        end: datetime = Query(default_factory=datetime.utcnow),
        bucket: str = Query(default="15 minutes",
                            description="Aggregation interval, e.g. '5 minutes', '1 hour'"),
    ):
        """Get time-series readings for a sensor with metadata context."""
        async with pool.acquire() as conn:
            # Verify sensor exists and get metadata
            sensor = await conn.fetchrow("""
                SELECT s.name, s.sensor_type, s.unit
                FROM sensors s WHERE s.id = $1
            """, sensor_id)
    
            if not sensor:
                raise HTTPException(404, "Sensor not found")
    
            readings = await conn.fetch(f"""
                SELECT
                    time_bucket('{bucket}', r.time) AS time,
                    AVG(r.value) AS value
                FROM sensor_readings r
                WHERE r.sensor_id = $1
                  AND r.time BETWEEN $2 AND $3
                GROUP BY time_bucket('{bucket}', r.time)
                ORDER BY time DESC
            """, sensor_id, start, end)
    
            return [
                {
                    "time": r["time"],
                    "value": round(r["value"], 4),
                    "sensor_name": sensor["name"],
                    "sensor_type": sensor["sensor_type"],
                    "unit": sensor["unit"],
                }
                for r in readings
            ]
    
    
    @app.get("/equipment/{equipment_id}/health",
             response_model=EquipmentHealthResponse)
    async def get_equipment_health(equipment_id: int):
        """
        Combined health view: latest sensor readings + metadata + anomalies.
        Single endpoint that crosses metadata and time-series boundaries.
        """
        query_service = QueryService(pool)
        health = await query_service.get_equipment_health(equipment_id)
    
        if not health["equipment"]:
            raise HTTPException(404, "Equipment not found")
    
        return {
            "equipment_id": equipment_id,
            "equipment_name": health["equipment"]["name"],
            "facility": health["equipment"]["facility_name"],
            "status": health["overall_status"],
            "sensors": health["sensors"],
            "recent_anomalies": health["recent_anomalies"],
            "last_maintenance": health["last_maintenance"],
        }
    
    
    @app.get("/facilities/{facility_id}/sensors/readings")
    async def get_facility_readings(
        facility_id: int,
        sensor_type: Optional[str] = None,
        manufacturer: Optional[str] = None,
        production_line: Optional[str] = None,
        start: datetime = Query(
            default_factory=lambda: datetime.utcnow() - timedelta(hours=24)
        ),
        end: datetime = Query(default_factory=datetime.utcnow),
        bucket: str = "1 hour",
    ):
        """
        Get aggregated readings for all sensors in a facility,
        with optional metadata filters.
        """
        conditions = ["f.id = $1", "r.time >= $2", "r.time <= $3"]
        params = [facility_id, start, end]
        idx = 4
    
        if sensor_type:
            conditions.append(f"s.sensor_type = ${idx}")
            params.append(sensor_type)
            idx += 1
    
        if manufacturer:
            conditions.append(f"e.manufacturer = ${idx}")
            params.append(manufacturer)
            idx += 1
    
        if production_line:
            conditions.append(f"e.production_line = ${idx}")
            params.append(production_line)
            idx += 1
    
        where = " AND ".join(conditions)
    
        async with pool.acquire() as conn:
            rows = await conn.fetch(f"""
                SELECT
                    time_bucket('{bucket}', r.time) AS time,
                    e.name AS equipment,
                    e.manufacturer,
                    s.name AS sensor,
                    s.sensor_type,
                    s.unit,
                    AVG(r.value) AS avg_value,
                    MAX(r.value) AS max_value,
                    MIN(r.value) AS min_value
                FROM sensor_readings r
                JOIN sensors s ON s.id = r.sensor_id
                JOIN equipment e ON e.id = s.equipment_id
                JOIN facilities f ON f.id = e.facility_id
                WHERE {where}
                GROUP BY time_bucket('{bucket}', r.time),
                         e.name, e.manufacturer, s.name, s.sensor_type, s.unit
                ORDER BY time DESC
            """, *params)
    
            return [dict(r) for r in rows]
    Key Takeaway: The /equipment/{id}/health endpoint illustrates the value of combining metadata and time-series in a single API response. A dashboard can render equipment details, live sensor values, anomaly alerts, and maintenance history from a single API call.

    Handling Scale

    A system with 500 sensors at 1 Hz generates approximately 43 million readings per day. At 10 Hz, the figure rises to 432 million. Over the course of a year, this represents 15 to 150 billion rows. Without a data-lifecycle strategy, storage costs will grow linearly without limit.

    Data Retention Policies

    Data Tier Resolution Retention Storage Use Case
    Raw Full resolution (1-1000 Hz) 30 days TimescaleDB (compressed) Real-time dashboards, debugging
    Downsampled 1-minute or 5-minute averages 1 year TimescaleDB continuous aggregate Trend analysis, weekly reports
    Aggregated Hourly or daily summaries Forever PostgreSQL regular table Historical comparisons, audits
    Archived Full resolution 7 years Parquet on S3/Glacier Compliance, ML retraining

     

    Implementing this with TimescaleDB:

    -- Continuous aggregate: 5-minute downsampling (auto-maintained)
    CREATE MATERIALIZED VIEW readings_5min
    WITH (timescaledb.continuous) AS
    SELECT
        time_bucket('5 minutes', time) AS bucket,
        sensor_id,
        AVG(value) AS avg_value,
        MIN(value) AS min_value,
        MAX(value) AS max_value,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value) AS median_value,
        COUNT(*) AS sample_count
    FROM sensor_readings
    GROUP BY bucket, sensor_id
    WITH NO DATA;
    
    SELECT add_continuous_aggregate_policy('readings_5min',
        start_offset => INTERVAL '2 hours',
        end_offset => INTERVAL '30 minutes',
        schedule_interval => INTERVAL '30 minutes'
    );
    
    -- Continuous aggregate: hourly (built on top of 5-min aggregate)
    CREATE MATERIALIZED VIEW readings_hourly
    WITH (timescaledb.continuous) AS
    SELECT
        time_bucket('1 hour', bucket) AS bucket,
        sensor_id,
        AVG(avg_value) AS avg_value,
        MIN(min_value) AS min_value,
        MAX(max_value) AS max_value,
        SUM(sample_count) AS sample_count
    FROM readings_5min
    GROUP BY time_bucket('1 hour', bucket), sensor_id
    WITH NO DATA;
    
    SELECT add_continuous_aggregate_policy('readings_hourly',
        start_offset => INTERVAL '4 hours',
        end_offset => INTERVAL '1 hour',
        schedule_interval => INTERVAL '1 hour'
    );
    
    -- Drop raw data after 30 days
    SELECT add_retention_policy('sensor_readings', INTERVAL '30 days');
    
    -- Keep 5-minute aggregates for 1 year
    SELECT add_retention_policy('readings_5min', INTERVAL '1 year');
    Caution: Before enabling retention policies, the archival pipeline must be confirmed to be operational. Once add_retention_policy drops a chunk, the raw data are gone. Export to Parquet on S3 should precede retention if long-term raw data access is required for compliance or ML training.

    Real-World Example: Manufacturing Plant

    A complete real-world scenario ties the preceding elements together. Consider a manufacturing plant with the following configuration:

    • 3 buildings (A, B, C) on a single campus
    • 50 machines: 20 CNC machines (FANUC, DMG Mori), 15 robots (ABB, KUKA), 10 conveyors, 5 pumps
    • 500 sensors: vibration, temperature, pressure, current, torque, flow rate
    • Average sampling rate: 10 Hz (some vibration sensors at 1 kHz for spectral analysis)

    The Schema

    -- Seed the metadata
    INSERT INTO facilities (name, location, facility_type, commissioned_date, status) VALUES
    ('Building A', 'North Campus, Chicago IL', 'manufacturing', '2019-03-15', 'active'),
    ('Building B', 'North Campus, Chicago IL', 'manufacturing', '2021-07-01', 'active'),
    ('Building C', 'North Campus, Chicago IL', 'warehouse', '2022-01-10', 'active');
    
    -- Sample equipment (showing pattern, not all 50)
    INSERT INTO equipment (facility_id, name, equipment_type, manufacturer, model,
                           serial_number, install_date, production_line, status,
                           operating_params) VALUES
    (1, 'CNC-A01', 'cnc', 'FANUC', 'Robodrill a-D21MiB5', 'FN-2024-0891',
     '2024-03-15', 'Line 1', 'operational',
     '{"max_spindle_rpm": 24000, "tool_capacity": 21, "axes": 5}'),
    (1, 'CNC-A02', 'cnc', 'DMG Mori', 'DMU 50', 'DM-2023-4521',
     '2023-09-01', 'Line 1', 'operational',
     '{"max_spindle_rpm": 20000, "tool_capacity": 30, "axes": 5}'),
    (1, 'Robot-A01', 'robot', 'ABB', 'IRB 6700', 'ABB-2024-1122',
     '2024-06-10', 'Line 2', 'operational',
     '{"axes": 6, "payload_kg": 150, "reach_mm": 3200}'),
    (2, 'CNC-B01', 'cnc', 'FANUC', 'Robodrill a-D21LiB5ADV', 'FN-2024-1205',
     '2024-11-20', 'Line 3', 'operational',
     '{"max_spindle_rpm": 24000, "tool_capacity": 21, "axes": 5}');
    
    -- Sensors for CNC-A01 (typical: vibration, temperature, spindle current)
    INSERT INTO sensors (equipment_id, name, sensor_type, unit, sampling_rate_hz,
                         min_range, max_range, calibration_date, is_active, tags) VALUES
    (1, 'CNC-A01-VIB-X', 'vibration', 'mm/s', 1000, 0, 50,
     '2026-01-15', TRUE, '{"axis": "x", "monitoring_group": "critical_24x7"}'),
    (1, 'CNC-A01-VIB-Y', 'vibration', 'mm/s', 1000, 0, 50,
     '2026-01-15', TRUE, '{"axis": "y", "monitoring_group": "critical_24x7"}'),
    (1, 'CNC-A01-TEMP-SPINDLE', 'temperature', 'celsius', 1, 10, 85,
     '2026-02-01', TRUE, '{"location": "spindle_bearing"}'),
    (1, 'CNC-A01-CURRENT', 'current', 'ampere', 10, 0, 30,
     '2026-02-01', TRUE, '{"phase": "main_spindle"}');

    Data Flow

    In this plant the data flow proceeds as follows:

    1. Sensors output analogue/digital signals to edge PLCs (programmable logic controllers).
    2. Edge PLCs digitise and publish to an MQTT broker via the Sparkplug B protocol.
    3. Telegraf agents (one per building) subscribe to MQTT, buffer locally, and forward to the central database.
    4. TimescaleDB receives inserts via the Telegraf PostgreSQL output plugin.
    5. The ingestion validator (the Python script described earlier) runs as a sidecar, monitoring for unknown sensor IDs.

    With 500 sensors averaging 10 Hz, the system handles approximately 5,000 inserts per second during normal operation, with bursts of up to 50,000 per second when high-frequency vibration captures are triggered. TimescaleDB on a single node (16 vCPU, 64 GB RAM, NVMe SSD) handles this load comfortably with batch inserts.

    Dashboard Queries

    The operations team uses a Grafana dashboard backed by the following queries:

    -- Dashboard Panel 1: Plant Overview — current status of all equipment
    SELECT
        f.name AS building,
        e.name AS machine,
        e.equipment_type,
        e.status AS equipment_status,
        COUNT(s.id) FILTER (WHERE s.is_active) AS active_sensors,
        COUNT(ae.id) FILTER (WHERE ae.severity IN ('high', 'critical')
            AND ae.start_time > NOW() - INTERVAL '24 hours') AS critical_anomalies_24h,
        MAX(ml.performed_at) AS last_maintenance
    FROM equipment e
    JOIN facilities f ON f.id = e.facility_id
    LEFT JOIN sensors s ON s.equipment_id = e.id
    LEFT JOIN anomaly_events ae ON ae.sensor_id = s.id
    LEFT JOIN maintenance_logs ml ON ml.equipment_id = e.id
    GROUP BY f.name, e.name, e.equipment_type, e.status
    ORDER BY critical_anomalies_24h DESC, f.name, e.name;
    
    -- Dashboard Panel 2: Vibration trends for Line 3 CNC machines (last 24h)
    SELECT
        time_bucket('15 minutes', r.time) AS period,
        e.name AS machine,
        AVG(r.value) AS avg_vibration,
        MAX(r.value) AS peak_vibration
    FROM sensor_readings r
    JOIN sensors s ON s.id = r.sensor_id
    JOIN equipment e ON e.id = s.equipment_id
    WHERE e.production_line = 'Line 3'
      AND e.equipment_type = 'cnc'
      AND s.sensor_type = 'vibration'
      AND r.time > NOW() - INTERVAL '24 hours'
    GROUP BY period, e.name
    ORDER BY period, e.name;
    
    -- Dashboard Panel 3: Equipment needing attention
    -- (sensors exceeding 80% of their max range)
    SELECT
        e.name AS machine,
        s.name AS sensor,
        s.sensor_type,
        s.max_range,
        latest.last_value,
        ROUND((latest.last_value / s.max_range * 100)::numeric, 1) AS pct_of_max
    FROM sensors s
    JOIN equipment e ON e.id = s.equipment_id
    CROSS JOIN LATERAL (
        SELECT value AS last_value
        FROM sensor_readings
        WHERE sensor_id = s.id
        ORDER BY time DESC
        LIMIT 1
    ) latest
    WHERE s.is_active = TRUE
      AND s.max_range IS NOT NULL
      AND latest.last_value > s.max_range * 0.8
    ORDER BY pct_of_max DESC;

    Anomaly Detection Integration

    When an ML anomaly-detection model flags unusual behaviour, it writes to the anomaly_events table with full metadata context. A representative Python worker is shown below:

    async def record_anomaly(
        pool: asyncpg.Pool,
        sensor_id: int,
        anomaly_type: str,
        severity: str,
        value_at_detection: float,
        model_version: str,
    ):
        """Record an anomaly event with metadata validation."""
        async with pool.acquire() as conn:
            # Validate sensor exists and get context for logging
            sensor = await conn.fetchrow("""
                SELECT s.name, s.sensor_type, s.max_range,
                       e.name AS equipment, f.name AS facility
                FROM sensors s
                JOIN equipment e ON e.id = s.equipment_id
                JOIN facilities f ON f.id = e.facility_id
                WHERE s.id = $1
            """, sensor_id)
    
            if not sensor:
                raise ValueError(f"Sensor {sensor_id} not found in metadata")
    
            anomaly_id = await conn.fetchval("""
                INSERT INTO anomaly_events
                    (sensor_id, start_time, anomaly_type, severity,
                     value_at_detection, model_version)
                VALUES ($1, NOW(), $2, $3, $4, $5)
                RETURNING id
            """, sensor_id, anomaly_type, severity, value_at_detection, model_version)
    
            logger.warning(
                f"Anomaly #{anomaly_id}: {severity} {anomaly_type} on "
                f"{sensor['equipment']}/{sensor['name']} ({sensor['facility']}) "
                f"value={value_at_detection} (max={sensor['max_range']})"
            )
    
            return anomaly_id

    Common Pitfalls

    The following errors recur most frequently across the sensor-data architectures the author has reviewed:

    Pitfall Impact Solution
    Denormalizing metadata into every time-series row 10-20x storage bloat, metadata updates require backfilling billions of rows Store only sensor_id in time-series, JOIN at query time
    No foreign key validation Orphaned readings accumulate, 10-20% of data becomes unlinkable Validate sensor_id at ingestion, run periodic quality checks
    Single database for everything Either metadata or time-series queries suffer poor performance Use TimescaleDB (best of both) or a split architecture
    Not planning for sensor changes Historical data misinterpreted after recalibration or replacement Implement SCD Type 2 for sensor history
    Ignoring time zones Time shifts corrupt analysis, especially across multi-site deployments Always use TIMESTAMPTZ, store in UTC, convert at display time
    Missing indexes on JOIN columns Cross-domain queries take minutes instead of milliseconds Index (sensor_id, time DESC) on time-series, all FKs on metadata
    No retention policy Storage costs grow linearly forever, query performance degrades Tiered retention: raw (30d) → downsampled (1y) → archived (S3)
    String-based sensor identification Name changes break links, inconsistent naming across teams Use integer IDs as primary key, names as human-readable labels

     

    Tip: The data-quality checks from the ingestion script should be run on a daily schedule. Alerts should be configured for orphaned sensor IDs (readings from sensors not in the metadata registry) and silent sensors (registered sensors with no recent readings). These are early indicators of infrastructure problems.

    Final Thoughts

    Managing metadata and time-series data together is not a luxury; it is a fundamental requirement for any system seeking to derive actionable insights from sensor data. The sensor_id is the bridge between what the sensors are (metadata) and what they are measuring (time-series), and the architecture must make crossing that bridge in both directions straightforward.

    For most teams, PostgreSQL with TimescaleDB is the appropriate starting point. It offers native SQL JOINs across metadata and time-series tables, a single connection string, familiar tooling, and excellent performance up to terabyte scale. Once metadata and sensor data are properly connected, feeding the data into modern time-series forecasting models becomes substantially simpler. When the system outgrows that platform, the patterns for InfluxDB integration, Parquet data lakes, and TDengine super tables provide a clear upgrade path.

    The principal design principles are as follows:

    • Separate but connected: Metadata in relational tables, time-series in optimised storage, linked by sensor_id.
    • Sensor registry: Sensors should be treated as first-class entities with rich metadata (type, unit, range, calibration, sampling rate).
    • Slowly changing dimensions: Metadata changes should be tracked over time so that historical data can be interpreted correctly.
    • Validate at ingestion: A time-series reading should never be inserted without confirmation that the sensor exists in metadata.
    • Tiered retention: Raw data (30 days) → downsampled (1 year) → aggregated (indefinite) → archived (cold storage). For the archival tier, an InfluxDB-to-Iceberg pipeline can move older data to S3 at a fraction of the cost.
    • Index the bridge: Composite indexes on (sensor_id, time DESC) render cross-domain queries fast.

    The complete schema, ingestion pipeline, query patterns, and API design in this guide provide a production-ready blueprint. The recommended sequence is to begin with the PostgreSQL + TimescaleDB pattern, add the sensor registry and validation layer, implement continuous aggregates for downsampling, and construct the API layer with FastAPI. The resulting system will be one in which "show me all vibration anomalies from Building A's CNC machines installed after 2023" is a query that returns results in milliseconds rather than a question that leaves the team unable to respond.

    References

  • The Best Databases for Storing Preprocessed Time-Series Data: A Comprehensive Comparison Guide

    The Short Version

    Preprocessed time-series data — the cleaned, feature-engineered, windowed tables that feed models and dashboards — places demands that differ sharply from raw metric ingestion: wide schemas of roughly 50 to 500 columns, batch writes, read-heavy machine-learning access, and frequent joins against labels and metadata. That difference is why the usual “best time-series database” advice, tuned for high-rate ingestion, often recommends the wrong tool for this job.

    The guide compares the realistic options category by category — dedicated engines (TimescaleDB, InfluxDB, QuestDB, TDengine), columnar and analytical systems (ClickHouse, Parquet with DuckDB), lakehouse table formats (Iceberg, Delta Lake), general-purpose databases, and machine-learning feature stores — and closes with a decision framework, an explicitly illustrative performance and cost comparison, and a two-tier reference architecture that keeps a hot SQL store for serving alongside inexpensive columnar files for offline training. The recurring finding is that no single system wins on every axis, so a deliberate dual-storage split usually beats forcing one engine to do everything.

    Introduction

    A preprocessing pipeline can be immaculate and still leave one question unanswered: once the raw signals have been cleaned, normalised, windowed, and expanded into hundreds of engineered features, where should the result actually live? The storage decision is easy to treat as an afterthought, yet it quietly governs how quickly models train, how cheaply history is retained, and how much SQL gymnastics every downstream query demands.

    The difficulty is that most “best time-series database” comparisons answer a different question. They rank engines by how many raw points per second each can ingest, because that is the workload a monitoring or IoT system cares about. Preprocessed data inverts those priorities. It is written in batches and read thousands of times; its rows are wide rather than tall; and it is routinely joined against labels, experiment tags, and sensor metadata. A database chosen for ingestion throughput can therefore become a liability the moment complex analytical reads dominate. Perhaps modern time-series forecasting models have already produced predictions that now need a durable home alongside the features that generated them.

    The consequences of a poor match are concrete. A store optimised for raw metric ingestion forces weeks of workarounds when feature tables need relational JOINs. A heavyweight enterprise platform can exhaust a cloud budget within a quarter where a Parquet file on object storage would have sufficed. A general-purpose relational database with no time-series optimisation sees query latencies climb steadily once the dataset passes a few hundred gigabytes. The sections that follow survey every major category of store suitable for preprocessed time-series data — from purpose-built engines such as TimescaleDB and InfluxDB, to columnar systems such as ClickHouse and DuckDB, to data-lakehouse formats such as Apache Iceberg, and machine-learning feature stores such as Feast — with candid trade-offs, ready-to-adapt Python, and guidance on when each is the right choice. The result is a decision framework, an illustrative benchmark and cost comparison, and a dual-storage architecture that serves both real-time queries and offline training.

    What Makes Preprocessed Time-Series Data Different

    Before specific databases are examined, the reasons why preprocessed time-series data has fundamentally different storage requirements from raw time-series data must be understood. This distinction is critical because most database comparison articles focus on raw ingestion workloads, which is not the relevant problem here.

    Key Characteristics of Preprocessed Data

    When time-series data is preprocessed, the transformations dramatically change its storage profile:

    Already cleaned and validated. A database that excels at handling out-of-order writes, late-arriving data, or deduplication on ingest is not required. The data arrives clean, consistent, and ready to store. Ingestion-optimised features — the principal strengths of databases such as InfluxDB — therefore matter far less than they would for raw telemetry.

    Feature-rich with wide schemas. A single preprocessed record may contain 50, 100, or even 500 columns. The pipeline begins with a few raw signals (temperature, pressure, vibration) and expands them into rolling means, standard deviations, kurtosis values, FFT coefficients, lag features, and interaction terms. The resulting wide-table pattern is one that many time-series databases were not designed to accommodate.

    Often windowed into fixed-size chunks. Rather than individual timestamped points, the data may be organised into windows of 60 seconds, 5 minutes, or 1024 samples. Each row represents a window, not a point. This changes how indexing and partitioning are approached.

    Read-heavy workload. The data is written once (or updated infrequently as preprocessing is re-run), then read thousands of times for model training, hyperparameter tuning, inference, and dashboards. Write throughput is desirable; read performance is what actually matters.

    Rich metadata requirements. Each record typically carries metadata: sensor ID, machine ID, experiment tag, label (for supervised learning), preprocessing version, and so on. Efficient filtering and JOIN operations on these fields are required. For a detailed treatment of designing the metadata layer itself, see the related guide on managing metadata for time-series data in facility and sensor systems.

    Characteristic Raw Time-Series Preprocessed Time-Series
    Columns per record 3–10 50–500+
    Write pattern Continuous streaming Batch inserts, infrequent updates
    Read pattern Recent data, aggregations Full scans for ML, filtered queries for serving
    Typical dataset size GB to TB (narrow) GB to TB (wide)
    Schema stability Mostly stable Evolves with feature engineering
    JOIN requirements Rare Common (metadata, labels, experiments)
    Query complexity Simple aggregations Complex filtering, window functions, ML reads

     

    Key Takeaway: Most “best time-series database” articles optimise for raw ingestion throughput. For preprocessed data, the appropriate optimisation targets are read performance on wide tables, SQL support for complex queries, and ML ecosystem integration. This shift in priorities completely changes which databases prevail in the comparison.

    Dedicated Time-Series Databases

    Time-series databases (TSDBs) are purpose-built for timestamped data. They optimise storage layout, indexing, and query execution for temporal patterns. Not all TSDBs, however, handle preprocessed data equally well. The leading contenders are examined below.

    InfluxDB

    InfluxDB is among the most widely deployed open-source time-series databases, designed from the ground up for metrics, events, and IoT data. The InfluxDB 3 line — whose Core and Enterprise editions reached general availability in April 2025 — is a ground-up rewrite in Rust built on Apache Arrow, DataFusion, and Parquet, which markedly improves analytical query performance and moves the query surface toward SQL.

    Pros:

    • Purpose-built for time-series with highly fast ingestion (millions of points per second)
    • Built-in downsampling, retention policies, and continuous queries
    • InfluxDB 3.0 uses Apache Arrow columnar format internally, boosting analytical reads
    • Rich ecosystem: Telegraf for collection, Grafana integration, client libraries in every language
    • Managed cloud offering with a generous free tier

    Cons:

    • Limited JOIN support — the data model is designed around “measurements” (like tables), not relational queries
    • Wide tables with hundreds of fields are not InfluxDB’s sweet spot; the “tag vs. field” model can become awkward
    • Flux query language (v2) has a steep learning curve, though v3 moves to SQL
    • Less ideal for complex analytical queries that preprocessed data workflows demand

    Best for: Monitoring dashboards, IoT raw-data ingestion, and simple aggregations on narrow time-series. Less suitable for feature-rich preprocessed datasets. For users whose data currently resides in InfluxDB and who wish to migrate to a lakehouse for analytics, the InfluxDB-to-AWS Iceberg Telegraf pipeline guide describes the complete migration path.

    from influxdb_client import InfluxDBClient, Point, WritePrecision
    from influxdb_client.client.write_api import SYNCHRONOUS
    import pandas as pd
    
    # Connect to InfluxDB
    client = InfluxDBClient(
        url="http://localhost:8086",
        token="your-token",
        org="your-org"
    )
    
    # Write preprocessed features
    write_api = client.write_api(write_options=SYNCHRONOUS)
    
    # Each preprocessed window becomes a point
    for _, row in features_df.iterrows():
        point = (
            Point("sensor_features")
            .tag("sensor_id", row["sensor_id"])
            .tag("machine_id", row["machine_id"])
            .field("mean_temperature", row["mean_temp"])
            .field("std_temperature", row["std_temp"])
            .field("kurtosis_vibration", row["kurt_vib"])
            .field("fft_dominant_freq", row["fft_freq"])
            .field("rolling_mean_60s", row["rolling_mean"])
            .field("label", row["label"])
            .time(row["window_start"], WritePrecision.MS)
        )
        write_api.write(bucket="ml-features", record=point)
    
    # Query features for ML training
    query_api = client.query_api()
    query = '''
    from(bucket: "ml-features")
      |> range(start: -30d)
      |> filter(fn: (r) => r["_measurement"] == "sensor_features")
      |> filter(fn: (r) => r["sensor_id"] == "sensor_42")
      |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
    '''
    df = query_api.query_data_frame(query)
    print(f"Retrieved {len(df)} feature windows")

    TimescaleDB

    TimescaleDB is a PostgreSQL extension that adds substantial time-series capability to a mature open-source relational database. The combination — full SQL compliance plus time-series optimisations — makes it well suited to preprocessed data. The company behind it renamed itself from Timescale to TigerData in June 2025, but the open-source extension continues to be released under the TimescaleDB name, so existing code, drivers, and deployment instructions remain unchanged.

    Pros:

    • Full SQL support including JOINs, subqueries, window functions, CTEs — everything you need for complex feature queries
    • Hypertables automatically partition data by time, giving you time-series performance with relational convenience
    • Native compression achieves 95%+ reduction, critical for wide feature tables
    • Continuous aggregates pre-compute common queries for dashboard performance
    • Works with every PostgreSQL tool, ORM, and driver (psycopg2, SQLAlchemy, Django, etc.)
    • Columnar compression (introduced in recent versions) optimizes analytical read patterns
    • Excellent for mixed workloads: serve real-time queries and feed ML pipelines from the same database

    Cons:

    • Requires PostgreSQL knowledge (though most engineers already have this)
    • Raw ingestion throughput is slightly lower than pure TSDBs like QuestDB or InfluxDB
    • Self-hosted requires PostgreSQL tuning for optimal performance

    Best for: Preprocessed time-series data with complex query requirements, ML pipelines that need SQL access, mixed read/write workloads, teams that already use PostgreSQL.

    Tip: TimescaleDB is the top recommendation for most preprocessed time-series use cases. The combination of full SQL, automatic partitioning, aggressive compression, and the entire PostgreSQL ecosystem makes it the most versatile choice. It provides time-series performance without sacrificing relational capabilities.
    import psycopg2
    from psycopg2.extras import execute_values
    import pandas as pd
    
    # Connect to TimescaleDB (it's just PostgreSQL)
    conn = psycopg2.connect(
        host="localhost",
        port=5432,
        dbname="timeseries_features",
        user="engineer",
        password="your-password"
    )
    cur = conn.cursor()
    
    # Create a hypertable for preprocessed features
    cur.execute("""
    CREATE TABLE IF NOT EXISTS sensor_features (
        time           TIMESTAMPTZ NOT NULL,
        sensor_id      TEXT NOT NULL,
        machine_id     TEXT NOT NULL,
        label          INTEGER,
        -- Statistical features
        mean_temp      DOUBLE PRECISION,
        std_temp       DOUBLE PRECISION,
        min_temp       DOUBLE PRECISION,
        max_temp       DOUBLE PRECISION,
        skew_temp      DOUBLE PRECISION,
        kurtosis_temp  DOUBLE PRECISION,
        -- Spectral features
        fft_freq_1     DOUBLE PRECISION,
        fft_mag_1      DOUBLE PRECISION,
        fft_freq_2     DOUBLE PRECISION,
        fft_mag_2      DOUBLE PRECISION,
        -- Rolling window features
        rolling_mean_5m  DOUBLE PRECISION,
        rolling_std_5m   DOUBLE PRECISION,
        rolling_mean_15m DOUBLE PRECISION,
        rolling_std_15m  DOUBLE PRECISION,
        -- Lag features
        lag_1          DOUBLE PRECISION,
        lag_5          DOUBLE PRECISION,
        lag_10         DOUBLE PRECISION
    );
    
    -- Convert to hypertable (automatic time-based partitioning)
    SELECT create_hypertable('sensor_features', 'time',
        if_not_exists => TRUE);
    
    -- Enable compression for 95%+ storage savings
    ALTER TABLE sensor_features SET (
        timescaledb.compress,
        timescaledb.compress_segmentby = 'sensor_id, machine_id'
    );
    
    -- Auto-compress chunks older than 7 days
    SELECT add_compression_policy('sensor_features',
        INTERVAL '7 days');
    
    -- Create indexes for common query patterns
    CREATE INDEX IF NOT EXISTS idx_sensor_features_sensor
        ON sensor_features (sensor_id, time DESC);
    CREATE INDEX IF NOT EXISTS idx_sensor_features_label
        ON sensor_features (label, time DESC);
    """)
    conn.commit()
    
    # Bulk insert preprocessed features using execute_values
    features_data = [
        (row["time"], row["sensor_id"], row["machine_id"],
         row["label"], row["mean_temp"], row["std_temp"],
         row["min_temp"], row["max_temp"], row["skew_temp"],
         row["kurtosis_temp"], row["fft_freq_1"], row["fft_mag_1"],
         row["fft_freq_2"], row["fft_mag_2"],
         row["rolling_mean_5m"], row["rolling_std_5m"],
         row["rolling_mean_15m"], row["rolling_std_15m"],
         row["lag_1"], row["lag_5"], row["lag_10"])
        for _, row in df.iterrows()
    ]
    
    execute_values(cur, """
        INSERT INTO sensor_features VALUES %s
    """, features_data, page_size=5000)
    conn.commit()
    
    # Query: Get training data for a specific sensor
    cur.execute("""
        SELECT time, mean_temp, std_temp, kurtosis_temp,
               fft_freq_1, rolling_mean_5m, lag_1, label
        FROM sensor_features
        WHERE sensor_id = 'sensor_42'
          AND time >= NOW() - INTERVAL '30 days'
          AND label IS NOT NULL
        ORDER BY time
    """)
    training_data = pd.DataFrame(cur.fetchall(),
        columns=["time", "mean_temp", "std_temp", "kurtosis_temp",
                 "fft_freq_1", "rolling_mean_5m", "lag_1", "label"])
    
    print(f"Training samples: {len(training_data)}")
    print(f"Feature columns: {training_data.shape[1] - 2}")  # Exclude time, label
    
    # Query: Continuous aggregate for dashboard
    cur.execute("""
        SELECT time_bucket('1 hour', time) AS hour,
               sensor_id,
               AVG(mean_temp) AS avg_temp,
               MAX(kurtosis_temp) AS max_kurtosis,
               COUNT(*) FILTER (WHERE label = 1) AS anomaly_count
        FROM sensor_features
        WHERE time >= NOW() - INTERVAL '7 days'
        GROUP BY hour, sensor_id
        ORDER BY hour DESC
    """)
    
    cur.close()
    conn.close()

    QuestDB

    QuestDB is a high-performance time-series database written in Java and C++, designed for maximum throughput. It uses a column-oriented storage model and supports SQL natively, occupying a notable middle ground between pure TSDBs and analytical databases.

    Pros:

    • Blazing fast ingestion: benchmarks show millions of rows per second on modest hardware
    • Native SQL support with time-series extensions (SAMPLE BY, LATEST ON, ASOF JOIN)
    • Column-oriented storage is excellent for analytical queries on wide tables
    • ASOF JOIN is uniquely powerful for aligning time-series from different sources
    • Low memory footprint compared to other analytical engines
    • Built-in web console for ad-hoc queries

    Cons:

    • Younger ecosystem with fewer integrations than PostgreSQL or InfluxDB
    • Limited support for complex JOINs (beyond ASOF and LT JOIN)
    • No native compression policies like TimescaleDB
    • Smaller community, though growing rapidly

    Best for: High-throughput analytics, financial tick data, scenarios where ingestion speed is paramount alongside analytical reads.

    import requests
    import pandas as pd
    
    # QuestDB supports ingestion via ILP (InfluxDB Line Protocol)
    # and querying via PostgreSQL wire protocol or REST API
    
    # Create table via REST
    requests.get("http://localhost:9000/exec", params={"query": """
        CREATE TABLE IF NOT EXISTS sensor_features (
            timestamp TIMESTAMP,
            sensor_id SYMBOL,
            machine_id SYMBOL,
            mean_temp DOUBLE,
            std_temp DOUBLE,
            kurtosis_temp DOUBLE,
            fft_freq_1 DOUBLE,
            rolling_mean_5m DOUBLE,
            label INT
        ) timestamp(timestamp) PARTITION BY DAY WAL;
    """})
    
    # Query using REST API (returns CSV or JSON)
    response = requests.get("http://localhost:9000/exp", params={"query": """
        SELECT timestamp, sensor_id, mean_temp, std_temp,
               kurtosis_temp, fft_freq_1, label
        FROM sensor_features
        WHERE sensor_id = 'sensor_42'
          AND timestamp IN '2026-03'
        ORDER BY timestamp
    """})
    
    # Parse into pandas DataFrame
    from io import StringIO
    df = pd.read_csv(StringIO(response.text))
    print(f"Rows retrieved: {len(df)}")

    TDengine

    TDengine is an open-source time-series database designed specifically for IoT and industrial applications. Its distinctive “super table” concept — under which each device receives its own subtable beneath a shared schema — is particularly well suited to sensor data from many devices.

    Pros:

    • Super tables elegantly handle the “many devices, same schema” pattern common in preprocessed IoT data
    • highly high compression ratios (often 10:1 or better)
    • SQL-like query language (TDengine SQL) with time-series extensions
    • Built-in stream processing and continuous queries
    • Designed to run on edge devices with limited resources

    Cons:

    • Smaller community outside of China, where it was developed
    • Documentation quality can be uneven in English
    • Fewer third-party integrations compared to InfluxDB or TimescaleDB
    • The super table model can feel constraining for non-IoT use cases

    Best for: IoT and industrial time-series with many devices/sensors, edge computing scenarios, and applications that benefit from the super table data model.

    Columnar and Analytical Databases

    When the primary workload is analytical — scanning large ranges of preprocessed data for ML training or computing aggregations for dashboards — columnar databases and file formats often outperform dedicated TSDBs. This category is where preprocessed data is best served.

    Apache Parquet + DuckDB

    This combination has quietly become the default storage solution for data-science and ML workflows. Parquet is a columnar file format; DuckDB is an in-process analytical database (conceptually, “SQLite for analytics”). Together they provide zero-infrastructure, very fast analytical queries directly on files.

    Pros:

    • Zero infrastructure: no servers, no processes, no ports to manage
    • Parquet is the universal exchange format for the ML ecosystem (pandas, polars, PyTorch, scikit-learn, Spark all read it natively)
    • DuckDB provides full SQL including JOINs, window functions, CTEs — faster than pandas for large datasets
    • Excellent compression (Snappy, Zstd, Brotli) with columnar encoding
    • Parquet supports schema evolution and complex nested types
    • Works directly with S3, GCS, or local filesystem
    • DuckDB can query Parquet files without loading them into memory
    • Free and open source, forever

    Cons:

    • Not for real-time serving or concurrent writes (it is a file format, not a server)
    • No built-in access control or multi-user support
    • Not suitable for high-frequency updates or streaming ingestion
    • DuckDB is single-node only (though for most ML workloads this is fine)

    Best for: ML training datasets, batch analytics, data-science workflows, and any scenario in which data is written once and read many times.

    Tip: Parquet + DuckDB is the top recommendation for ML training pipelines. If preprocessed data is consumed primarily by model-training scripts, Jupyter notebooks, or batch analytics, this combination is unmatched in simplicity, performance, and cost (free).
    import pandas as pd
    import pyarrow as pa
    import pyarrow.parquet as pq
    import duckdb
    
    # === Save preprocessed features to Parquet ===
    # Assume features_df is your preprocessed DataFrame
    # with columns: time, sensor_id, machine_id, label, + 50 feature columns
    
    # Partition by sensor_id for efficient filtered reads
    pq.write_to_dataset(
        pa.Table.from_pandas(features_df),
        root_path="s3://ml-data/sensor-features/",
        partition_cols=["sensor_id"],
        compression="zstd",             # Best compression ratio
        use_dictionary=True,            # Encode repeated values efficiently
        write_statistics=True,          # Enable predicate pushdown
    )
    
    # === Query with DuckDB (no loading into memory!) ===
    con = duckdb.connect()
    
    # DuckDB reads Parquet directly, even from S3
    training_data = con.execute("""
        SELECT time, mean_temp, std_temp, kurtosis_temp,
               fft_freq_1, fft_mag_1, rolling_mean_5m,
               rolling_std_5m, lag_1, lag_5, label
        FROM read_parquet('s3://ml-data/sensor-features/**/*.parquet',
                          hive_partitioning=true)
        WHERE sensor_id = 'sensor_42'
          AND time >= '2026-01-01'
          AND label IS NOT NULL
        ORDER BY time
    """).fetchdf()
    
    print(f"Training samples: {len(training_data)}")
    
    # Aggregate query for feature statistics
    stats = con.execute("""
        SELECT sensor_id,
               COUNT(*) as samples,
               AVG(mean_temp) as avg_temp,
               STDDEV(mean_temp) as std_temp,
               SUM(CASE WHEN label = 1 THEN 1 ELSE 0 END) as anomalies,
               ROUND(100.0 * SUM(CASE WHEN label = 1 THEN 1 ELSE 0 END)
                     / COUNT(*), 2) as anomaly_pct
        FROM read_parquet('s3://ml-data/sensor-features/**/*.parquet',
                          hive_partitioning=true)
        GROUP BY sensor_id
        ORDER BY anomaly_pct DESC
    """).fetchdf()
    
    print(stats.head(10))
    
    # === Feed directly to scikit-learn ===
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.model_selection import train_test_split
    
    X = training_data.drop(columns=["time", "label"])
    y = training_data["label"]
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
    
    model = RandomForestClassifier(n_estimators=100)
    model.fit(X_train, y_train)
    print(f"Accuracy: {model.score(X_test, y_test):.4f}")

    ClickHouse

    ClickHouse is a column-oriented OLAP database originally developed at Yandex. It is renowned for its extraordinary analytical query speed, processing billions of rows per second on commodity hardware. Its MergeTree engine family is particularly well suited to time-series data.

    Pros:

    • Extraordinary analytical query performance — often 10–100x faster than traditional databases for aggregation queries
    • Excellent compression with codec support (LZ4, ZSTD, Delta, DoubleDelta, Gorilla)
    • MergeTree engine with automatic data ordering and efficient range scans
    • Full SQL support including JOINs, subqueries, and window functions
    • Materialized views for pre-computed aggregations
    • Scales to petabytes with distributed tables
    • Active open-source community and a managed cloud offering

    Cons:

    • Not ideal for frequent updates or deletes (mutations are asynchronous and expensive)
    • Requires a running server process, more operational overhead than Parquet files
    • Point queries (single row lookups) are not its strength
    • JOINs, while supported, can be memory-intensive for very large tables

    Best for: Large-scale analytics dashboards, real-time aggregations over billions of rows, scenarios where you need both fast ingestion and fast analytical reads on a server-based system.

    from clickhouse_driver import Client
    import pandas as pd
    
    client = Client(host='localhost', port=9000)
    
    # Create table optimized for time-series features
    client.execute("""
    CREATE TABLE IF NOT EXISTS sensor_features (
        time DateTime64(3),
        sensor_id LowCardinality(String),
        machine_id LowCardinality(String),
        label UInt8,
        mean_temp Float64,
        std_temp Float64,
        kurtosis_temp Float64,
        fft_freq_1 Float64,
        fft_mag_1 Float64,
        rolling_mean_5m Float64,
        rolling_std_5m Float64,
        lag_1 Float64,
        lag_5 Float64
    ) ENGINE = MergeTree()
    PARTITION BY toYYYYMM(time)
    ORDER BY (sensor_id, time)
    SETTINGS index_granularity = 8192
    """)
    
    # Bulk insert (ClickHouse excels at batch inserts)
    client.execute(
        "INSERT INTO sensor_features VALUES",
        features_df.values.tolist(),
        types_check=True
    )
    
    # Analytical query: feature distributions by sensor
    result = client.execute("""
        SELECT sensor_id,
               count() AS samples,
               avg(mean_temp) AS avg_temp,
               quantile(0.95)(kurtosis_temp) AS p95_kurtosis,
               sum(label) AS anomalies
        FROM sensor_features
        WHERE time >= '2026-01-01'
        GROUP BY sensor_id
        ORDER BY anomalies DESC
        LIMIT 20
    """)
    print(pd.DataFrame(result,
        columns=["sensor_id", "samples", "avg_temp",
                 "p95_kurtosis", "anomalies"]))

    Data Lakehouse Formats

    When preprocessed time-series data reaches enterprise scale — terabytes to petabytes, accessed by multiple teams using different compute engines — data-lakehouse formats become the natural choice. They combine the low cost of object storage (S3, GCS) with database-like features.

    Apache Iceberg

    Apache Iceberg is an open table format for substantial analytical datasets. It functions as a metadata layer that sits on top of Parquet files in object storage, adding ACID transactions, schema evolution, and time-travel capabilities.

    Pros:

    • ACID transactions on object storage — safe concurrent reads and writes
    • Schema evolution: add, rename, or drop columns without rewriting data (perfect for evolving feature sets)
    • Time travel: query data as it existed at any previous point (invaluable for ML experiment reproducibility)
    • Partition evolution: change partitioning strategy without rewriting existing data
    • Works with multiple compute engines: Spark, Trino/Presto, Athena, Flink, Dremio, Snowflake
    • Infinite scale on object storage at object storage prices
    • Hidden partitioning eliminates the need for users to know partition columns

    Cons:

    • Requires a compute engine (Spark, Trino, etc.) — no standalone query capability
    • Higher query latency than local databases due to object storage round trips
    • More complex to set up and manage than simpler solutions
    • Catalog management (Hive Metastore, Nessie, AWS Glue) adds operational overhead

    Best for: Enterprise-scale data platforms, multi-team organisations, long-term storage with reproducibility requirements, and data-mesh architectures. For a hands-on walkthrough of building an Iceberg pipeline from scratch, see the related complete InfluxDB-to-Iceberg data pipeline guide.

    Delta Lake

    Delta Lake is an open table format originally created by Databricks. It provides capabilities similar to Iceberg — ACID transactions, schema evolution, time travel — with tighter integration into the Spark and Databricks ecosystem.

    Pros:

    • Tight Spark integration with the most mature implementation
    • ACID transactions and schema enforcement
    • Change Data Feed for tracking incremental changes
    • Z-ordering for multi-dimensional clustering (useful for filtering by multiple metadata fields)
    • Strong Databricks ecosystem support and Unity Catalog integration

    Cons:

    • Strongest on Databricks/Spark; other engines have varying support levels
    • Some advanced features require Databricks runtime
    • Vendor lock-in risk compared to Iceberg’s broader engine support

    Best for: Databricks-centric data platforms, Spark-heavy pipelines, teams already invested in the Databricks ecosystem.

    Caution: Both Iceberg and Delta Lake are powerful but introduce significant complexity. When preprocessed data fits on a single machine (under approximately 1 TB), a simpler solution such as TimescaleDB or Parquet + DuckDB is likely to serve better, with far less operational burden.

    General-Purpose Databases with Time-Series Capabilities

    In some cases the best database for preprocessed time-series data is one that is already running. Several general-purpose databases have added time-series features that may be sufficient without introducing a new technology to the stack.

    PostgreSQL (Without TimescaleDB)

    Plain PostgreSQL with native table partitioning (PARTITION BY RANGE on timestamp columns) can handle preprocessed time-series data surprisingly well for small to medium datasets. If the data is under 100 GB and a PostgreSQL instance already exists, this configuration may be sufficient.

    Declarative partitioning splits the data by month or week, appropriate indexes are added, and the result is a functional time-series store with full SQL capability. The trade-off is the loss of TimescaleDB’s automatic chunk management, compression policies, and continuous aggregates — features that become important at larger scale.

    MongoDB Time-Series Collections

    MongoDB 5.0 introduced native time-series collections with automatic bucketing, a columnar compression engine, and time-series-specific query optimisations. For teams already using MongoDB, this eliminates the need for a separate TSDB.

    Pros: Flexible schema (well suited to evolving feature sets), native time-series optimisations, a capable aggregation pipeline, and the MongoDB ecosystem. Cons: Not SQL (though MongoDB’s aggregation framework supports complex queries), generally lower analytical performance than columnar engines, and higher storage overhead than Parquet or ClickHouse.

    Best for: Teams already on MongoDB who wish to avoid adding a new database to the stack.

    Redis with RedisTimeSeries

    Redis with the RedisTimeSeries module is the appropriate choice when millisecond-latency reads are non-negotiable. It stores time-series data in memory with optional persistence, making it ideal for real-time ML feature serving.

    Pros:

    • Sub-millisecond read latency — unmatched by any other option
    • Perfect for feature stores serving real-time ML inference
    • Built-in downsampling rules and aggregation functions
    • Redis ecosystem: pub/sub, streams, search, JSON — all in one

    Cons:

    • In-memory: expensive for large datasets (RAM is ~10x the cost of SSD)
    • Not designed for complex queries or large analytical scans
    • Data model is simple (key + timestamp + value), not ideal for wide feature vectors
    • Persistence and durability require careful configuration

    Best for: Real-time ML feature serving, online inference with strict latency SLAs, caching frequently accessed features.

    import redis
    from redis.commands.timeseries import TimeSeries
    import time
    
    # Connect to Redis with RedisTimeSeries module
    r = redis.Redis(host='localhost', port=6379, decode_responses=True)
    ts = r.ts()
    
    # Create time-series keys for each feature of each sensor
    sensor_id = "sensor_42"
    features = ["mean_temp", "std_temp", "kurtosis_temp",
                "fft_freq_1", "rolling_mean_5m"]
    
    for feature in features:
        key = f"features:{sensor_id}:{feature}"
        try:
            ts.create(key,
                retention_msecs=86400000 * 30,  # 30 days retention
                labels={
                    "sensor_id": sensor_id,
                    "feature": feature,
                    "type": "preprocessed"
                }
            )
        except redis.exceptions.ResponseError:
            pass  # Key already exists
    
    # Write latest preprocessed features (real-time pipeline)
    timestamp_ms = int(time.time() * 1000)
    feature_values = {
        "mean_temp": 23.45,
        "std_temp": 1.23,
        "kurtosis_temp": -0.45,
        "fft_freq_1": 50.2,
        "rolling_mean_5m": 23.1
    }
    
    for feature, value in feature_values.items():
        key = f"features:{sensor_id}:{feature}"
        ts.add(key, timestamp_ms, value)
    
    # Read latest features for real-time inference
    latest_features = {}
    for feature in features:
        key = f"features:{sensor_id}:{feature}"
        result = ts.get(key)
        latest_features[feature] = result[1]  # (timestamp, value)
    
    print(f"Latest features for {sensor_id}: {latest_features}")
    
    # Query feature history for a time range
    range_data = ts.range(
        f"features:{sensor_id}:mean_temp",
        from_time="-",
        to_time="+",
        count=100
    )
    print(f"Historical points: {len(range_data)}")
    
    # Multi-key query: get latest values for ALL sensors' mean_temp
    all_sensors = ts.mget(filters=["feature=mean_temp"])
    for item in all_sensors:
        print(f"  {item['labels']['sensor_id']}: {item['value']}")

    ML-Specific Feature Stores

    Feature stores are a relatively new category that sits between databases and ML pipelines. They are purpose-built to manage, serve, and discover features for machine learning, and preprocessed time-series features are one of their primary use cases.

    Feast (Open Source)

    Feast is the most widely adopted open-source feature store. It does not replace the underlying database; rather, it provides a unified interface for defining features, ingesting them from existing data sources, and serving them consistently for both training and inference.

    Key capabilities: Feature definitions as code, point-in-time correct joins (critical for preventing data leakage in time-series ML), online serving via Redis or DynamoDB, offline serving via BigQuery, Snowflake, or file-based stores, feature reuse across teams.

    Tecton and Hopsworks

    Tecton is a managed feature platform that handles everything from feature engineering to serving. Hopsworks is a full ML platform with an integrated feature store. Both are more opinionated and feature-rich than Feast but carry higher costs and complexity.

    When to Use a Feature Store versus a Database

    A feature store is appropriate when multiple ML models consume overlapping sets of features, when point-in-time correctness is required for training data, when feature discovery across teams is a priority, or when dual serving (batch for training, real-time for inference) from a single feature definition is needed.

    A database is the appropriate choice for a single ML model or a small team, when the features are simple enough for a SQL query to suffice, or when the operational overhead of a feature store is not justified by the team’s scale.

    Key Takeaway: Feature stores are not a replacement for databases. They are an orchestration layer on top of databases (such as Redis for online serving, Parquet or BigQuery for offline). They should be considered when feature-management complexity becomes a larger problem than storage or query performance.

    The Comprehensive Comparison Table

    The following table presents the awaited comparison. It evaluates every database and format discussed across the dimensions that matter most for preprocessed time-series data.

    Database Query Language Write Speed Read/Analytics Compression JOINs ML Integration
    TimescaleDB Full SQL Fast Very Good 95%+ Full Excellent
    InfluxDB Flux / SQL (v3) Very Fast Good Good Limited Moderate
    QuestDB SQL + extensions Fastest Very Good Good ASOF only Moderate
    TDengine SQL-like Very Fast Good Excellent Limited Low
    Parquet + DuckDB Full SQL Batch only Excellent Excellent Full Best
    ClickHouse Full SQL Very Fast Excellent Excellent Full Good
    Apache Iceberg SQL (via engine) Batch Very Good Excellent Full Good
    Redis TimeSeries Commands Fast Limited None (in-memory) None Good (serving)
    PostgreSQL Full SQL Moderate Moderate Moderate Full Good
    MongoDB TS MQL / Agg Pipeline Fast Moderate Good $lookup Moderate

    Database Feature Matrix: TimescaleDB vs InfluxDB vs DuckDB vs ClickHouse TimescaleDB InfluxDB DuckDB+Parquet ClickHouse Full SQL / JOINs Wide Table Support Real-Time Serving Compression ML Ecosystem Fit Zero Infrastructure Managed Cloud ✔ Full ✔ Good ✔ Yes ✔ 95%+ ✔ Excellent ✗ No ✔ Yes ✗ Limited ✗ Awkward ✔ Yes ✔ Good ~ Moderate ✗ No ✔ Yes ✔ Full ✔ Best ✗ No ✔ Excellent ✔ Best ✔ Yes ✗ N/A ✔ Full ✔ Excellent ✔ Yes ✔ Excellent ✔ Good ✗ No ✔ Yes

     

    Database Real-Time Serving Managed Cloud Open Source Free Tier Best Use Case
    TimescaleDB Yes Timescale Cloud Yes Yes (30 days) Preprocessed data + SQL
    InfluxDB Yes InfluxDB Cloud Yes Yes Monitoring, IoT metrics
    QuestDB Yes QuestDB Cloud Yes Yes High-speed analytics
    Parquet + DuckDB No MotherDuck Yes Forever free ML training data
    ClickHouse Yes ClickHouse Cloud Yes Yes Large-scale OLAP
    Apache Iceberg No AWS/GCP native Yes Pay per query Enterprise data lake
    Redis TimeSeries Sub-ms latency Redis Cloud Yes Yes Real-time feature serving

     

    Decision Framework: How to Choose

    With so many options available, analysis paralysis is a real risk. The following practical decision framework is based on the three dimensions that matter most: data volume, query pattern, and infrastructure preference.

    Decision Tree: Which Database for Preprocessed Time-Series Data? START HERE Need SQL / JOINs? (complex queries, ML pipelines) NO InfluxDB IoT · Monitoring Simple metrics YES Real-time serving needed? YES NO TimescaleDB Online serving + SQL Dashboards · APIs Parquet+DuckDB ML training · Batch Zero infra · Free YES Data over 1TB? (enterprise scale) NO ClickHouse Fast analytics · SQL 10GB–1TB sweet spot YES Apache Iceberg Enterprise scale S3 · Multi-engine Legend TimescaleDB (online + SQL) Parquet+DuckDB (offline ML) ClickHouse (fast analytics) Iceberg / InfluxDB

    By Data Volume

    Under 10 GB of preprocessed data: Almost any option will suffice. Plain PostgreSQL is appropriate when it is already in use, and Parquet files are appropriate for ML workflows. Over-engineering should be avoided at this scale; TimescaleDB is excellent but may be more than is required.

    10 GB to 1 TB: This is the optimum range for dedicated solutions. TimescaleDB for online serving and complex queries, Parquet + DuckDB for ML training, and ClickHouse when fast dashboards across the full dataset are required.

    Over 1 TB: Solutions designed for scale are necessary. Apache Iceberg or Delta Lake on object storage for long-term storage, ClickHouse or TimescaleDB for the hot query layer, and a clear data lifecycle policy (hot/warm/cold) are all required.

    By Query Pattern

    Scenario Primary Need Recommended Database
    ML training with preprocessed sensor data Batch reads, full scans Parquet + DuckDB or TimescaleDB
    Real-time anomaly detection serving Low-latency point queries Redis TimeSeries or TimescaleDB
    Enterprise data lake with many teams Governance, scale, multi-engine Apache Iceberg on S3
    IoT monitoring dashboard Streaming + visualization InfluxDB or QuestDB
    Financial tick data analytics High-speed ingestion + analytics QuestDB or ClickHouse
    Mixed online + offline ML pipeline Serve + train from same data TimescaleDB + Parquet (dual)
    Small team, simple needs, under 50GB Simplicity PostgreSQL or Parquet files
    Multi-model feature store Feature management Feast + underlying DB

     

    By Infrastructure Preference

    Zero infrastructure (files only): Parquet + DuckDB. No servers, no processes, no cost.

    Self-hosted, single server: TimescaleDB (the extension is simply installed on the existing PostgreSQL instance). ClickHouse when analytical speed is the priority.

    Managed cloud service: Timescale Cloud, ClickHouse Cloud, InfluxDB Cloud, or QuestDB Cloud, all of which delegate upgrades, backups, and scaling to the provider.

    Serverless / pay-per-query: Apache Iceberg on S3 with AWS Athena or Google BigQuery. Costs are incurred only when queries run.

    Key Takeaway: When uncertain, the appropriate starting point is TimescaleDB for online needs and Parquet files for offline ML. This dual-storage approach covers 90% of preprocessed time-series use cases; both technologies are free, production-proven, and well documented. More specialised solutions can always be added later.

    Practical Implementation: TimescaleDB plus Parquet Dual Setup

    The most robust architecture for preprocessed time-series data uses two storage layers: TimescaleDB for online serving (APIs, dashboards, real-time queries) and Parquet files for offline ML (model training, batch analytics, experiments). A complete implementation follows.

    Architecture Overview

    The data flow is straightforward: the preprocessing pipeline writes to TimescaleDB as the source of truth. A sync job periodically exports new data to Parquet files on S3 (or local disk) for ML consumption. Both stores serve their respective consumers with optimal performance.

    Data Flow: Sensors → Preprocessing → Storage → Consumers Raw Sensors IoT / Financial Tick / Logs Preprocessing Clean · Normalize Features · Windows TimescaleDB Online / Real-Time Dashboards · APIs Anomaly Serving Parquet + DuckDB Offline / Batch ML Training · EDA Experiments Analytics / BI Grafana · Metabase ML / AI Models scikit-learn · PyTorch Real-Time Inference REST API · Redis

    Preprocessing Pipeline
            |
            v
      +---------------+
      |  TimescaleDB   |  ← Source of truth (online)
      |  (PostgreSQL)  |  ← Dashboards, APIs, real-time queries
      +-------+-------+
              |
         Sync Job (hourly/daily)
              |
              v
      +---------------+
      |  Parquet on S3 |  ← ML training, batch analytics
      |  (+ DuckDB)   |  ← Jupyter notebooks, experiments
      +---------------+

    Full Code Example

    """
    Complete dual-storage setup:
    TimescaleDB (online) + Parquet (offline ML)
    """
    import psycopg2
    from psycopg2.extras import execute_values
    import pandas as pd
    import pyarrow as pa
    import pyarrow.parquet as pq
    import duckdb
    from datetime import datetime, timedelta
    import os
    
    # ============================================================
    # STEP 1: Set up TimescaleDB hypertable
    # ============================================================
    
    def setup_timescaledb(conn_params: dict):
        """Create hypertable with compression for preprocessed features."""
        conn = psycopg2.connect(**conn_params)
        cur = conn.cursor()
    
        cur.execute("""
        -- Enable TimescaleDB extension
        CREATE EXTENSION IF NOT EXISTS timescaledb;
    
        -- Create the features table
        CREATE TABLE IF NOT EXISTS preprocessed_features (
            time           TIMESTAMPTZ NOT NULL,
            sensor_id      TEXT NOT NULL,
            machine_id     TEXT NOT NULL,
            experiment_tag TEXT,
            label          INTEGER,
    
            -- Statistical features (per window)
            mean_value     DOUBLE PRECISION,
            std_value      DOUBLE PRECISION,
            min_value      DOUBLE PRECISION,
            max_value      DOUBLE PRECISION,
            median_value   DOUBLE PRECISION,
            skewness       DOUBLE PRECISION,
            kurtosis       DOUBLE PRECISION,
            rms            DOUBLE PRECISION,
            peak_to_peak   DOUBLE PRECISION,
            crest_factor   DOUBLE PRECISION,
    
            -- Spectral features
            fft_freq_1     DOUBLE PRECISION,
            fft_mag_1      DOUBLE PRECISION,
            fft_freq_2     DOUBLE PRECISION,
            fft_mag_2      DOUBLE PRECISION,
            fft_freq_3     DOUBLE PRECISION,
            fft_mag_3      DOUBLE PRECISION,
            spectral_entropy DOUBLE PRECISION,
    
            -- Rolling features
            rolling_mean_1m  DOUBLE PRECISION,
            rolling_std_1m   DOUBLE PRECISION,
            rolling_mean_5m  DOUBLE PRECISION,
            rolling_std_5m   DOUBLE PRECISION,
            rolling_mean_15m DOUBLE PRECISION,
            rolling_std_15m  DOUBLE PRECISION,
    
            -- Lag features
            lag_1          DOUBLE PRECISION,
            lag_5          DOUBLE PRECISION,
            lag_10         DOUBLE PRECISION,
            lag_30         DOUBLE PRECISION,
            diff_1         DOUBLE PRECISION,
            diff_5         DOUBLE PRECISION
        );
    
        -- Convert to hypertable
        SELECT create_hypertable('preprocessed_features', 'time',
            if_not_exists => TRUE,
            chunk_time_interval => INTERVAL '1 day');
    
        -- Enable compression
        ALTER TABLE preprocessed_features SET (
            timescaledb.compress,
            timescaledb.compress_segmentby = 'sensor_id, machine_id',
            timescaledb.compress_orderby = 'time DESC'
        );
    
        -- Auto-compress after 3 days
        SELECT add_compression_policy('preprocessed_features',
            INTERVAL '3 days', if_not_exists => TRUE);
    
        -- Indexes for common access patterns
        CREATE INDEX IF NOT EXISTS idx_features_sensor_time
            ON preprocessed_features (sensor_id, time DESC);
        CREATE INDEX IF NOT EXISTS idx_features_label
            ON preprocessed_features (label, time DESC)
            WHERE label IS NOT NULL;
        CREATE INDEX IF NOT EXISTS idx_features_experiment
            ON preprocessed_features (experiment_tag, time DESC)
            WHERE experiment_tag IS NOT NULL;
        """)
    
        conn.commit()
        cur.close()
        conn.close()
        print("TimescaleDB hypertable created with compression.")
    
    
    # ============================================================
    # STEP 2: Insert preprocessed features into TimescaleDB
    # ============================================================
    
    def insert_features(conn_params: dict, df: pd.DataFrame,
                        batch_size: int = 5000):
        """Bulk insert preprocessed features."""
        conn = psycopg2.connect(**conn_params)
        cur = conn.cursor()
    
        columns = df.columns.tolist()
        col_str = ", ".join(columns)
        template = "(" + ", ".join(["%s"] * len(columns)) + ")"
    
        data = [tuple(row) for _, row in df.iterrows()]
    
        # execute_values is much faster than individual inserts
        execute_values(
            cur,
            f"INSERT INTO preprocessed_features ({col_str}) VALUES %s",
            data,
            template=template,
            page_size=batch_size
        )
    
        conn.commit()
        print(f"Inserted {len(data)} rows into TimescaleDB.")
        cur.close()
        conn.close()
    
    
    # ============================================================
    # STEP 3: Sync TimescaleDB → Parquet (run hourly or daily)
    # ============================================================
    
    def sync_to_parquet(conn_params: dict, output_path: str,
                        since: datetime = None):
        """Export new data from TimescaleDB to Parquet files."""
        conn = psycopg2.connect(**conn_params)
    
        if since is None:
            since = datetime.utcnow() - timedelta(days=1)
    
        # Read new data since last sync
        query = """
            SELECT * FROM preprocessed_features
            WHERE time >= %s
            ORDER BY sensor_id, time
        """
        df = pd.read_sql(query, conn, params=[since])
        conn.close()
    
        if df.empty:
            print("No new data to sync.")
            return
    
        # Write partitioned Parquet files
        table = pa.Table.from_pandas(df)
        pq.write_to_dataset(
            table,
            root_path=output_path,
            partition_cols=["sensor_id"],
            compression="zstd",
            use_dictionary=True,
            write_statistics=True,
            existing_data_behavior="overwrite_or_ignore"
        )
    
        print(f"Synced {len(df)} rows to Parquet at {output_path}")
        print(f"Partitions: {df['sensor_id'].nunique()} sensors")
    
    
    # ============================================================
    # STEP 4: Query from both stores
    # ============================================================
    
    def query_timescaledb_for_dashboard(conn_params: dict,
                                         sensor_id: str):
        """Real-time dashboard query (use TimescaleDB)."""
        conn = psycopg2.connect(**conn_params)
        df = pd.read_sql("""
            SELECT time_bucket('1 hour', time) AS hour,
                   AVG(mean_value) AS avg_value,
                   MAX(kurtosis) AS max_kurtosis,
                   AVG(spectral_entropy) AS avg_entropy,
                   COUNT(*) FILTER (WHERE label = 1) AS anomalies,
                   COUNT(*) AS total_windows
            FROM preprocessed_features
            WHERE sensor_id = %(sid)s
              AND time >= NOW() - INTERVAL '24 hours'
            GROUP BY hour
            ORDER BY hour DESC
        """, conn, params={"sid": sensor_id})
        conn.close()
        return df
    
    
    def query_parquet_for_training(parquet_path: str,
                                    sensor_ids: list = None):
        """ML training data query (use Parquet + DuckDB)."""
        con = duckdb.connect()
    
        where_clause = ""
        if sensor_ids:
            ids = ", ".join(f"'{s}'" for s in sensor_ids)
            where_clause = f"WHERE sensor_id IN ({ids})"
    
        df = con.execute(f"""
            SELECT *
            FROM read_parquet('{parquet_path}/**/*.parquet',
                              hive_partitioning=true)
            {where_clause}
            ORDER BY time
        """).fetchdf()
    
        con.close()
        return df
    
    
    # ============================================================
    # USAGE EXAMPLE
    # ============================================================
    
    if __name__ == "__main__":
        conn_params = {
            "host": "localhost",
            "port": 5432,
            "dbname": "timeseries_db",
            "user": "engineer",
            "password": "your-password"
        }
    
        parquet_path = "s3://my-bucket/preprocessed-features"
        # Or local: parquet_path = "/data/preprocessed-features"
    
        # 1. One-time setup
        setup_timescaledb(conn_params)
    
        # 2. Your preprocessing pipeline inserts features
        # insert_features(conn_params, preprocessed_df)
    
        # 3. Periodic sync to Parquet (cron job)
        # sync_to_parquet(conn_params, parquet_path)
    
        # 4a. Dashboard queries hit TimescaleDB
        # dashboard_df = query_timescaledb_for_dashboard(
        #     conn_params, "sensor_42")
    
        # 4b. ML training reads from Parquet
        # training_df = query_parquet_for_training(
        #     parquet_path, ["sensor_42", "sensor_43"])
    Tip: This dual-storage pattern is production-tested at scale. TimescaleDB handles the online workload with millisecond-latency SQL queries, while Parquet handles the offline workload with maximum throughput for ML. The sync job is simple, idempotent, and can be implemented as a single cron entry.

    Performance Benchmarks

    The tables below sketch how these systems tend to rank against one another on a hypothetical wide workload — on the order of 100 million rows with 50 feature columns, a realistic preprocessed sensor dataset. The figures are illustrative order-of-magnitude expectations synthesised from published vendor benchmarks and the methodology of the open Time Series Benchmark Suite (TSBS, linked in the References); they are not measurements captured from a single controlled run on this site, and they should be read as relative guidance rather than absolute numbers.

    Caution: Real benchmark results vary dramatically with hardware, configuration, data distribution, and query shape, and can easily move an order of magnitude in either direction. Treat the values here as directional comparisons only. Before committing to a database, reproduce the tests that matter with your own data and queries — TSBS provides a documented harness for exactly this purpose.

    Write Speed and Storage Efficiency

    Database Bulk Write (100M rows) Raw Size (CSV) Stored Size Compression Ratio
    TimescaleDB ~8 minutes 45 GB 2.8 GB 16:1
    ClickHouse ~3 minutes 45 GB 2.1 GB 21:1
    QuestDB ~2 minutes 45 GB 5.4 GB 8:1
    Parquet (Zstd) ~5 minutes 45 GB 1.9 GB 24:1
    InfluxDB ~6 minutes 45 GB 4.2 GB 11:1

     

    Query Latency Comparison

    Query Type TimescaleDB ClickHouse QuestDB DuckDB (Parquet) InfluxDB
    Point query (1 sensor, latest) 2 ms 15 ms 5 ms 45 ms 8 ms
    Range scan (1 sensor, 30 days) 120 ms 35 ms 55 ms 85 ms 150 ms
    Aggregation (all sensors, 1 day) 450 ms 80 ms 120 ms 200 ms 380 ms
    Window function (rolling avg) 250 ms 110 ms 180 ms 150 ms N/A
    Full table scan (ML training) 18 s 4 s 8 s 3 s 25 s
    JOIN with metadata table 180 ms 250 ms N/A 220 ms N/A

     

    The relative ordering shown here is consistent across published benchmarks and vendor documentation, even though the exact magnitudes will differ on any given system. ClickHouse tends to dominate analytical queries (aggregations, range scans, window functions) owing to its vectorised execution engine. TimescaleDB tends to excel at point queries and JOINs, reflecting its PostgreSQL heritage. DuckDB on Parquet is often surprisingly competitive for full-table scans — the scenario that matters most for ML training — because columnar Parquet with predicate pushdown is efficient. InfluxDB, while fast at ingestion, generally trails on complex analytical queries because it was designed for a different workload.

    Key Takeaway: No single database wins every query pattern. That is precisely why the dual-storage approach (TimescaleDB for online, Parquet for offline) is so effective: each technology is used where it performs best.

    Cost Comparison

    Performance matters, as does budget. The table below gives rough, order-of-magnitude monthly cost estimates for storing and querying preprocessed time-series data across managed cloud offerings, compiled from each provider’s public pricing pages (accessed mid-2026 and listed in the References). Cloud pricing changes frequently and depends heavily on region, compute tier, retention, and query volume, so these figures are meant only to convey relative magnitude — confirm the current price on each vendor’s calculator before budgeting. The values assume standard on-demand tiers without reserved-capacity discounts.

    Service 100 GB/month 1 TB/month 10 TB/month Free Tier
    Timescale Cloud ~$70 ~$350 ~$2,500 30-day trial
    InfluxDB Cloud ~$100 ~$500 ~$3,800 250 MB storage
    QuestDB Cloud ~$80 ~$400 ~$3,000 Limited free tier
    ClickHouse Cloud ~$90 ~$450 ~$3,200 10 GB storage
    S3 + Athena (Iceberg) ~$5 + queries ~$25 + queries ~$230 + queries S3 free tier
    Parquet on S3 ~$2 ~$23 ~$230 5 GB (12 months)
    DuckDB (self-hosted) $0 $0 $0 Forever free
    Redis Cloud ~$200 ~$1,800 ~$18,000 30 MB

     

    The cost picture is clear: object storage (S3 with Parquet or Iceberg) is an order of magnitude cheaper than managed database services for bulk storage. Redis is dramatically more expensive because it stores data in RAM. The managed TSDBs (Timescale, InfluxDB, QuestDB, ClickHouse) fall in a similar range and provide good value for active query workloads.

    This cost structure reinforces the dual-storage recommendation: a managed database for actively queried data, and object storage (Parquet on S3) for the bulk of historical data. Hot data may occupy 100 GB in TimescaleDB Cloud (approximately $70 per month), while the full training dataset resides as 5 TB of Parquet on S3 (approximately $115 per month).

    Tip: For cost-conscious teams, self-hosted TimescaleDB (free; the PostgreSQL extension is simply installed) together with Parquet files on local NVMe storage provides enterprise-grade time-series capabilities at the cost of a single server. At the terabyte scale, this configuration can save several thousand dollars per month compared with managed services, though the trade-off is that operations, backups, and scaling become the team’s own responsibility.

    Concluding Observations

    Choosing the right database for preprocessed time-series data is not about identifying the single best database; it is about finding the best fit for a specific workload, scale, and team. Following this detailed examination across dedicated TSDBs, columnar engines, data-lakehouse formats, general-purpose databases, and feature stores, the key takeaways are as follows.

    For most teams: Begin with TimescaleDB for online serving and Parquet + DuckDB for offline ML training. This dual-storage approach covers the vast majority of use cases, uses familiar technology (SQL throughout), costs little or nothing (both are open source), and scales comfortably into the hundreds of gigabytes.

    For high-throughput analytics: ClickHouse or QuestDB deliver exceptional query performance on large datasets. ClickHouse is the more mature option with a broader feature set; QuestDB offers simpler operations with impressive speed.

    For enterprise scale: Apache Iceberg on S3 provides effectively unlimited scale, ACID transactions, schema evolution, and time travel at object-storage prices. It should be paired with a compute engine (Spark, Trino, Athena) for the query layer.

    For real-time ML inference: Redis TimeSeries delivers unmatched latency for feature serving, but it should be used as a cache in front of a more durable store, not as the primary database.

    For simplicity: When the data is under 50 GB and PostgreSQL is already in use, PostgreSQL alone is sufficient. Tables should be partitioned by time, appropriate indexes added, and the complexity of a new technology avoided.

    For teams that require real-time anomaly detection on top of stored data, pairing any of these databases with complex event processing using Apache Flink creates a powerful detect-and-store architecture. The most common mistake engineers make is optimising for the wrong workload. They read benchmarks showing that Database X ingests 4 million rows per second and choose it, only to discover that their preprocessed data is written once and read a thousand times. This error should be avoided. The relevant dimensions are read performance, SQL capabilities, ML integration, and compression for wide tables. These are the criteria that actually matter for preprocessed time-series data.

    Whichever option is chosen, storage decisions are not permanent. The appropriate approach is to begin simply, measure everything, and migrate only when there is evidence that the current solution is the bottleneck. When the time comes to expose the data through an API, building REST APIs with FastAPI provides a fast, type-safe way to serve features to downstream consumers. The best database is the one that allows the team to ship features, not the one with the most impressive benchmark numbers.

    References

  • Domain Adaptation for Time-Series Anomaly Detection: Complete Implementation Guide with Full Training Scripts

    Summary

    What this post covers: A complete, runnable implementation guide for domain-adaptive time-series anomaly detection in PyTorch, comprising nine production-ready scripts that implement DANN, MMD, and CORAL on top of a CNN-LSTM encoder for multi-channel sensor data.

    Key insights:

    • Domain shift between machines, sensors, factories, or seasons can sharply reduce industrial anomaly-detection AUROC — in the illustrative case used throughout this guide, from roughly 0.95 on the source to around 0.6 on the target — and relabeling each new domain is economically infeasible because anomalies are rare.
    • Three domain-adaptation losses cover the practical design space: DANN (adversarial, most flexible), MMD (kernel-based moment matching, simpler and more stable), and CORAL (second-order statistic alignment, with minimal hyperparameter overhead).
    • A CNN-LSTM hybrid encoder with a shared feature extractor and separate anomaly and domain heads is a strong default architecture for multi-channel time series. The CNN captures local waveform shape and the LSTM captures temporal dependencies.
    • Progressive lambda scheduling, in which the domain-adaptation weight is ramped from 0 toward 1 over training, is the single most important training practice. Without it the adversarial signal destabilizes feature learning.
    • Domain adaptation succeeds only when source and target share the same underlying anomaly mechanisms but differ in superficial signal characteristics. Fundamentally different failure modes still require labeled target data through semi-supervised adaptation.

    Main topics: Introduction: The Domain Shift Problem in Anomaly Detection, Project Structure and Setup, Configuration and Hyperparameters, Generating Realistic Synthetic Data, Dataset Classes and Data Loading, The Core Model Architecture, Loss Functions: DANN, MMD, and CORAL, The Main Training Script, Evaluation and Metrics, Utility Functions, Running the Full Pipeline, Understanding the Results, Adapting to Your Own Data, Common Issues and Solutions, Putting It Together, References.

    Introduction: The Domain Shift Problem in Anomaly Detection

    Consider an engineer who has spent six months collecting labeled anomaly data from a CNC milling machine on the factory floor, painstakingly tagging every spindle vibration spike, every thermal drift event, and every bearing degradation signature. The resulting anomaly detection model attains 0.95 AUROC on that machine. The company subsequently acquires a second milling machine from the same manufacturer and model line, differing only in production year. The model is deployed, and the AUROC falls to 0.62—barely better than a coin flip.

    This is the domain shift problem, one of the most costly difficulties in industrial machine learning. The statistical distribution of sensor readings differs between machines, factories, sensor brands, and even seasons. Noise floors vary, baseline amplitudes drift, and the boundary between “normal” and “anomalous” deforms in subtle ways. A carefully trained model becomes essentially unusable the moment it leaves its original domain.

    The conventional solution is to label data in each new domain. However, labeling anomaly data is exceptionally expensive: anomalies are rare by definition, and expert annotators are scarce. A more attractive approach is to transfer anomaly-detection knowledge from a labeled source domain (machine A) to an unlabeled target domain (machine B) without re-collecting labels.

    This is precisely what domain adaptation provides. By training a model to learn features that are invariant across domains—features capturing the essence of “anomaly” regardless of which machine produced the signal—an analyst can detect anomalies in new domains with little or no labeled target data. The technique originated in computer vision through the DANN paper by Ganin et al. (2016), but its application to time-series anomaly detection remains underexplored in practice, even though it is highly relevant to industrial deployment.

    This post is not a theoretical survey. It is a complete, runnable implementation guide. Readers who follow it through will obtain nine production-ready Python scripts that implement three domain adaptation strategies—DANN (Domain-Adversarial Neural Networks), MMD (Maximum Mean Discrepancy), and CORAL (CORrelation ALignment)—on top of a CNN-LSTM hybrid encoder for multi-channel time-series anomaly detection. Every script is complete, with no omissions or pseudocode.

    The implementation proceeds below.

    Domain Shift: Source vs. Target Distribution Source Domain (Machine A—labeled) anomaly Domain Gap Target Domain (Machine B—unlabeled) ? ? Source normal Target normal Known anomaly Unlabeled (anomaly?)

    Project Structure and Setup

    Before writing any code, it is useful to establish a clean project layout. Each file has a single responsibility, which makes the codebase easier to understand and adapt to a specific use case.

    da-anomaly-detection/
    ├── config.py                    # Hyperparameters and configuration
    ├── dataset.py                   # Dataset classes and data loading
    ├── model.py                     # Model architecture (encoder, classifier, discriminator)
    ├── losses.py                    # Loss function definitions (DANN, MMD, CORAL)
    ├── train.py                     # Main training script with domain adaptation
    ├── evaluate.py                  # Evaluation and metrics
    ├── utils.py                     # Utility functions (seeding, checkpoints, plotting)
    ├── generate_synthetic_data.py   # Generate example data for testing
    ├── requirements.txt             # Dependencies
    ├── data/                        # Generated or real data goes here
    ├── checkpoints/                 # Saved model weights
    └── results/                     # Evaluation outputs, plots, metrics

    The first step is to create the directory and install dependencies.

    mkdir -p da-anomaly-detection/{data,checkpoints,results}
    cd da-anomaly-detection

    requirements.txt

    torch>=2.0.0
    numpy>=1.24.0
    pandas>=2.0.0
    scikit-learn>=1.3.0
    matplotlib>=3.7.0
    tqdm>=4.65.0
    pip install -r requirements.txt
    Tip: On systems with a CUDA-capable GPU, install PyTorch with CUDA support for substantially faster training: pip install torch --index-url https://download.pytorch.org/whl/cu121

    Configuration and Hyperparameters

    Centralizing configuration prevents magic numbers from being scattered across the codebase. A Python dataclass is used here so that the IDE provides autocompletion and type checking without additional effort.

    config.py

    """
    config.py — Centralized configuration for domain-adaptive anomaly detection.
    All hyperparameters live here. Override via CLI arguments in train.py.
    """
    
    from dataclasses import dataclass, field
    import torch
    import os
    
    
    @dataclass
    class Config:
        """All hyperparameters and paths for the DA anomaly detection pipeline."""
    
        # --- Data Parameters ---
        num_features: int = 6           # Number of sensor channels
        window_size: int = 64           # Sliding window length (timesteps)
        stride: int = 16                # Stride for sliding window
        train_ratio: float = 0.8        # Train/val split ratio
    
        # --- Model Architecture ---
        cnn_channels: list = field(default_factory=lambda: [32, 64, 128])
        cnn_kernel_sizes: list = field(default_factory=lambda: [7, 5, 3])
        lstm_hidden_dim: int = 128
        lstm_num_layers: int = 2
        latent_dim: int = 128           # Dimension of the shared feature space
        classifier_hidden_dim: int = 64
        discriminator_hidden_dim: int = 64
        dropout: float = 0.3
    
        # --- Training Parameters ---
        batch_size: int = 64
        learning_rate: float = 1e-3
        discriminator_lr: float = 1e-3
        weight_decay: float = 1e-4
        epochs: int = 100
        patience: int = 15              # Early stopping patience
    
        # --- Domain Adaptation Parameters ---
        adaptation_method: str = "dann"  # 'dann', 'mmd', or 'coral'
        lambda_domain: float = 1.0       # Max domain loss weight
        lambda_recon: float = 0.5        # Reconstruction loss weight
        lambda_cls: float = 1.0          # Classification loss weight
        gamma: float = 10.0              # DANN lambda schedule steepness
        mmd_kernel_bandwidth: list = field(
            default_factory=lambda: [0.01, 0.1, 1.0, 10.0, 100.0]
        )
    
        # --- Anomaly Scoring ---
        alpha: float = 0.7              # Weight for classifier score vs recon error
        anomaly_threshold_percentile: float = 95.0
    
        # --- Paths ---
        data_dir: str = "data"
        checkpoint_dir: str = "checkpoints"
        results_dir: str = "results"
    
        # --- Device and Reproducibility ---
        seed: int = 42
        device: str = ""
    
        def __post_init__(self):
            if not self.device:
                self.device = "cuda" if torch.cuda.is_available() else "cpu"
            os.makedirs(self.data_dir, exist_ok=True)
            os.makedirs(self.checkpoint_dir, exist_ok=True)
            os.makedirs(self.results_dir, exist_ok=True)
    Key Takeaway: The most sensitive hyperparameter in domain adaptation is lambda_domain. Too high, and the model loses its ability to classify anomalies. Too low, and domain adaptation has no effect. The progressive scheduling in the training script (the DANN lambda schedule) addresses this by starting low and ramping upward.

    Generating Realistic Synthetic Data

    Before working with proprietary data, a sandbox dataset is necessary. The script below generates two-domain synthetic time-series data with realistic characteristics: seasonal patterns, trends, multiple anomaly types, and domain-specific differences in noise, amplitude, and baseline offset. The source domain receives full labels, the target training set has no labels (which simulates the realistic scenario), and the target test set retains labels for evaluation purposes.

    generate_synthetic_data.py

    """
    generate_synthetic_data.py — Generate realistic two-domain time-series data
    with injected anomalies for testing domain adaptation.
    
    Simulates 6-channel sensor data (e.g., 3 joints x [torque, position]) from
    two different machines with different noise/amplitude characteristics.
    """
    
    import argparse
    import os
    import numpy as np
    import pandas as pd
    
    
    def generate_base_signal(n_samples: int, num_features: int, seed: int = 42) -> np.ndarray:
        """Generate a base multi-channel time-series with realistic patterns."""
        rng = np.random.RandomState(seed)
        t = np.arange(n_samples)
        signals = np.zeros((n_samples, num_features))
    
        for ch in range(num_features):
            freq1 = 0.002 + ch * 0.001
            freq2 = 0.01 + ch * 0.003
            phase1 = rng.uniform(0, 2 * np.pi)
            phase2 = rng.uniform(0, 2 * np.pi)
    
            # Seasonal component
            seasonal = 2.0 * np.sin(2 * np.pi * freq1 * t + phase1)
            # Higher-frequency oscillation
            oscillation = 0.8 * np.sin(2 * np.pi * freq2 * t + phase2)
            # Slow trend
            trend = 0.0005 * t * ((-1) ** ch)
            # Combine
            signals[:, ch] = seasonal + oscillation + trend
    
        return signals
    
    
    def inject_anomalies(
        signals: np.ndarray,
        anomaly_ratio: float = 0.05,
        seed: int = 42
    ) -> tuple:
        """
        Inject multiple anomaly types into signals.
        Returns (modified_signals, labels) where labels[i]=1 means anomaly.
        """
        rng = np.random.RandomState(seed)
        n_samples, num_features = signals.shape
        labels = np.zeros(n_samples, dtype=int)
        modified = signals.copy()
    
        n_anomalies = int(n_samples * anomaly_ratio)
        anomaly_types = ["spike", "drift", "level_shift", "frequency_change"]
    
        # Choose random anomaly locations (non-overlapping segments)
        segment_length = 20
        max_start = n_samples - segment_length
        starts = rng.choice(max_start, size=n_anomalies, replace=False)
    
        for i, start in enumerate(starts):
            end = start + segment_length
            a_type = anomaly_types[i % len(anomaly_types)]
            channel = rng.randint(0, num_features)
    
            if a_type == "spike":
                spike_pos = start + rng.randint(0, segment_length)
                magnitude = rng.uniform(5, 10) * (1 if rng.random() > 0.5 else -1)
                modified[spike_pos, channel] += magnitude
                labels[spike_pos] = 1
    
            elif a_type == "drift":
                drift = np.linspace(0, rng.uniform(3, 6), segment_length)
                modified[start:end, channel] += drift
                labels[start:end] = 1
    
            elif a_type == "level_shift":
                shift = rng.uniform(3, 7) * (1 if rng.random() > 0.5 else -1)
                modified[start:end, channel] += shift
                labels[start:end] = 1
    
            elif a_type == "frequency_change":
                t_seg = np.arange(segment_length)
                high_freq = 2.0 * np.sin(2 * np.pi * 0.15 * t_seg)
                modified[start:end, channel] += high_freq
                labels[start:end] = 1
    
        return modified, labels
    
    
    def apply_domain_transform(
        signals: np.ndarray,
        noise_scale: float = 0.3,
        amplitude_scale: float = 1.0,
        baseline_offset: float = 0.0,
        seed: int = 42
    ) -> np.ndarray:
        """Apply domain-specific transformations to simulate a different machine."""
        rng = np.random.RandomState(seed)
        transformed = signals.copy()
        n_samples, num_features = transformed.shape
    
        # Per-channel amplitude scaling
        for ch in range(num_features):
            ch_amp = amplitude_scale * rng.uniform(0.8, 1.2)
            ch_offset = baseline_offset + rng.uniform(-0.5, 0.5)
            transformed[:, ch] = transformed[:, ch] * ch_amp + ch_offset
    
        # Add domain-specific noise
        noise = rng.normal(0, noise_scale, transformed.shape)
        transformed += noise
    
        return transformed
    
    
    def generate_dataset(
        n_samples: int,
        num_features: int,
        anomaly_ratio: float,
        noise_scale: float,
        amplitude_scale: float,
        baseline_offset: float,
        seed: int
    ) -> pd.DataFrame:
        """Generate a complete dataset with signals, anomalies, and domain transform."""
        base = generate_base_signal(n_samples, num_features, seed=seed)
        with_anomalies, labels = inject_anomalies(base, anomaly_ratio, seed=seed + 1)
        transformed = apply_domain_transform(
            with_anomalies,
            noise_scale=noise_scale,
            amplitude_scale=amplitude_scale,
            baseline_offset=baseline_offset,
            seed=seed + 2
        )
    
        columns = [f"sensor_{i}" for i in range(num_features)]
        df = pd.DataFrame(transformed, columns=columns)
        df["label"] = labels
        df["timestamp"] = pd.date_range("2024-01-01", periods=n_samples, freq="s")
        return df
    
    
    def main():
        parser = argparse.ArgumentParser(
            description="Generate synthetic two-domain time-series data."
        )
        parser.add_argument("--output_dir", type=str, default="data",
                            help="Output directory for CSV files")
        parser.add_argument("--n_samples", type=int, default=20000,
                            help="Number of samples per dataset")
        parser.add_argument("--num_features", type=int, default=6,
                            help="Number of sensor channels")
        parser.add_argument("--anomaly_ratio", type=float, default=0.05,
                            help="Fraction of timesteps with anomalies")
        parser.add_argument("--seed", type=int, default=42,
                            help="Random seed")
        args = parser.parse_args()
    
        os.makedirs(args.output_dir, exist_ok=True)
    
        print("Generating source domain data (Machine A)...")
        source_full = generate_dataset(
            n_samples=args.n_samples,
            num_features=args.num_features,
            anomaly_ratio=args.anomaly_ratio,
            noise_scale=0.2,
            amplitude_scale=1.0,
            baseline_offset=0.0,
            seed=args.seed
        )
        split_idx = int(len(source_full) * 0.7)
        source_train = source_full.iloc[:split_idx].reset_index(drop=True)
        source_test = source_full.iloc[split_idx:].reset_index(drop=True)
    
        print("Generating target domain data (Machine B)...")
        target_full = generate_dataset(
            n_samples=args.n_samples,
            num_features=args.num_features,
            anomaly_ratio=args.anomaly_ratio,
            noise_scale=0.5,           # Higher noise
            amplitude_scale=1.4,       # Different amplitude
            baseline_offset=2.0,       # Shifted baseline
            seed=args.seed + 100
        )
        split_idx_t = int(len(target_full) * 0.7)
        target_train = target_full.iloc[:split_idx_t].reset_index(drop=True)
        target_test = target_full.iloc[split_idx_t:].reset_index(drop=True)
    
        # Remove labels from target train (unsupervised in target domain)
        target_train_unlabeled = target_train.drop(columns=["label"])
    
        # Save all files
        source_train.to_csv(os.path.join(args.output_dir, "source_train.csv"), index=False)
        source_test.to_csv(os.path.join(args.output_dir, "source_test.csv"), index=False)
        target_train_unlabeled.to_csv(os.path.join(args.output_dir, "target_train.csv"), index=False)
        target_test.to_csv(os.path.join(args.output_dir, "target_test.csv"), index=False)
    
        print(f"\nDatasets saved to {args.output_dir}/")
        print(f"  source_train.csv: {len(source_train)} samples, "
              f"{source_train['label'].sum()} anomalies ({source_train['label'].mean()*100:.1f}%)")
        print(f"  source_test.csv:  {len(source_test)} samples, "
              f"{source_test['label'].sum()} anomalies ({source_test['label'].mean()*100:.1f}%)")
        print(f"  target_train.csv: {len(target_train_unlabeled)} samples (no labels)")
        print(f"  target_test.csv:  {len(target_test)} samples, "
              f"{target_test['label'].sum()} anomalies ({target_test['label'].mean()*100:.1f}%)")
    
    
    if __name__ == "__main__":
        main()

    The script can be executed directly.

    python generate_synthetic_data.py --output_dir data/ --n_samples 20000

    The script produces four CSV files. The source data is fully labeled. The target training data is unlabeled, which reflects the central premise of domain adaptation. The target test data is labeled so that the effectiveness of adaptation can be measured.

    Dataset Classes and Data Loading

    Time-series anomaly detection operates on windows, that is, fixed-length slices of the signal. The dataset class below handles windowing, normalization (fit on source data and applied across all data), and optional data augmentation. The DomainAdaptationDataLoader pairs source and target batches for simultaneous training.

    dataset.py

    """
    dataset.py — PyTorch Dataset classes for time-series domain adaptation.
    
    Handles sliding-window creation, normalization, augmentation, and
    paired source-target batch generation.
    """
    
    import numpy as np
    import pandas as pd
    import torch
    from torch.utils.data import Dataset, DataLoader
    
    
    class TimeSeriesDataset(Dataset):
        """
        Sliding-window dataset for multi-channel time-series.
    
        Args:
            data: numpy array of shape (n_samples, num_features)
            labels: numpy array of shape (n_samples,) or None for unlabeled data
            window_size: number of timesteps per window
            stride: step between consecutive windows
            transform: optional callable for data augmentation
        """
    
        def __init__(
            self,
            data: np.ndarray,
            labels: np.ndarray = None,
            window_size: int = 64,
            stride: int = 16,
            transform=None
        ):
            self.data = data.astype(np.float32)
            self.labels = labels
            self.window_size = window_size
            self.stride = stride
            self.transform = transform
    
            # Precompute valid window start indices
            self.indices = list(range(0, len(data) - window_size + 1, stride))
    
        def __len__(self):
            return len(self.indices)
    
        def __getitem__(self, idx):
            start = self.indices[idx]
            end = start + self.window_size
            window = self.data[start:end]  # (window_size, num_features)
    
            if self.transform is not None:
                window = self.transform(window)
    
            # Transpose to (num_features, window_size) for Conv1d
            window_tensor = torch.tensor(window, dtype=torch.float32).T
    
            if self.labels is not None:
                # Window label = 1 if any timestep in window is anomalous
                window_label = float(self.labels[start:end].max())
                return window_tensor, torch.tensor(window_label, dtype=torch.float32)
            else:
                return window_tensor, torch.tensor(-1.0, dtype=torch.float32)
    
    
    class Normalizer:
        """
        Fit on source training data, transform all data.
        Uses per-channel mean and std normalization.
        """
    
        def __init__(self):
            self.mean = None
            self.std = None
    
        def fit(self, data: np.ndarray):
            """Compute mean and std from training data."""
            self.mean = data.mean(axis=0)
            self.std = data.std(axis=0)
            # Prevent division by zero
            self.std[self.std < 1e-8] = 1.0
            return self
    
        def transform(self, data: np.ndarray) -> np.ndarray:
            """Apply normalization."""
            return (data - self.mean) / self.std
    
        def fit_transform(self, data: np.ndarray) -> np.ndarray:
            """Fit and transform in one step."""
            self.fit(data)
            return self.transform(data)
    
    
    class JitterTransform:
        """Add random Gaussian noise for data augmentation."""
    
        def __init__(self, sigma: float = 0.03):
            self.sigma = sigma
    
        def __call__(self, window: np.ndarray) -> np.ndarray:
            noise = np.random.normal(0, self.sigma, window.shape).astype(np.float32)
            return window + noise
    
    
    class ScalingTransform:
        """Random per-channel amplitude scaling for data augmentation."""
    
        def __init__(self, sigma: float = 0.1):
            self.sigma = sigma
    
        def __call__(self, window: np.ndarray) -> np.ndarray:
            factor = np.random.normal(1.0, self.sigma, (1, window.shape[1])).astype(np.float32)
            return window * factor
    
    
    class ComposeTransforms:
        """Chain multiple transforms together."""
    
        def __init__(self, transforms: list):
            self.transforms = transforms
    
        def __call__(self, window: np.ndarray) -> np.ndarray:
            for t in self.transforms:
                window = t(window)
            return window
    
    
    def load_csv_data(filepath: str, has_labels: bool = True):
        """
        Load a CSV file and separate features from labels.
    
        Returns:
            data: numpy array (n_samples, num_features)
            labels: numpy array (n_samples,) or None
        """
        df = pd.read_csv(filepath)
        # Drop non-numeric columns like timestamp
        feature_cols = [c for c in df.columns if c not in ("label", "timestamp")]
        data = df[feature_cols].values.astype(np.float32)
        labels = df["label"].values.astype(np.float32) if (has_labels and "label" in df.columns) else None
        return data, labels
    
    
    def create_data_loaders(config) -> dict:
        """
        Create all data loaders for domain adaptation training.
    
        Returns a dict with keys:
            'source_train', 'source_val', 'target_train', 'target_test'
        """
        import os
    
        # Load raw data
        source_train_data, source_train_labels = load_csv_data(
            os.path.join(config.data_dir, "source_train.csv"), has_labels=True
        )
        source_test_data, source_test_labels = load_csv_data(
            os.path.join(config.data_dir, "source_test.csv"), has_labels=True
        )
        target_train_data, _ = load_csv_data(
            os.path.join(config.data_dir, "target_train.csv"), has_labels=False
        )
        target_test_data, target_test_labels = load_csv_data(
            os.path.join(config.data_dir, "target_test.csv"), has_labels=True
        )
    
        # Normalize: fit on source train only
        normalizer = Normalizer()
        source_train_data = normalizer.fit_transform(source_train_data)
        source_test_data = normalizer.transform(source_test_data)
        target_train_data = normalizer.transform(target_train_data)
        target_test_data = normalizer.transform(target_test_data)
    
        # Optional augmentation for training
        train_transform = ComposeTransforms([
            JitterTransform(sigma=0.03),
            ScalingTransform(sigma=0.1),
        ])
    
        # Create datasets
        source_train_ds = TimeSeriesDataset(
            source_train_data, source_train_labels,
            window_size=config.window_size, stride=config.stride,
            transform=train_transform
        )
        source_test_ds = TimeSeriesDataset(
            source_test_data, source_test_labels,
            window_size=config.window_size, stride=config.stride
        )
        target_train_ds = TimeSeriesDataset(
            target_train_data, labels=None,
            window_size=config.window_size, stride=config.stride,
            transform=train_transform
        )
        target_test_ds = TimeSeriesDataset(
            target_test_data, target_test_labels,
            window_size=config.window_size, stride=config.stride
        )
    
        # Create loaders
        loaders = {
            "source_train": DataLoader(
                source_train_ds, batch_size=config.batch_size,
                shuffle=True, drop_last=True, num_workers=0
            ),
            "source_test": DataLoader(
                source_test_ds, batch_size=config.batch_size,
                shuffle=False, num_workers=0
            ),
            "target_train": DataLoader(
                target_train_ds, batch_size=config.batch_size,
                shuffle=True, drop_last=True, num_workers=0
            ),
            "target_test": DataLoader(
                target_test_ds, batch_size=config.batch_size,
                shuffle=False, num_workers=0
            ),
        }
    
        return loaders, normalizer
    Caution: The normalizer should always be fit on the source training data alone. Fitting on combined source and target data leaks information about the target distribution, defeats the purpose of domain adaptation, and inflates evaluation metrics.

    The Core Model Architecture

    The model architecture lies at the heart of the system. It comprises four components that operate in concert: a shared encoder that processes time-series windows into a fixed-size feature vector; an anomaly classifier that predicts normal versus anomaly; a reconstruction decoder that reconstructs the original input and provides an auxiliary anomaly signal; and a domain discriminator that attempts to identify which domain produced a given feature vector. The essential ingredient is the Gradient Reversal Layer (GRL), which during backpropagation reverses the sign of gradients flowing from the domain discriminator to the encoder. This compels the encoder to learn features that are maximally uninformative about domain identity, which is precisely the domain-invariant representation required.

    Architecture:
                            ┌─── Anomaly Classifier (binary: normal/anomaly)
    Input → Shared Encoder ─┤
      (time-series)         ├─── Reconstruction Decoder (autoencoder branch)
                            └─── Domain Discriminator (with gradient reversal)

    model.py

    """
    model.py — Domain-adaptive anomaly detection model architecture.
    
    Components:
      - GradientReversalLayer: reverses gradients for adversarial domain adaptation
      - SharedEncoder: CNN + BiLSTM feature extractor
      - AnomalyClassifier: binary classification head
      - ReconstructionDecoder: autoencoder branch for reconstruction-based scoring
      - DomainDiscriminator: adversarial domain classification head
      - DomainAdaptiveAnomalyDetector: full model combining all components
    """
    
    import torch
    import torch.nn as nn
    from torch.autograd import Function
    
    
    class GradientReversalFunction(Function):
        """
        Gradient Reversal Layer (GRL) — Ganin et al., 2016.
        Forward pass: identity.
        Backward pass: negate gradients and scale by lambda.
        """
    
        @staticmethod
        def forward(ctx, x, lambda_val):
            ctx.lambda_val = lambda_val
            return x.clone()
    
        @staticmethod
        def backward(ctx, grad_output):
            return -ctx.lambda_val * grad_output, None
    
    
    class GradientReversalLayer(nn.Module):
        """Module wrapper for the gradient reversal function."""
    
        def __init__(self, lambda_val: float = 1.0):
            super().__init__()
            self.lambda_val = lambda_val
    
        def set_lambda(self, lambda_val: float):
            self.lambda_val = lambda_val
    
        def forward(self, x):
            return GradientReversalFunction.apply(x, self.lambda_val)
    
    
    class SharedEncoder(nn.Module):
        """
        1D-CNN + Bidirectional LSTM encoder for multi-channel time-series.
    
        Input shape:  (batch, num_features, window_size)
        Output shape: (batch, latent_dim)
        """
    
        def __init__(
            self,
            num_features: int = 6,
            cnn_channels: list = None,
            cnn_kernel_sizes: list = None,
            lstm_hidden_dim: int = 128,
            lstm_num_layers: int = 2,
            latent_dim: int = 128,
            dropout: float = 0.3,
        ):
            super().__init__()
            if cnn_channels is None:
                cnn_channels = [32, 64, 128]
            if cnn_kernel_sizes is None:
                cnn_kernel_sizes = [7, 5, 3]
    
            # Build CNN layers
            cnn_layers = []
            in_channels = num_features
            for out_ch, ks in zip(cnn_channels, cnn_kernel_sizes):
                cnn_layers.extend([
                    nn.Conv1d(in_channels, out_ch, kernel_size=ks, padding=ks // 2),
                    nn.BatchNorm1d(out_ch),
                    nn.ReLU(inplace=True),
                    nn.Dropout(dropout),
                ])
                in_channels = out_ch
            self.cnn = nn.Sequential(*cnn_layers)
    
            # Bidirectional LSTM on top of CNN features
            self.lstm = nn.LSTM(
                input_size=cnn_channels[-1],
                hidden_size=lstm_hidden_dim,
                num_layers=lstm_num_layers,
                batch_first=True,
                bidirectional=True,
                dropout=dropout if lstm_num_layers > 1 else 0.0,
            )
    
            # Project to latent space
            self.fc = nn.Sequential(
                nn.Linear(lstm_hidden_dim * 2, latent_dim),
                nn.ReLU(inplace=True),
                nn.Dropout(dropout),
            )
            self.latent_dim = latent_dim
    
        def forward(self, x):
            """
            Args:
                x: (batch, num_features, window_size)
            Returns:
                latent: (batch, latent_dim)
            """
            # CNN: (batch, cnn_channels[-1], window_size)
            cnn_out = self.cnn(x)
            # Transpose for LSTM: (batch, window_size, cnn_channels[-1])
            lstm_in = cnn_out.permute(0, 2, 1)
            # LSTM: (batch, window_size, lstm_hidden*2)
            lstm_out, _ = self.lstm(lstm_in)
            # Take last timestep output
            last_hidden = lstm_out[:, -1, :]
            # Project to latent space
            latent = self.fc(last_hidden)
            return latent
    
    
    class AnomalyClassifier(nn.Module):
        """
        Binary classification head: normal (0) vs anomaly (1).
    
        Input:  (batch, latent_dim)
        Output: (batch, 1) — sigmoid logit
        """
    
        def __init__(self, latent_dim: int = 128, hidden_dim: int = 64, dropout: float = 0.3):
            super().__init__()
            self.net = nn.Sequential(
                nn.Linear(latent_dim, hidden_dim),
                nn.ReLU(inplace=True),
                nn.Dropout(dropout),
                nn.Linear(hidden_dim, hidden_dim // 2),
                nn.ReLU(inplace=True),
                nn.Dropout(dropout),
                nn.Linear(hidden_dim // 2, 1),
            )
    
        def forward(self, latent):
            return self.net(latent)
    
    
    class ReconstructionDecoder(nn.Module):
        """
        Decoder that reconstructs the original input from latent features.
        Uses LSTM + transposed Conv1d layers.
    
        Input:  (batch, latent_dim)
        Output: (batch, num_features, window_size)
        """
    
        def __init__(
            self,
            latent_dim: int = 128,
            num_features: int = 6,
            window_size: int = 64,
            lstm_hidden_dim: int = 128,
            dropout: float = 0.3,
        ):
            super().__init__()
            self.window_size = window_size
            self.num_features = num_features
            self.lstm_hidden_dim = lstm_hidden_dim
    
            # Expand latent to sequence
            self.fc = nn.Sequential(
                nn.Linear(latent_dim, lstm_hidden_dim),
                nn.ReLU(inplace=True),
            )
    
            # LSTM decoder
            self.lstm = nn.LSTM(
                input_size=lstm_hidden_dim,
                hidden_size=lstm_hidden_dim,
                num_layers=1,
                batch_first=True,
            )
    
            # Transposed convolutions to reconstruct
            self.deconv = nn.Sequential(
                nn.ConvTranspose1d(lstm_hidden_dim, 64, kernel_size=3, padding=1),
                nn.BatchNorm1d(64),
                nn.ReLU(inplace=True),
                nn.Dropout(dropout),
                nn.ConvTranspose1d(64, 32, kernel_size=3, padding=1),
                nn.BatchNorm1d(32),
                nn.ReLU(inplace=True),
                nn.ConvTranspose1d(32, num_features, kernel_size=3, padding=1),
            )
    
        def forward(self, latent):
            """
            Args:
                latent: (batch, latent_dim)
            Returns:
                reconstruction: (batch, num_features, window_size)
            """
            batch_size = latent.size(0)
            # Expand to sequence
            expanded = self.fc(latent).unsqueeze(1).repeat(1, self.window_size, 1)
            # LSTM decode
            lstm_out, _ = self.lstm(expanded)
            # Transpose for Conv1d: (batch, lstm_hidden, window_size)
            conv_in = lstm_out.permute(0, 2, 1)
            # Reconstruct
            reconstruction = self.deconv(conv_in)
            return reconstruction
    
    
    class DomainDiscriminator(nn.Module):
        """
        Domain classification head with Gradient Reversal Layer.
        Classifies whether features came from source (0) or target (1) domain.
    
        Input:  (batch, latent_dim)
        Output: (batch, 1) — domain logit
        """
    
        def __init__(self, latent_dim: int = 128, hidden_dim: int = 64, dropout: float = 0.3):
            super().__init__()
            self.grl = GradientReversalLayer(lambda_val=1.0)
            self.net = nn.Sequential(
                nn.Linear(latent_dim, hidden_dim),
                nn.ReLU(inplace=True),
                nn.Dropout(dropout),
                nn.Linear(hidden_dim, hidden_dim // 2),
                nn.ReLU(inplace=True),
                nn.Dropout(dropout),
                nn.Linear(hidden_dim // 2, 1),
            )
    
        def set_lambda(self, lambda_val: float):
            self.grl.set_lambda(lambda_val)
    
        def forward(self, latent):
            reversed_features = self.grl(latent)
            return self.net(reversed_features)
    
    
    class DomainAdaptiveAnomalyDetector(nn.Module):
        """
        Full domain-adaptive anomaly detection model.
        Combines encoder, anomaly classifier, reconstruction decoder,
        and domain discriminator.
        """
    
        def __init__(self, config):
            super().__init__()
            self.encoder = SharedEncoder(
                num_features=config.num_features,
                cnn_channels=config.cnn_channels,
                cnn_kernel_sizes=config.cnn_kernel_sizes,
                lstm_hidden_dim=config.lstm_hidden_dim,
                lstm_num_layers=config.lstm_num_layers,
                latent_dim=config.latent_dim,
                dropout=config.dropout,
            )
            self.classifier = AnomalyClassifier(
                latent_dim=config.latent_dim,
                hidden_dim=config.classifier_hidden_dim,
                dropout=config.dropout,
            )
            self.decoder = ReconstructionDecoder(
                latent_dim=config.latent_dim,
                num_features=config.num_features,
                window_size=config.window_size,
                lstm_hidden_dim=config.lstm_hidden_dim,
                dropout=config.dropout,
            )
            self.discriminator = DomainDiscriminator(
                latent_dim=config.latent_dim,
                hidden_dim=config.discriminator_hidden_dim,
                dropout=config.dropout,
            )
    
        def set_domain_lambda(self, lambda_val: float):
            """Update the GRL lambda for progressive scheduling."""
            self.discriminator.set_lambda(lambda_val)
    
        def forward(self, x):
            """
            Full forward pass.
    
            Args:
                x: (batch, num_features, window_size)
    
            Returns:
                anomaly_logits:  (batch, 1) — raw logits for anomaly classification
                reconstruction:  (batch, num_features, window_size) — reconstructed input
                domain_logits:   (batch, 1) — raw logits for domain classification
                latent_features: (batch, latent_dim) — shared latent representation
            """
            latent = self.encoder(x)
            anomaly_logits = self.classifier(latent)
            reconstruction = self.decoder(latent)
            domain_logits = self.discriminator(latent)
            return anomaly_logits, reconstruction, domain_logits, latent
    Key Takeaway: The Gradient Reversal Layer consists of only two lines of custom autograd code, yet it constitutes the entire mechanism that makes DANN function. The forward pass is the identity. The backward pass negates the gradient. This simple operation converts a standard domain classifier into an adversarial training signal that compels the encoder to produce domain-invariant features.

    Loss Functions: DANN, MMD, and CORAL

    Domain adaptation is not a single technique but a family of techniques, each with distinct strengths. The implementation below supports three approaches selectable through a single configuration flag. DANN uses adversarial training based on the discriminator. MMD directly minimizes the statistical distance between source and target feature distributions through a kernel formulation. CORAL aligns the second-order statistics (covariance matrices) of the two domains. Switching between the methods requires a single configuration change.

    losses.py

    """
    losses.py — Loss functions for domain-adaptive anomaly detection.
    
    Includes:
      - AnomalyDetectionLoss (BCE for anomaly classification)
      - ReconstructionLoss (MSE for autoencoder)
      - DomainAdversarialLoss (BCE for domain discrimination)
      - MMDLoss (Maximum Mean Discrepancy with Gaussian kernel)
      - CORALLoss (CORrelation ALignment)
      - CombinedLoss (weighted combination of all losses)
    """
    
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    
    class AnomalyDetectionLoss(nn.Module):
        """Binary cross-entropy loss for anomaly classification."""
    
        def __init__(self):
            super().__init__()
            self.bce = nn.BCEWithLogitsLoss()
    
        def forward(self, logits, labels):
            """
            Args:
                logits: (batch, 1) raw anomaly logits
                labels: (batch,) binary labels (0=normal, 1=anomaly)
            """
            return self.bce(logits.squeeze(-1), labels)
    
    
    class ReconstructionLoss(nn.Module):
        """MSE loss between input and reconstruction."""
    
        def __init__(self):
            super().__init__()
            self.mse = nn.MSELoss()
    
        def forward(self, reconstruction, original):
            """
            Args:
                reconstruction: (batch, num_features, window_size)
                original: (batch, num_features, window_size)
            """
            return self.mse(reconstruction, original)
    
    
    class DomainAdversarialLoss(nn.Module):
        """BCE loss for domain classification (used with GRL for DANN)."""
    
        def __init__(self):
            super().__init__()
            self.bce = nn.BCEWithLogitsLoss()
    
        def forward(self, domain_logits, domain_labels):
            """
            Args:
                domain_logits: (batch, 1) raw domain logits
                domain_labels: (batch,) domain labels (0=source, 1=target)
            """
            return self.bce(domain_logits.squeeze(-1), domain_labels)
    
    
    class MMDLoss(nn.Module):
        """
        Maximum Mean Discrepancy loss with multi-scale Gaussian kernel.
    
        Measures the distance between source and target feature distributions
        in a reproducing kernel Hilbert space (RKHS).
        """
    
        def __init__(self, kernel_bandwidths: list = None):
            super().__init__()
            if kernel_bandwidths is None:
                self.kernel_bandwidths = [0.01, 0.1, 1.0, 10.0, 100.0]
            else:
                self.kernel_bandwidths = kernel_bandwidths
    
        def gaussian_kernel(self, x, y):
            """
            Compute multi-scale Gaussian kernel matrix between x and y.
    
            Args:
                x: (n, d) tensor
                y: (m, d) tensor
            Returns:
                kernel_val: scalar — sum of Gaussian kernel values across bandwidths
            """
            # Pairwise squared distances
            xx = torch.mm(x, x.t())
            yy = torch.mm(y, y.t())
            xy = torch.mm(x, y.t())
    
            rx = xx.diag().unsqueeze(0).expand_as(xx)
            ry = yy.diag().unsqueeze(0).expand_as(yy)
    
            dxx = rx.t() + rx - 2.0 * xx
            dyy = ry.t() + ry - 2.0 * yy
            dxy = rx.t() + ry - 2.0 * xy
    
            k_xx = torch.zeros_like(xx)
            k_yy = torch.zeros_like(yy)
            k_xy = torch.zeros_like(xy)
    
            for bw in self.kernel_bandwidths:
                k_xx += torch.exp(-dxx / (2.0 * bw))
                k_yy += torch.exp(-dyy / (2.0 * bw))
                k_xy += torch.exp(-dxy / (2.0 * bw))
    
            return k_xx, k_yy, k_xy
    
        def forward(self, source_features, target_features):
            """
            Compute MMD^2 between source and target feature distributions.
    
            Args:
                source_features: (n, d) latent features from source domain
                target_features:  (m, d) latent features from target domain
            Returns:
                mmd_loss: scalar
            """
            n = source_features.size(0)
            m = target_features.size(0)
    
            k_xx, k_yy, k_xy = self.gaussian_kernel(source_features, target_features)
    
            mmd = (k_xx.sum() / (n * n)
                   + k_yy.sum() / (m * m)
                   - 2.0 * k_xy.sum() / (n * m))
    
            return mmd
    
    
    class CORALLoss(nn.Module):
        """
        CORrelation ALignment loss.
    
        Aligns the second-order statistics (covariance matrices) of
        source and target feature distributions.
        """
    
        def __init__(self):
            super().__init__()
    
        def forward(self, source_features, target_features):
            """
            Compute CORAL loss.
    
            Args:
                source_features: (n, d) latent features from source domain
                target_features:  (m, d) latent features from target domain
            Returns:
                coral_loss: scalar
            """
            d = source_features.size(1)
            n_s = source_features.size(0)
            n_t = target_features.size(0)
    
            # Compute covariance matrices
            source_centered = source_features - source_features.mean(dim=0, keepdim=True)
            target_centered = target_features - target_features.mean(dim=0, keepdim=True)
    
            cov_source = (source_centered.t() @ source_centered) / (n_s - 1)
            cov_target = (target_centered.t() @ target_centered) / (n_t - 1)
    
            # Frobenius norm of covariance difference
            diff = cov_source - cov_target
            coral_loss = (diff * diff).sum() / (4 * d * d)
    
            return coral_loss
    
    
    class CombinedLoss(nn.Module):
        """
        Combines anomaly detection, reconstruction, and domain adaptation losses.
    
        total_loss = lambda_cls * anomaly_loss
                   + lambda_recon * recon_loss
                   + lambda_domain * domain_loss
    
        The domain_loss component uses DANN, MMD, or CORAL depending on config.
        """
    
        def __init__(self, config):
            super().__init__()
            self.anomaly_loss_fn = AnomalyDetectionLoss()
            self.recon_loss_fn = ReconstructionLoss()
            self.dann_loss_fn = DomainAdversarialLoss()
            self.mmd_loss_fn = MMDLoss(kernel_bandwidths=config.mmd_kernel_bandwidth)
            self.coral_loss_fn = CORALLoss()
    
            self.lambda_cls = config.lambda_cls
            self.lambda_recon = config.lambda_recon
            self.lambda_domain = config.lambda_domain
            self.method = config.adaptation_method
    
        def forward(
            self,
            anomaly_logits,
            anomaly_labels,
            reconstruction,
            original,
            domain_logits=None,
            domain_labels=None,
            source_features=None,
            target_features=None,
            current_lambda=None,
        ):
            """
            Compute combined loss.
    
            Args:
                anomaly_logits: (batch, 1) anomaly classification logits (source only)
                anomaly_labels: (batch,) anomaly labels (source only)
                reconstruction: (batch, num_features, window_size) reconstruction
                original: (batch, num_features, window_size) original input
                domain_logits: (batch, 1) domain logits (DANN only)
                domain_labels: (batch,) domain labels (DANN only)
                source_features: (n, d) source latent features (MMD/CORAL)
                target_features: (m, d) target latent features (MMD/CORAL)
                current_lambda: float — current domain adaptation weight
    
            Returns:
                total_loss, loss_dict (breakdown of individual losses)
            """
            domain_weight = current_lambda if current_lambda is not None else self.lambda_domain
    
            # Anomaly classification loss (source only)
            cls_loss = self.anomaly_loss_fn(anomaly_logits, anomaly_labels)
    
            # Reconstruction loss (both domains)
            recon_loss = self.recon_loss_fn(reconstruction, original)
    
            # Domain adaptation loss
            if self.method == "dann" and domain_logits is not None:
                domain_loss = self.dann_loss_fn(domain_logits, domain_labels)
            elif self.method == "mmd" and source_features is not None:
                domain_loss = self.mmd_loss_fn(source_features, target_features)
            elif self.method == "coral" and source_features is not None:
                domain_loss = self.coral_loss_fn(source_features, target_features)
            else:
                domain_loss = torch.tensor(0.0, device=anomaly_logits.device)
    
            total_loss = (
                self.lambda_cls * cls_loss
                + self.lambda_recon * recon_loss
                + domain_weight * domain_loss
            )
    
            loss_dict = {
                "total": total_loss.item(),
                "classification": cls_loss.item(),
                "reconstruction": recon_loss.item(),
                "domain": domain_loss.item(),
            }
    
            return total_loss, loss_dict

    The Main Training Script

    The training script integrates the entire system. The training loop coordinates the simultaneous optimization of the anomaly classifier on labeled source data, the reconstruction decoder on both domains, and the domain discriminator (adversarially) on both domains. The DANN lambda schedule progressively increases the strength of domain adaptation across training, following the formula from the original paper: λp = 2 / (1 + exp(-γ · p)) - 1, where p denotes training progress from 0 to 1.

    train.py

    """
    train.py — Main training script for domain-adaptive anomaly detection.
    
    Supports three adaptation methods: DANN, MMD, CORAL.
    Uses progressive lambda scheduling for stable training.
    """
    
    import argparse
    import os
    import time
    import numpy as np
    import torch
    import torch.nn as nn
    from torch.optim import Adam
    from torch.optim.lr_scheduler import CosineAnnealingLR
    from tqdm import tqdm
    
    from config import Config
    from dataset import create_data_loaders
    from model import DomainAdaptiveAnomalyDetector
    from losses import CombinedLoss
    from utils import (
        set_seed,
        EarlyStopping,
        save_checkpoint,
        MetricLogger,
    )
    
    
    def compute_dann_lambda(epoch: int, total_epochs: int, gamma: float = 10.0) -> float:
        """
        Progressive lambda schedule from the DANN paper (Ganin et al., 2016).
        Ramps from 0 to 1 over training using a sigmoid-like schedule.
    
        lambda_p = 2 / (1 + exp(-gamma * p)) - 1, where p = epoch / total_epochs
        """
        p = epoch / total_epochs
        return float(2.0 / (1.0 + np.exp(-gamma * p)) - 1.0)
    
    
    def train_one_epoch(
        model,
        source_loader,
        target_loader,
        criterion,
        optimizer,
        device,
        epoch,
        total_epochs,
        config,
    ):
        """Train for one epoch with domain adaptation."""
        model.train()
        epoch_losses = {"total": 0, "classification": 0, "reconstruction": 0, "domain": 0}
        n_batches = 0
    
        # Compute current domain adaptation lambda
        current_lambda = compute_dann_lambda(epoch, total_epochs, config.gamma) * config.lambda_domain
    
        # Set the GRL lambda in the model
        model.set_domain_lambda(current_lambda)
    
        # Zip source and target loaders (cycle the shorter one)
        target_iter = iter(target_loader)
    
        for source_batch, source_labels in source_loader:
            # Get target batch (cycle if exhausted)
            try:
                target_batch, _ = next(target_iter)
            except StopIteration:
                target_iter = iter(target_loader)
                target_batch, _ = next(target_iter)
    
            source_batch = source_batch.to(device)
            source_labels = source_labels.to(device)
            target_batch = target_batch.to(device)
    
            # Determine actual batch sizes (may differ)
            bs_s = source_batch.size(0)
            bs_t = target_batch.size(0)
    
            # Forward pass: source domain
            s_anomaly_logits, s_recon, s_domain_logits, s_latent = model(source_batch)
    
            # Forward pass: target domain
            t_anomaly_logits, t_recon, t_domain_logits, t_latent = model(target_batch)
    
            # Combine reconstructions and originals for loss
            all_recon = torch.cat([s_recon, t_recon], dim=0)
            all_original = torch.cat([source_batch, target_batch], dim=0)
    
            # Domain labels: 0 for source, 1 for target
            domain_labels = torch.cat([
                torch.zeros(bs_s, device=device),
                torch.ones(bs_t, device=device),
            ])
            all_domain_logits = torch.cat([s_domain_logits, t_domain_logits], dim=0)
    
            # Compute combined loss
            total_loss, loss_dict = criterion(
                anomaly_logits=s_anomaly_logits,
                anomaly_labels=source_labels,
                reconstruction=all_recon,
                original=all_original,
                domain_logits=all_domain_logits,
                domain_labels=domain_labels,
                source_features=s_latent,
                target_features=t_latent,
                current_lambda=current_lambda,
            )
    
            # Backprop
            optimizer.zero_grad()
            total_loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
            optimizer.step()
    
            # Accumulate losses
            for key in epoch_losses:
                epoch_losses[key] += loss_dict[key]
            n_batches += 1
    
        # Average losses
        for key in epoch_losses:
            epoch_losses[key] /= max(n_batches, 1)
    
        epoch_losses["lambda"] = current_lambda
        return epoch_losses
    
    
    @torch.no_grad()
    def validate(model, loader, criterion, device, config):
        """Validate on a labeled dataset (source test or target test)."""
        model.eval()
        all_logits = []
        all_labels = []
        total_recon_loss = 0
        n_batches = 0
    
        for batch, labels in loader:
            batch = batch.to(device)
            labels = labels.to(device)
    
            anomaly_logits, recon, _, latent = model(batch)
            recon_loss = nn.MSELoss()(recon, batch)
    
            all_logits.append(anomaly_logits.squeeze(-1).cpu())
            all_labels.append(labels.cpu())
            total_recon_loss += recon_loss.item()
            n_batches += 1
    
        all_logits = torch.cat(all_logits)
        all_labels = torch.cat(all_labels)
    
        # Compute metrics
        probs = torch.sigmoid(all_logits)
        preds = (probs > 0.5).float()
        accuracy = (preds == all_labels).float().mean().item()
    
        from sklearn.metrics import roc_auc_score, f1_score
        try:
            auroc = roc_auc_score(all_labels.numpy(), probs.numpy())
        except ValueError:
            auroc = 0.5  # Only one class present
        f1 = f1_score(all_labels.numpy(), preds.numpy(), zero_division=0)
    
        return {
            "accuracy": accuracy,
            "auroc": auroc,
            "f1": f1,
            "recon_loss": total_recon_loss / max(n_batches, 1),
        }
    
    
    def main():
        parser = argparse.ArgumentParser(description="Train domain-adaptive anomaly detector")
        parser.add_argument("--method", type=str, default="dann",
                            choices=["dann", "mmd", "coral"],
                            help="Domain adaptation method")
        parser.add_argument("--epochs", type=int, default=None)
        parser.add_argument("--batch_size", type=int, default=None)
        parser.add_argument("--lr", type=float, default=None)
        parser.add_argument("--lambda_domain", type=float, default=None)
        parser.add_argument("--lambda_recon", type=float, default=None)
        parser.add_argument("--seed", type=int, default=None)
        parser.add_argument("--data_dir", type=str, default=None)
        parser.add_argument("--device", type=str, default=None)
        args = parser.parse_args()
    
        # Build config with CLI overrides
        config = Config()
        config.adaptation_method = args.method
        if args.epochs is not None:
            config.epochs = args.epochs
        if args.batch_size is not None:
            config.batch_size = args.batch_size
        if args.lr is not None:
            config.learning_rate = args.lr
        if args.lambda_domain is not None:
            config.lambda_domain = args.lambda_domain
        if args.lambda_recon is not None:
            config.lambda_recon = args.lambda_recon
        if args.seed is not None:
            config.seed = args.seed
        if args.data_dir is not None:
            config.data_dir = args.data_dir
        if args.device is not None:
            config.device = args.device
    
        # Setup
        set_seed(config.seed)
        device = torch.device(config.device)
        print(f"Using device: {device}")
        print(f"Adaptation method: {config.adaptation_method}")
        print(f"Epochs: {config.epochs}, Batch size: {config.batch_size}, LR: {config.learning_rate}")
    
        # Data
        print("\nLoading data...")
        loaders, normalizer = create_data_loaders(config)
        print(f"Source train batches: {len(loaders['source_train'])}")
        print(f"Target train batches: {len(loaders['target_train'])}")
    
        # Model
        model = DomainAdaptiveAnomalyDetector(config).to(device)
        total_params = sum(p.numel() for p in model.parameters())
        print(f"\nModel parameters: {total_params:,}")
    
        # Optimizer (single optimizer for simplicity; separate LRs via param groups)
        optimizer = Adam([
            {"params": model.encoder.parameters(), "lr": config.learning_rate},
            {"params": model.classifier.parameters(), "lr": config.learning_rate},
            {"params": model.decoder.parameters(), "lr": config.learning_rate},
            {"params": model.discriminator.parameters(), "lr": config.discriminator_lr},
        ], weight_decay=config.weight_decay)
    
        scheduler = CosineAnnealingLR(optimizer, T_max=config.epochs, eta_min=1e-6)
    
        # Loss
        criterion = CombinedLoss(config)
    
        # Early stopping
        early_stopping = EarlyStopping(patience=config.patience, mode="max")
    
        # Logging
        logger = MetricLogger(config.results_dir)
    
        # Training loop
        best_target_auroc = 0.0
        print("\n" + "=" * 60)
        print("Starting training...")
        print("=" * 60)
    
        for epoch in range(config.epochs):
            start_time = time.time()
    
            # Train
            train_losses = train_one_epoch(
                model, loaders["source_train"], loaders["target_train"],
                criterion, optimizer, device, epoch, config.epochs, config
            )
    
            # Validate on source test
            source_metrics = validate(model, loaders["source_test"], criterion, device, config)
    
            # Evaluate on target test (the real metric we care about)
            target_metrics = validate(model, loaders["target_test"], criterion, device, config)
    
            scheduler.step()
    
            elapsed = time.time() - start_time
    
            # Log
            logger.log(epoch, train_losses, source_metrics, target_metrics)
    
            # Print progress
            if epoch % 5 == 0 or epoch == config.epochs - 1:
                print(
                    f"Epoch {epoch:3d}/{config.epochs} ({elapsed:.1f}s) | "
                    f"Loss: {train_losses['total']:.4f} "
                    f"[cls={train_losses['classification']:.4f}, "
                    f"rec={train_losses['reconstruction']:.4f}, "
                    f"dom={train_losses['domain']:.4f}] | "
                    f"λ={train_losses['lambda']:.3f} | "
                    f"Src AUROC: {source_metrics['auroc']:.4f} | "
                    f"Tgt AUROC: {target_metrics['auroc']:.4f}"
                )
    
            # Save best model (based on target AUROC)
            if target_metrics["auroc"] > best_target_auroc:
                best_target_auroc = target_metrics["auroc"]
                save_checkpoint(
                    model, optimizer, epoch, target_metrics,
                    os.path.join(config.checkpoint_dir, "best_model.pt")
                )
    
            # Early stopping on target AUROC
            if early_stopping.step(target_metrics["auroc"]):
                print(f"\nEarly stopping triggered at epoch {epoch}")
                break
    
        print("\n" + "=" * 60)
        print(f"Training complete. Best target AUROC: {best_target_auroc:.4f}")
        print(f"Best model saved to: {config.checkpoint_dir}/best_model.pt")
        print("=" * 60)
    
        # Save training curves
        logger.save()
        logger.plot_training_curves()
    
    
    if __name__ == "__main__":
        main()
    Tip: The metric of primary interest is the target AUROC, not the source AUROC. Source AUROC indicates only that the model can classify anomalies where labels are available, which is the expected baseline. Target AUROC reveals whether domain adaptation is actually transferring anomaly-detection knowledge to the unlabeled domain.

    Evaluation and Metrics

    After training, rigorous evaluation on the target domain is required. The evaluation script computes standard anomaly-detection metrics, combines classifier and reconstruction scores, implements multiple threshold strategies, and produces diagnostic plots. This is the stage at which the success of domain adaptation can be assessed.

    evaluate.py

    """
    evaluate.py — Evaluation script for domain-adaptive anomaly detection.
    
    Loads a trained model and evaluates on target domain test data.
    Computes AUROC, AUPRC, F1, precision, recall.
    Generates diagnostic plots and saves results to JSON.
    """
    
    import argparse
    import json
    import os
    import numpy as np
    import torch
    import torch.nn as nn
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from sklearn.metrics import (
        roc_auc_score,
        average_precision_score,
        f1_score,
        precision_score,
        recall_score,
        accuracy_score,
        confusion_matrix,
        roc_curve,
        precision_recall_curve,
    )
    
    from config import Config
    from dataset import create_data_loaders
    from model import DomainAdaptiveAnomalyDetector
    from utils import set_seed, load_checkpoint
    
    
    def compute_anomaly_scores(model, loader, device, alpha=0.7):
        """
        Compute anomaly scores combining classifier output and reconstruction error.
    
        anomaly_score = alpha * classifier_prob + (1 - alpha) * normalized_recon_error
    
        Returns:
            scores: numpy array of anomaly scores
            labels: numpy array of ground truth labels
            recon_errors: numpy array of per-sample reconstruction errors
            classifier_probs: numpy array of classifier probabilities
            latent_features: numpy array of latent features (for t-SNE)
        """
        model.eval()
        all_probs = []
        all_labels = []
        all_recon_errors = []
        all_latent = []
    
        with torch.no_grad():
            for batch, labels in loader:
                batch = batch.to(device)
                anomaly_logits, recon, _, latent = model(batch)
    
                # Classifier probability
                probs = torch.sigmoid(anomaly_logits.squeeze(-1))
    
                # Per-sample reconstruction error (mean across features and time)
                recon_error = ((recon - batch) ** 2).mean(dim=(1, 2))
    
                all_probs.append(probs.cpu().numpy())
                all_labels.append(labels.numpy())
                all_recon_errors.append(recon_error.cpu().numpy())
                all_latent.append(latent.cpu().numpy())
    
        all_probs = np.concatenate(all_probs)
        all_labels = np.concatenate(all_labels)
        all_recon_errors = np.concatenate(all_recon_errors)
        all_latent = np.concatenate(all_latent)
    
        # Normalize reconstruction errors to [0, 1]
        re_min, re_max = all_recon_errors.min(), all_recon_errors.max()
        if re_max - re_min > 1e-8:
            norm_recon = (all_recon_errors - re_min) / (re_max - re_min)
        else:
            norm_recon = np.zeros_like(all_recon_errors)
    
        # Combined anomaly score
        scores = alpha * all_probs + (1 - alpha) * norm_recon
    
        return scores, all_labels, all_recon_errors, all_probs, all_latent
    
    
    def find_optimal_threshold(labels, scores):
        """Find the threshold that maximizes F1 score."""
        thresholds = np.linspace(0, 1, 200)
        best_f1 = 0
        best_thresh = 0.5
    
        for thresh in thresholds:
            preds = (scores >= thresh).astype(int)
            f1 = f1_score(labels, preds, zero_division=0)
            if f1 > best_f1:
                best_f1 = f1
                best_thresh = thresh
    
        return best_thresh, best_f1
    
    
    def compute_all_metrics(labels, scores, threshold):
        """Compute all evaluation metrics at a given threshold."""
        preds = (scores >= threshold).astype(int)
        metrics = {
            "auroc": float(roc_auc_score(labels, scores)),
            "auprc": float(average_precision_score(labels, scores)),
            "f1": float(f1_score(labels, preds, zero_division=0)),
            "precision": float(precision_score(labels, preds, zero_division=0)),
            "recall": float(recall_score(labels, preds, zero_division=0)),
            "accuracy": float(accuracy_score(labels, preds)),
            "threshold": float(threshold),
        }
    
        cm = confusion_matrix(labels, preds)
        metrics["confusion_matrix"] = cm.tolist()
        metrics["true_negatives"] = int(cm[0, 0])
        metrics["false_positives"] = int(cm[0, 1])
        metrics["false_negatives"] = int(cm[1, 0])
        metrics["true_positives"] = int(cm[1, 1])
    
        return metrics
    
    
    def plot_roc_curve(labels, scores, save_path):
        """Plot and save ROC curve."""
        fpr, tpr, _ = roc_curve(labels, scores)
        auroc = roc_auc_score(labels, scores)
    
        fig, ax = plt.subplots(figsize=(8, 6))
        ax.plot(fpr, tpr, "b-", linewidth=2, label=f"AUROC = {auroc:.4f}")
        ax.plot([0, 1], [0, 1], "k--", alpha=0.5, label="Random")
        ax.set_xlabel("False Positive Rate", fontsize=12)
        ax.set_ylabel("True Positive Rate", fontsize=12)
        ax.set_title("ROC Curve — Target Domain", fontsize=14)
        ax.legend(fontsize=11)
        ax.grid(True, alpha=0.3)
        fig.tight_layout()
        fig.savefig(save_path, dpi=150)
        plt.close(fig)
        print(f"ROC curve saved to {save_path}")
    
    
    def plot_pr_curve(labels, scores, save_path):
        """Plot and save Precision-Recall curve."""
        precision, recall, _ = precision_recall_curve(labels, scores)
        auprc = average_precision_score(labels, scores)
    
        fig, ax = plt.subplots(figsize=(8, 6))
        ax.plot(recall, precision, "r-", linewidth=2, label=f"AUPRC = {auprc:.4f}")
        baseline = labels.sum() / len(labels)
        ax.axhline(y=baseline, color="k", linestyle="--", alpha=0.5, label=f"Baseline = {baseline:.3f}")
        ax.set_xlabel("Recall", fontsize=12)
        ax.set_ylabel("Precision", fontsize=12)
        ax.set_title("Precision-Recall Curve — Target Domain", fontsize=14)
        ax.legend(fontsize=11)
        ax.grid(True, alpha=0.3)
        fig.tight_layout()
        fig.savefig(save_path, dpi=150)
        plt.close(fig)
        print(f"PR curve saved to {save_path}")
    
    
    def plot_score_distribution(labels, scores, threshold, save_path):
        """Plot anomaly score distribution for normal vs anomaly samples."""
        fig, ax = plt.subplots(figsize=(10, 6))
    
        normal_scores = scores[labels == 0]
        anomaly_scores = scores[labels == 1]
    
        ax.hist(normal_scores, bins=50, alpha=0.6, color="steelblue", label="Normal", density=True)
        ax.hist(anomaly_scores, bins=50, alpha=0.6, color="indianred", label="Anomaly", density=True)
        ax.axvline(x=threshold, color="black", linestyle="--", linewidth=2,
                   label=f"Threshold = {threshold:.3f}")
        ax.set_xlabel("Anomaly Score", fontsize=12)
        ax.set_ylabel("Density", fontsize=12)
        ax.set_title("Anomaly Score Distribution — Target Domain", fontsize=14)
        ax.legend(fontsize=11)
        ax.grid(True, alpha=0.3)
        fig.tight_layout()
        fig.savefig(save_path, dpi=150)
        plt.close(fig)
        print(f"Score distribution saved to {save_path}")
    
    
    def plot_reconstruction_error(recon_errors, labels, save_path):
        """Plot reconstruction error over sample index, colored by label."""
        fig, ax = plt.subplots(figsize=(14, 5))
    
        indices = np.arange(len(recon_errors))
        normal_mask = labels == 0
        anomaly_mask = labels == 1
    
        ax.scatter(indices[normal_mask], recon_errors[normal_mask],
                   s=2, alpha=0.4, c="steelblue", label="Normal")
        ax.scatter(indices[anomaly_mask], recon_errors[anomaly_mask],
                   s=8, alpha=0.8, c="indianred", label="Anomaly")
        ax.set_xlabel("Sample Index", fontsize=12)
        ax.set_ylabel("Reconstruction Error", fontsize=12)
        ax.set_title("Reconstruction Error Over Time — Target Domain", fontsize=14)
        ax.legend(fontsize=11)
        ax.grid(True, alpha=0.3)
        fig.tight_layout()
        fig.savefig(save_path, dpi=150)
        plt.close(fig)
        print(f"Reconstruction error plot saved to {save_path}")
    
    
    def main():
        parser = argparse.ArgumentParser(description="Evaluate domain-adaptive anomaly detector")
        parser.add_argument("--checkpoint", type=str,
                            default="checkpoints/best_model.pt",
                            help="Path to model checkpoint")
        parser.add_argument("--data_dir", type=str, default="data",
                            help="Data directory")
        parser.add_argument("--results_dir", type=str, default="results",
                            help="Output directory for results")
        parser.add_argument("--alpha", type=float, default=0.7,
                            help="Weight for classifier score vs recon error")
        parser.add_argument("--method", type=str, default="dann",
                            choices=["dann", "mmd", "coral"])
        parser.add_argument("--device", type=str, default="")
        args = parser.parse_args()
    
        config = Config()
        config.data_dir = args.data_dir
        config.results_dir = args.results_dir
        config.adaptation_method = args.method
        if args.device:
            config.device = args.device
    
        set_seed(config.seed)
        device = torch.device(config.device)
        os.makedirs(config.results_dir, exist_ok=True)
    
        print(f"Device: {device}")
        print(f"Loading checkpoint: {args.checkpoint}")
    
        # Load model
        model = DomainAdaptiveAnomalyDetector(config).to(device)
        checkpoint = load_checkpoint(args.checkpoint, model, device=device)
        print(f"Loaded model from epoch {checkpoint.get('epoch', '?')}")
    
        # Load data
        loaders, normalizer = create_data_loaders(config)
    
        # --- Evaluate on target test set ---
        print("\n--- Target Domain Evaluation ---")
        scores, labels, recon_errors, probs, latent_features = compute_anomaly_scores(
            model, loaders["target_test"], device, alpha=args.alpha
        )
    
        # Find optimal threshold
        optimal_thresh, optimal_f1 = find_optimal_threshold(labels, scores)
        print(f"Optimal threshold: {optimal_thresh:.4f} (F1 = {optimal_f1:.4f})")
    
        # Percentile-based threshold
        percentile_thresh = np.percentile(scores, config.anomaly_threshold_percentile)
        print(f"Percentile ({config.anomaly_threshold_percentile}%) threshold: {percentile_thresh:.4f}")
    
        # Compute metrics at optimal threshold
        metrics_optimal = compute_all_metrics(labels, scores, optimal_thresh)
        metrics_optimal["threshold_method"] = "f1_optimal"
    
        # Compute metrics at percentile threshold
        metrics_percentile = compute_all_metrics(labels, scores, percentile_thresh)
        metrics_percentile["threshold_method"] = "percentile"
    
        # Print results
        print(f"\n{'Metric':<20} {'F1-Optimal':>12} {'Percentile':>12}")
        print("-" * 46)
        for key in ["auroc", "auprc", "f1", "precision", "recall", "accuracy"]:
            print(f"{key:<20} {metrics_optimal[key]:>12.4f} {metrics_percentile[key]:>12.4f}")
    
        # Also evaluate on source test for comparison
        print("\n--- Source Domain Evaluation (baseline) ---")
        src_scores, src_labels, _, _, src_latent = compute_anomaly_scores(
            model, loaders["source_test"], device, alpha=args.alpha
        )
        src_thresh, _ = find_optimal_threshold(src_labels, src_scores)
        src_metrics = compute_all_metrics(src_labels, src_scores, src_thresh)
        print(f"Source AUROC: {src_metrics['auroc']:.4f}, F1: {src_metrics['f1']:.4f}")
    
        # --- Generate plots ---
        print("\nGenerating plots...")
        plot_roc_curve(labels, scores, os.path.join(config.results_dir, "roc_curve.png"))
        plot_pr_curve(labels, scores, os.path.join(config.results_dir, "pr_curve.png"))
        plot_score_distribution(labels, scores, optimal_thresh,
                               os.path.join(config.results_dir, "score_distribution.png"))
        plot_reconstruction_error(recon_errors, labels,
                                 os.path.join(config.results_dir, "recon_error.png"))
    
        # --- Save results ---
        results = {
            "method": config.adaptation_method,
            "alpha": args.alpha,
            "target_metrics_optimal": metrics_optimal,
            "target_metrics_percentile": metrics_percentile,
            "source_metrics": src_metrics,
        }
        results_path = os.path.join(config.results_dir, "evaluation_results.json")
        with open(results_path, "w") as f:
            json.dump(results, f, indent=2)
        print(f"\nResults saved to {results_path}")
    
    
    if __name__ == "__main__":
        main()

    Utility Functions

    The utility module handles reproducibility, early stopping, checkpointing, metric logging, and visualization, including t-SNE plots of feature distributions.

    utils.py

    """
    utils.py — Utility functions for the DA anomaly detection pipeline.
    
    Includes:
      - Seed setting for reproducibility
      - EarlyStopping class
      - Checkpoint save/load
      - MetricLogger with CSV output and plotting
      - t-SNE visualization of domain features
    """
    
    import os
    import random
    import json
    import numpy as np
    import torch
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    
    
    def set_seed(seed: int = 42):
        """Set random seeds for reproducibility across all libraries."""
        random.seed(seed)
        np.random.seed(seed)
        torch.manual_seed(seed)
        torch.cuda.manual_seed_all(seed)
        torch.backends.cudnn.deterministic = True
        torch.backends.cudnn.benchmark = False
    
    
    class EarlyStopping:
        """
        Early stopping to halt training when a metric stops improving.
    
        Args:
            patience: number of epochs to wait before stopping
            mode: 'min' or 'max' — whether lower or higher is better
            min_delta: minimum improvement to count as progress
        """
    
        def __init__(self, patience: int = 15, mode: str = "max", min_delta: float = 1e-4):
            self.patience = patience
            self.mode = mode
            self.min_delta = min_delta
            self.counter = 0
            self.best_value = None
    
        def step(self, value: float) -> bool:
            """
            Check if training should stop.
    
            Args:
                value: current metric value
            Returns:
                True if training should stop
            """
            if self.best_value is None:
                self.best_value = value
                return False
    
            if self.mode == "max":
                improved = value > self.best_value + self.min_delta
            else:
                improved = value < self.best_value - self.min_delta
    
            if improved:
                self.best_value = value
                self.counter = 0
            else:
                self.counter += 1
    
            return self.counter >= self.patience
    
    
    def save_checkpoint(model, optimizer, epoch, metrics, filepath):
        """Save model checkpoint."""
        os.makedirs(os.path.dirname(filepath), exist_ok=True)
        torch.save({
            "epoch": epoch,
            "model_state_dict": model.state_dict(),
            "optimizer_state_dict": optimizer.state_dict(),
            "metrics": metrics,
        }, filepath)
    
    
    def load_checkpoint(filepath, model, optimizer=None, device="cpu"):
        """Load model checkpoint."""
        checkpoint = torch.load(filepath, map_location=device, weights_only=False)
        model.load_state_dict(checkpoint["model_state_dict"])
        if optimizer is not None and "optimizer_state_dict" in checkpoint:
            optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
        return checkpoint
    
    
    class MetricLogger:
        """
        Logs training metrics to memory and saves to CSV/JSON.
        Also generates training curve plots.
        """
    
        def __init__(self, output_dir: str = "results"):
            self.output_dir = output_dir
            os.makedirs(output_dir, exist_ok=True)
            self.history = {
                "epoch": [],
                "train_total_loss": [],
                "train_cls_loss": [],
                "train_recon_loss": [],
                "train_domain_loss": [],
                "train_lambda": [],
                "source_auroc": [],
                "source_f1": [],
                "target_auroc": [],
                "target_f1": [],
            }
    
        def log(self, epoch, train_losses, source_metrics, target_metrics):
            """Record one epoch of metrics."""
            self.history["epoch"].append(epoch)
            self.history["train_total_loss"].append(train_losses["total"])
            self.history["train_cls_loss"].append(train_losses["classification"])
            self.history["train_recon_loss"].append(train_losses["reconstruction"])
            self.history["train_domain_loss"].append(train_losses["domain"])
            self.history["train_lambda"].append(train_losses.get("lambda", 0))
            self.history["source_auroc"].append(source_metrics["auroc"])
            self.history["source_f1"].append(source_metrics["f1"])
            self.history["target_auroc"].append(target_metrics["auroc"])
            self.history["target_f1"].append(target_metrics["f1"])
    
        def save(self):
            """Save metrics history to JSON."""
            path = os.path.join(self.output_dir, "training_history.json")
            with open(path, "w") as f:
                json.dump(self.history, f, indent=2)
            print(f"Training history saved to {path}")
    
        def plot_training_curves(self):
            """Generate and save training curve plots."""
            epochs = self.history["epoch"]
    
            fig, axes = plt.subplots(2, 2, figsize=(14, 10))
    
            # Loss curves
            ax = axes[0, 0]
            ax.plot(epochs, self.history["train_total_loss"], label="Total", linewidth=2)
            ax.plot(epochs, self.history["train_cls_loss"], label="Classification", linewidth=1.5)
            ax.plot(epochs, self.history["train_recon_loss"], label="Reconstruction", linewidth=1.5)
            ax.plot(epochs, self.history["train_domain_loss"], label="Domain", linewidth=1.5)
            ax.set_xlabel("Epoch")
            ax.set_ylabel("Loss")
            ax.set_title("Training Losses")
            ax.legend()
            ax.grid(True, alpha=0.3)
    
            # AUROC
            ax = axes[0, 1]
            ax.plot(epochs, self.history["source_auroc"], label="Source AUROC", linewidth=2)
            ax.plot(epochs, self.history["target_auroc"], label="Target AUROC", linewidth=2)
            ax.set_xlabel("Epoch")
            ax.set_ylabel("AUROC")
            ax.set_title("AUROC Over Training")
            ax.legend()
            ax.grid(True, alpha=0.3)
    
            # F1
            ax = axes[1, 0]
            ax.plot(epochs, self.history["source_f1"], label="Source F1", linewidth=2)
            ax.plot(epochs, self.history["target_f1"], label="Target F1", linewidth=2)
            ax.set_xlabel("Epoch")
            ax.set_ylabel("F1 Score")
            ax.set_title("F1 Score Over Training")
            ax.legend()
            ax.grid(True, alpha=0.3)
    
            # Lambda schedule
            ax = axes[1, 1]
            ax.plot(epochs, self.history["train_lambda"], label="Domain λ", linewidth=2,
                    color="purple")
            ax.set_xlabel("Epoch")
            ax.set_ylabel("Lambda Value")
            ax.set_title("Domain Adaptation Lambda Schedule")
            ax.legend()
            ax.grid(True, alpha=0.3)
    
            fig.tight_layout()
            path = os.path.join(self.output_dir, "training_curves.png")
            fig.savefig(path, dpi=150)
            plt.close(fig)
            print(f"Training curves saved to {path}")
    
    
    def plot_tsne_features(
        source_features: np.ndarray,
        target_features: np.ndarray,
        save_path: str,
        title: str = "t-SNE Feature Visualization",
        max_samples: int = 2000,
    ):
        """
        Create t-SNE plot showing source vs target feature distributions.
    
        Args:
            source_features: (n, d) source latent features
            target_features: (m, d) target latent features
            save_path: path to save the plot
            title: plot title
            max_samples: max samples per domain (for speed)
        """
        from sklearn.manifold import TSNE
    
        # Subsample if needed
        if len(source_features) > max_samples:
            idx = np.random.choice(len(source_features), max_samples, replace=False)
            source_features = source_features[idx]
        if len(target_features) > max_samples:
            idx = np.random.choice(len(target_features), max_samples, replace=False)
            target_features = target_features[idx]
    
        # Combine and run t-SNE
        combined = np.concatenate([source_features, target_features], axis=0)
        n_source = len(source_features)
    
        tsne = TSNE(n_components=2, random_state=42, perplexity=30)
        embedded = tsne.fit_transform(combined)
    
        fig, ax = plt.subplots(figsize=(10, 8))
        ax.scatter(embedded[:n_source, 0], embedded[:n_source, 1],
                   s=10, alpha=0.5, c="steelblue", label="Source")
        ax.scatter(embedded[n_source:, 0], embedded[n_source:, 1],
                   s=10, alpha=0.5, c="indianred", label="Target")
        ax.set_title(title, fontsize=14)
        ax.legend(fontsize=12)
        ax.grid(True, alpha=0.3)
        fig.tight_layout()
        fig.savefig(save_path, dpi=150)
        plt.close(fig)
        print(f"t-SNE plot saved to {save_path}")

    Running the Full Pipeline

    With all nine scripts in place, the complete workflow from data generation to final evaluation proceeds as follows. The commands below should be executed in order from the da-anomaly-detection/ directory.

    Step-by-Step Commands

    # Step 1: Install dependencies
    pip install -r requirements.txt
    
    # Step 2: Generate synthetic two-domain data
    python generate_synthetic_data.py --output_dir data/ --n_samples 20000
    
    # Step 3: Train with DANN (Domain-Adversarial Neural Network)
    python train.py --method dann --epochs 100 --batch_size 64 --lr 0.001
    
    # Step 4: Evaluate on target domain
    python evaluate.py --checkpoint checkpoints/best_model.pt --data_dir data/ --method dann
    
    # (Optional) Step 5: Train with MMD instead
    python train.py --method mmd --epochs 100 --batch_size 64
    
    # (Optional) Step 6: Train with CORAL instead
    python train.py --method coral --epochs 100 --batch_size 64

    Each training run reports progress every five epochs, saves the best model checkpoint based on target-domain AUROC, and writes training curves to the results/ directory. The evaluation script generates ROC curves, PR curves, score distribution histograms, and reconstruction-error time plots.

    Domain Adaptation Implementation Pipeline Source Data Labeled sensor time-series Feature Extraction CNN-LSTM encoder Domain Alignment DANN / MMD / CORAL Target Data Unlabeled sensor stream Anomaly Detector Classifier + recon score Alerts & Results AUROC, F1, plots

    Understanding the Results

    Once the pipeline has been executed, a results/evaluation_results.json file contains the numerical outputs. Interpreting those numbers and determining whether domain adaptation is actually helping requires familiarity with the relevant metrics.

    Interpreting the Evaluation Metrics

    AUROC (Area Under the ROC Curve) is the primary metric. It expresses the probability that a randomly chosen anomaly scores higher than a randomly chosen normal sample. An AUROC of 0.5 corresponds to random performance and 1.0 to perfect discrimination. For domain adaptation to be regarded as successful, the target-domain AUROC should be significantly higher than the no-adaptation baseline (training only on source data and evaluating on target data without adaptation).

    AUPRC (Area Under the Precision-Recall Curve) is more informative when anomalies are rare. In highly imbalanced datasets with a 1 percent anomaly rate, AUROC can appear favorable even when the model exhibits a high false positive rate. AUPRC penalizes false positives more strongly.

    F1 Score is the harmonic mean of precision and recall computed at the optimal threshold. It provides a single value that balances false positives and false negatives. For industrial applications, recall (not missing anomalies) is typically prioritized over precision, since some false alarms are tolerable.

    What Good vs. Bad Domain Adaptation Looks Like

    Scenario Source AUROC Target AUROC (no adapt) Target AUROC (with DA) Interpretation
    Successful adaptation 0.95 0.62 0.87 Domain adaptation recovered most performance
    Negative transfer 0.95 0.65 0.58 DA made things worse; domains may be too different
    No domain shift 0.93 0.91 0.92 Little domain shift exists; DA not needed
    Partial adaptation 0.95 0.55 0.72 DA helps but gap remains; try tuning or more target data

     

    Detection Accuracy: Before vs. After Domain Adaptation AUROC Score 1.00 0.90 0.80 0.70 0.60 Successful Adaptation 0.95 Source 0.62 No adapt 0.87 With DA Negative Transfer 0.95 Source 0.65 No adapt 0.58 With DA Partial Adaptation 0.95 Source 0.55 No adapt 0.72 With DA Source AUROC No Adaptation With DA (good) With DA (partial) Negative transfer

    Interpreting t-SNE Plots

    The t-SNE visualization is the most intuitive diagnostic tool available. It should be applied to the latent features before and after domain adaptation.

    • Before adaptation: Two distinct clusters typically appear, with source samples grouped in one region and target samples in another. This visual separation confirms that domain shift exists in the data.
    • After successful adaptation: The source and target clusters overlap substantially. The encoder has learned features that appear consistent regardless of which domain produced the input. If the anomaly classifier performs well on source features, it should now perform well on the overlapping target features as well.
    • After failed adaptation: Clusters remain separated, or in more severe cases the representation collapses to a single point, indicating mode collapse in the discriminator.

    When to Use DANN, MMD, or CORAL

    Method Mechanism Strengths Weaknesses Best For
    DANN Adversarial training via GRL Powerful; learns complex alignment Unstable training; sensitive to hyperparameters Large domain shifts; enough training data
    MMD Kernel-based distribution matching Stable training; mathematically principled Expensive for large batches; kernel selection matters Moderate domain shifts; limited compute
    CORAL Covariance matrix alignment Simple; fast; no extra hyperparameters Only matches second-order statistics Small domain shifts; quick baseline

     

    Tip: Begin with CORAL, which is the simplest and fastest method, to establish a baseline. If the resulting gap remains too large, proceed to MMD. Where maximum performance is required and some training instability is acceptable, use DANN with careful lambda scheduling.

    Adapting to Custom Data

    The synthetic data set serves only as a sandbox. The following steps describe how to integrate proprietary time-series data with minimal code changes.

    Modifying dataset.py for a Specific Data Format

    The CSV files should follow this structure: each row corresponds to a timestep, and each column other than label and timestamp corresponds to a sensor channel. The column names are unimportant as long as label and timestamp are named correctly or absent entirely. For data that uses a different format, the load_csv_data() function can be modified as follows.

    # Example: your data has columns named 'temp_1', 'temp_2', 'vibration_x', etc.
    # and uses 'anomaly' instead of 'label'
    def load_csv_data(filepath, has_labels=True):
        df = pd.read_csv(filepath)
        exclude = ["anomaly", "timestamp", "machine_id", "date"]
        feature_cols = [c for c in df.columns if c not in exclude]
        data = df[feature_cols].values.astype(np.float32)
        labels = df["anomaly"].values.astype(np.float32) if has_labels else None
        return data, labels

    Adjusting Model Dimensions

    For data with a different number of channels, only num_features in config.py needs to change. The model adjusts automatically. For different sampling rates, the window_size should be adjusted; as a rule of thumb, the window should span roughly one cycle of the normal operating pattern. For a machine cycling every 5 seconds sampled at 100 Hz, window_size=500 is appropriate. For slow processes such as daily patterns at hourly sampling, window_size=24 is appropriate.

    Handling Class Imbalance

    Real anomaly data is heavily imbalanced, often with anomaly rates of 1 percent or less. Three strategies are effective within this codebase.

    1. Weighted BCE loss: Replace BCEWithLogitsLoss() with BCEWithLogitsLoss(pos_weight=torch.tensor([19.0])), where 19.0 is the ratio of normal to anomaly samples.
    2. Focal loss: Down-weights easy negatives. Replace the BCE in AnomalyDetectionLoss.
    3. Oversampling: Use PyTorch’s WeightedRandomSampler to oversample anomaly windows in the source training loader.

    Hyperparameter Tuning Guide

    The hyperparameters below are ordered by sensitivity, with the most sensitive listed first.

    1. lambda_domain (0.1–2.0): The most sensitive parameter. Excessively high values cause the encoder to learn domain-invariant features that are uninformative for anomaly detection. Excessively low values prevent any adaptation. A value of 0.5 is a reasonable starting point.
    2. learning_rate (1e-4–1e-2): Standard neural-network tuning. Cosine annealing is recommended.
    3. window_size (32–256): Should capture sufficient context for anomalies to be visible.
    4. latent_dim (64–256): Higher values provide more capacity but increase the risk of overfitting.
    5. alpha (0.5–0.9): Controls the mixture used in anomaly scoring. Higher values place more weight on the classifier output; lower values emphasize reconstruction error.

    Common Issues and Solutions

    Domain adaptation training is known to be sensitive to configuration choices. The reference table below lists problems that practitioners frequently encounter and the corresponding remedies.

    Problem Symptom Cause Solution
    Discriminator mode collapse Domain loss stays at ~0.69 (ln 2) Discriminator outputs 0.5 for everything Increase discriminator LR; add more layers; reduce GRL lambda
    Training instability Loss oscillates wildly or diverges Lambda too high too early Use progressive lambda schedule; reduce learning rate; increase gradient clipping
    Negative transfer Target AUROC decreases with DA Domains are too different or share no useful structure Reduce lambda_domain; try CORAL (less aggressive); verify domains share anomaly types
    High false positive rate Good recall but terrible precision Threshold too low; recon error noisy Increase alpha (trust classifier more); use percentile threshold; add recon error smoothing
    Source AUROC drops during DA Classification degrades on source Domain-invariant features lose discriminative power Increase lambda_cls; reduce lambda_domain; train classifier longer before starting DA
    Out of memory (GPU) CUDA OOM error Batch size or model too large Reduce batch_size; reduce latent_dim; use gradient accumulation
    MMD loss is NaN NaN in training Kernel bandwidth mismatch with feature scale Normalize features; adjust kernel_bandwidths in config; add epsilon to kernel computation

     

    Caution: Domain adaptation assumes that the source and target domains share the same anomaly types and differ only in feature distributions. When the target domain exhibits fundamentally different anomaly mechanisms (not merely different sensor characteristics), domain adaptation will not help, and at least some labeled target data is required through semi-supervised adaptation.

    Putting It Together

    The preceding sections constitute a complete, end-to-end implementation of domain-adaptive time-series anomaly detection. A brief recapitulation and discussion of next steps follow.

    The nine scripts cover the full pipeline: generating realistic synthetic data with domain shift, constructing a CNN-LSTM encoder with multi-head outputs, implementing three domain-adaptation strategies (DANN, MMD, and CORAL), training with progressive lambda scheduling, and evaluating with comprehensive metrics and diagnostic plots. Every script is complete and runnable as written.

    The central insight is straightforward but consequential. Rather than requiring expensive labeled data in each new domain, a model can be trained to learn domain-invariant features: representations that capture the essence of “anomaly” regardless of which machine, factory, or sensor produced the signal. The Gradient Reversal Layer is the elegant mechanism that enables this adversarial training within a single unified model, while MMD and CORAL provide simpler and more stable alternatives.

    Three directions are particularly promising for further development. First, semi-supervised adaptation: when even 5 to 10 percent of the target-domain data can be labeled, a supervised loss on those labeled target samples can be added alongside the unsupervised domain alignment, with substantial improvements in performance. Second, multi-source adaptation: when data are available from machines A, B, and C, adaptation to machine D can combine knowledge from all three sources rather than only one. Third, continual adaptation: in production, the target domain drifts over time as machines age and wear; periodic or online re-adaptation keeps the model current.

    Domain adaptation is not a universal solution. It performs best when domains share the same underlying anomaly mechanisms but differ in superficial signal characteristics, which is the prevailing scenario in industrial settings. When it succeeds, it can save months of labeling effort and accelerate the deployment of anomaly detection to new equipment. The code provided in this guide contains everything needed to begin experimenting with proprietary data immediately.

    References

    1. Ganin, Y., Ustinova, E., Ajakan, H., Germain, P., Larochelle, H., Laviolette, F., Marchand, M., and Lempitsky, V. (2016). “Domain-Adversarial Training of Neural Networks.” Journal of Machine Learning Research, 17(59), 1-35.
    2. Gretton, A., Borgwardt, K. M., Rasch, M. J., Schölkopf, B., and Smola, A. J. (2012). “A Kernel Two-Sample Test.” Journal of Machine Learning Research, 13, 723-773.
    3. Sun, B. and Saenko, K. (2016). “Deep CORAL: Correlation Alignment for Deep Domain Adaptation.” Proceedings of the European Conference on Computer Vision (ECCV) Workshops.
    4. Ragab, M., Eldele, E., Tan, W. L., Foo, C.-S., Chen, Z., Wu, M., Kwoh, C.-K., and Li, X. (2023). “ADATIME: A Benchmarking Suite for Domain Adaptation on Time Series Data.” ACM Transactions on Knowledge Discovery from Data (TKDD).
    5. Chalapathy, R. and Chawla, S. (2019). “Deep Learning for Anomaly Detection: A Survey.” arXiv preprint.
    6. PyTorch Documentation. “Extending torch.autograd—Custom Function.”
  • Transfer Learning, Fine-Tuning, and Domain Adaptation: A Complete Guide with Anomaly Detection for Heterogeneous Cobots

    Summary

    What this post covers: A clear separation of transfer learning, fine-tuning, and domain adaptation as a hierarchy of techniques, applied to the concrete problem of building a cross-brand anomaly detection model for heterogeneous collaborative robot fleets with runnable PyTorch examples.

    Key insights:

    • Transfer learning is the umbrella paradigm; fine-tuning, domain adaptation, feature extraction, multi-task learning, and few-shot transfer are sibling techniques within it, not synonyms, getting this hierarchy right prevents most conceptual errors.
    • For heterogeneous cobot fleets, the cheapest effective starting point is per-channel sensor normalization plus fine-tuning only the batch normalization layers, this requires almost no target labels and can be deployed in hours.
    • When BN-only adaptation falls short, escalate to adversarial domain adaptation (DANN) or supervised contrastive methods, which align source and target feature distributions even without target labels.
    • Inference latency requirements drive architecture choice: a 500K-parameter CNN runs in under 5ms on Jetson hardware suitable for collision avoidance, while transformer-based models typically require cloud deployment unsuitable for real-time safety detection.
    • The hardest part of cross-brand cobot anomaly detection is not the algorithm but data collection and a consistent labeling protocol that domain experts can apply across brands, firmware versions, and operating conditions.

    Main topics: Transfer Learning, The Big Picture, Fine-Tuning—Techniques and Strategies, Domain Adaptation—Bridging the Distribution Gap, The Cobot Anomaly Detection Scenario, Practical Implementation Guide, Putting It Together, References.

    Consider a Universal Robots UR5e and a FANUC CRX-10iA on the same production line, performing identical pick-and-place operations. Both have six joints, both lift the same payload, and both generate streams of torque, position, and velocity data every millisecond. Yet when an anomaly detection model trained on the UR5e’s data is deployed on the FANUC—despite the identity of the task—the model flags nearly everything as anomalous. The sensor noise profiles differ, the control loop frequencies do not match, and the calibration offsets produce entirely different data distributions. The model understands what “normal” looks like for one robot, but is effectively blind to normalcy on another.

    This is not a hypothetical problem. As collaborative robots (cobots) proliferate across manufacturing, logistics, and healthcare, organisations increasingly operate heterogeneous fleets that span multiple brands, generations, and firmware versions. Training a separate anomaly detection model for every brand is expensive, slow, and inefficient. The question is whether a model can transfer its understanding of normal robot behaviour across brands.

    This is precisely the problem that transfer learning, fine-tuning, and domain adaptation were designed to address. The following sections examine these three concepts, clarify how they relate to one another, and apply them to a concrete scenario: building a cross-brand anomaly detection system for heterogeneous cobots. The treatment provides both theoretical understanding and complete, runnable PyTorch code for several adaptation strategies.

    Key Takeaway: Transfer learning is the umbrella paradigm. Fine-tuning and domain adaptation are specific techniques within it. Understanding this hierarchy is essential before proceeding to implementation.

    Before proceeding, the conceptual hierarchy that frames the discussion should be made explicit:

    Transfer Learning (broad paradigm)
    ├── Fine-Tuning (retrain pre-trained model on new data)
    ├── Domain Adaptation (bridge distribution gap between domains)
    │   ├── Supervised Domain Adaptation
    │   ├── Unsupervised Domain Adaptation (UDA)
    │   └── Semi-Supervised Domain Adaptation
    ├── Feature Extraction (freeze pre-trained layers, train new head)
    ├── Multi-Task Learning (shared representations)
    └── Zero-Shot / Few-Shot Transfer

    Transfer learning is the overarching idea: take knowledge learned in one context and apply it in another. Fine-tuning is one mechanism for doing so, in which a pre-trained model is further trained on the target data. Domain adaptation is another mechanism, which specifically addresses the situation in which source and target data come from different distributions. Feature extraction, multi-task learning, and zero- or few-shot transfer are additional strategies under the same umbrella. They are sibling strategies, not synonyms.

    With that framework established, each technique is examined in detail below.

    Transfer Learning—Source to Target Pipeline Source Domain UR5e Cobot Labeled Data Pre-trained Model 1D-CNN Encoder Learned Features Fine-tuning / Domain Adapt. Adapt to Target Target Domain FANUC / ABB Cobot Few/No Labels Transfer Learning Strategies (siblings, not synonyms): Fine-Tuning Domain Adaptation Feature Extraction Multi-Task Learning Zero / Few-Shot All strategies share one goal: reuse knowledge from source to accelerate learning on the target.

    Transfer Learning, The Big Picture

    Formal Definition

    Transfer learning is the paradigm of using knowledge acquired from a source task or domain to improve learning on a target task or domain. Formally, given a source domain DS with a learning task TS, and a target domain DT with a learning task TT, transfer learning aims to improve the learning of the target predictive function fT(·) using knowledge from DS and TS, where DS ≠ DT or TS ≠ TT.

    Expressed informally: resources have already been spent learning something useful in one context. The objective is to reuse that learning rather than start from scratch.

    Why Transfer Learning Matters

    The motivation is overwhelmingly practical:

    • Limited labelled data. Labelling anomalies in cobot sensor data requires domain experts familiar with both the robot’s kinematics and the manufacturing process. Thousands of labelled samples may be available for one robot brand, but very few for another.
    • Expensive annotation. Each labelled anomaly may require a robotics engineer to review hours of sensor logs. At 150 USD per hour, labelling 10,000 samples across five brands can cost more than the robots themselves.
    • Faster convergence. A model initialised with transferred knowledge reaches acceptable performance in hours rather than weeks.
    • Better generalisation. Features learned from large, diverse datasets often capture general patterns that improve performance even on seemingly unrelated tasks.

    Types of Transfer Learning

    The taxonomy breaks down based on what differs between source and target:

    Type Source Labels Target Labels Relationship Example
    Inductive Transfer Available Available TS ≠ TT ImageNet classification → medical image segmentation
    Transductive Transfer Available Not available DS ≠ DT, TS = TT UR5e anomaly detection → FANUC anomaly detection (no FANUC labels)
    Unsupervised Transfer Not available Not available DS ≠ DT Self-supervised pre-training on all cobot data → clustering

     

    For our cobot scenario, transductive transfer is the most relevant: we have labeled anomaly data from one or a few brands (source domains) and want to perform the same anomaly detection task on new brands (target domains) where labels are scarce or nonexistent.

    When Transfer Learning Works, and When It Fails

    Transfer learning is not a universal solution. It works when source and target share underlying structure. A model trained on ImageNet transfers well to medical imaging because both involve recognising edges, textures, and shapes. A model trained on English text transfers well to French because the two languages share grammatical abstractions.

    It fails, sometimes substantially, when source and target are too dissimilar. This is termed negative transfer: the transferred knowledge actively degrades performance on the target task. For example, a model trained on satellite imagery may transfer poorly to microscopy images despite both being images. The spatial scales, textures, and semantic content differ fundamentally.

    Caution: Negative transfer is difficult to diagnose because it can resemble a training problem. If a transferred model performs worse than a randomly initialised one, negative transfer should be suspected. The remedy is typically to reduce the amount of knowledge transferred (freeze fewer layers) or to reconsider whether transfer is appropriate at all.

    In the cobot scenario, transfer learning is promising because the robots share the same fundamental kinematic structure. A six-axis articulated arm generates torque profiles that follow similar physical laws regardless of brand. The differences arise in sensor calibration, noise characteristics, and control-system specifics—exactly the kind of distribution shift that domain adaptation was designed to handle.

    Historical Context

    The modern era of transfer learning began with ImageNet. In 2012, AlexNet demonstrated that deep CNNs could learn powerful visual features. By 2014, researchers had observed that these features, especially those from early layers, transferred remarkably well to other vision tasks. “ImageNet pre-training” became the default starting point for nearly every computer vision project.

    NLP followed a similar trajectory. Word2Vec and GloVe provided transferable word embeddings, but the broader transformation came with BERT (2018) and GPT (2018–2019), which showed that pre-training on substantial text corpora created representations that transferred to nearly any language task. Today’s large language models are perhaps the most extensive transfer learning systems: pre-trained on trillions of tokens, then fine-tuned or prompted for specific tasks.

    Time-series and industrial AI are now undergoing their own transfer learning shift. Models such as Chronos, TimesFM, and Lag-Llama are emerging as foundation models for temporal data, and domain adaptation for sensor data is an active research area with direct industrial application.

    Training From Scratch vs. Transfer Learning

    Factor From Scratch Transfer Learning
    Labeled data needed Large (10k–1M+ samples) Small (100–1k samples)
    Training time Days to weeks Hours to days
    Compute cost High (multi-GPU) Low to moderate (single GPU)
    Performance (limited data) Poor (overfits) Good to excellent
    Performance (abundant data) Excellent (eventually) Excellent (faster)
    Domain expertise needed High (architecture design) Moderate (strategy selection)
    Risk of negative transfer None Possible if domains too different

     

    Fine-Tuning—Techniques and Strategies

    Fine-tuning is the most widely used transfer learning technique: take a model pre-trained on a source task or domain and continue training it on the target data. The concept is simple, but the practice is nuanced.

    Full Fine-Tuning and Partial Fine-Tuning

    Full fine-tuning updates all parameters of the pre-trained model. This affords maximum flexibility to adapt, but also presents the highest risk of overfitting, particularly when the target dataset is small. With 50,000 labelled samples in the target domain, full fine-tuning is generally safe. With 500, it is risky.

    Partial fine-tuning freezes some layers (typically the earlier ones) and updates only the remainder. The reasoning is that early layers learn generic, transferable features (edge detectors in vision, basic temporal patterns in time-series), while later layers learn task-specific features. Freezing early layers preserves the generic knowledge while adapting the task-specific parts.

    Layer-Wise Learning Rate Decay (Discriminative Fine-Tuning)

    Rather than imposing a binary freeze/unfreeze decision, discriminative fine-tuning assigns different learning rates to different layers. Earlier layers receive smaller learning rates (they change slowly), while later layers receive larger learning rates (they require more adaptation). A common approach multiplies the learning rate by a decay factor for each layer moving backwards from the output:

    # Discriminative learning rates in PyTorch
    def get_discriminative_params(model, base_lr=1e-3, decay_factor=0.9):
        """Assign decreasing learning rates to earlier layers."""
        params = []
        layers = list(model.named_parameters())
        n_layers = len(layers)
    
        for i, (name, param) in enumerate(layers):
            # Earlier layers get smaller LR
            layer_lr = base_lr * (decay_factor ** (n_layers - i - 1))
            params.append({
                'params': param,
                'lr': layer_lr,
                'name': name
            })
    
        return params
    
    # Usage
    param_groups = get_discriminative_params(model, base_lr=1e-3, decay_factor=0.85)
    optimizer = torch.optim.AdamW(param_groups)

    Gradual Unfreezing

    Gradual unfreezing begins by training only the final layer (or layers), then progressively unfreezes earlier layers as training proceeds. This prevents early layers from being corrupted by the large gradients that occur at the start of fine-tuning when the loss is high. The strategy was popularised by ULMFiT (Universal Language Model Fine-tuning) and works well for both NLP and time-series tasks.

    The Fine-Tuning Decision Matrix

    The appropriate fine-tuning strategy depends on two factors: the amount of available target data and the similarity between source and target domains.

    Scenario Target Data Size Domain Similarity Recommended Strategy
    A Small (<1k) High Feature extraction only (freeze all, train classifier head)
    B Small (<1k) Low Fine-tune final layers with aggressive regularization
    C Large (>10k) High Full fine-tuning with small learning rate
    D Large (>10k) Low Full fine-tuning or train from scratch

     

    For cobots that share kinematic structure but differ in brand, the situation falls firmly in the high domain similarity column. When labelled data for the target brand is limited (a common case), Scenario A applies, calling for feature extraction or minimal fine-tuning. When substantial data is available, Scenario C applies, with gentle full fine-tuning.

    Regularisation During Fine-Tuning

    Fine-tuning on small datasets risks catastrophic forgetting, in which the model loses what it learned during pre-training. Several regularisation techniques help mitigate this risk:

    • L2-SP (L2 penalty toward starting point). Instead of penalising weights toward zero, penalise them toward their pre-trained values. This keeps the model close to the pre-trained solution while allowing adaptation.
    • Dropout. Especially effective when added to fine-tuning layers. Typical values are 0.1 to 0.3 during fine-tuning, compared with 0.5 during training from scratch.
    • Early stopping. Monitor validation loss on the target domain and halt training when it begins to increase. With small target datasets, overfitting can occur within a few epochs.
    • Weight decay. Standard L2 regularisation remains effective, typically at 0.01 to 0.1 during fine-tuning.

    Modern Parameter-Efficient Fine-Tuning

    Full fine-tuning updates millions or billions of parameters, which is computationally expensive and requires storing a full copy of the model per task. Parameter-efficient fine-tuning (PEFT) methods address this constraint by updating only a small subset of parameters:

    • LoRA (Low-Rank Adaptation). Injects low-rank matrices into each layer. Rather than updating a weight matrix W directly, LoRA decomposes the update as ΔW = BA, where B and A are low-rank matrices. This reduces trainable parameters by a factor of approximately 10,000 while preserving performance.
    • QLoRA. Combines LoRA with 4-bit quantisation of the base model, enabling fine-tuning of large models on a single consumer GPU.
    • Adapters. Small bottleneck modules inserted between existing layers. Only adapter parameters are trained; the remainder remains frozen.
    • Prefix Tuning and Prompt Tuning. Prepend learnable vectors to the input or hidden states. These approaches originated in NLP but are conceptually applicable to any sequence model.
    Tip: For the cobot scenario, LoRA is particularly attractive. A practitioner can maintain a single base anomaly detection model and keep small per-brand LoRA adapters (a few MB each). Switching between brands consists of swapping the adapter weights.

    Fine-Tuning Code Example

    The following is a complete example of fine-tuning a PyTorch model with layer freezing and discriminative learning rates for a time-series anomaly detection task:

    import torch
    import torch.nn as nn
    
    
    class CobotAnomalyModel(nn.Module):
        """1D-CNN feature extractor + classifier for cobot anomaly detection."""
    
        def __init__(self, n_joints=6, n_features_per_joint=4, seq_len=200):
            super().__init__()
            in_channels = n_joints * n_features_per_joint  # 24 input channels
    
            # Feature extractor (transferable layers)
            self.features = nn.Sequential(
                nn.Conv1d(in_channels, 64, kernel_size=7, padding=3),
                nn.BatchNorm1d(64),
                nn.ReLU(),
                nn.Conv1d(64, 128, kernel_size=5, padding=2),
                nn.BatchNorm1d(128),
                nn.ReLU(),
                nn.AdaptiveAvgPool1d(1)
            )
    
            # Classifier head (task-specific)
            self.classifier = nn.Sequential(
                nn.Linear(128, 64),
                nn.ReLU(),
                nn.Dropout(0.3),
                nn.Linear(64, 2)  # normal vs anomaly
            )
    
        def forward(self, x):
            # x shape: (batch, channels, seq_len)
            feat = self.features(x).squeeze(-1)
            return self.classifier(feat)
    
    
    def fine_tune_for_new_brand(
        pretrained_model,
        target_loader,
        val_loader,
        freeze_features=True,
        base_lr=1e-3,
        n_epochs=30
    ):
        """Fine-tune a pre-trained cobot model for a new brand."""
        model = pretrained_model
    
        if freeze_features:
            # Strategy A: freeze feature extractor, train only classifier
            for param in model.features.parameters():
                param.requires_grad = False
            optimizer = torch.optim.Adam(
                model.classifier.parameters(), lr=base_lr
            )
        else:
            # Strategy C: discriminative learning rates
            param_groups = [
                {'params': model.features.parameters(), 'lr': base_lr * 0.1},
                {'params': model.classifier.parameters(), 'lr': base_lr},
            ]
            optimizer = torch.optim.Adam(param_groups)
    
        criterion = nn.CrossEntropyLoss()
        best_val_loss = float('inf')
        patience_counter = 0
    
        for epoch in range(n_epochs):
            model.train()
            for batch_x, batch_y in target_loader:
                optimizer.zero_grad()
                output = model(batch_x)
                loss = criterion(output, batch_y)
                loss.backward()
                optimizer.step()
    
            # Validation and early stopping
            model.eval()
            val_loss = 0
            with torch.no_grad():
                for batch_x, batch_y in val_loader:
                    output = model(batch_x)
                    val_loss += criterion(output, batch_y).item()
    
            val_loss /= len(val_loader)
            if val_loss < best_val_loss:
                best_val_loss = val_loss
                patience_counter = 0
                torch.save(model.state_dict(), 'best_model.pt')
            else:
                patience_counter += 1
                if patience_counter >= 5:
                    print(f"Early stopping at epoch {epoch}")
                    break
    
        model.load_state_dict(torch.load('best_model.pt'))
        return model

    Fine-Tuning Strategy Selection Matrix ↑ Low Domain Similarity (High Distribution Gap) Target Data Size Small Data · Low Similarity Freeze all layers Train classifier head only + Aggressive regularization Scenario B Small Data · High Similarity Feature extraction Freeze feature extractor Cobot cross-brand: ideal fit Scenario A ← You are here Large Data · Low Similarity Full fine-tuning or train from scratch Scenario D Large Data · High Similarity Full fine-tuning Small learning rate (1e-4) Scenario C ← Small Target Data Large Target Data →

    Domain Adaptation: Bridging the Distribution Gap

    Whereas fine-tuning assumes that at least some labelled data is available in the target domain, domain adaptation addresses a harder problem: substantial labelled data in the source domain, but no labels at all in the target domain. This is unsupervised domain adaptation (UDA), the most common and challenging scenario in real-world deployments.

    Formal Definition

    In domain adaptation, source and target domains share the same task (for example, anomaly detection) but have different data distributions. Formally: PS(X) ≠ PT(X), while the labelling function is identical. The objective is to learn a model that performs well on the target distribution despite being trained primarily on the source distribution.

    Several types of distribution shift can occur:

    • Covariate shift. P(X) changes while P(Y|X) remains constant. The input distributions differ but the relationship between inputs and outputs is preserved. This is the most common scenario for cobots: sensor data distributions differ across brands, while the definition of “anomaly” remains consistent.
    • Label shift. P(Y) changes while P(X|Y) remains constant. The prior probability of classes changes. For example, one brand may have a 2% anomaly rate while another has 5%.
    • Concept drift. P(Y|X) changes—the same input has different meanings in different domains. This is rare for same-structure cobots but can arise when different brands define “normal operating range” differently.

    Key Unsupervised Domain Adaptation Methods

    Discrepancy-Based Methods

    These methods explicitly measure and minimise the distance between source and target feature distributions.

    Maximum Mean Discrepancy (MMD) measures the distance between two distributions by comparing their mean embeddings in a reproducing kernel Hilbert space (RKHS). If the mean embeddings are identical, the distributions are identical (for characteristic kernels). In practice, an MMD penalty is added to the training loss to encourage the network to produce similar feature distributions for source and target data.

    CORAL (CORrelation ALignment) aligns the second-order statistics (covariance matrices) of source and target features. Deep CORAL integrates this alignment into the network by adding a CORAL loss at one or more hidden layers. The CORAL loss is the Frobenius norm of the difference between source and target covariance matrices.

    Adversarial-Based Methods

    These methods use an adversarial framework to learn domain-invariant features—features that are useful for the task but cannot be used by a discriminator to distinguish between source and target domains.

    Domain-Adversarial Neural Networks (DANN) represent the principal approach. The architecture has three components: a shared feature extractor, a task classifier (for anomaly detection), and a domain discriminator. The key element is the gradient reversal layer (GRL): during backpropagation, gradients from the domain discriminator are reversed before reaching the feature extractor. The feature extractor is thus trained to maximise the domain discriminator’s loss—that is, to produce features that confuse the discriminator about which domain the data came from.

    ADDA (Adversarial Discriminative Domain Adaptation) uses separate feature extractors for source and target, with the target extractor initialised from the source. The adversarial dynamic operates between the target encoder and the discriminator.

    CyCADA (Cycle-Consistent Adversarial Domain Adaptation) combines pixel-level adaptation (using CycleGAN-style image translation) with feature-level adaptation. Although primarily used for visual tasks, the concept of cycle-consistent adaptation extends to other modalities.

    DANN Architecture—Domain-Adversarial Neural Network Source Data UR5e (labeled) Target Data FANUC (unlabeled) Feature Extractor Shared Encoder 1D-CNN / Transformer Task Classifier Anomaly Detection Normal / Anomalous Task Loss Cross-entropy Gradient Reversal Layer Domain Classifier Source / Target? Binary discriminator Domain Loss GRL reverses domain gradients during backprop → feature extractor learns to confuse the discriminator Training Objective min (Task Loss)—Feature extractor minimizes anomaly detection error on labeled source data min (Domain Loss via GRL),Feature extractor maximizes domain confusion → domain-invariant features

    Self-Training and Pseudo-Labelling

    Self-training is conceptually simple but often effective: train on labelled source data, generate predictions (pseudo-labels) on unlabelled target data, and retrain on the combined dataset. The principal challenges are noise in the pseudo-labels and confirmation bias. Modern approaches use confidence thresholding (retaining only high-confidence pseudo-labels) and curriculum learning (beginning with the most confident predictions and gradually including less confident ones).

    Optimal Transport Methods

    Optimal transport provides a mathematically principled means of measuring and minimising the distance between distributions using the Wasserstein distance. It identifies the minimum cost of transforming one distribution into another and can be used to explicitly map source features to target features.

    Advanced Domain Adaptation Scenarios

    The standard UDA setup assumes one source and one target domain. Real-world scenarios are often more complex:

    • Multi-source domain adaptation. Labelled data is available from multiple source domains (for example, three cobot brands), and the objective is to adapt to a new target brand. Methods such as MDAN (Multi-source Domain Adversarial Networks) and M3SDA handle this by learning domain-specific and domain-shared features simultaneously.
    • Partial domain adaptation. The target domain contains fewer classes than the source. For example, the source model detects 10 types of anomalies, but the target brand exhibits only six of them. Standard UDA methods can perform poorly because they attempt to align classes that do not exist in the target.
    • Open-set domain adaptation. The target domain contains classes not seen in the source. This is realistic for cobots: a new brand may exhibit failure modes absent from the training data. Methods must both adapt known classes and detect unknown target-specific anomalies.

    Method Comparison

    Method Mechanism Best When Complexity Performance
    MMD Match kernel mean embeddings Small domain gap, clean data Low Good baseline
    CORAL Align covariance matrices Linear shifts between domains Low Good for simple shifts
    DANN Adversarial domain confusion Complex nonlinear shifts Medium Strong across scenarios
    Self-Training Pseudo-label target data High-confidence predictions available Low Variable (depends on pseudo-label quality)
    Optimal Transport Wasserstein distance minimization Strong theoretical guarantees needed High Strong but computationally expensive

     

    DANN Implementation with Gradient Reversal Layer

    The following is a complete PyTorch implementation of a Domain-Adversarial Neural Network:

    import torch
    import torch.nn as nn
    from torch.autograd import Function
    
    
    class GradientReversalFunction(Function):
        """Gradient Reversal Layer (GRL).
    
        Forward pass: identity function.
        Backward pass: negate gradients and scale by lambda.
        """
        @staticmethod
        def forward(ctx, x, lambda_val):
            ctx.lambda_val = lambda_val
            return x.clone()
    
        @staticmethod
        def backward(ctx, grad_output):
            return -ctx.lambda_val * grad_output, None
    
    
    class GradientReversalLayer(nn.Module):
        def __init__(self, lambda_val=1.0):
            super().__init__()
            self.lambda_val = lambda_val
    
        def forward(self, x):
            return GradientReversalFunction.apply(x, self.lambda_val)
    
    
    class DANN(nn.Module):
        """Domain-Adversarial Neural Network for time-series data."""
    
        def __init__(self, n_input_channels=24, n_classes=2, n_domains=2):
            super().__init__()
    
            # Shared feature extractor
            self.feature_extractor = nn.Sequential(
                nn.Conv1d(n_input_channels, 64, kernel_size=7, padding=3),
                nn.BatchNorm1d(64),
                nn.ReLU(),
                nn.Conv1d(64, 128, kernel_size=5, padding=2),
                nn.BatchNorm1d(128),
                nn.ReLU(),
                nn.Conv1d(128, 256, kernel_size=3, padding=1),
                nn.BatchNorm1d(256),
                nn.ReLU(),
                nn.AdaptiveAvgPool1d(1),  # Global average pooling
            )
    
            # Task classifier (anomaly detection)
            self.task_classifier = nn.Sequential(
                nn.Linear(256, 128),
                nn.ReLU(),
                nn.Dropout(0.3),
                nn.Linear(128, n_classes),
            )
    
            # Domain discriminator
            self.domain_discriminator = nn.Sequential(
                GradientReversalLayer(lambda_val=1.0),
                nn.Linear(256, 128),
                nn.ReLU(),
                nn.Dropout(0.3),
                nn.Linear(128, n_domains),
            )
    
        def forward(self, x):
            features = self.feature_extractor(x).squeeze(-1)
            task_output = self.task_classifier(features)
            domain_output = self.domain_discriminator(features)
            return task_output, domain_output
    
        def set_lambda(self, lambda_val):
            """Update GRL lambda (schedule during training)."""
            for module in self.domain_discriminator.modules():
                if isinstance(module, GradientReversalLayer):
                    module.lambda_val = lambda_val
    
    
    def train_dann(model, source_loader, target_loader, n_epochs=50, device='cpu'):
        """Train DANN with progressive lambda scheduling."""
        optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
        task_criterion = nn.CrossEntropyLoss()
        domain_criterion = nn.CrossEntropyLoss()
    
        model.to(device)
    
        for epoch in range(n_epochs):
            model.train()
    
            # Progressive lambda: 0 -> 1 over training
            p = epoch / n_epochs
            lambda_val = 2.0 / (1.0 + torch.exp(torch.tensor(-10.0 * p))) - 1.0
            model.set_lambda(lambda_val.item())
    
            # Iterate over both loaders simultaneously
            target_iter = iter(target_loader)
    
            for source_x, source_y in source_loader:
                try:
                    target_x, _ = next(target_iter)
                except StopIteration:
                    target_iter = iter(target_loader)
                    target_x, _ = next(target_iter)
    
                source_x = source_x.to(device)
                source_y = source_y.to(device)
                target_x = target_x.to(device)
    
                # Source domain: label = 0
                source_task_out, source_domain_out = model(source_x)
                source_domain_labels = torch.zeros(
                    source_x.size(0), dtype=torch.long, device=device
                )
    
                # Target domain: label = 1 (no task labels!)
                _, target_domain_out = model(target_x)
                target_domain_labels = torch.ones(
                    target_x.size(0), dtype=torch.long, device=device
                )
    
                # Combined loss
                task_loss = task_criterion(source_task_out, source_y)
                domain_loss = domain_criterion(source_domain_out, source_domain_labels) \
                            + domain_criterion(target_domain_out, target_domain_labels)
    
                total_loss = task_loss + domain_loss
    
                optimizer.zero_grad()
                total_loss.backward()
                optimizer.step()
    
            if (epoch + 1) % 10 == 0:
                print(f"Epoch {epoch+1}/{n_epochs} | "
                      f"Task Loss: {task_loss.item():.4f} | "
                      f"Domain Loss: {domain_loss.item():.4f} | "
                      f"Lambda: {lambda_val.item():.4f}")
    Key Takeaway: The gradient reversal layer is central to DANN. It causes the feature extractor to learn representations that simultaneously minimise the task classification loss and maximise the domain classification loss. The result is a set of features that are useful for anomaly detection while remaining brand-agnostic.

    The Cobot Anomaly Detection Scenario

    Consider applying the foregoing material to a concrete, industrially relevant problem. A factory operates multiple collaborative robots from different manufacturers: Universal Robots UR5e, FANUC CRX-10iA, ABB GoFa, KUKA LBR iiwa, and Doosan M1013. All are six- or seven-axis articulated arms performing similar tasks, and all generate sensor data: joint torques, positions, velocities, and motor currents.

    The objective is one anomaly detection system that works across all brands, or, at minimum, a system that can be quickly adapted to a new brand without collecting thousands of labelled anomaly examples.

    The challenge is that, despite a shared kinematic structure, each brand has fundamentally different data distributions, owing to:

    • Sensor characteristics. Different torque sensor resolutions, noise floors, and sampling rates (125 Hz, 500 Hz, or 1 kHz).
    • Control systems. Different PID gains, trajectory planning algorithms, and jerk limits.
    • Calibration. Different zero-point offsets, gear ratio tolerances, and friction models.
    • Firmware. Different interpolation methods, filtering strategies, and data encoding.

    Six strategies are now examined, ranging from simple preprocessing to sophisticated neural domain adaptation.

    Strategy 1: Domain-Invariant Feature Learning with DANN

    This is the most principled approach. Using the DANN architecture from the previous section, the practitioner trains on labelled data from one brand (for example, the UR5e, the most common cobot with the most available data) and uses unlabelled data from other brands during training. The gradient reversal layer requires the feature extractor to learn representations that capture anomaly-relevant patterns while remaining invariant to brand-specific sensor characteristics.

    import torch
    import torch.nn as nn
    from torch.utils.data import Dataset, DataLoader
    import numpy as np
    
    
    class CobotSensorDataset(Dataset):
        """Dataset for multi-joint cobot sensor data.
    
        Each sample: (n_joints * n_features, seq_len) tensor
        Features per joint: torque, position, velocity, current
        """
        def __init__(self, data, labels, domain_id):
            self.data = torch.FloatTensor(data)       # (N, channels, seq_len)
            self.labels = torch.LongTensor(labels)     # (N,) - 0=normal, 1=anomaly
            self.domain_id = domain_id
    
        def __len__(self):
            return len(self.data)
    
        def __getitem__(self, idx):
            return self.data[idx], self.labels[idx], self.domain_id
    
    
    class CobotDANN(nn.Module):
        """DANN specifically designed for cobot anomaly detection.
    
        Input: multi-joint sensor data (6 joints x 4 features = 24 channels)
        Task: binary anomaly detection
        Domain: cobot brand identification (adversarial)
        """
        def __init__(self, n_joints=6, features_per_joint=4, n_brands=5):
            super().__init__()
            in_ch = n_joints * features_per_joint
    
            self.encoder = nn.Sequential(
                # Block 1: capture local temporal patterns
                nn.Conv1d(in_ch, 64, kernel_size=7, padding=3),
                nn.BatchNorm1d(64),
                nn.ReLU(),
                nn.MaxPool1d(2),
    
                # Block 2: capture mid-range dependencies
                nn.Conv1d(64, 128, kernel_size=5, padding=2),
                nn.BatchNorm1d(128),
                nn.ReLU(),
                nn.MaxPool1d(2),
    
                # Block 3: high-level features
                nn.Conv1d(128, 256, kernel_size=3, padding=1),
                nn.BatchNorm1d(256),
                nn.ReLU(),
                nn.AdaptiveAvgPool1d(1),
            )
    
            self.anomaly_head = nn.Sequential(
                nn.Linear(256, 128),
                nn.ReLU(),
                nn.Dropout(0.3),
                nn.Linear(128, 2),
            )
    
            self.domain_head = nn.Sequential(
                GradientReversalLayer(lambda_val=1.0),
                nn.Linear(256, 128),
                nn.ReLU(),
                nn.Dropout(0.3),
                nn.Linear(128, n_brands),
            )
    
        def forward(self, x):
            features = self.encoder(x).squeeze(-1)
            anomaly_pred = self.anomaly_head(features)
            domain_pred = self.domain_head(features)
            return anomaly_pred, domain_pred, features
    
        def predict_anomaly(self, x):
            """Inference: only anomaly prediction needed."""
            features = self.encoder(x).squeeze(-1)
            return self.anomaly_head(features)

    Strategy 2: Multi-Source Domain Adaptation

    When data from multiple brands is available, all sources can be used simultaneously. The key idea is to use domain-specific batch normalisation: each brand receives its own BN layer to handle its distinctive distribution statistics, while all other weights remain shared. This captures the intuition that different brands have different means and variances in their sensor data, but the learned features (convolution filters) should be universal.

    class DomainSpecificBatchNorm(nn.Module):
        """Maintain separate BN statistics per domain (brand)."""
    
        def __init__(self, n_features, n_domains):
            super().__init__()
            self.bn_layers = nn.ModuleList([
                nn.BatchNorm1d(n_features) for _ in range(n_domains)
            ])
            self.n_domains = n_domains
    
        def forward(self, x, domain_id):
            if self.training:
                return self.bn_layers[domain_id](x)
            else:
                # At inference: use the specified domain's statistics
                return self.bn_layers[domain_id](x)
    
        def add_domain(self):
            """Add BN layer for a new brand — initialize from average of existing."""
            new_bn = nn.BatchNorm1d(self.bn_layers[0].num_features)
    
            # Initialize with average statistics across existing domains
            with torch.no_grad():
                avg_mean = torch.stack(
                    [bn.running_mean for bn in self.bn_layers]
                ).mean(0)
                avg_var = torch.stack(
                    [bn.running_var for bn in self.bn_layers]
                ).mean(0)
                new_bn.running_mean.copy_(avg_mean)
                new_bn.running_var.copy_(avg_var)
    
            self.bn_layers.append(new_bn)
            self.n_domains += 1
    
    
    class MultiSourceCobotModel(nn.Module):
        """Multi-source model with domain-specific batch normalization."""
    
        def __init__(self, n_joints=6, features_per_joint=4, n_brands=5):
            super().__init__()
            in_ch = n_joints * features_per_joint
    
            self.conv1 = nn.Conv1d(in_ch, 64, kernel_size=7, padding=3)
            self.bn1 = DomainSpecificBatchNorm(64, n_brands)
    
            self.conv2 = nn.Conv1d(64, 128, kernel_size=5, padding=2)
            self.bn2 = DomainSpecificBatchNorm(128, n_brands)
    
            self.conv3 = nn.Conv1d(128, 256, kernel_size=3, padding=1)
            self.bn3 = DomainSpecificBatchNorm(256, n_brands)
    
            self.pool = nn.AdaptiveAvgPool1d(1)
            self.classifier = nn.Sequential(
                nn.Linear(256, 128),
                nn.ReLU(),
                nn.Dropout(0.3),
                nn.Linear(128, 2),
            )
    
        def forward(self, x, domain_id=0):
            x = torch.relu(self.bn1(self.conv1(x), domain_id))
            x = torch.relu(self.bn2(self.conv2(x), domain_id))
            x = torch.relu(self.bn3(self.conv3(x), domain_id))
            x = self.pool(x).squeeze(-1)
            return self.classifier(x)
    Tip: When a new brand is introduced, call model.bn1.add_domain(), model.bn2.add_domain(), and so on. Then pass a few hundred unlabelled samples from the new brand through the model to calibrate the new BN statistics. No labelled data is required for initial deployment.

    Strategy 3: Fine-Tuning with Normalisation Alignment

    This is the pragmatic approach. Pre-train a full anomaly detection model on the best-labelled brand (for example, the UR5e with 50,000 labelled samples). When adapting to a new brand, freeze all convolutional and LSTM weights and fine-tune only the batch normalisation layers and the final classifier head.

    The reason this approach is effective is that the kinematic structure is the same across brands. The convolutional filters that detect “sudden torque spike in joint 3” or “velocity reversal pattern” are essentially the same regardless of brand. What differs is the statistical distribution of the data, which is precisely what batch normalisation captures.

    def bn_only_fine_tune(pretrained_model, target_loader, n_epochs=10, lr=1e-3):
        """Fine-tune only BatchNorm layers + classifier for a new cobot brand.
    
        This is the fastest adaptation strategy: typically converges in
        5-10 epochs with as few as 100-500 labeled samples.
        """
        model = pretrained_model
    
        # Freeze everything
        for param in model.parameters():
            param.requires_grad = False
    
        # Unfreeze only BatchNorm parameters and classifier
        for module in model.modules():
            if isinstance(module, nn.BatchNorm1d):
                for param in module.parameters():
                    param.requires_grad = True
                # Reset running statistics for the new domain
                module.reset_running_stats()
    
        for param in model.classifier.parameters():
            param.requires_grad = True
    
        # Collect trainable params
        trainable = [p for p in model.parameters() if p.requires_grad]
        optimizer = torch.optim.Adam(trainable, lr=lr)
        criterion = nn.CrossEntropyLoss()
    
        print(f"Trainable parameters: {sum(p.numel() for p in trainable):,}")
        print(f"Total parameters: {sum(p.numel() for p in model.parameters()):,}")
    
        for epoch in range(n_epochs):
            model.train()
            total_loss = 0
            correct = 0
            total = 0
    
            for batch_x, batch_y in target_loader:
                optimizer.zero_grad()
                output = model(batch_x)
                loss = criterion(output, batch_y)
                loss.backward()
                optimizer.step()
    
                total_loss += loss.item()
                predicted = output.argmax(dim=1)
                correct += (predicted == batch_y).sum().item()
                total += batch_y.size(0)
    
            acc = 100.0 * correct / total
            avg_loss = total_loss / len(target_loader)
            print(f"Epoch {epoch+1}/{n_epochs} | Loss: {avg_loss:.4f} | Acc: {acc:.1f}%")
    
        return model

    Strategy 4: Contrastive Domain Adaptation

    Contrastive learning offers a strong alternative to adversarial approaches. The core idea is to learn an embedding space in which “normal” operation from any brand maps to similar representations, while “anomalous” patterns remain distinguishable regardless of the brand that produced them.

    A Supervised Contrastive (SupCon) loss is used. It pulls together embeddings of the same class (normal or anomaly) regardless of brand, while pushing apart embeddings of different classes:

    class SupConDomainLoss(nn.Module):
        """Supervised contrastive loss that ignores domain (brand) labels.
    
        Positive pairs: same anomaly class, any brand
        Negative pairs: different anomaly class, any brand
    
        This forces brand-invariant but anomaly-discriminative embeddings.
        """
        def __init__(self, temperature=0.07):
            super().__init__()
            self.temperature = temperature
    
        def forward(self, features, labels):
            """
            Args:
                features: (batch_size, feature_dim) - L2-normalized embeddings
                labels: (batch_size,) - anomaly labels (0=normal, 1=anomaly)
            """
            device = features.device
            batch_size = features.shape[0]
    
            # Pairwise similarity matrix
            similarity = torch.matmul(features, features.T) / self.temperature
    
            # Mask: 1 where labels match (positive pairs), 0 otherwise
            labels = labels.unsqueeze(1)
            mask = torch.eq(labels, labels.T).float().to(device)
    
            # Remove self-similarity from mask
            self_mask = torch.eye(batch_size, device=device)
            mask = mask - self_mask
    
            # Numerical stability
            logits_max = similarity.max(dim=1, keepdim=True).values.detach()
            logits = similarity - logits_max
    
            # Denominator: all pairs except self
            exp_logits = torch.exp(logits) * (1 - self_mask)
            log_prob = logits - torch.log(exp_logits.sum(dim=1, keepdim=True) + 1e-8)
    
            # Average over positive pairs
            n_positives = mask.sum(dim=1)
            mean_log_prob = (mask * log_prob).sum(dim=1) / (n_positives + 1e-8)
    
            loss = -mean_log_prob[n_positives > 0].mean()
            return loss
    
    
    class ContrastiveCobotModel(nn.Module):
        """Contrastive model for cross-brand cobot anomaly detection."""
    
        def __init__(self, n_input_channels=24, embed_dim=128):
            super().__init__()
    
            self.encoder = nn.Sequential(
                nn.Conv1d(n_input_channels, 64, kernel_size=7, padding=3),
                nn.BatchNorm1d(64),
                nn.ReLU(),
                nn.Conv1d(64, 128, kernel_size=5, padding=2),
                nn.BatchNorm1d(128),
                nn.ReLU(),
                nn.Conv1d(128, 256, kernel_size=3, padding=1),
                nn.BatchNorm1d(256),
                nn.ReLU(),
                nn.AdaptiveAvgPool1d(1),
            )
    
            # Projection head for contrastive learning
            self.projector = nn.Sequential(
                nn.Linear(256, 256),
                nn.ReLU(),
                nn.Linear(256, embed_dim),
            )
    
            # Classifier for anomaly detection
            self.classifier = nn.Linear(256, 2)
    
        def forward(self, x):
            features = self.encoder(x).squeeze(-1)
            projections = nn.functional.normalize(self.projector(features), dim=1)
            logits = self.classifier(features)
            return logits, projections

    Strategy 5: Feature Normalisation and Preprocessing

    Before turning to neural domain adaptation, consider whether simple preprocessing can eliminate the distribution gap. This straightforward approach is often underused and is sometimes sufficient on its own:

    import numpy as np
    from scipy.interpolate import interp1d
    
    
    class CobotSignalNormalizer:
        """Normalize sensor signals to a common reference frame across brands.
    
        This preprocessing pipeline handles:
        1. Sampling rate alignment (resample to common rate)
        2. Per-joint Z-score normalization (per brand statistics)
        3. Torque residual computation (remove gravity/friction effects)
        4. Signal clipping for outlier robustness
        """
    
        def __init__(self, target_sample_rate=250, target_seq_len=200):
            self.target_sample_rate = target_sample_rate
            self.target_seq_len = target_seq_len
            self.brand_stats = {}  # {brand: {joint: {feature: (mean, std)}}}
    
        def fit_brand(self, brand_name, data):
            """Compute normalization statistics for a brand.
    
            Args:
                brand_name: str, e.g. 'ur5e'
                data: np.array of shape (n_samples, n_joints, n_features, seq_len)
            """
            n_samples, n_joints, n_features, seq_len = data.shape
            stats = {}
            for j in range(n_joints):
                stats[j] = {}
                for f in range(n_features):
                    channel_data = data[:, j, f, :].flatten()
                    stats[j][f] = (
                        float(np.mean(channel_data)),
                        float(np.std(channel_data)) + 1e-8
                    )
            self.brand_stats[brand_name] = stats
    
        def normalize(self, data, brand_name, source_sample_rate):
            """Normalize a batch of sensor data from a specific brand.
    
            Args:
                data: np.array (n_samples, n_joints, n_features, seq_len)
                brand_name: str
                source_sample_rate: int, Hz
    
            Returns:
                Normalized data: np.array (n_samples, n_joints*n_features, target_seq_len)
            """
            n_samples, n_joints, n_features, seq_len = data.shape
    
            # Step 1: Resample to common rate
            if source_sample_rate != self.target_sample_rate:
                source_times = np.linspace(0, 1, seq_len)
                target_times = np.linspace(0, 1, self.target_seq_len)
                resampled = np.zeros(
                    (n_samples, n_joints, n_features, self.target_seq_len)
                )
                for i in range(n_samples):
                    for j in range(n_joints):
                        for f in range(n_features):
                            interpolator = interp1d(
                                source_times, data[i, j, f, :], kind='cubic'
                            )
                            resampled[i, j, f, :] = interpolator(target_times)
                data = resampled
    
            # Step 2: Z-score normalization per joint per feature
            stats = self.brand_stats[brand_name]
            normalized = np.zeros_like(data)
            for j in range(n_joints):
                for f in range(n_features):
                    mean, std = stats[j][f]
                    normalized[:, j, f, :] = (data[:, j, f, :] - mean) / std
    
            # Step 3: Clip to ±5 sigma for robustness
            normalized = np.clip(normalized, -5, 5)
    
            # Step 4: Reshape to (n_samples, channels, seq_len)
            n_samples = normalized.shape[0]
            seq_len = normalized.shape[-1]
            output = normalized.reshape(n_samples, n_joints * n_features, seq_len)
    
            return output

    Strategy 6: Foundation Model Approach

    The most forward-looking approach draws on the emerging ecosystem of time-series foundation models. The pattern is to pre-train a large model on data from all available cobot brands in a self-supervised manner (for example, masked time-series modelling) and then fine-tune for anomaly detection with minimal labelled data from each brand.

    This approach is most appropriate when substantial unlabelled sensor data is available across many brands, which is increasingly common as cobot fleets grow. Models such as Chronos (Amazon), TimesFM (Google), and Lag-Llama have shown that transformer-based architectures can learn transferable representations across diverse time-series domains.

    class CobotFoundationModel(nn.Module):
        """Simplified foundation model for cobot sensor time-series.
    
        Pre-training task: masked sensor reconstruction
        Fine-tuning task: anomaly detection
        """
        def __init__(self, n_channels=24, d_model=256, n_heads=8,
                     n_layers=6, seq_len=200, mask_ratio=0.15):
            super().__init__()
            self.mask_ratio = mask_ratio
    
            # Patch embedding (treat each timestep as a "token")
            self.input_proj = nn.Linear(n_channels, d_model)
            self.pos_embedding = nn.Parameter(
                torch.randn(1, seq_len, d_model) * 0.02
            )
    
            # Transformer encoder
            encoder_layer = nn.TransformerEncoderLayer(
                d_model=d_model,
                nhead=n_heads,
                dim_feedforward=d_model * 4,
                dropout=0.1,
                batch_first=True,
            )
            self.transformer = nn.TransformerEncoder(
                encoder_layer, num_layers=n_layers
            )
    
            # Pre-training head: reconstruct masked timesteps
            self.reconstruction_head = nn.Linear(d_model, n_channels)
    
            # Fine-tuning head: anomaly classification
            self.anomaly_head = nn.Sequential(
                nn.Linear(d_model, 128),
                nn.ReLU(),
                nn.Dropout(0.1),
                nn.Linear(128, 2),
            )
    
        def forward_pretrain(self, x):
            """Pre-training: masked reconstruction.
    
            x: (batch, n_channels, seq_len)
            """
            x = x.transpose(1, 2)  # (batch, seq_len, n_channels)
            batch_size, seq_len, _ = x.shape
    
            # Create random mask
            mask = torch.rand(batch_size, seq_len, device=x.device) < self.mask_ratio
            masked_x = x.clone()
            masked_x[mask] = 0.0
    
            # Encode
            h = self.input_proj(masked_x) + self.pos_embedding[:, :seq_len, :]
            h = self.transformer(h)
    
            # Reconstruct
            reconstruction = self.reconstruction_head(h)
    
            # Loss only on masked positions
            loss = nn.functional.mse_loss(
                reconstruction[mask], x[mask]
            )
            return loss
    
        def forward_anomaly(self, x):
            """Fine-tuning / inference: anomaly detection.
    
            x: (batch, n_channels, seq_len)
            """
            x = x.transpose(1, 2)
            h = self.input_proj(x) + self.pos_embedding[:, :x.size(1), :]
            h = self.transformer(h)
    
            # Global average pooling across time
            h_pooled = h.mean(dim=1)
            return self.anomaly_head(h_pooled)

    Strategy Comparison and Recommendation

    Strategy Labeled Data Needed Complexity Adaptation Speed Expected Performance
    1. DANN Source only Medium-High Slow (retrain) High
    2. Multi-Source BN Multiple sources Medium Fast (BN calibration only) High
    3. BN Fine-Tuning 100-500 target samples Low Very fast (minutes) Good
    4. Contrastive Source + some target Medium-High Moderate High
    5. Normalization None (unsupervised stats) Very Low Instant Moderate
    6. Foundation Model Minimal per brand Very High Fast (once pre-trained) Highest (with scale)

     

    Key Takeaway and Recommended Pipeline: Begin with Strategy 5 (normalisation) combined with Strategy 3 (BN fine-tuning) as the baseline. This combination is fast to implement, requires minimal labelled data, and handles the most common sources of cross-brand distribution shift. If performance is insufficient, escalate to Strategy 1 (DANN) or Strategy 2 (Multi-Source BN). Reserve Strategy 6 (Foundation Model) for organisations with large-scale multi-brand data and the compute budget to match.

    Practical Implementation Guide

    Data Collection for Cobots

    The quality of domain adaptation depends entirely on the quality of the data. For multi-brand cobot anomaly detection, the following considerations apply:

    Sensor selection. At a minimum, collect per-joint torque, position, velocity, and motor current. These four signals per joint provide a comprehensive view of the robot's mechanical state. For a six-axis cobot, this yields 24 sensor channels.

    Sampling rate. Different brands sample at different rates (UR5e at 500 Hz, FANUC at 250 Hz, KUKA at 1 kHz). Either resample to a common rate, or use architectures that accept variable-length inputs.

    Labelling strategy. Labelling anomalies requires domain expertise. A practical approach is to label by operational segment (one pick-and-place cycle) rather than by individual timestep. Use a three-tier scheme—normal, anomalous, and uncertain—and train only on the first two.

    Data volume guidelines. For the source brand, aim for at least 10,000 labelled segments (with at least 500 anomalies). For target brands, even 100 to 500 labelled segments enable effective fine-tuning under Strategy 3 or 5.

    Feature Engineering for Multi-Joint Cobots

    Raw sensor signals can be augmented with engineered features that capture domain-relevant physics:

    • Joint torque residuals. The difference between measured torque and the torque expected from the robot's dynamic model. This removes the "normal" torque component (gravity, inertia, friction) and isolates anomalous forces.
    • Energy consumption profiles. Power = torque × velocity per joint. Anomalies often manifest as unexpected energy consumption patterns before they appear in raw signals.
    • Vibration spectra. FFT of accelerometer or high-frequency torque data. Bearing degradation, gear wear, and loose bolts each have distinctive frequency signatures.
    • Kinematic error metrics. The difference between commanded and actual trajectory. Increasing tracking error often precedes mechanical failure.

    Model Architecture Choices

    Architecture Strengths Weaknesses Best For
    1D-CNN Fast, local pattern detection Limited long-range dependencies Short anomaly patterns, real-time edge
    LSTM/GRU Sequential memory, temporal context Slow training, vanishing gradients Long-term degradation patterns
    LSTM-AutoEncoder Unsupervised, reconstruction-based Threshold tuning, slower inference Minimal labels, novelty detection
    Transformer Global attention, parallelizable Data-hungry, quadratic complexity Large datasets, complex multi-joint patterns
    CNN-LSTM Hybrid Best of both: local + temporal More hyperparameters General-purpose (recommended)

     

    For the cobot scenario, the CNN-LSTM hybrid is typically the best starting point. A complete implementation with domain adaptation support follows:

    class CobotCNNLSTMAutoEncoder(nn.Module):
        """CNN-LSTM AutoEncoder with domain adaptation for cobot anomaly detection.
    
        Architecture:
        - CNN encoder: extracts local temporal features
        - LSTM: captures sequential dependencies
        - CNN decoder: reconstructs input signal
        - Domain discriminator (optional): for DANN-style adaptation
    
        Anomaly score: reconstruction error (MSE)
        """
        def __init__(self, n_channels=24, hidden_dim=128, lstm_layers=2,
                     n_domains=None):
            super().__init__()
    
            # --- Encoder ---
            self.conv_encoder = nn.Sequential(
                nn.Conv1d(n_channels, 64, kernel_size=7, padding=3),
                nn.BatchNorm1d(64),
                nn.ReLU(),
                nn.MaxPool1d(2),
                nn.Conv1d(64, 128, kernel_size=5, padding=2),
                nn.BatchNorm1d(128),
                nn.ReLU(),
                nn.MaxPool1d(2),
            )
    
            self.lstm_encoder = nn.LSTM(
                input_size=128,
                hidden_size=hidden_dim,
                num_layers=lstm_layers,
                batch_first=True,
                bidirectional=True,
                dropout=0.2,
            )
    
            # Bottleneck
            self.bottleneck = nn.Linear(hidden_dim * 2, hidden_dim)
    
            # --- Decoder ---
            self.lstm_decoder = nn.LSTM(
                input_size=hidden_dim,
                hidden_size=hidden_dim,
                num_layers=lstm_layers,
                batch_first=True,
                dropout=0.2,
            )
    
            self.conv_decoder = nn.Sequential(
                nn.Upsample(scale_factor=2),
                nn.Conv1d(hidden_dim, 128, kernel_size=5, padding=2),
                nn.BatchNorm1d(128),
                nn.ReLU(),
                nn.Upsample(scale_factor=2),
                nn.Conv1d(128, 64, kernel_size=7, padding=3),
                nn.BatchNorm1d(64),
                nn.ReLU(),
                nn.Conv1d(64, n_channels, kernel_size=3, padding=1),
            )
    
            # Optional domain discriminator
            self.domain_discriminator = None
            if n_domains is not None:
                self.domain_discriminator = nn.Sequential(
                    GradientReversalLayer(lambda_val=1.0),
                    nn.Linear(hidden_dim, 64),
                    nn.ReLU(),
                    nn.Linear(64, n_domains),
                )
    
        def encode(self, x):
            """Encode input to latent representation.
    
            x: (batch, n_channels, seq_len)
            """
            # CNN encoding
            conv_out = self.conv_encoder(x)  # (batch, 128, seq_len//4)
    
            # LSTM encoding
            conv_out = conv_out.transpose(1, 2)  # (batch, seq_len//4, 128)
            lstm_out, _ = self.lstm_encoder(conv_out)  # (batch, seq_len//4, 256)
    
            # Take last timestep as global representation
            global_repr = lstm_out[:, -1, :]  # (batch, 256)
            latent = self.bottleneck(global_repr)  # (batch, hidden_dim)
    
            return latent, conv_out.shape[1]  # return seq_len for decoder
    
        def decode(self, latent, target_seq_len):
            """Decode latent representation back to signal.
    
            latent: (batch, hidden_dim)
            """
            # Repeat latent for each timestep
            repeated = latent.unsqueeze(1).repeat(1, target_seq_len, 1)
    
            # LSTM decoding
            lstm_out, _ = self.lstm_decoder(repeated)  # (batch, seq_len, hidden_dim)
    
            # CNN decoding
            lstm_out = lstm_out.transpose(1, 2)  # (batch, hidden_dim, seq_len)
            reconstruction = self.conv_decoder(lstm_out)
    
            return reconstruction
    
        def forward(self, x):
            latent, seq_len = self.encode(x)
            reconstruction = self.decode(latent, seq_len)
    
            # Ensure reconstruction matches input size
            if reconstruction.size(2) != x.size(2):
                reconstruction = nn.functional.interpolate(
                    reconstruction, size=x.size(2), mode='linear',
                    align_corners=False
                )
    
            domain_pred = None
            if self.domain_discriminator is not None:
                domain_pred = self.domain_discriminator(latent)
    
            return reconstruction, domain_pred, latent
    
        def anomaly_score(self, x):
            """Compute per-sample anomaly score (reconstruction error)."""
            reconstruction, _, _ = self.forward(x)
            # MSE per sample
            mse = ((x - reconstruction) ** 2).mean(dim=(1, 2))
            return mse
    
    
    def train_cobot_autoencoder(model, source_loader, target_loader=None,
                                n_epochs=100, device='cpu'):
        """Train the CNN-LSTM AutoEncoder with optional domain adaptation."""
        optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)
        scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, n_epochs)
    
        model.to(device)
    
        for epoch in range(n_epochs):
            model.train()
            total_recon_loss = 0
            total_domain_loss = 0
    
            target_iter = iter(target_loader) if target_loader else None
    
            for batch_x, _, _ in source_loader:
                batch_x = batch_x.to(device)
    
                reconstruction, domain_pred, _ = model(batch_x)
    
                # Match sizes if needed
                if reconstruction.size(2) != batch_x.size(2):
                    reconstruction = nn.functional.interpolate(
                        reconstruction, size=batch_x.size(2),
                        mode='linear', align_corners=False
                    )
    
                recon_loss = nn.functional.mse_loss(reconstruction, batch_x)
                total_loss = recon_loss
    
                # Domain adaptation loss (if target data available)
                if target_iter is not None and domain_pred is not None:
                    try:
                        target_x, _, _ = next(target_iter)
                    except StopIteration:
                        target_iter = iter(target_loader)
                        target_x, _, _ = next(target_iter)
    
                    target_x = target_x.to(device)
                    _, target_domain_pred, _ = model(target_x)
    
                    source_domain_labels = torch.zeros(
                        batch_x.size(0), dtype=torch.long, device=device
                    )
                    target_domain_labels = torch.ones(
                        target_x.size(0), dtype=torch.long, device=device
                    )
    
                    domain_loss = (
                        nn.functional.cross_entropy(domain_pred, source_domain_labels)
                        + nn.functional.cross_entropy(target_domain_pred, target_domain_labels)
                    )
                    total_loss += 0.1 * domain_loss
                    total_domain_loss += domain_loss.item()
    
                optimizer.zero_grad()
                total_loss.backward()
                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
                optimizer.step()
    
                total_recon_loss += recon_loss.item()
    
            scheduler.step()
    
            if (epoch + 1) % 10 == 0:
                avg_recon = total_recon_loss / len(source_loader)
                msg = f"Epoch {epoch+1}/{n_epochs} | Recon: {avg_recon:.6f}"
                if target_loader:
                    avg_domain = total_domain_loss / len(source_loader)
                    msg += f" | Domain: {avg_domain:.4f}"
                print(msg)
    
        return model

    Evaluation Metrics

    For production cobot anomaly detection, standard accuracy is uninformative. The class imbalance (often 99% normal and 1% anomaly) makes it trivial to obtain high accuracy by predicting "normal" in every case. The following metrics should be used instead:

    • AUROC (Area Under the ROC Curve). The primary metric. Measures the model's ability to rank anomalous samples above normal samples regardless of threshold. Aim for above 0.95.
    • F1 Score. The harmonic mean of precision and recall at the optimal threshold. Aim for above 0.85.
    • Precision@k. If the top-k most anomalous samples are flagged, the fraction that are true anomalies. This is important for maintenance teams that can investigate only a limited number of alerts per shift.
    • False Positive Rate (FPR). Perhaps the most important metric in production. Each false positive triggers an unnecessary investigation and erodes trust in the system. Target an FPR below 1% at the operating threshold.
    Caution: When evaluating domain adaptation, performance should always be measured on the target domain separately. A model with 0.98 AUROC averaged across all brands may still have 0.85 AUROC on the newest brand, and that is the brand on which performance actually matters.

    Deployment Considerations

    Edge versus cloud. Cobot anomaly detection often must run at the edge, directly on the robot controller or a nearby industrial PC. This constrains model size and inference latency. A CNN-based model with approximately 500K parameters can run inference in under 5 ms on an NVIDIA Jetson. The full CNN-LSTM AutoEncoder (around 2M parameters) requires roughly 20 ms. Transformer models may require cloud deployment.

    Inference latency requirements. For real-time safety-critical detection (such as collision avoidance), sub-10 ms inference is required. For predictive maintenance (detecting degradation patterns), latency of 100 ms to 1 s is acceptable, since trends are analysed over minutes or hours.

    Model update strategy. Domain drift occurs: sensors degrade, firmware updates change data characteristics, and new operating conditions emerge. Plan for periodic recalibration of BN statistics (weekly) and full fine-tuning (monthly) to maintain performance. Use monitoring to trigger updates: if the anomaly score distribution shifts significantly on data known to be normal, the model requires recalibration.

    Putting It Together

    Transfer learning is not a single technique but a paradigm that encompasses fine-tuning, domain adaptation, feature extraction, and additional related approaches. Understanding this hierarchy is the first step toward applying it effectively. Fine-tuning adapts a pre-trained model to new data through continued training. Domain adaptation bridges distribution gaps between source and target domains, even without target labels.

    For heterogeneous cobot fleets, these techniques are not academic luxuries but operational necessities. The alternative is training separate models for every brand, every firmware version, and every operational context. That path produces an unmaintainable accumulation of models, each requiring its own labelled dataset.

    The recommended practical pipeline begins simply: normalise sensor data across brands (Strategy 5) and fine-tune only the batch normalisation layers (Strategy 3). This baseline requires minimal labelled data and can be deployed within hours. If performance falls short, particularly on brands with unusual sensor characteristics, escalate to adversarial domain adaptation (Strategy 1 with DANN) or contrastive methods (Strategy 4). For organisations building long-term cobot intelligence platforms, investment in a foundation model (Strategy 6) yields compounding returns as the fleet grows.

    The code examples throughout this article are complete and runnable. They are not production-ready: proper data loading, logging, checkpointing, and monitoring must be added. They do, however, provide the architectural foundation for any of the six strategies discussed. The most demanding aspect of cross-brand cobot anomaly detection is not the algorithm but the collection of representative data and the establishment of a labelling protocol that domain experts can follow consistently.

    As collaborative robots become as common as industrial PCs on the factory floor, the ability to transfer anomaly detection across brands will distinguish organisations that scale their automation effectively from those that struggle with model maintenance. Transfer learning, fine-tuning, and domain adaptation are the tools that make such scaling possible.

    References

    1. Pan, S. J., & Yang, Q. (2010). A Survey on Transfer Learning. IEEE Transactions on Knowledge and Data Engineering, 22(10), 1345-1359.
    2. Ganin, Y., et al. (2016). Domain-Adversarial Training of Neural Networks. Journal of Machine Learning Research, 17(1), 2096-2030.
    3. Sun, B., & Saenko, K. (2016). Deep CORAL: Correlation Alignment for Deep Domain Adaptation. ECCV Workshops.
    4. Howard, J., & Ruder, S. (2018). Universal Language Model Fine-tuning for Text Classification. ACL 2018.
    5. Hu, E. J., et al. (2022). LoRA: Low-Rank Adaptation of Large Language Models. ICLR 2022.
    6. Ansari, A. F., et al. (2024). Chronos: Learning the Language of Time Series. arXiv preprint arXiv:2403.07815.
    7. Long, M., et al. (2015). Learning Transferable Features with Deep Adaptation Networks. ICML 2015.
    8. Tzeng, E., et al. (2017). Adversarial Discriminative Domain Adaptation. CVPR 2017.
    9. Khosla, P., et al. (2020). Supervised Contrastive Learning. NeurIPS 2020.
    10. Li, Y., et al. (2017). Revisiting Batch Normalization For Practical Domain Adaptation. ICLR Workshop 2017.
    11. Zhao, H., et al. (2018). Adversarial Multiple Source Domain Adaptation. NeurIPS 2018.
    12. Courty, N., et al. (2017). Optimal Transport for Domain Adaptation. IEEE TPAMI, 39(9), 1853-1865.
    13. Das, A., et al. (2024). A Foundation Model for Time Series Analysis. arXiv preprint arXiv:2310.10688 (TimesFM).
    14. ISO/TS 15066:2016. Robots and robotic devices—Collaborative robots. International Organization for Standardization.

    Disclaimer: This article is provided for informational and educational purposes only. Code examples are provided as-is and should be thoroughly tested and validated before use in production environments, particularly in safety-critical robotics applications. Practitioners should follow their organisation's safety protocols and applicable ISO standards when deploying anomaly detection systems on collaborative robots.

  • How to Transfer Data from InfluxDB to AWS Iceberg Using Telegraf: A Complete Data Pipeline Guide

    The short version

    This guide builds a working path from InfluxDB into Apache Iceberg tables on S3, with Telegraf as the extraction-and-transform agent, AWS Glue for cataloguing, and Athena for queries. Telegraf’s plugin model carries most of the load: input plugins read from InfluxDB, processors flatten the tag/field model into a columnar schema, and an output stage lands Hive-partitioned files (year=/month=/day=/) in an S3 landing zone that Glue can crawl into an Iceberg table. Because Telegraf ships no official S3 output, the honest options for that last hop are outputs.file plus a scheduled aws s3 sync, or an outputs.execd script that writes Parquet directly — both covered below. Once catalogued, the data gains ACID semantics, time travel, and schema evolution, so historical records can be backfilled, corrected, and extended without rewriting files. What follows is a complete reference telegraf.conf, four ingestion-automation options (Glue ETL, Athena INSERT, Lambda, and Spark on EMR), monitoring and tuning guidance, an order-of-magnitude cost comparison drawn from published list prices, and a higher-throughput Kafka-and-Spark variant for pipelines beyond roughly one terabyte per day.

    Introduction

    Time-series databases such as InfluxDB earn their place at the moment data is first collected. They ingest high-frequency measurements—factory-floor sensor readings, Kubernetes CPU and memory metrics, per-request application telemetry—with fast writes, aggressive compression, and query paths built specifically for timestamped points. The friction appears later. Once a bucket has grown to terabytes, the retention bill climbs; analysts want to join those metrics against warehouse tables in ordinary SQL; machine-learning engineers need the history as Parquet to train anomaly-detection models; and governance teams begin asking about schema evolution and audit trails. None of those later demands is something a purpose-built metrics store was designed to serve well.

    That accumulation of pressures is what pushes teams toward a lakehouse. Readers still weighing the storage question may find the comparison of databases for preprocessed time-series data useful for deciding whether a lakehouse fits at all. The specific target in this guide is Apache Iceberg on AWS—an open table format that layers ACID transactions, time travel, schema evolution, and partition evolution on top of inexpensive S3 object storage. The open question is mechanical: how to move data out of InfluxDB and into Iceberg reliably, incrementally, and without maintaining a large body of custom ETL code.

    The answer is Telegraf, InfluxData’s open-source agent originally built to collect and ship metrics but now evolved into a remarkably versatile data-pipeline tool with more than three hundred plugins. Telegraf can read from InfluxDB, transform the data on the fly, and land it on S3 in formats that AWS Glue can crawl and convert into Iceberg tables.

    This guide constructs the complete pipeline from scratch. Every configuration file is production-ready, and every SQL statement has been tested. By the end, readers will have a fully operational data pipeline that transfers time-series data from InfluxDB into queryable Iceberg tables on AWS, with sufficient understanding of each component to customise the system for individual use cases.

    Architecture Overview

    Before configuration begins, the full data flow should be understood. The pipeline moves data through five distinct stages:

    InfluxDBTelegraf (Input Plugin)Telegraf (Processors)Telegraf (File/execd Output)S3 Landing ZoneAWS Glue Crawler/ETLIceberg Table on S3Athena/Spark Queries

    In more detail:

    1. InfluxDB holds the raw time-series data in its native line protocol format, organised by measurements, tags, and fields.
    2. Telegraf Input reads data from InfluxDB using either pull-based Flux queries or push-based listener endpoints.
    3. Telegraf Processors transform the data: renaming fields, converting types, extracting date partitions, and flattening the InfluxDB tag/field model into a columnar schema suitable for Iceberg. When the data include sensor metadata alongside measurements, the guide on managing metadata for time-series sensor signals describes how to preserve that context through the migration.
    4. Telegraf output writes the transformed data as JSON or CSV files; because Telegraf has no official S3 output, those files reach the S3 landing zone through a outputs.file-plus-aws s3 sync path or an outputs.execd Parquet writer, organised with Hive-style partitioning (year=2026/month=04/day=03/).
    5. AWS Glue crawls the landing zone, discovers the schema, and either creates or updates an Iceberg table in the Glue Data Catalog.
    6. Athena or Spark queries the Iceberg table using standard SQL, with full support for time travel, partition pruning, and schema evolution.

    Data Pipeline Overview: InfluxDB → Telegraf → S3 → Iceberg → Analytics InfluxDB Time-Series DB Tags · Fields · TS Telegraf Input → Process → Output S3 Landing Zone Parquet / JSON Hive Partitions Apache Iceberg ACID · Time Travel Schema Evolution Analytics Athena · Spark SQL · ML Pipelines Source Pipeline Agent Landing Zone Table Format Query Engines AWS Glue

    Rationale for the Architecture

    The combination of Telegraf and Iceberg addresses four important needs simultaneously:

    • Cost reduction: S3 Standard storage costs approximately $0.023 per GB per month. InfluxDB Cloud (TSM) bills storage at $0.002 per GB-hour on its usage-based plan, which works out to roughly $1.50 per GB per month over a 30-day month (see InfluxData’s published pricing). At 10 TB the storage line therefore falls from the order of $15,000 per month to a few hundred dollars — the exact figure depends on retention, query volume, and plan.
    • SQL analytics: Iceberg tables are queryable with standard SQL via Athena, Spark, Trino, and Presto; neither Flux nor InfluxQL is required.
    • ML pipelines: Data scientists can read Iceberg tables directly as Parquet files for model training, or query them through Spark DataFrames. This facilitates feeding historical data into time-series forecasting models without querying InfluxDB directly.
    • Data governance: Iceberg provides ACID transactions, schema evolution, and time travel—features that InfluxDB was never designed to offer. When events must be streamed from Kafka into this pipeline, the Apache Kafka multivariate time-series engine guide covers the producer side of this architecture.

    Architecture Comparison

    Approach Complexity Real-Time? Schema Transformation Maintenance
    Direct InfluxDB Export (CSV/LP) Low No (batch only) None (manual post-processing) High (scripting)
    Telegraf Pipeline (this guide) Medium Near real-time Built-in processors Low (declarative config)
    Custom ETL (Python/Go) High Yes (configurable) Unlimited flexibility High (code ownership)
    Kafka Connect High Yes (streaming) SMTs + custom connectors Medium (cluster ops)

     

    Key Takeaway: The Telegraf-based pipeline provides an effective balance of flexibility and simplicity. It delivers near-real-time data movement with built-in transformation capabilities, all configured through a single declarative file. There is no JVM to manage, no cluster to operate, and no custom code to maintain.

    Understanding the Components

    It is useful to become familiar with each component before connecting them.

    InfluxDB

    InfluxDB is a purpose-built time-series database developed by InfluxData. It organises data using a distinctive model:

    • Measurements are like tables — they group related time-series data (e.g., cpu, temperature, http_requests).
    • Tags are indexed string key-value pairs used for filtering (e.g., host=server01, region=us-east).
    • Fields are the actual data values, which can be floats, integers, strings, or booleans (e.g., usage_idle=95.2, bytes_sent=1024i).
    • Timestamps are nanosecond-precision Unix timestamps.

    InfluxDB v2.x uses Flux as its query language, whereas v1.x uses InfluxQL (which is SQL-like). The discussion below primarily targets v2.x while noting v1.x alternatives where relevant.

    Telegraf

    Telegraf is InfluxData’s open-source, plugin-driven agent for collecting, processing, and writing metrics and data. Its architecture is built around four types of plugin:

    • Input plugins collect data from various sources (databases, APIs, system metrics, message queues).
    • Processor plugins transform data in-flight (rename, convert, filter, enrich).
    • Aggregator plugins create aggregate metrics (mean, min, max, percentiles) over configurable windows.
    • Output plugins write data to destinations (databases, cloud storage, message queues, HTTP endpoints).

    Telegraf is a single binary with no external dependencies. It consumes minimal resources and can handle hundreds of thousands of metrics per second on modest hardware.

    Telegraf Plugin Architecture INPUT PLUGINS influxdb_v2_listener influxdb (v1 pull) http / mqtt / kafka cpu / mem / disk PROCESSORS rename (field/tag) converter (type cast) starlark (custom) date (partition tags) AGGREGATORS basicstats (min/max) histogram quantile (p50/p99) merge (flush window) OUTPUT PLUGINS file / execd → S3 influxdb_v2 (mirror) kafka / http file (local debug) All four plugin types are configured in a single telegraf.conf—data flows left to right through the pipeline.

    Apache Iceberg

    Apache Iceberg is an open table format designed for substantial analytic datasets. Unlike older formats such as Hive, Iceberg provides:

    • ACID transactions: Concurrent readers and writers never see partial data.
    • Schema evolution: Add, drop, rename, or reorder columns without rewriting data.
    • Partition evolution: Change your partitioning scheme without rewriting existing data.
    • Time travel: Query your data as it existed at any previous point in time.
    • Hidden partitioning: Users write queries against actual columns, not partition columns. Iceberg handles partition pruning automatically.

    On AWS, Iceberg tables reside as Parquet files on S3, with metadata managed by the AWS Glue Data Catalog. They can be queried through Amazon Athena, Amazon EMR (Spark), AWS Glue ETL, or any engine that supports the Iceberg table format.

    Component Characteristics Comparison

    Characteristic InfluxDB Apache Iceberg on S3
    Query Language Flux / InfluxQL Standard SQL (Athena, Spark SQL)
    Storage Cost (per GB/month) ~$1.50 (Cloud TSM, from $0.002/GB-hour) / self-hosted varies ~$0.023 (S3 Standard)
    Data Retention Configurable retention policies Unlimited (S3 lifecycle policies)
    Schema Flexibility Schemaless (tags/fields) Schema evolution with ACID guarantees
    SQL Support Limited (InfluxQL) Full ANSI SQL
    Write Latency Sub-millisecond Seconds to minutes (batch)
    Best For Real-time monitoring, dashboards Analytics, ML, long-term storage

     

    Prerequisites and Setup

    Before constructing the pipeline, each component must be installed and configured. Readers who already have some components running may proceed directly to the sections they require.

    InfluxDB Setup (v2.x)

    For readers who do not yet have InfluxDB running, installation proceeds as follows:

    # Ubuntu/Debian
    wget https://dl.influxdata.com/influxdb/releases/influxdb2_2.7.5-1_amd64.deb
    sudo dpkg -i influxdb2_2.7.5-1_amd64.deb
    sudo systemctl start influxdb
    sudo systemctl enable influxdb
    
    # Initial setup (creates org, bucket, and admin token)
    influx setup \
      --org my-org \
      --bucket metrics \
      --username admin \
      --password SecurePassword123! \
      --token my-super-secret-token \
      --force
    
    # Verify it's running
    influx ping

    For InfluxDB v1.x, the installation is similar but employs a different configuration:

    # InfluxDB v1.x setup
    wget https://dl.influxdata.com/influxdb/releases/influxdb-1.8.10_linux_amd64.tar.gz
    tar xvfz influxdb-1.8.10_linux_amd64.tar.gz
    sudo cp influxdb-1.8.10-1/usr/bin/influxd /usr/local/bin/
    influxd &
    
    # Create database
    influx -execute "CREATE DATABASE metrics"
    influx -execute "CREATE RETENTION POLICY one_year ON metrics DURATION 365d REPLICATION 1 DEFAULT"

    Sample data should also be generated for use throughout this guide:

    # Write sample data to InfluxDB v2.x
    influx write --bucket metrics --org my-org --precision s \
      "cpu,host=server01,region=us-east usage_idle=95.2,usage_system=2.1,usage_user=2.7 $(date +%s)
    cpu,host=server02,region=us-west usage_idle=88.5,usage_system=5.3,usage_user=6.2 $(date +%s)
    memory,host=server01,region=us-east used_percent=42.3,available=8589934592i $(date +%s)
    memory,host=server02,region=us-west used_percent=67.8,available=4294967296i $(date +%s)
    http_requests,endpoint=/api/v1/users,method=GET count=1523i,latency_ms=45.2 $(date +%s)
    http_requests,endpoint=/api/v1/orders,method=POST count=89i,latency_ms=120.5 $(date +%s)"

    Telegraf Installation

    # Ubuntu/Debian (latest stable, 1.39.x as of mid-2026)
    wget https://dl.influxdata.com/telegraf/releases/telegraf_1.39.1-1_amd64.deb
    sudo dpkg -i telegraf_1.39.1-1_amd64.deb
    
    # Verify installation
    telegraf --version
    
    # Generate a default config for reference
    telegraf config > /tmp/telegraf-reference.conf

    AWS Setup

    The S3 bucket should be created and the AWS services configured:

    # Create the S3 bucket for the data pipeline
    aws s3 mb s3://my-timeseries-lakehouse --region us-east-1
    
    # Create directory structure
    aws s3api put-object --bucket my-timeseries-lakehouse --key landing-zone/
    aws s3api put-object --bucket my-timeseries-lakehouse --key iceberg-warehouse/
    
    # Create Glue database
    aws glue create-database --database-input '{
      "Name": "timeseries_db",
      "Description": "Time-series data from InfluxDB via Telegraf pipeline"
    }'
    
    # Configure Athena results location
    aws s3 mb s3://my-timeseries-lakehouse-athena-results --region us-east-1
    aws athena update-work-group \
      --work-group primary \
      --configuration-updates "ResultConfigurationUpdates={OutputLocation=s3://my-timeseries-lakehouse-athena-results/}"

    Required IAM Policy

    Create an IAM policy that grants Telegraf and Glue the permissions they need. Attach this to the IAM user or role used by Telegraf and the Glue service:

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "S3LakehouseAccess",
          "Effect": "Allow",
          "Action": [
            "s3:PutObject",
            "s3:GetObject",
            "s3:DeleteObject",
            "s3:ListBucket",
            "s3:GetBucketLocation"
          ],
          "Resource": [
            "arn:aws:s3:::my-timeseries-lakehouse",
            "arn:aws:s3:::my-timeseries-lakehouse/*"
          ]
        },
        {
          "Sid": "GlueCatalogAccess",
          "Effect": "Allow",
          "Action": [
            "glue:GetDatabase",
            "glue:GetDatabases",
            "glue:CreateTable",
            "glue:UpdateTable",
            "glue:GetTable",
            "glue:GetTables",
            "glue:DeleteTable",
            "glue:GetPartitions",
            "glue:CreatePartition",
            "glue:BatchCreatePartition",
            "glue:UpdatePartition",
            "glue:DeletePartition"
          ],
          "Resource": [
            "arn:aws:glue:us-east-1:ACCOUNT_ID:catalog",
            "arn:aws:glue:us-east-1:ACCOUNT_ID:database/timeseries_db",
            "arn:aws:glue:us-east-1:ACCOUNT_ID:table/timeseries_db/*"
          ]
        },
        {
          "Sid": "AthenaQueryAccess",
          "Effect": "Allow",
          "Action": [
            "athena:StartQueryExecution",
            "athena:GetQueryExecution",
            "athena:GetQueryResults",
            "athena:StopQueryExecution"
          ],
          "Resource": "arn:aws:athena:us-east-1:ACCOUNT_ID:workgroup/primary"
        },
        {
          "Sid": "AthenaResultsAccess",
          "Effect": "Allow",
          "Action": [
            "s3:PutObject",
            "s3:GetObject",
            "s3:ListBucket"
          ],
          "Resource": [
            "arn:aws:s3:::my-timeseries-lakehouse-athena-results",
            "arn:aws:s3:::my-timeseries-lakehouse-athena-results/*"
          ]
        },
        {
          "Sid": "GlueCrawlerAccess",
          "Effect": "Allow",
          "Action": [
            "glue:StartCrawler",
            "glue:GetCrawler",
            "glue:CreateCrawler",
            "glue:UpdateCrawler"
          ],
          "Resource": "arn:aws:glue:us-east-1:ACCOUNT_ID:crawler/*"
        }
      ]
    }
    Caution: Replace ACCOUNT_ID with your actual AWS account ID. In production, further restrict these permissions to specific resources. Never use * for resources in production IAM policies unless absolutely necessary.

    Configure Telegraf to Read from InfluxDB

    The pipeline begins here. Telegraf provides several methods for retrieving data from InfluxDB, each suited to different scenarios. Each is examined below.

    Method A: Using inputs.influxdb_v2 (InfluxDB 2.x — Pull-Based)

    This is the recommended approach for InfluxDB 2.x. Telegraf periodically executes a Flux query and ingests the results.

    # telegraf.conf - Input: InfluxDB v2 (pull-based Flux queries)
    [[inputs.influxdb_v2]]
      ## InfluxDB v2 API URL
      urls = ["http://localhost:8086"]
    
      ## Authentication token
      token = "${INFLUXDB_TOKEN}"
    
      ## Organization name
      organization = "my-org"
    
      ## List of Flux queries to execute
      ## Each query becomes a separate set of metrics
      [[inputs.influxdb_v2.query]]
        ## Bucket to query
        bucket = "metrics"
    
        ## Flux query - pull CPU metrics from the last interval
        query = '''
          from(bucket: "metrics")
            |> range(start: -1h)
            |> filter(fn: (r) => r._measurement == "cpu")
            |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
            |> drop(columns: ["_start", "_stop", "_measurement"])
        '''
    
        ## Override the measurement name
        measurement = "cpu_metrics"
    
      [[inputs.influxdb_v2.query]]
        bucket = "metrics"
        query = '''
          from(bucket: "metrics")
            |> range(start: -1h)
            |> filter(fn: (r) => r._measurement == "memory")
            |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
            |> drop(columns: ["_start", "_stop", "_measurement"])
        '''
        measurement = "memory_metrics"
    
      ## Collection interval - how often to run these queries
      interval = "1h"
    
      ## Timeout for each query
      timeout = "30s"
    Tip: The pivot() function in Flux is essential here. InfluxDB stores each field as a separate row, but a flat columnar layout in which each field becomes its own column is required for Iceberg. Pivoting transforms _field=usage_idle, _value=95.2 into usage_idle=95.2 as a proper column.

    Method B: Using inputs.influxdb (InfluxDB 1.x)

    For InfluxDB v1.x, the legacy input plugin is used:

    # telegraf.conf - Input: InfluxDB v1.x
    [[inputs.influxdb]]
      ## InfluxDB v1.x API URL
      urls = ["http://localhost:8086/debug/vars"]
    
      ## Optional: basic auth
      username = "${INFLUXDB_USER}"
      password = "${INFLUXDB_PASSWORD}"
    
      ## Timeout
      timeout = "10s"
    
      ## Only collect specific measurements
      insecure_skip_verify = false

    The v1.x plugin, however, primarily collects InfluxDB internal metrics. For extracting actual data from a v1.x instance, the HTTP input with InfluxQL is more practical:

    # telegraf.conf - Input: InfluxDB v1.x via HTTP + InfluxQL
    [[inputs.http]]
      urls = [
        "http://localhost:8086/query?db=metrics&q=SELECT+*+FROM+cpu+WHERE+time+>+now()-1h&epoch=ns"
      ]
    
      ## Authentication
      username = "${INFLUXDB_USER}"
      password = "${INFLUXDB_PASSWORD}"
    
      ## Parse the InfluxDB JSON response
      data_format = "json"
      json_query = "results.0.series"
    
      ## How often to poll
      interval = "1h"
      timeout = "30s"

    Method C: Using inputs.http with InfluxDB API (Both Versions)

    This is the most flexible approach, operating with both InfluxDB versions by calling the API directly:

    # telegraf.conf - Input: InfluxDB v2 API via HTTP
    [[inputs.http]]
      ## InfluxDB v2 query API endpoint
      urls = ["http://localhost:8086/api/v2/query?org=my-org"]
    
      ## POST method for Flux queries
      method = "POST"
    
      ## Headers
      [inputs.http.headers]
        Authorization = "Token ${INFLUXDB_TOKEN}"
        Content-Type = "application/vnd.flux"
        Accept = "application/csv"
    
      ## Flux query as the request body
      body = '''
        from(bucket: "metrics")
          |> range(start: -1h)
          |> filter(fn: (r) => r._measurement == "cpu" or r._measurement == "memory")
          |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
      '''
    
      ## Parse the CSV response from InfluxDB
      data_format = "csv"
      csv_header_row_count = 1
      csv_timestamp_column = "_time"
      csv_timestamp_format = "2006-01-02T15:04:05Z"
    
      interval = "1h"
      timeout = "60s"

    Method D: InfluxDB Pushing to Telegraf (Push-Based)

    Rather than Telegraf pulling data, InfluxDB may be configured to push data to Telegraf using the influxdb_listener input. This approach is well suited to real-time pipelines:

    # telegraf.conf - Input: InfluxDB Listener (push-based)
    [[inputs.influxdb_listener]]
      ## Address and port to listen on
      service_address = ":8186"
    
      ## Maximum allowed HTTP body size
      max_body_size = "50MB"
    
      ## Database tag to add (optional)
      database_tag = "source_db"
    
      ## Retention policy tag (optional)
      retention_policy_tag = ""
    
      ## TLS configuration (recommended for production)
      # tls_cert = "/etc/telegraf/cert.pem"
      # tls_key = "/etc/telegraf/key.pem"
    
    ## For InfluxDB v2, use the v2 listener
    [[inputs.influxdb_v2_listener]]
      ## Address to listen on
      service_address = ":8186"
    
      ## Maximum allowed HTTP body size
      max_body_size = "50MB"
    
      ## Authentication token (must match what the sender uses)
      token = "${TELEGRAF_LISTENER_TOKEN}"

    For the push-based approach, InfluxDB or another Telegraf instance is then configured to write to this listener. For InfluxDB 2.x, a task can be used to push data periodically:

    // InfluxDB Task: Push data to Telegraf listener every hour
    option task = {name: "export_to_telegraf", every: 1h}
    
    from(bucket: "metrics")
      |> range(start: -task.every)
      |> filter(fn: (r) => r._measurement == "cpu" or r._measurement == "memory")
      |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
      |> to(
          host: "http://telegraf-host:8186",
          token: "telegraf-listener-token",
          bucket: "pipeline",
          org: "my-org"
      )

    Handling Pagination for Large Datasets

    When backfilling historical data, querying everything at once is impractical. Flux’s range() with windowing should be used instead:

    # For large historical exports, create multiple queries with time windows
    # This Flux query processes data in manageable chunks
    
    from(bucket: "metrics")
      |> range(start: 2025-01-01T00:00:00Z, stop: 2025-02-01T00:00:00Z)
      |> filter(fn: (r) => r._measurement == "cpu")
      |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
      |> limit(n: 100000)
    Key Takeaway: For ongoing incremental synchronisation, Method A (pull-based) or Method D (push-based) is appropriate. For one-time historical backfill, Method C with time-windowed queries is preferable. The push-based approach has the lowest latency but requires configuration on the InfluxDB side.

    Transform Data with Telegraf Processors

    Raw InfluxDB data does not map cleanly to a columnar Iceberg schema. InfluxDB’s tag/field model, dynamic typing, and measurement-centric organisation must be flattened and standardised. Telegraf processors perform this transformation in flight, before the data reach S3.

    Rename Measurements, Tags, and Fields

    # telegraf.conf - Processor: Rename fields to match Iceberg schema
    [[processors.rename]]
      ## Rename measurements
      [[processors.rename.replace]]
        measurement = "cpu"
        dest = "server_cpu_metrics"
    
      [[processors.rename.replace]]
        measurement = "memory"
        dest = "server_memory_metrics"
    
      ## Rename tags
      [[processors.rename.replace]]
        tag = "host"
        dest = "hostname"
    
      ## Rename fields
      [[processors.rename.replace]]
        field = "usage_idle"
        dest = "cpu_idle_percent"
    
      [[processors.rename.replace]]
        field = "usage_system"
        dest = "cpu_system_percent"
    
      [[processors.rename.replace]]
        field = "usage_user"
        dest = "cpu_user_percent"

    Convert Field Types

    InfluxDB may store values as floats when the Iceberg schema expects integers, or vice versa:

    # telegraf.conf - Processor: Convert field types
    [[processors.converter]]
      ## Convert tags to fields (tags are always strings in InfluxDB)
      [processors.converter.tags]
        ## Convert string tags to string fields for columnar storage
        string = ["hostname", "region", "endpoint", "method"]
    
      ## Convert specific fields to different types
      [processors.converter.fields]
        ## Ensure these are always floats
        float = ["cpu_idle_percent", "cpu_system_percent", "cpu_user_percent", "latency_ms"]
    
        ## Ensure these are integers
        integer = ["available", "count"]
    
        ## Convert to unsigned integers if needed
        unsigned = []
    
        ## Convert to boolean
        boolean = []

    Custom Transformations with Starlark

    For complex transformation logic, the Starlark processor permits Python-like scripts. This is the appropriate point at which to flatten the InfluxDB data model into a structure that works well with Iceberg:

    # telegraf.conf - Processor: Starlark custom transformations
    [[processors.starlark]]
      namepass = ["server_cpu_metrics", "server_memory_metrics"]
    
      source = '''
    def apply(metric):
        # Add a computed field: total CPU usage
        if metric.name == "server_cpu_metrics":
            idle = metric.fields.get("cpu_idle_percent", 0.0)
            metric.fields["cpu_total_usage_percent"] = round(100.0 - idle, 2)
    
        # Add data quality flag
        if metric.name == "server_memory_metrics":
            used = metric.fields.get("used_percent", 0.0)
            if used > 95.0:
                metric.fields["memory_critical"] = True
            else:
                metric.fields["memory_critical"] = False
    
        # Normalize region names
        region = metric.tags.get("region", "unknown")
        region_map = {
            "us-east": "us-east-1",
            "us-west": "us-west-2",
            "eu-west": "eu-west-1",
            "ap-south": "ap-southeast-1"
        }
        if region in region_map:
            metric.tags["region"] = region_map[region]
    
        # Add pipeline metadata
        metric.tags["pipeline_version"] = "1.0"
        metric.tags["source_system"] = "influxdb"
    
        return metric
    '''

    Extract Date Partitions

    For Hive-style partitioning on S3 (which AWS Glue expects), the year, month, and day must be extracted from the timestamp:

    # telegraf.conf - Processor: Extract date components for partitioning
    [[processors.date]]
      ## Extract date components from the metric timestamp
      ## These become fields that we'll use for S3 path partitioning
    
      ## Tag name for the year
      tag_key = "partition_year"
      date_format = "2006"
    
    [[processors.date]]
      tag_key = "partition_month"
      date_format = "01"
    
    [[processors.date]]
      tag_key = "partition_day"
      date_format = "02"
    
    [[processors.date]]
      tag_key = "partition_hour"
      date_format = "15"

    Map Tag Values with Enum

    # telegraf.conf - Processor: Map tag values
    [[processors.enum]]
      [[processors.enum.mapping]]
        tag = "method"
        [processors.enum.mapping.value_mappings]
          GET = "read"
          POST = "write"
          PUT = "update"
          DELETE = "delete"
          PATCH = "partial_update"

    Full Transformation Example: Flattening InfluxDB to Columnar

    A complete Starlark processor that converts InfluxDB’s tag/field model into a fully flat record suitable for Iceberg is shown below:

    # telegraf.conf - Processor: Flatten InfluxDB model to columnar
    [[processors.starlark]]
      source = '''
    def apply(metric):
        # Move all tags into fields so everything becomes a column in Iceberg
        # Tags in InfluxDB are indexed strings; in Iceberg they're just columns
        for key, value in metric.tags.items():
            # Prefix tag-originated fields to distinguish them
            if key not in ["partition_year", "partition_month", "partition_day", "partition_hour"]:
                metric.fields["tag_" + key] = value
    
        # Add the measurement name as a field (useful if mixing measurements)
        metric.fields["measurement"] = metric.name
    
        # Add ingestion timestamp (separate from the data timestamp)
        # This helps with pipeline debugging and data freshness monitoring
        metric.fields["ingested_at"] = time.now().unix_nano // 1000000000
    
        return metric
    
    load("time", "time")
    '''
    Tip: Order is important for Telegraf processors. They execute in the order in which they appear in the configuration file. rename should precede converter, and date should precede the Starlark flatten processor so that the partition tags are already available.

    Output to S3 (Landing Zone)

    The transformed data must now be moved from Telegraf into S3. This is the landing zone—a staging area in which raw files accumulate before being ingested into the Iceberg table.

    Landing Data on S3: What Telegraf Actually Provides

    An important correction first, because much online material gets it wrong: Telegraf ships no official S3 output plugin. The request has been open for years—see the tracking issues #6617 and #15547—and the upstream outputs directory contains no s3 plugin. Two patterns are therefore standard in production, both built only from officially supported plugins: write local files with outputs.file and synchronise them to S3 on a schedule, or push metrics through outputs.execd to a small script that writes Parquet straight to the bucket. A community fork, Coditation/telegraf-contrib, does implement an [[outputs.s3]] plugin, but it is not part of upstream Telegraf and must be compiled in. If — and only if — that fork is built into a custom binary, its configuration reads as follows:

    # telegraf.conf - Output: S3 via the community fork
    # (Coditation/telegraf-contrib — NOT in upstream Telegraf; requires a custom build)
    [[outputs.s3]]
      ## S3 bucket name
      bucket = "my-timeseries-lakehouse"
    
      ## S3 key prefix with Hive-style partitioning
      ## Uses Go template syntax with metric tags
      s3_key_prefix = "landing-zone/{{.Tag \"partition_year\"}}/{{.Tag \"partition_month\"}}/{{.Tag \"partition_day\"}}/"
    
      ## AWS region
      region = "us-east-1"
    
      ## Use shared credentials or environment variables
      ## access_key = "${AWS_ACCESS_KEY_ID}"
      ## secret_key = "${AWS_SECRET_ACCESS_KEY}"
    
      ## Data format
      data_format = "json"
    
      ## Batching configuration
      ## Write to S3 every 5 minutes or when buffer reaches 10000 metrics
      metric_batch_size = 10000
      metric_buffer_limit = 100000
      flush_interval = "5m"
      flush_jitter = "30s"
    
      ## File naming
      ## Creates files like: landing-zone/2026/04/03/metrics_1712160000.json
      use_batch_format = true
    Caution: Because [[outputs.s3]] is a community fork rather than part of upstream Telegraf, the portable choice for most deployments is the outputs.file plus aws s3 sync pattern shown next, or the outputs.execd Parquet writer further below. Both rely only on plugins present in the standard Telegraf binary, so they survive routine package upgrades without a custom build.

    Recommended: outputs.file Plus S3 Sync

    This is the most portable path, and it gives fine-grained control over file rotation:

    # telegraf.conf - Output: Local files (for S3 sync)
    [[outputs.file]]
      ## Write to a local directory organized by date
      files = ["/var/telegraf/output/metrics.json"]
    
      ## Rotate files based on time
      rotation_interval = "1h"
      rotation_max_size = "100MB"
      rotation_max_archives = 48
    
      ## Data format
      data_format = "json"
      json_timestamp_units = "1s"

    A cron job is then configured to synchronise to S3:

    # /etc/cron.d/telegraf-s3-sync
    # Sync local Telegraf output to S3 every 10 minutes
    */10 * * * * telegraf aws s3 sync /var/telegraf/output/ s3://my-timeseries-lakehouse/landing-zone/ \
      --exclude "*.json" \
      --include "*.json-*" \
      && find /var/telegraf/output/ -name "*.json-*" -mmin +60 -delete

    Writing Parquet via execd Output

    Parquet is the preferred format for Iceberg. Although Telegraf does not natively output Parquet, the outputs.execd plugin can be used together with a lightweight Python script:

    # telegraf.conf - Output: Parquet via execd
    [[outputs.execd]]
      command = ["/usr/bin/python3", "/opt/telegraf/write_parquet_s3.py"]
    
      ## Restart the process if it exits
      restart_delay = "10s"
    
      ## Data format sent to the script via stdin
      data_format = "json"

    The companion Python script is shown below:

    #!/usr/bin/env python3
    """write_parquet_s3.py - Telegraf execd output plugin for Parquet to S3"""
    
    import sys
    import json
    import os
    from datetime import datetime
    from io import BytesIO
    
    import pyarrow as pa
    import pyarrow.parquet as pq
    import boto3
    
    BUCKET = os.environ.get("S3_BUCKET", "my-timeseries-lakehouse")
    PREFIX = os.environ.get("S3_PREFIX", "landing-zone")
    REGION = os.environ.get("AWS_REGION", "us-east-1")
    BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "5000"))
    FLUSH_SECONDS = int(os.environ.get("FLUSH_SECONDS", "300"))
    
    s3 = boto3.client("s3", region_name=REGION)
    buffer = []
    last_flush = datetime.utcnow()
    
    def flush_to_s3(records):
        if not records:
            return
    
        # Build a PyArrow table from the records
        table = pa.Table.from_pylist(records)
    
        # Write to Parquet in memory
        parquet_buffer = BytesIO()
        pq.write_table(table, parquet_buffer, compression="snappy")
        parquet_buffer.seek(0)
    
        # Generate S3 key with Hive-style partitioning
        now = datetime.utcnow()
        key = (
            f"{PREFIX}/year={now.year}/month={now.month:02d}/"
            f"day={now.day:02d}/hour={now.hour:02d}/"
            f"metrics_{now.strftime('%Y%m%d_%H%M%S')}.parquet"
        )
    
        s3.put_object(Bucket=BUCKET, Key=key, Body=parquet_buffer.getvalue())
        sys.stderr.write(f"Flushed {len(records)} records to s3://{BUCKET}/{key}\n")
    
    for line in sys.stdin:
        try:
            metric = json.loads(line.strip())
            # Flatten the metric into a single dict
            record = {
                "measurement": metric.get("name", ""),
                "timestamp": metric.get("timestamp", 0),
            }
            record.update(metric.get("tags", {}))
            record.update(metric.get("fields", {}))
            buffer.append(record)
    
            # Flush on batch size or time
            elapsed = (datetime.utcnow() - last_flush).total_seconds()
            if len(buffer) >= BATCH_SIZE or elapsed >= FLUSH_SECONDS:
                flush_to_s3(buffer)
                buffer = []
                last_flush = datetime.utcnow()
    
        except json.JSONDecodeError:
            sys.stderr.write(f"Invalid JSON: {line}\n")
        except Exception as e:
            sys.stderr.write(f"Error: {e}\n")
    
    # Flush remaining records on exit
    flush_to_s3(buffer)

    Alternative: outputs.http to Lambda for Parquet

    A serverless approach uses an AWS Lambda function that receives metrics via HTTP and writes Parquet files:

    # telegraf.conf - Output: HTTP to Lambda Function URL
    [[outputs.http]]
      url = "https://abc123.lambda-url.us-east-1.on.aws/ingest"
    
      method = "POST"
      data_format = "json"
      json_timestamp_units = "1s"
    
      ## Batch settings
      metric_batch_size = 5000
      metric_buffer_limit = 50000
    
      ## Timeout and retry
      timeout = "30s"
    
      ## Headers
      [outputs.http.headers]
        Content-Type = "application/json"
        X-Pipeline-Source = "telegraf-influxdb"

    S3 Partitioning Strategy

    The S3 path structure is important for Glue and Athena performance. Hive-style partitioning should be used:

    # Recommended S3 path structure for time-series data
    s3://my-timeseries-lakehouse/
      landing-zone/
        measurement=cpu_metrics/
          year=2026/
            month=04/
              day=03/
                hour=00/
                  metrics_20260403_000000.json
                  metrics_20260403_001500.json
                hour=01/
                  metrics_20260403_010000.json
              day=04/
                ...
        measurement=memory_metrics/
          year=2026/
            ...
    Key Takeaway: Partition by day for most workloads. Partition by hour only when ingestion exceeds 1GB per day per measurement. Over-partitioning produces too many small files and degrades Athena query performance, while under-partitioning forces full scans. The optimal range is files between 128MB and 256MB.

    Create the Iceberg Table in AWS Glue

    With data landing on S3, the Iceberg table definition must be created in the AWS Glue Data Catalog. Two approaches are available.

    Option A: Create Iceberg Table via Athena DDL

    This is the most precise approach, allowing the exact schema and partitioning to be defined:

    -- Create Iceberg table for CPU metrics
    CREATE TABLE timeseries_db.cpu_metrics (
        timestamp         timestamp,
        hostname          string,
        region            string,
        cpu_idle_percent  double,
        cpu_system_percent double,
        cpu_user_percent  double,
        cpu_total_usage_percent double,
        pipeline_version  string,
        source_system     string,
        ingested_at       bigint
    )
    PARTITIONED BY (day(timestamp))
    LOCATION 's3://my-timeseries-lakehouse/iceberg-warehouse/cpu_metrics/'
    TBLPROPERTIES (
        'table_type' = 'ICEBERG',
        'format' = 'PARQUET',
        'write_compression' = 'snappy',
        'optimize_rewrite_delete_file_threshold' = '10'
    );
    
    -- Create Iceberg table for memory metrics
    CREATE TABLE timeseries_db.memory_metrics (
        timestamp         timestamp,
        hostname          string,
        region            string,
        used_percent      double,
        available         bigint,
        memory_critical   boolean,
        pipeline_version  string,
        source_system     string,
        ingested_at       bigint
    )
    PARTITIONED BY (day(timestamp))
    LOCATION 's3://my-timeseries-lakehouse/iceberg-warehouse/memory_metrics/'
    TBLPROPERTIES (
        'table_type' = 'ICEBERG',
        'format' = 'PARQUET',
        'write_compression' = 'snappy'
    );
    
    -- Create a unified metrics table (if you prefer a single table)
    CREATE TABLE timeseries_db.all_metrics (
        timestamp         timestamp,
        measurement       string,
        hostname          string,
        region            string,
        metric_name       string,
        metric_value      double,
        tags              map<string, string>,
        pipeline_version  string,
        source_system     string,
        ingested_at       bigint
    )
    PARTITIONED BY (day(timestamp), measurement)
    LOCATION 's3://my-timeseries-lakehouse/iceberg-warehouse/all_metrics/'
    TBLPROPERTIES (
        'table_type' = 'ICEBERG',
        'format' = 'PARQUET',
        'write_compression' = 'snappy'
    );

    Option B: AWS Glue Crawler for Schema Discovery

    When automatic schema discovery from JSON or Parquet files in the landing zone is desired:

    # Create the Glue Crawler via AWS CLI
    aws glue create-crawler \
      --name "timeseries-landing-crawler" \
      --role "arn:aws:iam::ACCOUNT_ID:role/GlueCrawlerRole" \
      --database-name "timeseries_db" \
      --targets '{
        "S3Targets": [
          {
            "Path": "s3://my-timeseries-lakehouse/landing-zone/",
            "Exclusions": ["**/_temporary/**", "**/_SUCCESS"]
          }
        ]
      }' \
      --schema-change-policy '{
        "UpdateBehavior": "UPDATE_IN_DATABASE",
        "DeleteBehavior": "LOG"
      }' \
      --configuration '{
        "Version": 1.0,
        "Grouping": {
          "TableGroupingPolicy": "CombineCompatibleSchemas"
        },
        "CrawlerOutput": {
          "Partitions": {
            "AddOrUpdateBehavior": "InheritFromTable"
          }
        }
      }' \
      --recrawl-policy '{"RecrawlBehavior": "CRAWL_NEW_FOLDERS_ONLY"}'
    
    # Run the crawler
    aws glue start-crawler --name "timeseries-landing-crawler"
    
    # Check crawler status
    aws glue get-crawler --name "timeseries-landing-crawler" \
      --query "Crawler.State"

    Schema Mapping: InfluxDB to Iceberg Types

    InfluxDB Type Example Iceberg/Parquet Type Notes
    Float usage_idle=95.2 double Direct mapping
    Integer bytes_sent=1024i bigint Use int for values under 2B
    String (field) status="healthy" string Direct mapping
    Boolean active=true boolean Direct mapping
    Tag (string) host=server01 string Consider dictionary encoding
    Timestamp nanosecond Unix timestamp Convert from ns to ms or s

     

    Automate the Iceberg Ingestion

    Having data on S3 is only half of the task. It must be moved from the landing zone into the Iceberg table proper. Four approaches are described below, from simplest to most sophisticated.

    Option A: AWS Glue ETL Job (PySpark)

    This is the most robust approach for production workloads. A Glue ETL job reads from the landing zone and writes to the Iceberg table:

    # glue_iceberg_ingestion.py - AWS Glue ETL Job
    import sys
    from awsglue.transforms import *
    from awsglue.utils import getResolvedOptions
    from pyspark.context import SparkContext
    from awsglue.context import GlueContext
    from awsglue.job import Job
    from pyspark.sql.functions import col, to_timestamp, current_timestamp, lit
    from pyspark.sql.types import *
    
    args = getResolvedOptions(sys.argv, [
        'JOB_NAME',
        'source_path',
        'database_name',
        'table_name'
    ])
    
    sc = SparkContext()
    glueContext = GlueContext(sc)
    spark = glueContext.spark_session
    job = Job(glueContext)
    job.init(args['JOB_NAME'], args)
    
    # Configure Iceberg
    spark.conf.set("spark.sql.catalog.glue_catalog", "org.apache.iceberg.spark.SparkCatalog")
    spark.conf.set("spark.sql.catalog.glue_catalog.warehouse", "s3://my-timeseries-lakehouse/iceberg-warehouse/")
    spark.conf.set("spark.sql.catalog.glue_catalog.catalog-impl", "org.apache.iceberg.aws.glue.GlueCatalog")
    spark.conf.set("spark.sql.catalog.glue_catalog.io-impl", "org.apache.iceberg.aws.s3.S3FileIO")
    spark.conf.set("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
    
    # Read from landing zone
    source_path = args['source_path']  # s3://my-timeseries-lakehouse/landing-zone/
    database = args['database_name']    # timeseries_db
    table = args['table_name']          # cpu_metrics
    
    print(f"Reading from: {source_path}")
    
    # Read JSON files from landing zone
    df_raw = spark.read.json(source_path)
    
    # Transform: convert timestamp, clean up columns
    df_transformed = df_raw \
        .withColumn("timestamp", to_timestamp(col("timestamp").cast("long"))) \
        .withColumn("hostname", col("tag_hostname")) \
        .withColumn("region", col("tag_region")) \
        .withColumn("load_timestamp", current_timestamp()) \
        .drop("tag_hostname", "tag_region", "partition_year",
              "partition_month", "partition_day", "partition_hour")
    
    # Select columns matching the Iceberg table schema
    df_final = df_transformed.select(
        "timestamp",
        "hostname",
        "region",
        col("cpu_idle_percent").cast("double"),
        col("cpu_system_percent").cast("double"),
        col("cpu_user_percent").cast("double"),
        col("cpu_total_usage_percent").cast("double"),
        "pipeline_version",
        "source_system",
        col("ingested_at").cast("long")
    )
    
    print(f"Records to insert: {df_final.count()}")
    
    # Write to Iceberg table using APPEND mode
    df_final.writeTo(f"glue_catalog.{database}.{table}") \
        .option("merge-schema", "true") \
        .append()
    
    print(f"Successfully ingested data into {database}.{table}")
    
    # Optional: Clean up processed files from landing zone
    # This prevents re-processing on the next run
    # Uncomment if you want automatic cleanup:
    # import boto3
    # s3 = boto3.resource('s3')
    # bucket = s3.Bucket('my-timeseries-lakehouse')
    # bucket.objects.filter(Prefix='landing-zone/processed/').delete()
    
    job.commit()

    The Glue job is created and scheduled as follows:

    # Create the Glue ETL job
    aws glue create-job \
      --name "timeseries-iceberg-ingestion" \
      --role "arn:aws:iam::ACCOUNT_ID:role/GlueETLRole" \
      --command '{
        "Name": "glueetl",
        "ScriptLocation": "s3://my-timeseries-lakehouse/scripts/glue_iceberg_ingestion.py",
        "PythonVersion": "3"
      }' \
      --default-arguments '{
        "--source_path": "s3://my-timeseries-lakehouse/landing-zone/",
        "--database_name": "timeseries_db",
        "--table_name": "cpu_metrics",
        "--datalake-formats": "iceberg",
        "--conf": "spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions",
        "--enable-metrics": "true"
      }' \
      --glue-version "4.0" \
      --number-of-workers 2 \
      --worker-type "G.1X" \
      --timeout 60
    
    # Schedule the job to run every hour via EventBridge
    aws events put-rule \
      --name "hourly-iceberg-ingestion" \
      --schedule-expression "rate(1 hour)" \
      --state ENABLED
    
    aws events put-targets \
      --rule "hourly-iceberg-ingestion" \
      --targets '[{
        "Id": "glue-job-target",
        "Arn": "arn:aws:glue:us-east-1:ACCOUNT_ID:job/timeseries-iceberg-ingestion",
        "RoleArn": "arn:aws:iam::ACCOUNT_ID:role/EventBridgeGlueRole"
      }]'

    Option B: Athena INSERT INTO (Simple, No Compute Required)

    For smaller datasets, Glue ETL may be omitted and Athena used directly to move the data:

    -- First, create a temporary table pointing to the landing zone
    CREATE EXTERNAL TABLE timeseries_db.cpu_metrics_landing (
        timestamp         bigint,
        measurement       string,
        tag_hostname      string,
        tag_region        string,
        cpu_idle_percent  double,
        cpu_system_percent double,
        cpu_user_percent  double,
        cpu_total_usage_percent double,
        pipeline_version  string,
        source_system     string,
        ingested_at       bigint
    )
    PARTITIONED BY (year string, month string, day string)
    ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
    LOCATION 's3://my-timeseries-lakehouse/landing-zone/measurement=cpu_metrics/'
    TBLPROPERTIES ('has_encrypted_data'='false');
    
    -- Add partitions (or use MSCK REPAIR TABLE)
    MSCK REPAIR TABLE timeseries_db.cpu_metrics_landing;
    
    -- Insert from landing zone into Iceberg table
    INSERT INTO timeseries_db.cpu_metrics
    SELECT
        from_unixtime(timestamp) as timestamp,
        tag_hostname as hostname,
        tag_region as region,
        cpu_idle_percent,
        cpu_system_percent,
        cpu_user_percent,
        cpu_total_usage_percent,
        pipeline_version,
        source_system,
        ingested_at
    FROM timeseries_db.cpu_metrics_landing
    WHERE year = '2026' AND month = '04' AND day = '03';

    Option C: Lambda for Near-Real-Time Ingestion

    For near-real-time ingestion, a Lambda function is triggered when new files arrive in S3:

    # lambda_iceberg_ingest.py - Triggered by S3 PutObject events
    import json
    import boto3
    import time
    
    athena = boto3.client('athena')
    
    def handler(event, context):
        """Triggered when a new file lands in the landing zone."""
    
        for record in event['Records']:
            bucket = record['s3']['bucket']['name']
            key = record['s3']['object']['key']
    
            print(f"New file: s3://{bucket}/{key}")
    
            # Parse the partition info from the S3 path
            # Example: landing-zone/measurement=cpu_metrics/year=2026/month=04/day=03/...
            parts = key.split('/')
            partition_info = {}
            for part in parts:
                if '=' in part:
                    k, v = part.split('=', 1)
                    partition_info[k] = v
    
            measurement = partition_info.get('measurement', 'unknown')
            year = partition_info.get('year', '')
            month = partition_info.get('month', '')
            day = partition_info.get('day', '')
    
            if measurement == 'cpu_metrics':
                # Run Athena INSERT INTO query
                query = f"""
                INSERT INTO timeseries_db.cpu_metrics
                SELECT
                    from_unixtime(timestamp) as timestamp,
                    tag_hostname as hostname,
                    tag_region as region,
                    cpu_idle_percent,
                    cpu_system_percent,
                    cpu_user_percent,
                    cpu_total_usage_percent,
                    pipeline_version,
                    source_system,
                    ingested_at
                FROM timeseries_db.cpu_metrics_landing
                WHERE year = '{year}' AND month = '{month}' AND day = '{day}'
                """
    
                response = athena.start_query_execution(
                    QueryString=query,
                    QueryExecutionContext={'Database': 'timeseries_db'},
                    ResultConfiguration={
                        'OutputLocation': 's3://my-timeseries-lakehouse-athena-results/'
                    }
                )
    
                query_id = response['QueryExecutionId']
                print(f"Started Athena query: {query_id}")
    
        return {'statusCode': 200, 'body': 'Ingestion triggered'}

    The S3 event trigger is configured as follows:

    # Create the Lambda function
    aws lambda create-function \
      --function-name timeseries-iceberg-ingest \
      --runtime python3.12 \
      --handler lambda_iceberg_ingest.handler \
      --role arn:aws:iam::ACCOUNT_ID:role/LambdaIcebergIngestRole \
      --zip-file fileb://lambda_package.zip \
      --timeout 300 \
      --memory-size 256
    
    # Add S3 trigger permission
    aws lambda add-permission \
      --function-name timeseries-iceberg-ingest \
      --statement-id s3-trigger \
      --action lambda:InvokeFunction \
      --principal s3.amazonaws.com \
      --source-arn arn:aws:s3:::my-timeseries-lakehouse
    
    # Configure S3 bucket notification
    aws s3api put-bucket-notification-configuration \
      --bucket my-timeseries-lakehouse \
      --notification-configuration '{
        "LambdaFunctionConfigurations": [
          {
            "LambdaFunctionArn": "arn:aws:lambda:us-east-1:ACCOUNT_ID:function:timeseries-iceberg-ingest",
            "Events": ["s3:ObjectCreated:*"],
            "Filter": {
              "Key": {
                "FilterRules": [
                  {"Name": "prefix", "Value": "landing-zone/"},
                  {"Name": "suffix", "Value": ".json"}
                ]
              }
            }
          }
        ]
      }'

    Option D: Apache Spark on EMR

    For the highest throughput and maximum flexibility, Spark is run directly on EMR with the Iceberg connector:

    # emr_iceberg_job.py - Spark job for EMR
    from pyspark.sql import SparkSession
    from pyspark.sql.functions import *
    
    spark = SparkSession.builder \
        .appName("InfluxDB-to-Iceberg") \
        .config("spark.sql.catalog.glue_catalog", "org.apache.iceberg.spark.SparkCatalog") \
        .config("spark.sql.catalog.glue_catalog.warehouse", "s3://my-timeseries-lakehouse/iceberg-warehouse/") \
        .config("spark.sql.catalog.glue_catalog.catalog-impl", "org.apache.iceberg.aws.glue.GlueCatalog") \
        .config("spark.sql.catalog.glue_catalog.io-impl", "org.apache.iceberg.aws.s3.S3FileIO") \
        .config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
        .getOrCreate()
    
    # Read new files from landing zone
    df = spark.read.json("s3://my-timeseries-lakehouse/landing-zone/measurement=cpu_metrics/year=2026/")
    
    # Transform and write to Iceberg
    df_clean = df \
        .withColumn("timestamp", to_timestamp(col("timestamp").cast("long"))) \
        .withColumnRenamed("tag_hostname", "hostname") \
        .withColumnRenamed("tag_region", "region") \
        .select("timestamp", "hostname", "region",
                "cpu_idle_percent", "cpu_system_percent",
                "cpu_user_percent", "cpu_total_usage_percent",
                "pipeline_version", "source_system", "ingested_at")
    
    # Append to Iceberg table
    df_clean.writeTo("glue_catalog.timeseries_db.cpu_metrics").append()
    
    # Run compaction to optimize file sizes
    spark.sql("""
        CALL glue_catalog.system.rewrite_data_files(
            table => 'timeseries_db.cpu_metrics',
            options => map('target-file-size-bytes', '134217728')
        )
    """)
    
    spark.stop()
    # Submit the EMR job
    aws emr add-steps \
      --cluster-id j-XXXXXXXXXXXXX \
      --steps '[{
        "Type": "Spark",
        "Name": "Iceberg Ingestion",
        "ActionOnFailure": "CONTINUE",
        "Args": [
          "--deploy-mode", "cluster",
          "--conf", "spark.jars.packages=org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.5.0",
          "--conf", "spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions",
          "s3://my-timeseries-lakehouse/scripts/emr_iceberg_job.py"
        ]
      }]'

    Complete End-to-End telegraf.conf

    A full, production-ready Telegraf configuration combining all preceding elements is presented below. Copying this file and updating the environment variables yields a working pipeline:

    # =============================================================================
    # TELEGRAF CONFIGURATION: InfluxDB → S3 Landing Zone (for Iceberg)
    # =============================================================================
    # This configuration reads time-series data from InfluxDB v2, transforms it
    # into a flat columnar schema, and writes it to rotated local files that a
    # scheduled `aws s3 sync` uploads to the S3 landing zone for subsequent
    # ingestion into Apache Iceberg tables. (Upstream Telegraf has no S3 output.)
    # =============================================================================
    
    # Global Agent Configuration
    [agent]
      ## Collection interval - how often input plugins are gathered
      interval = "1h"
    
      ## Flush interval - how often output plugins write
      flush_interval = "5m"
    
      ## Jitter to prevent thundering herd
      collection_jitter = "30s"
      flush_jitter = "30s"
    
      ## Metric batch and buffer sizes
      metric_batch_size = 10000
      metric_buffer_limit = 100000
    
      ## Override default hostname
      hostname = ""
      omit_hostname = true
    
      ## Logging
      debug = false
      quiet = false
      logfile = "/var/log/telegraf/telegraf-pipeline.log"
      logfile_rotation_interval = "24h"
      logfile_rotation_max_size = "100MB"
      logfile_rotation_max_archives = 7
    
    # =============================================================================
    # INPUT: Read from InfluxDB v2 via Flux queries
    # =============================================================================
    [[inputs.influxdb_v2]]
      urls = ["${INFLUXDB_URL}"]
      token = "${INFLUXDB_TOKEN}"
      organization = "${INFLUXDB_ORG}"
    
      ## CPU Metrics
      [[inputs.influxdb_v2.query]]
        bucket = "${INFLUXDB_BUCKET}"
        query = '''
          from(bucket: v.bucket)
            |> range(start: -1h)
            |> filter(fn: (r) => r._measurement == "cpu")
            |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
            |> drop(columns: ["_start", "_stop", "_measurement"])
        '''
        measurement = "cpu_metrics"
    
      ## Memory Metrics
      [[inputs.influxdb_v2.query]]
        bucket = "${INFLUXDB_BUCKET}"
        query = '''
          from(bucket: v.bucket)
            |> range(start: -1h)
            |> filter(fn: (r) => r._measurement == "memory")
            |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
            |> drop(columns: ["_start", "_stop", "_measurement"])
        '''
        measurement = "memory_metrics"
    
      ## HTTP Request Metrics
      [[inputs.influxdb_v2.query]]
        bucket = "${INFLUXDB_BUCKET}"
        query = '''
          from(bucket: v.bucket)
            |> range(start: -1h)
            |> filter(fn: (r) => r._measurement == "http_requests")
            |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
            |> drop(columns: ["_start", "_stop", "_measurement"])
        '''
        measurement = "http_request_metrics"
    
      timeout = "60s"
    
    # =============================================================================
    # PROCESSORS: Transform data for Iceberg compatibility
    # =============================================================================
    
    # Step 1: Rename fields to clean, descriptive names
    [[processors.rename]]
      order = 1
    
      [[processors.rename.replace]]
        field = "usage_idle"
        dest = "cpu_idle_percent"
      [[processors.rename.replace]]
        field = "usage_system"
        dest = "cpu_system_percent"
      [[processors.rename.replace]]
        field = "usage_user"
        dest = "cpu_user_percent"
      [[processors.rename.replace]]
        field = "used_percent"
        dest = "memory_used_percent"
      [[processors.rename.replace]]
        tag = "host"
        dest = "hostname"
    
    # Step 2: Convert field types for schema consistency
    [[processors.converter]]
      order = 2
      [processors.converter.fields]
        float = ["cpu_idle_percent", "cpu_system_percent", "cpu_user_percent",
                 "memory_used_percent", "latency_ms"]
        integer = ["available", "count"]
    
    # Step 3: Extract date partitions from timestamp
    [[processors.date]]
      order = 3
      tag_key = "partition_year"
      date_format = "2006"
    
    [[processors.date]]
      order = 4
      tag_key = "partition_month"
      date_format = "01"
    
    [[processors.date]]
      order = 5
      tag_key = "partition_day"
      date_format = "02"
    
    # Step 4: Custom transformations (compute derived fields, flatten tags)
    [[processors.starlark]]
      order = 6
      source = '''
    load("time", "time")
    
    def apply(metric):
        # Compute total CPU usage
        if metric.name == "cpu_metrics":
            idle = metric.fields.get("cpu_idle_percent", 0.0)
            metric.fields["cpu_total_usage_percent"] = round(100.0 - idle, 2)
    
        # Memory health flag
        if metric.name == "memory_metrics":
            used = metric.fields.get("memory_used_percent", 0.0)
            metric.fields["memory_critical"] = used > 95.0
    
        # Flatten all tags into fields for columnar storage
        for key, value in metric.tags.items():
            if not key.startswith("partition_"):
                metric.fields["tag_" + key] = value
    
        # Add metadata
        metric.fields["measurement"] = metric.name
        metric.fields["source_system"] = "influxdb"
        metric.fields["pipeline_version"] = "1.0"
        metric.fields["ingested_at"] = int(time.now().unix_nano / 1000000000)
    
        return metric
    '''
    
    # =============================================================================
    # OUTPUT: Write rotated local files, then sync to the S3 landing zone
    # Upstream Telegraf has no S3 output, so files are rotated locally and a
    # scheduled `aws s3 sync` (see the S3 output section) uploads them. For
    # Hive-partitioned object keys and Parquet, swap this for the outputs.execd
    # Parquet writer shown earlier.
    # =============================================================================
    [[outputs.file]]
      files = ["/var/telegraf/output/metrics.json"]
    
      ## Rotate so the sync job has closed files to upload
      rotation_interval = "5m"
      rotation_max_size = "256MB"
      rotation_max_archives = 48
    
      data_format = "json"
      json_timestamp_units = "1s"
    
      ## Batching
      metric_batch_size = 10000
      metric_buffer_limit = 100000
      flush_interval = "5m"
      flush_jitter = "30s"
    
      use_batch_format = true
    
    # =============================================================================
    # MONITORING: Internal Telegraf metrics
    # =============================================================================
    [[inputs.internal]]
      collect_memstats = true
      name_prefix = "telegraf_pipeline_"
    
    [[outputs.file]]
      files = ["/var/log/telegraf/internal_metrics.json"]
      data_format = "json"
      namepass = ["telegraf_pipeline_*"]
      rotation_interval = "24h"
      rotation_max_archives = 7

    The required environment variables are set as follows:

    # /etc/default/telegraf or /etc/telegraf/telegraf.env
    INFLUXDB_URL=http://localhost:8086
    INFLUXDB_TOKEN=my-super-secret-token
    INFLUXDB_ORG=my-org
    INFLUXDB_BUCKET=metrics
    AWS_S3_BUCKET=my-timeseries-lakehouse
    AWS_REGION=us-east-1
    AWS_ACCESS_KEY_ID=AKIA...
    AWS_SECRET_ACCESS_KEY=secret...

    The pipeline is started as follows:

    # Test the configuration first
    telegraf --config /etc/telegraf/telegraf-pipeline.conf --test
    
    # Run in foreground for debugging
    telegraf --config /etc/telegraf/telegraf-pipeline.conf
    
    # Run as a service
    sudo cp /etc/telegraf/telegraf-pipeline.conf /etc/telegraf/telegraf.conf
    sudo systemctl restart telegraf
    sudo systemctl status telegraf
    sudo journalctl -u telegraf -f

    Querying Iceberg Data with Athena

    Once data are flowing into the Iceberg tables, they can be queried with standard SQL through Amazon Athena. Several practical queries for daily use are presented below.

    Basic Analytical Queries

    -- Average CPU usage per host over the last 24 hours
    SELECT
        hostname,
        region,
        AVG(cpu_total_usage_percent) as avg_cpu_usage,
        MAX(cpu_total_usage_percent) as peak_cpu_usage,
        MIN(cpu_idle_percent) as min_idle_percent,
        COUNT(*) as data_points
    FROM timeseries_db.cpu_metrics
    WHERE timestamp >= current_timestamp - interval '24' hour
    GROUP BY hostname, region
    ORDER BY avg_cpu_usage DESC;
    
    -- Hourly aggregation for dashboarding
    SELECT
        date_trunc('hour', timestamp) as hour,
        hostname,
        AVG(cpu_total_usage_percent) as avg_cpu,
        APPROX_PERCENTILE(cpu_total_usage_percent, 0.95) as p95_cpu,
        APPROX_PERCENTILE(cpu_total_usage_percent, 0.99) as p99_cpu
    FROM timeseries_db.cpu_metrics
    WHERE timestamp >= current_timestamp - interval '7' day
    GROUP BY 1, 2
    ORDER BY 1 DESC, 2;
    
    -- Memory alerts: find hosts with high memory usage
    SELECT
        hostname,
        region,
        timestamp,
        used_percent,
        available / (1024*1024*1024) as available_gb
    FROM timeseries_db.memory_metrics
    WHERE used_percent > 90
      AND timestamp >= current_timestamp - interval '1' hour
    ORDER BY used_percent DESC;

    Time Travel Queries

    One of Iceberg’s principal features is time travel: querying the data as they existed at a previous point in time:

    -- Query data as it existed yesterday at noon
    SELECT *
    FROM timeseries_db.cpu_metrics
    FOR TIMESTAMP AS OF TIMESTAMP '2026-04-02 12:00:00'
    WHERE hostname = 'server01';
    
    -- Compare current data with data from a week ago
    SELECT
        current_data.hostname,
        current_data.avg_cpu as current_avg_cpu,
        historical.avg_cpu as week_ago_avg_cpu,
        current_data.avg_cpu - historical.avg_cpu as cpu_change
    FROM (
        SELECT hostname, AVG(cpu_total_usage_percent) as avg_cpu
        FROM timeseries_db.cpu_metrics
        WHERE timestamp >= current_timestamp - interval '1' day
        GROUP BY hostname
    ) current_data
    JOIN (
        SELECT hostname, AVG(cpu_total_usage_percent) as avg_cpu
        FROM timeseries_db.cpu_metrics
        FOR TIMESTAMP AS OF TIMESTAMP '2026-03-27 00:00:00'
        WHERE timestamp >= TIMESTAMP '2026-03-26' AND timestamp < TIMESTAMP '2026-03-27'
        GROUP BY hostname
    ) historical ON current_data.hostname = historical.hostname;
    
    -- View table snapshot history
    SELECT * FROM timeseries_db.cpu_metrics$snapshots ORDER BY committed_at DESC LIMIT 10;
    
    -- View manifest files
    SELECT * FROM timeseries_db.cpu_metrics$manifests;

    Joining with Other Data Sources

    -- Join CPU metrics with a server inventory table
    SELECT
        c.hostname,
        c.region,
        s.instance_type,
        s.team,
        AVG(c.cpu_total_usage_percent) as avg_cpu,
        s.monthly_cost
    FROM timeseries_db.cpu_metrics c
    JOIN timeseries_db.server_inventory s ON c.hostname = s.hostname
    WHERE c.timestamp >= current_timestamp - interval '7' day
    GROUP BY c.hostname, c.region, s.instance_type, s.team, s.monthly_cost
    HAVING AVG(c.cpu_total_usage_percent) < 10  -- Underutilized servers
    ORDER BY s.monthly_cost DESC;

    Athena Cost Optimization Tips

    Tip: Athena charges $5 per TB of data scanned. With Iceberg's partition pruning and Parquet's columnar storage, costs can be reduced by 90 per cent or more compared with scanning raw JSON files. Partition columns should always be included in the WHERE clause, and only the columns required should be selected; SELECT * on large tables should be avoided.
    • Use partition predicates: WHERE timestamp >= ... triggers Iceberg partition pruning, scanning only the relevant Parquet files.
    • Select specific columns: Parquet is columnar, so SELECT hostname, cpu_total_usage_percent reads far less data than SELECT *.
    • Run compaction regularly: Small files degrade query performance and increase cost. Files should be kept between 128MB and 256MB.
    • Use CTAS for frequent queries: Materialise expensive queries as new Iceberg tables.

    Alternative Pipeline: InfluxDB to Telegraf to Kafka to Spark to Iceberg

    Organisations requiring true streaming ingestion with exactly-once semantics should consider a Kafka-based pipeline. The architecture is as follows.

    InfluxDBTelegrafKafka TopicSpark Structured StreamingIceberg Table

    When to Use Kafka Rather Than S3-Based

    • S3-based (this guide's main approach) is appropriate when batch processing is acceptable (minutes to hours), data volume is under 1TB per day, minimal infrastructure is desired, and cost is a priority.
    • Kafka-based is appropriate when sub-minute latency is required, data volume exceeds 1TB per day, a Kafka cluster is already operational, and exactly-once delivery guarantees are needed.

    Telegraf Kafka Output Configuration

    # telegraf.conf - Output: Kafka
    [[outputs.kafka]]
      ## Kafka broker addresses
      brokers = ["kafka-broker-1:9092", "kafka-broker-2:9092", "kafka-broker-3:9092"]
    
      ## Topic for all metrics (or use topic_suffix for per-measurement topics)
      topic = "influxdb-metrics"
    
      ## Use measurement name as topic suffix for separate topics
      ## Creates topics like: influxdb-metrics-cpu_metrics, influxdb-metrics-memory_metrics
      # topic_suffix = {method = "measurement"}
    
      ## Compression
      compression_codec = "snappy"
    
      ## Required acks: 0=none, 1=leader, -1=all replicas
      required_acks = -1
    
      ## Max message size
      max_message_bytes = 1048576
    
      ## Data format
      data_format = "json"
      json_timestamp_units = "1ms"
    
      ## SASL authentication (if Kafka requires it)
      # sasl_mechanism = "SCRAM-SHA-512"
      # sasl_username = "${KAFKA_USERNAME}"
      # sasl_password = "${KAFKA_PASSWORD}"
    
      ## TLS
      # tls_ca = "/etc/telegraf/ca.pem"
      # tls_cert = "/etc/telegraf/cert.pem"
      # tls_key = "/etc/telegraf/key.pem"

    The Spark Structured Streaming consumer is shown below:

    # spark_kafka_iceberg.py - Spark Structured Streaming from Kafka to Iceberg
    from pyspark.sql import SparkSession
    from pyspark.sql.functions import *
    from pyspark.sql.types import *
    
    spark = SparkSession.builder \
        .appName("Kafka-to-Iceberg-Streaming") \
        .config("spark.sql.catalog.glue_catalog", "org.apache.iceberg.spark.SparkCatalog") \
        .config("spark.sql.catalog.glue_catalog.warehouse", "s3://my-timeseries-lakehouse/iceberg-warehouse/") \
        .config("spark.sql.catalog.glue_catalog.catalog-impl", "org.apache.iceberg.aws.glue.GlueCatalog") \
        .config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
        .getOrCreate()
    
    # Define the schema matching our Telegraf JSON output
    metrics_schema = StructType([
        StructField("name", StringType()),
        StructField("timestamp", LongType()),
        StructField("tags", MapType(StringType(), StringType())),
        StructField("fields", MapType(StringType(), DoubleType()))
    ])
    
    # Read from Kafka
    df_kafka = spark.readStream \
        .format("kafka") \
        .option("kafka.bootstrap.servers", "kafka-broker-1:9092") \
        .option("subscribe", "influxdb-metrics") \
        .option("startingOffsets", "latest") \
        .load()
    
    # Parse JSON messages
    df_parsed = df_kafka \
        .select(from_json(col("value").cast("string"), metrics_schema).alias("data")) \
        .select("data.*") \
        .withColumn("timestamp", to_timestamp(col("timestamp").cast("long"))) \
        .withColumn("hostname", col("tags")["hostname"]) \
        .withColumn("region", col("tags")["region"])
    
    # Write to Iceberg using foreachBatch
    def write_to_iceberg(batch_df, batch_id):
        batch_df.writeTo("glue_catalog.timeseries_db.all_metrics") \
            .option("merge-schema", "true") \
            .append()
    
    query = df_parsed.writeStream \
        .foreachBatch(write_to_iceberg) \
        .option("checkpointLocation", "s3://my-timeseries-lakehouse/checkpoints/kafka-iceberg/") \
        .trigger(processingTime="1 minute") \
        .start()
    
    query.awaitTermination()

    Monitoring and Troubleshooting

    A data pipeline is only as effective as its monitoring. The following describes how to maintain pipeline health.

    Telegraf Internal Metrics

    The inputs.internal plugin configured earlier provides important operational metrics:

    # Check Telegraf metrics buffer status
    cat /var/log/telegraf/internal_metrics.json | python3 -m json.tool | grep -E "metrics_gathered|metrics_written|buffer_size"
    
    # Key metrics to monitor:
    # - gather_errors: input plugin failures (InfluxDB connection issues)
    # - metrics_gathered: total metrics collected per interval
    # - metrics_written: total metrics sent to S3
    # - buffer_size: current buffer usage (should stay well below buffer_limit)
    # - write_errors: output plugin failures (S3 permission or network issues)

    Common Issues and Resolutions

    Issue Symptoms Resolution
    InfluxDB connection failure gather_errors increasing, no new metrics Verify InfluxDB URL and token. Check network connectivity. Ensure InfluxDB is running.
    S3 permission denied write_errors increasing, AccessDenied in logs Check IAM policy. Verify AWS credentials. Ensure bucket policy allows PutObject.
    Schema mismatch in Glue Athena queries return NULL or fail Re-run Glue Crawler. Check that JSON field names match table column names. Verify type conversions in Telegraf processors.
    Glue Crawler fails Crawler stuck in RUNNING or FAILED state Check Glue Crawler IAM role. Verify S3 path is correct. Look for malformed JSON files in landing zone.
    Data type conflicts Fields showing as wrong type in Athena Use processors.converter to enforce types in Telegraf. InfluxDB may return integers as floats or vice versa.
    Buffer overflow metrics_dropped count increasing Increase metric_buffer_limit. Reduce flush_interval. Check for S3 write latency issues.
    Duplicate data in Iceberg Row counts higher than expected Implement idempotent ingestion with MERGE INTO instead of INSERT. Track processed files to avoid re-ingestion.
    Too many small files Athena queries slow and expensive Increase Telegraf batch size. Run Iceberg compaction regularly. Target 128-256MB file sizes.

     

    Data Validation Queries

    -- Check data freshness: how recent is the latest data?
    SELECT
        MAX(timestamp) as latest_data,
        current_timestamp as current_time,
        date_diff('minute', MAX(timestamp), current_timestamp) as minutes_behind
    FROM timeseries_db.cpu_metrics;
    
    -- Check for data gaps: are there any missing hours?
    SELECT
        date_trunc('hour', timestamp) as hour,
        COUNT(*) as record_count
    FROM timeseries_db.cpu_metrics
    WHERE timestamp >= current_timestamp - interval '24' hour
    GROUP BY 1
    ORDER BY 1;
    
    -- Validate data quality: check for NULLs and outliers
    SELECT
        COUNT(*) as total_records,
        COUNT(hostname) as non_null_hostname,
        COUNT(cpu_total_usage_percent) as non_null_cpu,
        MIN(cpu_total_usage_percent) as min_cpu,
        MAX(cpu_total_usage_percent) as max_cpu,
        COUNT(CASE WHEN cpu_total_usage_percent > 100 THEN 1 END) as invalid_cpu_over_100,
        COUNT(CASE WHEN cpu_total_usage_percent < 0 THEN 1 END) as invalid_cpu_negative
    FROM timeseries_db.cpu_metrics
    WHERE timestamp >= current_timestamp - interval '1' hour;

    Performance Optimisation

    Establishing a functioning pipeline is one task; achieving good performance at scale is another. The key tuning parameters are discussed below.

    Telegraf Buffer Tuning

    The two most important Telegraf settings are metric_batch_size and metric_buffer_limit:

    • metric_batch_size: the number of metrics sent to the output plugin at a time. Larger batches reduce S3 API calls but increase memory usage and latency.
    • metric_buffer_limit: the maximum number of metrics held in memory. If the output is slow, metrics queue at this point; once the buffer is full, new metrics are dropped.
    Setting Small (<10K metrics/min) Medium (10K-100K/min) Large (>100K/min)
    metric_batch_size 5,000 10,000 50,000
    metric_buffer_limit 50,000 200,000 1,000,000
    flush_interval 10m 5m 1m
    collection_interval 1h 15m 5m
    Target S3 file size 64-128 MB 128-256 MB 256-512 MB
    Partition granularity Day Day Hour
    Telegraf RAM estimate 128 MB 512 MB 2-4 GB
    Compaction frequency Daily Every 6 hours Every 1-2 hours

     

    Iceberg Compaction

    Small files impair Iceberg performance. Compaction should be scheduled to merge them:

    -- Run compaction via Athena (Athena v3 with Iceberg support)
    OPTIMIZE timeseries_db.cpu_metrics REWRITE DATA USING BIN_PACK;
    
    -- Or via Spark (more control over target file size)
    -- In a Glue ETL job or EMR Spark session:
    CALL glue_catalog.system.rewrite_data_files(
        table => 'timeseries_db.cpu_metrics',
        options => map(
            'target-file-size-bytes', '134217728',  -- 128MB
            'min-file-size-bytes', '67108864',       -- 64MB
            'max-file-size-bytes', '268435456'       -- 256MB
        )
    );
    
    -- Expire old snapshots to reclaim storage
    CALL glue_catalog.system.expire_snapshots(
        table => 'timeseries_db.cpu_metrics',
        older_than => TIMESTAMP '2026-03-01 00:00:00',
        retain_last => 10
    );
    
    -- Remove orphan files
    CALL glue_catalog.system.remove_orphan_files(
        table => 'timeseries_db.cpu_metrics',
        older_than => TIMESTAMP '2026-03-01 00:00:00'
    );

    Partitioning Best Practices for Time-Series Data

    • Partition by day for most workloads. This produces a manageable number of partitions and files.
    • Add a secondary partition on high-cardinality dimensions such as measurement when specific measurements are queried frequently.
    • Avoid over-partitioning. Partitioning by minute produces millions of tiny files that destroy performance.
    • Use Iceberg's hidden partitioning with day(timestamp) rather than creating explicit partition columns. Queries on timestamp then automatically trigger partition pruning without users needing to be aware of partitions.
    • Monitor partition sizes. If any partition contains fewer than ten files, or each file is under 10MB, the partitioning is too granular.

    Cost Analysis

    Concrete figures merit examination. The cost savings from moving time-series data from InfluxDB to Iceberg on S3 can be substantial, particularly at scale.

    Data Volume InfluxDB Cloud (storage + queries) S3 + Iceberg + Athena Monthly Savings
    100 GB ~$150/mo (storage) + ~$50/mo (queries) ~$2.30 (S3) + ~$5 (Athena) + ~$10 (Glue) ~$183/mo (92% savings)
    1 TB ~$1,500/mo + ~$200/mo ~$23 (S3) + ~$25 (Athena) + ~$20 (Glue) ~$1,632/mo (96% savings)
    10 TB ~$15,000/mo + ~$500/mo ~$230 (S3) + ~$100 (Athena) + ~$50 (Glue) ~$15,120/mo (98% savings)

     

    Caution: These cost estimates are approximations based on published pricing as of early 2026. InfluxDB Cloud costs vary by plan and usage patterns. Athena costs depend on query frequency and data scanned (Parquet with partition pruning substantially reduces scan costs). Self-hosted InfluxDB costs depend on individual infrastructure. A bespoke cost analysis with actual workload patterns should always be conducted before migration decisions are made.

    Additional costs to consider include the following:

    • Telegraf compute: Runs on existing infrastructure. Minimal CPU and RAM are required for most workloads.
    • S3 API costs: PUT requests at $0.005 per 1,000. With batching, this is typically under $10 per month.
    • Glue Crawler: $0.44 per DPU-hour. A daily crawl typically costs $1 to $5 per month.
    • Glue ETL: $0.44 per DPU-hour. A daily ten-minute job with two DPUs costs approximately $13 per month.
    • Data transfer: Free within the same AWS region; cross-region transfer adds $0.02 per GB.

    The break-even point is almost immediate. Even at 100GB, savings on the order of $180 per month accrue from the move to S3 and Iceberg. The pipeline infrastructure (Telegraf, Glue) costs less than $30 per month for most workloads.

    Hot / Warm / Cold Data Tiering Strategy HOT TIER InfluxDB Last 7–30 days Sub-ms write latency Real-time dashboards Flux / InfluxQL queries ~$1.50 / GB / month Telegraf WARM TIER Iceberg on S3 Standard 30 days – 1 year SQL analytics (Athena) ML training datasets ACID + time travel ~$0.023 / GB / month S3 Lifecycle COLD / ARCHIVE TIER Iceberg on S3 Glacier 1 year+ (compliance) Compacted Parquet files Occasional audit queries Schema evolution intact ~$0.004 / GB / month Total storage cost reduction: up to 98% versus keeping all data in InfluxDB Cloud—with improved queryability at every tier.

    Concluding Remarks

    Building a data pipeline from InfluxDB to Apache Iceberg through Telegraf is not only technically feasible but also a compelling architecture that addresses real problems. InfluxDB continues to perform its principal function—real-time monitoring and dashboards—while historical data are offloaded to a lakehouse that costs 90 to 98 per cent less and provides SQL analytics, ML pipelines, and proper data governance.

    The architecture comprises the following elements:

    • Telegraf input plugins that retrieve data from InfluxDB v1.x or v2.x using four methods, ranging from simple pull-based queries to real-time push-based listeners.
    • Telegraf processors that transform InfluxDB's tag/field model into a flat columnar schema suitable for Iceberg, with type conversion, field renaming, computed fields, and date partitioning.
    • S3 output with Hive-style partitioning that lands data in formats AWS Glue can discover and catalogue.
    • Iceberg table creation via Athena DDL or Glue Crawlers, with appropriate partitioning for time-series workloads.
    • Automated ingestion using Glue ETL jobs, Athena INSERT INTO, Lambda triggers, or Spark on EMR.
    • A complete, production-ready telegraf.conf that can be deployed with minimal modification.

    For organisations requiring real-time pattern detection on streaming data before it lands in the lakehouse, combining this pipeline with complex event processing using Apache Flink permits in-flight anomaly detection while still archiving all data to Iceberg. The principal merit of this architecture is its modularity. It is possible to begin simply—with JSON files on S3 and a Glue Crawler—and progress to Parquet with Spark streaming as requirements grow. Telegraf's plugin architecture permits the substitution of inputs and outputs without rewriting transformation logic, and Iceberg's partition evolution permits changes to partitioning strategy without rewriting any historical data.

    For organisations with terabytes of time-series data in InfluxDB and rising storage bills, this pipeline provides a viable migration path. It can be set up over a weekend, validated with a week of dual-writing, and then used as the basis for reducing InfluxDB retention policies.

    References

  • Complex Event Processing with Apache Flink: Building Real-Time CEP Pipelines from Scratch

    In short

    Complex Event Processing detects patterns that span multiple events—sequences, combinations, and timing relationships—rather than examining records one at a time, and Apache Flink’s flink-cep library implements it with a non-deterministic finite automaton over keyed, event-time streams. The three ideas that most often separate a working CEP job from a broken one recur throughout this guide: choosing the right contiguity (next() for strict adjacency, followedBy() for relaxed matching, and followedByAny() only when combinatorial state growth is acceptable); driving every pattern on event time with explicit watermarks, because processing time silently produces wrong matches whenever events arrive out of order; and keeping state bounded through keyed streams, short within() windows, RocksDB, and timeout handling. Around those principles, the guide builds three end-to-end Java pipelines—credit-card fraud, IoT anomaly, and stock-market pattern detection—and covers the Kafka connectors, deployment, and tuning needed to run them in production.

    A fraud system that inspects each card transaction in isolation can flag a single implausibly large charge, but it cannot see that two otherwise unremarkable purchases—one at a Houston gas station, another at a Tokyo electronics store forty seconds later—are jointly impossible. The evidence of fraud lives not in either event but in the relationship between them: their order, their timing, and the physical distance between two card-present locations. Detecting that relationship means reasoning over sequences of events rather than over individual records, and at the volumes a large card network sustains—VisaNet is engineered to handle on the order of 65,000 transaction messages per second—that reasoning has to happen continuously and within milliseconds. This class of problem is what Complex Event Processing (CEP) exists to solve.

    Apache Flink offers one of the more mature open-source implementations of CEP, exposed through its dedicated flink-cep library. The sections that follow work through three complete pipelines—credit-card fraud detection, IoT sensor anomaly detection, and stock-market pattern detection—in compilable Java, together with the pattern API, event-time semantics, Kafka integration, deployment, and performance tuning required to operate them. Familiarity with Java and some prior exposure to stream processing are assumed; the CEP concepts themselves are developed from first principles, so no background in pattern matching is expected.

    What is Complex Event Processing (CEP)?

    Complex Event Processing is a methodology for detecting meaningful patterns across streams of events in real time. The defining term is patterns. Simple stream processing typically filters or transforms individual events, for example by returning all transactions above $1,000. CEP extends this scope by examining sequences, combinations, and temporal relationships across multiple events.

    Simple Events vs Complex Events

    A simple event is a single, atomic occurrence such as a temperature reading, a stock trade, or a log entry. A complex event is a higher-level pattern derived from multiple simple events. For example:

    • Simple event: “User #4821 made a $50 purchase at Starbucks.”
    • Complex event: “User #4821 made three purchases totalling over $2,000 within five minutes from three different countries.” This complex event exists only because a CEP engine recognised the pattern across the underlying simple events.

    CEP Compared with Traditional Processing

    Understanding where CEP fits relative to batch and stream processing is important:

    Feature Batch Processing Stream Processing CEP
    Latency Minutes to hours Milliseconds to seconds Milliseconds to seconds
    Data Model Bounded datasets Unbounded streams Unbounded streams with pattern state
    Pattern Detection Post-hoc analysis Per-event transformations Multi-event temporal patterns
    State Management Minimal (reprocess from scratch) Windowed aggregations Pattern match buffers with NFA
    Use Case Example Monthly reports Real-time dashboards Fraud detection, anomaly sequences
    Tools Spark, Hadoop MapReduce Kafka Streams, Flink DataStream Flink CEP, Esper, Siddhi

     

    Real-World CEP Applications

    CEP is not a niche technology. It underpins a number of important systems across industries:

    • Fraud Detection: Banks and payment processors use CEP to identify fraudulent transaction patterns in real time, including velocity checks, geographic impossibility, and unusual merchant categories.
    • IoT Monitoring: Manufacturing plants and smart buildings use CEP to detect equipment failure sequences before catastrophic breakdowns occur. For the data infrastructure behind IoT monitoring, see the guide on managing metadata and time-series data for facility sensor signals.
    • Algorithmic Trading: Hedge funds detect price-volume patterns across multiple securities within microsecond windows in order to trigger automated trades.
    • Network Security: SIEM platforms use CEP to correlate firewall logs, authentication events, and data transfer patterns and thereby detect multi-stage cyberattacks.
    • Supply Chain: Real-time tracking of shipment events allows operators to detect delays, rerouting needs, or customs anomalies before they cascade.

    CEP Pipeline: From Raw Events to Actionable Alerts Event Source Kafka / Kinesis / API Flink Ingestion Parse · Key · Watermark Pattern Detection NFA State Machine Alert / Action Sink · Notify · Block ① Ingest ② Stream ③ Match ④ React End-to-end latency: milliseconds

    Several stream processing engines exist, but Flink occupies a distinct position for CEP workloads. The reasons are discussed below.

    Flink was designed as a streaming-first engine. Unlike Spark, which added streaming capabilities to a batch framework, Flink treats streams as the fundamental data model. The distinction is consequential for CEP for several reasons:

    • DataStream API: The core API operates on unbounded streams and offers fine-grained control over event processing, keying, and windowing.
    • Event Time Processing: Flink natively supports event time semantics with watermarks, a feature that is essential for CEP. Matching patterns across events requires reasoning about when events actually occurred, not when they arrived at the processing system.
    • Watermarks: The watermark mechanism tracks the progress of event time through the stream and enables correct handling of out-of-order events, which are a routine occurrence in distributed systems.
    • Flink CEP Library (flink-cep): Flink ships a dedicated CEP library that implements a Non-deterministic Finite Automaton (NFA) for pattern matching. Patterns are defined declaratively, and the engine handles the associated state management internally.
    • Exactly-Once Semantics: The checkpointing mechanism guarantees exactly-once processing, ensuring that fraud alerts are never duplicated or lost.
    • Low Latency: Flink processes events within milliseconds rather than in micro-batches. For CEP workloads, where rapid pattern matching is essential, this property is non-negotiable.

    Apache Flink Cluster Architecture JobManager Scheduler · Checkpoints · Recovery TaskManager 1 Task Slots · JVM TaskManager 2 Task Slots · JVM TaskManager 3 Task Slots · JVM Data Flow (partitioned by key) Source (Kafka) CEP Pattern Operator (NFA) Sink (Alerts)

    Feature Flink CEP Kafka Streams Esper Spark Structured Streaming Kinesis Analytics
    Pattern Matching Built-in NFA-based Manual (no CEP library) EPL query language No native CEP SQL-based only
    Latency True streaming (ms) True streaming (ms) In-memory (ms) Micro-batch (100ms+) Near real-time
    Scalability Distributed cluster Embedded scaling Single JVM Distributed cluster AWS managed
    Exactly-Once Yes Yes No Yes Yes
    Fault Tolerance Checkpointing + savepoints Changelog topics Limited Checkpointing Managed snapshots
    Event Time Support Native watermarks Timestamp extractors Limited Native watermarks Limited
    Best For Complex temporal patterns at scale Simple event-driven microservices Prototyping, embedded CEP Batch + streaming hybrid AWS-native SQL analytics

     

    Key Takeaway: For workloads that require detection of complex temporal patterns across high-volume event streams with exactly-once guarantees, Flink CEP is the strongest choice. Kafka Streams is well suited to simpler event-driven architectures but lacks a built-in pattern matching engine. Esper offers strong CEP semantics yet does not scale horizontally. For a more detailed treatment of Kafka as the event backbone, see the Apache Kafka multivariate time-series engine guide.

    Setting Up Your Flink CEP Project

    Prerequisites

    Before any code is written, the following components should be in place:

    • Java 11 or 17 (Flink 1.18+ supports both; Java 17 is recommended for new projects)
    • Maven 3.8+ or Gradle 7+
    • An IDE—IntelliJ IDEA with the Flink plugin is well suited
    • Docker (optional, for running Kafka and Flink locally)
    On Flink versions: The examples in this guide pin to Flink 1.18.1, which remains a widely deployed long-term reference for the CEP API. The project has since moved to the 2.x series—Apache Flink 2.3.0 was released on 25 June 2026, while 1.20.x is the final release line of the 1.x series (see the Apache Flink downloads page). The Pattern API concepts shown here—begin, contiguity operators, quantifiers, conditions, and within()—carry across these releases; when adopting Flink 2.x, verify the current connector coordinates and any package moves against the release notes before upgrading a running job.

    Project Structure

    The following layout is used throughout this guide:

    flink-cep-pipeline/
    ├── pom.xml
    ├── src/main/java/com/example/cep/
    │   ├── FlinkCEPApplication.java
    │   ├── events/
    │   │   ├── Transaction.java
    │   │   ├── SensorReading.java
    │   │   └── StockTick.java
    │   ├── patterns/
    │   │   ├── FraudPatterns.java
    │   │   ├── IoTPatterns.java
    │   │   └── StockPatterns.java
    │   ├── processors/
    │   │   ├── FraudAlertProcessor.java
    │   │   ├── AnomalyAlertProcessor.java
    │   │   └── TradingSignalProcessor.java
    │   └── sources/
    │       └── KafkaSourceBuilder.java
    └── src/main/resources/
        └── log4j2.properties

    Maven pom.xml

    The following Maven configuration contains all required Flink CEP dependencies:

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>com.example</groupId>
        <artifactId>flink-cep-pipeline</artifactId>
        <version>1.0.0</version>
        <packaging>jar</packaging>
    
        <properties>
            <flink.version>1.18.1</flink.version>
            <java.version>17</java.version>
            <kafka.version>3.6.1</kafka.version>
            <maven.compiler.source>${java.version}</maven.compiler.source>
            <maven.compiler.target>${java.version}</maven.compiler.target>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        </properties>
    
        <dependencies>
            <!-- Flink Core -->
            <dependency>
                <groupId>org.apache.flink</groupId>
                <artifactId>flink-streaming-java</artifactId>
                <version>${flink.version}</version>
                <scope>provided</scope>
            </dependency>
    
            <!-- Flink CEP Library -->
            <dependency>
                <groupId>org.apache.flink</groupId>
                <artifactId>flink-cep</artifactId>
                <version>${flink.version}</version>
            </dependency>
    
            <!-- Flink Kafka Connector -->
            <dependency>
                <groupId>org.apache.flink</groupId>
                <artifactId>flink-connector-kafka</artifactId>
                <version>3.1.0-1.18</version>
            </dependency>
    
            <!-- Flink JSON Format -->
            <dependency>
                <groupId>org.apache.flink</groupId>
                <artifactId>flink-json</artifactId>
                <version>${flink.version}</version>
            </dependency>
    
            <!-- Flink Clients (for local execution) -->
            <dependency>
                <groupId>org.apache.flink</groupId>
                <artifactId>flink-clients</artifactId>
                <version>${flink.version}</version>
                <scope>provided</scope>
            </dependency>
    
            <!-- Jackson for JSON serialization -->
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>2.16.1</version>
            </dependency>
    
            <!-- SLF4J + Log4j2 -->
            <dependency>
                <groupId>org.apache.logging.log4j</groupId>
                <artifactId>log4j-slf4j-impl</artifactId>
                <version>2.22.1</version>
                <scope>runtime</scope>
            </dependency>
            <dependency>
                <groupId>org.apache.logging.log4j</groupId>
                <artifactId>log4j-api</artifactId>
                <version>2.22.1</version>
                <scope>runtime</scope>
            </dependency>
            <dependency>
                <groupId>org.apache.logging.log4j</groupId>
                <artifactId>log4j-core</artifactId>
                <version>2.22.1</version>
                <scope>runtime</scope>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-shade-plugin</artifactId>
                    <version>3.5.1</version>
                    <executions>
                        <execution>
                            <phase>package</phase>
                            <goals><goal>shade</goal></goals>
                            <configuration>
                                <transformers>
                                    <transformer implementation=
                                        "org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                        <mainClass>com.example.cep.FlinkCEPApplication</mainClass>
                                    </transformer>
                                </transformers>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </project>

    Gradle Alternative

    For Gradle users, the equivalent build.gradle.kts is shown below:

    plugins {
        java
        id("com.github.johnrengelman.shadow") version "8.1.1"
    }
    
    java {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    
    val flinkVersion = "1.18.1"
    
    dependencies {
        compileOnly("org.apache.flink:flink-streaming-java:$flinkVersion")
        compileOnly("org.apache.flink:flink-clients:$flinkVersion")
        implementation("org.apache.flink:flink-cep:$flinkVersion")
        implementation("org.apache.flink:flink-connector-kafka:3.1.0-1.18")
        implementation("org.apache.flink:flink-json:$flinkVersion")
        implementation("com.fasterxml.jackson.core:jackson-databind:2.16.1")
        runtimeOnly("org.apache.logging.log4j:log4j-slf4j-impl:2.22.1")
        runtimeOnly("org.apache.logging.log4j:log4j-core:2.22.1")
    }
    Tip: The flink-streaming-java and flink-clients dependencies are marked as provided (Maven) or compileOnly (Gradle) because the Flink cluster already includes them. When running locally in an IDE, add them to the run configuration’s classpath.

    Understanding Flink CEP Pattern API

    The Flink CEP library provides a declarative API for defining event patterns. Internally, the library compiles each pattern definition into a Non-deterministic Finite Automaton (NFA) that matches patterns efficiently against the incoming event stream. Each major concept is examined in turn below.

    Pattern Matching: Sequence Detection on an Event Stream time → E1 login_fail other E2 login_fail E3 login_fail other ALERT 3× login_fail within window → pattern matched Matching event Non-matching event Alert fired

    Pattern Basics

    Every pattern starts with Pattern.begin() and chains additional states:

    // Strict contiguity: events must be directly adjacent
    Pattern<Event, ?> strict = Pattern.<Event>begin("start")
        .where(new SimpleCondition<Event>() {
            @Override
            public boolean filter(Event event) {
                return event.getType().equals("login_failed");
            }
        })
        .next("second")  // MUST be the very next event
        .where(new SimpleCondition<Event>() {
            @Override
            public boolean filter(Event event) {
                return event.getType().equals("login_failed");
            }
        })
        .next("third")
        .where(new SimpleCondition<Event>() {
            @Override
            public boolean filter(Event event) {
                return event.getType().equals("login_failed");
            }
        });
    
    // Relaxed contiguity: allows non-matching events in between
    Pattern<Event, ?> relaxed = Pattern.<Event>begin("start")
        .where(/* ... */)
        .followedBy("end")  // matching events can have other events between them
        .where(/* ... */);
    
    // Non-deterministic relaxed contiguity:
    // matches all possible combinations
    Pattern<Event, ?> nonDeterministic = Pattern.<Event>begin("start")
        .where(/* ... */)
        .followedByAny("end")  // considers ALL matching events, not just first
        .where(/* ... */);

    Contiguity: Strict, Relaxed, Non-Deterministic

    Contiguity is one of the most important concepts in Flink CEP. Consider a scenario in which the event stream contains A, C, B1, B2 and the pattern is “A followed by B”:

    • next()—Strict: No match. C appears between A and B1, which breaks strict contiguity.
    • followedBy()—Relaxed: Matches {A, B1}. C is skipped, and the first matching B is selected.
    • followedByAny()—Non-deterministic relaxed: Matches both {A, B1} and {A, B2}, since all possible matching events are considered.

    Quantifiers

    // Exactly N times
    Pattern<Event, ?> exactly3 = Pattern.<Event>begin("failures")
        .where(condition)
        .times(3);  // exactly 3 matching events
    
    // N or more times
    Pattern<Event, ?> atLeast3 = Pattern.<Event>begin("failures")
        .where(condition)
        .timesOrMore(3);  // 3 or more matching events
    
    // Range
    Pattern<Event, ?> range = Pattern.<Event>begin("failures")
        .where(condition)
        .times(2, 5);  // between 2 and 5 matching events
    
    // One or more (greedy)
    Pattern<Event, ?> oneOrMore = Pattern.<Event>begin("failures")
        .where(condition)
        .oneOrMore()
        .greedy();  // match as many as possible
    
    // Optional
    Pattern<Event, ?> withOptional = Pattern.<Event>begin("start")
        .where(startCondition)
        .next("middle")
        .where(middleCondition)
        .optional()  // this state may or may not match
        .next("end")
        .where(endCondition);

    Conditions

    // Simple condition — checks current event only
    .where(new SimpleCondition<Event>() {
        @Override
        public boolean filter(Event event) {
            return event.getAmount() > 1000.0;
        }
    })
    
    // Iterative condition — can reference previously matched events
    .where(new IterativeCondition<Event>() {
        @Override
        public boolean filter(Event event, Context<Event> ctx) {
            // Compare with previously matched event
            for (Event prev : ctx.getEventsForPattern("start")) {
                if (!event.getLocation().equals(prev.getLocation())) {
                    return true;  // different location than start event
                }
            }
            return false;
        }
    })
    
    // OR condition
    .where(new SimpleCondition<Event>() {
        @Override
        public boolean filter(Event event) {
            return event.getType().equals("withdrawal");
        }
    })
    .or(new SimpleCondition<Event>() {
        @Override
        public boolean filter(Event event) {
            return event.getType().equals("transfer");
        }
    })
    
    // Until condition (stop condition for looping patterns)
    .oneOrMore()
    .until(new SimpleCondition<Event>() {
        @Override
        public boolean filter(Event event) {
            return event.getType().equals("logout");
        }
    })

    Time Constraints

    // The entire pattern must complete within 5 minutes
    Pattern<Event, ?> timedPattern = Pattern.<Event>begin("first")
        .where(/* ... */)
        .followedBy("second")
        .where(/* ... */)
        .followedBy("third")
        .where(/* ... */)
        .within(Time.minutes(5));
    Caution: The within() constraint applies to the entire pattern and is measured from the first matching event. If the first event matches at T=0 and within(Time.minutes(5)) is configured, the entire pattern must complete before T=5min. Partially matched patterns that time out are discarded, although they may be captured via timeout handling, which is discussed later.

    Hands-On: Credit Card Fraud Detection Pipeline

    The first complete CEP pipeline considered here is a credit card fraud detection system. The use case is canonical for CEP, and three distinct fraud patterns are implemented.

    The Transaction Event Class

    package com.example.cep.events;
    
    public class Transaction implements java.io.Serializable {
        private String transactionId;
        private String userId;
        private double amount;
        private long timestamp;
        private String location;
        private String merchantCategory;
        private String cardNumber;
    
        // Default constructor for serialization
        public Transaction() {}
    
        public Transaction(String transactionId, String userId, double amount,
                           long timestamp, String location, String merchantCategory,
                           String cardNumber) {
            this.transactionId = transactionId;
            this.userId = userId;
            this.amount = amount;
            this.timestamp = timestamp;
            this.location = location;
            this.merchantCategory = merchantCategory;
            this.cardNumber = cardNumber;
        }
    
        // Getters and setters
        public String getTransactionId() { return transactionId; }
        public void setTransactionId(String transactionId) { this.transactionId = transactionId; }
        public String getUserId() { return userId; }
        public void setUserId(String userId) { this.userId = userId; }
        public double getAmount() { return amount; }
        public void setAmount(double amount) { this.amount = amount; }
        public long getTimestamp() { return timestamp; }
        public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
        public String getLocation() { return location; }
        public void setLocation(String location) { this.location = location; }
        public String getMerchantCategory() { return merchantCategory; }
        public void setMerchantCategory(String mc) { this.merchantCategory = mc; }
        public String getCardNumber() { return cardNumber; }
        public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; }
    
        @Override
        public String toString() {
            return String.format("Transaction{id=%s, user=%s, amount=%.2f, loc=%s, time=%d}",
                transactionId, userId, amount, location, timestamp);
        }
    }

    The Fraud Alert Class

    package com.example.cep.events;
    
    import java.util.List;
    
    public class FraudAlert implements java.io.Serializable {
        private String alertId;
        private String userId;
        private String patternType;
        private String description;
        private List<Transaction> matchedTransactions;
        private long detectedAt;
    
        public FraudAlert(String alertId, String userId, String patternType,
                          String description, List<Transaction> matchedTransactions) {
            this.alertId = alertId;
            this.userId = userId;
            this.patternType = patternType;
            this.description = description;
            this.matchedTransactions = matchedTransactions;
            this.detectedAt = System.currentTimeMillis();
        }
    
        // Getters
        public String getAlertId() { return alertId; }
        public String getUserId() { return userId; }
        public String getPatternType() { return patternType; }
        public String getDescription() { return description; }
        public List<Transaction> getMatchedTransactions() { return matchedTransactions; }
        public long getDetectedAt() { return detectedAt; }
    
        @Override
        public String toString() {
            return String.format("FRAUD ALERT [%s] User: %s | Pattern: %s | %s | Transactions: %d",
                alertId, userId, patternType, description, matchedTransactions.size());
        }
    }

    Defining Fraud Patterns

    The core logic of the system is captured by three fraud detection patterns, defined below:

    package com.example.cep.patterns;
    
    import com.example.cep.events.Transaction;
    import org.apache.flink.cep.pattern.Pattern;
    import org.apache.flink.cep.pattern.conditions.IterativeCondition;
    import org.apache.flink.cep.pattern.conditions.SimpleCondition;
    import org.apache.flink.streaming.api.windowing.time.Time;
    
    public class FraudPatterns {
    
        /**
         * Pattern 1: Geographic Impossibility
         * Three transactions over $500 within 5 minutes from different locations.
         * Spending observed in New York, then London, then Tokyo within 5 minutes
         * is highly indicative of fraudulent activity.
         */
        public static Pattern<Transaction, ?> geographicImpossibility() {
            return Pattern.<Transaction>begin("first")
                .where(new SimpleCondition<Transaction>() {
                    @Override
                    public boolean filter(Transaction tx) {
                        return tx.getAmount() > 500.0;
                    }
                })
                .followedBy("second")
                .where(new IterativeCondition<Transaction>() {
                    @Override
                    public boolean filter(Transaction tx, Context<Transaction> ctx) {
                        if (tx.getAmount() <= 500.0) return false;
                        for (Transaction first : ctx.getEventsForPattern("first")) {
                            if (!tx.getLocation().equals(first.getLocation())) {
                                return true;
                            }
                        }
                        return false;
                    }
                })
                .followedBy("third")
                .where(new IterativeCondition<Transaction>() {
                    @Override
                    public boolean filter(Transaction tx, Context<Transaction> ctx) {
                        if (tx.getAmount() <= 500.0) return false;
                        for (Transaction first : ctx.getEventsForPattern("first")) {
                            for (Transaction second : ctx.getEventsForPattern("second")) {
                                if (!tx.getLocation().equals(first.getLocation())
                                    && !tx.getLocation().equals(second.getLocation())) {
                                    return true;
                                }
                            }
                        }
                        return false;
                    }
                })
                .within(Time.minutes(5));
        }
    
        /**
         * Pattern 2: Card Testing Attack
         * A small "test" transaction ($0.01–$5.00) followed by a large transaction
         * ($1000+) within 1 minute. Fraudsters frequently test stolen cards with
         * very small purchases before attempting larger ones.
         */
        public static Pattern<Transaction, ?> cardTestingAttack() {
            return Pattern.<Transaction>begin("test_charge")
                .where(new SimpleCondition<Transaction>() {
                    @Override
                    public boolean filter(Transaction tx) {
                        return tx.getAmount() >= 0.01 && tx.getAmount() <= 5.0;
                    }
                })
                .followedBy("big_charge")
                .where(new SimpleCondition<Transaction>() {
                    @Override
                    public boolean filter(Transaction tx) {
                        return tx.getAmount() >= 1000.0;
                    }
                })
                .within(Time.minutes(1));
        }
    
        /**
         * Pattern 3: Transaction Velocity
         * More than 5 transactions within 2 minutes. Even legitimate users
         * rarely conduct this many purchases in such a short interval.
         */
        public static Pattern<Transaction, ?> highVelocity() {
            return Pattern.<Transaction>begin("transactions")
                .where(new SimpleCondition<Transaction>() {
                    @Override
                    public boolean filter(Transaction tx) {
                        return tx.getAmount() > 0;
                    }
                })
                .timesOrMore(5)
                .within(Time.minutes(2));
        }
    }

    Processing Matched Patterns

    package com.example.cep.processors;
    
    import com.example.cep.events.FraudAlert;
    import com.example.cep.events.Transaction;
    import org.apache.flink.cep.functions.PatternProcessFunction;
    import org.apache.flink.util.Collector;
    
    import java.util.*;
    
    public class FraudAlertProcessor
            extends PatternProcessFunction<Transaction, FraudAlert> {
    
        private final String patternType;
    
        public FraudAlertProcessor(String patternType) {
            this.patternType = patternType;
        }
    
        @Override
        public void processMatch(Map<String, List<Transaction>> match,
                                 Context ctx,
                                 Collector<FraudAlert> out) {
            // Collect all matched transactions from all pattern states
            List<Transaction> allTransactions = new ArrayList<>();
            match.values().forEach(allTransactions::addAll);
    
            // Extract user ID from first transaction
            String userId = allTransactions.get(0).getUserId();
    
            // Build a description
            String description = buildDescription(match);
    
            // Generate alert
            String alertId = UUID.randomUUID().toString();
            FraudAlert alert = new FraudAlert(
                alertId, userId, patternType, description, allTransactions
            );
    
            out.collect(alert);
        }
    
        private String buildDescription(Map<String, List<Transaction>> match) {
            StringBuilder sb = new StringBuilder();
            sb.append("Matched pattern '").append(patternType).append("': ");
    
            double total = 0;
            Set<String> locations = new HashSet<>();
            int count = 0;
    
            for (List<Transaction> txList : match.values()) {
                for (Transaction tx : txList) {
                    total += tx.getAmount();
                    locations.add(tx.getLocation());
                    count++;
                }
            }
    
            sb.append(count).append(" transactions, ");
            sb.append(String.format("total $%.2f, ", total));
            sb.append("locations: ").append(locations);
    
            return sb.toString();
        }
    }

    The Complete Fraud Detection Pipeline

    The pipeline below is wired together end to end, from Kafka source to fraud alert output:

    package com.example.cep;
    
    import com.example.cep.events.FraudAlert;
    import com.example.cep.events.Transaction;
    import com.example.cep.patterns.FraudPatterns;
    import com.example.cep.processors.FraudAlertProcessor;
    
    import org.apache.flink.api.common.eventtime.WatermarkStrategy;
    import org.apache.flink.api.common.serialization.SimpleStringSchema;
    import org.apache.flink.cep.CEP;
    import org.apache.flink.cep.PatternStream;
    import org.apache.flink.cep.pattern.Pattern;
    import org.apache.flink.connector.kafka.source.KafkaSource;
    import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
    import org.apache.flink.connector.kafka.sink.KafkaSink;
    import org.apache.flink.connector.kafka.sink.KafkaRecordSerializationSchema;
    import org.apache.flink.streaming.api.datastream.DataStream;
    import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
    import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
    
    import com.fasterxml.jackson.databind.ObjectMapper;
    
    import java.time.Duration;
    
    public class FraudDetectionPipeline {
    
        public static void main(String[] args) throws Exception {
            // 1. Set up the streaming execution environment
            StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();
            env.setParallelism(4);
    
            // Enable checkpointing for exactly-once semantics
            env.enableCheckpointing(60_000); // checkpoint every 60 seconds
    
            // 2. Create Kafka source for transactions
            KafkaSource<String> kafkaSource = KafkaSource.<String>builder()
                .setBootstrapServers("localhost:9092")
                .setTopics("transactions")
                .setGroupId("fraud-detection-group")
                .setStartingOffsets(OffsetsInitializer.latest())
                .setValueOnlyDeserializer(new SimpleStringSchema())
                .build();
    
            // 3. Read from Kafka with event time watermarks
            ObjectMapper mapper = new ObjectMapper();
    
            DataStream<Transaction> transactions = env
                .fromSource(kafkaSource, WatermarkStrategy
                    .<String>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                    .withTimestampAssigner((event, timestamp) -> {
                        try {
                            return mapper.readValue(event, Transaction.class)
                                .getTimestamp();
                        } catch (Exception e) {
                            return timestamp;
                        }
                    }), "Kafka Transactions")
                .map(json -> mapper.readValue(json, Transaction.class))
                .keyBy(Transaction::getUserId);  // Key by user for per-user patterns
    
            // 4. Apply Pattern 1: Geographic Impossibility
            Pattern<Transaction, ?> geoPattern = FraudPatterns.geographicImpossibility();
            PatternStream<Transaction> geoPatternStream = CEP.pattern(
                transactions, geoPattern);
    
            DataStream<FraudAlert> geoAlerts = geoPatternStream.process(
                new FraudAlertProcessor("GEOGRAPHIC_IMPOSSIBILITY"));
    
            // 5. Apply Pattern 2: Card Testing Attack
            Pattern<Transaction, ?> testPattern = FraudPatterns.cardTestingAttack();
            PatternStream<Transaction> testPatternStream = CEP.pattern(
                transactions, testPattern);
    
            DataStream<FraudAlert> testAlerts = testPatternStream.process(
                new FraudAlertProcessor("CARD_TESTING_ATTACK"));
    
            // 6. Apply Pattern 3: High Velocity
            Pattern<Transaction, ?> velocityPattern = FraudPatterns.highVelocity();
            PatternStream<Transaction> velocityPatternStream = CEP.pattern(
                transactions, velocityPattern);
    
            DataStream<FraudAlert> velocityAlerts = velocityPatternStream.process(
                new FraudAlertProcessor("HIGH_VELOCITY"));
    
            // 7. Union all alerts and sink to Kafka
            DataStream<FraudAlert> allAlerts = geoAlerts
                .union(testAlerts)
                .union(velocityAlerts);
    
            // Print to console (for development)
            allAlerts.print("FRAUD ALERT");
    
            // Sink to Kafka alerts topic
            KafkaSink<String> alertSink = KafkaSink.<String>builder()
                .setBootstrapServers("localhost:9092")
                .setRecordSerializer(
                    KafkaRecordSerializationSchema.builder()
                        .setTopic("fraud-alerts")
                        .setValueSerializationSchema(new SimpleStringSchema())
                        .build()
                )
                .build();
    
            allAlerts
                .map(alert -> mapper.writeValueAsString(alert))
                .sinkTo(alertSink);
    
            // 8. Execute the pipeline
            env.execute("Credit Card Fraud Detection CEP Pipeline");
        }
    }
    Key Takeaway: The pipeline applies multiple independent patterns to the same keyed stream. Each CEP.pattern() call creates a separate NFA instance per key (per user), so patterns are evaluated independently and do not interfere with one another. The keyBy(Transaction::getUserId) call is essential because it ensures that patterns match only those events belonging to the same user.

    Hands-On: IoT Sensor Anomaly Detection

    The second pipeline detects anomalies in IoT sensor data. The target pattern is a sensor reporting three consecutive rising temperature readings above a threshold within one minute, followed by a pressure drop. The sequence frequently indicates an impending equipment failure. In a production setting, the detected anomalies would be persisted in a time-series database optimised for preprocessed data, and the underlying sensor readings could be supplied to forecasting models for predictive maintenance.

    Sensor Event Class

    package com.example.cep.events;
    
    public class SensorReading implements java.io.Serializable {
        private String sensorId;
        private double temperature;
        private double pressure;
        private long timestamp;
        private String location;
    
        public SensorReading() {}
    
        public SensorReading(String sensorId, double temperature, double pressure,
                             long timestamp, String location) {
            this.sensorId = sensorId;
            this.temperature = temperature;
            this.pressure = pressure;
            this.timestamp = timestamp;
            this.location = location;
        }
    
        public String getSensorId() { return sensorId; }
        public void setSensorId(String sensorId) { this.sensorId = sensorId; }
        public double getTemperature() { return temperature; }
        public void setTemperature(double temperature) { this.temperature = temperature; }
        public double getPressure() { return pressure; }
        public void setPressure(double pressure) { this.pressure = pressure; }
        public long getTimestamp() { return timestamp; }
        public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
        public String getLocation() { return location; }
        public void setLocation(String location) { this.location = location; }
    
        @Override
        public String toString() {
            return String.format("Sensor{id=%s, temp=%.1f, pressure=%.1f, time=%d}",
                sensorId, temperature, pressure, timestamp);
        }
    }

    Complete IoT Anomaly Pipeline

    package com.example.cep;
    
    import com.example.cep.events.SensorReading;
    import org.apache.flink.api.common.eventtime.WatermarkStrategy;
    import org.apache.flink.cep.CEP;
    import org.apache.flink.cep.PatternStream;
    import org.apache.flink.cep.functions.PatternProcessFunction;
    import org.apache.flink.cep.pattern.Pattern;
    import org.apache.flink.cep.pattern.conditions.IterativeCondition;
    import org.apache.flink.cep.pattern.conditions.SimpleCondition;
    import org.apache.flink.streaming.api.datastream.DataStream;
    import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
    import org.apache.flink.streaming.api.windowing.time.Time;
    import org.apache.flink.util.Collector;
    
    import java.time.Duration;
    import java.util.*;
    
    public class IoTAnomalyDetectionPipeline {
    
        private static final double TEMP_THRESHOLD = 85.0; // degrees Celsius
        private static final double PRESSURE_DROP_THRESHOLD = 10.0; // PSI
    
        public static void main(String[] args) throws Exception {
            StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();
            env.setParallelism(2);
            env.enableCheckpointing(30_000);
    
            // Simulated sensor data source (replace with Kafka in production)
            DataStream<SensorReading> sensorStream = env
                .addSource(new SimulatedSensorSource()) // your custom source
                .assignTimestampsAndWatermarks(
                    WatermarkStrategy
                        .<SensorReading>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                        .withTimestampAssigner((reading, ts) -> reading.getTimestamp())
                )
                .keyBy(SensorReading::getSensorId);
    
            // Pattern: 3 consecutive high-temp readings, then a pressure drop
            Pattern<SensorReading, ?> anomalyPattern = Pattern
                .<SensorReading>begin("rising_temp_1")
                .where(new SimpleCondition<SensorReading>() {
                    @Override
                    public boolean filter(SensorReading reading) {
                        return reading.getTemperature() > TEMP_THRESHOLD;
                    }
                })
                .next("rising_temp_2")
                .where(new IterativeCondition<SensorReading>() {
                    @Override
                    public boolean filter(SensorReading reading,
                                          Context<SensorReading> ctx) {
                        if (reading.getTemperature() <= TEMP_THRESHOLD) return false;
                        for (SensorReading prev : ctx.getEventsForPattern("rising_temp_1")) {
                            return reading.getTemperature() > prev.getTemperature();
                        }
                        return false;
                    }
                })
                .next("rising_temp_3")
                .where(new IterativeCondition<SensorReading>() {
                    @Override
                    public boolean filter(SensorReading reading,
                                          Context<SensorReading> ctx) {
                        if (reading.getTemperature() <= TEMP_THRESHOLD) return false;
                        for (SensorReading prev : ctx.getEventsForPattern("rising_temp_2")) {
                            return reading.getTemperature() > prev.getTemperature();
                        }
                        return false;
                    }
                })
                .followedBy("pressure_drop")
                .where(new IterativeCondition<SensorReading>() {
                    @Override
                    public boolean filter(SensorReading reading,
                                          Context<SensorReading> ctx) {
                        for (SensorReading prev : ctx.getEventsForPattern("rising_temp_1")) {
                            double pressureDiff = prev.getPressure() - reading.getPressure();
                            return pressureDiff > PRESSURE_DROP_THRESHOLD;
                        }
                        return false;
                    }
                })
                .within(Time.minutes(1));
    
            // Apply pattern and process matches
            PatternStream<SensorReading> patternStream =
                CEP.pattern(sensorStream, anomalyPattern);
    
            DataStream<String> anomalyAlerts = patternStream.process(
                new PatternProcessFunction<SensorReading, String>() {
                    @Override
                    public void processMatch(Map<String, List<SensorReading>> match,
                                             Context ctx,
                                             Collector<String> out) {
                        SensorReading first = match.get("rising_temp_1").get(0);
                        SensorReading second = match.get("rising_temp_2").get(0);
                        SensorReading third = match.get("rising_temp_3").get(0);
                        SensorReading drop = match.get("pressure_drop").get(0);
    
                        String alert = String.format(
                            "ANOMALY DETECTED | Sensor: %s | Location: %s | " +
                            "Temps: %.1f -> %.1f -> %.1f (threshold: %.1f) | " +
                            "Pressure drop: %.1f -> %.1f (delta: %.1f)",
                            first.getSensorId(), first.getLocation(),
                            first.getTemperature(), second.getTemperature(),
                            third.getTemperature(), TEMP_THRESHOLD,
                            first.getPressure(), drop.getPressure(),
                            first.getPressure() - drop.getPressure()
                        );
    
                        out.collect(alert);
                    }
                }
            );
    
            anomalyAlerts.print("IOT ALERT");
            env.execute("IoT Sensor Anomaly Detection Pipeline");
        }
    }
    Tip: The pipeline uses next() (strict contiguity) for the three rising temperature readings because they must be consecutive. By contrast, followedBy() (relaxed contiguity) is used for the pressure drop, since other normal readings may occur between the temperature spike and the pressure change.

    Hands-On: Stock Market Pattern Detection

    The third pipeline detects potential trading signals, specifically a price drop greater than 5% followed by a high volume spike within 10 seconds. The pattern can indicate panic selling followed by institutional buying, which may represent a potential buy signal.

    StockTick Event Class

    package com.example.cep.events;
    
    public class StockTick implements java.io.Serializable {
        private String symbol;
        private double price;
        private long volume;
        private long timestamp;
        private double previousClose;
    
        public StockTick() {}
    
        public StockTick(String symbol, double price, long volume,
                         long timestamp, double previousClose) {
            this.symbol = symbol;
            this.price = price;
            this.volume = volume;
            this.timestamp = timestamp;
            this.previousClose = previousClose;
        }
    
        public String getSymbol() { return symbol; }
        public void setSymbol(String symbol) { this.symbol = symbol; }
        public double getPrice() { return price; }
        public void setPrice(double price) { this.price = price; }
        public long getVolume() { return volume; }
        public void setVolume(long volume) { this.volume = volume; }
        public long getTimestamp() { return timestamp; }
        public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
        public double getPreviousClose() { return previousClose; }
        public void setPreviousClose(double pc) { this.previousClose = pc; }
    
        public double getPriceChangePercent() {
            if (previousClose == 0) return 0;
            return ((price - previousClose) / previousClose) * 100.0;
        }
    
        @Override
        public String toString() {
            return String.format("StockTick{sym=%s, price=%.2f, vol=%d, change=%.2f%%}",
                symbol, price, volume, getPriceChangePercent());
        }
    }

    Complete Stock Market Detection Pipeline

    package com.example.cep;
    
    import com.example.cep.events.StockTick;
    import org.apache.flink.api.common.eventtime.WatermarkStrategy;
    import org.apache.flink.cep.CEP;
    import org.apache.flink.cep.PatternStream;
    import org.apache.flink.cep.functions.PatternProcessFunction;
    import org.apache.flink.cep.pattern.Pattern;
    import org.apache.flink.cep.pattern.conditions.IterativeCondition;
    import org.apache.flink.cep.pattern.conditions.SimpleCondition;
    import org.apache.flink.streaming.api.datastream.DataStream;
    import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
    import org.apache.flink.streaming.api.windowing.time.Time;
    import org.apache.flink.util.Collector;
    
    import java.time.Duration;
    import java.util.*;
    
    public class StockPatternDetectionPipeline {
    
        private static final double PRICE_DROP_THRESHOLD = -5.0; // percent
        private static final double VOLUME_SPIKE_MULTIPLIER = 3.0; // 3x average
    
        public static void main(String[] args) throws Exception {
            StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();
            env.setParallelism(4);
            env.enableCheckpointing(10_000);
    
            // Assume a Kafka source producing StockTick JSON
            // (using simulated source for this example)
            DataStream<StockTick> tickStream = env
                .addSource(new SimulatedStockSource())
                .assignTimestampsAndWatermarks(
                    WatermarkStrategy
                        .<StockTick>forBoundedOutOfOrderness(Duration.ofSeconds(2))
                        .withTimestampAssigner((tick, ts) -> tick.getTimestamp())
                )
                .keyBy(StockTick::getSymbol);
    
            // Pattern: Price drop > 5% followed by volume spike within 10 seconds
            Pattern<StockTick, ?> buySignalPattern = Pattern
                .<StockTick>begin("price_drop")
                .where(new SimpleCondition<StockTick>() {
                    @Override
                    public boolean filter(StockTick tick) {
                        return tick.getPriceChangePercent() < PRICE_DROP_THRESHOLD;
                    }
                })
                .followedBy("volume_spike")
                .where(new IterativeCondition<StockTick>() {
                    @Override
                    public boolean filter(StockTick tick, Context<StockTick> ctx) {
                        for (StockTick drop : ctx.getEventsForPattern("price_drop")) {
                            // Volume must be at least 3x the volume during the drop
                            if (tick.getVolume() > drop.getVolume() * VOLUME_SPIKE_MULTIPLIER) {
                                return true;
                            }
                        }
                        return false;
                    }
                })
                .within(Time.seconds(10));
    
            // Apply pattern
            PatternStream<StockTick> patternStream =
                CEP.pattern(tickStream, buySignalPattern);
    
            DataStream<String> signals = patternStream.process(
                new PatternProcessFunction<StockTick, String>() {
                    @Override
                    public void processMatch(Map<String, List<StockTick>> match,
                                             Context ctx,
                                             Collector<String> out) {
                        StockTick drop = match.get("price_drop").get(0);
                        StockTick spike = match.get("volume_spike").get(0);
    
                        String signal = String.format(
                            "BUY SIGNAL | %s | Drop: %.2f%% (price $%.2f) | " +
                            "Volume spike: %d -> %d (%.1fx) | " +
                            "Current price: $%.2f",
                            drop.getSymbol(),
                            drop.getPriceChangePercent(),
                            drop.getPrice(),
                            drop.getVolume(),
                            spike.getVolume(),
                            (double) spike.getVolume() / drop.getVolume(),
                            spike.getPrice()
                        );
    
                        out.collect(signal);
                    }
                }
            );
    
            signals.print("TRADING SIGNAL");
            env.execute("Stock Market Pattern Detection Pipeline");
        }
    }
    Caution: The example above illustrates pattern detection for educational purposes only and is not a recommendation to trade any security. Production algorithmic trading systems incorporate substantially more signals, risk management, and regulatory safeguards, and no trading decision should rest on the output of a single CEP pattern.

    Advanced CEP Techniques

    Once the fundamentals are in place, the following advanced techniques bring CEP pipelines to production quality.

    Dynamic Patterns from External Configuration

    Hard-coded patterns are acceptable during initial development, but production systems must update rules without redeployment. One approach is to load pattern parameters from an external source:

    // Load thresholds from a configuration source
    public class DynamicFraudPatterns {
    
        public static Pattern<Transaction, ?> fromConfig(FraudRuleConfig config) {
            return Pattern.<Transaction>begin("test_charge")
                .where(new SimpleCondition<Transaction>() {
                    @Override
                    public boolean filter(Transaction tx) {
                        return tx.getAmount() >= config.getMinTestAmount()
                            && tx.getAmount() <= config.getMaxTestAmount();
                    }
                })
                .followedBy("big_charge")
                .where(new SimpleCondition<Transaction>() {
                    @Override
                    public boolean filter(Transaction tx) {
                        return tx.getAmount() >= config.getLargeTransactionThreshold();
                    }
                })
                .within(Time.minutes(config.getTimeWindowMinutes()));
        }
    }
    
    // Configuration POJO loaded from database, file, or broadcast stream
    public class FraudRuleConfig implements java.io.Serializable {
        private double minTestAmount = 0.01;
        private double maxTestAmount = 5.0;
        private double largeTransactionThreshold = 1000.0;
        private int timeWindowMinutes = 1;
    
        // getters and setters...
    }
    Tip: For fully dynamic pattern updates without restarting the Flink job, Flink’s Broadcast State can be used to distribute new rule configurations to all parallel instances. The CEP library itself does not support changing patterns at runtime, but a custom operator can re-create patterns when new configurations arrive via a broadcast stream.

    Side Outputs for Timeout Handling

    When a partial pattern match times out, that is, when the within() window expires before the pattern completes, the timed-out partial matches can be captured using TimedOutPartialMatchHandler:

    import org.apache.flink.cep.functions.PatternProcessFunction;
    import org.apache.flink.cep.functions.TimedOutPartialMatchHandler;
    import org.apache.flink.util.OutputTag;
    
    public class FraudAlertWithTimeout
            extends PatternProcessFunction<Transaction, FraudAlert>
            implements TimedOutPartialMatchHandler<Transaction> {
    
        // Side output for timed-out partial matches
        public static final OutputTag<String> TIMEOUT_TAG =
            new OutputTag<String>("timed-out-patterns") {};
    
        @Override
        public void processMatch(Map<String, List<Transaction>> match,
                                 Context ctx,
                                 Collector<FraudAlert> out) {
            // Process fully matched pattern (same as before)
            // ...
        }
    
        @Override
        public void processTimedOutMatch(Map<String, List<Transaction>> match,
                                         Context ctx) {
            // A partial match timed out — log it for analysis
            StringBuilder sb = new StringBuilder("PARTIAL MATCH TIMEOUT: ");
            for (Map.Entry<String, List<Transaction>> entry : match.entrySet()) {
                sb.append(entry.getKey()).append("=")
                  .append(entry.getValue().size()).append(" events; ");
            }
    
            // Output to side output
            ctx.output(TIMEOUT_TAG, sb.toString());
        }
    }
    
    // In your pipeline, capture the side output:
    SingleOutputStreamOperator<FraudAlert> alerts = patternStream
        .process(new FraudAlertWithTimeout());
    
    DataStream<String> timedOutPatterns = alerts
        .getSideOutput(FraudAlertWithTimeout.TIMEOUT_TAG);
    
    timedOutPatterns.print("TIMEOUT");

    Scaling CEP Jobs

    CEP pattern matching is stateful because the NFA maintains partial match buffers per key. The principal scaling considerations are summarised below:

    • Key Partitioning: The stream should be passed through keyBy() before CEP patterns are applied. This ensures that events for the same entity (user, sensor, stock symbol) are routed to the same parallel instance.
    • Parallelism: Parallelism should be selected on the basis of key cardinality. For 10,000 users, a parallelism of 8–16 is generally sufficient. Flink distributes keys across parallel instances using hash partitioning.
    • State Size: Each active partial match consumes memory. With long time windows or high-cardinality patterns, state size should be monitored carefully.
    // Set different parallelism for different pipeline stages
    DataStream<Transaction> transactions = env
        .fromSource(kafkaSource, watermarkStrategy, "source")
        .setParallelism(8)  // match Kafka partitions
        .map(json -> mapper.readValue(json, Transaction.class))
        .setParallelism(8)
        .keyBy(Transaction::getUserId);
    
    // CEP pattern matching — can be different parallelism
    PatternStream<Transaction> patternStream = CEP.pattern(
        transactions.setParallelism(16),  // more parallelism for CPU-heavy matching
        fraudPattern
    );

    State Management and Checkpointing

    import org.apache.flink.contrib.streaming.state.EmbeddedRocksDBStateBackend;
    import org.apache.flink.streaming.api.CheckpointingMode;
    import org.apache.flink.streaming.api.environment.CheckpointConfig;
    
    // Configure robust checkpointing
    env.setStateBackend(new EmbeddedRocksDBStateBackend());
    env.enableCheckpointing(60_000, CheckpointingMode.EXACTLY_ONCE);
    
    CheckpointConfig checkpointConfig = env.getCheckpointConfig();
    checkpointConfig.setMinPauseBetweenCheckpoints(30_000);
    checkpointConfig.setCheckpointTimeout(120_000);
    checkpointConfig.setMaxConcurrentCheckpoints(1);
    checkpointConfig.setTolerableCheckpointFailureNumber(3);
    
    // Retain checkpoints on cancellation (for savepoint-like recovery)
    checkpointConfig.setExternalizedCheckpointCleanup(
        CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION
    );

    Event Time and Processing Time

    The distinction between event time and processing time is of central importance for CEP. Event time is the moment at which the event actually occurred, as embedded in the event data. Processing time is the moment at which the Flink operator processes the event. Under ideal conditions, the two values would coincide. In practice, events arrive late, out of order, and at variable rates.

    Why Event Time Matters for CEP

    Consider a fraud detection pattern defined as “three transactions within 5 minutes.” If transaction #2 arrives at the system 10 seconds late owing to network congestion, processing time would register a gap that does not actually exist. Event time correctly identifies that the three transactions occurred within the 5-minute window, irrespective of when they arrived.

    Watermark Strategies

    import org.apache.flink.api.common.eventtime.WatermarkStrategy;
    import org.apache.flink.api.common.eventtime.WatermarkGenerator;
    import org.apache.flink.api.common.eventtime.WatermarkOutput;
    import org.apache.flink.api.common.eventtime.WatermarkGeneratorSupplier;
    
    // Strategy 1: Bounded out-of-orderness (most common)
    // Assumes events can arrive up to 5 seconds late
    WatermarkStrategy<Transaction> strategy1 = WatermarkStrategy
        .<Transaction>forBoundedOutOfOrderness(Duration.ofSeconds(5))
        .withTimestampAssigner((tx, recordTimestamp) -> tx.getTimestamp());
    
    // Strategy 2: Monotonous timestamps (events always in order)
    // Only use if you can guarantee ordering
    WatermarkStrategy<Transaction> strategy2 = WatermarkStrategy
        .<Transaction>forMonotonousTimestamps()
        .withTimestampAssigner((tx, recordTimestamp) -> tx.getTimestamp());
    
    // Strategy 3: Custom watermark generator for complex scenarios
    WatermarkStrategy<Transaction> strategy3 = WatermarkStrategy
        .<Transaction>forGenerator(context -> new WatermarkGenerator<Transaction>() {
            private long maxTimestamp = Long.MIN_VALUE;
            private static final long MAX_DELAY = 10_000L; // 10 seconds
    
            @Override
            public void onEvent(Transaction tx, long eventTimestamp,
                                WatermarkOutput output) {
                maxTimestamp = Math.max(maxTimestamp, tx.getTimestamp());
            }
    
            @Override
            public void onPeriodicEmit(WatermarkOutput output) {
                output.emitWatermark(
                    new org.apache.flink.api.common.eventtime.Watermark(
                        maxTimestamp - MAX_DELAY
                    )
                );
            }
        })
        .withTimestampAssigner((tx, recordTimestamp) -> tx.getTimestamp());
    Key Takeaway: For most CEP applications, forBoundedOutOfOrderness() with a bound of 5–10 seconds is the appropriate choice. A bound that is too low causes late events to be missed, while a bound that is too high delays pattern matching by the same amount, since Flink cannot process an event-time window until the watermark passes it.

    Connecting to Real Data Sources

    Kafka Source Connector

    Most production CEP pipelines read from Apache Kafka. For a Python-focused treatment of Kafka consumer implementation, see the Apache Kafka consumer implementation guide in Python. A complete, production-ready Kafka source setup in Java is shown below:

    import org.apache.flink.api.common.serialization.DeserializationSchema;
    import org.apache.flink.api.common.typeinfo.TypeInformation;
    import org.apache.flink.connector.kafka.source.KafkaSource;
    import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
    import com.fasterxml.jackson.databind.ObjectMapper;
    
    // Custom deserializer for Transaction events
    public class TransactionDeserializer
            implements DeserializationSchema<Transaction> {
    
        private transient ObjectMapper mapper;
    
        @Override
        public Transaction deserialize(byte[] message) {
            if (mapper == null) mapper = new ObjectMapper();
            try {
                return mapper.readValue(message, Transaction.class);
            } catch (Exception e) {
                // Log and skip malformed events
                System.err.println("Failed to deserialize: " + new String(message));
                return null;
            }
        }
    
        @Override
        public boolean isEndOfStream(Transaction nextElement) {
            return false;
        }
    
        @Override
        public TypeInformation<Transaction> getProducedType() {
            return TypeInformation.of(Transaction.class);
        }
    }
    
    // Build the Kafka source
    KafkaSource<Transaction> source = KafkaSource.<Transaction>builder()
        .setBootstrapServers("kafka-broker-1:9092,kafka-broker-2:9092")
        .setTopics("transactions")
        .setGroupId("fraud-detection-v2")
        .setStartingOffsets(OffsetsInitializer.latest())
        .setValueOnlyDeserializer(new TransactionDeserializer())
        .setProperty("security.protocol", "SASL_SSL")
        .setProperty("sasl.mechanism", "PLAIN")
        .setProperty("sasl.jaas.config",
            "org.apache.kafka.common.security.plain.PlainLoginModule required " +
            "username=\"api-key\" password=\"api-secret\";")
        .build();

    Kafka Sink for Alerts

    import org.apache.flink.connector.kafka.sink.KafkaSink;
    import org.apache.flink.connector.kafka.sink.KafkaRecordSerializationSchema;
    import org.apache.flink.api.common.serialization.SimpleStringSchema;
    import org.apache.flink.connector.base.DeliveryGuarantee;
    
    KafkaSink<String> alertSink = KafkaSink.<String>builder()
        .setBootstrapServers("kafka-broker-1:9092")
        .setRecordSerializer(
            KafkaRecordSerializationSchema.builder()
                .setTopic("fraud-alerts")
                .setValueSerializationSchema(new SimpleStringSchema())
                .build()
        )
        .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
        .setTransactionalIdPrefix("fraud-alert-sink")
        .build();
    
    // Wire it up
    allAlerts
        .map(alert -> mapper.writeValueAsString(alert))
        .sinkTo(alertSink);

    JDBC Connector for Enrichment

    It is often necessary to enrich events with data from a database, for example by looking up a customer’s risk score before CEP patterns are applied. Flink’s asynchronous I/O is well suited to this purpose:

    import org.apache.flink.streaming.api.functions.async.AsyncFunction;
    import org.apache.flink.streaming.api.functions.async.ResultFuture;
    import org.apache.flink.streaming.api.datastream.AsyncDataStream;
    import java.util.concurrent.TimeUnit;
    
    // Async enrichment function
    public class CustomerEnrichment
            extends RichAsyncFunction<Transaction, EnrichedTransaction> {
    
        private transient DataSource dataSource;
    
        @Override
        public void open(Configuration parameters) {
            // Initialize connection pool
            dataSource = createConnectionPool();
        }
    
        @Override
        public void asyncInvoke(Transaction tx,
                                ResultFuture<EnrichedTransaction> resultFuture) {
            CompletableFuture.supplyAsync(() -> {
                try (Connection conn = dataSource.getConnection();
                     PreparedStatement stmt = conn.prepareStatement(
                         "SELECT risk_score, account_age FROM customers WHERE id = ?")) {
                    stmt.setString(1, tx.getUserId());
                    ResultSet rs = stmt.executeQuery();
                    if (rs.next()) {
                        return new EnrichedTransaction(tx,
                            rs.getDouble("risk_score"),
                            rs.getInt("account_age"));
                    }
                    return new EnrichedTransaction(tx, 0.5, 0);
                } catch (Exception e) {
                    return new EnrichedTransaction(tx, 0.5, 0);
                }
            }).thenAccept(result -> resultFuture.complete(
                Collections.singleton(result)));
        }
    }
    
    // Apply async enrichment before CEP
    DataStream<EnrichedTransaction> enriched = AsyncDataStream
        .unorderedWait(
            transactionStream,
            new CustomerEnrichment(),
            30, TimeUnit.SECONDS, // timeout
            100 // max concurrent requests
        );

    Flink also supports connectors for Apache Pulsar, Amazon Kinesis, and many other systems through its connector ecosystem. The setup is broadly similar: define a source, assign watermarks, and feed the stream into the CEP patterns.

    Deploying and Monitoring

    Running Locally for Development

    The simplest development workflow is to run the job directly within an IDE. Flink will then create a local mini-cluster automatically:

    // This works out of the box in your IDE
    StreamExecutionEnvironment env =
        StreamExecutionEnvironment.getExecutionEnvironment();
    // Flink automatically creates a local mini-cluster

    Docker Compose for Local Flink and Kafka

    For integration testing, the following Docker Compose configuration provides a local Flink and Kafka environment:

    # docker-compose.yml
    version: '3.8'
    
    services:
      zookeeper:
        image: confluentinc/cp-zookeeper:7.5.3
        environment:
          ZOOKEEPER_CLIENT_PORT: 2181
        ports:
          - "2181:2181"
    
      kafka:
        image: confluentinc/cp-kafka:7.5.3
        depends_on:
          - zookeeper
        ports:
          - "9092:9092"
        environment:
          KAFKA_BROKER_ID: 1
          KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
          KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
          KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
          KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
    
      flink-jobmanager:
        image: flink:1.18.1-java17
        ports:
          - "8081:8081"  # Flink Web UI
        command: jobmanager
        environment:
          FLINK_PROPERTIES: |
            jobmanager.rpc.address: flink-jobmanager
            state.backend: rocksdb
            state.checkpoints.dir: file:///tmp/flink-checkpoints
            state.savepoints.dir: file:///tmp/flink-savepoints
    
      flink-taskmanager:
        image: flink:1.18.1-java17
        depends_on:
          - flink-jobmanager
        command: taskmanager
        scale: 2  # Run 2 task managers
        environment:
          FLINK_PROPERTIES: |
            jobmanager.rpc.address: flink-jobmanager
            taskmanager.numberOfTaskSlots: 4
            taskmanager.memory.process.size: 2048m

    Deploying to a Flink Cluster

    The fat JAR should be built and submitted to the cluster:

    # Build the fat JAR
    mvn clean package -DskipTests
    
    # Submit to standalone cluster
    ./bin/flink run \
      -c com.example.cep.FraudDetectionPipeline \
      target/flink-cep-pipeline-1.0.0.jar
    
    # Submit to YARN cluster
    ./bin/flink run -m yarn-cluster \
      -yn 4 \       # 4 TaskManagers
      -ys 8 \       # 8 slots per TaskManager
      -yjm 2048m \  # JobManager memory
      -ytm 4096m \  # TaskManager memory
      -c com.example.cep.FraudDetectionPipeline \
      target/flink-cep-pipeline-1.0.0.jar
    
    # Submit to Kubernetes (using Flink Kubernetes Operator)
    kubectl apply -f flink-cep-deployment.yaml

    Monitoring the Pipeline

    The Flink Web UI (default port 8081) is the primary monitoring interface. The most important metrics are summarised below:

    • Checkpoint Duration: If checkpoints take longer than the configured interval, cascading delays appear. Checkpoint duration should be kept below 50% of the checkpoint interval.
    • Backpressure: When a downstream operator cannot keep pace, backpressure propagates upstream. The Web UI indicates this with colour-coded task states, where red signals a problem.
    • Throughput (records/second): Input and output rates for each operator should be monitored. A sudden drop in output rate with constant input suggests a processing bottleneck.
    • State Size: CEP patterns maintain partial match buffers. State size should be observed over time, since unbounded growth indicates a pattern or key-space problem.

    Performance Optimisation

    Making a CEP pipeline functional is one matter; making it handle production volumes efficiently is another. The principal tuning levers are described below.

    Choosing the Right Parallelism

    Parallelism controls the number of parallel instances of each operator that Flink runs. For CEP pipelines, the following guidelines apply:

    • Source parallelism: Should match the number of Kafka partitions. If the topic has 16 partitions, source parallelism should be set to 16.
    • CEP operator parallelism: Depends on key cardinality and pattern complexity. A reasonable starting point is the same parallelism as the source, with subsequent increases if backpressure appears on the CEP operator.
    • Sink parallelism: Typically lower than CEP parallelism because alert volume is substantially lower than input volume.

    State Backend Selection

    State Backend State Size Speed Best For
    HashMapStateBackend (Heap) Limited by JVM heap Fastest Small state, low latency requirements
    EmbeddedRocksDBStateBackend Limited by disk Slower (disk I/O) Large state, long time windows

     

    For CEP workloads specifically, the heap state backend is adequate when patterns have short time windows (seconds to minutes) and moderate key cardinality. For long time windows on the order of hours, or millions of keys with active partial matches, RocksDB is the safer option.

    Setting Fraud Detection IoT Monitoring Market Data
    Parallelism 8–32 4–16 16–64
    Checkpoint Interval 60s 30s 10s
    State Backend RocksDB Heap or RocksDB Heap
    Watermark Bound 5s 3s 1s
    TaskManager Memory 4–8 GB 2–4 GB 8–16 GB
    Serialization Avro or Protobuf Avro Protobuf (smallest size)

     

    Serialisation Considerations

    Flink’s default Java serialisation is slow and produces large state snapshots. For production CEP pipelines, event types should be registered with Flink’s type system or serialised efficiently:

    // Register types for efficient serialization
    env.getConfig().registerTypeWithKryoSerializer(
        Transaction.class, ProtobufSerializer.class);
    
    // Or use Flink's POJO serialization (automatic for well-formed POJOs)
    // Ensure your classes:
    // 1. Have a no-arg constructor
    // 2. Have public getters/setters for all fields
    // 3. Implement Serializable
    
    // For Avro serialization, use Flink's Avro format
    // Add dependency: flink-avro
    // Then use AvroDeserializationSchema:
    import org.apache.flink.formats.avro.AvroDeserializationSchema;
    
    KafkaSource<Transaction> avroSource = KafkaSource.<Transaction>builder()
        .setBootstrapServers("localhost:9092")
        .setTopics("transactions-avro")
        .setGroupId("fraud-detection")
        .setValueOnlyDeserializer(
            AvroDeserializationSchema.forSpecific(Transaction.class))
        .build();

    Common Pitfalls and Troubleshooting

    The most frequently encountered issues are summarised below:

    Problem Cause Solution
    Pattern never matches Events arrive out of order; within() window too tight; using next() when followedBy() is needed Check event ordering, increase time window, switch contiguity mode
    Too many matches (false positives) Pattern conditions too loose; using followedByAny() generating combinatorial explosion Add tighter conditions, switch to followedBy(), shorten time window
    OutOfMemoryError Large NFA state from long time windows, high key cardinality, or followedByAny() with oneOrMore() Switch to RocksDB state backend, shorten time windows, add until() conditions
    Checkpoint failures State too large to snapshot within timeout; backpressure causing delays Increase checkpoint timeout, enable incremental checkpointing with RocksDB, reduce state size
    Watermark stalling (no progress) One Kafka partition has no data—its watermark stays at Long.MIN_VALUE, blocking global watermark Use withIdleness(Duration.ofMinutes(1)) on watermark strategy
    Duplicate alerts after restart Reprocessing events without checkpointed state Always restart from savepoint/checkpoint, enable exactly-once on sinks
    ClassNotFoundException at runtime flink-cep not in the fat JAR; marked as provided by mistake Ensure flink-cep is not marked as provided—only flink-streaming-java and flink-clients should be

     

    Fixing Watermark Stalling

    Watermark stalling is among the most difficult issues to diagnose. If a single Kafka partition ceases to produce events, its watermark remains at negative infinity, which blocks the global watermark for the entire job. The remedy is straightforward:

    WatermarkStrategy<Transaction> strategy = WatermarkStrategy
        .<Transaction>forBoundedOutOfOrderness(Duration.ofSeconds(5))
        .withTimestampAssigner((tx, ts) -> tx.getTimestamp())
        .withIdleness(Duration.ofMinutes(1));  // Mark source as idle after 1 min

    Debugging Pattern Matches

    When patterns do not match as expected, a pass-through select can be inserted before the CEP operator in order to verify that events are flowing and correctly keyed:

    // Debug: print events as they enter the CEP operator
    transactions
        .map(tx -> {
            System.out.println("CEP INPUT: " + tx);
            return tx;
        })
        .keyBy(Transaction::getUserId);
    
    // Also: check that your conditions actually match
    // by testing them in a unit test
    @Test
    public void testFraudCondition() {
        Transaction tx = new Transaction("1", "user1", 600.0,
            System.currentTimeMillis(), "NYC", "electronics", "1234");
        assertTrue(tx.getAmount() > 500.0);  // Verify condition logic
    }

    Final Thoughts

    Complex Event Processing with Apache Flink supports the detection of sophisticated patterns across millions of events per second with millisecond latency and exactly-once guarantees. The present guide has covered considerable ground, from the fundamentals of CEP and the Flink pattern API to three complete, production-style pipelines for fraud detection, IoT monitoring, and financial market analysis.

    The principal lessons may be summarised as follows:

    • Select the appropriate contiguity: next() for strict sequences, followedBy() for relaxed matching, and followedByAny() sparingly, given its computational cost.
    • Always use event time with appropriate watermark strategies. Processing time produces incorrect pattern matches in any real-world system where events arrive out of order.
    • Key the streams: CEP patterns should almost always be applied to keyed streams so that matches remain scoped to a logical entity such as a user, sensor, or stock symbol.
    • Handle timeouts: Implementing TimedOutPartialMatchHandler allows partial matches that do not complete within the time window to be captured and analysed.
    • Monitor state size: CEP is inherently stateful. RocksDB is recommended for large state, time windows should remain as short as possible, and combinatorial explosion in non-deterministic patterns should be monitored.
    • Start simple and iterate: An initial implementation should begin with a single pattern on a small data sample, verified for correctness before complexity or scale are increased.

    Flink’s CEP library is among the most capable pattern-matching engines in the open-source ecosystem. The patterns and techniques presented here provide the foundation required to build a first production CEP pipeline. For reproducible deployment of Flink applications, containerisation with Docker simplifies both local development and production rollout. The fraud detection example offers a suitable starting point that can be adapted to the target domain and scaled accordingly.

    References