sweatstack 0.89.0__tar.gz → 0.90.0__tar.gz

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.
Files changed (77) hide show
  1. {sweatstack-0.89.0 → sweatstack-0.90.0}/CHANGELOG.md +23 -0
  2. {sweatstack-0.89.0 → sweatstack-0.90.0}/PKG-INFO +1 -1
  3. {sweatstack-0.89.0 → sweatstack-0.90.0}/pyproject.toml +1 -1
  4. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/client.py +50 -56
  5. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_longitudinal_mean_max_after.py +13 -20
  6. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_segmentation.py +15 -3
  7. {sweatstack-0.89.0 → sweatstack-0.90.0}/uv.lock +1 -1
  8. {sweatstack-0.89.0 → sweatstack-0.90.0}/.claude/settings.local.json +0 -0
  9. {sweatstack-0.89.0 → sweatstack-0.90.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  10. {sweatstack-0.89.0 → sweatstack-0.90.0}/.claude/skills/sweatstack-python/client.md +0 -0
  11. {sweatstack-0.89.0 → sweatstack-0.90.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  12. {sweatstack-0.89.0 → sweatstack-0.90.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  13. {sweatstack-0.89.0 → sweatstack-0.90.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  14. {sweatstack-0.89.0 → sweatstack-0.90.0}/.github/workflows/ci.yml +0 -0
  15. {sweatstack-0.89.0 → sweatstack-0.90.0}/.gitignore +0 -0
  16. {sweatstack-0.89.0 → sweatstack-0.90.0}/.python-version +0 -0
  17. {sweatstack-0.89.0 → sweatstack-0.90.0}/AGENTS.md +0 -0
  18. {sweatstack-0.89.0 → sweatstack-0.90.0}/CONTRIBUTING.md +0 -0
  19. {sweatstack-0.89.0 → sweatstack-0.90.0}/DEVELOPMENT.md +0 -0
  20. {sweatstack-0.89.0 → sweatstack-0.90.0}/LICENSE +0 -0
  21. {sweatstack-0.89.0 → sweatstack-0.90.0}/Makefile +0 -0
  22. {sweatstack-0.89.0 → sweatstack-0.90.0}/README.md +0 -0
  23. {sweatstack-0.89.0 → sweatstack-0.90.0}/docs/conf.py +0 -0
  24. {sweatstack-0.89.0 → sweatstack-0.90.0}/docs/everything.rst +0 -0
  25. {sweatstack-0.89.0 → sweatstack-0.90.0}/docs/index.rst +0 -0
  26. {sweatstack-0.89.0 → sweatstack-0.90.0}/examples/fastapi_webhooks_example.py +0 -0
  27. {sweatstack-0.89.0 → sweatstack-0.90.0}/examples/send_webhook.py +0 -0
  28. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/001a_tests.md +0 -0
  29. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/001b_metadata.md +0 -0
  30. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/001c_dailies.md +0 -0
  31. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  32. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/003_trace_test_linking.md +0 -0
  33. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/004_codebase_hygiene.md +0 -0
  34. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/005_ost_sport_bridge.md +0 -0
  35. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/006_output_backends.md +0 -0
  36. {sweatstack-0.89.0 → sweatstack-0.90.0}/plans/007_account_status_userinfo_portal.md +0 -0
  37. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  38. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/__init__.py +0 -0
  39. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/_frames.py +0 -0
  40. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/cli.py +0 -0
  41. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/constants.py +0 -0
  42. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/exceptions.py +0 -0
  43. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/__init__.py +0 -0
  44. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  45. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/config.py +0 -0
  46. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  47. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/models.py +0 -0
  48. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/routes.py +0 -0
  49. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/session.py +0 -0
  50. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  51. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  52. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/ipython_init.py +0 -0
  53. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  54. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/openapi_schemas.py +0 -0
  55. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/py.typed +0 -0
  56. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/schemas.py +0 -0
  57. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/streamlit.py +0 -0
  58. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/sweatshell.py +0 -0
  59. {sweatstack-0.89.0 → sweatstack-0.90.0}/src/sweatstack/utils.py +0 -0
  60. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/__init__.py +0 -0
  61. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_access_token_cache.py +0 -0
  62. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_account_status.py +0 -0
  63. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_dailies.py +0 -0
  64. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_dtype_conversion.py +0 -0
  65. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_exceptions.py +0 -0
  66. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_frames.py +0 -0
  67. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_identity.py +0 -0
  68. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_metadata.py +0 -0
  69. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_output.py +0 -0
  70. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_portal.py +0 -0
  71. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_public_surface.py +0 -0
  72. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_sport_ost.py +0 -0
  73. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_teams.py +0 -0
  74. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_tests.py +0 -0
  75. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_trace_test_linking.py +0 -0
  76. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_webhooks.py +0 -0
  77. {sweatstack-0.89.0 → sweatstack-0.90.0}/tests/test_write_timezone_validation.py +0 -0
@@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [0.90.0] - 2026-09-28
10
+
11
+ ### Changed
12
+
13
+ - **BREAKING: mean-max curves are one row per duration** (server plan 063).
14
+ `get_activity_mean_max`, `get_latest_activity_mean_max` and `get_longitudinal_mean_max` return
15
+ `duration`, the metric (W or m/s) and `start` (UTC timestamp of the best effort), plus
16
+ `activity_id`, `sport` and `after` on the longitudinal curve. By default 19 durations from 1 s to
17
+ 6 h; the curve can rise again at longer durations and is returned as it is.
18
+
19
+ ### Added
20
+
21
+ - `durations=` on all three mean-max methods: `None` for the 19 defaults, `"all"` for the full grid,
22
+ or a list of seconds. Keyword-only.
23
+
24
+ ### Removed
25
+
26
+ - **BREAKING:** `segmentation` on `get_activity_mean_max` and `get_latest_activity_mean_max` (it never
27
+ reduced the payload; the server ignores it). It was positional: passing `True` in that slot now
28
+ raises `TypeError`.
29
+ - **BREAKING:** `by` on `get_longitudinal_mean_max`, and its `DeprecationWarning`. Every curve is
30
+ duration-oriented.
31
+
9
32
  ## [0.89.0] - 2026-09-28
10
33
 
11
34
  Frames on your terms. Every method that returns a collection takes `output=`:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sweatstack
3
- Version: 0.89.0
3
+ Version: 0.90.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Project-URL: Homepage, https://sweatstack.no
6
6
  Project-URL: Documentation, https://docs.sweatstack.no/getting-started/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.89.0"
3
+ version = "0.90.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,6 +20,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
20
20
  from importlib.metadata import version
21
21
  from inspect import getmembers, isfunction
22
22
  from pathlib import Path
23
+ from collections.abc import Sequence
23
24
  from typing import TYPE_CHECKING, Any, Dict, Generator, Literal, overload
24
25
 
25
26
  from pydantic import SecretStr
@@ -715,6 +716,15 @@ class _DelegationMixin:
715
716
  )
716
717
 
717
718
 
719
+ def _with_durations(params: dict, durations: "Sequence[int] | str | None") -> dict:
720
+ """Add the mean-max ``durations`` query value: omitted, ``"all"``, or comma-separated seconds."""
721
+ if durations is None:
722
+ return params
723
+ if isinstance(durations, str):
724
+ return {**params, "durations": durations}
725
+ return {**params, "durations": ",".join(str(int(d)) for d in durations)}
726
+
727
+
718
728
  class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixin):
719
729
  """SweatStack API client for accessing activities, traces, and user data.
720
730
 
@@ -1393,8 +1403,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1393
1403
  self,
1394
1404
  activity_id: str,
1395
1405
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1396
- segmentation: bool = False,
1397
1406
  *,
1407
+ durations: Sequence[int] | Literal["all"] | None = None,
1398
1408
  output: None = None,
1399
1409
  ) -> pd.DataFrame | pl.DataFrame: ...
1400
1410
 
@@ -1403,8 +1413,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1403
1413
  self,
1404
1414
  activity_id: str,
1405
1415
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1406
- segmentation: bool = False,
1407
1416
  *,
1417
+ durations: Sequence[int] | Literal["all"] | None = None,
1408
1418
  output: Literal["pandas"],
1409
1419
  ) -> pd.DataFrame: ...
1410
1420
 
@@ -1413,8 +1423,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1413
1423
  self,
1414
1424
  activity_id: str,
1415
1425
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1416
- segmentation: bool = False,
1417
1426
  *,
1427
+ durations: Sequence[int] | Literal["all"] | None = None,
1418
1428
  output: Literal["polars"],
1419
1429
  ) -> pl.DataFrame: ...
1420
1430
 
@@ -1423,8 +1433,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1423
1433
  self,
1424
1434
  activity_id: str,
1425
1435
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1426
- segmentation: bool = False,
1427
1436
  *,
1437
+ durations: Sequence[int] | Literal["all"] | None = None,
1428
1438
  output: Literal["arrow"],
1429
1439
  ) -> pa.Table: ...
1430
1440
 
@@ -1433,8 +1443,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1433
1443
  self,
1434
1444
  activity_id: str,
1435
1445
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1436
- segmentation: bool = False,
1437
1446
  *,
1447
+ durations: Sequence[int] | Literal["all"] | None = None,
1438
1448
  output: Literal["bytes"],
1439
1449
  ) -> bytes: ...
1440
1450
 
@@ -1442,8 +1452,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1442
1452
  self,
1443
1453
  activity_id: str,
1444
1454
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1445
- segmentation: bool = False,
1446
1455
  *,
1456
+ durations: Sequence[int] | Literal["all"] | None = None,
1447
1457
  output: FrameOutput | None = None,
1448
1458
  ) -> pd.DataFrame | pl.DataFrame | pa.Table | bytes:
1449
1459
  """Gets the mean-max data for a specific activity.
@@ -1454,14 +1464,17 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1454
1464
  Args:
1455
1465
  activity_id: The unique identifier of the activity.
1456
1466
  metric: The metric to calculate mean-max values for, either "power" or "speed".
1457
- segmentation: Downsample with AISC (Adaptive Intensity Segmentation Codec) to reduce data points
1458
- for visualization. Defaults to False.
1467
+ durations: The durations to return, in seconds. ``None`` (default) for 19 durations from
1468
+ 1 s to 6 h, ``"all"`` for the full grid (1 s steps to 3 min, then 5, 10, 30 and 60 s
1469
+ steps), or a list of seconds. Durations the activity did not last are left out.
1459
1470
  output: ``"pandas"``, ``"polars"``, ``"arrow"`` or ``"bytes"`` (the raw parquet
1460
1471
  response). Defaults to the installed frame library, Polars if both are.
1461
1472
  Overrides the client-level default for this call.
1462
1473
 
1463
1474
  Returns:
1464
- A frame (per ``output``) containing the mean-max curve; the metric value and ``duration`` are columns.
1475
+ A frame (per ``output``), one row per duration: ``duration``, the metric (power in W or
1476
+ speed in m/s) and ``start`` (UTC timestamp at which that best effort began). The curve
1477
+ is returned as it is and can rise again at longer durations (intermittent efforts).
1465
1478
 
1466
1479
  Raises:
1467
1480
  SweatStackNotFoundError: If the activity does not exist.
@@ -1471,10 +1484,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1471
1484
  with self._http_client() as client:
1472
1485
  response = client.get(
1473
1486
  url=f"/api/v1/activities/{activity_id}/mean-max",
1474
- params={
1475
- "metric": metric,
1476
- "segmentation": segmentation,
1477
- },
1487
+ params=_with_durations({"metric": metric}, durations),
1478
1488
  )
1479
1489
  self._raise_for_status(response)
1480
1490
  return self._read_frame(response.content, output)
@@ -1650,8 +1660,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1650
1660
  self,
1651
1661
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1652
1662
  sport: Sport | str | None = None,
1653
- segmentation: bool = False,
1654
1663
  *,
1664
+ durations: Sequence[int] | Literal["all"] | None = None,
1655
1665
  output: None = None,
1656
1666
  ) -> pd.DataFrame | pl.DataFrame: ...
1657
1667
 
@@ -1660,8 +1670,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1660
1670
  self,
1661
1671
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1662
1672
  sport: Sport | str | None = None,
1663
- segmentation: bool = False,
1664
1673
  *,
1674
+ durations: Sequence[int] | Literal["all"] | None = None,
1665
1675
  output: Literal["pandas"],
1666
1676
  ) -> pd.DataFrame: ...
1667
1677
 
@@ -1670,8 +1680,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1670
1680
  self,
1671
1681
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1672
1682
  sport: Sport | str | None = None,
1673
- segmentation: bool = False,
1674
1683
  *,
1684
+ durations: Sequence[int] | Literal["all"] | None = None,
1675
1685
  output: Literal["polars"],
1676
1686
  ) -> pl.DataFrame: ...
1677
1687
 
@@ -1680,8 +1690,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1680
1690
  self,
1681
1691
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1682
1692
  sport: Sport | str | None = None,
1683
- segmentation: bool = False,
1684
1693
  *,
1694
+ durations: Sequence[int] | Literal["all"] | None = None,
1685
1695
  output: Literal["arrow"],
1686
1696
  ) -> pa.Table: ...
1687
1697
 
@@ -1690,8 +1700,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1690
1700
  self,
1691
1701
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1692
1702
  sport: Sport | str | None = None,
1693
- segmentation: bool = False,
1694
1703
  *,
1704
+ durations: Sequence[int] | Literal["all"] | None = None,
1695
1705
  output: Literal["bytes"],
1696
1706
  ) -> bytes: ...
1697
1707
 
@@ -1699,8 +1709,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1699
1709
  self,
1700
1710
  metric: Literal[Metric.power, Metric.speed] | Literal["power", "speed"],
1701
1711
  sport: Sport | str | None = None,
1702
- segmentation: bool = False,
1703
1712
  *,
1713
+ durations: Sequence[int] | Literal["all"] | None = None,
1704
1714
  output: FrameOutput | None = None,
1705
1715
  ) -> pd.DataFrame | pl.DataFrame | pa.Table | bytes:
1706
1716
  """Gets the mean-max curve for the latest activity of a specific sport.
@@ -1711,20 +1721,21 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1711
1721
  Args:
1712
1722
  metric: The metric to calculate the mean-max curve for. Can be either "power" or "speed".
1713
1723
  sport: Optional sport to filter by. Can be a Sport enum or string.
1714
- segmentation: Downsample the mean-max curve with AISC (Adaptive Intensity Segmentation Codec).
1715
- Defaults to False.
1724
+ durations: As for :meth:`get_activity_mean_max`.
1716
1725
  output: ``"pandas"``, ``"polars"``, ``"arrow"`` or ``"bytes"`` (the raw parquet
1717
1726
  response). Defaults to the installed frame library, Polars if both are.
1718
1727
  Overrides the client-level default for this call.
1719
1728
 
1720
1729
  Returns:
1721
- A frame (per ``output``) containing the mean-max curve; the metric value and ``duration`` are columns.
1730
+ A frame (per ``output``), one row per duration: ``duration``, the metric (power in W or
1731
+ speed in m/s) and ``start`` (UTC timestamp at which that best effort began). The curve
1732
+ is returned as it is and can rise again at longer durations (intermittent efforts).
1722
1733
 
1723
1734
  Raises:
1724
1735
  SweatStackAPIError: If the API request fails.
1725
1736
  """
1726
1737
  activity = self.get_latest_activity(sport=sport)
1727
- return self.get_activity_mean_max(activity.id, metric, segmentation, output=output)
1738
+ return self.get_activity_mean_max(activity.id, metric, durations=durations, output=output)
1728
1739
 
1729
1740
  @overload
1730
1741
  def get_longitudinal_data(
@@ -1879,7 +1890,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1879
1890
  date: date | str | None = None,
1880
1891
  window_days: int | None = None,
1881
1892
  after: list[float] | float | None = None,
1882
- by: Literal["intensity", "duration"] | None = None,
1893
+ durations: Sequence[int] | Literal["all"] | None = None,
1883
1894
  output: None = None,
1884
1895
  ) -> pd.DataFrame | pl.DataFrame: ...
1885
1896
 
@@ -1895,7 +1906,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1895
1906
  date: date | str | None = None,
1896
1907
  window_days: int | None = None,
1897
1908
  after: list[float] | float | None = None,
1898
- by: Literal["intensity", "duration"] | None = None,
1909
+ durations: Sequence[int] | Literal["all"] | None = None,
1899
1910
  output: Literal["pandas"],
1900
1911
  ) -> pd.DataFrame: ...
1901
1912
 
@@ -1911,7 +1922,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1911
1922
  date: date | str | None = None,
1912
1923
  window_days: int | None = None,
1913
1924
  after: list[float] | float | None = None,
1914
- by: Literal["intensity", "duration"] | None = None,
1925
+ durations: Sequence[int] | Literal["all"] | None = None,
1915
1926
  output: Literal["polars"],
1916
1927
  ) -> pl.DataFrame: ...
1917
1928
 
@@ -1927,7 +1938,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1927
1938
  date: date | str | None = None,
1928
1939
  window_days: int | None = None,
1929
1940
  after: list[float] | float | None = None,
1930
- by: Literal["intensity", "duration"] | None = None,
1941
+ durations: Sequence[int] | Literal["all"] | None = None,
1931
1942
  output: Literal["arrow"],
1932
1943
  ) -> pa.Table: ...
1933
1944
 
@@ -1943,7 +1954,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1943
1954
  date: date | str | None = None,
1944
1955
  window_days: int | None = None,
1945
1956
  after: list[float] | float | None = None,
1946
- by: Literal["intensity", "duration"] | None = None,
1957
+ durations: Sequence[int] | Literal["all"] | None = None,
1947
1958
  output: Literal["bytes"],
1948
1959
  ) -> bytes: ...
1949
1960
 
@@ -1958,7 +1969,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1958
1969
  date: date | str | None = None,
1959
1970
  window_days: int | None = None,
1960
1971
  after: list[float] | float | None = None,
1961
- by: Literal["intensity", "duration"] | None = None,
1972
+ durations: Sequence[int] | Literal["all"] | None = None,
1962
1973
  output: FrameOutput | None = None,
1963
1974
  ) -> pd.DataFrame | pl.DataFrame | pa.Table | bytes:
1964
1975
  """Gets the mean-max curve for one or more sports and a metric.
@@ -1977,22 +1988,19 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1977
1988
  enveloped across rides. The returned DataFrame then has an ``after``
1978
1989
  column (one curve per value). Max 5 values; the date range is capped at
1979
1990
  1 year when ``after`` is used.
1980
- by: Axis to index the curve on, only meaningful with ``after``. ``"duration"``
1981
- indexes by duration via the fast segment kernel (requires ``metric="power"``);
1982
- ``"intensity"`` indexes by the metric value and is **deprecated**. For the
1983
- ``after`` case ``"duration"`` will become the only supported orientation, and
1984
- more generally ``"duration"`` is set to become the default and only option, so
1985
- we recommend passing ``by="duration"`` explicitly. When left as ``None``
1986
- (default), the server picks: ``after`` with ``metric="power"`` resolves to
1987
- ``"duration"``, every other case to ``"intensity"``.
1991
+ durations: The durations to return, in seconds. ``None`` (default) for 19 durations from
1992
+ 1 s to 6 h, ``"all"`` for the full grid (1 s steps to 3 min, then 5, 10, 30 and 60 s
1993
+ steps), or a list of seconds. Durations no activity lasted are left out.
1988
1994
  output: ``"pandas"``, ``"polars"``, ``"arrow"`` or ``"bytes"`` (the raw parquet
1989
1995
  response). Defaults to the installed frame library, Polars if both are.
1990
1996
  Overrides the client-level default for this call.
1991
1997
 
1992
1998
  Returns:
1993
- A frame (per ``output``) containing the mean-max curve with the metric value, ``duration``,
1994
- ``activity_id`` and ``sport`` as columns. With ``after``, an ``after`` column
1995
- distinguishes the fatigue states.
1999
+ A frame (per ``output``), one row per duration: ``duration``, the metric (power in W or
2000
+ speed in m/s), ``start`` (UTC timestamp at which that best effort began), and the
2001
+ ``activity_id`` and ``sport`` that set it. With ``after``, an ``after`` column
2002
+ distinguishes the fatigue states. The curve is returned as it is and can rise again at
2003
+ longer durations (intermittent efforts).
1996
2004
 
1997
2005
  Raises:
1998
2006
  ValueError: If both ``sport`` and ``sports`` are provided, or neither is provided.
@@ -2011,24 +2019,10 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
2011
2019
  raise ValueError("'sports' is required.")
2012
2020
  metric = self._enums_to_strings([metric])[0]
2013
2021
 
2014
- # For the fatigue ('after') case, by='duration' is the default and the only
2015
- # orientation going forward; by='intensity' is deprecated there. (For metric='speed'
2016
- # it is still the only option, so no warning.)
2017
- if by == "intensity" and after is not None and metric == "power":
2018
- warnings.warn(
2019
- "by='intensity' is deprecated for 'after' (fatigue) mean-max; by='duration' "
2020
- "is the default and only supported orientation going forward. Pass "
2021
- "by='duration' or leave 'by' unset.",
2022
- DeprecationWarning,
2023
- stacklevel=2,
2024
- )
2025
-
2026
- params = {
2022
+ params = _with_durations({
2027
2023
  "sport": self._enums_to_strings(sports),
2028
2024
  "metric": metric,
2029
- }
2030
- if by is not None:
2031
- params["by"] = by
2025
+ }, durations)
2032
2026
  if start is not None:
2033
2027
  params["start"] = start
2034
2028
  if end is not None:
@@ -83,7 +83,7 @@ def test_no_after_is_unchanged(client):
83
83
 
84
84
 
85
85
  def _index_free_by_duration_response() -> bytes:
86
- """A by=duration long-format response (duration-indexed on the wire as columns)."""
86
+ """An `after` response as the server returns it (plan 063): one row per duration, columns only."""
87
87
  df = pd.DataFrame({
88
88
  "duration": pd.to_timedelta([5, 60, 300, 5, 60], unit="s"),
89
89
  "power": [400.0, 300.0, 250.0, 380.0, 290.0],
@@ -96,25 +96,22 @@ def _index_free_by_duration_response() -> bytes:
96
96
  return buf.getvalue()
97
97
 
98
98
 
99
- def test_by_defaults_to_none_and_is_omitted(client):
100
- # `by` unset -> omitted from the request so the server picks the orientation (0.85.0+),
101
- # rather than the client forcing the deprecated value-indexed path.
99
+ def test_durations_are_omitted_by_default_and_sent_when_given(client):
102
100
  _result, params = _call(client, after=[0, 500])
103
- assert "by" not in params
101
+ assert "durations" not in params and "by" not in params
102
+ _result, params = _call(client, after=[0, 500], durations=[5, 60, 300])
103
+ assert params["durations"] == "5,60,300"
104
+ _result, params = _call(client, durations="all")
105
+ assert params["durations"] == "all"
104
106
 
105
107
 
106
- def test_after_with_explicit_intensity_is_metric_indexed_and_warns(client):
107
- # The deprecated value-indexed orientation stays metric-indexed, is sent explicitly,
108
- # and warns (preserves the coverage the old default-intensity test used to give).
109
- with pytest.warns(DeprecationWarning):
110
- result, params = _call(client, after=[0, 500], by="intensity")
111
- assert params["by"] == "intensity"
112
- assert isinstance(result.index, pd.RangeIndex)
113
- assert {"power", "after"} <= set(result.columns)
114
- assert result["duration"].dtype == "timedelta64[ns]"
108
+ def test_by_is_removed(client):
109
+ # Server plan 063: every mean-max response is duration-oriented; `by` is gone.
110
+ with pytest.raises(TypeError):
111
+ _call(client, after=[0, 50], by="duration")
115
112
 
116
113
 
117
- def test_by_duration_is_sent_and_duration_indexed(client):
114
+ def test_after_response_is_duration_rows_with_columns_only(client):
118
115
  response = MagicMock(status_code=200, content=_index_free_by_duration_response())
119
116
  http = MagicMock()
120
117
  http.__enter__ = MagicMock(return_value=http)
@@ -123,11 +120,7 @@ def test_by_duration_is_sent_and_duration_indexed(client):
123
120
  with patch.object(client, "_http_client", return_value=http), \
124
121
  patch.object(client, "_raise_for_status"), \
125
122
  patch.object(client, "_cache_enabled", return_value=False):
126
- result = client.get_longitudinal_mean_max(
127
- sports=["cycling"], metric="power", after=[0, 50], by="duration", output="pandas")
128
- params = http.get.call_args.kwargs["params"]
129
- assert params["by"] == "duration"
130
- # duration, power and after are all columns; nothing is indexed
123
+ result = client.get_longitudinal_mean_max(sports=["cycling"], metric="power", after=[0, 50], output="pandas")
131
124
  assert isinstance(result.index, pd.RangeIndex)
132
125
  assert {"duration", "power", "after"} <= set(result.columns)
133
126
  assert set(result["after"].unique()) == {0.0, 50.0}
@@ -56,9 +56,21 @@ def test_get_longitudinal_data_sends_segmentation_on(client):
56
56
  assert params["segmentation_on"] == "power"
57
57
 
58
58
 
59
- def test_get_activity_mean_max_sends_segmentation(client):
60
- params = _params_sent(client, lambda: client.get_activity_mean_max("a", "power", segmentation=True))
61
- assert params["segmentation"] is True
59
+ def test_get_activity_mean_max_sends_durations(client):
60
+ assert "durations" not in _params_sent(client, lambda: client.get_activity_mean_max("a", "power"))
61
+ params = _params_sent(client, lambda: client.get_activity_mean_max("a", "power", durations=[300, 5]))
62
+ assert params["durations"] == "300,5"
63
+ params = _params_sent(client, lambda: client.get_activity_mean_max("a", "power", durations="all"))
64
+ assert params["durations"] == "all"
65
+
66
+
67
+ def test_mean_max_segmentation_is_removed(client):
68
+ # Server plan 063: `segmentation` never reduced the payload and is gone. A positional True
69
+ # in its old slot must fail loudly, not be read as durations.
70
+ with pytest.raises(TypeError):
71
+ client.get_activity_mean_max("a", "power", True)
72
+ with pytest.raises(TypeError):
73
+ client.get_activity_mean_max("a", "power", segmentation=True)
62
74
 
63
75
 
64
76
  def test_old_nlec_kwargs_are_rejected(client):
@@ -2647,7 +2647,7 @@ wheels = [
2647
2647
 
2648
2648
  [[package]]
2649
2649
  name = "sweatstack"
2650
- version = "0.89.0"
2650
+ version = "0.90.0"
2651
2651
  source = { editable = "." }
2652
2652
  dependencies = [
2653
2653
  { name = "email-validator" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes