cycles.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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 detect_cycles(samples: np.ndarray) -> tuple[list[DetectedCycle], dict[str, float | int]]:
  25. """Detect complete crankshaft cycles from a wave_sample array.
  26. The trigger contract intentionally mirrors showPV: second_value >= 30 is
  27. high, the longer high run marks 0 degrees, and eight pulses make one turn.
  28. Offsets are array offsets rather than sample_index values so the function
  29. also works when a file has a non-zero or sparse sample_index column.
  30. """
  31. values = np.asarray(samples, dtype=float)
  32. if values.ndim != 2 or values.shape[1] < 3 or not len(values):
  33. return [], {
  34. "triggerRunCount": 0,
  35. "zeroMarkerCount": 0,
  36. "completeCycleCount": 0,
  37. }
  38. runs = _trigger_runs(values[:, 2])
  39. diagnostics: dict[str, float | int] = {
  40. "triggerRunCount": len(runs),
  41. "zeroMarkerCount": 0,
  42. "completeCycleCount": 0,
  43. }
  44. if len(runs) < PULSES_PER_REVOLUTION + 1:
  45. return [], diagnostics
  46. lengths = np.asarray([run[2] for run in runs], dtype=float)
  47. ordinary_length = float(np.median(lengths))
  48. long_limit = ordinary_length + max(5.0, ordinary_length * 0.45)
  49. zero_runs = [run for run in runs if run[2] >= long_limit]
  50. diagnostics.update(
  51. ordinaryTriggerLength=ordinary_length,
  52. zeroMarkerThreshold=long_limit,
  53. zeroMarkerCount=len(zero_runs),
  54. )
  55. if len(zero_runs) < 2:
  56. return [], diagnostics
  57. cycles: list[DetectedCycle] = []
  58. signal = values[:, 1]
  59. for cycle_number, (zero_run, next_zero_run) in enumerate(
  60. zip(zero_runs, zero_runs[1:]),
  61. start=1,
  62. ):
  63. start = zero_run[0]
  64. end = next_zero_run[0]
  65. cycle_runs = [run for run in runs if start <= run[0] <= end]
  66. if len(cycle_runs) != PULSES_PER_REVOLUTION + 1 or end <= start:
  67. continue
  68. angle = np.full(end - start, np.nan, dtype=float)
  69. for pulse_index in range(PULSES_PER_REVOLUTION):
  70. segment_start = cycle_runs[pulse_index][0]
  71. segment_end = cycle_runs[pulse_index + 1][0]
  72. local_start = segment_start - start
  73. local_end = segment_end - start
  74. if local_end <= local_start:
  75. continue
  76. angle[local_start:local_end] = np.linspace(
  77. pulse_index * 45.0,
  78. (pulse_index + 1) * 45.0,
  79. local_end - local_start,
  80. endpoint=False,
  81. )
  82. cycles.append(
  83. DetectedCycle(
  84. number=cycle_number,
  85. start_offset=start,
  86. end_offset=end,
  87. angle=angle,
  88. signal=signal[start:end].copy(),
  89. trigger_offsets=tuple(run[0] for run in cycle_runs[:-1]),
  90. )
  91. )
  92. diagnostics["completeCycleCount"] = len(cycles)
  93. return cycles, diagnostics
  94. def build_angle_vector(sample_count: int, cycles: list[DetectedCycle]) -> np.ndarray:
  95. """Build a sparse full-file crank-angle vector (0-180 degrees).
  96. Mirrors showPV's display angle: the 0-360 keyphasor angle is folded so that
  97. the piston stroke position reads 0 -> 180 -> 0 across a full revolution.
  98. """
  99. angle = np.full(sample_count, np.nan, dtype=float)
  100. for cycle in cycles:
  101. full = cycle.angle
  102. angle[cycle.start_offset : cycle.end_offset] = np.where(
  103. full <= 180.0,
  104. full,
  105. 360.0 - full,
  106. )
  107. return angle
  108. def downsample_indices(
  109. values: np.ndarray,
  110. target_count: int,
  111. required_indices: set[int] | None = None,
  112. ) -> np.ndarray:
  113. """Min/max downsample while retaining requested period boundaries.
  114. Each bucket contributes its first/last/min/max points. Required offsets are
  115. added afterwards, so trigger boundaries survive even when the overview has
  116. far fewer pixels than the raw waveform.
  117. """
  118. values = np.asarray(values, dtype=float)
  119. count = len(values)
  120. if count == 0:
  121. return np.empty(0, dtype=np.int64)
  122. target_count = max(int(target_count), 16)
  123. required = {
  124. int(index)
  125. for index in (required_indices or set())
  126. if 0 <= int(index) < count
  127. }
  128. if count <= target_count:
  129. return np.arange(count, dtype=np.int64)
  130. bucket_count = max(1, target_count // 4)
  131. edges = np.linspace(0, count, bucket_count + 1, dtype=np.int64)
  132. selected = set(required)
  133. for bucket_index in range(bucket_count):
  134. start = int(edges[bucket_index])
  135. end = int(edges[bucket_index + 1])
  136. if end <= start:
  137. continue
  138. bucket = values[start:end]
  139. finite = np.isfinite(bucket)
  140. candidates = {start, end - 1}
  141. if finite.any():
  142. finite_values = bucket.copy()
  143. finite_values[~finite] = np.nan
  144. candidates.add(start + int(np.nanargmin(finite_values)))
  145. candidates.add(start + int(np.nanargmax(finite_values)))
  146. selected.update(candidates)
  147. if len(selected) > target_count:
  148. required_sorted = sorted(required)
  149. remaining = sorted(selected.difference(required))
  150. slots = max(0, target_count - len(required_sorted))
  151. if slots:
  152. positions = np.linspace(0, len(remaining) - 1, slots, dtype=np.int64)
  153. selected = set(required_sorted).union(remaining[int(position)] for position in positions)
  154. else:
  155. # Keep every period boundary even if it exceeds the requested budget.
  156. selected = set(required_sorted)
  157. elif len(selected) < target_count:
  158. missing = np.setdiff1d(
  159. np.arange(count, dtype=np.int64),
  160. np.asarray(sorted(selected), dtype=np.int64),
  161. assume_unique=True,
  162. )
  163. slots = min(target_count - len(selected), len(missing))
  164. if slots:
  165. positions = np.linspace(0, len(missing) - 1, slots, dtype=np.int64)
  166. selected.update(int(missing[int(position)]) for position in positions)
  167. return np.asarray(sorted(selected), dtype=np.int64)