File size: 11,452 Bytes
199bfa3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
"""
Testing Cycle Detection Module
Detects testing operation cycles from motor speed time series data,
qualifies them by pressure/flow activity, and ranks by significance.
"""
import pandas as pd
import numpy as np
from typing import List, Dict, Optional
from datetime import datetime
from core import config


class PumpCycleDetector:
    """Detect and qualify testing cycles using motor speed state machine + multi-signal qualification.

    Two-stage pipeline:
      1. Candidate generation: motor speed thresholding finds all motor-on periods
      2. Qualification: filters out brief jogs/false starts using pressure and flow data
    """

    def __init__(self):
        params = config.CYCLE_DETECTION
        self.idle_threshold = params['idle_threshold']
        self.min_cycle_seconds = params['min_cycle_seconds']
        self.smoothing_window = params['smoothing_window']
        self.max_gap_seconds = params['max_gap_seconds']
        # Qualification thresholds
        self.min_peak_pressure = params.get('min_peak_pressure_bar', 50.0)
        self.min_pressure_rise = params.get('min_pressure_rise_bar', 10.0)
        self.min_avg_flow = params.get('min_avg_flow_kg_min', 0.05)
        self.max_cycle_duration_min = params.get('max_cycle_duration_hours', 8.0) * 60

    def detect_cycles(self, motor_speed_df: pd.DataFrame) -> List[Dict]:
        """
        Detect testing cycles from motor speed time series.

        Args:
            motor_speed_df: DataFrame with columns [timestamp, value]
                            where value is motor speed in RPM

        Returns:
            List of cycle dicts with keys:
                cycle_id, start_time, end_time, duration_minutes, peak_speed
        """
        if motor_speed_df.empty or len(motor_speed_df) < 10:
            return []

        df = motor_speed_df.sort_values('timestamp').copy()
        df['speed_smooth'] = self._smooth_signal(df['value'])
        df['is_running'] = df['speed_smooth'] > self.idle_threshold

        # Find transitions
        df['state_change'] = df['is_running'].astype(int).diff().fillna(0)

        # Start events: state_change == 1 (idle -> running)
        starts = df[df['state_change'] == 1]['timestamp'].tolist()
        # Stop events: state_change == -1 (running -> idle)
        stops = df[df['state_change'] == -1]['timestamp'].tolist()

        if not starts and not stops:
            # Check if entire period is running
            if df['is_running'].any():
                return [{
                    'cycle_id': 1,
                    'start_time': df['timestamp'].iloc[0],
                    'end_time': df['timestamp'].iloc[-1],
                    'duration_minutes': (df['timestamp'].iloc[-1] - df['timestamp'].iloc[0]).total_seconds() / 60,
                    'peak_speed': df['value'].max(),
                }]
            return []

        # Pair starts and stops into cycles
        cycles = self._pair_transitions(starts, stops, df)

        # Filter by minimum duration
        cycles = [c for c in cycles if c['duration_seconds'] >= self.min_cycle_seconds]

        # Merge cycles with short gaps between them
        cycles = self._merge_short_gaps(cycles)

        # Compute peak speed for each cycle
        for c in cycles:
            mask = (df['timestamp'] >= c['start_time']) & (df['timestamp'] <= c['end_time'])
            cycle_data = df[mask]
            c['peak_speed'] = cycle_data['value'].max() if not cycle_data.empty else 0

        # Assign sequential IDs
        for i, c in enumerate(cycles, 1):
            c['cycle_id'] = i
            c['duration_minutes'] = c['duration_seconds'] / 60
            del c['duration_seconds']

        return cycles

    def _smooth_signal(self, series: pd.Series) -> pd.Series:
        """Apply rolling median filter to reduce noise"""
        return series.rolling(window=self.smoothing_window, center=True, min_periods=1).median()

    def _pair_transitions(
        self,
        starts: List[datetime],
        stops: List[datetime],
        df: pd.DataFrame,
    ) -> List[Dict]:
        """Pair start/stop transitions into cycles"""
        cycles = []

        # If first data point is already running, prepend a synthetic start
        if df['is_running'].iloc[0]:
            starts = [df['timestamp'].iloc[0]] + starts

        # If last data point is still running, append a synthetic stop
        if df['is_running'].iloc[-1]:
            stops = stops + [df['timestamp'].iloc[-1]]

        # Match each start with the next stop
        stop_idx = 0
        for start in starts:
            # Find the next stop after this start
            while stop_idx < len(stops) and stops[stop_idx] <= start:
                stop_idx += 1

            if stop_idx < len(stops):
                end = stops[stop_idx]
                duration = (end - start).total_seconds()
                cycles.append({
                    'start_time': start,
                    'end_time': end,
                    'duration_seconds': duration,
                })
                stop_idx += 1

        return cycles

    def _merge_short_gaps(self, cycles: List[Dict]) -> List[Dict]:
        """Merge cycles separated by brief motor stops"""
        if len(cycles) <= 1:
            return cycles

        merged = [cycles[0].copy()]
        for c in cycles[1:]:
            gap = (c['start_time'] - merged[-1]['end_time']).total_seconds()
            if gap <= self.max_gap_seconds:
                # Merge: extend the previous cycle
                merged[-1]['end_time'] = c['end_time']
                merged[-1]['duration_seconds'] = (
                    merged[-1]['end_time'] - merged[-1]['start_time']
                ).total_seconds()
            else:
                merged.append(c.copy())

        return merged

    def _qualify_cycle(self, cycle: Dict) -> bool:
        """Check if a cycle is a meaningful testing event.

        A cycle must show evidence of actual compression (pressure) or flow
        to qualify. Motor speed and duration alone are insufficient — they
        only confirm the motor ran, not that meaningful work happened.

        Hard rejects cycles exceeding max_cycle_duration_min (noise spans).
        """
        duration_min = cycle.get('duration_minutes', 0) or 0

        # Hard reject: multi-day noise spans masquerading as cycles
        if duration_min > self.max_cycle_duration_min:
            return False

        peak_pressure = cycle.get('peak_pressure', 0) or 0
        initial_pressure = cycle.get('initial_pressure', 0) or 0
        avg_flow = cycle.get('avg_flow', 0) or 0

        # Criterion 1: Pressure built beyond ambient
        if peak_pressure > self.min_peak_pressure:
            return True

        # Criterion 2: Meaningful pressure rise from initial value
        if initial_pressure > 0 and (peak_pressure - initial_pressure) > self.min_pressure_rise:
            return True

        # Criterion 3: Flow was delivered
        if avg_flow > self.min_avg_flow:
            return True

        return False

    def _sort_and_renumber(self, cycles: List[Dict]) -> List[Dict]:
        """Sort cycles by peak pressure descending (most significant first),
        then by duration as tiebreaker. Re-assign sequential cycle_ids."""
        sorted_cycles = sorted(
            cycles,
            key=lambda c: (-(c.get('peak_pressure', 0) or 0), -(c.get('duration_minutes', 0) or 0)),
        )
        for i, c in enumerate(sorted_cycles, 1):
            c['cycle_id'] = i
        return sorted_cycles

    def enrich_cycle_metadata(
        self,
        cycle: Dict,
        db_connector,
        tags: List[str] = None,
    ) -> Dict:
        """
        Add peak pressure, peak temp, avg flow to a cycle's metadata.

        Args:
            cycle: cycle dict with start_time, end_time
            db_connector: DatabaseConnector instance
            tags: list of tags to query (defaults to CYCLE_DETAIL_TAGS)

        Returns:
            Enriched cycle dict with additional metrics
        """
        if tags is None:
            tags = config.CYCLE_DETAIL_TAGS

        df = db_connector.get_sensor_data(
            tags, cycle['start_time'], cycle['end_time'],
        )

        if df.empty:
            return cycle

        enriched = cycle.copy()

        # Peak discharge pressure
        pt130 = df[df['tag_name'] == 'PT130']
        if not pt130.empty:
            enriched['peak_pressure'] = pt130['value'].max()

        # Peak temperatures
        for tag in ['TT110', 'TT130']:
            data = df[df['tag_name'] == tag]
            if not data.empty:
                enriched[f'peak_{tag}'] = data['value'].max()

        # Average flow
        ft140 = df[df['tag_name'] == 'FT140']
        if not ft140.empty:
            enriched['avg_flow'] = ft140['value'].mean()

        return enriched

    def batch_enrich_cycles(
        self,
        cycles: List[Dict],
        db_connector,
        tags: List[str] = None,
        qualify: bool = True,
    ) -> List[Dict]:
        """
        Enrich ALL cycles with metadata using a single DB query,
        then optionally qualify and rank them.

        Args:
            cycles: list of cycle dicts with start_time, end_time
            db_connector: DatabaseConnector instance
            tags: list of tags to query (defaults to key enrichment tags)
            qualify: if True, filter out non-meaningful cycles and sort by significance

        Returns:
            List of enriched (and optionally qualified + sorted) cycle dicts
        """
        if not cycles:
            return cycles

        if tags is None:
            tags = ['PT130', 'TT110', 'TT130', 'FT140']

        # Single query covering the full time span of all cycles
        earliest = min(c['start_time'] for c in cycles)
        latest = max(c['end_time'] for c in cycles)

        df = db_connector.get_sensor_data(
            tags, earliest, latest,
            table_override='procdatafloattable_utc_15sec',
        )

        if df.empty:
            return cycles

        enriched = []
        for c in cycles:
            ec = c.copy()
            mask = (df['timestamp'] >= c['start_time']) & (df['timestamp'] <= c['end_time'])
            cycle_df = df[mask]

            if cycle_df.empty:
                enriched.append(ec)
                continue

            # Peak discharge pressure
            pt130 = cycle_df[cycle_df['tag_name'] == 'PT130']
            if not pt130.empty:
                pt130_sorted = pt130.sort_values('timestamp')
                ec['peak_pressure'] = pt130_sorted['value'].max()
                ec['initial_pressure'] = pt130_sorted['value'].iloc[0]

            # Peak temperatures
            for tag in ['TT110', 'TT130']:
                data = cycle_df[cycle_df['tag_name'] == tag]
                if not data.empty:
                    ec[f'peak_{tag}'] = data['value'].max()

            # Average flow
            ft140 = cycle_df[cycle_df['tag_name'] == 'FT140']
            if not ft140.empty:
                ec['avg_flow'] = ft140['value'].mean()

            enriched.append(ec)

        # Qualification: filter out non-meaningful cycles
        if qualify:
            qualified = [c for c in enriched if self._qualify_cycle(c)]
            # Sort by significance and re-number
            return self._sort_and_renumber(qualified) if qualified else []

        return enriched