Aditya Adaki commited on
Commit
fdcec08
Β·
1 Parent(s): 6a0e853

Add DCRM Analysis API

Browse files
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ __pycache__
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies for OpenCV
6
+ RUN apt-get update && apt-get install -y \
7
+ libgl1-mesa-glx \
8
+ libglib2.0-0 \
9
+ libsm6 \
10
+ libxext6 \
11
+ libxrender-dev \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy requirements first for caching
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Copy application code
19
+ COPY flask_app.py .
20
+ COPY dcrm/ ./dcrm/
21
+
22
+ # Expose port 7860 (Hugging Face default)
23
+ EXPOSE 7860
24
+
25
+ # Run the Flask app
26
+ CMD ["python", "flask_app.py"]
dcrm/__init__.py ADDED
File without changes
dcrm/history_manager.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import datetime
4
+
5
+ class HistoryManager:
6
+ def __init__(self, history_file="data/history.json"):
7
+ self.history_file = history_file
8
+ self.ensure_data_dir()
9
+
10
+ def ensure_data_dir(self):
11
+ directory = os.path.dirname(self.history_file)
12
+ if directory and not os.path.exists(directory):
13
+ os.makedirs(directory)
14
+
15
+ if not os.path.exists(self.history_file):
16
+ with open(self.history_file, 'w') as f:
17
+ json.dump([], f)
18
+
19
+ def load_history(self):
20
+ try:
21
+ with open(self.history_file, 'r') as f:
22
+ return json.load(f)
23
+ except (json.JSONDecodeError, FileNotFoundError):
24
+ return []
25
+
26
+ def save_analysis(self, analysis_data, zone_analysis, filename="Unknown"):
27
+ history = self.load_history()
28
+
29
+ overall = zone_analysis.get('overall_health', {})
30
+
31
+ record = {
32
+ "timestamp": datetime.datetime.now().isoformat(),
33
+ "filename": filename,
34
+ "overall_status": overall.get('status', 'Unknown'),
35
+ "score": overall.get('overall_score', 0),
36
+ "recommendation": overall.get('recommendation', 'N/A'),
37
+ "issues_count": overall.get('total_issues', 0),
38
+ # Store minimal data to keep file size manageable
39
+ # We could store full analysis if needed, but for a list view this is enough
40
+ "analysis_summary": {
41
+ "static_resistance": analysis_data.get("analysis_metrics", {}).get("static_resistance_Rp_uOhm", "N/A") if analysis_data else "N/A"
42
+ }
43
+ }
44
+
45
+ # Prepend to list (newest first)
46
+ history.insert(0, record)
47
+
48
+ # Keep only last 50 records
49
+ if len(history) > 50:
50
+ history = history[:50]
51
+
52
+ with open(self.history_file, 'w') as f:
53
+ json.dump(history, f, indent=2)
54
+
55
+ return record
dcrm/image_processing.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import pandas as pd
4
+ from functools import reduce
5
+ from PIL import Image
6
+
7
+
8
+ def detect_graph_boundaries(img):
9
+ height, width = img.shape[:2]
10
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
11
+ _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
12
+
13
+ col_sums = np.sum(thresh, axis=0) / 255
14
+ is_line = col_sums > (height * 0.40)
15
+ line_indices = np.where(is_line)[0]
16
+
17
+ start_x = 0
18
+ if len(line_indices) > 0:
19
+ left_lines = [x for x in line_indices if x < width * 0.2 and x > 5]
20
+ if left_lines:
21
+ start_x = left_lines[0]
22
+
23
+ end_x = width - 1
24
+ if len(line_indices) > 0:
25
+ right_margin = width * 0.95
26
+ right_lines = [x for x in line_indices if x > right_margin]
27
+ if right_lines:
28
+ end_x = right_lines[-1]
29
+
30
+ # Create debug image
31
+ debug_img = img.copy()
32
+ cv2.line(debug_img, (int(start_x), 0), (int(start_x), height), (0, 255, 0), 3)
33
+ cv2.line(debug_img, (int(end_x), 0), (int(end_x), height), (0, 0, 255), 3)
34
+
35
+ return int(start_x), int(end_x), debug_img
36
+
37
+
38
+ def extract_color_pixels(
39
+ image, color="green", mode="dominant", threshold=0, difference=10
40
+ ):
41
+ """
42
+ Process an image and extract only pixels of a specific color.
43
+ Display them on a black background.
44
+
45
+ Args:
46
+ image: PIL Image object
47
+ color: str, one of 'red', 'green', or 'blue'
48
+ mode: str, detection mode - 'dominant', 'difference', or 'strict'
49
+ threshold: int, minimum value for the target color channel (0-255)
50
+ difference: int/float, parameter meaning depends on mode
51
+
52
+ Returns:
53
+ tuple: (PIL Image object with only specified color pixels, color_mask array)
54
+ """
55
+ # Convert image to RGB if it's not already
56
+ if image.mode != "RGB":
57
+ image = image.convert("RGB")
58
+
59
+ # Convert to numpy array for easier manipulation
60
+ img_array = np.array(image)
61
+
62
+ # Create a black background with the same dimensions
63
+ result_array = np.zeros_like(img_array)
64
+
65
+ # Extract RGB channels
66
+ red = img_array[:, :, 0].astype(np.float32)
67
+ green = img_array[:, :, 1].astype(np.float32)
68
+ blue = img_array[:, :, 2].astype(np.float32)
69
+
70
+ # Create mask based on selected color and mode
71
+ if mode == "dominant":
72
+ # Simply check if the target color is the highest channel
73
+ if color == "red":
74
+ color_mask = (red >= green) & (red >= blue) & (red > threshold)
75
+ elif color == "green":
76
+ color_mask = (green >= red) & (green >= blue) & (green > threshold)
77
+ elif color == "blue":
78
+ color_mask = (blue >= red) & (blue >= green) & (blue > threshold)
79
+
80
+ elif mode == "difference":
81
+ # Target color must be higher than others by a certain absolute difference
82
+ if color == "red":
83
+ color_mask = (
84
+ (red > threshold)
85
+ & (red > green + difference)
86
+ & (red > blue + difference)
87
+ )
88
+ elif color == "green":
89
+ color_mask = (
90
+ (green > threshold)
91
+ & (green > red + difference)
92
+ & (green > blue + difference)
93
+ )
94
+ elif color == "blue":
95
+ color_mask = (
96
+ (blue > threshold)
97
+ & (blue > red + difference)
98
+ & (blue > green + difference)
99
+ )
100
+
101
+ elif mode == "strict":
102
+ # Target color must be significantly higher (percentage-based)
103
+ dominance_factor = 1.0 + (difference / 100.0)
104
+ if color == "red":
105
+ color_mask = (
106
+ (red > threshold)
107
+ & (red > green * dominance_factor)
108
+ & (red > blue * dominance_factor)
109
+ )
110
+ elif color == "green":
111
+ color_mask = (
112
+ (green > threshold)
113
+ & (green > red * dominance_factor)
114
+ & (green > blue * dominance_factor)
115
+ )
116
+ elif color == "blue":
117
+ color_mask = (
118
+ (blue > threshold)
119
+ & (blue > red * dominance_factor)
120
+ & (blue > green * dominance_factor)
121
+ )
122
+ else:
123
+ raise ValueError("Mode must be 'dominant', 'difference', or 'strict'")
124
+
125
+ # Apply mask to keep only target color pixels
126
+ result_array[color_mask] = img_array[color_mask]
127
+
128
+ # Convert back to PIL Image
129
+ result_image = Image.fromarray(result_array.astype("uint8"))
130
+
131
+ return result_image, color_mask
132
+
133
+
134
+ def extract_line_mask(
135
+ img_cropped, line_color, saturation_factor, gap_fill_size, noise_threshold
136
+ ):
137
+ # Boost Saturation
138
+ hsv_pre = cv2.cvtColor(img_cropped, cv2.COLOR_BGR2HSV)
139
+ h, s, v = cv2.split(hsv_pre)
140
+ s = np.clip(s.astype(np.float32) * saturation_factor, 0, 255).astype(np.uint8)
141
+ hsv = cv2.merge((h, s, v))
142
+
143
+ # Convert OpenCV BGR (boosted) to PIL RGB
144
+ boosted_bgr = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
145
+ img_rgb = cv2.cvtColor(boosted_bgr, cv2.COLOR_BGR2RGB)
146
+ pil_image = Image.fromarray(img_rgb)
147
+
148
+ target_color = "green"
149
+ if line_color == "Red":
150
+ target_color = "red"
151
+ elif line_color == "Blue (Cyan)":
152
+ target_color = "blue"
153
+
154
+ diff_val = 20
155
+ if line_color == "Green":
156
+ diff_val = 30
157
+
158
+ _, color_mask = extract_color_pixels(
159
+ pil_image,
160
+ color=target_color,
161
+ mode="difference",
162
+ threshold=40,
163
+ difference=diff_val,
164
+ )
165
+
166
+ # Convert boolean mask to uint8
167
+ mask = np.zeros_like(img_cropped[:, :, 0], dtype=np.uint8)
168
+ mask[color_mask] = 255
169
+
170
+ debug_image = None
171
+
172
+ # Additional processing for Green (White removal)
173
+ if line_color == "Green":
174
+ original_bgr = img_cropped
175
+ original_hsv = cv2.cvtColor(original_bgr, cv2.COLOR_BGR2HSV)
176
+ _, orig_s, orig_v = cv2.split(original_hsv)
177
+ white_mask = (orig_v > 200) & (orig_s < 50)
178
+
179
+ mask_before_white_removal = mask.copy()
180
+ mask[white_mask] = 0
181
+
182
+ # Create debug visualization
183
+ debug_image = img_cropped.copy()
184
+ debug_image[mask > 0] = [0, 255, 0]
185
+ removed_white = white_mask & (mask_before_white_removal > 0)
186
+ debug_image[removed_white] = [0, 0, 255]
187
+
188
+ if mask is None:
189
+ return None, None
190
+
191
+ # Noise/Gap cleanup
192
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
193
+ mask_clean = np.zeros_like(mask)
194
+ for cnt in contours:
195
+ if cv2.contourArea(cnt) > (noise_threshold * 0.5):
196
+ cv2.drawContours(mask_clean, [cnt], -1, 255, -1)
197
+ mask = mask_clean
198
+
199
+ if gap_fill_size > 0:
200
+ k_h = np.ones((1, gap_fill_size), np.uint8)
201
+ close_h = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_h)
202
+ k_v = np.ones((gap_fill_size, 1), np.uint8)
203
+ close_v = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_v)
204
+ mask = cv2.bitwise_or(close_h, close_v)
205
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((2, 2), np.uint8))
206
+
207
+ return mask, debug_image
208
+
209
+
210
+ def generate_curve_data(mask, name_upper, name_lower):
211
+ height, width = mask.shape
212
+ data = []
213
+
214
+ for x in range(width):
215
+ col = mask[:, x]
216
+ indices = np.where(col > 0)[0]
217
+ val_top, val_bot = None, None
218
+
219
+ if len(indices) > 0:
220
+ y_min, y_max = indices[0], indices[-1]
221
+ graph_y_top = height - y_min
222
+ graph_y_bot = height - y_max
223
+ val_top = graph_y_top
224
+ val_bot = graph_y_bot
225
+
226
+ data.append({"X": x, name_upper: val_top, name_lower: val_bot})
227
+
228
+ df = pd.DataFrame(data)
229
+ df[name_upper] = df[name_upper].interpolate(
230
+ method="linear", limit=3, limit_area="inside"
231
+ )
232
+ df[name_lower] = df[name_lower].interpolate(
233
+ method="linear", limit=3, limit_area="inside"
234
+ )
235
+ df[name_upper] = df[name_upper].bfill().ffill()
236
+ df[name_lower] = df[name_lower].bfill().ffill()
237
+
238
+ return df
239
+
240
+
241
+ def process_uploaded_image(
242
+ file_bytes,
243
+ sat_factor,
244
+ gap_size,
245
+ noise_threshold,
246
+ crop_enabled,
247
+ total_duration,
248
+ travel_gradient_threshold=30,
249
+ ):
250
+ file_bytes = np.asarray(bytearray(file_bytes), dtype=np.uint8)
251
+ img_orig = cv2.imdecode(file_bytes, 1)
252
+
253
+ debug_img_bounds = img_orig.copy()
254
+ sx, ex = 0, img_orig.shape[1]
255
+
256
+ if crop_enabled:
257
+ sx, ex, debug_img_bounds = detect_graph_boundaries(img_orig)
258
+ img_working = img_orig[:, sx:ex]
259
+ else:
260
+ img_working = img_orig
261
+
262
+ if img_working.shape[1] == 0:
263
+ return None, None, None, "Crop failed.", {}
264
+
265
+ configs = [
266
+ ("Red", "Red", ("Travel", "C1")),
267
+ ("Green", "Green", ("Resistance", "C2")),
268
+ ("Blue (Cyan)", "Blue", ("Current", "C3")),
269
+ ]
270
+
271
+ dfs = []
272
+ debug_images = {}
273
+ debug_images["Boundaries"] = debug_img_bounds
274
+ height, width = img_working.shape[:2]
275
+
276
+ for color_key, _, col_names in configs:
277
+ mask, debug_img = extract_line_mask(
278
+ img_working, color_key, sat_factor, gap_size, noise_threshold
279
+ )
280
+
281
+ if mask is not None:
282
+ if debug_img is not None and color_key == "Green":
283
+ debug_images[color_key + " (White Removal)"] = cv2.cvtColor(
284
+ debug_img, cv2.COLOR_BGR2RGB
285
+ )
286
+ colored_mask_clean = np.zeros_like(img_working)
287
+ colored_mask_clean[mask > 0] = [0, 255, 0]
288
+ overlay_clean = cv2.addWeighted(
289
+ img_working, 0.7, colored_mask_clean, 0.3, 0
290
+ )
291
+ debug_images[color_key + " (Cleaned Overlay)"] = cv2.cvtColor(
292
+ overlay_clean, cv2.COLOR_BGR2RGB
293
+ )
294
+
295
+ colored_mask = np.zeros_like(img_working)
296
+ colored_mask[mask > 0] = [0, 255, 0]
297
+ overlay = cv2.addWeighted(img_working, 0.7, colored_mask, 0.3, 0)
298
+ debug_images[color_key] = cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB)
299
+
300
+ df_curve = generate_curve_data(mask, col_names[0], col_names[1])
301
+ dfs.append(df_curve)
302
+ else:
303
+ df_empty = pd.DataFrame(
304
+ {"X": range(width), col_names[0]: np.nan, col_names[1]: np.nan}
305
+ )
306
+ dfs.append(df_empty)
307
+
308
+ if dfs:
309
+ final_df = reduce(
310
+ lambda left, right: pd.merge(left, right, on="X", how="outer"), dfs
311
+ )
312
+
313
+ cols = ["X", "Travel", "C1", "Resistance", "C2", "Current", "C3"]
314
+ existing_cols = [c for c in cols if c in final_df.columns]
315
+
316
+ if "X" in final_df.columns:
317
+ # === UPDATED TIME CALCULATION ===
318
+ # Calculates strict linear time: Pixel 0 = 0ms, Pixel Last = total_duration
319
+ final_df["Time (ms)"] = (final_df["X"] / (width - 1)) * total_duration
320
+ existing_cols.insert(1, "Time (ms)")
321
+ else:
322
+ return None, None, None, "X-axis alignment failed.", {}
323
+
324
+ # IMPROVED BASELINE CLEANUP - Remove dotted reference lines
325
+ baselines = {}
326
+
327
+ for col in ["Travel", "Current"]:
328
+ if col in final_df.columns:
329
+ # Calculate baseline from first 60 entries
330
+ first_60 = final_df[col].head(60)
331
+
332
+ if first_60.notna().any():
333
+ initial_baseline = first_60.mean(skipna=True)
334
+
335
+ if col == "Travel":
336
+ # Identify outliers: points < 98% of initial baseline
337
+ outlier_threshold = initial_baseline * 0.98
338
+ valid_points = first_60[first_60 >= outlier_threshold]
339
+
340
+ if valid_points.notna().any():
341
+ baseline_val = valid_points.mean(skipna=True)
342
+ else:
343
+ baseline_val = initial_baseline
344
+ else:
345
+ baseline_val = initial_baseline
346
+ else:
347
+ valid_idx = final_df[col].first_valid_index()
348
+ if valid_idx is not None:
349
+ baseline_val = final_df.loc[valid_idx, col]
350
+ else:
351
+ continue
352
+
353
+ baselines[col] = baseline_val
354
+
355
+ # Find minimum value (dotted reference line level)
356
+ min_val = final_df[col].min(skipna=True)
357
+ # Set values near minimum to NaN
358
+ threshold = min_val + (baseline_val - min_val) * 0.15
359
+ final_df.loc[final_df[col] < threshold, col] = np.nan
360
+
361
+ # Abrupt Change (Gradient) Filter
362
+ if col == "Travel":
363
+ gradient_threshold = travel_gradient_threshold
364
+ diff = final_df[col].diff().abs()
365
+ mask_abrupt = diff > gradient_threshold
366
+ final_df.loc[mask_abrupt, col] = np.nan
367
+
368
+ # Time-Based Baseline Tolerances
369
+ # 1. Start (0-30ms)
370
+ mask_start = final_df["Time (ms)"] < 30
371
+ threshold_start = baseline_val * 0.98
372
+ mask_remove_start = mask_start & (final_df[col] < threshold_start)
373
+ final_df.loc[mask_remove_start, col] = np.nan
374
+
375
+ # 2. End (Last 50ms)
376
+ max_time = final_df["Time (ms)"].max()
377
+ mask_end = final_df["Time (ms)"] > (max_time - 50)
378
+ threshold_end = baseline_val * 0.98
379
+ mask_remove_end = mask_end & (final_df[col] < threshold_end)
380
+ final_df.loc[mask_remove_end, col] = np.nan
381
+
382
+ # 3. Center (100-300ms)
383
+ mask_center = (final_df["Time (ms)"] >= 100) & (
384
+ final_df["Time (ms)"] <= 300
385
+ )
386
+ threshold_center = baseline_val * 1.05
387
+ mask_remove_center = mask_center & (final_df[col] < threshold_center)
388
+ final_df.loc[mask_remove_center, col] = np.nan
389
+
390
+ # 4. Main (30-350ms) excluding Center
391
+ mask_main_pre = (final_df["Time (ms)"] >= 30) & (
392
+ final_df["Time (ms)"] < 100
393
+ )
394
+ mask_main_post = (final_df["Time (ms)"] > 300) & (
395
+ final_df["Time (ms)"] <= 350
396
+ )
397
+
398
+ mask_remove_main_pre = mask_main_pre & (final_df[col] < baseline_val)
399
+ mask_remove_main_post = mask_main_post & (final_df[col] < baseline_val)
400
+
401
+ final_df.loc[mask_remove_main_pre, col] = np.nan
402
+ final_df.loc[mask_remove_main_post, col] = np.nan
403
+
404
+ # Fill gaps
405
+ final_df[col] = (
406
+ final_df[col]
407
+ .interpolate(method="linear", limit=3, limit_area="inside")
408
+ .bfill()
409
+ .ffill()
410
+ )
411
+
412
+ # CROSS-CHANNEL BASELINE CONSTRAINTS
413
+ if "Travel" in baselines:
414
+ travel_base = baselines["Travel"]
415
+ if "Current" in final_df.columns:
416
+ mask = final_df["Current"] < travel_base
417
+ final_df.loc[mask, "Current"] = np.nan
418
+ final_df["Current"] = (
419
+ final_df["Current"]
420
+ .interpolate(method="linear", limit=3, limit_area="inside")
421
+ .bfill()
422
+ .ffill()
423
+ )
424
+
425
+ # AUXILIARY CURVE LOGIC
426
+ pairs = [("C1", "Travel"), ("C2", "Resistance"), ("C3", "Current")]
427
+ for lower, upper in pairs:
428
+ if lower in final_df.columns and upper in final_df.columns:
429
+ if not final_df[upper].isnull().all():
430
+ invalid_mask = final_df[lower] > final_df[upper]
431
+ final_df.loc[invalid_mask, lower] = np.nan
432
+
433
+ # Final global cleanup (excluding Resistance)
434
+ for col in ["Travel", "Current", "C1", "C3"]:
435
+ if col in final_df.columns:
436
+ final_df[col] = (
437
+ final_df[col]
438
+ .interpolate(method="linear", limit=3, limit_area="inside")
439
+ .bfill()
440
+ .ffill()
441
+ )
442
+
443
+ return final_df[existing_cols], debug_images, (sx, ex), None, baselines
444
+
445
+ return None, None, None, "No data extracted.", {}
dcrm/image_processing.py.backup ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import pandas as pd
4
+ from functools import reduce
5
+
6
+ def detect_graph_boundaries(img):
7
+ height, width = img.shape[:2]
8
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
9
+ _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
10
+
11
+ col_sums = np.sum(thresh, axis=0) / 255
12
+ is_line = col_sums > (height * 0.40)
13
+ line_indices = np.where(is_line)[0]
14
+
15
+ start_x = 0
16
+ if len(line_indices) > 0:
17
+ left_lines = [x for x in line_indices if x < width * 0.2 and x > 5]
18
+ if left_lines: start_x = left_lines[0]
19
+
20
+ end_x = width - 1
21
+ if len(line_indices) > 0:
22
+ right_margin = width * 0.95
23
+ right_lines = [x for x in line_indices if x > right_margin]
24
+ if right_lines: end_x = right_lines[-1]
25
+
26
+ # Create debug image
27
+ debug_img = img.copy()
28
+ cv2.line(debug_img, (int(start_x), 0), (int(start_x), height), (0, 255, 0), 3)
29
+ cv2.line(debug_img, (int(end_x), 0), (int(end_x), height), (0, 0, 255), 3)
30
+
31
+ return int(start_x), int(end_x), debug_img
32
+
33
+ def extract_line_mask(img_cropped, line_color, saturation_factor, gap_fill_size, noise_threshold):
34
+ # Boost Saturation
35
+ hsv_pre = cv2.cvtColor(img_cropped, cv2.COLOR_BGR2HSV)
36
+ h, s, v = cv2.split(hsv_pre)
37
+ s = np.clip(s.astype(np.float32) * saturation_factor, 0, 255).astype(np.uint8)
38
+ hsv = cv2.merge((h, s, v))
39
+
40
+ b, g, r = cv2.split(cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR))
41
+ mask = None
42
+
43
+ if line_color == "Green":
44
+ lower = np.array([35, 20, 100]); upper = np.array([75, 255, 255])
45
+ mask_hsv = cv2.inRange(hsv, lower, upper)
46
+ diff_gb = g.astype(np.int16) - b.astype(np.int16)
47
+ diff_gr = g.astype(np.int16) - r.astype(np.int16)
48
+ mask_channel = np.zeros_like(g, dtype=np.uint8)
49
+ mask_channel[(diff_gb > 20) & (diff_gr > 10)] = 255
50
+ mask = cv2.bitwise_and(mask_hsv, mask_channel)
51
+
52
+ elif line_color == "Blue (Cyan)":
53
+ lower = np.array([80, 20, 100]); upper = np.array([100, 255, 255])
54
+ mask_hsv = cv2.inRange(hsv, lower, upper)
55
+ diff_br = b.astype(np.int16) - r.astype(np.int16)
56
+ mask_channel = np.zeros_like(b, dtype=np.uint8)
57
+ mask_channel[diff_br > 20] = 255
58
+ mask = cv2.bitwise_and(mask_hsv, mask_channel)
59
+
60
+ elif line_color == "Red":
61
+ lower1 = np.array([0, 20, 100]); upper1 = np.array([10, 255, 255])
62
+ lower2 = np.array([170, 20, 100]); upper2 = np.array([180, 255, 255])
63
+ mask_hsv = cv2.bitwise_or(cv2.inRange(hsv, lower1, upper1), cv2.inRange(hsv, lower2, upper2))
64
+ diff_rg = r.astype(np.int16) - g.astype(np.int16)
65
+ diff_rb = r.astype(np.int16) - b.astype(np.int16)
66
+ mask_channel = np.zeros_like(r, dtype=np.uint8)
67
+ mask_channel[(diff_rg > 20) & (diff_rb > 20)] = 255
68
+ mask = cv2.bitwise_and(mask_hsv, mask_channel)
69
+
70
+ if mask is None: return None
71
+
72
+ # Noise/Gap cleanup
73
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
74
+ mask_clean = np.zeros_like(mask)
75
+ for cnt in contours:
76
+ # Reduced noise threshold slightly to keep thin spikes
77
+ if cv2.contourArea(cnt) > (noise_threshold * 0.5):
78
+ cv2.drawContours(mask_clean, [cnt], -1, 255, -1)
79
+ mask = mask_clean
80
+
81
+ if gap_fill_size > 0:
82
+ k_h = np.ones((1, gap_fill_size), np.uint8)
83
+ close_h = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_h)
84
+ k_v = np.ones((gap_fill_size, 1), np.uint8)
85
+ close_v = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_v)
86
+ mask = cv2.bitwise_or(close_h, close_v)
87
+ # Replaced the 3x3 CLOSE with a smaller one to prevent merging nearby spikes too aggressively
88
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((2,2), np.uint8))
89
+
90
+ # --- FIX 1: REMOVED MORPH_OPEN ---
91
+ # The previous code had: mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2,2), np.uint8))
92
+ # This erodes the image. If a spike is very sharp (1-2px tip), this line deletes the tip.
93
+ # We remove it to preserve high-frequency details.
94
+
95
+ return mask
96
+
97
+ def generate_curve_data(mask, name_upper, name_lower):
98
+ height, width = mask.shape
99
+ data = []
100
+ prev_top, prev_bot = None, None
101
+ proximity_thresh = 10
102
+
103
+ for x in range(width):
104
+ col = mask[:, x]
105
+ indices = np.where(col > 0)[0]
106
+ val_top, val_bot = None, None
107
+
108
+ if len(indices) > 0:
109
+ y_min, y_max = indices[0], indices[-1]
110
+ graph_y_top = height - y_min
111
+ graph_y_bot = height - y_max
112
+
113
+ # If the line is thin, it's a single curve
114
+ if abs(y_max - y_min) <= proximity_thresh:
115
+ current_val = height - int((y_min + y_max) / 2)
116
+ # Simple tracking to decide if it belongs to top or bottom curve if they were split previously
117
+ if prev_top is None and prev_bot is None: val_top = current_val
118
+ elif prev_top is not None and prev_bot is None: val_top = current_val
119
+ elif prev_top is None and prev_bot is not None: val_bot = current_val
120
+ else:
121
+ if abs(current_val - prev_top) <= abs(current_val - prev_bot): val_top = current_val
122
+ else: val_bot = current_val
123
+ else:
124
+ # Vertical line (spike) or filled area
125
+ val_top = graph_y_top
126
+ val_bot = graph_y_bot
127
+
128
+ if val_top is not None: prev_top = val_top
129
+ if val_bot is not None: prev_bot = val_bot
130
+ data.append({"X": x, name_upper: val_top, name_lower: val_bot})
131
+
132
+ df = pd.DataFrame(data)
133
+ # Using 'pchip' or 'linear' interpolation.
134
+ # 'linear' is safer for sharp spikes. 'pchip' can overshoot.
135
+ df[name_upper] = df[name_upper].interpolate(method='linear', limit_direction='both')
136
+ df[name_lower] = df[name_lower].interpolate(method='linear', limit_direction='both')
137
+ return df
138
+
139
+ def process_uploaded_image(file_bytes, sat_factor, gap_size, noise_threshold, crop_enabled, total_duration):
140
+ # 1. Decode Image
141
+ file_bytes = np.asarray(bytearray(file_bytes), dtype=np.uint8)
142
+ img_orig = cv2.imdecode(file_bytes, 1)
143
+
144
+ # 2. Crop
145
+ debug_img_bounds = img_orig.copy()
146
+ sx, ex = 0, img_orig.shape[1]
147
+
148
+ if crop_enabled:
149
+ sx, ex, debug_img_bounds = detect_graph_boundaries(img_orig)
150
+ img_working = img_orig[:, sx:ex]
151
+ else:
152
+ img_working = img_orig
153
+
154
+ if img_working.shape[1] == 0:
155
+ return None, None, None, "Crop failed."
156
+
157
+ # 3. Process Colors
158
+ configs = [
159
+ ("Red", "Red", ("Travel", "C1")),
160
+ ("Green", "Green", ("Resistance", "C2")),
161
+ ("Blue (Cyan)", "Blue", ("Current", "C3"))
162
+ ]
163
+
164
+ dfs = []
165
+ debug_images = {}
166
+ debug_images["Boundaries"] = debug_img_bounds
167
+ height, width = img_working.shape[:2]
168
+
169
+ for color_key, _, col_names in configs:
170
+ mask = extract_line_mask(img_working, color_key, sat_factor, gap_size, noise_threshold)
171
+
172
+ if mask is not None:
173
+ colored_mask = np.zeros_like(img_working)
174
+ colored_mask[mask > 0] = [0, 255, 0]
175
+ overlay = cv2.addWeighted(img_working, 0.7, colored_mask, 0.3, 0)
176
+ debug_images[color_key] = cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB)
177
+
178
+ df_curve = generate_curve_data(mask, col_names[0], col_names[1])
179
+ dfs.append(df_curve)
180
+ else:
181
+ df_empty = pd.DataFrame({"X": range(width), col_names[0]: np.nan, col_names[1]: np.nan})
182
+ dfs.append(df_empty)
183
+
184
+ # 4. Merge
185
+ if dfs:
186
+ final_df = reduce(lambda left, right: pd.merge(left, right, on='X', how='outer'), dfs)
187
+
188
+ # STEP A: GENERATE TIME COLUMN
189
+ cols = ['X', 'Travel', 'C1', 'Resistance', 'C2', 'Current', 'C3']
190
+ existing_cols = [c for c in cols if c in final_df.columns]
191
+
192
+ if 'X' in final_df.columns:
193
+ time_per_pixel = total_duration / width
194
+ final_df['Time (ms)'] = final_df['X'] * time_per_pixel
195
+ existing_cols.insert(1, 'Time (ms)')
196
+ else:
197
+ return None, None, None, "X-axis alignment failed."
198
+
199
+ # STEP B: CALCULATE BASELINES & CLEANUP
200
+ for col in ['Current', 'Travel']:
201
+ if col in final_df.columns:
202
+ # Baseline - keep this simple to detect zero offset
203
+ baseline_val = 0
204
+ start_window = final_df[final_df['Time (ms)'] <= 30]
205
+ if not start_window.empty and start_window[col].notna().any():
206
+ baseline_val = start_window[col].mean()
207
+ else:
208
+ valid_idx = final_df[col].first_valid_index()
209
+ if valid_idx is not None: baseline_val = final_df.loc[valid_idx, col]
210
+
211
+ # --- FIX 2: Relaxed Baseline Clipping ---
212
+ # Previous code strictly deleted anything below baseline.
213
+ # Spikes often oscillate. We allow small dips now.
214
+ end_x = width - 1
215
+ if len(line_indices) > 0:
216
+ right_margin = width * 0.95
217
+ right_lines = [x for x in line_indices if x > right_margin]
218
+ if right_lines: end_x = right_lines[-1]
219
+
220
+ # Create debug image
221
+ debug_img = img.copy()
222
+ cv2.line(debug_img, (int(start_x), 0), (int(start_x), height), (0, 255, 0), 3)
223
+ cv2.line(debug_img, (int(end_x), 0), (int(end_x), height), (0, 0, 255), 3)
224
+
225
+ return int(start_x), int(end_x), debug_img
226
+
227
+ def extract_line_mask(img_cropped, line_color, saturation_factor, gap_fill_size, noise_threshold):
228
+ # Boost Saturation
229
+ hsv_pre = cv2.cvtColor(img_cropped, cv2.COLOR_BGR2HSV)
230
+ h, s, v = cv2.split(hsv_pre)
231
+ s = np.clip(s.astype(np.float32) * saturation_factor, 0, 255).astype(np.uint8)
232
+ hsv = cv2.merge((h, s, v))
233
+
234
+ b, g, r = cv2.split(cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR))
235
+ mask = None
236
+
237
+ if line_color == "Green":
238
+ lower = np.array([35, 20, 100]); upper = np.array([75, 255, 255])
239
+ mask_hsv = cv2.inRange(hsv, lower, upper)
240
+ diff_gb = g.astype(np.int16) - b.astype(np.int16)
241
+ diff_gr = g.astype(np.int16) - r.astype(np.int16)
242
+ mask_channel = np.zeros_like(g, dtype=np.uint8)
243
+ mask_channel[(diff_gb > 20) & (diff_gr > 10)] = 255
244
+ mask = cv2.bitwise_and(mask_hsv, mask_channel)
245
+
246
+ elif line_color == "Blue (Cyan)":
247
+ lower = np.array([80, 20, 100]); upper = np.array([100, 255, 255])
248
+ mask_hsv = cv2.inRange(hsv, lower, upper)
249
+ diff_br = b.astype(np.int16) - r.astype(np.int16)
250
+ mask_channel = np.zeros_like(b, dtype=np.uint8)
251
+ mask_channel[diff_br > 20] = 255
252
+ mask = cv2.bitwise_and(mask_hsv, mask_channel)
253
+
254
+ elif line_color == "Red":
255
+ lower1 = np.array([0, 20, 100]); upper1 = np.array([10, 255, 255])
256
+ lower2 = np.array([170, 20, 100]); upper2 = np.array([180, 255, 255])
257
+ mask_hsv = cv2.bitwise_or(cv2.inRange(hsv, lower1, upper1), cv2.inRange(hsv, lower2, upper2))
258
+ diff_rg = r.astype(np.int16) - g.astype(np.int16)
259
+ diff_rb = r.astype(np.int16) - b.astype(np.int16)
260
+ mask_channel = np.zeros_like(r, dtype=np.uint8)
261
+ mask_channel[(diff_rg > 20) & (diff_rb > 20)] = 255
262
+ mask = cv2.bitwise_and(mask_hsv, mask_channel)
263
+
264
+ if mask is None: return None
265
+
266
+ # Noise/Gap cleanup
267
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
268
+ mask_clean = np.zeros_like(mask)
269
+ for cnt in contours:
270
+ # Reduced noise threshold slightly to keep thin spikes
271
+ if cv2.contourArea(cnt) > (noise_threshold * 0.5):
272
+ cv2.drawContours(mask_clean, [cnt], -1, 255, -1)
273
+ mask = mask_clean
274
+
275
+ if gap_fill_size > 0:
276
+ k_h = np.ones((1, gap_fill_size), np.uint8)
277
+ close_h = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_h)
278
+ k_v = np.ones((gap_fill_size, 1), np.uint8)
279
+ close_v = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_v)
280
+ mask = cv2.bitwise_or(close_h, close_v)
281
+ # Replaced the 3x3 CLOSE with a smaller one to prevent merging nearby spikes too aggressively
282
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((2,2), np.uint8))
283
+
284
+ # --- FIX 1: REMOVED MORPH_OPEN ---
285
+ # The previous code had: mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2,2), np.uint8))
286
+ # This erodes the image. If a spike is very sharp (1-2px tip), this line deletes the tip.
287
+ # We remove it to preserve high-frequency details.
288
+
289
+ return mask
290
+
291
+ def generate_curve_data(mask, name_upper, name_lower):
292
+ height, width = mask.shape
293
+ data = []
294
+ prev_top, prev_bot = None, None
295
+ proximity_thresh = 10
296
+
297
+ for x in range(width):
298
+ col = mask[:, x]
299
+ indices = np.where(col > 0)[0]
300
+ val_top, val_bot = None, None
301
+
302
+ if len(indices) > 0:
303
+ y_min, y_max = indices[0], indices[-1]
304
+ graph_y_top = height - y_min
305
+ graph_y_bot = height - y_max
306
+
307
+ # If the line is thin, it's a single curve
308
+ if abs(y_max - y_min) <= proximity_thresh:
309
+ current_val = height - int((y_min + y_max) / 2)
310
+ # Simple tracking to decide if it belongs to top or bottom curve if they were split previously
311
+ if prev_top is None and prev_bot is None: val_top = current_val
312
+ elif prev_top is not None and prev_bot is None: val_top = current_val
313
+ elif prev_top is None and prev_bot is not None: val_bot = current_val
314
+ else:
315
+ if abs(current_val - prev_top) <= abs(current_val - prev_bot): val_top = current_val
316
+ else: val_bot = current_val
317
+ else:
318
+ # Vertical line (spike) or filled area
319
+ val_top = graph_y_top
320
+ val_bot = graph_y_bot
321
+
322
+ if val_top is not None: prev_top = val_top
323
+ if val_bot is not None: prev_bot = val_bot
324
+ data.append({"X": x, name_upper: val_top, name_lower: val_bot})
325
+
326
+ df = pd.DataFrame(data)
327
+ # Using 'pchip' or 'linear' interpolation.
328
+ # 'linear' is safer for sharp spikes. 'pchip' can overshoot.
329
+ df[name_upper] = df[name_upper].interpolate(method='linear', limit_direction='both')
330
+ df[name_lower] = df[name_lower].interpolate(method='linear', limit_direction='both')
331
+ return df
332
+
333
+ def process_uploaded_image(file_bytes, sat_factor, gap_size, noise_threshold, crop_enabled, total_duration):
334
+ # 1. Decode Image
335
+ file_bytes = np.asarray(bytearray(file_bytes), dtype=np.uint8)
336
+ img_orig = cv2.imdecode(file_bytes, 1)
337
+
338
+ # 2. Crop
339
+ debug_img_bounds = img_orig.copy()
340
+ sx, ex = 0, img_orig.shape[1]
341
+
342
+ if crop_enabled:
343
+ sx, ex, debug_img_bounds = detect_graph_boundaries(img_orig)
344
+ img_working = img_orig[:, sx:ex]
345
+ else:
346
+ img_working = img_orig
347
+
348
+ if img_working.shape[1] == 0:
349
+ return None, None, None, "Crop failed."
350
+
351
+ # 3. Process Colors
352
+ configs = [
353
+ ("Red", "Red", ("Travel", "C1")),
354
+ ("Green", "Green", ("Resistance", "C2")),
355
+ ("Blue (Cyan)", "Blue", ("Current", "C3"))
356
+ ]
357
+
358
+ dfs = []
359
+ debug_images = {}
360
+ debug_images["Boundaries"] = debug_img_bounds
361
+ height, width = img_working.shape[:2]
362
+
363
+ for color_key, _, col_names in configs:
364
+ mask = extract_line_mask(img_working, color_key, sat_factor, gap_size, noise_threshold)
365
+
366
+ if mask is not None:
367
+ colored_mask = np.zeros_like(img_working)
368
+ colored_mask[mask > 0] = [0, 255, 0]
369
+ overlay = cv2.addWeighted(img_working, 0.7, colored_mask, 0.3, 0)
370
+ debug_images[color_key] = cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB)
371
+
372
+ df_curve = generate_curve_data(mask, col_names[0], col_names[1])
373
+ dfs.append(df_curve)
374
+ else:
375
+ df_empty = pd.DataFrame({"X": range(width), col_names[0]: np.nan, col_names[1]: np.nan})
376
+ dfs.append(df_empty)
377
+
378
+ # 4. Merge
379
+ if dfs:
380
+ final_df = reduce(lambda left, right: pd.merge(left, right, on='X', how='outer'), dfs)
381
+
382
+ # STEP A: GENERATE TIME COLUMN
383
+ cols = ['X', 'Travel', 'C1', 'Resistance', 'C2', 'Current', 'C3']
384
+ existing_cols = [c for c in cols if c in final_df.columns]
385
+
386
+ if 'X' in final_df.columns:
387
+ time_per_pixel = total_duration / width
388
+ final_df['Time (ms)'] = final_df['X'] * time_per_pixel
389
+ existing_cols.insert(1, 'Time (ms)')
390
+ else:
391
+ return None, None, None, "X-axis alignment failed."
392
+
393
+ # STEP B: CALCULATE BASELINES & CLEANUP
394
+ for col in ['Current', 'Travel']:
395
+ if col in final_df.columns:
396
+ # Baseline - keep this simple to detect zero offset
397
+ baseline_val = 0
398
+ start_window = final_df[final_df['Time (ms)'] <= 30]
399
+ if not start_window.empty and start_window[col].notna().any():
400
+ baseline_val = start_window[col].mean()
401
+ else:
402
+ valid_idx = final_df[col].first_valid_index()
403
+ if valid_idx is not None: baseline_val = final_df.loc[valid_idx, col]
404
+
405
+ # --- FIX 2: Relaxed Baseline Clipping ---
406
+ # Previous code strictly deleted anything below baseline.
407
+ # Spikes often oscillate. We allow small dips now.
408
+ # Only clip if it is essentially noise below 0 (assuming values are positive)
409
+ # If your graph allows negative values, remove this line entirely.
410
+ if baseline_val > 0:
411
+ final_df.loc[final_df[col] < (baseline_val * 0.8), col] = np.nan
412
+
413
+
414
+ final_df[col] = final_df[col].interpolate(method='linear', limit_direction='both')
415
+
416
+ # --- FIX 3: REMOVED "Middle Section Cleanup" ---
417
+ # The previous code deleted data between 120ms and 250ms if it was near the baseline.
418
+ # This was the primary cause of spikes disappearing in that region.
419
+ # The code block is deleted here.
420
+
421
+ # STEP C: AUXILIARY CURVE LOGIC
422
+ # Purpose: Clean auxiliary curves (C1, C2, C3) that represent the bottom edge of thick lines
423
+ # The auxiliary curve should never be ABOVE the main curve at the same X position
424
+ pairs = [('C1', 'Travel'), ('C2', 'Resistance'), ('C3', 'Current')]
425
+ for lower, upper in pairs:
426
+ if lower in final_df.columns and upper in final_df.columns:
427
+ if not final_df[upper].isnull().all():
428
+ # FIX: Compare at same X position to preserve spikes
429
+ # Previous logic: final_df[lower] > min_upper (global minimum)
430
+ # - This deleted spike data because spike bottoms exceeded the global min
431
+ # New logic: final_df[lower] > final_df[upper] (position-wise comparison)
432
+ # - This only deletes truly invalid crossovers at the same X
433
+ # - Preserves spikes where upper and lower legitimately differ
434
+ invalid_mask = final_df[lower] > final_df[upper]
435
+ final_df.loc[invalid_mask, lower] = np.nan
436
+
437
+ # Final global cleanup
438
+ for col in ['Current', 'Travel', 'C1', 'C2', 'C3']:
439
+ if col in final_df.columns:
440
+ final_df[col] = final_df[col].interpolate(method='linear', limit_direction='both')
441
+
442
+
443
+ return final_df[existing_cols], debug_images, (sx, ex), None
444
+
445
+ return None, None, None, "No data extracted."
dcrm/image_zone_analysis.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image-Based Zone Analysis Module for DCRM Curves
3
+
4
+ This module analyzes zones directly from the annotated image with segmentation lines,
5
+ providing visual analysis of each zone based on the actual image content.
6
+ """
7
+
8
+ import cv2
9
+ import numpy as np
10
+ from typing import Dict, List, Tuple, Any
11
+ import pandas as pd
12
+
13
+
14
+ class ImageZoneAnalyzer:
15
+ """Analyzes zones directly from the segmented image."""
16
+
17
+ def __init__(self, image: np.ndarray, zones_data: Dict[str, Any],
18
+ bounds: Tuple[int, int], total_duration: float):
19
+ """
20
+ Initialize the image-based zone analyzer.
21
+
22
+ Args:
23
+ image: Original image (BGR format)
24
+ zones_data: Dictionary containing zone segmentation information
25
+ bounds: (start_x, end_x) boundaries of the graph
26
+ total_duration: Total duration in milliseconds
27
+ """
28
+ self.image = image
29
+ self.zones_data = zones_data
30
+ self.bounds = bounds
31
+ self.total_duration = total_duration
32
+ self.analysis_results = {}
33
+
34
+ # Extract graph region
35
+ sx, ex = bounds
36
+ self.graph_width = ex - sx
37
+ self.graph_image = image[:, sx:ex]
38
+
39
+ def analyze_all_zones(self) -> Dict[str, Any]:
40
+ """
41
+ Analyze all zones based on image content.
42
+
43
+ Returns:
44
+ Dictionary containing analysis results for each zone
45
+ """
46
+ if 'zones' not in self.zones_data:
47
+ return {'error': 'No zone data available'}
48
+
49
+ zones = self.zones_data['zones']
50
+
51
+ # Analyze each zone
52
+ for zone_name, zone_info in zones.items():
53
+ zone_image = self._extract_zone_image(zone_info)
54
+
55
+ if zone_image is not None and zone_image.shape[1] > 0:
56
+ analysis = self._analyze_zone_image(zone_name, zone_image, zone_info)
57
+ self.analysis_results[zone_name] = analysis
58
+
59
+ # Generate overall health assessment
60
+ overall_health = self._calculate_overall_health()
61
+ self.analysis_results['overall_health'] = overall_health
62
+
63
+ return self.analysis_results
64
+
65
+ def _extract_zone_image(self, zone_info: Dict) -> np.ndarray:
66
+ """Extract image region for a specific zone."""
67
+ start_ms = zone_info.get('start_ms', 0)
68
+ end_ms = zone_info.get('end_ms', 0)
69
+
70
+ # Convert time to pixel coordinates
71
+ start_x = int((start_ms / self.total_duration) * self.graph_width)
72
+ end_x = int((end_ms / self.total_duration) * self.graph_width)
73
+
74
+ # Ensure valid bounds
75
+ start_x = max(0, min(start_x, self.graph_width - 1))
76
+ end_x = max(start_x + 1, min(end_x, self.graph_width))
77
+
78
+ return self.graph_image[:, start_x:end_x]
79
+
80
+ def _analyze_zone_image(self, zone_name: str, zone_image: np.ndarray,
81
+ zone_info: Dict) -> Dict[str, Any]:
82
+ """
83
+ Analyze a zone based on its image content.
84
+
85
+ Args:
86
+ zone_name: Name of the zone
87
+ zone_image: Image region for this zone
88
+ zone_info: Zone metadata
89
+
90
+ Returns:
91
+ Dictionary with zone analysis results
92
+ """
93
+ analysis = {
94
+ 'zone_name': zone_name,
95
+ 'duration_ms': zone_info.get('end_ms', 0) - zone_info.get('start_ms', 0),
96
+ 'health_status': 'Unknown',
97
+ 'health_score': 0.0,
98
+ 'issues': [],
99
+ 'metrics': {}
100
+ }
101
+
102
+ # Extract color channels for each curve
103
+ red_mask = self._extract_color_mask(zone_image, 'red')
104
+ green_mask = self._extract_color_mask(zone_image, 'green')
105
+ blue_mask = self._extract_color_mask(zone_image, 'blue')
106
+
107
+ # Analyze based on zone type
108
+ if 'zone_1' in zone_name:
109
+ analysis.update(self._analyze_zone_1_image(zone_image, red_mask, green_mask, blue_mask))
110
+ elif 'zone_2' in zone_name:
111
+ analysis.update(self._analyze_zone_2_image(zone_image, red_mask, green_mask, blue_mask))
112
+ elif 'zone_3' in zone_name:
113
+ analysis.update(self._analyze_zone_3_image(zone_image, red_mask, green_mask, blue_mask))
114
+ elif 'zone_4' in zone_name:
115
+ analysis.update(self._analyze_zone_4_image(zone_image, red_mask, green_mask, blue_mask))
116
+ elif 'zone_5' in zone_name:
117
+ analysis.update(self._analyze_zone_5_image(zone_image, red_mask, green_mask, blue_mask))
118
+
119
+ return analysis
120
+
121
+ def _extract_color_mask(self, image: np.ndarray, color: str) -> np.ndarray:
122
+ """Extract mask for a specific color curve."""
123
+ hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
124
+
125
+ if color == 'red':
126
+ lower1 = np.array([0, 50, 50])
127
+ upper1 = np.array([10, 255, 255])
128
+ lower2 = np.array([170, 50, 50])
129
+ upper2 = np.array([180, 255, 255])
130
+ mask = cv2.bitwise_or(cv2.inRange(hsv, lower1, upper1),
131
+ cv2.inRange(hsv, lower2, upper2))
132
+ elif color == 'green':
133
+ lower = np.array([35, 50, 50])
134
+ upper = np.array([85, 255, 255])
135
+ mask = cv2.inRange(hsv, lower, upper)
136
+ elif color == 'blue':
137
+ lower = np.array([90, 50, 50])
138
+ upper = np.array([130, 255, 255])
139
+ mask = cv2.inRange(hsv, lower, upper)
140
+ else:
141
+ mask = np.zeros(image.shape[:2], dtype=np.uint8)
142
+
143
+ return mask
144
+
145
+ def _analyze_zone_1_image(self, zone_img, red_mask, green_mask, blue_mask):
146
+ """Analyze Zone 1 from image."""
147
+ metrics = {}
148
+ issues = []
149
+
150
+ # Check red curve (travel) progression
151
+ red_profile = self._get_vertical_profile(red_mask)
152
+ if len(red_profile) > 0:
153
+ # Travel should be present and relatively stable/increasing
154
+ red_coverage = np.sum(red_mask > 0) / red_mask.size * 100
155
+ metrics['travel_coverage_pct'] = float(red_coverage)
156
+
157
+ if red_coverage < 5:
158
+ issues.append('Low travel signal visibility - possible data quality issue')
159
+
160
+ # Check blue curve (current) - should be low/baseline
161
+ blue_profile = self._get_vertical_profile(blue_mask)
162
+ if len(blue_profile) > 0:
163
+ blue_coverage = np.sum(blue_mask > 0) / blue_mask.size * 100
164
+ metrics['current_coverage_pct'] = float(blue_coverage)
165
+
166
+ # Current should start rising towards end
167
+ if blue_coverage > 20:
168
+ issues.append('High current activity - possible early contact')
169
+
170
+ health_score = self._calculate_image_health_score(metrics, issues)
171
+
172
+ return {
173
+ 'metrics': metrics,
174
+ 'issues': issues,
175
+ 'health_score': health_score,
176
+ 'health_status': self._get_health_status(health_score)
177
+ }
178
+
179
+ def _analyze_zone_2_image(self, zone_img, red_mask, green_mask, blue_mask):
180
+ """Analyze Zone 2 from image - Arcing engagement."""
181
+ metrics = {}
182
+ issues = []
183
+
184
+ # Check green curve (resistance) for spikes
185
+ green_profile = self._get_vertical_profile(green_mask)
186
+ if len(green_profile) > 0:
187
+ # Detect spikes in resistance
188
+ spike_count = self._count_spikes_in_mask(green_mask)
189
+ metrics['resistance_spike_count'] = spike_count
190
+
191
+ green_coverage = np.sum(green_mask > 0) / green_mask.size * 100
192
+ metrics['resistance_coverage_pct'] = float(green_coverage)
193
+
194
+ # Check for excessive spiking
195
+ if spike_count > 10:
196
+ issues.append(f'Excessive resistance spikes ({spike_count}) - possible contact damage')
197
+
198
+ # Check vertical spread (indicates spike height)
199
+ vertical_spread = self._get_vertical_spread(green_mask)
200
+ metrics['resistance_vertical_spread'] = float(vertical_spread)
201
+
202
+ if vertical_spread > zone_img.shape[0] * 0.5:
203
+ issues.append('Very high resistance spikes - severe arcing')
204
+
205
+ # Check blue curve (current) activity
206
+ blue_coverage = np.sum(blue_mask > 0) / blue_mask.size * 100
207
+ metrics['current_coverage_pct'] = float(blue_coverage)
208
+
209
+ health_score = self._calculate_image_health_score(metrics, issues)
210
+
211
+ return {
212
+ 'metrics': metrics,
213
+ 'issues': issues,
214
+ 'health_score': health_score,
215
+ 'health_status': self._get_health_status(health_score)
216
+ }
217
+
218
+ def _analyze_zone_3_image(self, zone_img, red_mask, green_mask, blue_mask):
219
+ """Analyze Zone 3 from image - Main conduction (most critical)."""
220
+ metrics = {}
221
+ issues = []
222
+
223
+ # Green curve (resistance) should be low and stable
224
+ if np.sum(green_mask) > 0:
225
+ # Check vertical spread (should be minimal - flat line)
226
+ vertical_spread = self._get_vertical_spread(green_mask)
227
+ metrics['resistance_vertical_spread'] = float(vertical_spread)
228
+
229
+ # Calculate stability (lower spread = more stable)
230
+ height = zone_img.shape[0]
231
+ stability_score = max(0, 100 - (vertical_spread / height * 100))
232
+ metrics['resistance_stability_score'] = float(stability_score)
233
+
234
+ if vertical_spread > height * 0.15:
235
+ issues.append(f'Unstable resistance (spread: {vertical_spread:.0f}px) - poor contact quality')
236
+
237
+ # Check for oscillations
238
+ oscillation_count = self._count_oscillations(green_mask)
239
+ metrics['resistance_oscillation_count'] = oscillation_count
240
+
241
+ if oscillation_count > 5:
242
+ issues.append(f'Excessive oscillations ({oscillation_count}) - contact bouncing')
243
+
244
+ # Check coverage (should be continuous)
245
+ green_coverage = np.sum(green_mask > 0) / green_mask.size * 100
246
+ metrics['resistance_coverage_pct'] = float(green_coverage)
247
+
248
+ if green_coverage < 10:
249
+ issues.append('Low resistance signal - possible data extraction issue')
250
+
251
+ # Red curve (travel) should be stable at plateau
252
+ if np.sum(red_mask) > 0:
253
+ travel_spread = self._get_vertical_spread(red_mask)
254
+ metrics['travel_vertical_spread'] = float(travel_spread)
255
+
256
+ if travel_spread > height * 0.1:
257
+ issues.append('Travel not stable - mechanical issue during conduction')
258
+
259
+ health_score = self._calculate_image_health_score(metrics, issues)
260
+
261
+ return {
262
+ 'metrics': metrics,
263
+ 'issues': issues,
264
+ 'health_score': health_score,
265
+ 'health_status': self._get_health_status(health_score)
266
+ }
267
+
268
+ def _analyze_zone_4_image(self, zone_img, red_mask, green_mask, blue_mask):
269
+ """Analyze Zone 4 from image - Parting."""
270
+ metrics = {}
271
+ issues = []
272
+
273
+ # Green curve (resistance) should be increasing
274
+ if np.sum(green_mask) > 0:
275
+ # Check for upward trend
276
+ green_profile = self._get_vertical_profile(green_mask)
277
+ if len(green_profile) > 2:
278
+ # Compare left vs right side vertical positions
279
+ left_avg = np.mean(green_profile[:len(green_profile)//3])
280
+ right_avg = np.mean(green_profile[-len(green_profile)//3:])
281
+
282
+ # Lower pixel value = higher on graph
283
+ if left_avg < right_avg:
284
+ metrics['resistance_trend'] = 'decreasing'
285
+ issues.append('Resistance decreasing during parting - abnormal behavior')
286
+ else:
287
+ metrics['resistance_trend'] = 'increasing'
288
+
289
+ # Check for parting spikes
290
+ spike_count = self._count_spikes_in_mask(green_mask)
291
+ metrics['parting_spike_count'] = spike_count
292
+
293
+ vertical_spread = self._get_vertical_spread(green_mask)
294
+ metrics['resistance_vertical_spread'] = float(vertical_spread)
295
+
296
+ if spike_count > 15:
297
+ issues.append(f'Excessive parting spikes ({spike_count}) - severe arcing')
298
+
299
+ # Red curve (travel) should be decreasing (opening)
300
+ if np.sum(red_mask) > 0:
301
+ red_profile = self._get_vertical_profile(red_mask)
302
+ if len(red_profile) > 2:
303
+ left_avg = np.mean(red_profile[:len(red_profile)//3])
304
+ right_avg = np.mean(red_profile[-len(red_profile)//3:])
305
+
306
+ # Higher pixel value = lower on graph (opening)
307
+ if left_avg > right_avg:
308
+ issues.append('Travel not decreasing - mechanical opening issue')
309
+
310
+ health_score = self._calculate_image_health_score(metrics, issues)
311
+
312
+ return {
313
+ 'metrics': metrics,
314
+ 'issues': issues,
315
+ 'health_score': health_score,
316
+ 'health_status': self._get_health_status(health_score)
317
+ }
318
+
319
+ def _analyze_zone_5_image(self, zone_img, red_mask, green_mask, blue_mask):
320
+ """Analyze Zone 5 from image - Final open state."""
321
+ metrics = {}
322
+ issues = []
323
+
324
+ # Green curve (resistance) should be high and stable
325
+ if np.sum(green_mask) > 0:
326
+ vertical_spread = self._get_vertical_spread(green_mask)
327
+ metrics['resistance_vertical_spread'] = float(vertical_spread)
328
+
329
+ if vertical_spread > zone_img.shape[0] * 0.1:
330
+ issues.append('Unstable final resistance - incomplete opening')
331
+
332
+ green_coverage = np.sum(green_mask > 0) / green_mask.size * 100
333
+ metrics['resistance_coverage_pct'] = float(green_coverage)
334
+
335
+ # Blue curve (current) should be minimal
336
+ if np.sum(blue_mask) > 0:
337
+ blue_coverage = np.sum(blue_mask > 0) / blue_mask.size * 100
338
+ metrics['current_coverage_pct'] = float(blue_coverage)
339
+
340
+ if blue_coverage > 10:
341
+ issues.append('Elevated current in open state - possible leakage')
342
+
343
+ health_score = self._calculate_image_health_score(metrics, issues)
344
+
345
+ return {
346
+ 'metrics': metrics,
347
+ 'issues': issues,
348
+ 'health_score': health_score,
349
+ 'health_status': self._get_health_status(health_score)
350
+ }
351
+
352
+ def _get_vertical_profile(self, mask: np.ndarray) -> np.ndarray:
353
+ """Get vertical position profile across horizontal axis."""
354
+ profile = []
355
+ for x in range(mask.shape[1]):
356
+ col = mask[:, x]
357
+ if np.sum(col) > 0:
358
+ # Get center of mass in this column
359
+ indices = np.where(col > 0)[0]
360
+ center = np.mean(indices)
361
+ profile.append(center)
362
+ return np.array(profile)
363
+
364
+ def _get_vertical_spread(self, mask: np.ndarray) -> float:
365
+ """Calculate vertical spread of a mask (height of signal)."""
366
+ if np.sum(mask) == 0:
367
+ return 0.0
368
+
369
+ # Find min and max y coordinates where mask is active
370
+ y_coords = np.where(mask > 0)[0]
371
+ if len(y_coords) == 0:
372
+ return 0.0
373
+
374
+ return float(np.max(y_coords) - np.min(y_coords))
375
+
376
+ def _count_spikes_in_mask(self, mask: np.ndarray) -> int:
377
+ """Count number of spikes in a mask."""
378
+ profile = self._get_vertical_profile(mask)
379
+ if len(profile) < 3:
380
+ return 0
381
+
382
+ # Detect peaks
383
+ spike_count = 0
384
+ for i in range(1, len(profile) - 1):
385
+ # Peak if lower than neighbors (remember: lower y = higher on graph)
386
+ if profile[i] < profile[i-1] and profile[i] < profile[i+1]:
387
+ # Check if significant
388
+ if abs(profile[i] - profile[i-1]) > 5 or abs(profile[i] - profile[i+1]) > 5:
389
+ spike_count += 1
390
+
391
+ return spike_count
392
+
393
+ def _count_oscillations(self, mask: np.ndarray) -> int:
394
+ """Count oscillations in the signal."""
395
+ profile = self._get_vertical_profile(mask)
396
+ if len(profile) < 5:
397
+ return 0
398
+
399
+ # Simple moving average smoothing (no scipy needed)
400
+ window_size = min(5, len(profile) // 3)
401
+ if window_size < 2:
402
+ smoothed = profile
403
+ else:
404
+ smoothed = np.convolve(profile, np.ones(window_size)/window_size, mode='same')
405
+
406
+ # Count direction changes
407
+ oscillations = 0
408
+ direction = 0 # 0: none, 1: up, -1: down
409
+
410
+ for i in range(1, len(smoothed)):
411
+ diff = smoothed[i] - smoothed[i-1]
412
+ if abs(diff) > 2: # Threshold for significant change
413
+ new_direction = 1 if diff > 0 else -1
414
+ if direction != 0 and new_direction != direction:
415
+ oscillations += 1
416
+ direction = new_direction
417
+
418
+ return oscillations
419
+
420
+ def _calculate_image_health_score(self, metrics: Dict, issues: List[str]) -> float:
421
+ """Calculate health score based on image analysis."""
422
+ score = 100.0
423
+
424
+ # Deduct for issues
425
+ score -= len(issues) * 15
426
+
427
+ # Additional deductions based on metrics
428
+ if 'resistance_vertical_spread' in metrics:
429
+ spread = metrics['resistance_vertical_spread']
430
+ if spread > 100:
431
+ score -= 20
432
+ elif spread > 50:
433
+ score -= 10
434
+
435
+ if 'resistance_spike_count' in metrics:
436
+ spikes = metrics['resistance_spike_count']
437
+ if spikes > 15:
438
+ score -= 25
439
+ elif spikes > 10:
440
+ score -= 15
441
+
442
+ return max(0.0, min(100.0, score))
443
+
444
+ def _get_health_status(self, score: float) -> str:
445
+ """Convert health score to status label."""
446
+ if score >= 85:
447
+ return 'Excellent'
448
+ elif score >= 70:
449
+ return 'Good'
450
+ elif score >= 50:
451
+ return 'Fair'
452
+ elif score >= 30:
453
+ return 'Poor'
454
+ else:
455
+ return 'Critical'
456
+
457
+ def _calculate_overall_health(self) -> Dict[str, Any]:
458
+ """Calculate overall health assessment."""
459
+ if not self.analysis_results:
460
+ return {'status': 'No data', 'score': 0.0}
461
+
462
+ zone_scores = []
463
+ all_issues = []
464
+
465
+ for zone_name, analysis in self.analysis_results.items():
466
+ if isinstance(analysis, dict) and 'health_score' in analysis:
467
+ zone_scores.append(analysis['health_score'])
468
+ all_issues.extend(analysis.get('issues', []))
469
+
470
+ if not zone_scores:
471
+ return {'status': 'Unknown', 'score': 0.0}
472
+
473
+ # Weighted average
474
+ weights = {
475
+ 'zone_1_pre_contact': 0.15,
476
+ 'zone_2_arcing_engagement': 0.20,
477
+ 'zone_3_main_conduction': 0.35,
478
+ 'zone_4_parting': 0.20,
479
+ 'zone_5_final_open': 0.10
480
+ }
481
+
482
+ weighted_score = 0.0
483
+ total_weight = 0.0
484
+
485
+ for zone_name, analysis in self.analysis_results.items():
486
+ if isinstance(analysis, dict) and 'health_score' in analysis:
487
+ weight = weights.get(zone_name, 0.2)
488
+ weighted_score += analysis['health_score'] * weight
489
+ total_weight += weight
490
+
491
+ overall_score = weighted_score / total_weight if total_weight > 0 else 0.0
492
+
493
+ return {
494
+ 'overall_score': round(overall_score, 2),
495
+ 'status': self._get_health_status(overall_score),
496
+ 'total_issues': len(all_issues),
497
+ 'critical_issues': [issue for issue in all_issues if 'severe' in issue.lower() or 'critical' in issue.lower()],
498
+ 'recommendation': self._generate_recommendation(overall_score, all_issues)
499
+ }
500
+
501
+ def _generate_recommendation(self, score: float, issues: List[str]) -> str:
502
+ """Generate maintenance recommendation."""
503
+ if score >= 85:
504
+ return 'Circuit breaker is in excellent condition. Continue regular monitoring.'
505
+ elif score >= 70:
506
+ return 'Circuit breaker is in good condition. Schedule routine maintenance as planned.'
507
+ elif score >= 50:
508
+ return 'Circuit breaker shows signs of wear. Increase monitoring frequency and plan maintenance.'
509
+ elif score >= 30:
510
+ return 'Circuit breaker condition is poor. Schedule maintenance soon to prevent failure.'
511
+ else:
512
+ return 'CRITICAL: Circuit breaker requires immediate attention. Risk of failure is high.'
dcrm/llm.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # llm.py
2
+ import google.generativeai as genai
3
+ import json
4
+ import PIL.Image
5
+ import io
6
+
7
+ def get_dcrm_prompt(data_str):
8
+ return f"""
9
+ I have extracted data from a DCRM (Dynamic Contact Resistance Measurement) graph.
10
+ Data (Sampled): {data_str}
11
+
12
+ The columns are:
13
+ - 'time': Time in milliseconds.
14
+ - 'curr': Current signal amplitude (Blue curve) - represents the test current flowing through the contacts.
15
+ - 'res': Dynamic Resistance amplitude (Green curve) - represents the contact resistance in micro-ohms (¡Ω).
16
+ - 'travel': Travel signal amplitude (Red curve) - represents the mechanical position/displacement of the moving contact.
17
+
18
+ IMPORTANT: Higher values mean the signal is HIGHER on the graph.
19
+
20
+ I have also provided the image of the graph. Use the visual information from the image to cross-reference with the data.
21
+
22
+ === HEALTHY DCRM SIGNATURE REFERENCE ===
23
+
24
+ **Resistance (Green) - Healthy Characteristics:**
25
+ - Pre-contact: Infinite/Very High (off-scale or flat at top)
26
+ - Arcing engagement: Drops sharply with moderate spikes (arcing activity), typically 100-500 ¡Ω
27
+ - Main conduction: LOW and STABLE (30-80 ¡Ω for healthy contacts), minimal oscillation (<10 ¡Ω variance)
28
+ - Parting: Sharp rise with spikes (arcing during separation)
29
+ - Final open: Returns to infinite/very high (off-scale)
30
+
31
+ **Current (Blue) - Healthy Characteristics:**
32
+ - Pre-contact: Near zero baseline
33
+ - Arcing engagement: Begins rising as circuit closes
34
+ - Main conduction: Stable at test current level (plateau)
35
+ - Parting: Maintained until final separation
36
+ - Final open: Drops to zero
37
+
38
+ **Travel (Red) - Healthy Characteristics:**
39
+ - Pre-contact: Increasing linearly (contacts approaching)
40
+ - Arcing engagement: Continues increasing
41
+ - Main conduction: Reaches MAXIMUM and plateaus (fully closed position)
42
+ - Parting: Decreases linearly (contacts separating)
43
+ - Final open: Stabilizes at minimum (fully open position)
44
+
45
+ === TASK: SEGMENT INTO 5 KINEMATIC ZONES ===
46
+
47
+ Use ALL THREE curves together for accurate boundary detection. Each zone represents a distinct physical state of the circuit breaker.
48
+
49
+ **Zone 1: Pre-Contact Travel (Initial Closing Motion)**
50
+ * **Physical Meaning**: The moving contact is traveling toward the stationary contact but has NOT yet made electrical contact. This is pure mechanical motion with no current flow.
51
+ * **Start**: time = 0 ms
52
+ * **End Boundary**: Detect when CURRENT (blue) FIRST starts rising significantly from baseline.
53
+ * Cross-reference: Resistance (green) should still be very high/infinite
54
+ * Cross-reference: Travel (red) should be steadily increasing
55
+ * **Typical Duration**: 80-120 ms
56
+ * **Detection Logic**: Find the point where 'curr' rises above baseline noise (e.g., >5% of max current)
57
+
58
+ **Zone 2: Arcing Contact Engagement (Initial Electrical Contact)**
59
+ * **Physical Meaning**: The arcing contacts (W-Cu tips) make first contact and establish an electrical path. Current begins flowing through a small contact area, causing arcing and resistance fluctuations. This is the "make" transition.
60
+ * **Start**: End of Zone 1
61
+ * **End Boundary**: Detect when resistance SETTLES after initial spike activity.
62
+ * Primary indicator: Resistance (green) drops from high values, exhibits spikes, then STABILIZES to low plateau
63
+ * Cross-reference: Current (blue) should be rising/stabilizing
64
+ * Cross-reference: Travel (red) continues increasing toward maximum
65
+ * **Typical Duration**: 20-40 ms (Zone 2 typically ends around 110-150 ms total time)
66
+ * **Detection Logic**: Find where 'res' completes its descent and spike activity, settling into a stable low range
67
+
68
+ **Zone 3: Main Contact Conduction (Fully Closed State)**
69
+ * **Physical Meaning**: The main contacts (Ag-plated) are fully engaged, providing a large, stable contact area. This is the "healthy contact" signature zone - resistance should be at its MINIMUM and STABLE. The breaker is in its fully closed, current-carrying state.
70
+ * **Start**: End of Zone 2
71
+ * **End Boundary**: Detect when the breaker begins OPENING (travel reverses direction).
72
+ * Primary indicator: Travel (red) reaches MAXIMUM and starts to DESCEND
73
+ * Cross-reference: Resistance (green) should remain low and stable throughout this zone
74
+ * Cross-reference: Current (blue) should be stable at test level
75
+ * **Typical Duration**: 100-200 ms (this is the longest zone, representing the dwell time)
76
+ * **Detection Logic**: Find the peak of 'travel' curve and the point where it starts decreasing
77
+
78
+ **Zone 4: Main Contact Parting (Breaking/Opening Transition)**
79
+ * **Physical Meaning**: The main contacts are separating. As the contact area decreases, resistance rises sharply. Arcing occurs during the final separation of the arcing contacts. This is the "break" transition - the most critical phase for fault detection.
80
+ * **Start**: End of Zone 3
81
+ * **End Boundary**: Detect when resistance STABILIZES at high value after parting spikes.
82
+ * Primary indicator: Resistance (green) shoots UP, exhibits parting spikes, then STABILIZES at high/infinite value
83
+ * Cross-reference: Travel (red) should be decreasing (opening motion)
84
+ * Cross-reference: Current (blue) may drop or fluctuate during final arc extinction
85
+ * **Typical Duration**: 40-80 ms (Zone 4 typically ends around 280-340 ms total time)
86
+ * **Detection Logic**: Find where 'res' completes its rise and spike activity, becoming constant at high value
87
+ * **CRITICAL**: Do NOT extend this zone too long - end AS SOON AS resistance stabilizes
88
+
89
+ **Zone 5: Final Open State (Fully Open)**
90
+ * **Physical Meaning**: The contacts are fully separated with an air gap. No current flows, resistance is infinite. The breaker is in its fully open, non-conducting state.
91
+ * **Start**: End of Zone 4
92
+ * **End**: The last time point in the dataset
93
+ * **Characteristics**:
94
+ * Resistance (green): Very high/infinite (flat line at top)
95
+ * Current (blue): Zero or near-zero
96
+ * Travel (red): Stable at minimum (fully open position)
97
+
98
+ **MULTI-CURVE ANALYSIS STRATEGY:**
99
+ 1. Use Current (blue) to identify Zone 1 β†’ Zone 2 transition (first current rise)
100
+ 2. Use Resistance (green) to identify Zone 2 β†’ Zone 3 transition (resistance settles to low plateau)
101
+ 3. Use Travel (red) to identify Zone 3 β†’ Zone 4 transition (travel peak and reversal)
102
+ 4. Use Resistance (green) to identify Zone 4 β†’ Zone 5 transition (resistance stabilizes at high value)
103
+ 5. Always cross-validate boundaries using all three curves for consistency
104
+
105
+ **OUTPUT FORMAT (Strict JSON)**
106
+ Return ONLY this JSON object:
107
+ {{
108
+ "zones": {{
109
+ "zone_1_pre_contact": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }},
110
+ "zone_2_arcing_engagement": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }},
111
+ "zone_3_main_conduction": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }},
112
+ "zone_4_parting": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }},
113
+ "zone_5_final_open": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }}
114
+ }},
115
+ "report_card": {{
116
+ "opening_speed": {{ "status": "Pass"|"Warning"|"Fail", "comment": "Assessment of travel curve steepness" }},
117
+ "contact_wear": {{ "status": "Pass"|"Warning"|"Fail", "comment": "Based on resistance fluctuations in Zone 2/4" }},
118
+ "timing_consistency": {{ "status": "Pass"|"Warning"|"Fail", "comment": "Are phases within expected ranges?" }},
119
+ "overall_health": {{ "status": "Healthy"|"Needs Review"|"Critical", "comment": "Overall summary" }}
120
+ }},
121
+ "detailed_analysis": "Provide a comprehensive technical analysis (in Markdown)..."
122
+ }}
123
+ """
124
+
125
+ def ask_llm_for_breakage(df, api_key, model_name, image_bytes=None):
126
+ """
127
+ Sends the DataFrame and optional image to LLM (Gemini) for segmentation.
128
+ Returns (df, result_json) where df has a new 'Zone' column.
129
+ """
130
+ if not api_key: return df, None
131
+
132
+ try:
133
+ genai.configure(api_key=api_key)
134
+
135
+ # Configure safety settings
136
+ safety_settings = [
137
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
138
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
139
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
140
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
141
+ ]
142
+
143
+ model = genai.GenerativeModel(
144
+ model_name=model_name,
145
+ safety_settings=safety_settings
146
+ )
147
+ except Exception as e:
148
+ return df, {"error": f"Failed to initialize Gemini API: {str(e)}"}
149
+
150
+ # Prepare Data
151
+ # Rename columns for LLM clarity
152
+ df_llm = df[['Time (ms)', 'Current', 'Resistance', 'Travel']].copy()
153
+ df_llm.columns = ['time', 'curr', 'res', 'travel']
154
+
155
+ # Round values
156
+ df_llm = df_llm.round(1)
157
+
158
+ # Sample to keep prompt size manageable (e.g., every 5th row)
159
+ # User's code used df.to_string(index=False), implying they might not have sampled,
160
+ # but for safety with large CSVs, we'll keep sampling but use to_string format.
161
+ df_sampled = df_llm.iloc[::5, :]
162
+
163
+ data_str = df_sampled.to_string(index=False)
164
+
165
+ prompt = get_dcrm_prompt(data_str)
166
+
167
+ content = [prompt]
168
+ if image_bytes:
169
+ try:
170
+ image = PIL.Image.open(io.BytesIO(image_bytes))
171
+ content.append(image)
172
+ except Exception as e:
173
+ return df, {"error": f"Failed to process image: {str(e)}"}
174
+
175
+ try:
176
+ response = model.generate_content(content)
177
+
178
+ if not response.text:
179
+ if hasattr(response, 'prompt_feedback'):
180
+ return df, {
181
+ "error": "Response blocked by safety filters",
182
+ "raw_response": str(response.prompt_feedback)
183
+ }
184
+ return df, {"error": "LLM returned empty response"}
185
+
186
+ result = response.text.strip()
187
+
188
+ # Remove markdown code blocks
189
+ if "```json" in result:
190
+ result = result.split("```json")[1].split("```")[0].strip()
191
+ elif "```" in result:
192
+ result = result.split("```")[1].split("```")[0].strip()
193
+
194
+ # Parse JSON
195
+ try:
196
+ result_json = json.loads(result)
197
+ zones = result_json.get("zones", {})
198
+
199
+ # Enrich DataFrame with Zones
200
+ df['Zone'] = "Unknown"
201
+
202
+ for zone_name, details in zones.items():
203
+ start = details.get("start_ms")
204
+ end = details.get("end_ms")
205
+ if start is not None and end is not None:
206
+ # Map zone name to a simpler label (e.g., "Zone 1")
207
+ short_name = zone_name.split('_')[1] # "1", "2", etc.
208
+ mask = (df['Time (ms)'] >= start) & (df['Time (ms)'] <= end)
209
+ df.loc[mask, 'Zone'] = f"Zone {short_name}"
210
+
211
+ return df, result_json
212
+
213
+ except json.JSONDecodeError as je:
214
+ return df, {
215
+ "error": f"JSON parsing failed: {str(je)}",
216
+ "raw_response": result[:1000]
217
+ }
218
+
219
+ except Exception as e:
220
+ return df, {"error": f"LLM API error: {str(e)}"}
221
+
222
+ def analyze_health_with_llm(image_bytes, api_key, model_name, numerical_context=None):
223
+ """
224
+ Sends the DCRM image to Gemini for expert diagnostic analysis.
225
+ Numerical context is a dict of extracted values (e.g. min resistance) to prevent hallucination.
226
+ """
227
+ if not api_key or not image_bytes: return None
228
+
229
+ try:
230
+ genai.configure(api_key=api_key)
231
+
232
+ safety_settings = [
233
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
234
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
235
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
236
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
237
+ ]
238
+
239
+ model = genai.GenerativeModel(
240
+ model_name=model_name,
241
+ safety_settings=safety_settings
242
+ )
243
+
244
+ # Build context string
245
+ context_str = ""
246
+ if numerical_context:
247
+ context_str = f"""
248
+ NUMERICAL DATA CONTEXT (From Raw Extraction):
249
+ - Minimum Static Resistance Found: {numerical_context.get('min_resistance', 'N/A')} ¡Ω
250
+ - Median Resistance Found: {numerical_context.get('median_resistance', 'N/A')} ¡Ω
251
+
252
+ NOTE: If the extracted resistance is HIGH (e.g. >200 uOhm) but the curve looks flat and healthy,
253
+ it indicates the data extraction scale is uncalibrated, but the relative health is good.
254
+ Trust the SHAPE (flatness/noise) over the absolute number if they conflict, but mention the value.
255
+ """
256
+
257
+ prompt = f"""
258
+ System Role: Principal DCRM & Kinematic Analyst
259
+ Role:
260
+ You are an expert High-Voltage Circuit Breaker Diagnostician. Your task is to interpret Dynamic Contact Resistance (DCRM) traces to detect specific electrical and mechanical faults.
261
+
262
+ {context_str}
263
+
264
+ Critical "Anti-Overfitting" Directive:
265
+ You must distinguish between Systematic Defects and Artifacts.
266
+ Sensor/Manufacturing Noise: A totally flat line is rare in real-world data. Slight "fuzz" or very minute "grassiness" (amplitude < 10 ΞΌΞ©) is often sensor noise, ADC quantization, or normal manufacturing surface variance. Do not flag this as a defect.
267
+ True Degradation: Flag issues only when the visual signature is statistically significant and exceeds the "noise floor."
268
+
269
+ Capability:
270
+ Identify Multiple Concurrent Issues if present. (e.g., A breaker can have both misalignment and contact wear).
271
+ there will mostly be 3 line charts in the input
272
+ green resistance profile
273
+ blue current profile
274
+ red travel profile
275
+
276
+ 1. Diagnostic Heuristics & Defect Taxonomy
277
+ Map the visual DCRM trace to ONLY the following defect types. Use the specific Visual Heuristics to confirm detection.
278
+
279
+ Defect Type | Visual Heuristic (The "Hint") | Mechanical Significance (Root Cause)
280
+ --- | --- | ---
281
+ Main Contact Issue (Corrosion/Oxidation) | "The Significant Grass"<br>In the fully closed plateau, look for pronounced, erratic instability. <br>β€’ Ignore: Uniform, low-amplitude fuzz (sensor noise).<br>β€’ Flag: Jagged, irregular peaks/valleys with significant amplitude (e.g., > 15–20 ΞΌΞ© variance). The trace looks like a "rough rocky road," not just a "gravel path." | Surface Pathology: The Silver (Ag) plating is compromised (fretting corrosion) or heavy oxidation has occurred. The current path is constantly shifting through microscopic non-conductive spots.
282
+ Arcing Contact Wear | "Big Spikes & Short Wipe"<br>Resistance spikes are frequent and significantly large (high amplitude). Crucially, the duration of the arcing zone (the time between first touch and main contact touch) is noticeably shorter than expected. | Ablation: The Tungsten-Copper (W-Cu) tips are heavily eroded. The contact length has physically diminished, risking failure to commutate current during opening.
283
+ Misalignment (Main) | "The Struggle to Settle"<br>There are significant, high-amplitude peaks just before the trace tries to settle into the stable plateau. These are not bounces; they are "struggles" to mate that persist longer than 3-5ms. | Mechanical Centering: The moving contact pin is hitting the side or edge of the stationary rosette fingers before forcing its way in. Caused by loose nuts, kinematic play, or guide ring failure.
284
+ Misalignment (Arcing) | "Rough Entry"<br>Erratic resistance spikes occurring specifically during the initial entry (commutation), well before the main contacts engage. | Tip Eccentricity: The arcing pin is not entering the nozzle concentrically. It is scraping the nozzle throat or hitting the side, indicating a bent rod or skewed interrupter.
285
+ Slow Mechanism | "Stretched Time"<br>The entire resistance profile is elongated along the X-axis. Events happen later than normal. | Energy Starvation: Low spring charge, hydraulic pressure loss, or high friction due to hardened grease in the linkage.
286
+
287
+ 2. Analysis Logic (The "Signal-to-Noise" Filter)
288
+ Before declaring a defect, run these logic checks:
289
+ The "Noise Floor" Test (For Main Contacts):
290
+ Is the plateau variance uniform and small (< 10 ΞΌΞ©)? -> Classify as Healthy (Sensor/Manufacturing artifact).
291
+ Is the variance erratic, jagged, and large (> 15 ΞΌΞ©)? -> Classify as Corrosion/Oxidation.
292
+ The "Duration" Test (For Misalignment):
293
+ Are the pre-plateau peaks < 2ms? -> Ignore (Benign Bounce).
294
+ Do the peaks persist > 3-5ms before settling? -> Classify as Misalignment.
295
+ The "Combination" Check:
296
+ Does the trace show both "Rough Entry" AND "Stretched Time"? -> Report Both (Misalignment + Slow Mechanism).
297
+
298
+ 3. Output Structure
299
+ Provide a concise Executive Lead followed by the JSON.
300
+
301
+ Executive Lead (3-4 Lines)
302
+ Status: Healthy | Warning | Critical.
303
+ Key Findings: Summary of valid defects found (ignoring sensor noise).
304
+ Action: "Return to service" or specific repair instruction.
305
+
306
+ JSON Schema
307
+ ```json
308
+ {
309
+ "image_url": "string",
310
+ "overall_condition": "Healthy|Warning|Critical",
311
+ "health_score": "integer (0-100) where 100 is perfect condition",
312
+ "detected_issues": [
313
+ {
314
+ "issue_type": "Main Contact Issue (Corrosion/Oxidation)|Arcing Contact Wear|Misalignment (Main)|Misalignment (Arcing)|Slow Mechanism",
315
+ "confidence": "High|Medium|Low",
316
+ "visual_evidence": "string (e.g., 'Plateau instability >20 micro-ohms detected, exceeding sensor noise threshold.')",
317
+ "mechanical_significance": "string (Root cause from table)",
318
+ "severity": "Low|Medium|High"
319
+ }
320
+ ],
321
+ "analysis_metrics": {
322
+ "static_resistance_Rp_uOhm": "float",
323
+ "signal_noise_level": "Low (Sensor/Mfg)|High (Defect)",
324
+ "wipe_quality": "Normal|Short|Erratic"
325
+ },
326
+ "maintenance_recommendation": "string"
327
+ }
328
+ ```
329
+ """
330
+
331
+ image = PIL.Image.open(io.BytesIO(image_bytes))
332
+
333
+ response = model.generate_content([prompt, image])
334
+
335
+ if not response.text:
336
+ return {"error": "LLM returned empty response"}
337
+
338
+ return response.text
339
+
340
+ except Exception as e:
341
+ return {"error": f"LLM Analysis Error: {str(e)}"}
dcrm/llm_copy.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import google.generativeai as genai
2
+ import json
3
+ import PIL.Image
4
+ import io
5
+
6
+ def get_dcrm_prompt(data_str):
7
+ return f"""
8
+ I have extracted data from a DCRM (Dynamic Contact Resistance Measurement) graph.
9
+ Data (Sampled): {data_str}
10
+
11
+ The columns are:
12
+ - 'time': Time in milliseconds.
13
+ - 'curr': Current signal amplitude (Blue curve) - represents the test current flowing through the contacts.
14
+ - 'res': Dynamic Resistance amplitude (Green curve) - represents the contact resistance in micro-ohms (¡Ω).
15
+ - 'travel': Travel signal amplitude (Red curve) - represents the mechanical position/displacement of the moving contact.
16
+
17
+ IMPORTANT: Higher values mean the signal is HIGHER on the graph.
18
+
19
+ I have also provided the image of the graph. Use the visual information from the image to cross-reference with the data.
20
+
21
+ === HEALTHY DCRM SIGNATURE REFERENCE ===
22
+
23
+ **Resistance (Green) - Healthy Characteristics:**
24
+ - Pre-contact: Infinite/Very High (off-scale or flat at top)
25
+ - Arcing engagement: Drops sharply with moderate spikes (arcing activity), typically 100-500 ¡Ω
26
+ - Main conduction: LOW and STABLE (30-80 ¡Ω for healthy contacts), minimal oscillation (<10 ¡Ω variance)
27
+ - Parting: Sharp rise with spikes (arcing during separation)
28
+ - Final open: Returns to infinite/very high (off-scale)
29
+
30
+ **Current (Blue) - Healthy Characteristics:**
31
+ - Pre-contact: Near zero baseline
32
+ - Arcing engagement: Begins rising as circuit closes
33
+ - Main conduction: Stable at test current level (plateau)
34
+ - Parting: Maintained until final separation
35
+ - Final open: Drops to zero
36
+
37
+ **Travel (Red) - Healthy Characteristics:**
38
+ - Pre-contact: Increasing linearly (contacts approaching)
39
+ - Arcing engagement: Continues increasing
40
+ - Main conduction: Reaches MAXIMUM and plateaus (fully closed position)
41
+ - Parting: Decreases linearly (contacts separating)
42
+ - Final open: Stabilizes at minimum (fully open position)
43
+
44
+ === TASK: SEGMENT INTO 5 KINEMATIC ZONES ===
45
+
46
+ Use ALL THREE curves together for accurate boundary detection. Each zone represents a distinct physical state of the circuit breaker.
47
+
48
+ **Zone 1: Pre-Contact Travel (Initial Closing Motion)**
49
+ * **Physical Meaning**: The moving contact is traveling toward the stationary contact but has NOT yet made electrical contact. This is pure mechanical motion with no current flow.
50
+ * **Start**: time = 0 ms
51
+ * **End Boundary**: Detect when CURRENT (blue) FIRST starts rising significantly from baseline.
52
+ * Cross-reference: Resistance (green) should still be very high/infinite
53
+ * Cross-reference: Travel (red) should be steadily increasing
54
+ * **Typical Duration**: 80-120 ms
55
+ * **Detection Logic**: Find the point where 'curr' rises above baseline noise (e.g., >5% of max current)
56
+
57
+ **Zone 2: Arcing Contact Engagement (Initial Electrical Contact)**
58
+ * **Physical Meaning**: The arcing contacts (W-Cu tips) make first contact and establish an electrical path. Current begins flowing through a small contact area, causing arcing and resistance fluctuations. This is the "make" transition.
59
+ * **Start**: End of Zone 1
60
+ * **End Boundary**: Detect when resistance SETTLES after initial spike activity.
61
+ * Primary indicator: Resistance (green) drops from high values, exhibits spikes, then STABILIZES to low plateau
62
+ * Cross-reference: Current (blue) should be rising/stabilizing
63
+ * Cross-reference: Travel (red) continues increasing toward maximum
64
+ * **Typical Duration**: 20-40 ms (Zone 2 typically ends around 110-150 ms total time)
65
+ * **Detection Logic**: Find where 'res' completes its descent and spike activity, settling into a stable low range
66
+
67
+ **Zone 3: Main Contact Conduction (Fully Closed State)**
68
+ * **Physical Meaning**: The main contacts (Ag-plated) are fully engaged, providing a large, stable contact area. This is the "healthy contact" signature zone - resistance should be at its MINIMUM and STABLE. The breaker is in its fully closed, current-carrying state.
69
+ * **Start**: End of Zone 2
70
+ * **End Boundary**: Detect when the breaker begins OPENING (travel reverses direction).
71
+ * Primary indicator: Travel (red) reaches MAXIMUM and starts to DESCEND
72
+ * Cross-reference: Resistance (green) should remain low and stable throughout this zone
73
+ * Cross-reference: Current (blue) should be stable at test level
74
+ * **Typical Duration**: 100-200 ms (this is the longest zone, representing the dwell time)
75
+ * **Detection Logic**: Find the peak of 'travel' curve and the point where it starts decreasing
76
+
77
+ **Zone 4: Main Contact Parting (Breaking/Opening Transition)**
78
+ * **Physical Meaning**: The main contacts are separating. As the contact area decreases, resistance rises sharply. Arcing occurs during the final separation of the arcing contacts. This is the "break" transition - the most critical phase for fault detection.
79
+ * **Start**: End of Zone 3
80
+ * **End Boundary**: Detect when resistance STABILIZES at high value after parting spikes.
81
+ * Primary indicator: Resistance (green) shoots UP, exhibits parting spikes, then STABILIZES at high/infinite value
82
+ * Cross-reference: Travel (red) should be decreasing (opening motion)
83
+ * Cross-reference: Current (blue) may drop or fluctuate during final arc extinction
84
+ * **Typical Duration**: 40-80 ms (Zone 4 typically ends around 280-340 ms total time)
85
+ * **Detection Logic**: Find where 'res' completes its rise and spike activity, becoming constant at high value
86
+ * **CRITICAL**: Do NOT extend this zone too long - end AS SOON AS resistance stabilizes
87
+
88
+ **Zone 5: Final Open State (Fully Open)**
89
+ * **Physical Meaning**: The contacts are fully separated with an air gap. No current flows, resistance is infinite. The breaker is in its fully open, non-conducting state.
90
+ * **Start**: End of Zone 4
91
+ * **End**: The last time point in the dataset
92
+ * **Characteristics**:
93
+ * Resistance (green): Very high/infinite (flat line at top)
94
+ * Current (blue): Zero or near-zero
95
+ * Travel (red): Stable at minimum (fully open position)
96
+
97
+ **MULTI-CURVE ANALYSIS STRATEGY:**
98
+ 1. Use Current (blue) to identify Zone 1 β†’ Zone 2 transition (first current rise)
99
+ 2. Use Resistance (green) to identify Zone 2 β†’ Zone 3 transition (resistance settles to low plateau)
100
+ 3. Use Travel (red) to identify Zone 3 β†’ Zone 4 transition (travel peak and reversal)
101
+ 4. Use Resistance (green) to identify Zone 4 β†’ Zone 5 transition (resistance stabilizes at high value)
102
+ 5. Always cross-validate boundaries using all three curves for consistency
103
+
104
+ **OUTPUT FORMAT (Strict JSON)**
105
+ Return ONLY this JSON object:
106
+ {{
107
+ "zones": {{
108
+ "zone_1_pre_contact": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }},
109
+ "zone_2_arcing_engagement": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }},
110
+ "zone_3_main_conduction": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }},
111
+ "zone_4_parting": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }},
112
+ "zone_5_final_open": {{ "start_ms": float, "end_ms": float, "justification": "string (explain which curve indicators were used)" }}
113
+ }},
114
+ "report_card": {{
115
+ "opening_speed": {{ "status": "Pass"|"Warning"|"Fail", "comment": "Assessment of travel curve steepness" }},
116
+ "contact_wear": {{ "status": "Pass"|"Warning"|"Fail", "comment": "Based on resistance fluctuations in Zone 2/4" }},
117
+ "timing_consistency": {{ "status": "Pass"|"Warning"|"Fail", "comment": "Are phases within expected ranges?" }},
118
+ "overall_health": {{ "status": "Healthy"|"Needs Review"|"Critical", "comment": "Overall summary" }}
119
+ }},
120
+ "detailed_analysis": "Provide a comprehensive technical analysis (in Markdown)..."
121
+ }}
122
+ """
123
+
124
+ def ask_llm_for_breakage(df, api_key, model_name, image_bytes=None):
125
+ """
126
+ Sends the DataFrame and optional image to LLM (Gemini) for segmentation.
127
+ Returns (df, result_json) where df has a new 'Zone' column.
128
+ """
129
+ if not api_key: return df, None
130
+
131
+ try:
132
+ genai.configure(api_key=api_key)
133
+
134
+ # Configure safety settings
135
+ safety_settings = [
136
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
137
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
138
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
139
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
140
+ ]
141
+
142
+ model = genai.GenerativeModel(
143
+ model_name=model_name,
144
+ safety_settings=safety_settings
145
+ )
146
+ except Exception as e:
147
+ return df, {"error": f"Failed to initialize Gemini API: {str(e)}"}
148
+
149
+ # Prepare Data
150
+ # Rename columns for LLM clarity
151
+ df_llm = df[['Time (ms)', 'Current', 'Resistance', 'Travel']].copy()
152
+ df_llm.columns = ['time', 'curr', 'res', 'travel']
153
+
154
+ # Round values
155
+ df_llm = df_llm.round(1)
156
+
157
+ # Sample to keep prompt size manageable (e.g., every 5th row)
158
+ # User's code used df.to_string(index=False), implying they might not have sampled,
159
+ # but for safety with large CSVs, we'll keep sampling but use to_string format.
160
+ df_sampled = df_llm.iloc[::5, :]
161
+
162
+ data_str = df_sampled.to_string(index=False)
163
+
164
+ prompt = get_dcrm_prompt(data_str)
165
+
166
+ content = [prompt]
167
+ if image_bytes:
168
+ try:
169
+ image = PIL.Image.open(io.BytesIO(image_bytes))
170
+ content.append(image)
171
+ except Exception as e:
172
+ return df, {"error": f"Failed to process image: {str(e)}"}
173
+
174
+ try:
175
+ response = model.generate_content(content)
176
+
177
+ if not response.text:
178
+ if hasattr(response, 'prompt_feedback'):
179
+ return df, {
180
+ "error": "Response blocked by safety filters",
181
+ "raw_response": str(response.prompt_feedback)
182
+ }
183
+ return df, {"error": "LLM returned empty response"}
184
+
185
+ result = response.text.strip()
186
+
187
+ # Remove markdown code blocks
188
+ if "```json" in result:
189
+ result = result.split("```json")[1].split("```")[0].strip()
190
+ elif "```" in result:
191
+ result = result.split("```")[1].split("```")[0].strip()
192
+
193
+ # Parse JSON
194
+ try:
195
+ result_json = json.loads(result)
196
+ zones = result_json.get("zones", {})
197
+
198
+ # Enrich DataFrame with Zones
199
+ df['Zone'] = "Unknown"
200
+
201
+ for zone_name, details in zones.items():
202
+ start = details.get("start_ms")
203
+ end = details.get("end_ms")
204
+ if start is not None and end is not None:
205
+ # Map zone name to a simpler label (e.g., "Zone 1")
206
+ short_name = zone_name.split('_')[1] # "1", "2", etc.
207
+ mask = (df['Time (ms)'] >= start) & (df['Time (ms)'] <= end)
208
+ df.loc[mask, 'Zone'] = f"Zone {short_name}"
209
+
210
+ return df, result_json
211
+
212
+ except json.JSONDecodeError as je:
213
+ return df, {
214
+ "error": f"JSON parsing failed: {str(je)}",
215
+ "raw_response": result[:1000]
216
+ }
217
+
218
+ except Exception as e:
219
+ return df, {"error": f"LLM API error: {str(e)}"}
220
+
221
+ def analyze_health_with_llm(image_bytes, api_key, model_name):
222
+ """
223
+ Sends the DCRM image to Gemini for expert diagnostic analysis.
224
+ """
225
+ if not api_key or not image_bytes: return None
226
+
227
+ try:
228
+ genai.configure(api_key=api_key)
229
+
230
+ safety_settings = [
231
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
232
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
233
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
234
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
235
+ ]
236
+
237
+ model = genai.GenerativeModel(
238
+ model_name=model_name,
239
+ safety_settings=safety_settings
240
+ )
241
+
242
+ prompt = """
243
+ System Role: Principal DCRM & Kinematic Analyst
244
+ Role:
245
+ You are an expert High-Voltage Circuit Breaker Diagnostician. Your task is to interpret Dynamic Contact Resistance (DCRM) traces to detect specific electrical and mechanical faults.
246
+
247
+ Critical "Anti-Overfitting" Directive:
248
+ You must distinguish between Systematic Defects and Artifacts.
249
+ Sensor/Manufacturing Noise: A totally flat line is rare in real-world data. Slight "fuzz" or very minute "grassiness" (amplitude < 10 ΞΌΞ©) is often sensor noise, ADC quantization, or normal manufacturing surface variance. Do not flag this as a defect.
250
+ True Degradation: Flag issues only when the visual signature is statistically significant and exceeds the "noise floor."
251
+
252
+ Capability:
253
+ Identify Multiple Concurrent Issues if present. (e.g., A breaker can have both misalignment and contact wear).
254
+ there will mostly be 3 line charts in the input
255
+ green resistance profile
256
+ blue current profile
257
+ red travel profile
258
+
259
+ 1. Diagnostic Heuristics & Defect Taxonomy
260
+ Map the visual DCRM trace to ONLY the following defect types. Use the specific Visual Heuristics to confirm detection.
261
+
262
+ Defect Type | Visual Heuristic (The "Hint") | Mechanical Significance (Root Cause)
263
+ --- | --- | ---
264
+ Main Contact Issue (Corrosion/Oxidation) | "The Significant Grass"<br>In the fully closed plateau, look for pronounced, erratic instability. <br>β€’ Ignore: Uniform, low-amplitude fuzz (sensor noise).<br>β€’ Flag: Jagged, irregular peaks/valleys with significant amplitude (e.g., > 15–20 ΞΌΞ© variance). The trace looks like a "rough rocky road," not just a "gravel path." | Surface Pathology: The Silver (Ag) plating is compromised (fretting corrosion) or heavy oxidation has occurred. The current path is constantly shifting through microscopic non-conductive spots.
265
+ Arcing Contact Wear | "Big Spikes & Short Wipe"<br>Resistance spikes are frequent and significantly large (high amplitude). Crucially, the duration of the arcing zone (the time between first touch and main contact touch) is noticeably shorter than expected. | Ablation: The Tungsten-Copper (W-Cu) tips are heavily eroded. The contact length has physically diminished, risking failure to commutate current during opening.
266
+ Misalignment (Main) | "The Struggle to Settle"<br>There are significant, high-amplitude peaks just before the trace tries to settle into the stable plateau. These are not bounces; they are "struggles" to mate that persist longer than 3-5ms. | Mechanical Centering: The moving contact pin is hitting the side or edge of the stationary rosette fingers before forcing its way in. Caused by loose nuts, kinematic play, or guide ring failure.
267
+ Misalignment (Arcing) | "Rough Entry"<br>Erratic resistance spikes occurring specifically during the initial entry (commutation), well before the main contacts engage. | Tip Eccentricity: The arcing pin is not entering the nozzle concentrically. It is scraping the nozzle throat or hitting the side, indicating a bent rod or skewed interrupter.
268
+ Slow Mechanism | "Stretched Time"<br>The entire resistance profile is elongated along the X-axis. Events happen later than normal. | Energy Starvation: Low spring charge, hydraulic pressure loss, or high friction due to hardened grease in the linkage.
269
+
270
+ 2. Analysis Logic (The "Signal-to-Noise" Filter)
271
+ Before declaring a defect, run these logic checks:
272
+ The "Noise Floor" Test (For Main Contacts):
273
+ Is the plateau variance uniform and small (< 10 ΞΌΞ©)? -> Classify as Healthy (Sensor/Manufacturing artifact).
274
+ Is the variance erratic, jagged, and large (> 15 ΞΌΞ©)? -> Classify as Corrosion/Oxidation.
275
+ The "Duration" Test (For Misalignment):
276
+ Are the pre-plateau peaks < 2ms? -> Ignore (Benign Bounce).
277
+ Do the peaks persist > 3-5ms before settling? -> Classify as Misalignment.
278
+ The "Combination" Check:
279
+ Does the trace show both "Rough Entry" AND "Stretched Time"? -> Report Both (Misalignment + Slow Mechanism).
280
+
281
+ 3. Output Structure
282
+ Provide a concise Executive Lead followed by the JSON.
283
+
284
+ Executive Lead (3-4 Lines)
285
+ Status: Healthy | Warning | Critical.
286
+ Key Findings: Summary of valid defects found (ignoring sensor noise).
287
+ Action: "Return to service" or specific repair instruction.
288
+
289
+ JSON Schema
290
+ ```json
291
+ {
292
+ "image_url": "string",
293
+ "overall_condition": "Healthy|Warning|Critical",
294
+ "detected_issues": [
295
+ {
296
+ "issue_type": "Main Contact Issue (Corrosion/Oxidation)|Arcing Contact Wear|Misalignment (Main)|Misalignment (Arcing)|Slow Mechanism",
297
+ "confidence": "High|Medium|Low",
298
+ "visual_evidence": "string (e.g., 'Plateau instability >20 micro-ohms detected, exceeding sensor noise threshold.')",
299
+ "mechanical_significance": "string (Root cause from table)",
300
+ "severity": "Low|Medium|High"
301
+ }
302
+ ],
303
+ "analysis_metrics": {
304
+ "static_resistance_Rp_uOhm": "float",
305
+ "signal_noise_level": "Low (Sensor/Mfg)|High (Defect)",
306
+ "wipe_quality": "Normal|Short|Erratic"
307
+ },
308
+ "maintenance_recommendation": "string"
309
+ }
310
+ ```
311
+ """
312
+
313
+ image = PIL.Image.open(io.BytesIO(image_bytes))
314
+
315
+ response = model.generate_content([prompt, image])
316
+
317
+ if not response.text:
318
+ return {"error": "LLM returned empty response"}
319
+
320
+ return response.text
321
+
322
+ except Exception as e:
323
+ return {"error": f"LLM Analysis Error: {str(e)}"}
dcrm/plotting.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import plotly.graph_objects as go
2
+ from plotly.subplots import make_subplots
3
+
4
+ def create_dcrm_plot(df, zones):
5
+ # Create figure with secondary y-axis
6
+ fig = make_subplots(specs=[[{"secondary_y": True}]])
7
+
8
+ # Add Traces
9
+ # Ensure column names match what is in the DF (dcrm_llm_app.py uses 'Time (ms)', 'Current', 'Resistance', 'Travel')
10
+ # The user's code uses 'Time_ms'. I need to be careful here.
11
+ # process_uploaded_image returns DF with 'Time (ms)', 'Current', 'Resistance', 'Travel'.
12
+ # I should adapt the plotting code to use the existing column names OR rename columns in the DF.
13
+ # The user's code expects 'Time_ms'.
14
+ # I will handle this mapping inside the function to be safe.
15
+
16
+ time_col = 'Time (ms)' if 'Time (ms)' in df.columns else 'Time_ms'
17
+
18
+ fig.add_trace(go.Scatter(x=df[time_col], y=df['Current'], name="Current (A)", line=dict(color='#2980b9', width=2)), secondary_y=False)
19
+ fig.add_trace(go.Scatter(x=df[time_col], y=df['Resistance'], name="Resistance (uOhm)", line=dict(color='#27ae60', width=2)), secondary_y=False)
20
+ fig.add_trace(go.Scatter(x=df[time_col], y=df['Travel'], name="Travel (mm)", line=dict(color='#c0392b', width=2)), secondary_y=True)
21
+
22
+ # Zone Colors
23
+ zone_colors = {
24
+ "zone_1_pre_contact": "rgba(52, 152, 219, 0.1)",
25
+ "zone_2_arcing_engagement": "rgba(231, 76, 60, 0.1)",
26
+ "zone_3_main_conduction": "rgba(46, 204, 113, 0.1)",
27
+ "zone_4_parting": "rgba(155, 89, 182, 0.1)",
28
+ "zone_5_final_open": "rgba(149, 165, 166, 0.1)"
29
+ }
30
+
31
+ # Add Zone Rectangles
32
+ # The user's code expects 'zones' to be a dict of zone details.
33
+ # The result_json has "zones" key.
34
+ zones_dict = zones.get("zones", {}) if "zones" in zones else zones
35
+
36
+ for zone_name, details in zones_dict.items():
37
+ start = details.get("start_ms")
38
+ end = details.get("end_ms")
39
+ color = zone_colors.get(zone_name, "rgba(0,0,0,0)")
40
+
41
+ if start is not None and end is not None:
42
+ fig.add_vrect(
43
+ x0=start, x1=end,
44
+ fillcolor=color, opacity=1,
45
+ layer="below", line_width=0,
46
+ annotation_text=zone_name.split('_')[1].upper(),
47
+ annotation_position="top left",
48
+ annotation_font_color="#7f8c8d"
49
+ )
50
+
51
+ fig.update_layout(
52
+ title_text="<b>Main Signals & Zones</b>",
53
+ height=500,
54
+ hovermode="x unified",
55
+ plot_bgcolor="white",
56
+ paper_bgcolor="white",
57
+ font=dict(family="Segoe UI, sans-serif"),
58
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
59
+ margin=dict(l=20, r=20, t=60, b=20)
60
+ )
61
+ fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='#f0f0f0')
62
+ fig.update_yaxes(title_text="Current / Resistance", secondary_y=False, showgrid=True, gridwidth=1, gridcolor='#f0f0f0')
63
+ fig.update_yaxes(title_text="Travel", secondary_y=True, showgrid=False)
64
+
65
+ return fig
66
+
67
+ def create_velocity_plot(df):
68
+ time_col = 'Time (ms)' if 'Time (ms)' in df.columns else 'Time_ms'
69
+
70
+ # Calculate Velocity (Derivative of Travel)
71
+ # V = d(Travel) / d(Time)
72
+ # Units: mm/ms = m/s
73
+ df['Velocity'] = df['Travel'].diff() / df[time_col].diff()
74
+
75
+ fig = go.Figure()
76
+ fig.add_trace(go.Scatter(x=df[time_col], y=df['Velocity'], name="Velocity (m/s)", line=dict(color='#e67e22', width=2), fill='tozeroy'))
77
+
78
+ fig.update_layout(
79
+ title_text="<b>Contact Velocity Profile</b>",
80
+ height=300,
81
+ hovermode="x unified",
82
+ plot_bgcolor="white",
83
+ paper_bgcolor="white",
84
+ font=dict(family="Segoe UI, sans-serif"),
85
+ margin=dict(l=20, r=20, t=40, b=20)
86
+ )
87
+ fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='#f0f0f0')
88
+ fig.update_yaxes(title_text="Velocity (m/s)", showgrid=True, gridwidth=1, gridcolor='#f0f0f0')
89
+ return fig
90
+
91
+ def create_resistance_zoom_plot(df):
92
+ time_col = 'Time (ms)' if 'Time (ms)' in df.columns else 'Time_ms'
93
+
94
+ fig = go.Figure()
95
+ fig.add_trace(go.Scatter(x=df[time_col], y=df['Resistance'], name="Resistance", line=dict(color='#27ae60', width=2)))
96
+
97
+ fig.update_layout(
98
+ title_text="<b>Detailed Resistance (Log Scale)</b>",
99
+ height=300,
100
+ hovermode="x unified",
101
+ plot_bgcolor="white",
102
+ paper_bgcolor="white",
103
+ font=dict(family="Segoe UI, sans-serif"),
104
+ yaxis_type="log", # Log scale to see details
105
+ margin=dict(l=20, r=20, t=40, b=20)
106
+ )
107
+ fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='#f0f0f0')
108
+ fig.update_yaxes(title_text="Resistance (uOhm)", showgrid=True, gridwidth=1, gridcolor='#f0f0f0')
109
+ return fig
dcrm/report_generator.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fpdf import FPDF
2
+ import datetime
3
+ import os
4
+ import tempfile
5
+
6
+ class PDFReportGenerator(FPDF):
7
+ def __init__(self):
8
+ super().__init__()
9
+ self.set_auto_page_break(auto=True, margin=15)
10
+ self.add_page()
11
+ self.set_font("helvetica", size=12)
12
+
13
+ def header(self):
14
+ self.set_font("helvetica", "B", 15)
15
+ self.cell(0, 10, "DCRM Analysis Report", align="C")
16
+ self.ln(20)
17
+
18
+ def footer(self):
19
+ self.set_y(-15)
20
+ self.set_font("helvetica", "I", 8)
21
+ self.cell(0, 10, f"Page {self.page_no()}", align="C")
22
+
23
+ def add_section_title(self, title):
24
+ self.set_font("helvetica", "B", 12)
25
+ self.set_fill_color(200, 220, 255)
26
+ self.cell(0, 10, title, fill=True, ln=True)
27
+ self.ln(5)
28
+
29
+ def sanitize_text(self, text):
30
+ """Replace unsupported characters with ASCII equivalents."""
31
+ if not isinstance(text, str):
32
+ text = str(text)
33
+ replacements = {
34
+ "ΞΌ": "u",
35
+ "Ξ©": "Ohm",
36
+ "–": "-",
37
+ "β€”": "-",
38
+ "’": "'",
39
+ "β€œ": '"',
40
+ "”": '"',
41
+ "…": "...",
42
+ "Β°": "deg"
43
+ }
44
+ for char, replacement in replacements.items():
45
+ text = text.replace(char, replacement)
46
+
47
+ # Final fallback: encode to ascii, ignoring errors, then decode back
48
+ return text.encode('ascii', 'ignore').decode('ascii')
49
+
50
+ def add_key_value(self, key, value):
51
+ self.set_font("helvetica", "B", 10)
52
+ self.cell(50, 8, self.sanitize_text(f"{key}:"), border=0)
53
+ self.set_font("helvetica", "", 10)
54
+ self.cell(0, 8, self.sanitize_text(str(value)), border=0, ln=True)
55
+
56
+ def add_multiline_text(self, text):
57
+ self.set_font("helvetica", "", 10)
58
+ self.multi_cell(0, 5, self.sanitize_text(str(text)))
59
+ self.ln(5)
60
+
61
+ def generate_report(self, analysis_data, zone_analysis, graph_image_path=None):
62
+ # 1. Executive Summary
63
+ self.add_section_title("Executive Summary")
64
+
65
+ overall = zone_analysis.get('overall_health', {})
66
+ self.add_key_value("Date", datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
67
+ self.add_key_value("Overall Condition", overall.get('status', 'Unknown'))
68
+ self.add_key_value("Health Score", f"{overall.get('overall_score', 0):.1f}/100")
69
+ self.add_key_value("Recommendation", overall.get('recommendation', 'N/A'))
70
+ self.ln(5)
71
+
72
+ # 2. Visual Evidence (Graph)
73
+ if graph_image_path and os.path.exists(graph_image_path):
74
+ self.add_section_title("DCRM Graph Analysis")
75
+ # Calculate width to fit page
76
+ page_width = self.w - 2 * self.l_margin
77
+ self.image(graph_image_path, w=page_width)
78
+ self.ln(10)
79
+
80
+ # 3. Detailed Metrics
81
+ if analysis_data:
82
+ self.add_section_title("Key Technical Metrics")
83
+ metrics = analysis_data.get("analysis_metrics", {})
84
+ self.add_key_value("Static Resistance", f"{metrics.get('static_resistance_Rp_uOhm', 'N/A')} uOhm")
85
+ self.add_key_value("Signal Noise", metrics.get('signal_noise_level', 'N/A'))
86
+ self.add_key_value("Wipe Quality", metrics.get('wipe_quality', 'N/A'))
87
+ self.ln(5)
88
+
89
+ # Issues
90
+ issues = analysis_data.get("detected_issues", [])
91
+ if issues:
92
+ self.add_section_title("Detected Issues")
93
+ for i, issue in enumerate(issues, 1):
94
+ self.set_font("helvetica", "B", 10)
95
+ self.cell(0, 8, self.sanitize_text(f"{i}. {issue.get('issue_type', 'Issue')}"), ln=True)
96
+ self.set_font("helvetica", "", 10)
97
+ self.multi_cell(0, 5, self.sanitize_text(f"Severity: {issue.get('severity')}\nEvidence: {issue.get('visual_evidence')}\nRoot Cause: {issue.get('mechanical_significance')}"))
98
+ self.ln(3)
99
+
100
+ # 4. Zone Details
101
+ self.add_section_title("Zone-by-Zone Analysis")
102
+ zone_names_display = {
103
+ 'zone_1_pre_contact': '1. Pre-Contact Travel',
104
+ 'zone_2_arcing_engagement': '2. Arcing Contact Engagement',
105
+ 'zone_3_main_conduction': '3. Main Contact Conduction',
106
+ 'zone_4_parting': '4. Main Contact Parting',
107
+ 'zone_5_final_open': '5. Final Open State'
108
+ }
109
+
110
+ for zone_key, display_name in zone_names_display.items():
111
+ if zone_key in zone_analysis:
112
+ z_health = zone_analysis[zone_key]
113
+ self.set_font("helvetica", "B", 10)
114
+ self.cell(0, 8, self.sanitize_text(f"{display_name} - {z_health.get('health_status', 'Unknown')}"), ln=True)
115
+
116
+ # Issues in zone
117
+ if z_health.get('issues'):
118
+ self.set_font("helvetica", "I", 9)
119
+ for issue in z_health['issues']:
120
+ self.cell(10) # Indent
121
+ self.cell(0, 5, self.sanitize_text(f"- {issue}"), ln=True)
122
+ else:
123
+ self.set_font("helvetica", "", 9)
124
+ self.cell(10)
125
+ self.cell(0, 5, "No issues detected.", ln=True)
126
+ self.ln(2)
127
+
128
+ return bytes(self.output())
dcrm/zone_analysis.py ADDED
@@ -0,0 +1,658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Zone Analysis Module for DCRM Curves
3
+
4
+ This module analyzes each segmented zone from DCRM graphs and evaluates
5
+ the health characteristics based on industry standards for circuit breaker
6
+ dynamic contact resistance measurements.
7
+
8
+ Healthy DCRM Curve Characteristics:
9
+ - Smooth resistance profile without excessive spikes
10
+ - Gradual resistance drop during arcing contact engagement
11
+ - Sharp drop to low, stable resistance (30-80 ¡Ω) during main contact engagement
12
+ - Smooth resistance increase during opening operation
13
+ - Minimal oscillations and no high peaks
14
+ - Reproducible signature over time
15
+ """
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from typing import Dict, List, Tuple, Any
20
+
21
+
22
+ class ZoneAnalyzer:
23
+ """Analyzes individual zones of DCRM curves for health assessment."""
24
+
25
+ # Healthy curve thresholds (based on research)
26
+ HEALTHY_THRESHOLDS = {
27
+ 'main_contact_resistance_max': 80, # ¡Ω (micro-ohms) - converted to graph units
28
+ 'main_contact_resistance_min': 30, # ¡Ω
29
+ 'max_resistance_spike_ratio': 3.0, # Max spike should be < 3x baseline
30
+ 'max_oscillation_percentage': 15, # Max 15% oscillation in stable zones
31
+ 'smoothness_threshold': 0.85, # Correlation coefficient for smoothness
32
+ 'current_rise_rate_min': 0.5, # Minimum rate of current rise in Zone 1
33
+ 'travel_stability_threshold': 5, # Max variation in travel during conduction
34
+ }
35
+
36
+ def __init__(self, df: pd.DataFrame, zones_data: Dict[str, Any]):
37
+ """
38
+ Initialize the zone analyzer.
39
+
40
+ Args:
41
+ df: DataFrame with columns ['Time (ms)', 'Current', 'Resistance', 'Travel']
42
+ zones_data: Dictionary containing zone segmentation information
43
+ """
44
+ self.df = df
45
+ self.zones_data = zones_data
46
+ self.analysis_results = {}
47
+
48
+ def analyze_all_zones(self) -> Dict[str, Any]:
49
+ """
50
+ Analyze all zones and return comprehensive health assessment.
51
+
52
+ Returns:
53
+ Dictionary containing analysis results for each zone
54
+ """
55
+ if 'zones' not in self.zones_data:
56
+ return {'error': 'No zone data available'}
57
+
58
+ zones = self.zones_data['zones']
59
+
60
+ # Analyze each zone
61
+ for zone_name, zone_info in zones.items():
62
+ zone_df = self._extract_zone_data(zone_info)
63
+
64
+ if zone_df is not None and len(zone_df) > 0:
65
+ analysis = self._analyze_zone(zone_name, zone_df, zone_info)
66
+ self.analysis_results[zone_name] = analysis
67
+
68
+ # Generate overall health assessment
69
+ overall_health = self._calculate_overall_health()
70
+ self.analysis_results['overall_health'] = overall_health
71
+
72
+ return self.analysis_results
73
+
74
+ def _extract_zone_data(self, zone_info: Dict) -> pd.DataFrame:
75
+ """Extract data for a specific zone based on time boundaries."""
76
+ start_ms = zone_info.get('start_ms', 0)
77
+ end_ms = zone_info.get('end_ms', 0)
78
+
79
+ mask = (self.df['Time (ms)'] >= start_ms) & (self.df['Time (ms)'] <= end_ms)
80
+ return self.df[mask].copy()
81
+
82
+ def _analyze_zone(self, zone_name: str, zone_df: pd.DataFrame,
83
+ zone_info: Dict) -> Dict[str, Any]:
84
+ """
85
+ Analyze a specific zone based on its characteristics.
86
+
87
+ Args:
88
+ zone_name: Name of the zone
89
+ zone_df: DataFrame containing zone data
90
+ zone_info: Zone metadata
91
+
92
+ Returns:
93
+ Dictionary with zone analysis results
94
+ """
95
+ analysis = {
96
+ 'zone_name': zone_name,
97
+ 'duration_ms': zone_info.get('end_ms', 0) - zone_info.get('start_ms', 0),
98
+ 'health_status': 'Unknown',
99
+ 'health_score': 0.0,
100
+ 'issues': [],
101
+ 'metrics': {}
102
+ }
103
+
104
+ # Zone-specific analysis
105
+ if 'zone_1' in zone_name:
106
+ analysis.update(self._analyze_zone_1_pre_contact(zone_df))
107
+ elif 'zone_2' in zone_name:
108
+ analysis.update(self._analyze_zone_2_arcing_engagement(zone_df))
109
+ elif 'zone_3' in zone_name:
110
+ analysis.update(self._analyze_zone_3_main_conduction(zone_df))
111
+ elif 'zone_4' in zone_name:
112
+ analysis.update(self._analyze_zone_4_parting(zone_df))
113
+ elif 'zone_5' in zone_name:
114
+ analysis.update(self._analyze_zone_5_final_open(zone_df))
115
+
116
+ return analysis
117
+
118
+ def _analyze_zone_1_pre_contact(self, zone_df: pd.DataFrame) -> Dict[str, Any]:
119
+ """
120
+ Analyze Zone 1: Pre-Contact Travel
121
+
122
+ Expected behavior:
123
+ - Travel should be increasing (contacts moving)
124
+ - Current should be near zero (no contact yet)
125
+ - Resistance should be very high (infinite/open circuit)
126
+ """
127
+ metrics = {}
128
+ issues = []
129
+
130
+ # Check travel progression
131
+ travel_values = zone_df['Travel'].dropna()
132
+ if len(travel_values) > 1:
133
+ travel_trend = np.polyfit(range(len(travel_values)), travel_values, 1)[0]
134
+ metrics['travel_rate'] = float(travel_trend)
135
+
136
+ if travel_trend < 0.1:
137
+ issues.append('Travel not increasing properly - possible mechanical issue')
138
+
139
+ # Check current is near baseline
140
+ current_values = zone_df['Current'].dropna()
141
+ if len(current_values) > 0:
142
+ current_mean = current_values.mean()
143
+ current_std = current_values.std()
144
+ metrics['current_baseline'] = float(current_mean)
145
+ metrics['current_stability'] = float(current_std)
146
+
147
+ # Current should rise towards end of zone
148
+ if len(current_values) > 5:
149
+ early_current = current_values.iloc[:len(current_values)//3].mean()
150
+ late_current = current_values.iloc[-len(current_values)//3:].mean()
151
+ current_rise = late_current - early_current
152
+ metrics['current_rise'] = float(current_rise)
153
+
154
+ if current_rise < self.HEALTHY_THRESHOLDS['current_rise_rate_min']:
155
+ issues.append('Insufficient current rise - delayed contact engagement')
156
+
157
+ # Calculate health score
158
+ health_score = self._calculate_zone_health_score(metrics, issues, zone_type='zone_1')
159
+
160
+ return {
161
+ 'metrics': metrics,
162
+ 'issues': issues,
163
+ 'health_score': health_score,
164
+ 'health_status': self._get_health_status(health_score)
165
+ }
166
+
167
+ def _analyze_zone_2_arcing_engagement(self, zone_df: pd.DataFrame) -> Dict[str, Any]:
168
+ """
169
+ Analyze Zone 2: Arcing Contact Engagement
170
+
171
+ Expected behavior:
172
+ - Resistance drops from high to moderate (arcing contacts engaging)
173
+ - Should see resistance spikes (arcing activity)
174
+ - Current starts flowing
175
+ - Smooth gradual drop is healthy
176
+ """
177
+ metrics = {}
178
+ issues = []
179
+
180
+ resistance_values = zone_df['Resistance'].dropna()
181
+
182
+ if len(resistance_values) > 2:
183
+ # Check for gradual resistance drop
184
+ res_start = resistance_values.iloc[:3].mean()
185
+ res_end = resistance_values.iloc[-3:].mean()
186
+ res_drop = res_start - res_end
187
+ metrics['resistance_drop'] = float(res_drop)
188
+
189
+ if res_drop < 0:
190
+ issues.append('Resistance increasing instead of dropping - abnormal arcing')
191
+
192
+ # Analyze resistance spikes (expected during arcing)
193
+ res_peaks = self._detect_peaks(resistance_values)
194
+ metrics['spike_count'] = len(res_peaks)
195
+
196
+ if len(res_peaks) > 0:
197
+ max_spike = resistance_values.iloc[res_peaks].max()
198
+ baseline = resistance_values.median()
199
+ spike_ratio = max_spike / baseline if baseline > 0 else 0
200
+ metrics['max_spike_ratio'] = float(spike_ratio)
201
+
202
+ if spike_ratio > self.HEALTHY_THRESHOLDS['max_resistance_spike_ratio']:
203
+ issues.append(f'Excessive resistance spikes ({spike_ratio:.1f}x) - possible contact damage')
204
+
205
+ # Check smoothness of transition
206
+ smoothness = self._calculate_smoothness(resistance_values)
207
+ metrics['transition_smoothness'] = float(smoothness)
208
+
209
+ if smoothness < 0.6: # Lower threshold for arcing zone (spikes expected)
210
+ issues.append('Erratic resistance pattern - possible contact erosion')
211
+
212
+ # Check current flow
213
+ current_values = zone_df['Current'].dropna()
214
+ if len(current_values) > 0:
215
+ metrics['current_mean'] = float(current_values.mean())
216
+ metrics['current_max'] = float(current_values.max())
217
+
218
+ health_score = self._calculate_zone_health_score(metrics, issues, zone_type='zone_2')
219
+
220
+ return {
221
+ 'metrics': metrics,
222
+ 'issues': issues,
223
+ 'health_score': health_score,
224
+ 'health_status': self._get_health_status(health_score)
225
+ }
226
+
227
+ def _analyze_zone_3_main_conduction(self, zone_df: pd.DataFrame) -> Dict[str, Any]:
228
+ """
229
+ Analyze Zone 3: Main Contact Conduction
230
+
231
+ Expected behavior:
232
+ - Resistance should be LOW and STABLE (30-80 ¡Ω ideal)
233
+ - Travel should be at maximum (plateau)
234
+ - Current should be stable
235
+ - This is the "healthy contact" signature zone
236
+ """
237
+ metrics = {}
238
+ issues = []
239
+
240
+ resistance_values = zone_df['Resistance'].dropna()
241
+
242
+ if len(resistance_values) > 0:
243
+ res_mean = resistance_values.mean()
244
+ res_std = resistance_values.std()
245
+ res_min = resistance_values.min()
246
+ res_max = resistance_values.max()
247
+
248
+ metrics['resistance_mean'] = float(res_mean)
249
+ metrics['resistance_std'] = float(res_std)
250
+ metrics['resistance_range'] = float(res_max - res_min)
251
+
252
+ # Check if resistance is in healthy range
253
+ # Note: Graph units may not be ¡Ω, so we check relative stability instead
254
+ oscillation_pct = (res_std / res_mean * 100) if res_mean > 0 else 0
255
+ metrics['oscillation_percentage'] = float(oscillation_pct)
256
+
257
+ if oscillation_pct > self.HEALTHY_THRESHOLDS['max_oscillation_percentage']:
258
+ issues.append(f'Excessive resistance oscillation ({oscillation_pct:.1f}%) - poor contact quality')
259
+
260
+ # Check for stability (should be flat)
261
+ smoothness = self._calculate_smoothness(resistance_values)
262
+ metrics['resistance_stability'] = float(smoothness)
263
+
264
+ if smoothness < self.HEALTHY_THRESHOLDS['smoothness_threshold']:
265
+ issues.append('Unstable resistance - possible contact bouncing or misalignment')
266
+
267
+ # Check travel plateau
268
+ travel_values = zone_df['Travel'].dropna()
269
+ if len(travel_values) > 0:
270
+ travel_variation = travel_values.std()
271
+ metrics['travel_variation'] = float(travel_variation)
272
+
273
+ if travel_variation > self.HEALTHY_THRESHOLDS['travel_stability_threshold']:
274
+ issues.append('Travel not stable - mechanical issue during conduction')
275
+
276
+ # Check current stability
277
+ current_values = zone_df['Current'].dropna()
278
+ if len(current_values) > 0:
279
+ current_std = current_values.std()
280
+ current_mean = current_values.mean()
281
+ current_stability = (current_std / current_mean * 100) if current_mean > 0 else 0
282
+ metrics['current_stability_pct'] = float(current_stability)
283
+
284
+ health_score = self._calculate_zone_health_score(metrics, issues, zone_type='zone_3')
285
+
286
+ return {
287
+ 'metrics': metrics,
288
+ 'issues': issues,
289
+ 'health_score': health_score,
290
+ 'health_status': self._get_health_status(health_score)
291
+ }
292
+
293
+ def _analyze_zone_4_parting(self, zone_df: pd.DataFrame) -> Dict[str, Any]:
294
+ """
295
+ Analyze Zone 4: Main Contact Parting (The Break)
296
+
297
+ Expected behavior:
298
+ - Resistance should INCREASE sharply (contacts separating)
299
+ - May see resistance spikes (arcing during separation)
300
+ - Travel should start decreasing (opening)
301
+ - Smooth increase is healthy
302
+ """
303
+ metrics = {}
304
+ issues = []
305
+
306
+ resistance_values = zone_df['Resistance'].dropna()
307
+
308
+ if len(resistance_values) > 2:
309
+ # Check for resistance increase
310
+ res_start = resistance_values.iloc[:3].mean()
311
+ res_end = resistance_values.iloc[-3:].mean()
312
+ res_increase = res_end - res_start
313
+ metrics['resistance_increase'] = float(res_increase)
314
+
315
+ if res_increase < 0:
316
+ issues.append('Resistance decreasing during parting - abnormal behavior')
317
+
318
+ # Check rate of increase
319
+ if len(resistance_values) > 1:
320
+ res_trend = np.polyfit(range(len(resistance_values)), resistance_values, 1)[0]
321
+ metrics['resistance_rise_rate'] = float(res_trend)
322
+
323
+ if res_trend < 0.1:
324
+ issues.append('Slow resistance rise - possible contact sticking')
325
+
326
+ # Analyze spikes during parting (some arcing is normal)
327
+ res_peaks = self._detect_peaks(resistance_values)
328
+ metrics['parting_spike_count'] = len(res_peaks)
329
+
330
+ if len(res_peaks) > 0:
331
+ max_spike = resistance_values.iloc[res_peaks].max()
332
+ baseline = resistance_values.median()
333
+ spike_ratio = max_spike / baseline if baseline > 0 else 0
334
+ metrics['max_parting_spike_ratio'] = float(spike_ratio)
335
+
336
+ if spike_ratio > self.HEALTHY_THRESHOLDS['max_resistance_spike_ratio'] * 1.5:
337
+ issues.append(f'Excessive parting spikes ({spike_ratio:.1f}x) - severe arcing or contact damage')
338
+
339
+ # Check travel movement
340
+ travel_values = zone_df['Travel'].dropna()
341
+ if len(travel_values) > 1:
342
+ travel_trend = np.polyfit(range(len(travel_values)), travel_values, 1)[0]
343
+ metrics['travel_opening_rate'] = float(travel_trend)
344
+
345
+ if travel_trend > -0.1: # Should be negative (decreasing)
346
+ issues.append('Travel not decreasing properly - mechanical opening issue')
347
+
348
+ health_score = self._calculate_zone_health_score(metrics, issues, zone_type='zone_4')
349
+
350
+ return {
351
+ 'metrics': metrics,
352
+ 'issues': issues,
353
+ 'health_score': health_score,
354
+ 'health_status': self._get_health_status(health_score)
355
+ }
356
+
357
+ def _analyze_zone_5_final_open(self, zone_df: pd.DataFrame) -> Dict[str, Any]:
358
+ """
359
+ Analyze Zone 5: Final Open State
360
+
361
+ Expected behavior:
362
+ - Resistance should be very high and stable (infinite/open circuit)
363
+ - Travel should be stable at minimum (fully open)
364
+ - Current should be zero
365
+ """
366
+ metrics = {}
367
+ issues = []
368
+
369
+ resistance_values = zone_df['Resistance'].dropna()
370
+
371
+ if len(resistance_values) > 0:
372
+ res_mean = resistance_values.mean()
373
+ res_std = resistance_values.std()
374
+ metrics['final_resistance_mean'] = float(res_mean)
375
+ metrics['final_resistance_stability'] = float(res_std)
376
+
377
+ # Should be stable (flat line at high value)
378
+ stability_pct = (res_std / res_mean * 100) if res_mean > 0 else 0
379
+ metrics['stability_percentage'] = float(stability_pct)
380
+
381
+ if stability_pct > 10:
382
+ issues.append('Unstable final resistance - possible incomplete opening')
383
+
384
+ # Check travel is stable
385
+ travel_values = zone_df['Travel'].dropna()
386
+ if len(travel_values) > 0:
387
+ travel_std = travel_values.std()
388
+ metrics['travel_final_stability'] = float(travel_std)
389
+
390
+ if travel_std > 3:
391
+ issues.append('Travel unstable in final state - mechanical issue')
392
+
393
+ # Check current is near zero
394
+ current_values = zone_df['Current'].dropna()
395
+ if len(current_values) > 0:
396
+ current_mean = current_values.mean()
397
+ metrics['final_current'] = float(current_mean)
398
+
399
+ # Current should be very low in open state
400
+ initial_current = self.df['Current'].iloc[:10].mean() # Baseline from start
401
+ if current_mean > initial_current * 1.5:
402
+ issues.append('Elevated current in open state - possible leakage')
403
+
404
+ health_score = self._calculate_zone_health_score(metrics, issues, zone_type='zone_5')
405
+
406
+ return {
407
+ 'metrics': metrics,
408
+ 'issues': issues,
409
+ 'health_score': health_score,
410
+ 'health_status': self._get_health_status(health_score)
411
+ }
412
+
413
+ def _detect_peaks(self, signal: pd.Series, prominence_factor: float = 0.3) -> List[int]:
414
+ """
415
+ Detect peaks in a signal.
416
+
417
+ Args:
418
+ signal: Input signal
419
+ prominence_factor: Minimum prominence as fraction of signal range
420
+
421
+ Returns:
422
+ List of peak indices
423
+ """
424
+ if len(signal) < 3:
425
+ return []
426
+
427
+ values = signal.values
428
+ signal_range = values.max() - values.min()
429
+ min_prominence = signal_range * prominence_factor
430
+
431
+ peaks = []
432
+ for i in range(1, len(values) - 1):
433
+ if values[i] > values[i-1] and values[i] > values[i+1]:
434
+ # Check prominence
435
+ left_min = min(values[max(0, i-5):i])
436
+ right_min = min(values[i+1:min(len(values), i+6)])
437
+ prominence = values[i] - max(left_min, right_min)
438
+
439
+ if prominence >= min_prominence:
440
+ peaks.append(i)
441
+
442
+ return peaks
443
+
444
+ def _calculate_smoothness(self, signal: pd.Series) -> float:
445
+ """
446
+ Calculate smoothness of a signal using correlation with fitted line.
447
+
448
+ Args:
449
+ signal: Input signal
450
+
451
+ Returns:
452
+ Smoothness score (0-1, higher is smoother)
453
+ """
454
+ if len(signal) < 3:
455
+ return 0.0
456
+
457
+ x = np.arange(len(signal))
458
+ y = signal.values
459
+
460
+ # Fit a polynomial (degree 2 for curves, degree 1 for lines)
461
+ try:
462
+ coeffs = np.polyfit(x, y, deg=2)
463
+ fitted = np.polyval(coeffs, x)
464
+
465
+ # Calculate correlation
466
+ correlation = np.corrcoef(y, fitted)[0, 1]
467
+ return abs(correlation) if not np.isnan(correlation) else 0.0
468
+ except:
469
+ return 0.0
470
+
471
+ def _calculate_zone_health_score(self, metrics: Dict, issues: List[str],
472
+ zone_type: str) -> float:
473
+ """
474
+ Calculate health score for a zone (0-100).
475
+
476
+ Args:
477
+ metrics: Zone metrics
478
+ issues: List of detected issues
479
+ zone_type: Type of zone
480
+
481
+ Returns:
482
+ Health score (0-100)
483
+ """
484
+ # Start with perfect score
485
+ score = 100.0
486
+
487
+ # Deduct points for each issue
488
+ score -= len(issues) * 15
489
+
490
+ # Zone-specific scoring adjustments
491
+ if zone_type == 'zone_3': # Main conduction - most critical
492
+ if 'oscillation_percentage' in metrics:
493
+ osc = metrics['oscillation_percentage']
494
+ if osc > 20:
495
+ score -= 20
496
+ elif osc > 15:
497
+ score -= 10
498
+
499
+ if 'resistance_stability' in metrics:
500
+ if metrics['resistance_stability'] < 0.85:
501
+ score -= 15
502
+
503
+ elif zone_type == 'zone_2' or zone_type == 'zone_4': # Arcing zones
504
+ if 'max_spike_ratio' in metrics or 'max_parting_spike_ratio' in metrics:
505
+ spike_key = 'max_spike_ratio' if 'max_spike_ratio' in metrics else 'max_parting_spike_ratio'
506
+ spike_ratio = metrics[spike_key]
507
+ if spike_ratio > 5:
508
+ score -= 25
509
+ elif spike_ratio > 3:
510
+ score -= 10
511
+
512
+ # Ensure score is in valid range
513
+ return max(0.0, min(100.0, score))
514
+
515
+ def _get_health_status(self, score: float) -> str:
516
+ """Convert health score to status label."""
517
+ if score >= 85:
518
+ return 'Excellent'
519
+ elif score >= 70:
520
+ return 'Good'
521
+ elif score >= 50:
522
+ return 'Fair'
523
+ elif score >= 30:
524
+ return 'Poor'
525
+ else:
526
+ return 'Critical'
527
+
528
+ def _calculate_overall_health(self) -> Dict[str, Any]:
529
+ """
530
+ Calculate overall health assessment across all zones.
531
+
532
+ Returns:
533
+ Dictionary with overall health metrics
534
+ """
535
+ if not self.analysis_results:
536
+ return {'status': 'No data', 'score': 0.0}
537
+
538
+ # Collect all zone scores
539
+ zone_scores = []
540
+ all_issues = []
541
+
542
+ for zone_name, analysis in self.analysis_results.items():
543
+ if isinstance(analysis, dict) and 'health_score' in analysis:
544
+ zone_scores.append(analysis['health_score'])
545
+ all_issues.extend(analysis.get('issues', []))
546
+
547
+ if not zone_scores:
548
+ return {'status': 'Unknown', 'score': 0.0}
549
+
550
+ # Calculate weighted average (Zone 3 is most important)
551
+ weights = {
552
+ 'zone_1_pre_contact': 0.15,
553
+ 'zone_2_arcing_engagement': 0.20,
554
+ 'zone_3_main_conduction': 0.35, # Most critical
555
+ 'zone_4_parting': 0.20,
556
+ 'zone_5_final_open': 0.10
557
+ }
558
+
559
+ weighted_score = 0.0
560
+ total_weight = 0.0
561
+
562
+ for zone_name, analysis in self.analysis_results.items():
563
+ if isinstance(analysis, dict) and 'health_score' in analysis:
564
+ weight = weights.get(zone_name, 0.2)
565
+ weighted_score += analysis['health_score'] * weight
566
+ total_weight += weight
567
+
568
+ overall_score = weighted_score / total_weight if total_weight > 0 else 0.0
569
+
570
+ return {
571
+ 'overall_score': round(overall_score, 2),
572
+ 'status': self._get_health_status(overall_score),
573
+ 'total_issues': len(all_issues),
574
+ 'critical_issues': [issue for issue in all_issues if 'severe' in issue.lower() or 'critical' in issue.lower()],
575
+ 'recommendation': self._generate_recommendation(overall_score, all_issues)
576
+ }
577
+
578
+ def _generate_recommendation(self, score: float, issues: List[str]) -> str:
579
+ """Generate maintenance recommendation based on analysis."""
580
+ if score >= 85:
581
+ return 'Circuit breaker is in excellent condition. Continue regular monitoring.'
582
+ elif score >= 70:
583
+ return 'Circuit breaker is in good condition. Schedule routine maintenance as planned.'
584
+ elif score >= 50:
585
+ return 'Circuit breaker shows signs of wear. Increase monitoring frequency and plan maintenance.'
586
+ elif score >= 30:
587
+ return 'Circuit breaker condition is poor. Schedule maintenance soon to prevent failure.'
588
+ else:
589
+ return 'CRITICAL: Circuit breaker requires immediate attention. Risk of failure is high.'
590
+
591
+
592
+ def analyze_zones_with_image(df: pd.DataFrame, zones_data: Dict[str, Any],
593
+ annotated_image: np.ndarray = None) -> Dict[str, Any]:
594
+ """
595
+ Convenience function to analyze zones and optionally annotate image.
596
+
597
+ Args:
598
+ df: DataFrame with DCRM data
599
+ zones_data: Zone segmentation data
600
+ annotated_image: Optional image to annotate with analysis results
601
+
602
+ Returns:
603
+ Complete analysis results
604
+ """
605
+ analyzer = ZoneAnalyzer(df, zones_data)
606
+ results = analyzer.analyze_all_zones()
607
+
608
+ # If image provided, add visual annotations
609
+ if annotated_image is not None:
610
+ results['annotated_image'] = _annotate_image_with_health(
611
+ annotated_image, results, zones_data
612
+ )
613
+
614
+ return results
615
+
616
+
617
+ def _annotate_image_with_health(image: np.ndarray, analysis_results: Dict[str, Any],
618
+ zones_data: Dict[str, Any]) -> np.ndarray:
619
+ """
620
+ Annotate image with health status for each zone.
621
+
622
+ Args:
623
+ image: Input image
624
+ analysis_results: Analysis results from ZoneAnalyzer
625
+ zones_data: Zone segmentation data
626
+
627
+ Returns:
628
+ Annotated image
629
+ """
630
+ import cv2
631
+
632
+ annotated = image.copy()
633
+ height = annotated.shape[0]
634
+
635
+ # Color coding for health status
636
+ status_colors = {
637
+ 'Excellent': (0, 255, 0), # Green
638
+ 'Good': (144, 238, 144), # Light Green
639
+ 'Fair': (255, 255, 0), # Yellow
640
+ 'Poor': (255, 165, 0), # Orange
641
+ 'Critical': (255, 0, 0) # Red
642
+ }
643
+
644
+ if 'zones' in zones_data:
645
+ for zone_name, zone_info in zones_data['zones'].items():
646
+ if zone_name in analysis_results:
647
+ analysis = analysis_results[zone_name]
648
+ status = analysis.get('health_status', 'Unknown')
649
+ color = status_colors.get(status, (128, 128, 128))
650
+
651
+ # Add colored indicator at top of zone
652
+ # This is a simple implementation - can be enhanced
653
+ y_pos = 30
654
+ text = f"{status} ({analysis.get('health_score', 0):.0f})"
655
+ cv2.putText(annotated, text, (10, y_pos),
656
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
657
+
658
+ return annotated
flask_app.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # flask_app.py
2
+ """
3
+ Flask API for DCRM (Dynamic Contact Resistance Measurement) Analysis
4
+ Provides endpoints for uploading DCRM graph images and getting AI-powered analysis.
5
+ """
6
+
7
+ from flask import Flask, request, jsonify
8
+ from flask_cors import CORS
9
+ import cv2
10
+ import numpy as np
11
+ import os
12
+ import json
13
+ import re
14
+ import tempfile
15
+ import base64
16
+ from werkzeug.utils import secure_filename
17
+
18
+ # Import DCRM modules
19
+ from dcrm.image_processing import process_uploaded_image
20
+ from dcrm.llm import ask_llm_for_breakage, analyze_health_with_llm
21
+ from dcrm.zone_analysis import ZoneAnalyzer
22
+
23
+ app = Flask(__name__)
24
+ CORS(app) # Enable CORS for all routes
25
+
26
+ # Configuration
27
+ app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16MB max file size
28
+ ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg"}
29
+
30
+ # Default processing parameters
31
+ DEFAULT_SAT_FACTOR = 3.0
32
+ DEFAULT_GAP_SIZE = 1
33
+ DEFAULT_NOISE_THRESHOLD = 100
34
+ DEFAULT_TOTAL_DURATION = 400
35
+ DEFAULT_CROP_OPTION = True
36
+ DEFAULT_MODEL_NAME = "gemini-2.0-flash"
37
+
38
+
39
+ def allowed_file(filename):
40
+ """Check if file extension is allowed"""
41
+ return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
42
+
43
+
44
+ def safe_parse_llm_json(llm_response):
45
+ """Robustly extracts JSON from LLM response, handling markdown and plain text."""
46
+ try:
47
+ # Try finding markdown block first
48
+ json_match = re.search(r"```json\s*(\{.*?\})\s*```", llm_response, re.DOTALL)
49
+ if json_match:
50
+ return json.loads(json_match.group(1))
51
+
52
+ # Try finding just a JSON object structure
53
+ json_match_loose = re.search(r"(\{.*\})", llm_response, re.DOTALL)
54
+ if json_match_loose:
55
+ return json.loads(json_match_loose.group(1))
56
+
57
+ # Try loading the whole string
58
+ return json.loads(llm_response)
59
+ except:
60
+ return None
61
+
62
+
63
+ def convert_numpy_types(obj):
64
+ """Convert numpy types to Python native types for JSON serialization"""
65
+ if isinstance(obj, dict):
66
+ return {key: convert_numpy_types(value) for key, value in obj.items()}
67
+ elif isinstance(obj, list):
68
+ return [convert_numpy_types(item) for item in obj]
69
+ elif isinstance(obj, np.integer):
70
+ return int(obj)
71
+ elif isinstance(obj, np.floating):
72
+ return float(obj)
73
+ elif isinstance(obj, np.ndarray):
74
+ return obj.tolist()
75
+ elif hasattr(obj, "item"): # For numpy scalar types
76
+ return obj.item()
77
+ else:
78
+ return obj
79
+
80
+
81
+ def image_to_base64(img_array):
82
+ """Convert a numpy image array to base64 string"""
83
+ if img_array is None:
84
+ return None
85
+ # Ensure it's in BGR format for encoding
86
+ if len(img_array.shape) == 3 and img_array.shape[2] == 3:
87
+ # Convert RGB to BGR if needed (OpenCV expects BGR)
88
+ img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
89
+ else:
90
+ img_bgr = img_array
91
+
92
+ _, buffer = cv2.imencode(".png", img_bgr)
93
+ return base64.b64encode(buffer).decode("utf-8")
94
+
95
+
96
+ @app.route("/health", methods=["GET"])
97
+ def health_check():
98
+ """Health check endpoint"""
99
+ return jsonify({"status": "healthy", "service": "DCRM Analysis API"})
100
+
101
+
102
+ @app.route("/analyze", methods=["POST"])
103
+ def analyze_image():
104
+ """
105
+ Main endpoint for DCRM image analysis.
106
+
107
+ Expects:
108
+ - image: File upload (multipart/form-data) or base64 encoded image
109
+ - api_key: Gemini API key (required)
110
+ - sat_factor: Saturation boost factor (optional, default: 3.0)
111
+ - gap_size: Gap fill size (optional, default: 1)
112
+ - noise_threshold: Minimum object area (optional, default: 100)
113
+ - total_duration: Graph duration in ms (optional, default: 400)
114
+ - crop_option: Auto-crop option (optional, default: true)
115
+ - analysis_method: "image" or "csv" (optional, default: "image")
116
+
117
+ Returns:
118
+ JSON response with analysis results
119
+ """
120
+ try:
121
+ # Get API key
122
+ api_key = (
123
+ request.form.get("api_key") or request.json.get("api_key")
124
+ if request.is_json
125
+ else request.form.get("api_key")
126
+ )
127
+
128
+ if not api_key:
129
+ # Try to get from environment
130
+ api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get(
131
+ "GOOGLE_API_KEY"
132
+ )
133
+
134
+ if not api_key:
135
+ return (
136
+ jsonify(
137
+ {
138
+ "error": "API key is required. Provide 'api_key' in the request or set GEMINI_API_KEY environment variable."
139
+ }
140
+ ),
141
+ 400,
142
+ )
143
+
144
+ # Get image data
145
+ file_bytes = None
146
+
147
+ # Check for file upload
148
+ if "image" in request.files:
149
+ file = request.files["image"]
150
+ if file.filename == "":
151
+ return jsonify({"error": "No file selected"}), 400
152
+ if not allowed_file(file.filename):
153
+ return (
154
+ jsonify(
155
+ {
156
+ "error": f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
157
+ }
158
+ ),
159
+ 400,
160
+ )
161
+ file_bytes = file.read()
162
+
163
+ # Check for base64 image
164
+ elif request.is_json and "image_base64" in request.json:
165
+ try:
166
+ file_bytes = base64.b64decode(request.json["image_base64"])
167
+ except Exception as e:
168
+ return jsonify({"error": f"Invalid base64 image: {str(e)}"}), 400
169
+
170
+ else:
171
+ return (
172
+ jsonify(
173
+ {
174
+ "error": "No image provided. Use 'image' file upload or 'image_base64' in JSON."
175
+ }
176
+ ),
177
+ 400,
178
+ )
179
+
180
+ # Get processing parameters
181
+ if request.is_json:
182
+ params = request.json
183
+ else:
184
+ params = request.form
185
+
186
+ sat_factor = float(params.get("sat_factor", DEFAULT_SAT_FACTOR))
187
+ gap_size = int(params.get("gap_size", DEFAULT_GAP_SIZE))
188
+ noise_threshold = int(params.get("noise_threshold", DEFAULT_NOISE_THRESHOLD))
189
+ total_duration = int(params.get("total_duration", DEFAULT_TOTAL_DURATION))
190
+ crop_option = str(params.get("crop_option", "true")).lower() == "true"
191
+ analysis_method = params.get("analysis_method", "image")
192
+ model_name = params.get("model_name", DEFAULT_MODEL_NAME)
193
+ include_debug_images = (
194
+ str(params.get("include_debug_images", "false")).lower() == "true"
195
+ )
196
+
197
+ # Step 1: Extract curves from image
198
+ df_result, debug_images, bounds, error_msg, _ = process_uploaded_image(
199
+ file_bytes,
200
+ sat_factor,
201
+ gap_size,
202
+ noise_threshold,
203
+ crop_option,
204
+ total_duration,
205
+ )
206
+
207
+ if error_msg:
208
+ return (
209
+ jsonify(
210
+ {
211
+ "error": f"Curve extraction failed: {error_msg}",
212
+ "stage": "extraction",
213
+ }
214
+ ),
215
+ 400,
216
+ )
217
+
218
+ # Step 2: Get LLM segmentation
219
+ cropped_bytes = None
220
+ if bounds:
221
+ try:
222
+ sx, ex = bounds
223
+ nparr = np.frombuffer(file_bytes, np.uint8)
224
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
225
+ if img is not None:
226
+ cropped_img = img[:, sx:ex]
227
+ is_success, buffer = cv2.imencode(".jpg", cropped_img)
228
+ if is_success:
229
+ cropped_bytes = buffer.tobytes()
230
+ except Exception as e:
231
+ pass # Continue without cropped image
232
+
233
+ df_result, result_json = ask_llm_for_breakage(
234
+ df_result, api_key, model_name, image_bytes=cropped_bytes
235
+ )
236
+
237
+ if not result_json or "error" in result_json:
238
+ return (
239
+ jsonify(
240
+ {
241
+ "error": "AI segmentation failed",
242
+ "details": (
243
+ result_json.get("error") if result_json else "Unknown error"
244
+ ),
245
+ "stage": "segmentation",
246
+ }
247
+ ),
248
+ 400,
249
+ )
250
+
251
+ # Step 3: Perform zone health analysis
252
+ zone_analysis = {}
253
+ analysis_type = ""
254
+ analysis_data = None
255
+ executive_lead = None
256
+ issues = []
257
+
258
+ success_expert_image = False
259
+
260
+ if analysis_method.lower() == "image":
261
+ # Image-based analysis
262
+ numerical_context = {}
263
+ if "Resistance" in df_result.columns:
264
+ valid_res = df_result["Resistance"].dropna()
265
+ if not valid_res.empty:
266
+ numerical_context["min_resistance"] = float(valid_res.min())
267
+ numerical_context["median_resistance"] = float(valid_res.median())
268
+
269
+ img_bytes_for_analysis = cropped_bytes if cropped_bytes else file_bytes
270
+ llm_response = analyze_health_with_llm(
271
+ img_bytes_for_analysis, api_key, model_name, numerical_context
272
+ )
273
+
274
+ if isinstance(llm_response, dict) and "error" in llm_response:
275
+ analysis_type = "Image-Based (Failed) - Fallback to CSV"
276
+ success_expert_image = False
277
+ else:
278
+ analysis_data = safe_parse_llm_json(llm_response)
279
+
280
+ if analysis_data:
281
+ executive_lead = llm_response.split("{")[0].strip()
282
+ if "```json" in executive_lead:
283
+ executive_lead = executive_lead.replace("```json", "").strip()
284
+
285
+ issues = analysis_data.get("detected_issues", [])
286
+
287
+ extracted_score = analysis_data.get("health_score")
288
+ status = analysis_data.get("overall_condition", "Unknown")
289
+
290
+ if extracted_score is None:
291
+ if status == "Healthy":
292
+ extracted_score = 100
293
+ elif status == "Warning":
294
+ extracted_score = 60
295
+ elif status == "Critical":
296
+ extracted_score = 20
297
+ else:
298
+ extracted_score = 0
299
+
300
+ zone_analysis = {
301
+ "overall_health": {
302
+ "status": status,
303
+ "overall_score": extracted_score,
304
+ "recommendation": analysis_data.get(
305
+ "maintenance_recommendation"
306
+ ),
307
+ "total_issues": len(issues),
308
+ "critical_issues": [],
309
+ }
310
+ }
311
+ analysis_type = "Expert Image Diagnostic"
312
+ success_expert_image = True
313
+ else:
314
+ analysis_type = "Image-Based (Parse Error) - Fallback to CSV"
315
+ success_expert_image = False
316
+
317
+ # Fallback to CSV analysis
318
+ if not success_expert_image:
319
+ analyzer = ZoneAnalyzer(df_result, result_json)
320
+ zone_analysis = analyzer.analyze_all_zones()
321
+ analysis_type = "CSV-Based"
322
+
323
+ # Prepare response
324
+ response_data = {
325
+ "success": True,
326
+ "analysis_type": analysis_type,
327
+ "segmentation": convert_numpy_types(result_json),
328
+ "zone_analysis": convert_numpy_types(zone_analysis),
329
+ "curve_data": {
330
+ "columns": df_result.columns.tolist(),
331
+ "data": df_result.to_dict(orient="records"),
332
+ "num_points": len(df_result),
333
+ },
334
+ "processing_params": {
335
+ "sat_factor": sat_factor,
336
+ "gap_size": gap_size,
337
+ "noise_threshold": noise_threshold,
338
+ "total_duration": total_duration,
339
+ "crop_option": crop_option,
340
+ },
341
+ }
342
+
343
+ # Add expert analysis details if available
344
+ if analysis_data:
345
+ response_data["expert_analysis"] = {
346
+ "executive_summary": executive_lead,
347
+ "detailed_analysis": convert_numpy_types(analysis_data),
348
+ "issues": convert_numpy_types(issues),
349
+ }
350
+
351
+ # Include debug images if requested
352
+ if include_debug_images and debug_images:
353
+ response_data["debug_images"] = {}
354
+ for name, img in debug_images.items():
355
+ img_b64 = image_to_base64(img)
356
+ if img_b64:
357
+ response_data["debug_images"][name] = img_b64
358
+
359
+ return jsonify(convert_numpy_types(response_data))
360
+
361
+ except Exception as e:
362
+ import traceback
363
+
364
+ return (
365
+ jsonify(
366
+ {
367
+ "error": f"Internal server error: {str(e)}",
368
+ "traceback": traceback.format_exc(),
369
+ }
370
+ ),
371
+ 500,
372
+ )
373
+
374
+
375
+ @app.route("/extract-curves", methods=["POST"])
376
+ def extract_curves():
377
+ """
378
+ Lightweight endpoint that only extracts curves without LLM analysis.
379
+ Useful for quick data extraction without AI processing.
380
+
381
+ Expects:
382
+ - image: File upload (multipart/form-data) or base64 encoded image
383
+ - sat_factor, gap_size, noise_threshold, total_duration, crop_option (optional)
384
+
385
+ Returns:
386
+ JSON with extracted curve data
387
+ """
388
+ try:
389
+ # Get image data
390
+ file_bytes = None
391
+
392
+ if "image" in request.files:
393
+ file = request.files["image"]
394
+ if file.filename == "":
395
+ return jsonify({"error": "No file selected"}), 400
396
+ if not allowed_file(file.filename):
397
+ return (
398
+ jsonify(
399
+ {
400
+ "error": f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
401
+ }
402
+ ),
403
+ 400,
404
+ )
405
+ file_bytes = file.read()
406
+
407
+ elif request.is_json and "image_base64" in request.json:
408
+ try:
409
+ file_bytes = base64.b64decode(request.json["image_base64"])
410
+ except Exception as e:
411
+ return jsonify({"error": f"Invalid base64 image: {str(e)}"}), 400
412
+
413
+ else:
414
+ return jsonify({"error": "No image provided"}), 400
415
+
416
+ # Get processing parameters
417
+ if request.is_json:
418
+ params = request.json
419
+ else:
420
+ params = request.form
421
+
422
+ sat_factor = float(params.get("sat_factor", DEFAULT_SAT_FACTOR))
423
+ gap_size = int(params.get("gap_size", DEFAULT_GAP_SIZE))
424
+ noise_threshold = int(params.get("noise_threshold", DEFAULT_NOISE_THRESHOLD))
425
+ total_duration = int(params.get("total_duration", DEFAULT_TOTAL_DURATION))
426
+ crop_option = str(params.get("crop_option", "true")).lower() == "true"
427
+ include_debug_images = (
428
+ str(params.get("include_debug_images", "false")).lower() == "true"
429
+ )
430
+
431
+ # Extract curves
432
+ df_result, debug_images, bounds, error_msg, _ = process_uploaded_image(
433
+ file_bytes,
434
+ sat_factor,
435
+ gap_size,
436
+ noise_threshold,
437
+ crop_option,
438
+ total_duration,
439
+ )
440
+
441
+ if error_msg:
442
+ return jsonify({"error": f"Curve extraction failed: {error_msg}"}), 400
443
+
444
+ response_data = {
445
+ "success": True,
446
+ "curve_data": {
447
+ "columns": df_result.columns.tolist(),
448
+ "data": df_result.to_dict(orient="records"),
449
+ "num_points": len(df_result),
450
+ },
451
+ "bounds": bounds,
452
+ "processing_params": {
453
+ "sat_factor": sat_factor,
454
+ "gap_size": gap_size,
455
+ "noise_threshold": noise_threshold,
456
+ "total_duration": total_duration,
457
+ "crop_option": crop_option,
458
+ },
459
+ }
460
+
461
+ if include_debug_images and debug_images:
462
+ response_data["debug_images"] = {}
463
+ for name, img in debug_images.items():
464
+ img_b64 = image_to_base64(img)
465
+ if img_b64:
466
+ response_data["debug_images"][name] = img_b64
467
+
468
+ return jsonify(convert_numpy_types(response_data))
469
+
470
+ except Exception as e:
471
+ import traceback
472
+
473
+ return (
474
+ jsonify(
475
+ {
476
+ "error": f"Internal server error: {str(e)}",
477
+ "traceback": traceback.format_exc(),
478
+ }
479
+ ),
480
+ 500,
481
+ )
482
+
483
+
484
+ @app.errorhandler(413)
485
+ def too_large(e):
486
+ return jsonify({"error": "File too large. Maximum size is 16MB."}), 413
487
+
488
+
489
+ @app.errorhandler(404)
490
+ def not_found(e):
491
+ return jsonify({"error": "Endpoint not found"}), 404
492
+
493
+
494
+ @app.errorhandler(500)
495
+ def internal_error(e):
496
+ return jsonify({"error": "Internal server error"}), 500
497
+
498
+
499
+ if __name__ == "__main__":
500
+ # Get port from environment or use default (7860 for Hugging Face Spaces)
501
+ port = int(os.environ.get("PORT", 7860))
502
+ debug = os.environ.get("FLASK_DEBUG", "false").lower() == "true"
503
+
504
+ print(
505
+ f"""
506
+ ╔══════════════════════════════════════════════════════════════╗
507
+ β•‘ DCRM Analysis API - Flask Server β•‘
508
+ ╠══════════════════════════════════════════════════════════════╣
509
+ β•‘ Endpoints: β•‘
510
+ β•‘ GET /health - Health check β•‘
511
+ β•‘ POST /analyze - Full DCRM analysis with AI β•‘
512
+ β•‘ POST /extract-curves - Extract curves only (no AI) β•‘
513
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
514
+ """
515
+ )
516
+
517
+ app.run(host="0.0.0.0", port=port, debug=debug)
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core
2
+ flask>=3.0.0
3
+ flask-cors>=4.0.0
4
+
5
+ # Image Processing
6
+ opencv-python-headless>=4.8.0
7
+ numpy>=1.24.0
8
+ pandas>=2.0.0
9
+ Pillow>=10.0.0
10
+
11
+ # Google Generative AI (Gemini)
12
+ google-generativeai>=0.3.0
13
+
14
+ # Plotting
15
+ plotly>=5.17.0
16
+
17
+ # Utilities
18
+ python-dotenv>=1.0.0
response.json ADDED
The diff for this file is too large to render. See raw diff