File size: 18,902 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
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
"""
Enhanced Database Connector for CSH2 Web Dashboard
Smart table routing, connection health checks, pivot data support
"""
import psycopg2
from psycopg2.extras import RealDictCursor
import pandas as pd
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timezone
import streamlit as st
from core import config


class DatabaseConnector:
    """Handles all database operations with smart table routing"""

    def __init__(self):
        self.conn = None
        self.tag_cache = {}
        self.index_to_tag = {}
        self._connect()
        self._load_tag_cache()

    def _connect(self):
        try:
            self.conn = psycopg2.connect(**config.DB_CONFIG)
            self.conn.autocommit = True
        except Exception as e:
            raise ConnectionError(f"Database connection failed: {e}")

    def _ensure_connection(self):
        """Reconnect if the connection has gone stale"""
        try:
            with self.conn.cursor() as cur:
                cur.execute("SELECT 1")
        except Exception:
            self._connect()

    def _load_tag_cache(self):
        try:
            query = f"SELECT tagindex, tagname FROM {config.TABLES['tag_metadata']}"
            df = pd.read_sql(query, self.conn)
            self.tag_cache = {row['tagname']: row['tagindex'] for _, row in df.iterrows()}
            self.index_to_tag = {row['tagindex']: row['tagname'] for _, row in df.iterrows()}
        except Exception as e:
            self.tag_cache = {}
            self.index_to_tag = {}

    def get_tag_index(self, tag_name: str) -> Optional[int]:
        return self.tag_cache.get(tag_name) or self.tag_cache.get(tag_name.upper())

    def get_tag_name(self, tag_index: int) -> Optional[str]:
        return self.index_to_tag.get(tag_index)

    def get_all_tag_names(self) -> List[str]:
        return sorted(self.tag_cache.keys())

    def _select_table(self, start_time: datetime, end_time: datetime) -> str:
        """Select the optimal table based on time range duration"""
        duration_seconds = (end_time - start_time).total_seconds()
        routing = config.TABLE_ROUTING

        if duration_seconds <= routing['raw_max_seconds']:
            return config.TABLES['raw']
        elif duration_seconds <= routing['agg_1sec_max_seconds']:
            return config.TABLES['agg_1sec']
        else:
            return config.TABLES['agg_15sec']

    def get_sensor_data(
        self,
        tag_names: List[str],
        start_time: datetime,
        end_time: datetime,
        table_override: Optional[str] = None,
        limit: Optional[int] = None,
    ) -> pd.DataFrame:
        """
        Fetch sensor data with smart table routing.

        Returns DataFrame with columns: timestamp, tag_name, value
        """
        self._ensure_connection()

        tag_indices = []
        valid_tags = []
        for tag in tag_names:
            idx = self.get_tag_index(tag)
            if idx is not None:
                tag_indices.append(idx)
                valid_tags.append(tag)

        if not tag_indices:
            return pd.DataFrame(columns=['timestamp', 'tag_name', 'value'])

        table = table_override or self._select_table(start_time, end_time)
        tag_list = ', '.join(map(str, tag_indices))
        limit_clause = f"LIMIT {limit}" if limit else f"LIMIT {config.MAX_ROWS_RETURN}"

        query = f"""
        SELECT
            utc_full_timestamp as timestamp,
            tagindex,
            val as value
        FROM {table}
        WHERE tagindex IN ({tag_list})
          AND utc_full_timestamp >= %s
          AND utc_full_timestamp <= %s
        ORDER BY utc_full_timestamp, tagindex
        {limit_clause}
        """

        try:
            df = pd.read_sql(query, self.conn, params=(start_time, end_time))
            df['tag_name'] = df['tagindex'].map(self.index_to_tag)
            df = df[['timestamp', 'tag_name', 'value']]
            return df
        except Exception as e:
            st.error(f"Query failed: {e}")
            return pd.DataFrame(columns=['timestamp', 'tag_name', 'value'])

    def get_pivot_data(
        self,
        tag_names: List[str],
        start_time: datetime,
        end_time: datetime,
    ) -> pd.DataFrame:
        """Fetch data and return in wide (pivoted) format"""
        df = self.get_sensor_data(tag_names, start_time, end_time)
        if df.empty:
            return df

        pivot_df = df.pivot_table(
            index='timestamp',
            columns='tag_name',
            values='value',
        ).reset_index()

        pivot_df.columns.name = None
        return pivot_df

    def get_available_months(self) -> List[str]:
        """Get distinct months where data exists (using 15sec table for speed)"""
        self._ensure_connection()
        query = f"""
        SELECT DISTINCT date_trunc('month', utc_full_timestamp)::date as month
        FROM {config.TABLES['agg_15sec']}
        ORDER BY month
        """
        try:
            df = pd.read_sql(query, self.conn)
            return [row['month'].strftime('%Y-%m') for _, row in df.iterrows()]
        except Exception:
            # Fallback: use raw table with a limit
            try:
                query = f"""
                SELECT DISTINCT date_trunc('month', utc_full_timestamp)::date as month
                FROM {config.TABLES['raw']}
                ORDER BY month
                """
                df = pd.read_sql(query, self.conn)
                return [row['month'].strftime('%Y-%m') for _, row in df.iterrows()]
            except Exception:
                return []

    def get_time_range(self) -> Tuple[Optional[datetime], Optional[datetime]]:
        """Get overall time range of available data"""
        self._ensure_connection()
        query = f"""
        SELECT MIN(utc_full_timestamp) as earliest, MAX(utc_full_timestamp) as latest
        FROM {config.TABLES['agg_15sec']}
        """
        try:
            df = pd.read_sql(query, self.conn)
            return df['earliest'][0], df['latest'][0]
        except Exception:
            return None, None

    def get_data_summary(
        self,
        tag_name: str,
        start_time: datetime,
        end_time: datetime,
    ) -> Dict:
        """Get statistical summary for a single tag"""
        self._ensure_connection()
        tag_index = self.get_tag_index(tag_name)
        if tag_index is None:
            return {'error': f'Tag {tag_name} not found'}

        table = self._select_table(start_time, end_time)
        query = f"""
        SELECT COUNT(*) as count, MIN(val) as min_value, MAX(val) as max_value,
               AVG(val) as avg_value, STDDEV(val) as std_value
        FROM {table}
        WHERE tagindex = %s AND utc_full_timestamp >= %s AND utc_full_timestamp <= %s
        """
        try:
            with self.conn.cursor(cursor_factory=RealDictCursor) as cur:
                cur.execute(query, (tag_index, start_time, end_time))
                result = cur.fetchone()
                return dict(result)
        except Exception as e:
            return {'error': str(e)}

    def check_table_exists(self, table_name: str) -> bool:
        """Check if a table exists in the database"""
        self._ensure_connection()
        query = """
        SELECT EXISTS (
            SELECT FROM information_schema.tables
            WHERE table_schema = 'public' AND table_name = %s
        )
        """
        try:
            with self.conn.cursor() as cur:
                cur.execute(query, (table_name,))
                return cur.fetchone()[0]
        except Exception:
            return False

    def get_cooldown_periods(self, start_time: datetime, end_time: datetime) -> pd.DataFrame:
        """Query cooldown_periods table if it exists"""
        self._ensure_connection()
        if not self.check_table_exists('cooldown_periods'):
            return pd.DataFrame()

        query = """
        SELECT cooldown_start, cooldown_end, start_temperature, end_temperature, duration_sec
        FROM cooldown_periods
        WHERE cooldown_start >= %s AND cooldown_start <= %s
        ORDER BY cooldown_start
        """
        try:
            return pd.read_sql(query, self.conn, params=(start_time, end_time))
        except Exception:
            return pd.DataFrame()

    def resolve_motor_speed_tag(self) -> Optional[str]:
        """Find which motor speed tag exists in the database"""
        for tag in config.MOTOR_SPEED_TAGS:
            if tag in self.tag_cache:
                return tag
        # Search for any tag containing 'speed' or 'rpm'
        for tag_name in self.tag_cache:
            if 'speed' in tag_name.lower() or 'rpm' in tag_name.lower():
                return tag_name
        return None

    def get_cycle_periods(self, month_str: str = None) -> List[Dict]:
        """Fetch pre-computed cycles from the cycle_periods table.

        Args:
            month_str: Optional "YYYY-MM" filter. None returns all cycles.

        Returns:
            List of cycle dicts in the format expected by downstream pages
            (cycle_id, start_time, end_time, duration_minutes, peak_speed,
            peak_pressure, avg_flow, etc.), plus new fields from the DB.
        """
        self._ensure_connection()

        query = """
        SELECT id, cycle_start, cycle_end, duration_sec,
               max_motor_speed, max_flow_rate, avg_flow_rate,
               total_kg_dispensed, min_discharge_pressure, max_discharge_pressure,
               min_fill_temperature, max_fill_temperature, avg_ambient_temperature,
               cryotank_start_pressure, cryotank_end_pressure, total_pump_strokes
        FROM cycle_periods
        """
        params = []
        if month_str:
            year, month = map(int, month_str.split('-'))
            if month == 12:
                next_year, next_month = year + 1, 1
            else:
                next_year, next_month = year, month + 1
            query += " WHERE cycle_start >= %s AND cycle_start < %s"
            params = [
                datetime(year, month, 1, tzinfo=timezone.utc),
                datetime(next_year, next_month, 1, tzinfo=timezone.utc),
            ]
        query += " ORDER BY cycle_start"

        try:
            df = pd.read_sql(query, self.conn, params=params or None)
        except Exception:
            return []

        if df.empty:
            return []

        from core.timezone import cycle_periods_to_utc, cycle_periods_to_eastern

        speed_mult = getattr(config, 'MOTOR_SPEED_DISPLAY_MULTIPLIER', 1.0)

        cycles = []
        for _, row in df.iterrows():
            raw_start = row['cycle_start'].to_pydatetime() if hasattr(row['cycle_start'], 'to_pydatetime') else row['cycle_start']
            raw_end = row['cycle_end'].to_pydatetime() if hasattr(row['cycle_end'], 'to_pydatetime') else row['cycle_end']

            cycles.append({
                # Times corrected to real UTC — use these for all DB queries
                'start_time': cycle_periods_to_utc(raw_start),
                'end_time': cycle_periods_to_utc(raw_end),
                # Eastern display times — use these for all UI rendering
                'start_time_et': cycle_periods_to_eastern(raw_start),
                'end_time_et': cycle_periods_to_eastern(raw_end),
                # Standard fields
                'cycle_id': int(row['id']),
                'duration_minutes': float(row['duration_sec']) / 60.0,
                'peak_speed': float(row['max_motor_speed'] or 0) * speed_mult,
                'peak_pressure': float(row['max_discharge_pressure'] or 0),
                'initial_pressure': float(row['min_discharge_pressure'] or 0),
                'avg_flow': float(row['avg_flow_rate'] or 0),
                'peak_TT110': float(row['max_fill_temperature'] or 0),
                # Additional fields from cycle_periods table
                'max_flow_rate': float(row['max_flow_rate'] or 0),
                'total_kg_dispensed': float(row['total_kg_dispensed'] or 0),
                'min_fill_temperature': float(row['min_fill_temperature'] or 0),
                'avg_ambient_temperature': float(row['avg_ambient_temperature'] or 0),
                'cryotank_start_pressure': float(row['cryotank_start_pressure'] or 0),
                'cryotank_end_pressure': float(row['cryotank_end_pressure'] or 0),
                'total_pump_strokes': float(row['total_pump_strokes'] or 0),
            })
        return cycles

    def get_cycle_months(self) -> List[str]:
        """Get distinct months where cycles exist in cycle_periods table."""
        self._ensure_connection()
        query = """
        SELECT DISTINCT date_trunc('month', cycle_start)::date as month
        FROM cycle_periods
        ORDER BY month
        """
        try:
            df = pd.read_sql(query, self.conn)
            return [row['month'].strftime('%Y-%m') for _, row in df.iterrows()]
        except Exception:
            return []

    # ── Text Search Methods ──

    def search_memo_log(
        self,
        query: str,
        limit: int = 20,
        engineer: Optional[str] = None,
        severity: Optional[str] = None,
    ) -> pd.DataFrame:
        """Full-text ranked search across engineer field notes in memo_log.

        Uses PostgreSQL tsvector/tsquery with ts_rank for BM25-style relevance.
        Searches across summary, issues_found, raw_transcript, maintenance_done,
        and system_performance columns.
        """
        self._ensure_connection()
        sql = """
        SELECT id, logged_at, engineer, severity, activity_type,
               summary, issues_found, maintenance_done, system_performance,
               components_affected,
               ts_rank(
                   to_tsvector('english',
                       coalesce(summary, '') || ' ' ||
                       coalesce(issues_found, '') || ' ' ||
                       coalesce(raw_transcript, '') || ' ' ||
                       coalesce(maintenance_done, '') || ' ' ||
                       coalesce(system_performance, '')
                   ),
                   plainto_tsquery('english', %(query)s)
               ) AS rank
        FROM memo_log
        WHERE to_tsvector('english',
                  coalesce(summary, '') || ' ' ||
                  coalesce(issues_found, '') || ' ' ||
                  coalesce(raw_transcript, '') || ' ' ||
                  coalesce(maintenance_done, '') || ' ' ||
                  coalesce(system_performance, '')
              ) @@ plainto_tsquery('english', %(query)s)
        """
        params: Dict = {'query': query}
        if engineer:
            sql += " AND engineer = %(engineer)s"
            params['engineer'] = engineer
        if severity:
            sql += " AND severity = %(severity)s"
            params['severity'] = severity
        sql += " ORDER BY rank DESC LIMIT %(limit)s"
        params['limit'] = limit

        try:
            return pd.read_sql(sql, self.conn, params=params)
        except Exception as e:
            st.error(f"Memo search failed: {e}")
            return pd.DataFrame()

    def search_events(
        self,
        query: str,
        condition: Optional[str] = None,
        limit: int = 50,
    ) -> pd.DataFrame:
        """Search PLC alarm events using both full-text and pattern matching.

        Alarm messages contain sensor tags in bracket notation (e.g. [AI_PT110_Alarm]),
        so we use ILIKE alongside tsvector to catch tag name patterns.
        """
        self._ensure_connection()
        # Use ILIKE for pattern matching (catches sensor names in brackets)
        # plus tsvector for any natural text content
        sql = """
        SELECT eventtimestamp, conditionname, message, inputvalue, limitvalue
        FROM allevent
        WHERE (
            message ILIKE %(pattern)s
            OR conditionname ILIKE %(pattern)s
        )
        """
        params: Dict = {'query': query, 'pattern': f'%{query}%', 'limit': limit}
        if condition:
            sql += " AND conditionname = %(condition)s"
            params['condition'] = condition
        sql += " ORDER BY eventtimestamp DESC LIMIT %(limit)s"
        try:
            return pd.read_sql(sql, self.conn, params=params)
        except Exception as e:
            st.error(f"Event search failed: {e}")
            return pd.DataFrame()

    def get_event_conditions(self) -> List[str]:
        """Get distinct condition types from allevent for filter dropdown."""
        self._ensure_connection()
        try:
            with self.conn.cursor() as cur:
                cur.execute(
                    "SELECT conditionname, COUNT(*) as cnt FROM allevent "
                    "GROUP BY conditionname ORDER BY cnt DESC"
                )
                return [row[0] for row in cur.fetchall() if row[0]]
        except Exception:
            return []

    def search_tags_fuzzy(self, query: str, limit: int = 15) -> pd.DataFrame:
        """Fuzzy tag/sensor search using pg_trgm similarity on tagimport."""
        self._ensure_connection()
        sql = """
        SELECT tagname, tagdesc, tagunit,
               greatest(
                   similarity(lower(tagname), lower(%(query)s)),
                   similarity(lower(coalesce(tagdesc, '')), lower(%(query)s))
               ) AS sim
        FROM tagimport
        WHERE lower(tagname) %% lower(%(query)s)
           OR lower(coalesce(tagdesc, '')) %% lower(%(query)s)
        ORDER BY sim DESC
        LIMIT %(limit)s
        """
        try:
            return pd.read_sql(sql, self.conn, params={'query': query, 'limit': limit})
        except Exception as e:
            st.error(f"Tag search failed: {e}")
            return pd.DataFrame()

    def get_memo_engineers(self) -> List[str]:
        """Get distinct engineers from memo_log for filter dropdown."""
        self._ensure_connection()
        try:
            with self.conn.cursor() as cur:
                cur.execute("SELECT DISTINCT engineer FROM memo_log WHERE engineer IS NOT NULL ORDER BY engineer")
                return [row[0] for row in cur.fetchall()]
        except Exception:
            return []

    def get_memo_severities(self) -> List[str]:
        """Get distinct severity levels from memo_log for filter dropdown."""
        self._ensure_connection()
        try:
            with self.conn.cursor() as cur:
                cur.execute("SELECT DISTINCT severity FROM memo_log WHERE severity IS NOT NULL ORDER BY severity")
                return [row[0] for row in cur.fetchall()]
        except Exception:
            return []

    def close(self):
        if self.conn:
            self.conn.close()


@st.cache_resource
def get_db_connector() -> DatabaseConnector:
    """Create a singleton database connector"""
    return DatabaseConnector()