Spaces:
Sleeping
Sleeping
Aditya Adaki
commited on
Commit
Β·
fdcec08
1
Parent(s):
6a0e853
Add DCRM Analysis API
Browse files- .gitignore +1 -0
- Dockerfile +26 -0
- dcrm/__init__.py +0 -0
- dcrm/history_manager.py +55 -0
- dcrm/image_processing.py +445 -0
- dcrm/image_processing.py.backup +445 -0
- dcrm/image_zone_analysis.py +512 -0
- dcrm/llm.py +341 -0
- dcrm/llm_copy.py +323 -0
- dcrm/plotting.py +109 -0
- dcrm/report_generator.py +128 -0
- dcrm/zone_analysis.py +658 -0
- flask_app.py +517 -0
- requirements.txt +18 -0
- response.json +0 -0
.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
|
|
|