cycles.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. from dataclasses import dataclass
  2. import numpy as np
  3. TRIGGER_THRESHOLD = 30.0
  4. PULSES_PER_REVOLUTION = 8
  5. @dataclass(frozen=True)
  6. class DetectedCycle:
  7. number: int
  8. start_offset: int
  9. end_offset: int
  10. angle: np.ndarray
  11. signal: np.ndarray
  12. trigger_offsets: tuple[int, ...]
  13. def _trigger_runs(trigger: np.ndarray) -> list[tuple[int, int, int]]:
  14. """Return start offset, end offset and length for each high trigger run."""
  15. values = np.asarray(trigger, dtype=float)
  16. high = np.nan_to_num(values, nan=-np.inf) >= TRIGGER_THRESHOLD
  17. changes = np.diff(np.r_[False, high, False].astype(np.int8))
  18. starts = np.flatnonzero(changes == 1)
  19. ends = np.flatnonzero(changes == -1) - 1
  20. return [
  21. (int(start), int(end), int(end - start + 1))
  22. for start, end in zip(starts, ends)
  23. ]
  24. def _zero_marker_threshold(lengths: np.ndarray) -> float:
  25. """Adaptively separate ordinary keyphasor pulses from the wider 0-degree marker.
  26. The wide marker's extra sensing time varies by machine (measured ratios from
  27. ~1.4x to ~1.7x of the ordinary pulse), so a fixed margin misses machines
  28. whose zero marker is only slightly wider. Otsu-style thresholding splits the
  29. run lengths into two clusters regardless of the exact ratio; a near-single
  30. cluster (ratio < 1.15) means no reliable zero marker exists and the returned
  31. threshold rejects every run.
  32. """
  33. lengths = np.asarray(lengths, dtype=float)
  34. if len(lengths) < 3:
  35. return float("inf")
  36. uniq = np.unique(lengths)
  37. if len(uniq) < 2:
  38. return float("inf")
  39. best_variance = -1.0
  40. best_threshold = float("inf")
  41. best_means = (float(lengths.min()), float(lengths.max()))
  42. for t in uniq[:-1]:
  43. low = lengths[lengths <= t]
  44. high = lengths[lengths > t]
  45. if len(low) == 0 or len(high) == 0:
  46. continue
  47. weight_low = len(low) / len(lengths)
  48. weight_high = len(high) / len(lengths)
  49. mean_low = float(low.mean())
  50. mean_high = float(high.mean())
  51. between = weight_low * weight_high * (mean_low - mean_high) ** 2
  52. if between > best_variance:
  53. best_variance = between
  54. best_threshold = float(t)
  55. best_means = (mean_low, mean_high)
  56. if best_means[1] < best_means[0] * 1.15:
  57. return float("inf")
  58. return best_threshold
  59. def detect_cycles(samples: np.ndarray) -> tuple[list[DetectedCycle], dict[str, float | int]]:
  60. """Detect complete crankshaft cycles from a wave_sample array.
  61. The trigger contract intentionally mirrors showPV: second_value >= 30 is
  62. high, the longer high run marks 0 degrees, and eight pulses make one turn.
  63. Offsets are array offsets rather than sample_index values so the function
  64. also works when a file has a non-zero or sparse sample_index column.
  65. """
  66. values = np.asarray(samples, dtype=float)
  67. if values.ndim != 2 or values.shape[1] < 3 or not len(values):
  68. return [], {
  69. "triggerRunCount": 0,
  70. "zeroMarkerCount": 0,
  71. "completeCycleCount": 0,
  72. }
  73. runs = _trigger_runs(values[:, 2])
  74. diagnostics: dict[str, float | int] = {
  75. "triggerRunCount": len(runs),
  76. "zeroMarkerCount": 0,
  77. "completeCycleCount": 0,
  78. }
  79. if len(runs) < PULSES_PER_REVOLUTION + 1:
  80. return [], diagnostics
  81. lengths = np.asarray([run[2] for run in runs], dtype=float)
  82. ordinary_length = float(np.median(lengths))
  83. long_limit = _zero_marker_threshold(lengths)
  84. zero_runs = [run for run in runs if run[2] > long_limit]
  85. diagnostics.update(
  86. ordinaryTriggerLength=ordinary_length,
  87. zeroMarkerThreshold=long_limit,
  88. zeroMarkerCount=len(zero_runs),
  89. )
  90. if len(zero_runs) < 2:
  91. return [], diagnostics
  92. cycles: list[DetectedCycle] = []
  93. signal = values[:, 1]
  94. for cycle_number, (zero_run, next_zero_run) in enumerate(
  95. zip(zero_runs, zero_runs[1:]),
  96. start=1,
  97. ):
  98. start = zero_run[0]
  99. end = next_zero_run[0]
  100. cycle_runs = [run for run in runs if start <= run[0] <= end]
  101. if len(cycle_runs) != PULSES_PER_REVOLUTION + 1 or end <= start:
  102. continue
  103. angle = np.full(end - start, np.nan, dtype=float)
  104. for pulse_index in range(PULSES_PER_REVOLUTION):
  105. segment_start = cycle_runs[pulse_index][0]
  106. segment_end = cycle_runs[pulse_index + 1][0]
  107. local_start = segment_start - start
  108. local_end = segment_end - start
  109. if local_end <= local_start:
  110. continue
  111. angle[local_start:local_end] = np.linspace(
  112. pulse_index * 45.0,
  113. (pulse_index + 1) * 45.0,
  114. local_end - local_start,
  115. endpoint=False,
  116. )
  117. cycles.append(
  118. DetectedCycle(
  119. number=cycle_number,
  120. start_offset=start,
  121. end_offset=end,
  122. angle=angle,
  123. signal=signal[start:end].copy(),
  124. trigger_offsets=tuple(run[0] for run in cycle_runs[:-1]),
  125. )
  126. )
  127. diagnostics["completeCycleCount"] = len(cycles)
  128. return cycles, diagnostics
  129. def build_angle_vector(sample_count: int, cycles: list[DetectedCycle]) -> np.ndarray:
  130. """Build a sparse full-file crank-angle vector (0-180 degrees).
  131. Mirrors showPV's display angle: the 0-360 keyphasor angle is folded so that
  132. the piston stroke position reads 0 -> 180 -> 0 across a full revolution.
  133. """
  134. angle = np.full(sample_count, np.nan, dtype=float)
  135. for cycle in cycles:
  136. full = cycle.angle
  137. angle[cycle.start_offset : cycle.end_offset] = np.where(
  138. full <= 180.0,
  139. full,
  140. 360.0 - full,
  141. )
  142. return angle
  143. def downsample_indices(
  144. values: np.ndarray,
  145. target_count: int,
  146. required_indices: set[int] | None = None,
  147. ) -> np.ndarray:
  148. """Min/max downsample while retaining requested period boundaries.
  149. Each bucket contributes its first/last/min/max points. Required offsets are
  150. added afterwards, so trigger boundaries survive even when the overview has
  151. far fewer pixels than the raw waveform.
  152. """
  153. values = np.asarray(values, dtype=float)
  154. count = len(values)
  155. if count == 0:
  156. return np.empty(0, dtype=np.int64)
  157. target_count = max(int(target_count), 16)
  158. required = {
  159. int(index)
  160. for index in (required_indices or set())
  161. if 0 <= int(index) < count
  162. }
  163. if count <= target_count:
  164. return np.arange(count, dtype=np.int64)
  165. bucket_count = max(1, target_count // 4)
  166. edges = np.linspace(0, count, bucket_count + 1, dtype=np.int64)
  167. selected = set(required)
  168. for bucket_index in range(bucket_count):
  169. start = int(edges[bucket_index])
  170. end = int(edges[bucket_index + 1])
  171. if end <= start:
  172. continue
  173. bucket = values[start:end]
  174. finite = np.isfinite(bucket)
  175. candidates = {start, end - 1}
  176. if finite.any():
  177. finite_values = bucket.copy()
  178. finite_values[~finite] = np.nan
  179. candidates.add(start + int(np.nanargmin(finite_values)))
  180. candidates.add(start + int(np.nanargmax(finite_values)))
  181. selected.update(candidates)
  182. if len(selected) > target_count:
  183. required_sorted = sorted(required)
  184. remaining = sorted(selected.difference(required))
  185. slots = max(0, target_count - len(required_sorted))
  186. if slots:
  187. positions = np.linspace(0, len(remaining) - 1, slots, dtype=np.int64)
  188. selected = set(required_sorted).union(remaining[int(position)] for position in positions)
  189. else:
  190. # Keep every period boundary even if it exceeds the requested budget.
  191. selected = set(required_sorted)
  192. elif len(selected) < target_count:
  193. missing = np.setdiff1d(
  194. np.arange(count, dtype=np.int64),
  195. np.asarray(sorted(selected), dtype=np.int64),
  196. assume_unique=True,
  197. )
  198. slots = min(target_count - len(selected), len(missing))
  199. if slots:
  200. positions = np.linspace(0, len(missing) - 1, slots, dtype=np.int64)
  201. selected.update(int(missing[int(position)]) for position in positions)
  202. return np.asarray(sorted(selected), dtype=np.int64)