indicator_features.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  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). Absolute pressure levels
  5. depend on the process, so these features are meant to be compared:
  6. * against the same cylinder's own recent baseline (trend), and
  7. * against the peer cylinders of the same compression stage at the same time,
  8. * and between the cover/shaft chambers of the same cylinder.
  9. Compression staging (confirmed with the site):
  10. 一级(1st) = 2/4/6 缸, 二级(2nd) = 3/5 缸, 三级(3rd) = 1 缸.
  11. No model is trained here; the goal is to expose interpretable features.
  12. """
  13. from __future__ import annotations
  14. import numpy as np
  15. CYLINDER_BORE_MM = {"一": 360.0, "二": 490.0, "三": 390.0, "四": 490.0, "五": 390.0, "六": 490.0}
  16. CYLINDER_STAGE = {"一": "三级", "二": "一级", "三": "二级", "四": "一级", "五": "二级", "六": "一级"}
  17. PISTON_STROKE_MM = 148.0
  18. CONNECTING_ROD_LENGTH_MM = 460.0
  19. CLEARANCE_VOLUME_L_BY_BORE = {490.0: 1.51, 390.0: 0.74, 360.0: 0.62}
  20. def smooth(values: np.ndarray, window: int) -> np.ndarray:
  21. window = max(1, int(window))
  22. if window <= 1 or len(values) < window:
  23. return values
  24. kernel = np.ones(window, dtype=float) / window
  25. return np.convolve(values, kernel, mode="same")
  26. def volume_from_angle(count: int, bore_mm: float) -> np.ndarray:
  27. """Chamber volume (L) over one revolution, from crank-slider geometry."""
  28. crank_radius = PISTON_STROKE_MM / 2.0
  29. clearance = CLEARANCE_VOLUME_L_BY_BORE.get(bore_mm)
  30. if clearance is None:
  31. clearance = 1.0
  32. theta = np.linspace(0.0, 2.0 * np.pi, count, endpoint=False)
  33. travel = (
  34. crank_radius * (1.0 - np.cos(theta))
  35. + CONNECTING_ROD_LENGTH_MM
  36. - np.sqrt(CONNECTING_ROD_LENGTH_MM**2 - (crank_radius * np.sin(theta)) ** 2)
  37. )
  38. piston_area = np.pi * (bore_mm / 2.0) ** 2
  39. return clearance + piston_area * travel / 1_000_000.0
  40. def _plateau_levels(p: np.ndarray) -> tuple[float, float]:
  41. """Suction (low) and discharge (high) plateau levels via robust quantiles."""
  42. low = np.quantile(p, 0.12)
  43. high = np.quantile(p, 0.88)
  44. near_low = p[p <= low + 0.15 * (high - low)]
  45. near_high = p[p >= high - 0.15 * (high - low)]
  46. suction = float(np.median(near_low)) if near_low.size else float(np.min(p))
  47. discharge = float(np.median(near_high)) if near_high.size else float(np.max(p))
  48. return suction, discharge
  49. def cycle_features(signal: np.ndarray, cylinder: str, smooth_window: int = 21) -> dict:
  50. signal = np.asarray(signal, dtype=float)
  51. signal = signal[np.isfinite(signal)]
  52. count = len(signal)
  53. if count < 50:
  54. return {}
  55. p = smooth(signal, smooth_window)
  56. p = np.where(np.isfinite(p), p, signal)
  57. suction, discharge = _plateau_levels(p)
  58. span = discharge - suction
  59. threshold = suction + 0.5 * span
  60. high = p >= threshold
  61. dp = np.diff(p)
  62. rising = dp[dp > 0]
  63. falling = dp[dp < 0]
  64. # Compression/expansion line steepness (per sample).
  65. compression_slope = float(np.quantile(rising, 0.9)) if rising.size else 0.0
  66. expansion_slope = float(-np.quantile(falling, 0.1)) if falling.size else 0.0
  67. # Plateau occupancy: fraction of the cycle spent near high/low plateau.
  68. near = 0.08 * span + 1e-9
  69. suction_frac = float(np.mean(np.abs(p - suction) <= near))
  70. discharge_frac = float(np.mean(np.abs(p - discharge) <= near))
  71. # Indicated work over the revolution (net area) and mean effective pressure.
  72. volume = volume_from_angle(count, CYLINDER_BORE_MM.get(cylinder, 490.0))
  73. dvolume = np.diff(volume, append=volume[0])
  74. work = float(-np.sum(p * dvolume)) # net work done on the gas (positive)
  75. sweep = float(np.max(volume) - np.min(volume))
  76. imep = work / sweep if sweep else 0.0
  77. # Corner sharpness: mean |curvature| of pressure where it crosses the mid band.
  78. d2 = np.diff(p, 2)
  79. band = np.abs(p[1:-1] - threshold) <= 0.1 * span
  80. corner = float(np.mean(np.abs(d2[band]))) if band.any() else 0.0
  81. return {
  82. "n_samples": count,
  83. "p_min": float(np.min(p)),
  84. "p_max": float(np.max(p)),
  85. "p_mean": float(np.mean(p)),
  86. "p_suction": suction,
  87. "p_discharge": discharge,
  88. "p_span": span,
  89. "pressure_ratio": (discharge / suction) if suction else None,
  90. "pressure_rise": span,
  91. "suction_frac": suction_frac,
  92. "discharge_frac": discharge_frac,
  93. "compression_slope": compression_slope,
  94. "expansion_slope": expansion_slope,
  95. "indicated_work": work,
  96. "imep": imep,
  97. "corner_sharpness": corner,
  98. }