Sahil Garg commited on
Commit
de597ec
·
1 Parent(s): aeaf551

modularization, structure reorganization, code cleanup, yaml prompts, logging

Browse files
Files changed (12) hide show
  1. agent/agent.py +14 -18
  2. agent/prompts.yaml +19 -0
  3. app.py +9 -77
  4. data/phase2_output.json +0 -7
  5. main.py +2 -4
  6. ml/features.py +4 -14
  7. ml/inference.py +22 -55
  8. requirements.txt +2 -1
  9. src/config.py +36 -0
  10. src/models.py +10 -0
  11. src/services.py +54 -0
  12. src/utils.py +16 -0
agent/agent.py CHANGED
@@ -1,5 +1,7 @@
1
  import json
2
  import re
 
 
3
  from langchain_google_genai import GoogleGenerativeAI
4
 
5
  class MaintenanceAgent:
@@ -9,27 +11,21 @@ class MaintenanceAgent:
9
  temperature=temperature,
10
  google_api_key=api_key
11
  )
 
 
 
 
 
 
 
 
 
 
12
 
13
  def _build_prompt(self, phase2_output: dict) -> str:
14
  """Build the maintenance analysis prompt."""
15
- return f"""
16
- You are a maintenance decision AI.
17
- You must reason ONLY from the provided JSON.
18
- Do NOT invent data.
19
-
20
- INPUT:
21
- {json.dumps(phase2_output, indent=2)}
22
-
23
- MANDATORY: Return output strictly in JSON format only. Do not include any markdown, code blocks, or extra text.
24
-
25
- OUTPUT FORMAT:
26
- {{
27
- "diagnosis": "...",
28
- "urgency": "Low | Medium | High",
29
- "recommended_action": "...",
30
- "justification": ["...", "..."]
31
- }}
32
- """
33
 
34
  def _parse_response(self, response: str) -> dict:
35
  """Parse LLM response, handling various JSON formats."""
 
1
  import json
2
  import re
3
+ import os
4
+ import yaml
5
  from langchain_google_genai import GoogleGenerativeAI
6
 
7
  class MaintenanceAgent:
 
11
  temperature=temperature,
12
  google_api_key=api_key
13
  )
14
+ self.prompts = self._load_prompts()
15
+
16
+ def _load_prompts(self) -> dict:
17
+ """Load prompts from YAML file."""
18
+ # Get the directory where this file is located
19
+ current_dir = os.path.dirname(os.path.abspath(__file__))
20
+ prompts_file = os.path.join(current_dir, 'prompts.yaml')
21
+
22
+ with open(prompts_file, 'r') as f:
23
+ return yaml.safe_load(f)
24
 
25
  def _build_prompt(self, phase2_output: dict) -> str:
26
  """Build the maintenance analysis prompt."""
27
+ user_template = self.prompts['maintenance']['user_template']
28
+ return user_template.format(phase2_output=json.dumps(phase2_output, indent=2))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  def _parse_response(self, response: str) -> dict:
31
  """Parse LLM response, handling various JSON formats."""
agent/prompts.yaml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ maintenance:
2
+ system: |
3
+ You are a maintenance decision AI.
4
+ You must reason ONLY from the provided JSON.
5
+ Do NOT invent data.
6
+
7
+ user_template: |
8
+ INPUT:
9
+ {phase2_output}
10
+
11
+ MANDATORY: Return output strictly in JSON format only. Do not include any markdown, code blocks, or extra text.
12
+
13
+ OUTPUT FORMAT:
14
+ {{
15
+ "diagnosis": "...",
16
+ "urgency": "Low | Medium | High",
17
+ "recommended_action": "...",
18
+ "justification": ["...", "..."]
19
+ }}
app.py CHANGED
@@ -1,89 +1,21 @@
1
  from fastapi import FastAPI, HTTPException
2
- from pydantic import BaseModel
3
- import pandas as pd
4
- import os
5
  import logging
6
- from dotenv import load_dotenv
7
- from ml.inference import MLEngine
8
- from agent.agent import MaintenanceAgent
9
-
10
- load_dotenv()
11
  logging.basicConfig(level=logging.INFO)
12
  logger = logging.getLogger(__name__)
13
 
14
- app = FastAPI(title="Solar PV Predictive Maintenance API", version="1.0.0")
15
-
16
- # Load ML models once on startup for production performance
17
- ml_engine = MLEngine()
18
-
19
- # ============ Helper Functions ============
20
-
21
- def validate_sensor_data(vdc1: list, idc1: list) -> None:
22
- """Validate sensor data consistency. Raises HTTPException on error."""
23
- if len(vdc1) != len(idc1):
24
- raise HTTPException(status_code=400, detail="Voltage and current lists must have the same length")
25
- if len(vdc1) < 3:
26
- raise HTTPException(status_code=400, detail="Need at least 3 data points")
27
-
28
- def prepare_dataframe(vdc1: list, idc1: list) -> pd.DataFrame:
29
- """Prepare sensor data for ML inference by padding to 100 points."""
30
- return pd.DataFrame({
31
- "vdc1": (vdc1 * (100 // len(vdc1) + 1))[:100],
32
- "idc1": (idc1 * (100 // len(idc1) + 1))[:100]
33
- })
34
-
35
- def get_agent_output(api_key: str, ml_output: dict) -> dict:
36
- """Get agent analysis if API key is provided, otherwise return no-key message."""
37
- if not api_key:
38
- return {
39
- "diagnosis": "No API key provided - LLM features disabled",
40
- "urgency": "Unknown",
41
- "recommended_action": "Provide Google API key in request for AI diagnosis",
42
- "justification": ["Google API key required for maintenance reasoning"]
43
- }
44
-
45
- try:
46
- agent = MaintenanceAgent(
47
- api_key=api_key,
48
- model_name="gemini-2.5-flash-lite",
49
- temperature=0.0
50
- )
51
- return agent.run(ml_output)
52
- except Exception as e:
53
- logger.warning(f"Agent initialization failed: {e}")
54
- return {
55
- "diagnosis": "Agent initialization failed",
56
- "urgency": "Unknown",
57
- "recommended_action": "Check your Google API key",
58
- "justification": [f"Error: {str(e)}"]
59
- }
60
-
61
- class SensorData(BaseModel):
62
- vdc1: list[float]
63
- idc1: list[float]
64
- api_key: str = None # Optional Google API key for LLM features
65
-
66
- class AnalysisResponse(BaseModel):
67
- ml_output: dict
68
- agent_output: dict
69
 
70
  @app.post("/analyze", response_model=AnalysisResponse)
71
  async def analyze_sensor_data(data: SensorData):
72
  try:
73
  logger.info(f"Processing request with {len(data.vdc1)} voltage and {len(data.idc1)} current data points")
74
-
75
- # Validate input
76
- validate_sensor_data(data.vdc1, data.idc1)
77
-
78
- # Prepare data and run ML inference
79
- raw_df = prepare_dataframe(data.vdc1, data.idc1)
80
- ml_output = ml_engine.predict_from_raw(raw_df)
81
-
82
- # Get agent analysis
83
- agent_output = get_agent_output(data.api_key, ml_output)
84
-
85
  return AnalysisResponse(ml_output=ml_output, agent_output=agent_output)
86
-
87
  except HTTPException:
88
  raise
89
  except Exception as e:
@@ -92,8 +24,8 @@ async def analyze_sensor_data(data: SensorData):
92
 
93
  @app.get("/")
94
  async def root():
95
- return {"message": "Solar PV Predictive Maintenance API", "endpoint": "/analyze (POST)"}
96
 
97
  if __name__ == "__main__":
98
  import uvicorn
99
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
  from fastapi import FastAPI, HTTPException
 
 
 
2
  import logging
3
+ from src.config import Config
4
+ from src.models import SensorData, AnalysisResponse
5
+ from src.services import AnalysisService
 
 
6
  logging.basicConfig(level=logging.INFO)
7
  logger = logging.getLogger(__name__)
8
 
9
+ config = Config()
10
+ app = FastAPI(title=config.APP_TITLE, version=config.APP_VERSION)
11
+ service = AnalysisService(config)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  @app.post("/analyze", response_model=AnalysisResponse)
14
  async def analyze_sensor_data(data: SensorData):
15
  try:
16
  logger.info(f"Processing request with {len(data.vdc1)} voltage and {len(data.idc1)} current data points")
17
+ ml_output, agent_output = service.analyze(data.vdc1, data.idc1, data.api_key)
 
 
 
 
 
 
 
 
 
 
18
  return AnalysisResponse(ml_output=ml_output, agent_output=agent_output)
 
19
  except HTTPException:
20
  raise
21
  except Exception as e:
 
24
 
25
  @app.get("/")
26
  async def root():
27
+ return {"message": "Solar PV Predictive Maintenance", "endpoint": "/analyze (POST)"}
28
 
29
  if __name__ == "__main__":
30
  import uvicorn
31
+ uvicorn.run(app, host=config.HOST, port=config.PORT)
data/phase2_output.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "asset_id": "PV_INVERTER_001",
3
- "failure_probability": 0.0,
4
- "expected_ttf_days": 10338.5,
5
- "expected_rul_days": 10942.0,
6
- "confidence": 1.0
7
- }
 
 
 
 
 
 
 
 
main.py CHANGED
@@ -1,9 +1,9 @@
1
- import pandas as pd
2
  import os
 
 
3
  from dotenv import load_dotenv
4
  from ml.inference import MLEngine
5
  from agent.agent import MaintenanceAgent
6
- import numpy as np
7
 
8
  load_dotenv()
9
 
@@ -12,14 +12,12 @@ raw_df = pd.DataFrame({
12
  "idc1": np.random.normal(10.0, 0.2, 200)
13
  })
14
 
15
-
16
  engine = MLEngine()
17
  phase2_output = engine.predict_from_raw(raw_df)
18
 
19
  print("\n=== ML OUTPUT ===")
20
  print(phase2_output)
21
 
22
- # ---- LLM AGENT ----
23
  agent = MaintenanceAgent(
24
  api_key=os.getenv("GOOGLE_API_KEY"),
25
  model_name="gemini-2.5-flash-lite",
 
 
1
  import os
2
+ import pandas as pd
3
+ import numpy as np
4
  from dotenv import load_dotenv
5
  from ml.inference import MLEngine
6
  from agent.agent import MaintenanceAgent
 
7
 
8
  load_dotenv()
9
 
 
12
  "idc1": np.random.normal(10.0, 0.2, 200)
13
  })
14
 
 
15
  engine = MLEngine()
16
  phase2_output = engine.predict_from_raw(raw_df)
17
 
18
  print("\n=== ML OUTPUT ===")
19
  print(phase2_output)
20
 
 
21
  agent = MaintenanceAgent(
22
  api_key=os.getenv("GOOGLE_API_KEY"),
23
  model_name="gemini-2.5-flash-lite",
ml/features.py CHANGED
@@ -3,25 +3,15 @@ import pandas as pd
3
 
4
  def build_features(df, window):
5
  df = df.copy()
6
-
7
  df["pdc1"] = df["vdc1"] * df["idc1"]
8
-
9
  df["vdc_mean"] = df["vdc1"].rolling(window).mean()
10
- df["vdc_std"] = df["vdc1"].rolling(window).std()
11
-
12
  df["pdc_mean"] = df["pdc1"].rolling(window).mean()
13
- df["pdc_std"] = df["pdc1"].rolling(window).std()
14
-
15
  df["pdc_delta"] = df["pdc1"].diff()
16
-
17
  df["pdc_slope"] = df["pdc1"].rolling(window).apply(
18
- lambda x: np.polyfit(range(len(x)), x, 1)[0],
19
- raw=False
20
  )
21
-
22
  df["efficiency"] = df["pdc1"] / (df["vdc1"] * df["idc1"] + 1e-6)
23
- df["efficiency_norm"] = (
24
- df["efficiency"] / df["efficiency"].rolling(window).mean()
25
- )
26
-
27
  return df
 
3
 
4
  def build_features(df, window):
5
  df = df.copy()
 
6
  df["pdc1"] = df["vdc1"] * df["idc1"]
 
7
  df["vdc_mean"] = df["vdc1"].rolling(window).mean()
8
+ df["vdc_std"] = df["vdc1"].rolling(window).std()
 
9
  df["pdc_mean"] = df["pdc1"].rolling(window).mean()
10
+ df["pdc_std"] = df["pdc1"].rolling(window).std()
 
11
  df["pdc_delta"] = df["pdc1"].diff()
 
12
  df["pdc_slope"] = df["pdc1"].rolling(window).apply(
13
+ lambda x: np.polyfit(range(len(x)), x, 1)[0], raw=False
 
14
  )
 
15
  df["efficiency"] = df["pdc1"] / (df["vdc1"] * df["idc1"] + 1e-6)
16
+ df["efficiency_norm"] = df["efficiency"] / df["efficiency"].rolling(window).mean()
 
 
 
17
  return df
ml/inference.py CHANGED
@@ -4,41 +4,43 @@ import joblib
4
  import torch
5
  import numpy as np
6
  import pandas as pd
 
7
  from sklearn.preprocessing import StandardScaler
8
  from sklearn.ensemble import IsolationForest
9
  from safetensors.torch import load_file
10
 
11
  from ml.features import build_features
12
  from ml.lstm_model import LSTMAutoencoder
 
 
 
13
 
14
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
15
  ARTIFACTS_DIR = os.path.join(BASE_DIR, "artifacts")
16
 
 
17
  class MLEngine:
18
  def __init__(self):
19
- # Load configuration
20
- self._load_config()
21
- # Load all models
22
  self._load_scaler()
23
  self._load_isolation_forest()
24
  self._load_xgboost_models()
25
  self._load_lstm_model()
 
26
 
27
- def _load_config(self):
28
- """Load ML configuration from JSON."""
29
- with open(os.path.join(ARTIFACTS_DIR, "ml_config.json")) as f:
30
- self.config = json.load(f)
31
-
32
- self.feature_cols = self.config["feature_cols"]
33
- self.window = self.config["window"]
34
- self.seq_len = self.config["seq_len"]
35
- self.design_life_days = self.config["design_life_days"]
36
 
37
  def _load_scaler(self):
38
  """Load and reconstruct StandardScaler from JSON."""
39
  with open(os.path.join(ARTIFACTS_DIR, "scaler.json"), "r") as f:
40
  params = json.load(f)
41
-
42
  self.scaler = StandardScaler()
43
  self.scaler.mean_ = np.array(params["mean"])
44
  self.scaler.scale_ = np.array(params["scale"])
@@ -58,10 +60,8 @@ class MLEngine:
58
  def _load_xgboost_models(self):
59
  """Load XGBoost models from JSON artifacts."""
60
  import xgboost as xgb
61
-
62
  self.ttf_model = xgb.XGBRegressor()
63
  self.ttf_model.load_model(os.path.join(ARTIFACTS_DIR, "xgb_ttf.json"))
64
-
65
  self.fail_model = xgb.XGBClassifier()
66
  self.fail_model.load_model(os.path.join(ARTIFACTS_DIR, "xgb_fail.json"))
67
 
@@ -77,57 +77,33 @@ class MLEngine:
77
 
78
  def _compute_anomalies(self, df_scaled: pd.DataFrame) -> tuple:
79
  """Compute anomaly scores from LSTM and IsolationForest.
80
-
81
- Returns:
82
- (anomaly_lstm, health) tuple
83
  """
84
- # --- Isolation Forest anomaly ---
85
  df_scaled["anomaly_iforest"] = -self.iso.decision_function(df_scaled)
86
-
87
- # --- LSTM anomaly ---
88
  X = df_scaled[self.feature_cols].values
89
  X_seq = np.array([X[-self.seq_len:]])
90
-
91
  with torch.no_grad():
92
  recon = self.lstm(torch.tensor(X_seq, dtype=torch.float32))
93
-
94
  anomaly_lstm = float(((recon - torch.tensor(X_seq)) ** 2).mean())
95
-
96
- # --- Health (0–1) ---
97
- # Normalize anomaly_lstm (assuming max error ~1e6 from training)
98
  anomaly_norm = min(anomaly_lstm / 1e6, 1.0)
99
  health = max(0.0, 1.0 - anomaly_norm)
100
-
101
  return anomaly_lstm, health
102
 
103
  def _make_predictions(self, df_scaled: pd.DataFrame, anomaly_lstm: float, health: float) -> dict:
104
  """Make TTF and failure probability predictions.
105
-
106
- Returns:
107
- Dictionary with ttf, failure_prob, and rul predictions
108
  """
109
  latest_features = df_scaled[self.feature_cols].iloc[[-1]].copy()
110
  latest_features["anomaly_lstm"] = anomaly_lstm
111
  latest_features["health_index"] = health
112
-
113
  expected_ttf_days = float(
114
  self.ttf_model.predict(latest_features, validate_features=False)[0]
115
  )
116
-
117
  failure_probability = float(
118
  self.fail_model.predict_proba(latest_features, validate_features=False)[0][1]
119
  )
120
-
121
- # --- RUL ---
122
  expected_rul_days = float(health * self.design_life_days)
123
-
124
- # --- Confidence ---
125
- confidence = round(
126
- 0.5 * abs(failure_probability - 0.5) * 2
127
- + 0.5 * health,
128
- 2
129
- )
130
-
131
  return {
132
  "ttf_days": expected_ttf_days,
133
  "failure_prob": failure_probability,
@@ -136,30 +112,21 @@ class MLEngine:
136
  }
137
 
138
  def predict_from_raw(self, raw_df: pd.DataFrame):
139
- # --- Feature engineering ---
140
  df = build_features(raw_df, self.window)
141
  df = df[self.feature_cols].dropna()
142
-
143
  if len(df) < self.seq_len:
144
  raise ValueError("Not enough data for LSTM sequence")
145
-
146
- # --- Scaling ---
147
  df_scaled = pd.DataFrame(
148
- self.scaler.transform(df),
149
- columns=self.feature_cols,
150
- index=df.index
151
  )
152
-
153
- # --- Compute anomalies ---
154
  anomaly_lstm, health = self._compute_anomalies(df_scaled)
155
-
156
- # --- Make predictions ---
157
  predictions = self._make_predictions(df_scaled, anomaly_lstm, health)
158
-
159
  return {
160
  "asset_id": "PV_INVERTER_001",
161
  "failure_probability": round(predictions["failure_prob"], 2),
162
  "expected_ttf_days": round(predictions["ttf_days"], 1),
163
  "expected_rul_days": round(predictions["rul_days"], 1),
164
  "confidence": predictions["confidence"]
165
- }
 
4
  import torch
5
  import numpy as np
6
  import pandas as pd
7
+ import logging
8
  from sklearn.preprocessing import StandardScaler
9
  from sklearn.ensemble import IsolationForest
10
  from safetensors.torch import load_file
11
 
12
  from ml.features import build_features
13
  from ml.lstm_model import LSTMAutoencoder
14
+ from src.config import MLConfig
15
+
16
+ logger = logging.getLogger(__name__)
17
 
18
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
19
  ARTIFACTS_DIR = os.path.join(BASE_DIR, "artifacts")
20
 
21
+
22
  class MLEngine:
23
  def __init__(self):
24
+ logger.info("Initializing ML Engine...")
25
+ self._load_ml_config()
 
26
  self._load_scaler()
27
  self._load_isolation_forest()
28
  self._load_xgboost_models()
29
  self._load_lstm_model()
30
+ logger.info("ML Engine initialized successfully")
31
 
32
+ def _load_ml_config(self):
33
+ """Load ML configuration from config."""
34
+ config = MLConfig.load()
35
+ self.feature_cols = config["feature_cols"]
36
+ self.window = config["window"]
37
+ self.seq_len = config["seq_len"]
38
+ self.design_life_days = config["design_life_days"]
 
 
39
 
40
  def _load_scaler(self):
41
  """Load and reconstruct StandardScaler from JSON."""
42
  with open(os.path.join(ARTIFACTS_DIR, "scaler.json"), "r") as f:
43
  params = json.load(f)
 
44
  self.scaler = StandardScaler()
45
  self.scaler.mean_ = np.array(params["mean"])
46
  self.scaler.scale_ = np.array(params["scale"])
 
60
  def _load_xgboost_models(self):
61
  """Load XGBoost models from JSON artifacts."""
62
  import xgboost as xgb
 
63
  self.ttf_model = xgb.XGBRegressor()
64
  self.ttf_model.load_model(os.path.join(ARTIFACTS_DIR, "xgb_ttf.json"))
 
65
  self.fail_model = xgb.XGBClassifier()
66
  self.fail_model.load_model(os.path.join(ARTIFACTS_DIR, "xgb_fail.json"))
67
 
 
77
 
78
  def _compute_anomalies(self, df_scaled: pd.DataFrame) -> tuple:
79
  """Compute anomaly scores from LSTM and IsolationForest.
80
+ Returns: (anomaly_lstm, health) tuple
 
 
81
  """
 
82
  df_scaled["anomaly_iforest"] = -self.iso.decision_function(df_scaled)
 
 
83
  X = df_scaled[self.feature_cols].values
84
  X_seq = np.array([X[-self.seq_len:]])
 
85
  with torch.no_grad():
86
  recon = self.lstm(torch.tensor(X_seq, dtype=torch.float32))
 
87
  anomaly_lstm = float(((recon - torch.tensor(X_seq)) ** 2).mean())
 
 
 
88
  anomaly_norm = min(anomaly_lstm / 1e6, 1.0)
89
  health = max(0.0, 1.0 - anomaly_norm)
 
90
  return anomaly_lstm, health
91
 
92
  def _make_predictions(self, df_scaled: pd.DataFrame, anomaly_lstm: float, health: float) -> dict:
93
  """Make TTF and failure probability predictions.
94
+ Returns: Dictionary with ttf, failure_prob, and rul predictions
 
 
95
  """
96
  latest_features = df_scaled[self.feature_cols].iloc[[-1]].copy()
97
  latest_features["anomaly_lstm"] = anomaly_lstm
98
  latest_features["health_index"] = health
 
99
  expected_ttf_days = float(
100
  self.ttf_model.predict(latest_features, validate_features=False)[0]
101
  )
 
102
  failure_probability = float(
103
  self.fail_model.predict_proba(latest_features, validate_features=False)[0][1]
104
  )
 
 
105
  expected_rul_days = float(health * self.design_life_days)
106
+ confidence = round(0.5 * abs(failure_probability - 0.5) * 2 + 0.5 * health, 2)
 
 
 
 
 
 
 
107
  return {
108
  "ttf_days": expected_ttf_days,
109
  "failure_prob": failure_probability,
 
112
  }
113
 
114
  def predict_from_raw(self, raw_df: pd.DataFrame):
115
+ logger.info("ML analysis start")
116
  df = build_features(raw_df, self.window)
117
  df = df[self.feature_cols].dropna()
 
118
  if len(df) < self.seq_len:
119
  raise ValueError("Not enough data for LSTM sequence")
 
 
120
  df_scaled = pd.DataFrame(
121
+ self.scaler.transform(df), columns=self.feature_cols, index=df.index
 
 
122
  )
 
 
123
  anomaly_lstm, health = self._compute_anomalies(df_scaled)
 
 
124
  predictions = self._make_predictions(df_scaled, anomaly_lstm, health)
125
+ logger.info("ML analysis end")
126
  return {
127
  "asset_id": "PV_INVERTER_001",
128
  "failure_probability": round(predictions["failure_prob"], 2),
129
  "expected_ttf_days": round(predictions["ttf_days"], 1),
130
  "expected_rul_days": round(predictions["rul_days"], 1),
131
  "confidence": predictions["confidence"]
132
+ }
requirements.txt CHANGED
@@ -8,4 +8,5 @@ scikit-learn
8
  xgboost
9
  fastapi
10
  uvicorn
11
- safetensors
 
 
8
  xgboost
9
  fastapi
10
  uvicorn
11
+ safetensors
12
+ pyyaml
src/config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Dict, Any
4
+
5
+
6
+ class AppConfig:
7
+ """Application and API configuration."""
8
+
9
+ # LLM Configuration
10
+ MODEL_NAME: str = "gemini-2.5-flash-lite"
11
+ TEMPERATURE: float = 0.0
12
+
13
+ # Server Configuration
14
+ HOST: str = "0.0.0.0"
15
+ PORT: int = 7860
16
+
17
+ # Application Metadata
18
+ APP_TITLE: str = "Solar PV Predictive Maintenance API"
19
+ APP_VERSION: str = "1.0.0"
20
+
21
+
22
+ class MLConfig:
23
+ """ML model configuration from ml/artifacts/ml_config.json."""
24
+
25
+ @staticmethod
26
+ def load() -> Dict[str, Any]:
27
+ """Load and return ML configuration."""
28
+ config_path = os.path.join(
29
+ os.path.dirname(__file__), "..", "ml", "artifacts", "ml_config.json"
30
+ )
31
+ with open(config_path) as f:
32
+ return json.load(f)
33
+
34
+
35
+ # For backwards compatibility
36
+ Config = AppConfig
src/models.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+ class SensorData(BaseModel):
4
+ vdc1: list[float]
5
+ idc1: list[float]
6
+ api_key: str = None # Optional Google API key for LLM features
7
+
8
+ class AnalysisResponse(BaseModel):
9
+ ml_output: dict
10
+ agent_output: dict
src/services.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from ml.inference import MLEngine
3
+ from agent.agent import MaintenanceAgent
4
+ from src.utils import validate_sensor_data, prepare_dataframe
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class AnalysisService:
9
+ """Service class for handling sensor data analysis logic."""
10
+
11
+ def __init__(self, config):
12
+ self.config = config
13
+ self.ml_engine = MLEngine()
14
+
15
+ def analyze(self, vdc1: list, idc1: list, api_key: str) -> tuple:
16
+ """Analyze sensor data and return ML and agent outputs."""
17
+ logger.info(f"Complete analysis start - processing {len(vdc1)} data points")
18
+ validate_sensor_data(vdc1, idc1)
19
+ raw_df = prepare_dataframe(vdc1, idc1)
20
+ ml_output = self.ml_engine.predict_from_raw(raw_df)
21
+ agent_output = self.get_agent_output(api_key, ml_output)
22
+
23
+ logger.info("Complete analysis end")
24
+ return ml_output, agent_output
25
+
26
+ def get_agent_output(self, api_key: str, ml_output: dict) -> dict:
27
+ """Get agent analysis if API key is provided, otherwise return no-key message."""
28
+ if not api_key:
29
+ logger.info("No API key provided - skipping agent analysis")
30
+ return {
31
+ "diagnosis": "No API key provided - LLM features disabled",
32
+ "urgency": "Unknown",
33
+ "recommended_action": "Provide Google API key in request for AI diagnosis",
34
+ "justification": ["Google API key required for maintenance reasoning"]
35
+ }
36
+
37
+ try:
38
+ logger.info("Agent analysis start")
39
+ agent = MaintenanceAgent(
40
+ api_key=api_key,
41
+ model_name=self.config.MODEL_NAME,
42
+ temperature=self.config.TEMPERATURE
43
+ )
44
+ result = agent.run(ml_output)
45
+ logger.info("Agent analysis end")
46
+ return result
47
+ except Exception as e:
48
+ logger.warning(f"Agent initialization failed: {e}")
49
+ return {
50
+ "diagnosis": "Agent initialization failed",
51
+ "urgency": "Unknown",
52
+ "recommended_action": "Check your Google API key",
53
+ "justification": [f"Error: {str(e)}"]
54
+ }
src/utils.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException
2
+ import pandas as pd
3
+
4
+ def validate_sensor_data(vdc1: list, idc1: list) -> None:
5
+ """Validate sensor data consistency. Raises HTTPException on error."""
6
+ if len(vdc1) != len(idc1):
7
+ raise HTTPException(status_code=400, detail="Voltage and current lists must have the same length")
8
+ if len(vdc1) < 3:
9
+ raise HTTPException(status_code=400, detail="Need at least 3 data points")
10
+
11
+ def prepare_dataframe(vdc1: list, idc1: list) -> pd.DataFrame:
12
+ """Prepare sensor data for ML inference by padding to 100 points."""
13
+ return pd.DataFrame({
14
+ "vdc1": (vdc1 * (100 // len(vdc1) + 1))[:100],
15
+ "idc1": (idc1 * (100 // len(idc1) + 1))[:100]
16
+ })