kinemotion 0.73.0__py3-none-any.whl → 0.75.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kinemotion might be problematic. Click here for more details.

@@ -155,38 +155,21 @@ class CMJMetricsValidator(MetricsValidator):
155
155
 
156
156
  flight_time = self._convert_raw_duration_to_seconds(flight_time_raw)
157
157
  bounds = CMJBounds.FLIGHT_TIME
158
+ error_label = (
159
+ "below frame rate resolution limit"
160
+ if flight_time < bounds.absolute_min
161
+ else "exceeds elite human capability"
162
+ )
158
163
 
159
- if not bounds.is_physically_possible(flight_time):
160
- if flight_time < bounds.absolute_min:
161
- result.add_error(
162
- "flight_time",
163
- f"Flight time {flight_time:.3f}s below frame rate resolution limit",
164
- value=flight_time,
165
- bounds=(bounds.absolute_min, bounds.absolute_max),
166
- )
167
- else:
168
- result.add_error(
169
- "flight_time",
170
- f"Flight time {flight_time:.3f}s exceeds elite human capability",
171
- value=flight_time,
172
- bounds=(bounds.absolute_min, bounds.absolute_max),
173
- )
174
- elif bounds.contains(flight_time, profile):
175
- result.add_info(
176
- "flight_time",
177
- f"Flight time {flight_time:.3f}s within expected range for {profile.value}",
178
- value=flight_time,
179
- )
180
- else:
181
- # Outside expected range but physically possible
182
- expected_min, expected_max = self._get_profile_range(profile, bounds)
183
- result.add_warning(
184
- "flight_time",
185
- f"Flight time {flight_time:.3f}s outside typical range "
186
- f"[{expected_min:.3f}-{expected_max:.3f}]s for {profile.value}",
187
- value=flight_time,
188
- bounds=(expected_min, expected_max),
189
- )
164
+ self._validate_metric_with_bounds(
165
+ "flight_time",
166
+ flight_time,
167
+ bounds,
168
+ profile,
169
+ result,
170
+ error_suffix=error_label,
171
+ format_str="{value:.3f}s",
172
+ )
190
173
 
191
174
  def _check_jump_height(
192
175
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
@@ -197,37 +180,21 @@ class CMJMetricsValidator(MetricsValidator):
197
180
  return
198
181
 
199
182
  bounds = CMJBounds.JUMP_HEIGHT
183
+ error_label = (
184
+ "essentially no jump (noise)"
185
+ if jump_height < bounds.absolute_min
186
+ else "exceeds human capability"
187
+ )
200
188
 
201
- if not bounds.is_physically_possible(jump_height):
202
- if jump_height < bounds.absolute_min:
203
- result.add_error(
204
- "jump_height",
205
- f"Jump height {jump_height:.3f}m essentially no jump (noise)",
206
- value=jump_height,
207
- bounds=(bounds.absolute_min, bounds.absolute_max),
208
- )
209
- else:
210
- result.add_error(
211
- "jump_height",
212
- f"Jump height {jump_height:.3f}m exceeds human capability",
213
- value=jump_height,
214
- bounds=(bounds.absolute_min, bounds.absolute_max),
215
- )
216
- elif bounds.contains(jump_height, profile):
217
- result.add_info(
218
- "jump_height",
219
- f"Jump height {jump_height:.3f}m within expected range for {profile.value}",
220
- value=jump_height,
221
- )
222
- else:
223
- expected_min, expected_max = self._get_profile_range(profile, bounds)
224
- result.add_warning(
225
- "jump_height",
226
- f"Jump height {jump_height:.3f}m outside typical range "
227
- f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
228
- value=jump_height,
229
- bounds=(expected_min, expected_max),
230
- )
189
+ self._validate_metric_with_bounds(
190
+ "jump_height",
191
+ jump_height,
192
+ bounds,
193
+ profile,
194
+ result,
195
+ error_suffix=error_label,
196
+ format_str="{value:.3f}m",
197
+ )
231
198
 
232
199
  def _check_countermovement_depth(
233
200
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
@@ -238,37 +205,19 @@ class CMJMetricsValidator(MetricsValidator):
238
205
  return
239
206
 
240
207
  bounds = CMJBounds.COUNTERMOVEMENT_DEPTH
208
+ error_label = (
209
+ "essentially no squat" if depth < bounds.absolute_min else "exceeds physical limit"
210
+ )
241
211
 
242
- if not bounds.is_physically_possible(depth):
243
- if depth < bounds.absolute_min:
244
- result.add_error(
245
- "countermovement_depth",
246
- f"Countermovement depth {depth:.3f}m essentially no squat",
247
- value=depth,
248
- bounds=(bounds.absolute_min, bounds.absolute_max),
249
- )
250
- else:
251
- result.add_error(
252
- "countermovement_depth",
253
- f"Countermovement depth {depth:.3f}m exceeds physical limit",
254
- value=depth,
255
- bounds=(bounds.absolute_min, bounds.absolute_max),
256
- )
257
- elif bounds.contains(depth, profile):
258
- result.add_info(
259
- "countermovement_depth",
260
- f"Countermovement depth {depth:.3f}m within expected range for {profile.value}",
261
- value=depth,
262
- )
263
- else:
264
- expected_min, expected_max = self._get_profile_range(profile, bounds)
265
- result.add_warning(
266
- "countermovement_depth",
267
- f"Countermovement depth {depth:.3f}m outside typical range "
268
- f"[{expected_min:.3f}-{expected_max:.3f}]m for {profile.value}",
269
- value=depth,
270
- bounds=(expected_min, expected_max),
271
- )
212
+ self._validate_metric_with_bounds(
213
+ "countermovement_depth",
214
+ depth,
215
+ bounds,
216
+ profile,
217
+ result,
218
+ error_suffix=error_label,
219
+ format_str="{value:.3f}m",
220
+ )
272
221
 
273
222
  def _check_concentric_duration(
274
223
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
@@ -340,71 +289,74 @@ class CMJMetricsValidator(MetricsValidator):
340
289
  self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
341
290
  ) -> None:
342
291
  """Validate peak eccentric and concentric velocities."""
343
- # Eccentric
344
- ecc_vel = self._get_metric_value(
345
- metrics, "peak_eccentric_velocity_m_s", "peak_eccentric_velocity"
346
- )
347
- if ecc_vel is not None:
348
- bounds = CMJBounds.PEAK_ECCENTRIC_VELOCITY
349
- if not bounds.is_physically_possible(ecc_vel):
350
- result.add_error(
351
- "peak_eccentric_velocity",
352
- f"Peak eccentric velocity {ecc_vel:.2f} m/s outside limits",
353
- value=ecc_vel,
354
- bounds=(bounds.absolute_min, bounds.absolute_max),
355
- )
356
- elif bounds.contains(ecc_vel, profile):
357
- result.add_info(
358
- "peak_eccentric_velocity",
359
- f"Peak eccentric velocity {ecc_vel:.2f} m/s within range for {profile.value}",
360
- value=ecc_vel,
361
- )
362
- else:
363
- expected_min, expected_max = self._get_profile_range(profile, bounds)
364
- result.add_warning(
365
- "peak_eccentric_velocity",
366
- f"Peak eccentric velocity {ecc_vel:.2f} m/s outside typical range "
367
- f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
368
- value=ecc_vel,
369
- bounds=(expected_min, expected_max),
370
- )
292
+ velocity_checks = [
293
+ (
294
+ "peak_eccentric_velocity",
295
+ "peak_eccentric_velocity_m_s",
296
+ CMJBounds.PEAK_ECCENTRIC_VELOCITY,
297
+ "",
298
+ ),
299
+ (
300
+ "peak_concentric_velocity",
301
+ "peak_concentric_velocity_m_s",
302
+ CMJBounds.PEAK_CONCENTRIC_VELOCITY,
303
+ "insufficient to leave ground",
304
+ ),
305
+ ]
306
+
307
+ for metric_name, key_name, bounds, error_suffix in velocity_checks:
308
+ velocity = self._get_metric_value(metrics, key_name, metric_name)
309
+ if velocity is None:
310
+ continue
311
+
312
+ self._validate_velocity_metric(
313
+ metric_name, velocity, bounds, profile, result, error_suffix
314
+ )
371
315
 
372
- # Concentric
373
- con_vel = self._get_metric_value(
374
- metrics, "peak_concentric_velocity_m_s", "peak_concentric_velocity"
375
- )
376
- if con_vel is not None:
377
- bounds = CMJBounds.PEAK_CONCENTRIC_VELOCITY
378
- if not bounds.is_physically_possible(con_vel):
379
- if con_vel < bounds.absolute_min:
380
- result.add_error(
381
- "peak_concentric_velocity",
382
- f"Peak concentric velocity {con_vel:.2f} m/s insufficient to leave ground",
383
- value=con_vel,
384
- bounds=(bounds.absolute_min, bounds.absolute_max),
385
- )
386
- else:
387
- result.add_error(
388
- "peak_concentric_velocity",
389
- f"Peak concentric velocity {con_vel:.2f} m/s exceeds elite capability",
390
- value=con_vel,
391
- bounds=(bounds.absolute_min, bounds.absolute_max),
392
- )
393
- elif bounds.contains(con_vel, profile):
394
- result.add_info(
395
- "peak_concentric_velocity",
396
- f"Peak concentric velocity {con_vel:.2f} m/s within range for {profile.value}",
397
- value=con_vel,
316
+ def _validate_velocity_metric(
317
+ self,
318
+ name: str,
319
+ velocity: float,
320
+ bounds: MetricBounds,
321
+ profile: AthleteProfile,
322
+ result: CMJValidationResult,
323
+ error_suffix: str,
324
+ ) -> None:
325
+ """Validate a velocity metric against bounds."""
326
+ if not bounds.is_physically_possible(velocity):
327
+ if velocity < bounds.absolute_min and error_suffix:
328
+ error_msg = (
329
+ f"Peak {name.replace('peak_', '')} velocity {velocity:.2f} m/s {error_suffix}"
398
330
  )
399
331
  else:
400
- expected_min, expected_max = self._get_profile_range(profile, bounds)
401
- result.add_warning(
402
- "peak_concentric_velocity",
403
- f"Peak concentric velocity {con_vel:.2f} m/s outside typical range "
404
- f"[{expected_min:.2f}-{expected_max:.2f}] for {profile.value}",
405
- value=con_vel,
406
- bounds=(expected_min, expected_max),
332
+ error_msg = (
333
+ f"Peak {name.replace('peak_', '')} velocity {velocity:.2f} m/s outside limits"
407
334
  )
335
+ result.add_error(
336
+ name,
337
+ error_msg,
338
+ value=velocity,
339
+ bounds=(bounds.absolute_min, bounds.absolute_max),
340
+ )
341
+ elif bounds.contains(velocity, profile):
342
+ velocity_type = name.replace("peak_", "").replace("_", " ")
343
+ result.add_info(
344
+ name,
345
+ f"Peak {velocity_type} velocity {velocity:.2f} m/s "
346
+ f"within range for {profile.value}",
347
+ value=velocity,
348
+ )
349
+ else:
350
+ expected_min, expected_max = self._get_profile_range(profile, bounds)
351
+ velocity_type = name.replace("peak_", "").replace("_", " ")
352
+ result.add_warning(
353
+ name,
354
+ f"Peak {velocity_type} velocity {velocity:.2f} m/s "
355
+ f"outside typical range [{expected_min:.2f}-{expected_max:.2f}] "
356
+ f"for {profile.value}",
357
+ value=velocity,
358
+ bounds=(expected_min, expected_max),
359
+ )
408
360
 
409
361
  def _check_flight_time_height_consistency(
410
362
  self, metrics: MetricsDict, result: CMJValidationResult
@@ -625,49 +577,28 @@ class CMJMetricsValidator(MetricsValidator):
625
577
  if angles is None:
626
578
  return
627
579
 
628
- hip = angles.get("hip_angle")
629
- if hip is not None:
630
- if not TripleExtensionBounds.hip_angle_valid(hip, profile):
631
- result.add_warning(
632
- "hip_angle",
633
- f"Hip angle {hip:.1f}° outside expected range for {profile.value}",
634
- value=hip,
635
- )
636
- else:
637
- result.add_info(
638
- "hip_angle",
639
- f"Hip angle {hip:.1f}° within expected range for {profile.value}",
640
- value=hip,
641
- )
580
+ joint_definitions = [
581
+ ("hip_angle", TripleExtensionBounds.hip_angle_valid, "Hip"),
582
+ ("knee_angle", TripleExtensionBounds.knee_angle_valid, "Knee"),
583
+ ("ankle_angle", TripleExtensionBounds.ankle_angle_valid, "Ankle"),
584
+ ]
642
585
 
643
- knee = angles.get("knee_angle")
644
- if knee is not None:
645
- if not TripleExtensionBounds.knee_angle_valid(knee, profile):
646
- result.add_warning(
647
- "knee_angle",
648
- f"Knee angle {knee:.1f}° outside expected range for {profile.value}",
649
- value=knee,
650
- )
651
- else:
652
- result.add_info(
653
- "knee_angle",
654
- f"Knee angle {knee:.1f}° within expected range for {profile.value}",
655
- value=knee,
656
- )
586
+ for metric_name, validator, joint_name in joint_definitions:
587
+ angle = angles.get(metric_name)
588
+ if angle is None:
589
+ continue
657
590
 
658
- ankle = angles.get("ankle_angle")
659
- if ankle is not None:
660
- if not TripleExtensionBounds.ankle_angle_valid(ankle, profile):
591
+ if not validator(angle, profile):
661
592
  result.add_warning(
662
- "ankle_angle",
663
- f"Ankle angle {ankle:.1f}° outside expected range for {profile.value}",
664
- value=ankle,
593
+ metric_name,
594
+ f"{joint_name} angle {angle:.1f}° outside expected range for {profile.value}",
595
+ value=angle,
665
596
  )
666
597
  else:
667
598
  result.add_info(
668
- "ankle_angle",
669
- f"Ankle angle {ankle:.1f}° within expected range for {profile.value}",
670
- value=ankle,
599
+ metric_name,
600
+ f"{joint_name} angle {angle:.1f}° within expected range for {profile.value}",
601
+ value=angle,
671
602
  )
672
603
 
673
604
  # Detect joint compensation patterns
@@ -691,32 +622,32 @@ class CMJMetricsValidator(MetricsValidator):
691
622
  if hip is None or knee is None or ankle is None:
692
623
  return # Need all three to detect patterns
693
624
 
694
- # Get profile-specific bounds
695
- if profile == AthleteProfile.ELDERLY:
696
- hip_min, hip_max = 150, 175
697
- knee_min, knee_max = 155, 175
698
- ankle_min, ankle_max = 100, 125
699
- elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
700
- hip_min, hip_max = 160, 180
701
- knee_min, knee_max = 165, 182
702
- ankle_min, ankle_max = 110, 140
703
- elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
704
- hip_min, hip_max = 170, 185
705
- knee_min, knee_max = 173, 190
706
- ankle_min, ankle_max = 125, 155
707
- else:
625
+ # Profile-specific bounds lookup
626
+ profile_bounds = {
627
+ AthleteProfile.ELDERLY: (150, 175, 155, 175, 100, 125),
628
+ AthleteProfile.UNTRAINED: (160, 180, 165, 182, 110, 140),
629
+ AthleteProfile.RECREATIONAL: (160, 180, 165, 182, 110, 140),
630
+ AthleteProfile.TRAINED: (170, 185, 173, 190, 125, 155),
631
+ AthleteProfile.ELITE: (170, 185, 173, 190, 125, 155),
632
+ }
633
+
634
+ bounds_tuple = profile_bounds.get(profile)
635
+ if not bounds_tuple:
708
636
  return
709
637
 
710
- # Count how many joints are near their boundaries
711
- joints_at_boundary = 0
712
- boundary_threshold = 3.0 # degrees from limit
638
+ hip_min, hip_max, knee_min, knee_max, ankle_min, ankle_max = bounds_tuple
713
639
 
714
- if hip <= hip_min + boundary_threshold or hip >= hip_max - boundary_threshold:
715
- joints_at_boundary += 1
716
- if knee <= knee_min + boundary_threshold or knee >= knee_max - boundary_threshold:
717
- joints_at_boundary += 1
718
- if ankle <= ankle_min + boundary_threshold or ankle >= ankle_max - boundary_threshold:
719
- joints_at_boundary += 1
640
+ # Count joints at boundaries
641
+ boundary_threshold = 3.0 # degrees from limit
642
+ joints_at_boundary = sum(
643
+ 1
644
+ for val, min_val, max_val in [
645
+ (hip, hip_min, hip_max),
646
+ (knee, knee_min, knee_max),
647
+ (ankle, ankle_min, ankle_max),
648
+ ]
649
+ if val <= min_val + boundary_threshold or val >= max_val - boundary_threshold
650
+ )
720
651
 
721
652
  # If 2+ joints at boundaries, likely compensation pattern
722
653
  if joints_at_boundary >= 2:
@@ -727,20 +658,3 @@ class CMJMetricsValidator(MetricsValidator):
727
658
  f"May indicate compensatory movement pattern.",
728
659
  value=float(joints_at_boundary),
729
660
  )
730
-
731
- @staticmethod
732
- def _get_profile_range(profile: AthleteProfile, bounds: MetricBounds) -> tuple[float, float]:
733
- """Get min/max bounds for specific profile."""
734
- if profile == AthleteProfile.ELDERLY:
735
- return (bounds.practical_min, bounds.recreational_max)
736
- elif profile == AthleteProfile.UNTRAINED:
737
- return (bounds.practical_min, bounds.recreational_max)
738
- elif profile == AthleteProfile.RECREATIONAL:
739
- return (bounds.recreational_min, bounds.recreational_max)
740
- elif profile == AthleteProfile.TRAINED:
741
- trained_min = (bounds.recreational_min + bounds.elite_min) / 2
742
- trained_max = (bounds.recreational_max + bounds.elite_max) / 2
743
- return (trained_min, trained_max)
744
- elif profile == AthleteProfile.ELITE:
745
- return (bounds.elite_min, bounds.elite_max)
746
- return (bounds.absolute_min, bounds.absolute_max)
@@ -314,6 +314,8 @@ def _assign_contact_states(
314
314
  ) -> list[ContactState]:
315
315
  """Assign contact states based on contact frames and visibility.
316
316
 
317
+ Vectorized implementation for 2-3x speedup over loop-based version.
318
+
317
319
  Args:
318
320
  n_frames: Total number of frames
319
321
  contact_frames: Set of frames with confirmed contact
@@ -323,15 +325,33 @@ def _assign_contact_states(
323
325
  Returns:
324
326
  List of ContactState for each frame
325
327
  """
326
- states = []
327
- for i in range(n_frames):
328
- if visibilities is not None and visibilities[i] < visibility_threshold:
329
- states.append(ContactState.UNKNOWN)
330
- elif i in contact_frames:
331
- states.append(ContactState.ON_GROUND)
328
+ # Integer mapping for vectorized operations: IN_AIR=0, ON_GROUND=1, UNKNOWN=2
329
+ _state_order = [ContactState.IN_AIR, ContactState.ON_GROUND, ContactState.UNKNOWN]
330
+
331
+ # Initialize with IN_AIR (default)
332
+ states = np.zeros(n_frames, dtype=np.int8)
333
+
334
+ # Mark ON_GROUND where visibility is sufficient
335
+ if contact_frames:
336
+ contact_array = np.fromiter(contact_frames, dtype=int)
337
+ # Filter to valid indices only
338
+ valid_mask = (contact_array >= 0) & (contact_array < n_frames)
339
+ valid_contacts = contact_array[valid_mask]
340
+
341
+ # Only mark ON_GROUND for frames with good visibility
342
+ if visibilities is not None:
343
+ good_visibility = visibilities[valid_contacts] >= visibility_threshold
344
+ states[valid_contacts[good_visibility]] = 1
332
345
  else:
333
- states.append(ContactState.IN_AIR)
334
- return states
346
+ states[valid_contacts] = 1
347
+
348
+ # Mark UNKNOWN last (highest priority - overrides ON_GROUND)
349
+ if visibilities is not None:
350
+ unknown_mask = visibilities < visibility_threshold
351
+ states[unknown_mask] = 2
352
+
353
+ # Convert integer indices back to ContactState
354
+ return [_state_order[s] for s in states]
335
355
 
336
356
 
337
357
  def _compute_near_ground_mask(
@@ -677,6 +697,48 @@ def refine_transition_with_curvature(
677
697
  return refined_frame
678
698
 
679
699
 
700
+ def _refine_phase_boundaries(
701
+ foot_positions: FloatArray,
702
+ start_frac: float,
703
+ end_frac: float,
704
+ start_type: str,
705
+ end_type: str,
706
+ smoothing_window: int,
707
+ polyorder: int,
708
+ ) -> tuple[float, float]:
709
+ """Refine phase boundary frames using curvature analysis.
710
+
711
+ Args:
712
+ foot_positions: Array of foot y-positions (normalized, 0-1)
713
+ start_frac: Start frame (fractional)
714
+ end_frac: End frame (fractional)
715
+ start_type: Transition type for start ("landing" or "takeoff")
716
+ end_type: Transition type for end ("landing" or "takeoff")
717
+ smoothing_window: Window size for acceleration computation
718
+ polyorder: Polynomial order for Savitzky-Golay filter
719
+
720
+ Returns:
721
+ Tuple of (refined_start, refined_end) fractional frame indices
722
+ """
723
+ refined_start = refine_transition_with_curvature(
724
+ foot_positions,
725
+ start_frac,
726
+ start_type,
727
+ search_window=3,
728
+ smoothing_window=smoothing_window,
729
+ polyorder=polyorder,
730
+ )
731
+ refined_end = refine_transition_with_curvature(
732
+ foot_positions,
733
+ end_frac,
734
+ end_type,
735
+ search_window=3,
736
+ smoothing_window=smoothing_window,
737
+ polyorder=polyorder,
738
+ )
739
+ return refined_start, refined_end
740
+
741
+
680
742
  def find_interpolated_phase_transitions_with_curvature(
681
743
  foot_positions: FloatArray,
682
744
  contact_states: list[ContactState],
@@ -716,47 +778,30 @@ def find_interpolated_phase_transitions_with_curvature(
716
778
  refined_phases: list[tuple[float, float, ContactState]] = []
717
779
 
718
780
  for start_frac, end_frac, state in interpolated_phases:
719
- refined_start = start_frac
720
- refined_end = end_frac
721
-
722
781
  if state == ContactState.ON_GROUND:
723
- # Refine landing (start of ground contact)
724
- refined_start = refine_transition_with_curvature(
782
+ # ON_GROUND: landing at start, takeoff at end
783
+ refined_start, refined_end = _refine_phase_boundaries(
725
784
  foot_positions,
726
785
  start_frac,
727
- "landing",
728
- search_window=3,
729
- smoothing_window=smoothing_window,
730
- polyorder=polyorder,
731
- )
732
- # Refine takeoff (end of ground contact)
733
- refined_end = refine_transition_with_curvature(
734
- foot_positions,
735
786
  end_frac,
787
+ "landing",
736
788
  "takeoff",
737
- search_window=3,
738
- smoothing_window=smoothing_window,
739
- polyorder=polyorder,
789
+ smoothing_window,
790
+ polyorder,
740
791
  )
741
-
742
792
  elif state == ContactState.IN_AIR:
743
- # For flight phases, takeoff is at start, landing is at end
744
- refined_start = refine_transition_with_curvature(
793
+ # IN_AIR: takeoff at start, landing at end
794
+ refined_start, refined_end = _refine_phase_boundaries(
745
795
  foot_positions,
746
796
  start_frac,
747
- "takeoff",
748
- search_window=3,
749
- smoothing_window=smoothing_window,
750
- polyorder=polyorder,
751
- )
752
- refined_end = refine_transition_with_curvature(
753
- foot_positions,
754
797
  end_frac,
798
+ "takeoff",
755
799
  "landing",
756
- search_window=3,
757
- smoothing_window=smoothing_window,
758
- polyorder=polyorder,
800
+ smoothing_window,
801
+ polyorder,
759
802
  )
803
+ else:
804
+ refined_start, refined_end = start_frac, end_frac
760
805
 
761
806
  refined_phases.append((refined_start, refined_end, state))
762
807