"""Analysis module for heavy CPU tasks (offloaded to executor).""" from __future__ import annotations import logging from typing import Any, Optional import numpy as np _LOGGER = logging.getLogger(__name__) ALIGNMENT_CONTEXT_BUFFER = 50 def find_best_alignment( current_power: list[float] | np.ndarray, sample_power: list[float] | np.ndarray, dt: float = 1.0 # pylint: disable=unused-argument ) -> tuple[float, dict[str, float], int]: """Find Best Alignment using Coarse-to-Fine Search (CPU Bound).""" curr = np.array(current_power) ref = np.array(sample_power) n_curr = len(curr) n_ref = len(ref) # 1. Coarse Alignment (Cross-Correlation) # Downsample for speed if arrays are large ds_factor = 1 if n_curr > 200: ds_factor = int(n_curr / 100) if ds_factor > 1: c_coarse = curr[::ds_factor] r_coarse = ref[::ds_factor] else: c_coarse = curr r_coarse = ref # Standardize if np.std(c_coarse) > 1e-6: c_norm = (c_coarse - np.mean(c_coarse)) / np.std(c_coarse) else: c_norm = c_coarse if np.std(r_coarse) > 1e-6: r_norm = (r_coarse - np.mean(r_coarse)) / np.std(r_coarse) else: r_norm = r_coarse # Cross correlation correlation = np.correlate(c_norm, r_norm, mode="full") lags = np.arange(-len(r_norm) + 1, len(c_norm)) best_idx = int(np.argmax(correlation)) best_lag_coarse = lags[best_idx] best_offset = best_lag_coarse * ds_factor # 2. Fine Refinement window = 10 * ds_factor min_off = max(-len(ref) + 1, best_offset - window) max_off = min(len(curr), best_offset + window) best_mae = float("inf") final_offset = best_offset for off in range(int(min_off), int(max_off) + 1): # intersection c_start = max(0, off) c_end = min(n_curr, n_ref + off) r_start = max(0, -off) r_end = min(n_ref, n_curr - off) if (c_end - c_start) < 10: continue c_seg = curr[c_start:c_end] r_seg = ref[r_start:r_end] mae = np.mean(np.abs(c_seg - r_seg)) if mae < best_mae: best_mae = mae final_offset = off # Calculate Final Score metrics off = final_offset c_start = max(0, off) c_end = min(n_curr, n_ref + off) r_start = max(0, -off) r_end = min(n_ref, n_curr - off) if (c_end - c_start) < 5: return 0.0, {"mae": float(best_mae)}, final_offset c_final = curr[c_start:c_end] r_final = ref[r_start:r_end] mae = np.mean(np.abs(c_final - r_final)) # Correlation if np.std(c_final) > 1e-6 and np.std(r_final) > 1e-6: corr = np.corrcoef(c_final, r_final)[0, 1] else: corr = 0.0 mae_score = 100.0 / (100.0 + mae) score = (0.6 * max(0, corr)) + (0.4 * mae_score) return float(score), {"mae": float(mae), "corr": float(corr)}, final_offset def compute_dtw_lite( x: np.ndarray, y: np.ndarray, band_width_ratio: float = 0.1 ) -> float: """ Compute DTW distance with Sakoe-Chiba band constraint. Optimized 1D DP implementation. O(N*W). """ n, m = len(x), len(y) if n == 0 or m == 0: return float("inf") # Band width w = max(1, int(min(n, m) * band_width_ratio)) # Use two rows to save memory and improve cache locality prev_row = np.full(m + 1, float("inf")) curr_row = np.full(m + 1, float("inf")) prev_row[0] = 0 for i in range(1, n + 1): center = int(i * (m / n)) start_j = max(1, center - w) end_j = min(m, center + w + 1) curr_row.fill(float("inf")) # Pre-calculate costs for the current window to reduce Python overhead # x is 0-indexed, so x[i-1] val_x = x[i - 1] for j in range(start_j, end_j + 1): cost = abs(float(val_x - y[j - 1])) # Standard DTW recursion # curr_row[j] = cost + min(insertion, deletion, match) # insertion: prev_row[j] # deletion: curr_row[j-1] # match: prev_row[j-1] # Use a slightly faster min implementation if possible m1 = prev_row[j] m2 = curr_row[j - 1] m3 = prev_row[j - 1] if m1 < m2: if m1 < m3: best_prev = m1 else: best_prev = m3 else: if m2 < m3: best_prev = m2 else: best_prev = m3 curr_row[j] = cost + best_prev # Swap rows prev_row[:] = curr_row[:] return float(prev_row[m]) def compute_matches_worker( current_power: list[float], current_duration: float, snapshots: list[dict[str, Any]], config: dict[str, Any] ) -> list[dict[str, Any]]: """Worker function to compute matches against snapshots.""" candidates: list[dict[str, Any]] = [] min_duration_ratio = config.get("min_duration_ratio", 0.07) max_duration_ratio = config.get("max_duration_ratio", 1.3) dtw_bandwidth = config.get("dtw_bandwidth", 0.1) curr_arr = np.array(current_power) for item in snapshots: name = item["name"] profile_duration = item["avg_duration"] sample_power = item["sample_power"] # Duration Check if profile_duration > 0: ratio = current_duration / profile_duration if ratio < min_duration_ratio or ratio > max_duration_ratio: continue # Core Similarity score, metrics, offset = find_best_alignment( current_power, sample_power, 1.0 ) if score > 0.1: candidates.append({ "name": name, "score": score, "metrics": metrics, "profile_duration": profile_duration, "current": current_power, "sample": sample_power, "offset": offset }) candidates.sort(key=lambda x: x["score"], reverse=True) # Stage 3: DTW Refinement on Top 3 if dtw_bandwidth > 0.0 and len(candidates) > 0: to_refine = candidates[:3] for cand in to_refine: sample_arr = np.array(cand["sample"]) dtw_dist = compute_dtw_lite( curr_arr, sample_arr, band_width_ratio=dtw_bandwidth, ) n_points = len(curr_arr) if n_points > 0: norm_dist = dtw_dist / n_points else: norm_dist = 999.0 dtw_score = 1.0 / (1.0 + norm_dist / 50.0) cand["original_score"] = float(cand["score"]) cand["score"] = float(0.5 * cand["score"] + 0.5 * dtw_score) cand["dtw_dist"] = float(norm_dist) candidates.sort(key=lambda x: x["score"], reverse=True) return candidates def compute_dtw_path( x: np.ndarray, y: np.ndarray, band_width_ratio: float = 0.1 ) -> list[tuple[int, int]]: """ Compute DTW path with Sakoe-Chiba constraint. Returns list of (x_index, y_index) tuples mapping X to Y. """ n, m = len(x), len(y) if n == 0 or m == 0: return [] w = max(1, int(min(n, m) * band_width_ratio)) cost_matrix = np.full((n + 1, m + 1), float("inf")) cost_matrix[0, 0] = 0 # Cost Matrix for i in range(1, n + 1): center = i * (m / n) start_j = max(1, int(center - w)) end_j = min(m, int(center + w) + 1) for j in range(start_j, end_j + 1): cost = abs(float(x[i - 1] - y[j - 1])) cost_matrix[i, j] = cost + min( cost_matrix[i - 1, j], cost_matrix[i, j - 1], cost_matrix[i - 1, j - 1] ) # Backtracking if np.isinf(cost_matrix[n, m]): # Endpoint is unreachable (e.g. Sakoe-Chiba band excluded it); no valid path. return [] path: list[tuple[int, int]] = [] i, j = n, m while i > 0 or j > 0: # Record current zero-based coordinate before stepping back. path.append((max(i - 1, 0), max(j - 1, 0))) if i == 0: j -= 1 elif j == 0: i -= 1 else: candidates_cost = [ (cost_matrix[i - 1, j], 0), # deletion (i-1) (cost_matrix[i, j - 1], 1), # insertion (j-1) (cost_matrix[i - 1, j - 1], 2) # match (both) ] candidates_cost.sort(key=lambda item: item[0]) best_move = candidates_cost[0][1] if best_move == 0: i -= 1 elif best_move == 1: j -= 1 else: i -= 1 j -= 1 path.reverse() return path def compute_envelope_worker( raw_cycles_data: list[tuple[list[float], list[float], Optional[float]]] | list[tuple[list[float], list[float]]], dtw_bandwidth: float ) -> tuple[list[float], list[float], list[float], list[float], list[float], float] | None: """ Compute statistical envelope. Args: raw_cycles_data: list of (offsets, power_values, duration) tuples. Duration may be None and is used to compute target_duration. dtw_bandwidth: ratio. Returns: (time_grid, min_curve, max_curve, avg_curve, std_curve, target_duration) or None. """ if not raw_cycles_data: return None normalized_curves: list[tuple[np.ndarray, np.ndarray, float]] = [] sampling_rates: list[float] = [] # 1. Pre-process input for curve in raw_cycles_data: # Unpack curve tuple: (offsets, values) or (offsets, values, duration) # Backward compatible with 2-tuple (offsets, values) format try: offsets_list, values_list, *rest = curve curve_duration = rest[0] if rest else None except (ValueError, TypeError): continue if not offsets_list or not values_list: continue if len(offsets_list) != len(values_list): min_len = min(len(offsets_list), len(values_list)) if min_len < 3: continue offsets_list = offsets_list[:min_len] values_list = values_list[:min_len] if len(offsets_list) < 3 or len(values_list) < 3: continue try: offsets = np.asarray(offsets_list, dtype=float) values = np.asarray(values_list, dtype=float) except (TypeError, ValueError): continue # Drop paired entries where either coordinate is non-finite. finite_mask = np.isfinite(offsets) & np.isfinite(values) offsets = offsets[finite_mask] values = values[finite_mask] if len(offsets) < 3: continue if not np.all(np.diff(offsets) > 0): continue try: dur = float(curve_duration) if curve_duration is not None else float(offsets[-1]) except (TypeError, ValueError, OverflowError): continue # Validate duration is positive and finite before appending. if not (dur > 0 and np.isfinite(dur)): continue normalized_curves.append((offsets, values, dur)) if len(offsets) > 1: intervals = np.diff(offsets) positive_intervals = intervals[intervals > 0] if positive_intervals.size > 0: sr = float(np.median(positive_intervals)) if np.isfinite(sr): sampling_rates.append(sr) if not normalized_curves: return None # 2. Reference Selection (Median Duration) # Input is now (offsets, values, duration) max_times = [float(dur) for _, _, dur in normalized_curves] median_dur = float(np.median(max_times)) ref_idx = int(np.argmin([abs(t - median_dur) for t in max_times])) target_duration = max_times[ref_idx] avg_sample_rate = float(np.median(sampling_rates)) if sampling_rates else 2.0 # Ensure target_duration is valid for calculations if not (target_duration > 0 and np.isfinite(target_duration)): target_duration = 1.0 # Safe default align_dt = avg_sample_rate num_points = max(50, int(target_duration / align_dt)) time_grid = np.linspace(0.0, target_duration, num_points) ref_offsets, ref_values, _ = normalized_curves[ref_idx] ref_array = np.interp(time_grid, ref_offsets, ref_values) # 3. Resample & DTW resampled: list[np.ndarray] = [] for i, (offsets, values, dur) in enumerate(normalized_curves): if i == ref_idx: resampled.append(ref_array) continue this_dur = dur this_num_points = max(10, int(this_dur / align_dt)) this_grid = np.linspace(0.0, this_dur, this_num_points) this_array = np.interp(this_grid, offsets, values) path = compute_dtw_path(this_array, ref_array, band_width_ratio=dtw_bandwidth) if not path: resampled.append(np.interp(time_grid, offsets, values)) continue path_arr = np.array(path) cand_indices = path_arr[:, 0] ref_indices = path_arr[:, 1] # Interpolate map # Map ref indices (time_grid indices) to cand indices (this_grid indices) # We assume monotonicity and filter duplicates by taking mean # Simplified: Use numpy interp of indicies # ref_indices are 0..N_ref # cand_indices are 0..N_cand # We need mapping: for ref_idx in 0..num_points, what is cand_idx? # Since ref_indices in path are not strictly increasing (duplicates), # we can't use them as 'x' for interp directly if strictness required. # But we can sort/unique them. # Sort by ref_index? Path is already sorted roughly. # Handle duplicates: average candidate indices for same ref index. unique_ref, inverse = np.unique(ref_indices, return_inverse=True) # Computing mean candidate index for each unique ref index # This is slow in python loop. # Vectorized: # np.bincount? mean_cand_indices = np.zeros_like(unique_ref, dtype=float) np.add.at(mean_cand_indices, inverse, cand_indices) counts = np.bincount(inverse) mean_cand_indices /= counts # Now we have unique_ref -> mean_cand_indices # Interpolate to full time_grid (0..num_points-1) mapped_cand_indices = np.interp( np.arange(num_points), unique_ref, mean_cand_indices, left=0, right=len(this_array)-1 ) # Now get values mapped_times = mapped_cand_indices * (this_dur / (len(this_array)-1)) warped_values = np.interp(mapped_times, this_grid, this_array) resampled.append(warped_values) # 4. Compute Stats stacked = np.vstack(resampled) min_curve = np.min(stacked, axis=0) max_curve = np.max(stacked, axis=0) avg_curve = np.mean(stacked, axis=0) std_curve = np.std(stacked, axis=0) return ( time_grid.tolist(), min_curve.tolist(), max_curve.tolist(), avg_curve.tolist(), std_curve.tolist(), float(target_duration) ) def verify_profile_alignment_worker( current_power: list[float], envelope_avg_curve: list[float], envelope_time_grid: list[float], dtw_bandwidth: float ) -> tuple[float, float, float]: """ Verify alignment of current trace against profile envelope. Returns: (mapped_envelope_time, mapped_envelope_power, overlap_score) """ if not current_power or not envelope_avg_curve: return 0.0, 9999.0, 0.0 curr = np.array(current_power) ref = np.array(envelope_avg_curve) # 1. Coarse Alignment score, _, offset = find_best_alignment(curr, ref, 1.0) # 2. Extract aligned segments # Determine the mapping window. # Symmetric context window: pad equally left and right of the coarse alignment. half = ALIGNMENT_CONTEXT_BUFFER // 2 start_ref = max(0, offset - half) end_ref = min(len(ref), offset + len(curr) + half) if end_ref <= start_ref: return 0.0, 9999.0, 0.0 ref_seg = ref[start_ref:end_ref] curr_seg = curr if offset < 0: curr_seg = curr[-offset:] path = compute_dtw_path(curr_seg, ref_seg, band_width_ratio=dtw_bandwidth) if not path: # Fallback to linear mapping based on offset mapped_idx = min(len(ref)-1, offset + len(curr) - 1) mapped_idx = max(0, mapped_idx) else: # Map the final point of the current trace to the reference index last_pair = path[-1] ref_seg_idx = last_pair[1] mapped_idx = start_ref + ref_seg_idx # Ensure sequences are non-empty before indexing if not envelope_time_grid or len(ref) == 0: return 0.0, 9999.0, 0.0 mapped_idx = min(mapped_idx, len(envelope_time_grid) - 1, len(ref) - 1) mapped_time = float(envelope_time_grid[mapped_idx]) mapped_power = float(ref[mapped_idx]) return mapped_time, mapped_power, float(score)