kinemotion 0.73.0__py3-none-any.whl → 0.74.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.

@@ -8,9 +8,12 @@ import click
8
8
 
9
9
  from ..core.auto_tuning import QualityPreset
10
10
  from ..core.cli_utils import (
11
+ batch_processing_options,
11
12
  collect_video_files,
12
13
  common_output_options,
13
14
  generate_batch_output_paths,
15
+ quality_option,
16
+ verbose_option,
14
17
  )
15
18
  from .api import AnalysisOverrides, process_cmj_video
16
19
  from .kinematics import CMJMetrics
@@ -59,52 +62,9 @@ def _process_batch_videos(
59
62
  @click.command(name="cmj-analyze")
60
63
  @click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
61
64
  @common_output_options
62
- @click.option(
63
- "--quality",
64
- type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
65
- default="balanced",
66
- help=(
67
- "Analysis quality preset: "
68
- "fast (quick, less precise), "
69
- "balanced (default, good for most cases), "
70
- "accurate (research-grade, slower)"
71
- ),
72
- show_default=True,
73
- )
74
- @click.option(
75
- "--verbose",
76
- "-v",
77
- is_flag=True,
78
- help="Show auto-selected parameters and analysis details",
79
- )
80
- # Batch processing options
81
- @click.option(
82
- "--batch",
83
- is_flag=True,
84
- help="Enable batch processing mode for multiple videos",
85
- )
86
- @click.option(
87
- "--workers",
88
- type=int,
89
- default=4,
90
- help="Number of parallel workers for batch processing (default: 4)",
91
- show_default=True,
92
- )
93
- @click.option(
94
- "--output-dir",
95
- type=click.Path(),
96
- help="Directory for debug video outputs (batch mode only)",
97
- )
98
- @click.option(
99
- "--json-output-dir",
100
- type=click.Path(),
101
- help="Directory for JSON metrics outputs (batch mode only)",
102
- )
103
- @click.option(
104
- "--csv-summary",
105
- type=click.Path(),
106
- help="Path for CSV summary export (batch mode only)",
107
- )
65
+ @quality_option
66
+ @verbose_option
67
+ @batch_processing_options
108
68
  # Expert parameters (hidden in help, but always available for advanced users)
109
69
  @click.option(
110
70
  "--smoothing-window",
@@ -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)
@@ -677,6 +677,48 @@ def refine_transition_with_curvature(
677
677
  return refined_frame
678
678
 
679
679
 
680
+ def _refine_phase_boundaries(
681
+ foot_positions: FloatArray,
682
+ start_frac: float,
683
+ end_frac: float,
684
+ start_type: str,
685
+ end_type: str,
686
+ smoothing_window: int,
687
+ polyorder: int,
688
+ ) -> tuple[float, float]:
689
+ """Refine phase boundary frames using curvature analysis.
690
+
691
+ Args:
692
+ foot_positions: Array of foot y-positions (normalized, 0-1)
693
+ start_frac: Start frame (fractional)
694
+ end_frac: End frame (fractional)
695
+ start_type: Transition type for start ("landing" or "takeoff")
696
+ end_type: Transition type for end ("landing" or "takeoff")
697
+ smoothing_window: Window size for acceleration computation
698
+ polyorder: Polynomial order for Savitzky-Golay filter
699
+
700
+ Returns:
701
+ Tuple of (refined_start, refined_end) fractional frame indices
702
+ """
703
+ refined_start = refine_transition_with_curvature(
704
+ foot_positions,
705
+ start_frac,
706
+ start_type,
707
+ search_window=3,
708
+ smoothing_window=smoothing_window,
709
+ polyorder=polyorder,
710
+ )
711
+ refined_end = refine_transition_with_curvature(
712
+ foot_positions,
713
+ end_frac,
714
+ end_type,
715
+ search_window=3,
716
+ smoothing_window=smoothing_window,
717
+ polyorder=polyorder,
718
+ )
719
+ return refined_start, refined_end
720
+
721
+
680
722
  def find_interpolated_phase_transitions_with_curvature(
681
723
  foot_positions: FloatArray,
682
724
  contact_states: list[ContactState],
@@ -716,47 +758,30 @@ def find_interpolated_phase_transitions_with_curvature(
716
758
  refined_phases: list[tuple[float, float, ContactState]] = []
717
759
 
718
760
  for start_frac, end_frac, state in interpolated_phases:
719
- refined_start = start_frac
720
- refined_end = end_frac
721
-
722
761
  if state == ContactState.ON_GROUND:
723
- # Refine landing (start of ground contact)
724
- refined_start = refine_transition_with_curvature(
762
+ # ON_GROUND: landing at start, takeoff at end
763
+ refined_start, refined_end = _refine_phase_boundaries(
725
764
  foot_positions,
726
765
  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
766
  end_frac,
767
+ "landing",
736
768
  "takeoff",
737
- search_window=3,
738
- smoothing_window=smoothing_window,
739
- polyorder=polyorder,
769
+ smoothing_window,
770
+ polyorder,
740
771
  )
741
-
742
772
  elif state == ContactState.IN_AIR:
743
- # For flight phases, takeoff is at start, landing is at end
744
- refined_start = refine_transition_with_curvature(
773
+ # IN_AIR: takeoff at start, landing at end
774
+ refined_start, refined_end = _refine_phase_boundaries(
745
775
  foot_positions,
746
776
  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
777
  end_frac,
778
+ "takeoff",
755
779
  "landing",
756
- search_window=3,
757
- smoothing_window=smoothing_window,
758
- polyorder=polyorder,
780
+ smoothing_window,
781
+ polyorder,
759
782
  )
783
+ else:
784
+ refined_start, refined_end = start_frac, end_frac
760
785
 
761
786
  refined_phases.append((refined_start, refined_end, state))
762
787