ccburn 0.1.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.
- ccburn/__init__.py +8 -0
- ccburn/app.py +465 -0
- ccburn/cli.py +285 -0
- ccburn/data/__init__.py +24 -0
- ccburn/data/credentials.py +135 -0
- ccburn/data/history.py +397 -0
- ccburn/data/models.py +148 -0
- ccburn/data/usage_client.py +141 -0
- ccburn/display/__init__.py +17 -0
- ccburn/display/chart.py +300 -0
- ccburn/display/gauges.py +275 -0
- ccburn/display/layout.py +246 -0
- ccburn/main.py +98 -0
- ccburn/utils/__init__.py +45 -0
- ccburn/utils/calculator.py +207 -0
- ccburn/utils/formatting.py +127 -0
- ccburn-0.1.0.dist-info/METADATA +197 -0
- ccburn-0.1.0.dist-info/RECORD +22 -0
- ccburn-0.1.0.dist-info/WHEEL +5 -0
- ccburn-0.1.0.dist-info/entry_points.txt +2 -0
- ccburn-0.1.0.dist-info/licenses/LICENSE +21 -0
- ccburn-0.1.0.dist-info/top_level.txt +1 -0
ccburn/display/layout.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Layout manager for ccburn TUI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.layout import Layout
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from ..data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
|
|
12
|
+
from ..utils.calculator import calculate_budget_pace, calculate_burn_metrics
|
|
13
|
+
from .chart import BurnupChart
|
|
14
|
+
from .gauges import create_gauge_section, create_header
|
|
15
|
+
except ImportError:
|
|
16
|
+
from ccburn.data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
|
|
17
|
+
from ccburn.display.chart import BurnupChart
|
|
18
|
+
from ccburn.display.gauges import create_gauge_section, create_header
|
|
19
|
+
from ccburn.utils.calculator import calculate_budget_pace, calculate_burn_metrics
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BurnupLayout:
|
|
23
|
+
"""Layout manager for the ccburn TUI."""
|
|
24
|
+
|
|
25
|
+
MIN_WIDTH = 40
|
|
26
|
+
MIN_HEIGHT = 10
|
|
27
|
+
COMPACT_WIDTH = 60
|
|
28
|
+
COMPACT_HEIGHT = 15
|
|
29
|
+
|
|
30
|
+
def __init__(self, console: Console | None = None):
|
|
31
|
+
"""Initialize the layout manager.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
console: Rich Console instance (creates one if not provided)
|
|
35
|
+
"""
|
|
36
|
+
self.console = console or Console()
|
|
37
|
+
self._last_limit_data: LimitData | None = None
|
|
38
|
+
self._last_snapshots: list[UsageSnapshot] = []
|
|
39
|
+
self._last_metrics: BurnMetrics | None = None
|
|
40
|
+
self._error_message: str | None = None
|
|
41
|
+
self._stale_data_time: datetime | None = None
|
|
42
|
+
|
|
43
|
+
def get_terminal_size(self) -> tuple[int, int]:
|
|
44
|
+
"""Get current terminal size.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
(width, height) tuple
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
size = os.get_terminal_size()
|
|
51
|
+
return size.columns, size.lines
|
|
52
|
+
except OSError:
|
|
53
|
+
return 80, 24 # Default fallback
|
|
54
|
+
|
|
55
|
+
def should_use_compact_mode(self) -> bool:
|
|
56
|
+
"""Determine if compact mode should be used.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True if terminal is too small for full layout
|
|
60
|
+
"""
|
|
61
|
+
width, height = self.get_terminal_size()
|
|
62
|
+
return width < self.COMPACT_WIDTH or height < self.COMPACT_HEIGHT
|
|
63
|
+
|
|
64
|
+
def update(
|
|
65
|
+
self,
|
|
66
|
+
limit_type: LimitType,
|
|
67
|
+
limit_data: LimitData | None,
|
|
68
|
+
snapshots: list[UsageSnapshot],
|
|
69
|
+
error: str | None = None,
|
|
70
|
+
stale_since: datetime | None = None,
|
|
71
|
+
since: datetime | None = None,
|
|
72
|
+
) -> Layout:
|
|
73
|
+
"""Update the layout with new data.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
limit_type: Which limit to display
|
|
77
|
+
limit_data: Current limit data
|
|
78
|
+
snapshots: Historical snapshots for chart
|
|
79
|
+
error: Error message to display (if any)
|
|
80
|
+
stale_since: When data became stale (if using cached data)
|
|
81
|
+
since: Zoom view start time
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Updated Rich Layout
|
|
85
|
+
"""
|
|
86
|
+
self._last_limit_data = limit_data
|
|
87
|
+
self._last_snapshots = snapshots
|
|
88
|
+
self._error_message = error
|
|
89
|
+
self._stale_data_time = stale_since
|
|
90
|
+
|
|
91
|
+
# Calculate metrics
|
|
92
|
+
if limit_data:
|
|
93
|
+
self._last_metrics = calculate_burn_metrics(limit_data, snapshots)
|
|
94
|
+
|
|
95
|
+
width, height = self.get_terminal_size()
|
|
96
|
+
|
|
97
|
+
if self.should_use_compact_mode():
|
|
98
|
+
return self._create_compact_layout(limit_type, width, height)
|
|
99
|
+
|
|
100
|
+
return self._create_full_layout(limit_type, width, height, since)
|
|
101
|
+
|
|
102
|
+
def _create_full_layout(
|
|
103
|
+
self,
|
|
104
|
+
limit_type: LimitType,
|
|
105
|
+
width: int,
|
|
106
|
+
height: int,
|
|
107
|
+
since: datetime | None = None,
|
|
108
|
+
) -> Layout:
|
|
109
|
+
"""Create full TUI layout with header, gauges, and chart.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
limit_type: Which limit to display
|
|
113
|
+
width: Terminal width
|
|
114
|
+
height: Terminal height
|
|
115
|
+
since: Zoom view start time
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Rich Layout
|
|
119
|
+
"""
|
|
120
|
+
root = Layout()
|
|
121
|
+
|
|
122
|
+
# Calculate sizes - minimize header/gauges, maximize chart
|
|
123
|
+
header_height = 1
|
|
124
|
+
gauges_height = 2 # Two progress bars
|
|
125
|
+
error_height = 1 if self._error_message or self._stale_data_time else 0
|
|
126
|
+
# Use full remaining height for chart (no extra padding with screen=True)
|
|
127
|
+
chart_height = height - header_height - gauges_height - error_height
|
|
128
|
+
|
|
129
|
+
# Create sections
|
|
130
|
+
header_layout = Layout(size=header_height, name="header")
|
|
131
|
+
gauges_layout = Layout(size=gauges_height, name="gauges")
|
|
132
|
+
chart_layout = Layout(name="chart")
|
|
133
|
+
|
|
134
|
+
# Add error banner if needed
|
|
135
|
+
if self._error_message or self._stale_data_time:
|
|
136
|
+
error_layout = Layout(size=error_height, name="error")
|
|
137
|
+
root.split_column(header_layout, gauges_layout, error_layout, chart_layout)
|
|
138
|
+
error_layout.update(self._create_error_banner())
|
|
139
|
+
else:
|
|
140
|
+
root.split_column(header_layout, gauges_layout, chart_layout)
|
|
141
|
+
|
|
142
|
+
# Update header
|
|
143
|
+
header_layout.update(create_header(limit_type, self._last_limit_data))
|
|
144
|
+
|
|
145
|
+
# Update gauges
|
|
146
|
+
budget_pace = 0.0
|
|
147
|
+
if self._last_limit_data:
|
|
148
|
+
budget_pace = calculate_budget_pace(
|
|
149
|
+
self._last_limit_data.resets_at,
|
|
150
|
+
self._last_limit_data.window_hours,
|
|
151
|
+
)
|
|
152
|
+
gauges_layout.update(create_gauge_section(self._last_limit_data, budget_pace))
|
|
153
|
+
|
|
154
|
+
# Update chart
|
|
155
|
+
chart = BurnupChart(
|
|
156
|
+
self._last_limit_data,
|
|
157
|
+
self._last_snapshots,
|
|
158
|
+
since=since,
|
|
159
|
+
explicit_height=chart_height,
|
|
160
|
+
)
|
|
161
|
+
chart_layout.update(chart)
|
|
162
|
+
|
|
163
|
+
return root
|
|
164
|
+
|
|
165
|
+
def _create_compact_layout(
|
|
166
|
+
self,
|
|
167
|
+
limit_type: LimitType,
|
|
168
|
+
width: int,
|
|
169
|
+
height: int,
|
|
170
|
+
) -> Layout:
|
|
171
|
+
"""Create compact layout for small terminals.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
limit_type: Which limit to display
|
|
175
|
+
width: Terminal width
|
|
176
|
+
height: Terminal height
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Rich Layout with minimal display
|
|
180
|
+
"""
|
|
181
|
+
root = Layout()
|
|
182
|
+
|
|
183
|
+
# Just header and gauges in compact mode
|
|
184
|
+
header_layout = Layout(size=1, name="header")
|
|
185
|
+
gauges_layout = Layout(size=3, name="gauges")
|
|
186
|
+
info_layout = Layout(name="info")
|
|
187
|
+
|
|
188
|
+
root.split_column(header_layout, gauges_layout, info_layout)
|
|
189
|
+
|
|
190
|
+
# Update header
|
|
191
|
+
header_layout.update(create_header(limit_type, self._last_limit_data))
|
|
192
|
+
|
|
193
|
+
# Update gauges
|
|
194
|
+
budget_pace = 0.0
|
|
195
|
+
if self._last_limit_data:
|
|
196
|
+
budget_pace = calculate_budget_pace(
|
|
197
|
+
self._last_limit_data.resets_at,
|
|
198
|
+
self._last_limit_data.window_hours,
|
|
199
|
+
)
|
|
200
|
+
gauges_layout.update(create_gauge_section(self._last_limit_data, budget_pace))
|
|
201
|
+
|
|
202
|
+
# Show info text instead of chart
|
|
203
|
+
info_text = Text("Terminal too small for chart. Expand window or use --compact.", style="dim")
|
|
204
|
+
if self._error_message:
|
|
205
|
+
info_text = Text(self._error_message, style="yellow")
|
|
206
|
+
info_layout.update(info_text)
|
|
207
|
+
|
|
208
|
+
return root
|
|
209
|
+
|
|
210
|
+
def _create_error_banner(self) -> Text:
|
|
211
|
+
"""Create error/warning banner.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Rich Text with error/warning message
|
|
215
|
+
"""
|
|
216
|
+
if self._error_message:
|
|
217
|
+
return Text(f"Warning: {self._error_message}", style="yellow")
|
|
218
|
+
|
|
219
|
+
if self._stale_data_time:
|
|
220
|
+
from ..utils.formatting import format_duration
|
|
221
|
+
|
|
222
|
+
now = datetime.now(timezone.utc)
|
|
223
|
+
minutes_stale = int((now - self._stale_data_time).total_seconds() / 60)
|
|
224
|
+
return Text(
|
|
225
|
+
f"Using cached data (last updated {format_duration(minutes_stale)} ago)",
|
|
226
|
+
style="yellow",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return Text("")
|
|
230
|
+
|
|
231
|
+
def render(self) -> Layout:
|
|
232
|
+
"""Get the current layout without updating data.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Current Rich Layout
|
|
236
|
+
"""
|
|
237
|
+
limit_type = LimitType.SESSION
|
|
238
|
+
if self._last_limit_data:
|
|
239
|
+
limit_type = self._last_limit_data.limit_type
|
|
240
|
+
|
|
241
|
+
width, height = self.get_terminal_size()
|
|
242
|
+
|
|
243
|
+
if self.should_use_compact_mode():
|
|
244
|
+
return self._create_compact_layout(limit_type, width, height)
|
|
245
|
+
|
|
246
|
+
return self._create_full_layout(limit_type, width, height)
|
ccburn/main.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""ccburn - Terminal-based Claude Code usage limit visualizer."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from .cli import (
|
|
8
|
+
CompactOption,
|
|
9
|
+
DebugOption,
|
|
10
|
+
JsonOption,
|
|
11
|
+
OnceOption,
|
|
12
|
+
SessionIntervalOption,
|
|
13
|
+
SinceOption,
|
|
14
|
+
register_commands,
|
|
15
|
+
run_app,
|
|
16
|
+
)
|
|
17
|
+
from .data.models import LimitType
|
|
18
|
+
except ImportError:
|
|
19
|
+
from ccburn.cli import (
|
|
20
|
+
CompactOption,
|
|
21
|
+
DebugOption,
|
|
22
|
+
JsonOption,
|
|
23
|
+
OnceOption,
|
|
24
|
+
SessionIntervalOption,
|
|
25
|
+
SinceOption,
|
|
26
|
+
register_commands,
|
|
27
|
+
run_app,
|
|
28
|
+
)
|
|
29
|
+
from ccburn.data.models import LimitType
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="ccburn",
|
|
34
|
+
help="Visualize Claude Code usage limits with real-time burn-up charts.",
|
|
35
|
+
rich_markup_mode="rich",
|
|
36
|
+
add_completion=True,
|
|
37
|
+
no_args_is_help=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Register subcommands
|
|
41
|
+
register_commands(app)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.callback(invoke_without_command=True)
|
|
45
|
+
def main(
|
|
46
|
+
ctx: typer.Context,
|
|
47
|
+
version: bool = typer.Option(
|
|
48
|
+
False,
|
|
49
|
+
"--version",
|
|
50
|
+
"-v",
|
|
51
|
+
help="Show version and exit.",
|
|
52
|
+
),
|
|
53
|
+
json_output: bool = JsonOption,
|
|
54
|
+
once: bool = OnceOption,
|
|
55
|
+
compact: bool = CompactOption,
|
|
56
|
+
since: str | None = SinceOption,
|
|
57
|
+
interval: int = SessionIntervalOption,
|
|
58
|
+
debug: bool = DebugOption,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""ccburn - Claude Code usage limit visualizer.
|
|
61
|
+
|
|
62
|
+
Visualize your Claude Code usage limits with real-time burn-up charts.
|
|
63
|
+
Shows 5-hour rolling session limit by default.
|
|
64
|
+
|
|
65
|
+
\b
|
|
66
|
+
Examples:
|
|
67
|
+
ccburn # Live TUI showing session limit
|
|
68
|
+
ccburn session # Same as above (explicit)
|
|
69
|
+
ccburn weekly # Show 7-day weekly limit
|
|
70
|
+
ccburn weekly-sonnet # Show 7-day Sonnet limit
|
|
71
|
+
ccburn --json # JSON output
|
|
72
|
+
ccburn --compact # Single-line for status bars
|
|
73
|
+
ccburn --once # Print once and exit
|
|
74
|
+
"""
|
|
75
|
+
if version:
|
|
76
|
+
try:
|
|
77
|
+
from importlib.metadata import version as get_version
|
|
78
|
+
|
|
79
|
+
typer.echo(f"ccburn {get_version('ccburn')}")
|
|
80
|
+
except Exception:
|
|
81
|
+
typer.echo("ccburn 1.0.0")
|
|
82
|
+
raise typer.Exit()
|
|
83
|
+
|
|
84
|
+
# If no subcommand, default to session
|
|
85
|
+
if ctx.invoked_subcommand is None:
|
|
86
|
+
run_app(
|
|
87
|
+
LimitType.SESSION,
|
|
88
|
+
json_output=json_output,
|
|
89
|
+
once=once,
|
|
90
|
+
compact=compact,
|
|
91
|
+
since=since,
|
|
92
|
+
interval=interval,
|
|
93
|
+
debug=debug,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
app()
|
ccburn/utils/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Utilities for ccburn - calculators and formatters."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from .calculator import (
|
|
5
|
+
calculate_budget_pace,
|
|
6
|
+
calculate_burn_metrics,
|
|
7
|
+
calculate_burn_rate,
|
|
8
|
+
classify_burn_trend,
|
|
9
|
+
estimate_time_to_empty,
|
|
10
|
+
get_recommendation,
|
|
11
|
+
)
|
|
12
|
+
from .formatting import (
|
|
13
|
+
format_duration,
|
|
14
|
+
format_percentage,
|
|
15
|
+
format_reset_time,
|
|
16
|
+
get_utilization_color,
|
|
17
|
+
)
|
|
18
|
+
except ImportError:
|
|
19
|
+
from ccburn.utils.calculator import (
|
|
20
|
+
calculate_budget_pace,
|
|
21
|
+
calculate_burn_metrics,
|
|
22
|
+
calculate_burn_rate,
|
|
23
|
+
classify_burn_trend,
|
|
24
|
+
estimate_time_to_empty,
|
|
25
|
+
get_recommendation,
|
|
26
|
+
)
|
|
27
|
+
from ccburn.utils.formatting import (
|
|
28
|
+
format_duration,
|
|
29
|
+
format_percentage,
|
|
30
|
+
format_reset_time,
|
|
31
|
+
get_utilization_color,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"format_duration",
|
|
36
|
+
"format_percentage",
|
|
37
|
+
"format_reset_time",
|
|
38
|
+
"get_utilization_color",
|
|
39
|
+
"calculate_budget_pace",
|
|
40
|
+
"calculate_burn_rate",
|
|
41
|
+
"estimate_time_to_empty",
|
|
42
|
+
"classify_burn_trend",
|
|
43
|
+
"get_recommendation",
|
|
44
|
+
"calculate_burn_metrics",
|
|
45
|
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Calculator utilities for burn rate, budget pace, and predictions."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from ..data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
|
|
7
|
+
except ImportError:
|
|
8
|
+
from ccburn.data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def calculate_budget_pace(resets_at: datetime, window_hours: float) -> float:
|
|
12
|
+
"""Calculate what percentage of the window has elapsed.
|
|
13
|
+
|
|
14
|
+
Formula: (now - window_start) / window_duration
|
|
15
|
+
Where: window_start = resets_at - window_hours
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
resets_at: When the limit resets
|
|
19
|
+
window_hours: Duration of the window in hours
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Float between 0.0 and 1.0 representing elapsed fraction
|
|
23
|
+
"""
|
|
24
|
+
now = datetime.now(timezone.utc)
|
|
25
|
+
|
|
26
|
+
# Ensure resets_at is timezone-aware
|
|
27
|
+
if resets_at.tzinfo is None:
|
|
28
|
+
resets_at = resets_at.replace(tzinfo=timezone.utc)
|
|
29
|
+
|
|
30
|
+
window_start = resets_at - timedelta(hours=window_hours)
|
|
31
|
+
elapsed = (now - window_start).total_seconds()
|
|
32
|
+
window_seconds = window_hours * 3600
|
|
33
|
+
|
|
34
|
+
if window_seconds <= 0:
|
|
35
|
+
return 0.0
|
|
36
|
+
|
|
37
|
+
pace = elapsed / window_seconds
|
|
38
|
+
return max(0.0, min(1.0, pace)) # Clamp 0-1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def calculate_burn_rate(
|
|
42
|
+
snapshots: list[UsageSnapshot],
|
|
43
|
+
limit_type: LimitType,
|
|
44
|
+
window_minutes: int = 5,
|
|
45
|
+
) -> float:
|
|
46
|
+
"""Calculate burn rate as percentage points per hour.
|
|
47
|
+
|
|
48
|
+
Uses simple linear calculation over recent snapshots.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
snapshots: List of usage snapshots (should be sorted by timestamp)
|
|
52
|
+
limit_type: Which limit to calculate burn rate for
|
|
53
|
+
window_minutes: How far back to look for calculation
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Burn rate in percentage points per hour (e.g., 12.5 means 12.5%/hour)
|
|
57
|
+
"""
|
|
58
|
+
if len(snapshots) < 2:
|
|
59
|
+
return 0.0
|
|
60
|
+
|
|
61
|
+
now = datetime.now(timezone.utc)
|
|
62
|
+
cutoff = now - timedelta(minutes=window_minutes)
|
|
63
|
+
|
|
64
|
+
# Filter to recent snapshots
|
|
65
|
+
recent = [s for s in snapshots if s.timestamp >= cutoff]
|
|
66
|
+
|
|
67
|
+
if len(recent) < 2:
|
|
68
|
+
# Fall back to any 2 snapshots if not enough recent ones
|
|
69
|
+
recent = snapshots[-2:] if len(snapshots) >= 2 else []
|
|
70
|
+
|
|
71
|
+
if len(recent) < 2:
|
|
72
|
+
return 0.0
|
|
73
|
+
|
|
74
|
+
# Get utilization for the specified limit type
|
|
75
|
+
first = recent[0]
|
|
76
|
+
last = recent[-1]
|
|
77
|
+
|
|
78
|
+
first_limit = first.get_limit(limit_type)
|
|
79
|
+
last_limit = last.get_limit(limit_type)
|
|
80
|
+
|
|
81
|
+
if first_limit is None or last_limit is None:
|
|
82
|
+
return 0.0
|
|
83
|
+
|
|
84
|
+
delta_util = (last_limit.utilization - first_limit.utilization) * 100 # Convert to %
|
|
85
|
+
delta_hours = (last.timestamp - first.timestamp).total_seconds() / 3600
|
|
86
|
+
|
|
87
|
+
if delta_hours <= 0:
|
|
88
|
+
return 0.0
|
|
89
|
+
|
|
90
|
+
return delta_util / delta_hours # %/hour
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def estimate_time_to_empty(current_utilization: float, burn_rate_per_hour: float) -> int | None:
|
|
94
|
+
"""Estimate minutes until 100% utilization at current burn rate.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
current_utilization: Current utilization (0-1)
|
|
98
|
+
burn_rate_per_hour: Burn rate in percentage points per hour
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Minutes until 100%, or None if burn rate is zero or negative
|
|
102
|
+
"""
|
|
103
|
+
if burn_rate_per_hour <= 0:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
remaining = 100.0 - (current_utilization * 100)
|
|
107
|
+
if remaining <= 0:
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
hours_to_empty = remaining / burn_rate_per_hour
|
|
111
|
+
return int(hours_to_empty * 60)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def classify_burn_trend(burn_rate_per_hour: float) -> str:
|
|
115
|
+
"""Classify burn rate into human-readable trend.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
burn_rate_per_hour: Burn rate in percentage points per hour
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
One of: "low", "moderate", "high", "critical"
|
|
122
|
+
"""
|
|
123
|
+
if burn_rate_per_hour < 5:
|
|
124
|
+
return "low"
|
|
125
|
+
elif burn_rate_per_hour < 15:
|
|
126
|
+
return "moderate"
|
|
127
|
+
elif burn_rate_per_hour < 30:
|
|
128
|
+
return "high"
|
|
129
|
+
else:
|
|
130
|
+
return "critical"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_recommendation(utilization: float, budget_pace: float) -> str:
|
|
134
|
+
"""Get recommendation based on utilization and budget pace.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
utilization: Current utilization (0-1)
|
|
138
|
+
budget_pace: How much of window has elapsed (0-1)
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
One of: "plenty_available", "on_track", "moderate_pace", "conserve", "critical"
|
|
142
|
+
"""
|
|
143
|
+
if utilization > 0.9:
|
|
144
|
+
return "critical"
|
|
145
|
+
elif utilization > 0.75:
|
|
146
|
+
return "conserve"
|
|
147
|
+
elif utilization > 0.5:
|
|
148
|
+
if utilization <= budget_pace:
|
|
149
|
+
return "on_track"
|
|
150
|
+
return "moderate_pace"
|
|
151
|
+
else:
|
|
152
|
+
return "plenty_available"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_status(utilization: float, budget_pace: float) -> str:
|
|
156
|
+
"""Get pace status.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
utilization: Current utilization (0-1)
|
|
160
|
+
budget_pace: How much of window has elapsed (0-1)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
One of: "ahead_of_pace", "on_pace", "behind_pace"
|
|
164
|
+
"""
|
|
165
|
+
# "ahead" means using more than expected (bad)
|
|
166
|
+
# "behind" means using less than expected (good)
|
|
167
|
+
diff = utilization - budget_pace
|
|
168
|
+
|
|
169
|
+
if abs(diff) < 0.05: # Within 5% tolerance
|
|
170
|
+
return "on_pace"
|
|
171
|
+
elif diff > 0:
|
|
172
|
+
return "ahead_of_pace"
|
|
173
|
+
else:
|
|
174
|
+
return "behind_pace"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def calculate_burn_metrics(
|
|
178
|
+
limit_data: LimitData,
|
|
179
|
+
snapshots: list[UsageSnapshot],
|
|
180
|
+
window_minutes: int = 5,
|
|
181
|
+
) -> BurnMetrics:
|
|
182
|
+
"""Calculate all burn metrics for a limit.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
limit_data: Current limit data
|
|
186
|
+
snapshots: Historical snapshots for burn rate calculation
|
|
187
|
+
window_minutes: How far back to look for burn rate
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
BurnMetrics with all calculated values
|
|
191
|
+
"""
|
|
192
|
+
budget_pace = calculate_budget_pace(limit_data.resets_at, limit_data.window_hours)
|
|
193
|
+
burn_rate = calculate_burn_rate(snapshots, limit_data.limit_type, window_minutes)
|
|
194
|
+
time_to_empty = estimate_time_to_empty(limit_data.utilization, burn_rate)
|
|
195
|
+
trend = classify_burn_trend(burn_rate)
|
|
196
|
+
status = get_status(limit_data.utilization, budget_pace)
|
|
197
|
+
recommendation = get_recommendation(limit_data.utilization, budget_pace)
|
|
198
|
+
|
|
199
|
+
return BurnMetrics(
|
|
200
|
+
limit_type=limit_data.limit_type,
|
|
201
|
+
percent_per_hour=burn_rate,
|
|
202
|
+
trend=trend,
|
|
203
|
+
estimated_minutes_to_100=time_to_empty,
|
|
204
|
+
budget_pace=budget_pace,
|
|
205
|
+
status=status,
|
|
206
|
+
recommendation=recommendation,
|
|
207
|
+
)
|