use std::collections::HashMap;
use axonml_autograd::Variable;
use axonml_nn::Module;
use axonml_nn::layers::{Embedding, LSTM, LayerNorm, Linear, TransformerEncoder};
use axonml_nn::parameter::Parameter;
use axonml_tensor::Tensor;
pub const EQUIP_AHU: usize = 0;
pub const EQUIP_DOAS: usize = 1;
pub const EQUIP_BOILER: usize = 2;
pub const EQUIP_STEAM_BUNDLE: usize = 3;
pub const EQUIP_FAN_COIL: usize = 4;
pub const EQUIP_PUMP: usize = 5;
pub const EQUIP_CHILLER: usize = 6;
pub const NUM_EQUIP_TYPES: usize = 7;
pub const AHU_SENSORS: usize = 12;
pub const DOAS_SENSORS: usize = 6;
pub const BOILER_SENSORS: usize = 7;
pub const STEAM_BUNDLE_SENSORS: usize = 5;
pub const FAN_COIL_SENSORS: usize = 9;
pub const PUMP_SENSORS: usize = 7;
pub const CHILLER_SENSORS: usize = 9;
pub const MAX_SENSORS: usize = AHU_SENSORS;
pub const EMBED_DIM: usize = 32;
#[derive(Clone)]
pub struct FacilitySnapshot {
pub features: Vec<f32>,
pub mask: Vec<f32>,
pub equip_types: Vec<usize>,
pub equip_ids: Vec<String>,
pub num_equipment: usize,
}
impl FacilitySnapshot {
pub fn new(num_equipment: usize) -> Self {
Self {
features: vec![0.0; num_equipment * MAX_SENSORS],
mask: vec![0.0; num_equipment * MAX_SENSORS],
equip_types: vec![0; num_equipment],
equip_ids: vec![String::new(); num_equipment],
num_equipment,
}
}
pub fn set_equipment(
&mut self,
slot: usize,
equip_id: &str,
equip_type: usize,
values: &[Option<f32>],
) {
assert!(slot < self.num_equipment);
self.equip_ids[slot] = equip_id.to_string();
self.equip_types[slot] = equip_type;
let base = slot * MAX_SENSORS;
for (i, val) in values.iter().enumerate() {
if i >= MAX_SENSORS {
break;
}
match val {
Some(v) => {
self.features[base + i] = *v;
self.mask[base + i] = 1.0;
}
None => {
self.features[base + i] = 0.0;
self.mask[base + i] = 0.0;
}
}
}
}
}
pub struct FacilityConfig {
pub equipment: Vec<(String, usize)>,
pub id_to_slot: HashMap<String, usize>,
}
impl FacilityConfig {
pub fn new(equipment: Vec<(String, usize)>) -> Self {
let id_to_slot: HashMap<String, usize> = equipment
.iter()
.enumerate()
.map(|(i, (id, _))| (id.clone(), i))
.collect();
Self {
equipment,
id_to_slot,
}
}
pub fn num_equipment(&self) -> usize {
self.equipment.len()
}
pub fn warren() -> Self {
let mut equipment = Vec::new();
for id in &[
"warren-ahu-6",
"warren-ahu-1",
"warren-ahu-4",
"warren-ahu-2",
"warren-ahu-5",
"warren-ahu-7",
] {
equipment.push(((*id).to_string(), EQUIP_AHU));
}
equipment.push(("warren-fahl-doas".to_string(), EQUIP_DOAS));
for id in &["warren-boiler-1", "warren-boiler-2", "warren-boiler-3"] {
equipment.push(((*id).to_string(), EQUIP_BOILER));
}
for id in &[
"warren-steambundle-1",
"warren-steambundle-5",
"warren-steambundle-4",
"warren-steambundle-fahl",
"warren-steambundle-6",
"warren-steambundle-7",
"warren-steambundle-3",
"warren-steambundle-8",
"warren-steambundle-2",
] {
equipment.push(((*id).to_string(), EQUIP_STEAM_BUNDLE));
}
for i in 1..=18 {
equipment.push((format!("warren-fancoil-{i}"), EQUIP_FAN_COIL));
}
for id in &[
"warren-cwbooster-1",
"warren-cwbooster-2",
"warren-cwpump-5",
"warren-cwpump-6",
"warren-hwpump-7",
"warren-hwpump-8",
"warren-hwpump-9",
"warren-hwpump-10",
"warren-hwpump-11",
"warren-hwpump-12",
"warren-chwpump-3",
"warren-chwpump-4",
"warren-cwpump-3",
"warren-cwpump-4",
"warren-hwpump-5",
"warren-hwpump-6",
"warren-hwpump-1",
"warren-hwpump-2",
"warren-hwpump-3",
"warren-hwpump-4",
] {
equipment.push(((*id).to_string(), EQUIP_PUMP));
}
for id in &["warren-chiller-2", "warren-chiller-1"] {
equipment.push(((*id).to_string(), EQUIP_CHILLER));
}
Self::new(equipment)
}
}
struct EquipTypeEncoder {
linear: Linear,
norm: LayerNorm,
}
impl EquipTypeEncoder {
fn new(sensor_count: usize) -> Self {
Self {
linear: Linear::new(sensor_count, EMBED_DIM),
norm: LayerNorm::new(vec![EMBED_DIM]),
}
}
fn forward(&self, x: &Variable) -> Variable {
self.norm.forward(&self.linear.forward(x).relu())
}
fn parameters(&self) -> Vec<Parameter> {
[self.linear.parameters(), self.norm.parameters()].concat()
}
}
pub struct Panoptes {
type_encoders: Vec<EquipTypeEncoder>,
type_embed: Embedding,
id_embed: Embedding,
missing_embed: Parameter,
cross_attn: TransformerEncoder,
temporal_lstm: LSTM,
equip_head_snapshot: Linear,
equip_head_temporal: Linear,
facility_head_snapshot: Linear,
facility_head_temporal: Linear,
num_equipment: usize,
sensor_counts: [usize; NUM_EQUIP_TYPES],
}
impl Panoptes {
pub fn new(num_equipment: usize) -> Self {
let sensor_counts = [
AHU_SENSORS, DOAS_SENSORS, BOILER_SENSORS, STEAM_BUNDLE_SENSORS, FAN_COIL_SENSORS, PUMP_SENSORS, CHILLER_SENSORS, ];
let type_encoders: Vec<EquipTypeEncoder> = sensor_counts
.iter()
.map(|&count| EquipTypeEncoder::new(count))
.collect();
let missing_data = axonml_nn::init::normal(&[1, EMBED_DIM], 0.0, 0.02);
let missing_embed = Parameter::named("missing_embed", missing_data, true);
Self {
type_encoders,
type_embed: Embedding::new(NUM_EQUIP_TYPES, EMBED_DIM),
id_embed: Embedding::new(num_equipment, EMBED_DIM),
missing_embed,
cross_attn: TransformerEncoder::new(EMBED_DIM, 4, 64, 2),
temporal_lstm: LSTM::new(EMBED_DIM, 64, 1),
equip_head_snapshot: Linear::new(EMBED_DIM, 1),
equip_head_temporal: Linear::new(64, 1),
facility_head_snapshot: Linear::new(EMBED_DIM, 1),
facility_head_temporal: Linear::new(64, 1),
num_equipment,
sensor_counts,
}
}
pub fn num_parameters(&self) -> usize {
self.parameters().iter().map(|p| p.numel()).sum()
}
pub fn encode_snapshot(&self, snapshot: &FacilitySnapshot) -> Variable {
let n = snapshot.num_equipment;
assert_eq!(n, self.num_equipment);
let mut encoded_vecs: Vec<f32> = Vec::with_capacity(n * EMBED_DIM);
for slot in 0..n {
let equip_type = snapshot.equip_types[slot];
let sensor_count = self.sensor_counts[equip_type];
let base = slot * MAX_SENSORS;
let sensor_values: Vec<f32> = (0..sensor_count)
.map(|i| snapshot.features[base + i])
.collect();
let all_missing = (0..sensor_count).all(|i| snapshot.mask[base + i] == 0.0);
if all_missing {
let missing = self.missing_embed.data().to_vec();
encoded_vecs.extend_from_slice(&missing);
} else {
let masked_values: Vec<f32> = (0..sensor_count)
.map(|i| sensor_values[i] * snapshot.mask[base + i])
.collect();
let input_tensor = Tensor::from_vec(masked_values, &[1, sensor_count]).unwrap();
let input_var = Variable::new(input_tensor, false);
let encoded = self.type_encoders[equip_type].forward(&input_var);
let enc_data = encoded.data().to_vec();
encoded_vecs.extend_from_slice(&enc_data);
}
}
let encoded_tensor = Tensor::from_vec(encoded_vecs, &[1, n, EMBED_DIM]).unwrap();
let mut result = Variable::new(encoded_tensor, true);
let type_indices: Vec<f32> = snapshot.equip_types.iter().map(|&t| t as f32).collect();
let type_idx_tensor = Tensor::from_vec(type_indices, &[1, n]).unwrap();
let type_idx_var = Variable::new(type_idx_tensor, false);
let type_emb = self.type_embed.forward(&type_idx_var); result = result.add_var(&type_emb);
let id_indices: Vec<f32> = (0..n).map(|i| i as f32).collect();
let id_idx_tensor = Tensor::from_vec(id_indices, &[1, n]).unwrap();
let id_idx_var = Variable::new(id_idx_tensor, false);
let id_emb = self.id_embed.forward(&id_idx_var); result = result.add_var(&id_emb);
result
}
pub fn forward_snapshot(&self, snapshot: &FacilitySnapshot) -> (Variable, Variable) {
let encoded = self.encode_snapshot(snapshot);
let attended = self.cross_attn.forward(&encoded);
let flat = attended.reshape(&[self.num_equipment, EMBED_DIM]);
let equip_scores = self.equip_head_snapshot.forward(&flat); let equip_scores = equip_scores.reshape(&[1, self.num_equipment]);
let pooled = attended.mean_dim(1, false); let facility_score = self.facility_head_snapshot.forward(&pooled);
(equip_scores, facility_score)
}
pub fn forward_temporal(&self, snapshots: &[FacilitySnapshot]) -> (Variable, Variable) {
let seq_len = snapshots.len();
let n = self.num_equipment;
let mut all_attended: Vec<f32> = Vec::with_capacity(seq_len * n * EMBED_DIM);
for snap in snapshots {
let encoded = self.encode_snapshot(snap);
let attended = self.cross_attn.forward(&encoded); all_attended.extend_from_slice(&attended.data().to_vec());
}
let mut lstm_input_data: Vec<f32> = vec![0.0; n * seq_len * EMBED_DIM];
for t in 0..seq_len {
for e in 0..n {
for d in 0..EMBED_DIM {
let src = t * n * EMBED_DIM + e * EMBED_DIM + d;
let dst = e * seq_len * EMBED_DIM + t * EMBED_DIM + d;
lstm_input_data[dst] = all_attended[src];
}
}
}
let lstm_input = Variable::new(
Tensor::from_vec(lstm_input_data, &[n, seq_len, EMBED_DIM]).unwrap(),
true,
);
let lstm_out = self.temporal_lstm.forward(&lstm_input);
let last = lstm_out.narrow(1, seq_len - 1, 1).reshape(&[n, 64]);
let equip_scores = self.equip_head_temporal.forward(&last).reshape(&[1, n]);
let pooled = last.reshape(&[1, n, 64]).mean_dim(1, false);
let facility_score = self.facility_head_temporal.forward(&pooled);
(equip_scores, facility_score)
}
pub fn parameters(&self) -> Vec<Parameter> {
let mut params = Vec::new();
for encoder in &self.type_encoders {
params.extend(encoder.parameters());
}
params.extend(self.type_embed.parameters());
params.extend(self.id_embed.parameters());
params.push(self.missing_embed.clone());
params.extend(self.cross_attn.parameters());
params.extend(self.temporal_lstm.parameters());
params.extend(self.equip_head_snapshot.parameters());
params.extend(self.equip_head_temporal.parameters());
params.extend(self.facility_head_snapshot.parameters());
params.extend(self.facility_head_temporal.parameters());
params
}
}
impl std::fmt::Debug for Panoptes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Panoptes")
.field("num_equipment", &self.num_equipment)
.field("embed_dim", &EMBED_DIM)
.field("num_parameters", &self.num_parameters())
.finish()
}
}
#[derive(Debug)]
pub struct PanoptesOutput {
pub equipment_scores: Vec<(String, f32)>,
pub facility_score: f32,
pub alerts: Vec<PanoptesAlert>,
}
#[derive(Debug)]
pub struct PanoptesAlert {
pub equipment_id: String,
pub equipment_type: usize,
pub score: f32,
pub severity: &'static str,
}
impl PanoptesOutput {
pub fn from_scores(
equip_scores: &[f32],
facility_score: f32,
config: &FacilityConfig,
threshold: f32,
) -> Self {
let mut equipment_scores = Vec::new();
let mut alerts = Vec::new();
for (i, &score) in equip_scores.iter().enumerate() {
let (id, equip_type) = &config.equipment[i];
equipment_scores.push((id.clone(), score));
if score > threshold {
let severity = if score > threshold * 4.0 {
"critical"
} else if score > threshold * 2.5 {
"high"
} else if score > threshold * 1.5 {
"medium"
} else {
"low"
};
alerts.push(PanoptesAlert {
equipment_id: id.clone(),
equipment_type: *equip_type,
score,
severity,
});
}
}
alerts.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
Self {
equipment_scores,
facility_score,
alerts,
}
}
pub fn summary(&self) -> String {
let mut lines = Vec::new();
lines.push(format!(
"Panoptes Facility Health: {:.4} (0=normal)",
self.facility_score
));
if self.alerts.is_empty() {
lines.push(" No anomalies detected.".to_string());
} else {
lines.push(format!(" {} alert(s):", self.alerts.len()));
for alert in &self.alerts {
let type_name = match alert.equipment_type {
EQUIP_AHU => "AHU",
EQUIP_DOAS => "DOAS",
EQUIP_BOILER => "Boiler",
EQUIP_STEAM_BUNDLE => "SteamBundle",
EQUIP_FAN_COIL => "FanCoil",
EQUIP_PUMP => "Pump",
EQUIP_CHILLER => "Chiller",
_ => "Unknown",
};
lines.push(format!(
" [{:8}] {} — score: {:.4} ({})",
type_name, alert.equipment_id, alert.score, alert.severity
));
}
}
lines.join("\n")
}
}
impl FacilitySnapshot {
pub fn for_warren(config: &FacilityConfig) -> Self {
Self::new(config.num_equipment())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_snapshot(n: usize) -> FacilitySnapshot {
let mut snap = FacilitySnapshot::new(n);
for i in 0..n {
let equip_type = i % NUM_EQUIP_TYPES;
let sensor_count = [
AHU_SENSORS,
DOAS_SENSORS,
BOILER_SENSORS,
STEAM_BUNDLE_SENSORS,
FAN_COIL_SENSORS,
PUMP_SENSORS,
CHILLER_SENSORS,
][equip_type];
let values: Vec<Option<f32>> = (0..sensor_count)
.map(|j| Some((i as f32 * 0.1 + j as f32 * 0.3).sin() * 50.0 + 70.0))
.collect();
snap.set_equipment(i, &format!("test-equip-{i}"), equip_type, &values);
}
snap
}
#[test]
fn test_panoptes_creation() {
let model = Panoptes::new(59);
println!("Panoptes parameters: {}", model.num_parameters());
assert!(
model.num_parameters() > 40_000,
"got {}",
model.num_parameters()
);
assert!(model.num_parameters() < 500_000);
}
#[test]
fn test_facility_snapshot() {
let config = FacilityConfig::warren();
assert_eq!(config.num_equipment(), 59);
assert_eq!(config.id_to_slot["warren-ahu-6"], 0);
assert_eq!(config.id_to_slot["warren-chiller-1"], 58);
}
#[test]
fn test_snapshot_encoding() {
let n = 7; let model = Panoptes::new(n);
let snap = make_test_snapshot(n);
let encoded = model.encode_snapshot(&snap);
assert_eq!(encoded.shape(), vec![1, n, EMBED_DIM]);
}
#[test]
fn test_forward_snapshot() {
let n = 7;
let model = Panoptes::new(n);
let snap = make_test_snapshot(n);
let (equip_scores, facility_score) = model.forward_snapshot(&snap);
assert_eq!(equip_scores.shape(), vec![1, n]);
assert_eq!(facility_score.shape(), vec![1, 1]);
}
#[test]
fn test_forward_temporal() {
let n = 7;
let model = Panoptes::new(n);
let snapshots: Vec<FacilitySnapshot> = (0..5).map(|_| make_test_snapshot(n)).collect();
let (equip_scores, facility_score) = model.forward_temporal(&snapshots);
assert_eq!(equip_scores.shape(), vec![1, n]);
assert_eq!(facility_score.shape(), vec![1, 1]);
}
#[test]
fn test_missing_values() {
let n = 3;
let model = Panoptes::new(n);
let mut snap = FacilitySnapshot::new(n);
snap.set_equipment(0, "test-missing", EQUIP_AHU, &vec![None; AHU_SENSORS]);
snap.set_equipment(
1,
"test-partial",
EQUIP_BOILER,
&[
Some(127.0),
None,
Some(88.5),
Some(1.0),
None,
Some(17.0),
Some(1.0),
],
);
snap.set_equipment(
2,
"test-full",
EQUIP_FAN_COIL,
&[
Some(73.5),
Some(74.5),
Some(71.4),
Some(0.0),
Some(0.0),
Some(100.0),
Some(2.2),
Some(1.0),
Some(50.0),
],
);
let (equip_scores, _) = model.forward_snapshot(&snap);
assert_eq!(equip_scores.shape(), vec![1, n]);
}
#[test]
fn test_warren_config() {
let config = FacilityConfig::warren();
let ahu_count = config
.equipment
.iter()
.filter(|(_, t)| *t == EQUIP_AHU)
.count();
let boiler_count = config
.equipment
.iter()
.filter(|(_, t)| *t == EQUIP_BOILER)
.count();
let bundle_count = config
.equipment
.iter()
.filter(|(_, t)| *t == EQUIP_STEAM_BUNDLE)
.count();
let fc_count = config
.equipment
.iter()
.filter(|(_, t)| *t == EQUIP_FAN_COIL)
.count();
let pump_count = config
.equipment
.iter()
.filter(|(_, t)| *t == EQUIP_PUMP)
.count();
let chiller_count = config
.equipment
.iter()
.filter(|(_, t)| *t == EQUIP_CHILLER)
.count();
let doas_count = config
.equipment
.iter()
.filter(|(_, t)| *t == EQUIP_DOAS)
.count();
assert_eq!(ahu_count, 6);
assert_eq!(boiler_count, 3);
assert_eq!(bundle_count, 9);
assert_eq!(fc_count, 18);
assert_eq!(pump_count, 20);
assert_eq!(chiller_count, 2);
assert_eq!(doas_count, 1);
assert_eq!(config.num_equipment(), 59);
}
#[test]
fn test_panoptes_output() {
let config = FacilityConfig::warren();
let scores = vec![0.1; 59];
let output = PanoptesOutput::from_scores(&scores, 0.05, &config, 0.5);
assert!(output.alerts.is_empty());
assert_eq!(output.equipment_scores.len(), 59);
let mut scores2 = vec![0.1; 59];
scores2[0] = 2.5; scores2[10] = 1.8; let output2 = PanoptesOutput::from_scores(&scores2, 1.2, &config, 0.5);
assert_eq!(output2.alerts.len(), 2);
assert_eq!(output2.alerts[0].equipment_id, "warren-ahu-6");
}
#[test]
fn test_panoptes_gradient_flow() {
use axonml_optim::Optimizer;
let n = 7;
let model = Panoptes::new(n);
let snap = make_test_snapshot(n);
let mse = axonml_nn::MSELoss::new();
let target = Variable::new(Tensor::from_vec(vec![0.0; n], &[1, n]).unwrap(), false);
let params = model.parameters();
let mut optimizer = axonml_optim::Adam::new(params, 1e-3);
let mut first_loss = 0.0f32;
let mut last_loss = 0.0f32;
for step in 0..10 {
optimizer.zero_grad();
let (equip_scores, _) = model.forward_snapshot(&snap);
let loss = mse.compute(&equip_scores, &target);
let loss_val = loss.data().to_vec()[0];
if step == 0 {
first_loss = loss_val;
}
last_loss = loss_val;
loss.backward();
optimizer.step();
}
println!("Panoptes training: {:.6} → {:.6}", first_loss, last_loss);
assert!(
last_loss < first_loss,
"Loss did not decrease: {} → {}",
first_loss,
last_loss
);
}
}