cronphase 0.2.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cronphase contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: cronphase
3
+ Version: 0.2.1
4
+ Summary: Fleet-wide cron thundering-herd analyzer & deterministic phase-shift compiler
5
+ Author: cronphase contributors
6
+ License: MIT
7
+ Keywords: cron,crontab,kubernetes,devops,sre,scheduler,thundering-herd
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: System :: Monitoring
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # `cronphase` — Fleet-Wide Cron Thundering-Herd Oscilloscope & Phase-Shift Compiler
22
+
23
+ > **Why does your database CPU spike to 100% at `00:00`, `:00`, and `:15` every day?**
24
+ > Because humans write `0 * * * *`, `*/5 * * * *`, `*/15 * * * *`, and `0 0 * * *`. Across 20+ microservices, Kubernetes `CronJob` manifests, and GitHub Actions workflows, those schedules constructively interfere like acoustic waves—creating **The Midnight Stampede**.
25
+
26
+ Existing tools (like `crontab.guru`) only translate a **single** cron line into English.
27
+ **`cronphase`** analyzes your **entire fleet** as a 1,440-minute diurnal signal, pinpoints multi-job resource collisions (accounting for multi-minute execution durations and midnight wrap-around), and runs a **constrained minimax phase-shift solver** that rewrites cron expressions with deterministic offsets (`*/15 * * * *` → `7-59/15 * * * *`, `0 * * * *` → `23 * * * *`, `0 */4 * * *` → `19 1-23/4 * * *`) while strictly preserving every job's exact frequency, SLA window bounds, and pinned locks.
28
+
29
+ ## Key Capabilities
30
+
31
+ 1. **Multi-Format Fleet Ingestion**: Parses standard UNIX `/etc/crontab`, Kubernetes `batch/v1 CronJob` YAML manifests, and GitHub Actions `.github/workflows/*.yml` schedule blocks.
32
+ 2. **1,440-Minute Load Envelope & Crest Factor Simulator**: Models job execution duration (`@duration=8m`), resource weight (`@weight=7.5`), and target subsystem contention (`@resource=db-primary`), including jobs that start at `23:55` and spill across midnight into `00:05`.
33
+ 3. **Harmonic Resonance Spectrum**: Quantifies `:00`, `:15`, `:30`, `:45`, and `5m-grid` harmonic clustering across your fleet.
34
+ 4. **Constrained Minimax Phase-Shift Compiler**:
35
+ - **Coordinate Descent / Minimax (`--strategy anneal`)**: Minimizes $L_\infty$ peak resource load and $L_2$ load variance across the 1,440-minute ring.
36
+ - **Deterministic FNV-1a Hash Jitter (`--strategy fnv-hash`)**: Zero-coordination stateless phase assignment (compiles Jenkins `H/15 * * * *` semantics to standard POSIX/Vixie cron syntax).
37
+ - **Prime-Modulo Splay (`--strategy prime-splay`)**: Coprime stride distribution across periodic lattices.
38
+ 5. **Ready-to-Apply Unified Git Diff & CI Gate**: Generates a `git apply`-ready patch and supports `--max-crest-factor` for CI/CD pull request checks.
39
+
40
+ ## Quick Start (CLI)
41
+
42
+ ```bash
43
+ # 1. Analyze a production crontab and print the 24h waveform + collision spikes
44
+ python3 -m cronphase examples/prod.crontab
45
+
46
+ # 2. Run the constrained phase-shift compiler and emit a unified git diff patch
47
+ python3 -m cronphase examples/prod.crontab --optimize --diff
48
+
49
+ # 3. Gate CI pipelines if peak-to-mean crest factor exceeds 2.5x
50
+ python3 -m cronphase examples/k8s-cronjobs.yaml --optimize --max-crest-factor 2.5
51
+ ```
@@ -0,0 +1,31 @@
1
+ # `cronphase` — Fleet-Wide Cron Thundering-Herd Oscilloscope & Phase-Shift Compiler
2
+
3
+ > **Why does your database CPU spike to 100% at `00:00`, `:00`, and `:15` every day?**
4
+ > Because humans write `0 * * * *`, `*/5 * * * *`, `*/15 * * * *`, and `0 0 * * *`. Across 20+ microservices, Kubernetes `CronJob` manifests, and GitHub Actions workflows, those schedules constructively interfere like acoustic waves—creating **The Midnight Stampede**.
5
+
6
+ Existing tools (like `crontab.guru`) only translate a **single** cron line into English.
7
+ **`cronphase`** analyzes your **entire fleet** as a 1,440-minute diurnal signal, pinpoints multi-job resource collisions (accounting for multi-minute execution durations and midnight wrap-around), and runs a **constrained minimax phase-shift solver** that rewrites cron expressions with deterministic offsets (`*/15 * * * *` → `7-59/15 * * * *`, `0 * * * *` → `23 * * * *`, `0 */4 * * *` → `19 1-23/4 * * *`) while strictly preserving every job's exact frequency, SLA window bounds, and pinned locks.
8
+
9
+ ## Key Capabilities
10
+
11
+ 1. **Multi-Format Fleet Ingestion**: Parses standard UNIX `/etc/crontab`, Kubernetes `batch/v1 CronJob` YAML manifests, and GitHub Actions `.github/workflows/*.yml` schedule blocks.
12
+ 2. **1,440-Minute Load Envelope & Crest Factor Simulator**: Models job execution duration (`@duration=8m`), resource weight (`@weight=7.5`), and target subsystem contention (`@resource=db-primary`), including jobs that start at `23:55` and spill across midnight into `00:05`.
13
+ 3. **Harmonic Resonance Spectrum**: Quantifies `:00`, `:15`, `:30`, `:45`, and `5m-grid` harmonic clustering across your fleet.
14
+ 4. **Constrained Minimax Phase-Shift Compiler**:
15
+ - **Coordinate Descent / Minimax (`--strategy anneal`)**: Minimizes $L_\infty$ peak resource load and $L_2$ load variance across the 1,440-minute ring.
16
+ - **Deterministic FNV-1a Hash Jitter (`--strategy fnv-hash`)**: Zero-coordination stateless phase assignment (compiles Jenkins `H/15 * * * *` semantics to standard POSIX/Vixie cron syntax).
17
+ - **Prime-Modulo Splay (`--strategy prime-splay`)**: Coprime stride distribution across periodic lattices.
18
+ 5. **Ready-to-Apply Unified Git Diff & CI Gate**: Generates a `git apply`-ready patch and supports `--max-crest-factor` for CI/CD pull request checks.
19
+
20
+ ## Quick Start (CLI)
21
+
22
+ ```bash
23
+ # 1. Analyze a production crontab and print the 24h waveform + collision spikes
24
+ python3 -m cronphase examples/prod.crontab
25
+
26
+ # 2. Run the constrained phase-shift compiler and emit a unified git diff patch
27
+ python3 -m cronphase examples/prod.crontab --optimize --diff
28
+
29
+ # 3. Gate CI pipelines if peak-to-mean crest factor exceeds 2.5x
30
+ python3 -m cronphase examples/k8s-cronjobs.yaml --optimize --max-crest-factor 2.5
31
+ ```
@@ -0,0 +1,21 @@
1
+ """
2
+ cronphase — Fleet-wide cron thundering-herd detector & deterministic phase-shift solver.
3
+ """
4
+
5
+ from .parser import CronExpression, ScheduledJob, parse_manifest
6
+ from .engine import SimulationResult, simulate_day
7
+ from .solver import SolverConfig, optimize_fleet
8
+ from .diff import generate_unified_diff, apply_rewrites
9
+
10
+ __version__ = "0.2.1"
11
+ __all__ = [
12
+ "CronExpression",
13
+ "ScheduledJob",
14
+ "parse_manifest",
15
+ "SimulationResult",
16
+ "simulate_day",
17
+ "SolverConfig",
18
+ "optimize_fleet",
19
+ "generate_unified_diff",
20
+ "apply_rewrites",
21
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ """
2
+ Command-Line Interface for `cronphase`.
3
+ Usage:
4
+ python3 -m cronphase examples/prod.crontab --optimize --diff
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from typing import List, Optional
12
+ from .parser import parse_manifest
13
+ from .engine import format_hhmm, simulate_day
14
+ from .solver import SolverConfig, optimize_fleet
15
+ from .diff import apply_rewrites, generate_unified_diff
16
+
17
+
18
+ def _ascii_sparkline(values: List[float], width: int = 48) -> str:
19
+ blocks = " ▂▃▄▅▆▇█"
20
+ if not values:
21
+ return ""
22
+ step = max(1, len(values) // width)
23
+ bucketed = [max(values[i : i + step]) for i in range(0, len(values), step)][:width]
24
+ hi = max(bucketed) if bucketed else 1.0
25
+ if hi <= 0:
26
+ return blocks[0] * len(bucketed)
27
+ return "".join(blocks[min(len(blocks) - 1, int((v / hi) * (len(blocks) - 1)))] for v in bucketed)
28
+
29
+
30
+ def main(argv: Optional[List[str]] = None) -> int:
31
+ parser = argparse.ArgumentParser(
32
+ prog="cronphase",
33
+ description="Fleet-wide cron thundering-herd analyzer & deterministic phase-shift compiler.",
34
+ )
35
+ parser.add_argument("manifest", help="Path to crontab, Kubernetes CronJob YAML, or GitHub Actions YAML ('-' for stdin)")
36
+ parser.add_argument("--optimize", "-o", action="store_true", help="Run constrained phase-shift solver")
37
+ parser.add_argument(
38
+ "--strategy",
39
+ choices=["anneal", "prime-splay", "fnv-hash"],
40
+ default="anneal",
41
+ help="Phase-shift solver algorithm (default: anneal)",
42
+ )
43
+ parser.add_argument("--diff", "-d", action="store_true", help="Output unified git diff patch")
44
+ parser.add_argument("--json", action="store_true", help="Output machine-readable JSON report")
45
+ parser.add_argument(
46
+ "--max-crest-factor",
47
+ type=float,
48
+ default=None,
49
+ help="Exit with code 2 if Crest Factor (Peak/Mean load) exceeds threshold (for CI linting)",
50
+ )
51
+
52
+ args = parser.parse_args(argv)
53
+ if args.manifest == "-":
54
+ raw = sys.stdin.read()
55
+ fname = "stdin.crontab"
56
+ else:
57
+ with open(args.manifest, "r", encoding="utf-8") as f:
58
+ raw = f.read()
59
+ fname = args.manifest.rsplit("/", 1)[-1]
60
+
61
+ jobs = parse_manifest(raw)
62
+ if not jobs:
63
+ print("cronphase: no valid cron schedules found in input.", file=sys.stderr)
64
+ return 1
65
+
66
+ cfg = SolverConfig(strategy=args.strategy)
67
+ if args.optimize or args.diff:
68
+ opt_jobs, rewrites, before_sim, after_sim = optimize_fleet(jobs, cfg)
69
+ else:
70
+ before_sim = simulate_day(jobs)
71
+ opt_jobs, rewrites, after_sim = jobs, [], before_sim
72
+
73
+ if args.json:
74
+ payload = {
75
+ "job_count": len(jobs),
76
+ "before": {
77
+ "peak_concurrency": before_sim.peak_concurrency,
78
+ "peak_load": before_sim.peak_load,
79
+ "peak_time": format_hhmm(before_sim.peak_minute),
80
+ "mean_load": before_sim.mean_load,
81
+ "crest_factor": before_sim.crest_factor,
82
+ "collision_minutes": before_sim.collision_minutes_count,
83
+ "harmonics": before_sim.harmonic_buckets,
84
+ },
85
+ "after": {
86
+ "peak_concurrency": after_sim.peak_concurrency,
87
+ "peak_load": after_sim.peak_load,
88
+ "peak_time": format_hhmm(after_sim.peak_minute),
89
+ "mean_load": after_sim.mean_load,
90
+ "crest_factor": after_sim.crest_factor,
91
+ "collision_minutes": after_sim.collision_minutes_count,
92
+ "harmonics": after_sim.harmonic_buckets,
93
+ },
94
+ "rewrites": [
95
+ {
96
+ "job_id": r.job_id,
97
+ "job_name": r.job_name,
98
+ "old_expr": r.old_expr,
99
+ "new_expr": r.new_expr,
100
+ "phase_shift_minutes": r.phase_shift_minutes,
101
+ }
102
+ for r in rewrites
103
+ ],
104
+ }
105
+ print(json.dumps(payload, indent=2))
106
+ else:
107
+ print(f"CRONPHASE // FLEET HARMONIC & COLLISION REPORT ({len(jobs)} jobs parsed)")
108
+ print("=" * 74)
109
+ b_loads = [s.load for s in before_sim.timeline]
110
+ print(
111
+ f"BASELINE | Peak Load: {before_sim.peak_load:5.1f} ({before_sim.peak_concurrency} jobs @ {format_hhmm(before_sim.peak_minute)}) "
112
+ f"| Crest: {before_sim.crest_factor:4.2f}x | Collisions: {before_sim.collision_minutes_count}m"
113
+ )
114
+ print(f"WAVEFORM | [{_ascii_sparkline(b_loads, 48)}] (00:00..23:59)")
115
+ if args.optimize or args.diff:
116
+ a_loads = [s.load for s in after_sim.timeline]
117
+ drop_pct = ((before_sim.peak_load - after_sim.peak_load) / before_sim.peak_load * 100.0) if before_sim.peak_load else 0.0
118
+ print(
119
+ f"OPTIMIZED | Peak Load: {after_sim.peak_load:5.1f} ({after_sim.peak_concurrency} jobs @ {format_hhmm(after_sim.peak_minute)}) "
120
+ f"| Crest: {after_sim.crest_factor:4.2f}x | Peak Drop: -{drop_pct:.1f}%"
121
+ )
122
+ print(f"DE-PHASED | [{_ascii_sparkline(a_loads, 48)}] ({len(rewrites)} schedules shifted)")
123
+
124
+ if args.diff and rewrites:
125
+ print("\n" + "-" * 74)
126
+ rewritten_text = apply_rewrites(raw, jobs, rewrites)
127
+ print(generate_unified_diff(raw, rewritten_text, filename=fname).rstrip())
128
+
129
+ active_sim = after_sim if (args.optimize or args.diff) else before_sim
130
+ if args.max_crest_factor is not None and active_sim.crest_factor > args.max_crest_factor:
131
+ return 2
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -0,0 +1,51 @@
1
+ """
2
+ Unified Diff & Rewritten Manifest Generator for Crontab, Kubernetes YAML, and GitHub Actions.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import difflib
7
+ from typing import Dict, List
8
+ from .parser import ScheduledJob
9
+ from .solver import JobRewrite
10
+
11
+
12
+ def apply_rewrites(original_text: str, jobs: List[ScheduledJob], rewrites: List[JobRewrite]) -> str:
13
+ """Applies phase-shifted cron expressions back to the original manifest line-by-line."""
14
+ rewrite_by_id: Dict[str, JobRewrite] = {r.job_id: r for r in rewrites}
15
+ line_to_rewrite: Dict[int, JobRewrite] = {}
16
+ for j in jobs:
17
+ if j.id in rewrite_by_id and j.source_line > 0:
18
+ line_to_rewrite[j.source_line] = rewrite_by_id[j.id]
19
+
20
+ out_lines: List[str] = []
21
+ for idx, line in enumerate(original_text.splitlines(keepends=True), start=1):
22
+ rw = line_to_rewrite.get(idx)
23
+ if rw is not None:
24
+ # Replace only inside the schedule portion before any trailing comment
25
+ comment_idx = line.find(" #")
26
+ if comment_idx != -1:
27
+ head = line[:comment_idx].replace(rw.old_expr, rw.new_expr, 1)
28
+ replaced = head + line[comment_idx:]
29
+ else:
30
+ replaced = line.replace(rw.old_expr, rw.new_expr, 1)
31
+ out_lines.append(replaced)
32
+ else:
33
+ out_lines.append(line)
34
+ return "".join(out_lines)
35
+
36
+
37
+ def generate_unified_diff(
38
+ original_text: str,
39
+ rewritten_text: str,
40
+ filename: str = "crontab",
41
+ ) -> str:
42
+ orig_lines = [ln if ln.endswith("\n") else ln + "\n" for ln in original_text.splitlines()]
43
+ new_lines = [ln if ln.endswith("\n") else ln + "\n" for ln in rewritten_text.splitlines()]
44
+ diff = difflib.unified_diff(
45
+ orig_lines,
46
+ new_lines,
47
+ fromfile=f"a/{filename}",
48
+ tofile=f"b/{filename}",
49
+ lineterm="\n",
50
+ )
51
+ return "".join(diff)
@@ -0,0 +1,167 @@
1
+ """
2
+ 1,440-Minute Diurnal Concurrency, Resource Load & Harmonic Spectrum Simulator.
3
+ Accurately models multi-minute job durations, midnight wrap-around spillover,
4
+ crest factor (Peak / Mean), and pairwise job collision matrices.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import dataclasses
9
+ import math
10
+ from typing import Dict, List, Tuple
11
+ from .parser import ScheduledJob
12
+
13
+
14
+ @dataclasses.dataclass
15
+ class MinuteSlice:
16
+ minute: int # 0..1439
17
+ concurrency: int
18
+ load: float
19
+ active_job_ids: List[str]
20
+ resource_loads: Dict[str, float]
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class CollisionSpike:
25
+ minute: int
26
+ time_label: str
27
+ concurrency: int
28
+ total_load: float
29
+ dominant_resource: str
30
+ job_names: List[str]
31
+
32
+
33
+ @dataclasses.dataclass
34
+ class SimulationResult:
35
+ timeline: List[MinuteSlice]
36
+ peak_concurrency: int
37
+ peak_load: float
38
+ peak_minute: int
39
+ mean_load: float
40
+ p95_load: float
41
+ l2_energy: float
42
+ crest_factor: float
43
+ collision_minutes_count: int
44
+ harmonic_buckets: Dict[str, int]
45
+ top_spikes: List[CollisionSpike]
46
+ pairwise_overlaps: Dict[Tuple[str, str], int]
47
+
48
+
49
+ def format_hhmm(minute: int) -> str:
50
+ m = minute % 1440
51
+ return f"{m // 60:02d}:{m % 60:02d}"
52
+
53
+
54
+ def simulate_day(jobs: List[ScheduledJob], collision_threshold: int = 3) -> SimulationResult:
55
+ timeline: List[MinuteSlice] = [
56
+ MinuteSlice(minute=m, concurrency=0, load=0.0, active_job_ids=[], resource_loads={})
57
+ for m in range(1440)
58
+ ]
59
+
60
+ harmonic_buckets: Dict[str, int] = {
61
+ ":00": 0,
62
+ ":15": 0,
63
+ ":30": 0,
64
+ ":45": 0,
65
+ "5m-grid": 0,
66
+ "prime/off-grid": 0,
67
+ }
68
+
69
+ for job in jobs:
70
+ starts = job.cron.firing_minutes_in_day()
71
+ dur = max(1, job.duration_min)
72
+ for start_min in starts:
73
+ mod_m = start_min % 60
74
+ if mod_m == 0:
75
+ harmonic_buckets[":00"] += 1
76
+ elif mod_m == 15:
77
+ harmonic_buckets[":15"] += 1
78
+ elif mod_m == 30:
79
+ harmonic_buckets[":30"] += 1
80
+ elif mod_m == 45:
81
+ harmonic_buckets[":45"] += 1
82
+ elif mod_m % 5 == 0:
83
+ harmonic_buckets["5m-grid"] += 1
84
+ else:
85
+ harmonic_buckets["prime/off-grid"] += 1
86
+
87
+ for offset in range(dur):
88
+ m_idx = (start_min + offset) % 1440
89
+ sl = timeline[m_idx]
90
+ if job.id not in sl.active_job_ids:
91
+ sl.active_job_ids.append(job.id)
92
+ sl.concurrency += 1
93
+ sl.load = round(sl.load + job.weight, 4)
94
+ sl.resource_loads[job.resource] = round(
95
+ sl.resource_loads.get(job.resource, 0.0) + job.weight, 4
96
+ )
97
+
98
+ job_map = {j.id: j for j in jobs}
99
+ loads = [sl.load for sl in timeline]
100
+ concurrencies = [sl.concurrency for sl in timeline]
101
+
102
+ peak_load = max(loads) if loads else 0.0
103
+ peak_concurrency = max(concurrencies) if concurrencies else 0
104
+ peak_minute = 0
105
+ for sl in timeline:
106
+ if sl.load == peak_load:
107
+ peak_minute = sl.minute
108
+ break
109
+
110
+ mean_load = sum(loads) / 1440.0 if loads else 0.0
111
+ sorted_loads = sorted(loads)
112
+ p95_load = sorted_loads[int(1440 * 0.95)] if sorted_loads else 0.0
113
+ l2_energy = math.sqrt(sum(x * x for x in loads) / 1440.0) if loads else 0.0
114
+ crest_factor = (peak_load / mean_load) if mean_load > 1e-6 else 1.0
115
+ collision_minutes_count = sum(1 for sl in timeline if sl.concurrency >= collision_threshold)
116
+
117
+ pairwise: Dict[Tuple[str, str], int] = {}
118
+ for sl in timeline:
119
+ active = sl.active_job_ids
120
+ n = len(active)
121
+ if n > 1:
122
+ for i in range(n):
123
+ for k in range(i + 1, n):
124
+ pair = (active[i], active[k]) if active[i] < active[k] else (active[k], active[i])
125
+ pairwise[pair] = pairwise.get(pair, 0) + 1
126
+
127
+ candidate_slices = sorted(
128
+ [sl for sl in timeline if sl.concurrency >= 2],
129
+ key=lambda s: (s.load, s.concurrency, -s.minute),
130
+ reverse=True,
131
+ )
132
+ top_spikes: List[CollisionSpike] = []
133
+ used_minutes: List[int] = []
134
+ for sl in candidate_slices:
135
+ if any(min((sl.minute - u) % 1440, (u - sl.minute) % 1440) < 5 for u in used_minutes):
136
+ continue
137
+ used_minutes.append(sl.minute)
138
+ dom_res = "none"
139
+ if sl.resource_loads:
140
+ dom_res = max(sl.resource_loads.items(), key=lambda kv: kv[1])[0]
141
+ top_spikes.append(
142
+ CollisionSpike(
143
+ minute=sl.minute,
144
+ time_label=format_hhmm(sl.minute),
145
+ concurrency=sl.concurrency,
146
+ total_load=round(sl.load, 2),
147
+ dominant_resource=dom_res,
148
+ job_names=[job_map[jid].name for jid in sl.active_job_ids],
149
+ )
150
+ )
151
+ if len(top_spikes) >= 8:
152
+ break
153
+
154
+ return SimulationResult(
155
+ timeline=timeline,
156
+ peak_concurrency=peak_concurrency,
157
+ peak_load=round(peak_load, 2),
158
+ peak_minute=peak_minute,
159
+ mean_load=round(mean_load, 2),
160
+ p95_load=round(p95_load, 2),
161
+ l2_energy=round(l2_energy, 2),
162
+ crest_factor=round(crest_factor, 2),
163
+ collision_minutes_count=collision_minutes_count,
164
+ harmonic_buckets=harmonic_buckets,
165
+ top_spikes=top_spikes,
166
+ pairwise_overlaps=pairwise,
167
+ )