indicator_features.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. #!/usr/bin/env python3
  2. """Phase-level features from one compressor indicator diagram cycle.
  3. One stored cycle (wave_sample_one) is the pressure of a single cylinder chamber
  4. over one crankshaft revolution (~60 ms at ~996 RPM).
  5. Design goals:
  6. * robust two-plateau (suction P_s / discharge P_d) detection via Otsu split;
  7. * physical phase alignment (search the crank offset that maximises the PV loop
  8. area) so indicated work and a pressure-conversion efficiency are meaningful;
  9. * a normalised phase profile for peer / double-chamber / self-baseline review.
  10. Compression staging (confirmed with the site):
  11. 一级(1st) = 2/4/6 缸, 二级(2nd) = 3/5 缸, 三级(3rd) = 1 缸.
  12. Absolute levels depend on the process, so features are meant to be compared.
  13. """
  14. from __future__ import annotations
  15. import numpy as np
  16. CYLINDER_BORE_MM = {"一": 360.0, "二": 490.0, "三": 390.0, "四": 490.0, "五": 390.0, "六": 490.0}
  17. CYLINDER_STAGE = {"一": "三级", "二": "一级", "三": "二级", "四": "一级", "五": "二级", "六": "一级"}
  18. PISTON_STROKE_MM = 148.0
  19. CONNECTING_ROD_LENGTH_MM = 460.0
  20. CLEARANCE_VOLUME_L_BY_BORE = {490.0: 1.51, 390.0: 0.74, 360.0: 0.62}
  21. PROFILE_BINS = 12
  22. K_ISENTROPIC = 1.30
  23. def smooth(values: np.ndarray, window: int) -> np.ndarray:
  24. window = max(1, int(window))
  25. if window <= 1 or len(values) < window:
  26. return values
  27. kernel = np.ones(window, dtype=float) / window
  28. return np.convolve(values, kernel, mode="same")
  29. def otsu_threshold(values: np.ndarray, bins: int = 96) -> float:
  30. hist, edges = np.histogram(values, bins=bins)
  31. centers = (edges[:-1] + edges[1:]) / 2.0
  32. total = float(values.size)
  33. if total <= 0:
  34. return float(np.mean(values))
  35. sum_total = float(np.sum(centers * hist))
  36. sum_before = 0.0
  37. weight_before = 0.0
  38. best_variance = -1.0
  39. threshold = float(centers[0])
  40. for index in range(len(hist)):
  41. weight_before += float(hist[index])
  42. if weight_before == 0.0:
  43. continue
  44. weight_after = total - weight_before
  45. if weight_after == 0.0:
  46. break
  47. sum_before += centers[index] * hist[index]
  48. mean_before = sum_before / weight_before
  49. mean_after = (sum_total - sum_before) / weight_after
  50. variance = weight_before * weight_after * (mean_before - mean_after) ** 2
  51. if variance > best_variance:
  52. best_variance = variance
  53. threshold = float(centers[index])
  54. return threshold
  55. def volume_from_angle(count: int, bore_mm: float) -> np.ndarray:
  56. crank_radius = PISTON_STROKE_MM / 2.0
  57. clearance = CLEARANCE_VOLUME_L_BY_BORE.get(bore_mm, 1.0)
  58. theta = np.linspace(0.0, 2.0 * np.pi, count, endpoint=False)
  59. travel = (
  60. crank_radius * (1.0 - np.cos(theta))
  61. + CONNECTING_ROD_LENGTH_MM
  62. - np.sqrt(CONNECTING_ROD_LENGTH_MM**2 - (crank_radius * np.sin(theta)) ** 2)
  63. )
  64. piston_area = np.pi * (bore_mm / 2.0) ** 2
  65. return clearance + piston_area * travel / 1_000_000.0
  66. def _plateau_levels(p: np.ndarray) -> tuple[float, float, float]:
  67. threshold = otsu_threshold(p)
  68. low = p[p <= threshold]
  69. high = p[p > threshold]
  70. suction = float(np.median(low)) if low.size else float(np.min(p))
  71. discharge = float(np.median(high)) if high.size else float(np.max(p))
  72. return suction, discharge, threshold
  73. def _align_offset(p: np.ndarray, dvolume: np.ndarray) -> tuple[int, float]:
  74. """Offset (in samples) that maximises the indicated work -sum(p dV)."""
  75. count = len(p)
  76. cross = np.real(np.fft.ifft(np.conj(np.fft.fft(p)) * np.fft.fft(dvolume)))
  77. work = -cross
  78. offset = int(np.argmax(work))
  79. return offset, float(work[offset])
  80. def _polytropic_index(volume: np.ndarray, pressure: np.ndarray, low: float, high: float, rising: bool) -> float | None:
  81. dp = np.diff(pressure, prepend=pressure[0])
  82. band = (pressure > low + 0.15 * (high - low)) & (pressure < low + 0.85 * (high - low))
  83. direction = dp > 0 if rising else dp < 0
  84. mask = band & direction
  85. v = volume[mask]
  86. p = pressure[mask]
  87. valid = (v > 0) & (p > 0)
  88. if np.count_nonzero(valid) < 8:
  89. return None
  90. slope, _ = np.polyfit(np.log(v[valid]), np.log(p[valid]), 1)
  91. return float(-slope)
  92. def cycle_features(signal: np.ndarray, cylinder: str, smooth_window: int = 21) -> dict:
  93. signal = np.asarray(signal, dtype=float)
  94. signal = signal[np.isfinite(signal)]
  95. count = len(signal)
  96. if count < 60:
  97. return {}
  98. p = smooth(signal, smooth_window)
  99. p = np.where(np.isfinite(p), p, signal)
  100. suction, discharge, threshold = _plateau_levels(p)
  101. span = discharge - suction
  102. volume = volume_from_angle(count, CYLINDER_BORE_MM.get(cylinder, 490.0))
  103. dvolume = np.diff(volume, append=volume[0])
  104. offset, indicated_work = _align_offset(p, dvolume)
  105. aligned_volume = np.roll(volume, -offset)
  106. aligned_dvolume = np.roll(dvolume, -offset)
  107. # Isentropic ideal work (MPa*L = kJ) and pressure-conversion efficiency.
  108. v_suction = float(np.max(aligned_volume))
  109. ratio = (discharge / suction) if suction > 0 else None
  110. if ratio and ratio > 1:
  111. ideal = (K_ISENTROPIC / (K_ISENTROPIC - 1.0)) * suction * v_suction * (
  112. ratio ** ((K_ISENTROPIC - 1.0) / K_ISENTROPIC) - 1.0
  113. )
  114. else:
  115. ideal = None
  116. efficiency = (ideal / indicated_work) if ideal and indicated_work and indicated_work > 1e-9 else None
  117. # Phase boundaries by pressure crossing (aligned).
  118. high_mask = p >= threshold
  119. suction_frac = float(np.mean(~high_mask))
  120. discharge_frac = float(np.mean(high_mask))
  121. dp = np.diff(p)
  122. rising = dp[dp > 0]
  123. falling = dp[dp < 0]
  124. compression_slope = float(np.quantile(rising, 0.9)) if rising.size else 0.0
  125. expansion_slope = float(-np.quantile(falling, 0.1)) if falling.size else 0.0
  126. d2 = np.diff(p, 2)
  127. band = np.abs(p[1:-1] - threshold) <= 0.1 * span
  128. corner = float(np.mean(np.abs(d2[band]))) if band.any() else 0.0
  129. # Normalised phase profile (0..1) on the aligned cycle.
  130. idx = np.linspace(0, count - 1, PROFILE_BINS).round().astype(int)
  131. prof = (p[idx] - suction) / span if span else np.zeros(PROFILE_BINS)
  132. profile = {f"prof_{i:02d}": float(prof[i]) for i in range(PROFILE_BINS)}
  133. features = {
  134. "n_samples": count,
  135. "p_min": float(np.min(p)),
  136. "p_max": float(np.max(p)),
  137. "p_mean": float(np.mean(p)),
  138. "p_suction": suction,
  139. "p_discharge": discharge,
  140. "p_span": span,
  141. "pressure_ratio": ratio,
  142. "suction_frac": suction_frac,
  143. "discharge_frac": discharge_frac,
  144. "compression_slope": compression_slope,
  145. "expansion_slope": expansion_slope,
  146. "corner_sharpness": corner,
  147. "n_comp": _polytropic_index(aligned_volume, p, suction, discharge, rising=True),
  148. "n_exp": _polytropic_index(aligned_volume, p, suction, discharge, rising=False),
  149. "indicated_work": indicated_work,
  150. "ideal_work_isentropic": ideal,
  151. "efficiency": efficiency,
  152. "phase_offset": offset,
  153. }
  154. features.update(profile)
  155. return features
  156. def normalized_profile(signal: np.ndarray, cylinder: str, bins: int = 180, smooth_window: int = 21) -> np.ndarray | None:
  157. """Process-invariant P-theta shape: PV-area-aligned, resampled, plateau-normalised.
  158. Returns (P - P_suction) / (P_discharge - P_suction) over one revolution, so
  159. level/load changes cancel and only the waveform shape remains. Valve faults
  160. distort corners/tilts here even when scalar levels look normal.
  161. """
  162. signal = np.asarray(signal, dtype=float)
  163. signal = signal[np.isfinite(signal)]
  164. if len(signal) < 60:
  165. return None
  166. p = smooth(signal, smooth_window)
  167. p = np.where(np.isfinite(p), p, signal)
  168. suction, discharge, _ = _plateau_levels(p)
  169. span = discharge - suction
  170. if span <= 1e-9:
  171. return None
  172. volume = volume_from_angle(len(p), CYLINDER_BORE_MM.get(cylinder, 490.0))
  173. dvolume = np.diff(volume, append=volume[0])
  174. offset, _ = _align_offset(p, dvolume)
  175. rotated = np.roll(p, -offset)
  176. idx = np.linspace(0, len(rotated) - 1, bins)
  177. resampled = np.interp(idx, np.arange(len(rotated)), rotated)
  178. return (resampled - suction) / span