Spaces:
Sleeping
Sleeping
File size: 7,392 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 | """
Execution Engine for Advanced Data Explorer.
Validates an ExecutionPlan, fetches the required data, dispatches
operations sequentially via OperationDispatcher, and packages results.
"""
import time
import pandas as pd
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
from datetime import datetime
from core import config
from core.db_connector import get_db_connector
from core.cached_queries import cached_get_pivot_data
from analysis.operations import OperationDispatcher, OperationOutput
# ── Result Dataclasses ───────────────────────────────────────────
@dataclass
class ExecutionResult:
"""Complete result of executing a plan."""
success: bool
query_type: str
explanation: str
source_data: Optional[pd.DataFrame] = None
operation_results: List[OperationOutput] = field(default_factory=list)
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
execution_time_seconds: float = 0.0
table_used: str = ""
row_count: int = 0
# ── Table label mapping (for display) ───────────────────────────
TABLE_LABELS = {
config.TABLES['raw']: 'Raw (10Hz)',
config.TABLES['agg_1sec']: '1-second aggregates',
config.TABLES['agg_15sec']: '15-second aggregates',
}
# ── Execution Engine ─────────────────────────────────────────────
class ExecutionEngine:
"""
Validate an ExecutionPlan, fetch data, run operations, package results.
Usage:
engine = ExecutionEngine()
result = engine.execute(plan)
"""
def __init__(self):
self.dispatcher = OperationDispatcher()
def execute(self, plan: Dict) -> ExecutionResult:
"""Execute a validated plan end-to-end."""
t0 = time.time()
errors = []
warnings = []
# ── 1. Validate plan structure ──
qt = plan.get("query_type", "FETCH")
explanation = plan.get("explanation", "")
dr = plan.get("data_requirements", {})
operations = plan.get("operations", [])
sensors = dr.get("sensors", [])
start_time = dr.get("start_time")
end_time = dr.get("end_time")
resolution = dr.get("resolution", "auto")
if not sensors:
return self._fail("No sensors specified in plan", qt, explanation, t0)
if not start_time or not end_time:
return self._fail("Missing start_time or end_time in plan", qt, explanation, t0)
# Ensure datetimes are datetime objects
if isinstance(start_time, str):
start_time = datetime.fromisoformat(start_time)
if isinstance(end_time, str):
end_time = datetime.fromisoformat(end_time)
# ── 2. Resolve table from resolution ──
table_override = None
if resolution in config.RESOLUTION_MAP:
table_override = config.RESOLUTION_MAP[resolution]
elif resolution == "auto":
# Let the DB connector decide via smart routing
table_override = None
else:
warnings.append(f"Unknown resolution '{resolution}', using auto routing.")
# ── 3. Safety: raw table time-range cap ──
if table_override == config.TABLES['raw']:
duration_h = (end_time - start_time).total_seconds() / 3600
if duration_h > 1.0:
return self._fail(
f"Raw resolution limited to 1 hour. Requested {duration_h:.1f} hours. "
f"Use '1sec' or '15sec' for longer ranges.",
qt, explanation, t0,
)
# ── 4. Fetch data ──
try:
db = get_db_connector()
df = cached_get_pivot_data(
tag_names=tuple(sensors),
start_time=start_time,
end_time=end_time,
table_override=table_override,
)
except Exception as e:
return self._fail(f"Data fetch failed: {e}", qt, explanation, t0)
if df is None or df.empty:
return self._fail(
"No data returned for the specified sensors and time range. "
"Check that the sensors exist and the time range is within March 14 – September 25, 2025.",
qt, explanation, t0,
)
# Determine which table was actually used (for display)
if table_override:
table_used = table_override
else:
table_used = db._select_table(start_time, end_time)
table_label = TABLE_LABELS.get(table_used, table_used)
# Check which sensors actually came back
returned_sensors = [s for s in sensors if s in df.columns]
missing = [s for s in sensors if s not in df.columns and s != "timestamp"]
if missing:
warnings.append(f"Sensors not found in data: {', '.join(missing)}")
# ── 5. Run operations sequentially ──
op_results = []
prev_result = None
for i, op_spec in enumerate(operations):
op_name = op_spec.get("op", "")
op_params = op_spec.get("params", {})
op_label = op_spec.get("label", op_name)
try:
output = self.dispatcher.run(
op_name=op_name,
df=df,
params=op_params,
prev_result=prev_result,
db_connector=get_db_connector(),
)
op_results.append(output)
prev_result = output
# Check for operation-level errors
if output.metadata.get("error"):
warnings.append(f"Operation '{op_label}': {output.metadata['error']}")
except Exception as e:
error_msg = f"Operation '{op_label}' failed: {e}"
errors.append(error_msg)
# Create a placeholder output so downstream chaining can detect the failure
op_results.append(OperationOutput(
op_name=op_name,
label=f"{op_label} (FAILED)",
data=None,
metadata={"error": str(e)},
))
prev_result = op_results[-1]
# ── 6. Package result ──
elapsed = time.time() - t0
success = len(errors) == 0
return ExecutionResult(
success=success,
query_type=qt,
explanation=explanation,
source_data=df,
operation_results=op_results,
errors=errors,
warnings=warnings,
execution_time_seconds=round(elapsed, 2),
table_used=table_label,
row_count=len(df),
)
@staticmethod
def _fail(msg: str, qt: str, explanation: str, t0: float) -> ExecutionResult:
"""Return a failed ExecutionResult."""
return ExecutionResult(
success=False,
query_type=qt,
explanation=explanation,
errors=[msg],
execution_time_seconds=round(time.time() - t0, 2),
)
|