""" Standalone scorer for the OT/ICS Intrusion Detector (autoencoder on HAI sensor data). Loads the trained autoencoder + scaler + metadata (input_dim / encoding_dim / threshold / the 59 sensor feature order) that sit alongside this file, computes the reconstruction error for a sensor reading, and flags it as an intrusion if the error exceeds the trained threshold. Missing sensor fields are filled from a real recorded normal reading (BASELINE_READING). Usage: from otics_score import score_reading, BASELINE_READING score_reading(BASELINE_READING) # normal -> is_anomaly False score_reading({"P1_B2004": 5.0}) # tampered -> likely anomaly """ import ast import os from functools import lru_cache _HERE = os.path.dirname(os.path.abspath(__file__)) def _meta(): m = {} for line in open(os.path.join(_HERE, "autoencoder_hai_meta.txt"), encoding="utf-8"): k, _, v = line.partition("=") m[k] = v.strip() return m _M = _meta() INPUT_DIM = int(_M["input_dim"]) ENCODING_DIM = int(_M["encoding_dim"]) THRESHOLD = float(_M["threshold"]) FEATURE_ORDER = ast.literal_eval(_M["feature_cols"]) # A real observed normal reading (train1.csv row 1000, attack=0), used to fill any field missing # from a request — an all-means vector is NOT physically realistic and scores as anomalous. BASELINE_READING = { "P1_B2004": 0.0983, "P1_B2016": 0.9481, "P1_B3004": 399.2321, "P1_B3005": 1110.3986, "P1_B4002": 32.0, "P1_B4005": 0.0, "P1_B400B": 32.9705, "P1_B4022": 35.3325, "P1_FCV01D": 0.0, "P1_FCV01Z": 0.2838, "P1_FCV02D": 100.0, "P1_FCV02Z": 95.5368, "P1_FCV03D": 53.7863, "P1_FCV03Z": 54.3228, "P1_FT01": 115.6234, "P1_FT01Z": 579.1716, "P1_FT02": 6.1417, "P1_FT02Z": 31.9776, "P1_FT03": 310.936, "P1_FT03Z": 1111.5228, "P1_LCV01D": 21.9717, "P1_LCV01Z": 21.7834, "P1_LIT01": 395.0419, "P1_PCV01D": 30.7836, "P1_PCV01Z": 31.4728, "P1_PCV02D": 12.0, "P1_PCV02Z": 12.0102, "P1_PIT01": 0.8943, "P1_PIT02": 0.2153, "P1_TIT01": 35.8032, "P1_TIT02": 37.146, "P2_24Vdc": 28.0246, "P2_Auto": 1.0, "P2_Emgy": 0.0, "P2_On": 1.0, "P2_SD01": 20.0, "P2_SIT01": 814.0, "P2_TripEx": 0.0, "P2_VT01e": 11.8639, "P2_VXT02": -3.2829, "P2_VXT03": -1.2577, "P2_VYT02": 0.4135, "P2_VYT03": 1.8313, "P3_LCP01D": 4.0, "P3_LCV01D": 0.0, "P3_LH": 70.0, "P3_LL": 10.0, "P3_LT01": 68.95255, "P4_HT_FD": -0.0001, "P4_HT_LD": -0.0072, "P4_HT_PO": 0.0724, "P4_HT_PS": 0.0, "P4_LD": 300.9802, "P4_ST_FD": -0.003, "P4_ST_LD": 298.0324, "P4_ST_PO": 287.1274, "P4_ST_PS": 50.9871, "P4_ST_PT01": 9916.0, "P4_ST_TT01": 27627.0, } def _build_autoencoder(): import torch.nn as nn class Autoencoder(nn.Module): def __init__(self, input_dim, encoding_dim=32): super().__init__() self.encoder = nn.Sequential(nn.Linear(input_dim, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, encoding_dim)) self.decoder = nn.Sequential(nn.Linear(encoding_dim, 64), nn.ReLU(), nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, input_dim)) def forward(self, x): return self.decoder(self.encoder(x)) return Autoencoder @lru_cache(maxsize=1) def _load(): import joblib import torch scaler = joblib.load(os.path.join(_HERE, "scaler_hai.pkl")) model = _build_autoencoder()(INPUT_DIM, ENCODING_DIM) model.load_state_dict(torch.load(os.path.join(_HERE, "autoencoder_hai.pth"), map_location="cpu")) model.eval() return model, scaler def score_reading(readings: dict) -> dict: """Score a sensor reading (dict of tag->value). Returns {is_anomaly, reconstruction_error, threshold, missing_fields}.""" import torch model, scaler = _load() missing = [f for f in FEATURE_ORDER if f not in readings] row = [readings.get(f, BASELINE_READING[f]) for f in FEATURE_ORDER] scaled = scaler.transform([row]) with torch.no_grad(): x = torch.tensor(scaled, dtype=torch.float32) error = torch.mean((model(x) - x) ** 2).item() return {"is_anomaly": error > THRESHOLD, "reconstruction_error": round(error, 6), "threshold": THRESHOLD, "missing_fields": missing} if __name__ == "__main__": import json print("normal:", json.dumps(score_reading(BASELINE_READING))) tampered = dict(BASELINE_READING); tampered["P1_B2004"] = 8.0; tampered["P1_FT01"] = 900.0 print("tampered:", json.dumps(score_reading(tampered)))